Compare commits
7
Commits
79c9e34a5d
...
devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b3ec81b1b | ||
|
|
364b515e3c | ||
|
|
4daf4e960d | ||
|
|
7cc2a1c63e | ||
|
|
c69f5f9166 | ||
|
|
c399a6aed3 | ||
|
|
c0ad1c1553 |
+9
-5
@@ -1,4 +1,8 @@
|
|||||||
# Python
|
# --- Rust ---------------------------------------------------------------
|
||||||
|
# Cargo build output (debug/release builds, incremental caches)
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# --- Python -------------------------------------------------------------
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
@@ -11,15 +15,16 @@ __pycache__/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
# Environment and secrets
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Logs (loguru file sink)
|
# Logs (loguru file sink)
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
# PyInstaller build artifacts (dist/ is committed — see DESIGN_DOCUMENT §13)
|
# PyInstaller build artifacts (dist/ is committed — see DESIGN_DOCUMENT §13)
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# --- Common -------------------------------------------------------------
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
|
||||||
# Curator runtime metadata (user/machine-specific, regenerated)
|
# Curator runtime metadata (user/machine-specific, regenerated)
|
||||||
.Curator.!gtag
|
.Curator.!gtag
|
||||||
*.!gtag
|
*.!gtag
|
||||||
@@ -35,4 +40,3 @@ AGENTS.md
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
DESIGN_DOCUMENT.md
|
DESIGN_DOCUMENT.md
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
+472
-48
@@ -19,7 +19,435 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
- Always update this file before bumping the version in `pyproject.toml`
|
- Always update this file before bumping the version in `pyproject.toml`
|
||||||
- Document changes as they are made, not all at once at release time
|
- Document changes as they are made, not all at once at release time
|
||||||
|
|
||||||
## Unreleased
|
## 1.14.0 — 2026-10-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The codebase is mypy-clean** and plain `uv run mypy` works (`[tool.mypy]`
|
||||||
|
in `pyproject.toml` lists the targets). Qt enums are now fully qualified
|
||||||
|
(`QDialog.DialogCode.Accepted`, `QMessageBox.StandardButton.Yes`,
|
||||||
|
`Qt.GlobalColor.red`, …) instead of PySide6's short aliases, and the few
|
||||||
|
loosely typed spots (tag schema, transforms, `on_files_changed`, dialog cell
|
||||||
|
widgets) are annotated. No behaviour change.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `src/core/media_utils.py` — Tagger-era leftovers (`load_icon` returned a
|
||||||
|
Tkinter image, `add_video_resolution_tag` logged via `print()`); nothing in
|
||||||
|
the app called either.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- Removed `pillow` — `media_utils` was its only user.
|
||||||
|
|
||||||
|
## 1.13.0 — 2026-10-12
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **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
|
### Fixed
|
||||||
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the
|
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the
|
||||||
@@ -27,60 +455,56 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
"Filmy"), so a pool of only mixed/series/homevideo libraries had nowhere to put
|
"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**),
|
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
|
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.
|
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
|
offers to delete **empty** pool folders that are no longer a library, and lists
|
||||||
non-empty ones (never auto-deleted). `FileManager.orphan_pool_folders` /
|
non-empty ones (never auto-deleted). `FileManager.orphan_pool_folders` /
|
||||||
`remove_empty_pool_folder`.
|
`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
|
### Dependencies
|
||||||
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at
|
- **Poetry and pyenv are replaced by uv.** `pyproject.toml` was recreated with
|
||||||
import (new column in the movie import dialog) and editable in *Upravit film*.
|
`uv init` / `uv add` (no `[tool.poetry]`, no `[build-system]`), `poetry.lock`
|
||||||
Kept only as a pointer next to the TMDb id; nothing is fetched from ČSFD.
|
is replaced by `uv.lock`, and `.venv` is built on a uv-managed Python 3.14.
|
||||||
- **Series import into `mixed` libraries.** `series_libraries` (import target +
|
Commands are now `uv sync` / `uv add` / `uv run` (`DESIGN_DOCUMENT_PYTHON.md`
|
||||||
`load_series` scan) now covers kind `series` **and** `mixed`, so episodes can be
|
§11). Dependency lower bounds moved up to the versions uv resolved; Poetry's
|
||||||
imported into a mixed library and show up in the series overview/editor.
|
upper bounds (`<7.0.0`, …) are gone.
|
||||||
|
- 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).
|
||||||
|
- **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
|
### Changed
|
||||||
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**.
|
- **Codebase cleaned up for ruff 0.17's default rules** (`uv run ruff check`
|
||||||
(Genre tags come from the NFO; manual tag editing isn't needed in the Jellyfin
|
passes). Mostly mechanical: PEP 604/585 annotations (`X | None`, `list[...]`
|
||||||
flow.)
|
instead of `Optional`/`List`), sorted imports, stale `# noqa` removed, unused
|
||||||
- **Episode filenames now keep the original name:** `SxxEyy - {original}.ext`
|
unpacked variables prefixed with `_`, silent `try/except/pass` blocks
|
||||||
(was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The
|
rewritten as `contextlib.suppress`, dialog class-level tables in
|
||||||
`SxxEyy` code is stripped from the original before re-applying, so repeated
|
`src/ui/qt_app.py` annotated `ClassVar`. `scripts/tipy_dne.py` is now
|
||||||
renames don't double it (`series.episode_body`); `episode_filename` /
|
executable (it has a shebang).
|
||||||
`is_canonical_episode` and the series import/rename/edit paths follow.
|
- **Three blanket `except Exception` handlers are narrowed** — anything outside
|
||||||
|
the listed types now propagates instead of being swallowed:
|
||||||
### Added
|
`config.load_global_config` (`OSError`/`ValueError`/`TypeError`),
|
||||||
- **Library organizer** (*Pool → "Knihovny…"*). One dialog manages the pool's
|
the ffprobe resolution lookup in `media_utils`
|
||||||
top-level libraries as ``{name, kind}`` — kinds **movie / series / mixed /
|
(`OSError`/`SubprocessError`/`ValueError`), and the window-geometry restore
|
||||||
homevideo** — stored in a unified ``libraries`` setting in the pool index
|
in `qt_app` (`ValueError`/`AttributeError`).
|
||||||
(`FileManager.libraries` / `set_libraries`; the legacy `movie_libraries` /
|
- User-facing "missing packages" hints (ČSFD) now say `uv sync` instead of
|
||||||
`copyasis_folders` are now derived from it and synthesised for old pools).
|
`poetry install`.
|
||||||
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.
|
|
||||||
|
|
||||||
## 1.12.0 — 2026-08-31
|
## 1.12.0 — 2026-08-31
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,9 @@
|
|||||||
# Imports
|
# Imports
|
||||||
from src.ui.qt_app import run
|
import contextlib
|
||||||
|
|
||||||
from src.core.file_manager import FileManager
|
from src.core.file_manager import FileManager
|
||||||
from src.core.tag_manager import TagManager
|
from src.core.tag_manager import TagManager
|
||||||
|
from src.ui.qt_app import run
|
||||||
|
|
||||||
|
|
||||||
def _use_system_certificates() -> None:
|
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
|
Windows trust store but not in certifi's bundle. Best-effort; ignored if the
|
||||||
optional `truststore` package is unavailable.
|
optional `truststore` package is unavailable.
|
||||||
"""
|
"""
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
import truststore
|
import truststore
|
||||||
|
|
||||||
truststore.inject_into_ssl()
|
truststore.inject_into_ssl()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class State:
|
class State:
|
||||||
@@ -32,4 +33,3 @@ def main() -> None:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
+4
-11
@@ -19,27 +19,20 @@ pyz = PYZ(a.pure)
|
|||||||
exe = EXE(
|
exe = EXE(
|
||||||
pyz,
|
pyz,
|
||||||
a.scripts,
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
[],
|
[],
|
||||||
exclude_binaries=True,
|
|
||||||
name='Curator',
|
name='Curator',
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
console=False,
|
console=False,
|
||||||
onefile=True,
|
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
argv_emulation=False,
|
argv_emulation=False,
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
)
|
)
|
||||||
coll = COLLECT(
|
|
||||||
exe,
|
|
||||||
a.binaries,
|
|
||||||
a.datas,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
name='Curator',
|
|
||||||
)
|
|
||||||
|
|||||||
+18
-15
@@ -1,6 +1,6 @@
|
|||||||
# Python Development Guidelines
|
# Python Development Guidelines
|
||||||
|
|
||||||
**Document Version:** v11
|
**Document Version:** v12
|
||||||
|
|
||||||
> **Note on Versioning:**
|
> **Note on Versioning:**
|
||||||
> - This document version is independent — reused across projects
|
> - 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:
|
Run before every commit:
|
||||||
```bash
|
```bash
|
||||||
poetry run ruff check
|
uv run ruff check
|
||||||
poetry run mypy
|
uv run mypy
|
||||||
poetry run pytest
|
uv run pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Poetry
|
## 11. uv
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry install # Install all dependencies
|
uv sync # Install all dependencies
|
||||||
poetry add <pkg> # Add runtime dependency
|
uv add <pkg> # Add runtime dependency
|
||||||
poetry add --group dev <pkg> # Add dev dependency
|
uv add --dev <pkg> # Add dev dependency
|
||||||
poetry remove <pkg> # Remove dependency
|
uv remove <pkg> # Remove dependency
|
||||||
poetry run <cmd> # Run command in virtualenv
|
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.
|
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
|
├── src/ # All application modules
|
||||||
├── tests/ # Tests
|
├── tests/ # Tests
|
||||||
├── docs/ # Detailed documentation
|
├── docs/ # Detailed documentation
|
||||||
├── .venv/ # Virtual environment (managed by Poetry)
|
├── .venv/ # Virtual environment (managed by uv)
|
||||||
└── pyproject.toml # Project config and dependencies
|
└── 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:
|
Build commands:
|
||||||
```bash
|
```bash
|
||||||
poetry run python prebuild.py
|
uv run python prebuild.py
|
||||||
poetry run pyinstaller ToolName.spec
|
uv run pyinstaller ToolName.spec
|
||||||
```
|
```
|
||||||
|
|
||||||
> This section applies only to projects that produce standalone executables. Skip for libraries or web services.
|
> This section applies only to projects that produce standalone executables. Skip for libraries or web services.
|
||||||
|
|||||||
+180
-27
@@ -66,6 +66,16 @@ movie table, and one-click Filmotéka generation.
|
|||||||
- **Metadata storage:** one **unified metadata file** for the whole pool (a
|
- **Metadata storage:** one **unified metadata file** for the whole pool (a
|
||||||
central index), not per-file sidecars. Justified because Curator owns the pool
|
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.
|
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
|
- **Config split by scope:** the global config (`.Curator.!gtag`) holds **app
|
||||||
data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir`
|
data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir`
|
||||||
pointers (which must stay global: the index is found *through* them). Everything
|
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`
|
`Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda`
|
||||||
(homevideo). Kids are **not** a separate folder — see decisions.
|
(homevideo). Kids are **not** a separate folder — see decisions.
|
||||||
- **Naming convention:**
|
- **Naming convention:**
|
||||||
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form.
|
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form. A 3D
|
||||||
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {filename}.ext`
|
movie carries an extra `.3D.<FORMAT>` tag before the extension (Jellyfin's
|
||||||
(season 0 → `Specials`); the episode keeps its original filename after the
|
own convention; `FORMAT` ∈ `HSBS`/`FSBS`/`HTAB`/`FTAB`/`MVC`), set via the
|
||||||
`SxxEyy` code. Episode numbers are recognised from the filename; editable in
|
edit dialog and preserved across every future rename like the tmdbid is.
|
||||||
the series GUI.
|
- **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
|
- **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by
|
||||||
`tmdbid` (yearless title search picks the wrong film, hence the earlier
|
`tmdbid` (yearless title search picks the wrong film, hence the earlier
|
||||||
"weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when
|
"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)
|
- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to)
|
||||||
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
|
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
|
||||||
Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is
|
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/
|
kept **only** as a title source (not for tags/matching — it exposes no
|
||||||
matching — it exposes no TMDb/IMDb id, verified).
|
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**
|
- **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,
|
- **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser,
|
||||||
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
|
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;
|
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
|
||||||
key + URL in `.env`. NFO on disk stays authoritative.
|
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)
|
### 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];
|
verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done];
|
||||||
(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing
|
(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing
|
||||||
pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs +
|
pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs +
|
||||||
library organizer (unified `libraries` model with kinds) [done].
|
library organizer (unified `libraries` model with kinds) [done]; (9) mixed-library
|
||||||
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs);
|
scanning + generation (movies + series in one folder) [done].
|
||||||
**(9) mixed-library scanning + generation** (movies + series in one folder).
|
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs).
|
||||||
|
|
||||||
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
|
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
|
||||||
[tmdbid].ext`); per-movie folders are a later refinement.
|
[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).
|
skip the TMDb requirement; `movie` keeps requiring a year (as today).
|
||||||
- `homevideo` library scanning: load its files as items (flat), no TMDb naming,
|
- `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.
|
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.
|
- Tests: import with no year/id lands correctly; homevideo library loads.
|
||||||
|
|
||||||
### Phase 9 — mixed-library scanning + generation
|
### Phase 9 — mixed-library scanning + generation
|
||||||
|
|
||||||
- Scan a `mixed` library by structure: a top-level **single video file = movie**;
|
- Scanning **[done]**: a `mixed` library's top-level files load as movies
|
||||||
a subfolder containing `Season NN/` (or `SxxEyy` files) = **series**. Reuse
|
(`load_pool_movies`), its subfolders as series (`load_series` /
|
||||||
`series.scan_series` for the series part and the flat movie scan for the rest.
|
`series_libraries` = kind `series` **or** `mixed`, reusing `scan_series`) —
|
||||||
- `load_pool_movies` / `load_series` become **kind-aware** (iterate `libraries`,
|
*except* a canonical-shaped subfolder with no `Season NN`/`Specials` inside
|
||||||
dispatch by kind); today they read `movie_libraries` / `copyasis_folders`.
|
it, which is a movie's version folder instead and loads as a movie too (see
|
||||||
- miniDLNA generation + NFO refresh must cover mixed libraries too.
|
"Multiple movie versions" above). Both are kind-aware over the unified
|
||||||
- The library filter / table should surface mixed libraries once loadable.
|
`libraries` list. The library filter / table already surfaces mixed
|
||||||
- Tests: a mixed folder with both a movie and a series loads/generates correctly.
|
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
|
### Later / optional
|
||||||
|
|
||||||
- **ČSFD CZ-title fetch (deferred).** Live test showed TMDb cs-CZ titles are good
|
- **ČSFD → TMDb title bridge (done).** A direct TMDb search on a Czech title was
|
||||||
when matched by tmdbid, so ČSFD title-fetch was **not** rebuilt (would need the
|
matching the wrong film too often, so a lean `csfd.py` was revived (Anubis PoW
|
||||||
Anubis PoW solver removed in phase 4). If TMDb gets a title wrong, edit the
|
solver, film-page + search parsing, `original_title` extraction — the entry in
|
||||||
filename in the GUI. The ČSFD **link** is already stored per movie
|
`.film-names` whose flag matches the country of origin). `resolve_via_csfd`
|
||||||
(`File.csfd_url`, set at import / in the edit dialog) as a pointer for later —
|
drives it from the import dialog and from migration. **Not** a return of ČSFD
|
||||||
revisit fetching only if TMDb proves insufficient in practice.
|
tags — genres still come from TMDb via Jellyfin/NFO. Open follow-up: batch
|
||||||
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat files.
|
"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.
|
- Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO.
|
||||||
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
|
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
|
||||||
path-aware `library_of` (currently single-segment).
|
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
|
- Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie
|
||||||
libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD
|
libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD
|
||||||
removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs
|
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/
|
- Library organizer (`libraries` unified model with kinds movie/series/mixed/
|
||||||
homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived
|
homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived
|
||||||
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
|
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
|
||||||
|
|||||||
@@ -1,60 +1,88 @@
|
|||||||
# Curator
|
# Curator
|
||||||
|
|
||||||
Curator is a desktop **movie-library manager (Filmotéka)**. You import video
|
Curator is a PySide6 desktop app for managing a **Jellyfin-first movie and series
|
||||||
files into a managed **pool**, tag them, and Curator generates a browsable
|
library**. You import video files into a managed **pool**; Curator identifies them
|
||||||
**Filmotéka** — a directory tree of hardlinks into the pool — without ever
|
through **TMDb**, names and lays them out the way Jellyfin expects, and can
|
||||||
duplicating the actual video data on disk.
|
generate a genre-split **miniDLNA / Filmotéka** tree of hardlinks for dumb DLNA
|
||||||
|
players — without ever duplicating the video data on disk.
|
||||||
|
|
||||||
Curator is a fork of the former *Tagger* project; the tagging, filtering and
|
Metadata (posters, plots, `.nfo` files) is Jellyfin's job; Curator owns the files,
|
||||||
hardlink-tree machinery is inherited and extended into a full library workflow.
|
their names and the library structure.
|
||||||
|
|
||||||
## Concepts
|
## Concepts
|
||||||
|
|
||||||
- **Pool** — the managed repository of files and the **single source of truth**.
|
- **Pool** — the managed folder and the **single source of truth**. Curator
|
||||||
Curator owns it: it imports and removes files itself, so nothing moves behind
|
imports, renames and removes files itself, so nothing moves behind its back.
|
||||||
its back. The pool has two top-level folders:
|
Each top-level folder is a **library** with a kind:
|
||||||
- `Filmy` — movies, organized by tags into the generated tree.
|
|
||||||
- `Seriály` — series (and any other **copy-as-is** folder), mirrored verbatim.
|
|
||||||
- **Filmotéka (output)** — a generated tree of **hardlinks** into the pool. It is
|
|
||||||
fully disposable: delete it and nothing is lost, because it is regenerated from
|
|
||||||
the pool on demand.
|
|
||||||
- **Pool index** — all pool metadata lives in a single `.Curator.!index` JSON
|
|
||||||
file at the pool root (title, ČSFD link, tags, date per file).
|
|
||||||
|
|
||||||
## Workflow
|
| Kind | Holds | Layout |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `movie` | movies | `Title (rok) [tmdbid].ext`, or a folder once a film has several versions |
|
||||||
|
| `series` | series | `Series (rok) [tmdbid]/Season NN/SxxEyy - Series.ext` |
|
||||||
|
| `mixed` | movies and series together | as above, side by side |
|
||||||
|
| `homevideo` | files without a TMDb id | mirrored as-is |
|
||||||
|
|
||||||
1. Configure the **pool** and the **Filmotéka output** folder.
|
- **Pool index** — Curator's own metadata (TMDb id, ČSFD link, versions, tags,
|
||||||
2. **Import a movie**: pick a video, enter its **Title** and a **ČSFD link**. The
|
library settings) lives in one `.Curator.!index` JSON file at the pool root.
|
||||||
file is copied (non-destructively) into `pool/Filmy` as `Title.ext` and
|
- **miniDLNA output** — a generated tree of **hardlinks** into the pool, built
|
||||||
recorded in the index. If a ČSFD link is given, Curator fetches the movie from
|
from the genres/years/ratings in Jellyfin's NFO files. It is disposable: delete
|
||||||
[ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země původu** tags
|
it and regenerate it at any time.
|
||||||
automatically (use "Načíst tagy z ČSFD" to (re)fetch later).
|
|
||||||
3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI.
|
## Features
|
||||||
4. **Generate the Filmotéka**: movies become a `Category/Tag/film` hardlink tree
|
|
||||||
(from the `Rok` / `Žánr` / `Hodnocení` categories); copy-as-is folders such as
|
- Import movies and episodes with a mandatory **TMDb id**; Czech titles come from
|
||||||
`Seriály` are mirrored 1:1 as hardlinks.
|
TMDb, with **ČSFD** as a fallback and as a bridge for finding the right TMDb id.
|
||||||
|
- **Multiple versions** of one film (2D/3D, 4K, director's cut) and **multi-part**
|
||||||
|
films and episodes, named so Jellyfin groups them.
|
||||||
|
- Subtitle sidecars follow their video through renames.
|
||||||
|
- Duplicate check, video integrity check (FFmpeg), library statistics.
|
||||||
|
- Jellyfin integration: trigger a library scan, read what Jellyfin has.
|
||||||
|
- miniDLNA tree scoped per library, with "Tipy dne" and "Nově přidané" folders.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- [uv](https://docs.astral.sh/uv/) — manages Python 3.14 and all dependencies
|
||||||
|
- `ffmpeg` / `ffprobe` on `PATH` — optional, for the integrity check
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Secrets and settings are read from a `.env` file in the project root (never
|
||||||
|
committed):
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `TMDB_API_KEY` | TMDb API key — required for identifying movies and series |
|
||||||
|
| `TMDB_LANGUAGE` | TMDb language, default `cs-CZ` |
|
||||||
|
| `JELLYFIN_URL` | Jellyfin server URL — optional, for the Jellyfin menu |
|
||||||
|
| `JELLYFIN_API_KEY` | Jellyfin API key — optional |
|
||||||
|
| `ENV_DEBUG` | `true` enables debug mode |
|
||||||
|
|
||||||
|
The pool and output folders are chosen in the app (*Pool* menu).
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry install
|
uv sync
|
||||||
poetry run python Curator.py
|
uv run python Curator.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The GUI is built with **PySide6**.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run pytest # tests
|
uv run pytest # tests
|
||||||
poetry run ruff check # lint / format
|
uv run ruff check # lint
|
||||||
poetry run mypy # type checking
|
uv run ruff format # format
|
||||||
|
uv run mypy # type checking
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Detailed documentation — architecture and a per-module reference — is in
|
||||||
|
[`docs/`](docs/README.md).
|
||||||
|
|
||||||
## Building a standalone executable
|
## Building a standalone executable
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run pyinstaller Curator.spec
|
uv run python prebuild.py
|
||||||
|
uv run pyinstaller Curator.spec --noconfirm
|
||||||
```
|
```
|
||||||
|
|
||||||
The resulting `dist/Curator` is the distribution artifact.
|
The result is a single self-contained `dist/Curator` executable.
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -0,0 +1,41 @@
|
|||||||
|
# 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/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. |
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# `src/_version.py`
|
||||||
|
|
||||||
|
Auto-generated — do not edit manually.
|
||||||
|
|
||||||
|
Source: [`src/_version.py`](../../src/_version.py)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# `src/constants.py`
|
||||||
|
|
||||||
|
Application-level constants for Curator (build/runtime metadata).
|
||||||
|
|
||||||
|
This module derives the app version and debug flag. The version is loaded from
|
||||||
|
`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for
|
||||||
|
frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`).
|
||||||
|
|
||||||
|
It is the single source of truth for app metadata used across the GUI and build
|
||||||
|
tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`).
|
||||||
|
|
||||||
|
Source: [`src/constants.py`](../../src/constants.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `get_version`
|
||||||
|
|
||||||
|
*function · line 26*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_version() -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the project version (pyproject → _version.py → unknown).
|
||||||
|
|
||||||
|
### `get_debug_mode`
|
||||||
|
|
||||||
|
*function · line 55*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_debug_mode() -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Return True when ENV_DEBUG is set to a truthy value in the environment.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# `src/core/config.py`
|
||||||
|
|
||||||
|
Configuration for Curator.
|
||||||
|
|
||||||
|
Two stores, by responsibility:
|
||||||
|
|
||||||
|
1. Global config (`.Curator.!gtag` next to Curator.py) — **application data
|
||||||
|
only**: window state, MRU folders, and the pointers to where the pool and the
|
||||||
|
Filmotéka output live (`pool_dir` / `filmoteka_dir`). Those pointers must
|
||||||
|
stay here because the pool index is found *through* them.
|
||||||
|
2. Pool index (`<pool>/.Curator.!index`, see `pool_index.py`) — everything
|
||||||
|
describing a particular library: per-movie metadata **and** library-level
|
||||||
|
settings (`tag_schema`, `copyasis_folders`). It travels with the pool.
|
||||||
|
|
||||||
|
Source: [`src/core/config.py`](../../src/core/config.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `load_global_config`
|
||||||
|
|
||||||
|
*function · line 68*
|
||||||
|
|
||||||
|
```python
|
||||||
|
load_global_config() -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Load global application config
|
||||||
|
|
||||||
|
### `save_global_config`
|
||||||
|
|
||||||
|
*function · line 84*
|
||||||
|
|
||||||
|
```python
|
||||||
|
save_global_config(cfg: dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
Save global application config
|
||||||
|
|
||||||
|
### `load_config`
|
||||||
|
|
||||||
|
*function · line 94*
|
||||||
|
|
||||||
|
```python
|
||||||
|
load_config()
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy function - returns global config
|
||||||
|
|
||||||
|
### `save_config`
|
||||||
|
|
||||||
|
*function · line 99*
|
||||||
|
|
||||||
|
```python
|
||||||
|
save_config(cfg: dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy function - saves global config
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# `src/core/csfd.py`
|
||||||
|
|
||||||
|
ČSFD.cz scraper — a *title bridge* to TMDb.
|
||||||
|
|
||||||
|
Curator identifies movies by **TMDb id**. A direct TMDb search on a Czech
|
||||||
|
distribution title is unreliable (remakes, localized names, "weird" hits).
|
||||||
|
ČSFD, on the other hand, indexes Czech release names well **and** lists a
|
||||||
|
film's *original* title. So the bridge is::
|
||||||
|
|
||||||
|
filename → ČSFD search → ČSFD film page → original title + year
|
||||||
|
→ TMDb search → tmdbid
|
||||||
|
|
||||||
|
This module only scrapes ČSFD; the TMDb half lives in
|
||||||
|
`src.core.file_manager` (`FileManager.resolve_via_csfd`). Nothing
|
||||||
|
here feeds tags/genres — those still come from TMDb via Jellyfin/NFO.
|
||||||
|
|
||||||
|
ČSFD serves an **Anubis** proof-of-work wall in front of every page: a plain
|
||||||
|
request gets a 200 with a JS challenge instead of the movie. We detect it,
|
||||||
|
solve the SHA-256 PoW the way the bundled worker JS does, and replay the
|
||||||
|
request through a shared `requests.Session` (so a batch pays the PoW
|
||||||
|
cost only once).
|
||||||
|
|
||||||
|
Source: [`src/core/csfd.py`](../../src/core/csfd.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `CsfdError`
|
||||||
|
|
||||||
|
*class · line 62*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CsfdError(RuntimeError):
|
||||||
|
```
|
||||||
|
|
||||||
|
A ČSFD lookup could not be completed (deps missing, network, anti-bot).
|
||||||
|
|
||||||
|
### `CsfdFilm`
|
||||||
|
|
||||||
|
*class · line 67*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CsfdFilm:
|
||||||
|
```
|
||||||
|
|
||||||
|
The slice of a ČSFD film page Curator needs to reach a TMDb id.
|
||||||
|
|
||||||
|
#### `CsfdFilm.tmdb_query`
|
||||||
|
|
||||||
|
*property · line 77*
|
||||||
|
|
||||||
|
```python
|
||||||
|
tmdb_query -> tuple[str, Optional[str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(title, year)` best suited for a TMDb search — original title first.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `dependencies_available`
|
||||||
|
|
||||||
|
*function · line 82*
|
||||||
|
|
||||||
|
```python
|
||||||
|
dependencies_available() -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when `requests` + `beautifulsoup4` are importable.
|
||||||
|
|
||||||
|
### `_check_dependencies`
|
||||||
|
|
||||||
|
*function · line 87*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_check_dependencies() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Raise `CsfdError` when the optional `requests` / `beautifulsoup4` packages are missing (they are needed only for ČSFD scraping).
|
||||||
|
|
||||||
|
### `_extract_csfd_id`
|
||||||
|
|
||||||
|
*function · line 95*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_extract_csfd_id(url: str) -> Optional[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pull the numeric film id out of a ČSFD URL, or `None`.
|
||||||
|
|
||||||
|
### `_extract_json_blob`
|
||||||
|
|
||||||
|
*function · line 105*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_extract_json_blob(html: str, element_id: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the parsed JSON from an Anubis `<script id=...>` blob, or None.
|
||||||
|
|
||||||
|
### `_solve_anubis_pow`
|
||||||
|
|
||||||
|
*function · line 120*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Brute-force the Anubis proof-of-work.
|
||||||
|
|
||||||
|
Mirrors the bundled `sha256-purejs` worker: find the smallest `nonce`
|
||||||
|
such that `sha256(random_data + str(nonce))` has `difficulty` leading
|
||||||
|
zero nibbles. Returns `(hash_hex, nonce, elapsed_ms)`.
|
||||||
|
|
||||||
|
### `_solve_anubis_challenge`
|
||||||
|
|
||||||
|
*function · line 143*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_solve_anubis_challenge(session, html: str, url: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Solve the Anubis challenge in `html` and return the real page response.
|
||||||
|
|
||||||
|
### `_get_page`
|
||||||
|
|
||||||
|
*function · line 180*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_get_page(session, url: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
GET `url` through `session`, transparently clearing an Anubis wall.
|
||||||
|
|
||||||
|
### `_parse_json_ld`
|
||||||
|
|
||||||
|
*function · line 193*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_json_ld(soup) -> tuple[str, Optional[str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(name, year)` from the film page's JSON-LD (name is the Czech title).
|
||||||
|
|
||||||
|
### `_parse_origin`
|
||||||
|
|
||||||
|
*function · line 215*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_origin(soup) -> tuple[Optional[str], Optional[str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(origin_country, year)` from the `.origin` line.
|
||||||
|
|
||||||
|
ČSFD separates values with inline bullet `<span>` elements (no commas),
|
||||||
|
e.g. `USA • 1999 • 136 min`. For a co-production only the first country
|
||||||
|
is returned — enough to match it against a name's flag.
|
||||||
|
|
||||||
|
### `_parse_original_title`
|
||||||
|
|
||||||
|
*function · line 245*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_original_title(soup, origin_country: Optional[str]) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The film's original title from `.film-names` — or `None` to use the CZ title.
|
||||||
|
|
||||||
|
Each `<li>` carries a country/language flag (`img.flag` `title`) and a
|
||||||
|
title. The original title is the entry whose flag matches the **country of
|
||||||
|
origin** (USA → "The Shawshank Redemption"). ČSFD does **not** repeat the
|
||||||
|
Czech title here when it equals the `<h1>`, so a Czech film has only
|
||||||
|
foreign/festival alt-titles in this list (`[angličtina] Cosy Dens`,
|
||||||
|
`[Slovinsko] Samotarji`) — picking one of those would be wrong, so we
|
||||||
|
return `None` and let the caller fall back to the Czech `<h1>` title.
|
||||||
|
|
||||||
|
### `fetch_film`
|
||||||
|
|
||||||
|
*function · line 276*
|
||||||
|
|
||||||
|
```python
|
||||||
|
fetch_film(url: str, session=None) -> CsfdFilm
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetch a ČSFD film page → `CsfdFilm` (Czech + original title, year).
|
||||||
|
|
||||||
|
Pass a shared `requests.Session` to reuse the Anubis auth cookie across a
|
||||||
|
batch. Raises `CsfdError` on a network / anti-bot / parse failure.
|
||||||
|
|
||||||
|
### `_films_section`
|
||||||
|
|
||||||
|
*function · line 311*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_films_section(soup)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<section>` holding the *Filmy* results, or the whole document.
|
||||||
|
|
||||||
|
ČSFD's search page groups hits into `Filmy` / `Seriály` / `Tvůrci` /
|
||||||
|
`Uživatelé` sections; `a.film-title-name` matches in all of them, so
|
||||||
|
without scoping a bare-title search happily returns a series episode.
|
||||||
|
|
||||||
|
### `_is_film_href`
|
||||||
|
|
||||||
|
*function · line 325*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_is_film_href(href: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True for a film URL `/film/<id>-slug/` — a series episode has two segments
|
||||||
|
(`/film/<series>/<episode>/`) and is rejected.
|
||||||
|
|
||||||
|
### `search_films`
|
||||||
|
|
||||||
|
*function · line 332*
|
||||||
|
|
||||||
|
```python
|
||||||
|
search_films(query: str, limit: int=5, session=None) -> list[CsfdFilm]
|
||||||
|
```
|
||||||
|
|
||||||
|
Search ČSFD for `query`; film hits only (no series/episodes), best first.
|
||||||
|
|
||||||
|
### `find_csfd_url`
|
||||||
|
|
||||||
|
*function · line 365*
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_csfd_url(query: str, session=None) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the first ČSFD film URL matching `query`, or `None`.
|
||||||
|
|
||||||
|
ČSFD's full-text search treats a trailing year as a search term (not a
|
||||||
|
filter) and can miss on `"Title 2006"` while `"Title"` hits — so a
|
||||||
|
yearful query with no result is retried without the year.
|
||||||
|
|
||||||
|
### `clean_filename_to_query`
|
||||||
|
|
||||||
|
*function · line 397*
|
||||||
|
|
||||||
|
```python
|
||||||
|
clean_filename_to_query(filename: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Turn a (possibly release-named) filename into a ČSFD search query.
|
||||||
|
|
||||||
|
Strips the path/extension, splits on common separators and keeps the words
|
||||||
|
before the first release marker (year, resolution, codec, source, …). The
|
||||||
|
detected year is appended back as a disambiguator::
|
||||||
|
|
||||||
|
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# `src/core/file.py`
|
||||||
|
|
||||||
|
The `File` class: one playable movie file as the UI sees it. A `File` is a *view* onto one media entry of a movie record in the pool index — metadata (title, tags, ČSFD link, attributes, date) lives on the shared record, so every version of a film reads the same values. See `docs/architecture.md`.
|
||||||
|
|
||||||
|
Source: [`src/core/file.py`](../../src/core/file.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `File`
|
||||||
|
|
||||||
|
*class · line 5*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class File:
|
||||||
|
```
|
||||||
|
|
||||||
|
One movie file in the pool, as the UI sees it. Holds the path, the list of tags and the metadata accessors; metadata itself is stored on the movie record in the pool index and the `File` reads it through it.
|
||||||
|
|
||||||
|
#### `File.__init__`
|
||||||
|
|
||||||
|
*method · line 6*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__(file_path: Path, tagmanager=None, index=None) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a file object for `file_path`. Metadata is loaded from the pool index when `index` is given; without an index the object is in-memory only.
|
||||||
|
|
||||||
|
#### `File._identity_from_path`
|
||||||
|
|
||||||
|
*method · line 41*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_identity_from_path() -> tuple[str, str, str | None, int | None, str]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(key, title, year, tmdb_id, label)` implied by this file's location.
|
||||||
|
|
||||||
|
A file inside its own `Title (rok) [tmdbid]` folder is one version of
|
||||||
|
that movie — identity comes from the **folder**, and the rest of its own
|
||||||
|
stem is the version label. A flat file carries both in its own name.
|
||||||
|
|
||||||
|
#### `File.get_metadata`
|
||||||
|
|
||||||
|
*method · line 67*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_metadata() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Load this file's metadata from the movie record that owns it.
|
||||||
|
|
||||||
|
Since the pool index became identity-keyed (see `src.core.movie`),
|
||||||
|
a File is a *view* on one media entry of a movie record — every version
|
||||||
|
of the same film therefore reads the same title/tags/ČSFD/… A file the
|
||||||
|
index doesn't know yet joins the movie of the same identity when there
|
||||||
|
is one (that's how a newly added version inherits the shared metadata
|
||||||
|
instead of blanking it), otherwise it starts a new record.
|
||||||
|
|
||||||
|
#### `File._init_new_metadata`
|
||||||
|
|
||||||
|
*method · line 95*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_init_new_metadata() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset all metadata fields to their defaults for a file the index doesn't know yet.
|
||||||
|
|
||||||
|
#### `File._build_record`
|
||||||
|
|
||||||
|
*method · line 107*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_build_record() -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Serialise this file's metadata into the dictionary stored in the pool index (tags as full paths, auto-derived tags, date, title, ČSFD link, …).
|
||||||
|
|
||||||
|
#### `File._apply_record`
|
||||||
|
|
||||||
|
*method · line 126*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_apply_record(data: dict) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Load metadata from an index record dictionary into this object's fields (the inverse of `_build_record`). Missing keys fall back to defaults.
|
||||||
|
|
||||||
|
#### `File.save_metadata`
|
||||||
|
|
||||||
|
*method · line 152*
|
||||||
|
|
||||||
|
```python
|
||||||
|
save_metadata()
|
||||||
|
```
|
||||||
|
|
||||||
|
Persist metadata into this file's movie record (no-op without index).
|
||||||
|
|
||||||
|
Writes the shared fields onto the movie record and makes sure the
|
||||||
|
record lists this file among its media. A file the index has never seen
|
||||||
|
starts a new record under its own identity key.
|
||||||
|
|
||||||
|
#### `File._library_name`
|
||||||
|
|
||||||
|
*method · line 182*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_library_name() -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Top-level pool folder this file sits in (its library), or `""`.
|
||||||
|
|
||||||
|
#### `File.added_timestamp`
|
||||||
|
|
||||||
|
*method · line 190*
|
||||||
|
|
||||||
|
```python
|
||||||
|
added_timestamp() -> float
|
||||||
|
```
|
||||||
|
|
||||||
|
Epoch seconds for 'date added' — the stored `added` or the file mtime.
|
||||||
|
|
||||||
|
Used to order the "recently added" folder (newer = larger). The fallback
|
||||||
|
is **mtime**, not ctime: creating the Filmotéka hardlinks bumps the source
|
||||||
|
inode's ctime, which would make ctime-based ordering meaningless, whereas
|
||||||
|
mtime (preserved from the source on import) stays stable.
|
||||||
|
|
||||||
|
#### `File.name_context`
|
||||||
|
|
||||||
|
*method · line 209*
|
||||||
|
|
||||||
|
```python
|
||||||
|
name_context() -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields for a miniDLNA filename template (see config tag schema).
|
||||||
|
|
||||||
|
Provides title / year / rating / ext / stem / filename from the movie's
|
||||||
|
metadata (year & rating come from the NFO cache, year falling back to a
|
||||||
|
`Rok` tag, then the canonical filename). Missing values render empty.
|
||||||
|
|
||||||
|
#### `File.set_attribute`
|
||||||
|
|
||||||
|
*method · line 242*
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_attribute(key: str, value: str | None) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it.
|
||||||
|
|
||||||
|
#### `File.delete_metadata`
|
||||||
|
|
||||||
|
*method · line 253*
|
||||||
|
|
||||||
|
```python
|
||||||
|
delete_metadata() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Drop this file from its movie record; the last one takes the record.
|
||||||
|
|
||||||
|
Removing one version of a multi-version movie leaves the movie (and its
|
||||||
|
metadata) in place — only when its last media entry goes does the whole
|
||||||
|
record go with it.
|
||||||
|
|
||||||
|
#### `File.relocate`
|
||||||
|
|
||||||
|
*method · line 273*
|
||||||
|
|
||||||
|
```python
|
||||||
|
relocate(new_path: Path) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Point this File at a new path, moving its media entry along.
|
||||||
|
|
||||||
|
The physical file must already have been moved/renamed by the caller;
|
||||||
|
this only repoints the movie record's media entry (and the File), so a
|
||||||
|
rename never detaches a file from its movie.
|
||||||
|
|
||||||
|
#### `File.set_date`
|
||||||
|
|
||||||
|
*method · line 294*
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_date(date_str: str | None)
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the date the movie was added (`YYYY-MM-DD`) or clear it with `None` / an empty string, then save the metadata.
|
||||||
|
|
||||||
|
#### `File.apply_nfo`
|
||||||
|
|
||||||
|
*method · line 303*
|
||||||
|
|
||||||
|
```python
|
||||||
|
apply_nfo(nfo, schema: list[dict] | None=None) -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Regenerate the auto (NFO-derived) tags from the movie's NFO.
|
||||||
|
|
||||||
|
Reads genres/year/countries/rating from `nfo` (`nfo.NfoData`) per the
|
||||||
|
tag schema's source fields and (re)creates those tags; user-added tags are
|
||||||
|
kept. Also caches year/rating (for folder grouping) and fills the title
|
||||||
|
when missing. `nfo` of `None` (no NFO yet) is a no-op.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'success' and 'tags_added'.
|
||||||
|
|
||||||
|
#### `File.add_tag`
|
||||||
|
|
||||||
|
*method · line 347*
|
||||||
|
|
||||||
|
```python
|
||||||
|
add_tag(tag)
|
||||||
|
```
|
||||||
|
|
||||||
|
Attach a tag to this file. Accepts a `Tag` or a `category/name` string; a string without a category goes to the `default` category. Saves on change.
|
||||||
|
|
||||||
|
#### `File.remove_tag`
|
||||||
|
|
||||||
|
*method · line 364*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_tag(tag)
|
||||||
|
```
|
||||||
|
|
||||||
|
Detach a tag, given as a `Tag` or a `category/name` string. Saves on change.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
|||||||
|
# `src/core/hardlink_manager.py`
|
||||||
|
|
||||||
|
Hardlink Manager for Curator
|
||||||
|
|
||||||
|
Creates directory structure based on file tags and creates hardlinks
|
||||||
|
to organize files without duplicating them on disk.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:
|
||||||
|
|
||||||
|
output/
|
||||||
|
├── žánr/
|
||||||
|
│ ├── Komedie/
|
||||||
|
│ │ └── film.mkv (hardlink)
|
||||||
|
│ └── Akční/
|
||||||
|
│ └── film.mkv (hardlink)
|
||||||
|
└── rok/
|
||||||
|
└── 1988/
|
||||||
|
└── film.mkv (hardlink)
|
||||||
|
|
||||||
|
Source: [`src/core/hardlink_manager.py`](../../src/core/hardlink_manager.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `_SafeDict`
|
||||||
|
|
||||||
|
*class · line 30*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class _SafeDict(dict):
|
||||||
|
```
|
||||||
|
|
||||||
|
dict for str.format_map that leaves unknown fields as an empty string.
|
||||||
|
|
||||||
|
### `HardlinkManager`
|
||||||
|
|
||||||
|
*class · line 37*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class HardlinkManager:
|
||||||
|
```
|
||||||
|
|
||||||
|
Manager for creating hardlink-based directory structures from tagged files.
|
||||||
|
|
||||||
|
The output layout is driven by a *category → root folder* mapping
|
||||||
|
(`category_roots`). Each tag is placed at
|
||||||
|
`output/<root>/<tag_name>/<file>`; an empty root means the tag's own
|
||||||
|
folders sit directly at the output root (e.g. genre folders next to the
|
||||||
|
"Dle roku" / "Dle země původu" folders). The legacy `categories` list
|
||||||
|
(folder == category name) is still accepted and treated as the identity
|
||||||
|
mapping `{cat: cat}`.
|
||||||
|
|
||||||
|
#### `HardlinkManager.__init__`
|
||||||
|
|
||||||
|
*method · line 49*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__(output_dir: Path)
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialize HardlinkManager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Base directory where the tag-based structure will be created
|
||||||
|
|
||||||
|
#### `HardlinkManager._resolve_roots`
|
||||||
|
|
||||||
|
*method · line 60*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_resolve_roots(categories: Optional[List[str]], category_roots: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Normalize the two filter styles into a category → root-folder map.
|
||||||
|
|
||||||
|
`None` means "all categories", folder == category name.
|
||||||
|
|
||||||
|
#### `HardlinkManager._clean_title`
|
||||||
|
|
||||||
|
*method · line 75*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_clean_title(file_obj: File) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
The movie's clean title (no trailing `(YYYY)`), path-safe.
|
||||||
|
|
||||||
|
#### `HardlinkManager._default_link_name`
|
||||||
|
|
||||||
|
*method · line 85*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_default_link_name(file_obj: File) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Filmotéka hardlink name: the clean title + extension (no year).
|
||||||
|
|
||||||
|
#### `HardlinkManager._year_link_name`
|
||||||
|
|
||||||
|
*method · line 89*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_year_link_name(file_obj: File) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
`Title (YYYY).ext` used to disambiguate a name collision, or None.
|
||||||
|
|
||||||
|
#### `HardlinkManager._render_template`
|
||||||
|
|
||||||
|
*method · line 96*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_render_template(file_obj: File, template: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Render a per-category filename template; fall back to the clean name.
|
||||||
|
|
||||||
|
#### `HardlinkManager._plan_links`
|
||||||
|
|
||||||
|
*method · line 105*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_plan_links(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]], templates: Optional[Dict[str, str]]) -> List[Tuple[File, Path]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve the tag tree to a collision-free `(file, link_path)` plan.
|
||||||
|
|
||||||
|
The default hardlink name is the movie's **clean title** (no year). When
|
||||||
|
two *different* movies would land on the same name in the same folder,
|
||||||
|
the colliding ones fall back to the year-qualified `Title (YYYY).ext` to
|
||||||
|
tell them apart; a residual clash (same title *and* year) gets a stable
|
||||||
|
numeric suffix so every path is unique. A per-category
|
||||||
|
`filename_template` overrides the default inside that category's folders.
|
||||||
|
|
||||||
|
Both link creation and obsolete detection consume this one plan, so they
|
||||||
|
can never disagree on a name (which is what caused hardlink churn before).
|
||||||
|
|
||||||
|
#### `HardlinkManager._folder_value`
|
||||||
|
|
||||||
|
*method · line 172*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_folder_value(tag, transforms: Optional[Dict[str, str]]) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Folder name for a tag — its value run through the category transform.
|
||||||
|
|
||||||
|
The tag keeps its exact value; the grouping (e.g. rating → ten-point
|
||||||
|
band) only happens here, when naming the folder.
|
||||||
|
|
||||||
|
#### `HardlinkManager._target_dir`
|
||||||
|
|
||||||
|
*method · line 183*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_target_dir(tag, roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None) -> Optional[Path]
|
||||||
|
```
|
||||||
|
|
||||||
|
Output directory for a tag, or None if its category is excluded.
|
||||||
|
|
||||||
|
#### `HardlinkManager._managed_top_dirs`
|
||||||
|
|
||||||
|
*method · line 197*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_managed_top_dirs(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Optional[Set[str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Top-level output folders owned by the tag tree (None = all of them).
|
||||||
|
|
||||||
|
A category with a non-empty root owns that root folder. A category placed
|
||||||
|
at the output root (empty root, e.g. genres) owns its own folders at the
|
||||||
|
root — and, so a genre whose last movie dropped the tag still gets its
|
||||||
|
now-stale folder cleaned, *every* root-level folder is treated as owned
|
||||||
|
except the grouping roots and any `reserved_subfolders` (copy-as-is
|
||||||
|
mirrors, "Tipy dne", …). The obsolete check still only removes links to
|
||||||
|
current pool files, so reserved/foreign folders are doubly protected.
|
||||||
|
|
||||||
|
#### `HardlinkManager.create_structure_for_files`
|
||||||
|
|
||||||
|
*method · line 236*
|
||||||
|
|
||||||
|
```python
|
||||||
|
create_structure_for_files(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None) -> Tuple[int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Create hardlink structure for given files based on their tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects to process
|
||||||
|
categories: Optional list of categories to include (None = all)
|
||||||
|
dry_run: If True, only simulate without creating actual links
|
||||||
|
category_roots: Optional category → root-folder map (see class doc);
|
||||||
|
overrides `categories` when given.
|
||||||
|
category_transforms: Optional category → transform-name map applied to
|
||||||
|
the tag value when naming its folder (e.g. rating → decade band).
|
||||||
|
category_filename_templates: Optional category → hardlink-name template
|
||||||
|
applied only inside that category's folders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (successful_links, failed_links)
|
||||||
|
|
||||||
|
#### `HardlinkManager.mirror_as_is`
|
||||||
|
|
||||||
|
*method · line 294*
|
||||||
|
|
||||||
|
```python
|
||||||
|
mirror_as_is(source_dir: Path, subfolder: str | None=None, dry_run: bool=False) -> Tuple[int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.
|
||||||
|
|
||||||
|
Recreates the exact directory hierarchy of `source_dir` under
|
||||||
|
`output_dir/subfolder` (or directly under `output_dir` when
|
||||||
|
`subfolder` is None) and hardlinks every file. Curator metadata files
|
||||||
|
(`.!tag` / `.!ftag` / `.!gtag` / `.!index`) are skipped.
|
||||||
|
|
||||||
|
Used for Seriály: the pool structure is the source of truth and is cloned
|
||||||
|
verbatim instead of being rebuilt from tags.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (successful_links, failed_links)
|
||||||
|
|
||||||
|
#### `HardlinkManager.remove_mirror`
|
||||||
|
|
||||||
|
*method · line 345*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_mirror(name: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove `output_dir/name` entirely (file or folder tree).
|
||||||
|
|
||||||
|
Used to drop a whole mirrored library (or one entry of the shared
|
||||||
|
mixed-library-series bucket) when it falls out of the miniDLNA
|
||||||
|
generation's scope. Mirrors are pure hardlinks, so deleting the folder
|
||||||
|
loses nothing — it's fully regenerable from the pool. `name` may
|
||||||
|
contain "/" to address one entry inside a bucket folder.
|
||||||
|
|
||||||
|
Returns whether anything was actually removed.
|
||||||
|
|
||||||
|
#### `HardlinkManager._fill_special_folder`
|
||||||
|
|
||||||
|
*method · line 365*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_fill_special_folder(chosen: List[File], subfolder: str, dry_run: bool) -> Tuple[int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty `subfolder` and hardlink `chosen` files into it.
|
||||||
|
|
||||||
|
#### `HardlinkManager.generate_recently_added`
|
||||||
|
|
||||||
|
*method · line 405*
|
||||||
|
|
||||||
|
```python
|
||||||
|
generate_recently_added(files: List[File], count: int=10, subfolder: str='- Nově přidané', dry_run: bool=False) -> Tuple[int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
(Re)fill `subfolder` with the `count` most recently added movies.
|
||||||
|
|
||||||
|
Ordering is by `File.added_timestamp()` (import time, ctime fallback),
|
||||||
|
newest first. Returns (created_links, failed_links).
|
||||||
|
|
||||||
|
#### `HardlinkManager.generate_random_tips`
|
||||||
|
|
||||||
|
*method · line 420*
|
||||||
|
|
||||||
|
```python
|
||||||
|
generate_random_tips(files: List[File], count: int=10, subfolder: str='Tipy dne', dry_run: bool=False) -> Tuple[int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
(Re)fill `subfolder` with `count` random files as hardlinks.
|
||||||
|
|
||||||
|
The folder is emptied first so each run picks a fresh random selection.
|
||||||
|
`files` should be the pool movies only (copy-as-is mirrors excluded).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (created_links, failed_links)
|
||||||
|
|
||||||
|
#### `HardlinkManager._is_same_file`
|
||||||
|
|
||||||
|
*method · line 438*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_is_same_file(path1: Path, path2: Path) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Check if two paths point to the same file (same inode).
|
||||||
|
|
||||||
|
#### `HardlinkManager._get_unique_name`
|
||||||
|
|
||||||
|
*method · line 445*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_get_unique_name(path: Path) -> Path
|
||||||
|
```
|
||||||
|
|
||||||
|
Get a unique filename by adding a numeric suffix.
|
||||||
|
|
||||||
|
#### `HardlinkManager.remove_created_links`
|
||||||
|
|
||||||
|
*method · line 459*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_created_links() -> int
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove all hardlinks created by the last operation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of links removed
|
||||||
|
|
||||||
|
#### `HardlinkManager._remove_empty_parents`
|
||||||
|
|
||||||
|
*method · line 481*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_remove_empty_parents(path: Path) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove empty parent directories up to output_dir.
|
||||||
|
|
||||||
|
#### `HardlinkManager.get_preview`
|
||||||
|
|
||||||
|
*method · line 492*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_preview(files: List[File], categories: Optional[List[str]]=None, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None) -> List[Tuple[Path, Path]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Get a preview of what links would be created.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects
|
||||||
|
categories: Optional list of categories to include
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
`categories` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (source_path, target_path)
|
||||||
|
|
||||||
|
#### `HardlinkManager.find_obsolete_links`
|
||||||
|
|
||||||
|
*method · line 519*
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_obsolete_links(files: List[File], categories: Optional[List[str]]=None, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> List[Tuple[Path, Path]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Find hardlinks in the output directory that no longer match file tags.
|
||||||
|
|
||||||
|
Scans the managed parts of the output directory for hardlinks that point
|
||||||
|
to source files but whose path no longer matches the file's current tags.
|
||||||
|
Only the tag-tree's own top-level folders are scanned, so copy-as-is
|
||||||
|
mirrors (e.g. Seriály) are left untouched.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects (source files)
|
||||||
|
categories: Optional list of categories to check (None = all)
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
`categories` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (link_path, source_path) for obsolete links
|
||||||
|
|
||||||
|
#### `HardlinkManager.remove_obsolete_links`
|
||||||
|
|
||||||
|
*method · line 608*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_obsolete_links(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, List[Path]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove hardlinks that no longer match file tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects
|
||||||
|
categories: Optional list of categories to check
|
||||||
|
dry_run: If True, only return what would be removed
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
`categories` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
reserved_subfolders: Output top-level folders to never touch (mirrors,
|
||||||
|
"Tipy dne", …).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (removed_count, list_of_removed_paths)
|
||||||
|
|
||||||
|
#### `HardlinkManager.sync_structure`
|
||||||
|
|
||||||
|
*method · line 655*
|
||||||
|
|
||||||
|
```python
|
||||||
|
sync_structure(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, int, int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Synchronize hardlink structure with current file tags.
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Remove hardlinks for removed tags
|
||||||
|
2. Create new hardlinks for new tags
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects
|
||||||
|
categories: Optional list of categories to sync
|
||||||
|
dry_run: If True, only simulate
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
`categories` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
reserved_subfolders: Output top-level folders to never touch (mirrors,
|
||||||
|
"Tipy dne", …).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (created, create_failed, removed, remove_failed)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `create_hardlink_structure`
|
||||||
|
|
||||||
|
*function · line 707*
|
||||||
|
|
||||||
|
```python
|
||||||
|
create_hardlink_structure(files: List[File], output_dir: Path, categories: Optional[List[str]]=None) -> Tuple[int, int, List[Tuple[Path, str]]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Convenience function to create hardlink structure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects to process
|
||||||
|
output_dir: Base directory for output
|
||||||
|
categories: Optional list of categories to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (successful_count, failed_count, errors_list)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# `src/core/integrity.py`
|
||||||
|
|
||||||
|
Video integrity checking via FFmpeg.
|
||||||
|
|
||||||
|
Detects corrupted or unreadable video files. Two depths:
|
||||||
|
|
||||||
|
- **deep** (default): a full `ffmpeg` decode to the null muxer
|
||||||
|
(`-f null`). It actually decodes every frame, so it catches mid-file data
|
||||||
|
corruption (broken GOPs, truncated streams, bad packets). Thorough but slow —
|
||||||
|
it reads and decodes the whole file.
|
||||||
|
- **quick**: an `ffprobe` container/stream probe. Fast but shallow — it catches
|
||||||
|
an unreadable container, a truncated header or a missing video stream, but not
|
||||||
|
corruption buried inside otherwise-parseable data.
|
||||||
|
|
||||||
|
Both tools ship with FFmpeg; `ffmpeg_available` reports whether they are on the
|
||||||
|
PATH so callers can degrade gracefully.
|
||||||
|
|
||||||
|
Source: [`src/core/integrity.py`](../../src/core/integrity.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `IntegrityResult`
|
||||||
|
|
||||||
|
*class · line 30*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class IntegrityResult:
|
||||||
|
```
|
||||||
|
|
||||||
|
Outcome of checking one file.
|
||||||
|
|
||||||
|
`ok` — the file decoded/probed cleanly.
|
||||||
|
`checked` — the check actually ran; False means it couldn't (missing tool,
|
||||||
|
missing file), which is *not* the same as "corrupted".
|
||||||
|
`error` — a short reason when not `ok`.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `ffmpeg_available`
|
||||||
|
|
||||||
|
*function · line 45*
|
||||||
|
|
||||||
|
```python
|
||||||
|
ffmpeg_available(deep: bool=True) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Is the required FFmpeg tool (ffmpeg for deep, ffprobe for quick) on PATH?
|
||||||
|
|
||||||
|
### `check_video_integrity`
|
||||||
|
|
||||||
|
*function · line 50*
|
||||||
|
|
||||||
|
```python
|
||||||
|
check_video_integrity(path: Path, deep: bool=True, timeout: Optional[int]=None) -> IntegrityResult
|
||||||
|
```
|
||||||
|
|
||||||
|
Check a single video file for corruption.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: the video file to check.
|
||||||
|
deep: True → full ffmpeg decode (catches data corruption, slow);
|
||||||
|
False → ffprobe probe (catches unreadable/truncated files, fast).
|
||||||
|
timeout: per-file timeout in seconds (None = no limit).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An `IntegrityResult`. `checked=False` when the check could not
|
||||||
|
be run at all (tool or file missing) — distinct from a real failure.
|
||||||
|
|
||||||
|
### `_first_error_line`
|
||||||
|
|
||||||
|
*function · line 108*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_first_error_line(stderr: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
First non-empty stderr line, truncated — a compact reason for the report.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# `src/core/jellyfin.py`
|
||||||
|
|
||||||
|
Jellyfin server API client — thin, hybrid integration.
|
||||||
|
|
||||||
|
The NFO + artwork on disk stay authoritative (Jellyfin writes and reads them);
|
||||||
|
this client only does what the disk can't: **trigger a library scan** and **read
|
||||||
|
back what Jellyfin currently sees** (to verify the pool against it). Connection
|
||||||
|
details come from `.env` — `JELLYFIN_URL` and `JELLYFIN_API_KEY` (created in
|
||||||
|
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
|
||||||
|
|
||||||
|
Source: [`src/core/jellyfin.py`](../../src/core/jellyfin.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `JellyfinMovie`
|
||||||
|
|
||||||
|
*class · line 25*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class JellyfinMovie:
|
||||||
|
```
|
||||||
|
|
||||||
|
A movie as Jellyfin currently sees it (for verification).
|
||||||
|
|
||||||
|
### `JellyfinError`
|
||||||
|
|
||||||
|
*class · line 35*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class JellyfinError(RuntimeError):
|
||||||
|
```
|
||||||
|
|
||||||
|
A Jellyfin request could not be completed (no config, network, status).
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `base_url`
|
||||||
|
|
||||||
|
*function · line 39*
|
||||||
|
|
||||||
|
```python
|
||||||
|
base_url() -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The configured Jellyfin base URL (no trailing slash), or None.
|
||||||
|
|
||||||
|
### `api_key`
|
||||||
|
|
||||||
|
*function · line 45*
|
||||||
|
|
||||||
|
```python
|
||||||
|
api_key() -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The configured Jellyfin API key, or None.
|
||||||
|
|
||||||
|
### `api_available`
|
||||||
|
|
||||||
|
*function · line 51*
|
||||||
|
|
||||||
|
```python
|
||||||
|
api_available() -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when both the Jellyfin URL and API key are configured.
|
||||||
|
|
||||||
|
### `_request`
|
||||||
|
|
||||||
|
*function · line 56*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_request(method: str, path: str, params: Optional[dict]=None, timeout: Optional[int]=None) -> requests.Response
|
||||||
|
```
|
||||||
|
|
||||||
|
Send one authenticated request to the Jellyfin server (`X-Emby-Token` header). Raises `JellyfinError` when the URL/key are missing or the server can't be reached.
|
||||||
|
|
||||||
|
### `server_version`
|
||||||
|
|
||||||
|
*function · line 74*
|
||||||
|
|
||||||
|
```python
|
||||||
|
server_version(timeout: Optional[int]=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the Jellyfin server version (a quick connectivity check).
|
||||||
|
|
||||||
|
### `scan_all`
|
||||||
|
|
||||||
|
*function · line 80*
|
||||||
|
|
||||||
|
```python
|
||||||
|
scan_all() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Trigger a scan of all Jellyfin libraries (picks up on-disk changes).
|
||||||
|
|
||||||
|
### `fetch_movies`
|
||||||
|
|
||||||
|
*function · line 85*
|
||||||
|
|
||||||
|
```python
|
||||||
|
fetch_movies() -> list[JellyfinMovie]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read every movie Jellyfin currently has, with its TMDb id and path.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# `src/core/legacy_meta.py`
|
||||||
|
|
||||||
|
Readers for Curator's *pre-index* metadata layouts.
|
||||||
|
|
||||||
|
Before the unified pool index (`.Curator.!index`), Tagger / early Curator stored
|
||||||
|
a movie's metadata in a **per-file sidecar** `.{filename}.!tag` (JSON) sitting
|
||||||
|
next to the video. When importing a file that came from such a pool we can
|
||||||
|
recover its **ČSFD link** (and title / year) from that sidecar — or from an old
|
||||||
|
`.Curator.!index` found by walking up from the file.
|
||||||
|
|
||||||
|
This is read-only recovery for the import dialog; nothing here writes.
|
||||||
|
|
||||||
|
Source: [`src/core/legacy_meta.py`](../../src/core/legacy_meta.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `LegacyMeta`
|
||||||
|
|
||||||
|
*class · line 25*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LegacyMeta:
|
||||||
|
```
|
||||||
|
|
||||||
|
What an old sidecar / index record can still tell us about a movie.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `_record_to_meta`
|
||||||
|
|
||||||
|
*function · line 34*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_record_to_meta(record: object, source: str) -> Optional[LegacyMeta]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pull the useful fields out of a stored movie record (any schema version).
|
||||||
|
|
||||||
|
### `_from_sidecar`
|
||||||
|
|
||||||
|
*function · line 51*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_from_sidecar(video_path: Path) -> Optional[LegacyMeta]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the metadata from the per-file `.{name}.!tag` JSON sidecar next to a video, or return `None` when there is no readable sidecar.
|
||||||
|
|
||||||
|
### `_lookup_in_index`
|
||||||
|
|
||||||
|
*function · line 62*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> Optional[dict]
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the record for `video_path` in an index's `movies` map.
|
||||||
|
|
||||||
|
Tries the exact pool-relative key first, then falls back to a unique
|
||||||
|
basename match (the file may have moved since the index was written).
|
||||||
|
|
||||||
|
### `_from_legacy_index`
|
||||||
|
|
||||||
|
*function · line 79*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_from_legacy_index(video_path: Path) -> Optional[LegacyMeta]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the metadata from a `.Curator.!index` file found by walking up at most `_MAX_INDEX_WALK_UP` directories from the video. Returns `None` if nothing matches.
|
||||||
|
|
||||||
|
### `read_legacy_meta`
|
||||||
|
|
||||||
|
*function · line 99*
|
||||||
|
|
||||||
|
```python
|
||||||
|
read_legacy_meta(video_path: Path) -> Optional[LegacyMeta]
|
||||||
|
```
|
||||||
|
|
||||||
|
Recover ČSFD link / title / year from a pre-index layout, or `None`.
|
||||||
|
|
||||||
|
Order: a per-file `.{name}.!tag` sidecar next to the video, then a
|
||||||
|
`.Curator.!index` found walking up (max 4 levels) from the file.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# `src/core/list_manager.py`
|
||||||
|
|
||||||
|
Sorting of file lists by name or by date added. Small helper used by the file list views.
|
||||||
|
|
||||||
|
Source: [`src/core/list_manager.py`](../../src/core/list_manager.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `ListManager`
|
||||||
|
|
||||||
|
*class · line 4*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ListManager:
|
||||||
|
```
|
||||||
|
|
||||||
|
Holds the current sort mode (`name` or `date`) and sorts file lists accordingly.
|
||||||
|
|
||||||
|
#### `ListManager.__init__`
|
||||||
|
|
||||||
|
*method · line 5*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__()
|
||||||
|
```
|
||||||
|
|
||||||
|
Start sorting by name.
|
||||||
|
|
||||||
|
#### `ListManager.set_sort`
|
||||||
|
|
||||||
|
*method · line 9*
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_sort(mode: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Switch the sort mode to `name` or `date`; other values are ignored.
|
||||||
|
|
||||||
|
#### `ListManager.sort_files`
|
||||||
|
|
||||||
|
*method · line 13*
|
||||||
|
|
||||||
|
```python
|
||||||
|
sort_files(files: List[File]) -> List[File]
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the files sorted by name (case-insensitive) or by date. Files without a date go last in date mode.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# `src/core/migrate.py`
|
||||||
|
|
||||||
|
One-off migration of an existing pool to the Jellyfin naming convention.
|
||||||
|
|
||||||
|
Older pools carry files like `Title.ext` / `Title (rok).ext` with **no TMDb
|
||||||
|
id**. This matches each such movie against TMDb by title + year and proposes a
|
||||||
|
`tmdbid` (and the missing year, which TMDb supplies), so the file can be renamed
|
||||||
|
to `Title (rok) [tmdbid].ext` and Jellyfin can match it deterministically.
|
||||||
|
|
||||||
|
The classification is deliberately **conservative**: only an exact
|
||||||
|
(diacritics-insensitive) title match with a matching year and a single candidate
|
||||||
|
is `auto`; everything else is `ambiguous` for the user to confirm.
|
||||||
|
|
||||||
|
Source: [`src/core/migrate.py`](../../src/core/migrate.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `MatchProposal`
|
||||||
|
|
||||||
|
*class · line 19*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MatchProposal:
|
||||||
|
```
|
||||||
|
|
||||||
|
One movie's proposed TMDb match.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `normalize`
|
||||||
|
|
||||||
|
*function · line 33*
|
||||||
|
|
||||||
|
```python
|
||||||
|
normalize(text: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Fold a title for comparison: strip diacritics/punctuation, lowercase.
|
||||||
|
|
||||||
|
### `_year_close`
|
||||||
|
|
||||||
|
*function · line 41*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_year_close(a: Optional[str], b: Optional[str]) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when two year strings are both numeric and differ by at most one year (release years often disagree by one).
|
||||||
|
|
||||||
|
### `classify`
|
||||||
|
|
||||||
|
*function · line 47*
|
||||||
|
|
||||||
|
```python
|
||||||
|
classify(title: str, year: Optional[str], candidates: list) -> tuple[str, object]
|
||||||
|
```
|
||||||
|
|
||||||
|
Return `(status, chosen)` for a title/year against TMDb candidates.
|
||||||
|
|
||||||
|
`auto` requires an exact normalized-title match, a year within ±1, and that
|
||||||
|
exactly one candidate qualifies. Otherwise `ambiguous` (top hit preselected)
|
||||||
|
or `no_match` when there are no candidates.
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# `src/core/movie.py`
|
||||||
|
|
||||||
|
The movie object — metadata first, files second.
|
||||||
|
|
||||||
|
A movie is identified by `(title, year, tmdb_id)` and owns **its files**, not
|
||||||
|
the other way round. That inversion is the point: the pool index used to key a
|
||||||
|
record by a file path, so metadata was a passenger on one particular file — and
|
||||||
|
everything about multi-version movies got awkward (which file "holds" the tags,
|
||||||
|
why renaming a grouped movie had to be blocked, why an import could silently
|
||||||
|
create a duplicate instead of a version).
|
||||||
|
|
||||||
|
Here the record is the movie; each playable file is a `MediaFile` under
|
||||||
|
it, told apart by a free-text `label` (`3D HSBS`, `4K`, `Director's
|
||||||
|
Cut`, …). Where those files *live* is then a pure function of the record —
|
||||||
|
`Movie.desired_layout` — so promoting to a folder, demoting back, or
|
||||||
|
renaming the whole thing are all the same operation: recompute and move what
|
||||||
|
doesn't match.
|
||||||
|
|
||||||
|
Layout (Jellyfin's own conventions — https://jellyfin.org/docs/general/server/media/movies/)::
|
||||||
|
|
||||||
|
one video <Library>/Title (rok) [tmdbid-N].ext
|
||||||
|
two or more <Library>/Title (rok) [tmdbid-N]/
|
||||||
|
Title (rok) [tmdbid-N] - Výchozí.ext
|
||||||
|
Title (rok) [tmdbid-N].3D.HSBS.ext
|
||||||
|
|
||||||
|
Every file in a movie folder must start with the folder's exact name; both
|
||||||
|
`" - label"` and `".3D.FMT"` are valid separators, so a 3D file keeps the
|
||||||
|
documented `.3D.<FORMAT>` spelling whether it sits flat or grouped.
|
||||||
|
Subtitles are not media entries — they ride along with their video by filename
|
||||||
|
stem (see `src.core.subtitles`).
|
||||||
|
|
||||||
|
Source: [`src/core/movie.py`](../../src/core/movie.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `MediaFile`
|
||||||
|
|
||||||
|
*class · line 102*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MediaFile:
|
||||||
|
```
|
||||||
|
|
||||||
|
One file belonging to a movie (today always a playable video).
|
||||||
|
|
||||||
|
#### `MediaFile.format_3d`
|
||||||
|
|
||||||
|
*property · line 110*
|
||||||
|
|
||||||
|
```python
|
||||||
|
format_3d -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The 3D format this version's label denotes, if any.
|
||||||
|
|
||||||
|
#### `MediaFile.to_record`
|
||||||
|
|
||||||
|
*method · line 114*
|
||||||
|
|
||||||
|
```python
|
||||||
|
to_record(pool_dir: Path) -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Serialise for the pool index (path stored pool-relative, POSIX).
|
||||||
|
|
||||||
|
#### `MediaFile.from_record`
|
||||||
|
|
||||||
|
*class method · line 123*
|
||||||
|
|
||||||
|
```python
|
||||||
|
from_record(data: dict, pool_dir: Path) -> 'MediaFile'
|
||||||
|
```
|
||||||
|
|
||||||
|
Build a `MediaFile` from one entry of a movie record's `media` list, resolving its relative path against the pool directory.
|
||||||
|
|
||||||
|
### `Movie`
|
||||||
|
|
||||||
|
*class · line 132*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Movie:
|
||||||
|
```
|
||||||
|
|
||||||
|
A film in the pool: its identity, its metadata, and its media files.
|
||||||
|
|
||||||
|
#### `Movie.videos`
|
||||||
|
|
||||||
|
*property · line 156*
|
||||||
|
|
||||||
|
```python
|
||||||
|
videos -> list[MediaFile]
|
||||||
|
```
|
||||||
|
|
||||||
|
Playable versions, in a stable order (labelled ones by label).
|
||||||
|
|
||||||
|
#### `Movie.is_grouped`
|
||||||
|
|
||||||
|
*property · line 163*
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_grouped -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when the movie needs its own folder (more than one version).
|
||||||
|
|
||||||
|
#### `Movie.stem`
|
||||||
|
|
||||||
|
*property · line 168*
|
||||||
|
|
||||||
|
```python
|
||||||
|
stem -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical `Title (rok) [tmdbid-N]` — the folder / filename base.
|
||||||
|
|
||||||
|
Falls back to the bare title when no four-digit year is known (a movie
|
||||||
|
can be imported without one).
|
||||||
|
|
||||||
|
#### `Movie.primary`
|
||||||
|
|
||||||
|
*property · line 182*
|
||||||
|
|
||||||
|
```python
|
||||||
|
primary -> Optional[MediaFile]
|
||||||
|
```
|
||||||
|
|
||||||
|
The version that represents the movie (first by `videos`).
|
||||||
|
|
||||||
|
#### `Movie.desired_layout`
|
||||||
|
|
||||||
|
*method · line 187*
|
||||||
|
|
||||||
|
```python
|
||||||
|
desired_layout(pool_dir: Path) -> list[tuple[MediaFile, Path]]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(media, where it belongs)` for every file, from the record alone.
|
||||||
|
|
||||||
|
The whole flat-vs-folder rule lives here: one video sits directly in
|
||||||
|
the library, two or more move into `<Library>/<stem>/`. Callers move
|
||||||
|
whatever doesn't already match — which makes promotion, demotion and
|
||||||
|
renaming the same operation.
|
||||||
|
|
||||||
|
#### `Movie.to_record`
|
||||||
|
|
||||||
|
*method · line 208*
|
||||||
|
|
||||||
|
```python
|
||||||
|
to_record(pool_dir: Path) -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Serialise the movie into the dictionary stored in the pool index, with media paths relative to the pool directory.
|
||||||
|
|
||||||
|
#### `Movie.from_record`
|
||||||
|
|
||||||
|
*class method · line 227*
|
||||||
|
|
||||||
|
```python
|
||||||
|
from_record(key: str, data: dict, pool_dir: Path, tagmanager=None) -> 'Movie'
|
||||||
|
```
|
||||||
|
|
||||||
|
Build a `Movie` from an index record stored under `key`. Tags are resolved through `tagmanager` when one is given.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `normalize_3d_label`
|
||||||
|
|
||||||
|
*function · line 53*
|
||||||
|
|
||||||
|
```python
|
||||||
|
normalize_3d_label(label: object) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The 3D format a version label denotes (upper-case), or `None`.
|
||||||
|
|
||||||
|
Lets a plain label double as the 3D marker — `"3D HSBS"` typed in the
|
||||||
|
version dialog and `"hsbs"` picked from a dropdown mean the same thing.
|
||||||
|
|
||||||
|
### `media_filename`
|
||||||
|
|
||||||
|
*function · line 63*
|
||||||
|
|
||||||
|
```python
|
||||||
|
media_filename(stem: str, label: str, ext: str, *, grouped: bool) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Filename for one media file of a movie whose canonical stem is `stem`.
|
||||||
|
|
||||||
|
- no label → `stem.ext` (a lone, plain video)
|
||||||
|
- 3D label → `stem.3D.<FORMAT>.ext` (Jellyfin's documented 3D form)
|
||||||
|
- anything else → `stem - label.ext`
|
||||||
|
|
||||||
|
`grouped` only decides whether an empty label is allowed: inside a movie
|
||||||
|
folder every file gets one, so it falls back to `DEFAULT_VERSION_LABEL`.
|
||||||
|
|
||||||
|
### `identity_key`
|
||||||
|
|
||||||
|
*function · line 84*
|
||||||
|
|
||||||
|
```python
|
||||||
|
identity_key(tmdb_id: object=None, title: str='', year: object=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
The pool-index key for a movie — `tmdb-<id>`, else a title/year slug.
|
||||||
|
|
||||||
|
Keying by identity (rather than by file path) is what lets a second file of
|
||||||
|
the same film find its movie instead of becoming a separate entry.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# `src/core/naming.py`
|
||||||
|
|
||||||
|
Pool file naming convention.
|
||||||
|
|
||||||
|
Every movie in the pool is named `Title (YYYY) [tmdbid-N].ext` — a title, a
|
||||||
|
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
|
||||||
|
is exactly the form Jellyfin matches on. The `[tmdbid-N]` part is optional: a
|
||||||
|
plain `Title (YYYY)` is still valid (Jellyfin then matches by name). The year
|
||||||
|
lets two same-named films coexist and makes the filename self-describing.
|
||||||
|
|
||||||
|
A 3D movie additionally carries a `.3D.<FORMAT>` tag right before the
|
||||||
|
extension (Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/):
|
||||||
|
`Title (YYYY) [tmdbid-N].3D.HSBS.ext`. `FORMAT` is one of
|
||||||
|
`THREE_D_FORMATS`; Jellyfin reads it case-insensitively but Curator always
|
||||||
|
writes it upper-case for a single canonical form.
|
||||||
|
|
||||||
|
These helpers build, validate and parse the stem (the filename without its
|
||||||
|
extension).
|
||||||
|
|
||||||
|
Source: [`src/core/naming.py`](../../src/core/naming.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `is_valid_year`
|
||||||
|
|
||||||
|
*function · line 35*
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_valid_year(year: object) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when `year` is (or stringifies to) exactly four digits.
|
||||||
|
|
||||||
|
### `canonical_pool_stem`
|
||||||
|
|
||||||
|
*function · line 40*
|
||||||
|
|
||||||
|
```python
|
||||||
|
canonical_pool_stem(title: str, year: object, tmdb_id: object=None, format_3d: object=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the canonical pool stem `Title (YYYY)` (+ ` [tmdbid-N]` /
|
||||||
|
`.3D.<FORMAT>` if given).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: the title is empty, the year is not four digits, a given
|
||||||
|
`tmdb_id` is not a positive integer, or `format_3d` isn't one
|
||||||
|
of `THREE_D_FORMATS`.
|
||||||
|
|
||||||
|
### `parse_pool_stem`
|
||||||
|
|
||||||
|
*function · line 73*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_pool_stem(stem: str) -> tuple[str, int] | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Split a canonical stem into `(title, year)`; `None` if it isn't one.
|
||||||
|
|
||||||
|
A trailing `[tmdbid-N]` is tolerated and dropped (use `parse_tmdb_id`
|
||||||
|
to read it), so the returned title is always the clean title.
|
||||||
|
|
||||||
|
### `parse_tmdb_id`
|
||||||
|
|
||||||
|
*function · line 85*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_tmdb_id(stem: str) -> int | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the `[tmdbid-N]` from a canonical stem, or `None` if absent.
|
||||||
|
|
||||||
|
### `parse_3d_format`
|
||||||
|
|
||||||
|
*function · line 93*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_3d_format(stem: str) -> str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the `.3D.<FORMAT>` tag from a canonical stem (upper-cased), or
|
||||||
|
`None` if absent.
|
||||||
|
|
||||||
|
### `is_canonical_pool_stem`
|
||||||
|
|
||||||
|
*function · line 102*
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_canonical_pool_stem(stem: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when `stem` matches the `Title (YYYY)` convention.
|
||||||
|
|
||||||
|
### `year_from_filename`
|
||||||
|
|
||||||
|
*function · line 107*
|
||||||
|
|
||||||
|
```python
|
||||||
|
year_from_filename(name: str) -> str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Best-effort four-digit year guessed from a raw (release) filename.
|
||||||
|
|
||||||
|
Prefers a parenthesised year (`… (1999) …`) and otherwise falls back to the
|
||||||
|
last standalone 19xx/20xx run. Used only to pre-fill the import dialog — the
|
||||||
|
user confirms it. Returns the year as a string, or `None`.
|
||||||
|
|
||||||
|
### `three_d_format_from_filename`
|
||||||
|
|
||||||
|
*function · line 129*
|
||||||
|
|
||||||
|
```python
|
||||||
|
three_d_format_from_filename(name: str) -> str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Best-effort 3D format guessed from a raw (release) filename, e.g.
|
||||||
|
`Avatar.2009.3D.HSBS.BluRay.mkv` → `"HSBS"`. Unlike `parse_3d_format`
|
||||||
|
this doesn't require the rest of the name to be a canonical pool stem — used
|
||||||
|
to pre-fill a version label while importing. Returns `None` if absent.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# `src/core/nfo.py`
|
||||||
|
|
||||||
|
NFO reader — Curator's metadata source after ČSFD.
|
||||||
|
|
||||||
|
Jellyfin (configured to save metadata into the media folders) writes a Kodi-style
|
||||||
|
`.nfo` next to each movie plus artwork. Curator only **reads** it — to drive the
|
||||||
|
miniDLNA genre tree and to fill the year/rating used by folder grouping. It never
|
||||||
|
writes NFO (Jellyfin owns that).
|
||||||
|
|
||||||
|
For a flat pool file `Title (rok) [tmdbid].mkv` the sidecar is
|
||||||
|
`Title (rok) [tmdbid].nfo`; a per-movie folder may instead hold `movie.nfo`.
|
||||||
|
Both are found. Missing or unparsable NFO → `None` (the movie simply has no
|
||||||
|
genres until Jellyfin has scanned it).
|
||||||
|
|
||||||
|
Source: [`src/core/nfo.py`](../../src/core/nfo.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `NfoData`
|
||||||
|
|
||||||
|
*class · line 20*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class NfoData:
|
||||||
|
```
|
||||||
|
|
||||||
|
The bits of a movie `.nfo` Curator uses.
|
||||||
|
|
||||||
|
#### `NfoData.field_values`
|
||||||
|
|
||||||
|
*method · line 31*
|
||||||
|
|
||||||
|
```python
|
||||||
|
field_values(name: str) -> list[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
Values for a tag-schema source field (genres/countries/year/rating).
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `find_nfo`
|
||||||
|
|
||||||
|
*function · line 44*
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_nfo(video_path: Path) -> Optional[Path]
|
||||||
|
```
|
||||||
|
|
||||||
|
Locate the `.nfo` for a movie: sibling `<stem>.nfo` or `movie.nfo`.
|
||||||
|
|
||||||
|
### `_texts`
|
||||||
|
|
||||||
|
*function · line 56*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_texts(root: ET.Element, tag: str) -> list[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
All non-empty stripped texts of the child elements called `tag`.
|
||||||
|
|
||||||
|
### `_parse_rating`
|
||||||
|
|
||||||
|
*function · line 60*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_rating(root: ET.Element) -> Optional[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands).
|
||||||
|
|
||||||
|
### `_parse_tmdb_id`
|
||||||
|
|
||||||
|
*function · line 74*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_tmdb_id(root: ET.Element) -> Optional[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
The TMDb id from a Kodi NFO: a `<uniqueid type="tmdb">` element, or a legacy `<tmdbid>` element. `None` if neither holds a number.
|
||||||
|
|
||||||
|
### `_parse_csfd_url`
|
||||||
|
|
||||||
|
*function · line 82*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_csfd_url(root: ET.Element) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
A ČSFD film URL from the NFO, if any.
|
||||||
|
|
||||||
|
Czech Kodi scrapers record ČSFD as `<uniqueid type="csfd">` (a bare id or a
|
||||||
|
full URL) or a legacy `<csfdid>` / `<csfd>` element. A bare id is expanded
|
||||||
|
to `https://www.csfd.cz/film/<id>/`.
|
||||||
|
|
||||||
|
### `read_nfo`
|
||||||
|
|
||||||
|
*function · line 105*
|
||||||
|
|
||||||
|
```python
|
||||||
|
read_nfo(video_path: Path) -> Optional[NfoData]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the movie's `.nfo` into `NfoData`, or None if absent/broken.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# `src/core/pool_index.py`
|
||||||
|
|
||||||
|
Unified metadata index for the Curator pool.
|
||||||
|
|
||||||
|
Instead of one sidecar file per movie, the whole pool keeps a single JSON index
|
||||||
|
at `<pool>/.Curator.!index`. Curator owns the pool (it inserts/removes files
|
||||||
|
itself), so files never move behind its back and a central index is safe.
|
||||||
|
|
||||||
|
The index also carries **library-level settings** (`tag_schema`,
|
||||||
|
`copyasis_folders`) under a `settings` section, so everything describing a
|
||||||
|
particular pool travels with the pool. The global config keeps only app data
|
||||||
|
(window state, MRU) and the pointers to where the pool/output live.
|
||||||
|
|
||||||
|
On-disk shape (**v2**)::
|
||||||
|
|
||||||
|
{"version": 2,
|
||||||
|
"settings": {...},
|
||||||
|
"movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
|
||||||
|
|
||||||
|
Records are keyed by the movie's **identity** (`tmdb-<id>`, or a title/year
|
||||||
|
slug when there's no id) and own their files — see `src.core.movie`. The
|
||||||
|
original **v1** shape keyed a record by one file's pool-relative path, which
|
||||||
|
made metadata a passenger on that file; `PoolIndex.load` migrates such an
|
||||||
|
index in place (backing the old file up first). Migration only rewrites
|
||||||
|
metadata — no file on disk is moved, because a one-video movie is already in
|
||||||
|
its v2 position.
|
||||||
|
|
||||||
|
Source: [`src/core/pool_index.py`](../../src/core/pool_index.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `PoolIndex`
|
||||||
|
|
||||||
|
*class · line 35*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PoolIndex:
|
||||||
|
```
|
||||||
|
|
||||||
|
Single-file JSON metadata store for all files in a pool.
|
||||||
|
|
||||||
|
#### `PoolIndex.__init__`
|
||||||
|
|
||||||
|
*method · line 38*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__(pool_dir: Path) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the index of the pool at `pool_dir` (loads it, migrating an older version if needed).
|
||||||
|
|
||||||
|
#### `PoolIndex._key`
|
||||||
|
|
||||||
|
*method · line 45*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_key(file_path: Path) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Pool-relative POSIX key for a file path.
|
||||||
|
|
||||||
|
#### `PoolIndex.load`
|
||||||
|
|
||||||
|
*method · line 53*
|
||||||
|
|
||||||
|
```python
|
||||||
|
load() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Load the index from disk (missing/corrupt index = empty).
|
||||||
|
|
||||||
|
A v1 (path-keyed) index is migrated to v2 in place — the old file is
|
||||||
|
copied to `.Curator.!index.v1.bak` first.
|
||||||
|
|
||||||
|
#### `PoolIndex._migrate_v1_to_v2`
|
||||||
|
|
||||||
|
*method · line 76*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_migrate_v1_to_v2() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-key path-keyed records into identity-keyed movie records.
|
||||||
|
|
||||||
|
Each v1 record described one file; several of them can describe one
|
||||||
|
movie (the interim multi-version layout put them in a shared folder),
|
||||||
|
so they are merged by identity. Metadata only — nothing is moved on
|
||||||
|
disk, since a v1 movie's files already sit where v2 wants them.
|
||||||
|
|
||||||
|
#### `PoolIndex.save`
|
||||||
|
|
||||||
|
*method · line 139*
|
||||||
|
|
||||||
|
```python
|
||||||
|
save() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Persist the index to disk.
|
||||||
|
|
||||||
|
#### `PoolIndex.get_setting`
|
||||||
|
|
||||||
|
*method · line 150*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_setting(key: str, default=None)
|
||||||
|
```
|
||||||
|
|
||||||
|
Return a library-level setting stored in the index.
|
||||||
|
|
||||||
|
#### `PoolIndex.set_setting`
|
||||||
|
|
||||||
|
*method · line 154*
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_setting(key: str, value) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Upsert a library-level setting and persist the index.
|
||||||
|
|
||||||
|
#### `PoolIndex.get_movie`
|
||||||
|
|
||||||
|
*method · line 163*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_movie(key: str) -> dict | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the movie record under `key`, or None.
|
||||||
|
|
||||||
|
#### `PoolIndex.set_movie`
|
||||||
|
|
||||||
|
*method · line 167*
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_movie(key: str, data: dict) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Upsert a movie record and persist the index.
|
||||||
|
|
||||||
|
#### `PoolIndex.delete_movie`
|
||||||
|
|
||||||
|
*method · line 172*
|
||||||
|
|
||||||
|
```python
|
||||||
|
delete_movie(key: str) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove a movie record (if present) and persist the index.
|
||||||
|
|
||||||
|
#### `PoolIndex.rekey_movie`
|
||||||
|
|
||||||
|
*method · line 177*
|
||||||
|
|
||||||
|
```python
|
||||||
|
rekey_movie(old_key: str, new_key: str) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Move a record to a new identity key (e.g. its TMDb id was set/fixed).
|
||||||
|
|
||||||
|
#### `PoolIndex.all_movies`
|
||||||
|
|
||||||
|
*method · line 184*
|
||||||
|
|
||||||
|
```python
|
||||||
|
all_movies() -> dict[str, dict]
|
||||||
|
```
|
||||||
|
|
||||||
|
Every movie record, keyed by identity.
|
||||||
|
|
||||||
|
#### `PoolIndex.record_for`
|
||||||
|
|
||||||
|
*method · line 188*
|
||||||
|
|
||||||
|
```python
|
||||||
|
record_for(file_path: Path) -> dict | None
|
||||||
|
```
|
||||||
|
|
||||||
|
The movie record that owns `file_path`, or None if untracked.
|
||||||
|
|
||||||
|
#### `PoolIndex.find_movie_by_media`
|
||||||
|
|
||||||
|
*method · line 193*
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_movie_by_media(file_path: Path) -> tuple[str, dict] | None
|
||||||
|
```
|
||||||
|
|
||||||
|
`(key, record)` of the movie owning `file_path`, or None.
|
||||||
|
|
||||||
|
The reverse lookup a file needs now that records are keyed by identity
|
||||||
|
rather than by path.
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# `src/core/series.py`
|
||||||
|
|
||||||
|
Series naming + scanning for the Jellyfin TV layout.
|
||||||
|
|
||||||
|
Jellyfin wants series laid out as::
|
||||||
|
|
||||||
|
Seriály/Series (year) [tmdbid-N]/
|
||||||
|
tvshow.nfo (Jellyfin writes it)
|
||||||
|
Season 01/
|
||||||
|
Series (year) S01E01.ext
|
||||||
|
Specials/ (season 0)
|
||||||
|
|
||||||
|
Episodes are matched by `SxxEyy`. These helpers parse the episode number out of
|
||||||
|
a raw filename, build the canonical episode/season/series names, and scan a
|
||||||
|
series library into a `Series → Season → Episode` tree with a naming status.
|
||||||
|
|
||||||
|
Source: [`src/core/series.py`](../../src/core/series.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `Episode`
|
||||||
|
|
||||||
|
*class · line 49*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Episode:
|
||||||
|
```
|
||||||
|
|
||||||
|
One episode file found in a series folder: its path, its season and episode numbers (`None` when not recognised) and its part number when the episode is split across several files.
|
||||||
|
|
||||||
|
#### `Episode.code`
|
||||||
|
|
||||||
|
*property · line 58*
|
||||||
|
|
||||||
|
```python
|
||||||
|
code -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Human-readable episode code, e.g. `S01E02`, with `.ptN` appended for a part (`S01E02.pt3`). `?` when the season or episode is unknown.
|
||||||
|
|
||||||
|
### `Series`
|
||||||
|
|
||||||
|
*class · line 66*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Series:
|
||||||
|
```
|
||||||
|
|
||||||
|
One series folder with its title, year, TMDb id, library and the episodes found below it.
|
||||||
|
|
||||||
|
#### `Series.seasons`
|
||||||
|
|
||||||
|
*method · line 74*
|
||||||
|
|
||||||
|
```python
|
||||||
|
seasons() -> dict[int, list[Episode]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Episodes grouped by season number (`-1` = unrecognised).
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `parse_episode`
|
||||||
|
|
||||||
|
*function · line 84*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_episode(name: str) -> Optional[tuple[int, int]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract `(season, episode)` from a filename, or `None`.
|
||||||
|
|
||||||
|
### `parse_episode_part`
|
||||||
|
|
||||||
|
*function · line 94*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_episode_part(name: str) -> Optional[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Part number implied by a Jellyfin stacking suffix in a filename, or `None`.
|
||||||
|
|
||||||
|
Looks for *cd*, *dvd*, *part*, *pt*, *disc* or *disk* + a number sitting
|
||||||
|
right before the extension (`Show S01E02.pt2.mkv`,
|
||||||
|
`Show S01E02-part-2.mkv`, …) — the same tokens Jellyfin itself scans for.
|
||||||
|
|
||||||
|
### `parse_episode_code`
|
||||||
|
|
||||||
|
*function · line 105*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_episode_code(text: str) -> Optional[tuple[int, int, Optional[int]]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse a compact user-typed code: `SxxEyy`, optionally + a part.
|
||||||
|
|
||||||
|
Accepts everything `parse_episode` does, plus a trailing part marker
|
||||||
|
right after it — `S01E02P3`, `S01E02 pt3`, `S01E02.part.3`,
|
||||||
|
`S01E02 cd2` — for a file that is only *one part* of that episode.
|
||||||
|
Returns `(season, episode, part)` with `part` `None` when absent, or
|
||||||
|
`None` when no `SxxEyy` is found at all.
|
||||||
|
|
||||||
|
### `collect_episode_files`
|
||||||
|
|
||||||
|
*function · line 126*
|
||||||
|
|
||||||
|
```python
|
||||||
|
collect_episode_files(folder: Path) -> list[Path]
|
||||||
|
```
|
||||||
|
|
||||||
|
Every video file under `folder` (recursively), sidecars excluded, sorted.
|
||||||
|
|
||||||
|
Lets the episode-import dialog take a whole `Series/Season NN/` tree (or any
|
||||||
|
nested layout) in one pick instead of file-by-file. Season/episode numbers
|
||||||
|
are still read per file from its name by `parse_episode`.
|
||||||
|
|
||||||
|
### `season_folder_name`
|
||||||
|
|
||||||
|
*function · line 142*
|
||||||
|
|
||||||
|
```python
|
||||||
|
season_folder_name(season: int) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Folder name for a season — `Specials` for 0, else `Season NN`.
|
||||||
|
|
||||||
|
### `is_season_folder`
|
||||||
|
|
||||||
|
*function · line 147*
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_season_folder(name: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True for a season folder name — `Specials` or `Season <n>` (any padding).
|
||||||
|
|
||||||
|
### `looks_like_series_folder`
|
||||||
|
|
||||||
|
*function · line 152*
|
||||||
|
|
||||||
|
```python
|
||||||
|
looks_like_series_folder(folder: Path) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when `folder` holds at least one `Season NN`/`Specials` subfolder.
|
||||||
|
|
||||||
|
That's the shape Curator always gives a series (episodes always land in a
|
||||||
|
season folder, never loose at the series root) — a movie's version folder
|
||||||
|
never has one, its files sit flat inside it. This is how a `mixed`
|
||||||
|
library tells the two apart even though both use the identical outer
|
||||||
|
`Title (rok) [tmdbid]/` name: `FileManager.is_movie_group_member`
|
||||||
|
and `scan_series` both check it before deciding which one a
|
||||||
|
canonical-shaped subfolder is.
|
||||||
|
|
||||||
|
### `sanitize_title`
|
||||||
|
|
||||||
|
*function · line 173*
|
||||||
|
|
||||||
|
```python
|
||||||
|
sanitize_title(title: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
A series/episode title made safe to sit in a filename (spaces collapsed).
|
||||||
|
|
||||||
|
### `episode_filename`
|
||||||
|
|
||||||
|
*function · line 179*
|
||||||
|
|
||||||
|
```python
|
||||||
|
episode_filename(season: int, episode: int, series_title: str, ext: str, *, part: Optional[int]=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical episode filename `SxxEyy - {series title}.ext`.
|
||||||
|
|
||||||
|
The release string is deliberately **not** kept — identity is the `SxxEyy`
|
||||||
|
code, and Jellyfin fetches the episode title/metadata from TMDb (matched by
|
||||||
|
the code + the series folder's `[tmdbid-N]`). An empty title collapses to
|
||||||
|
`SxxEyy.ext`. `part` marks one physical part of that episode — an old
|
||||||
|
recording split across several files — with Jellyfin's own `.ptN`
|
||||||
|
stacking suffix right before the extension.
|
||||||
|
|
||||||
|
### `series_folder_stem`
|
||||||
|
|
||||||
|
*function · line 200*
|
||||||
|
|
||||||
|
```python
|
||||||
|
series_folder_stem(title: str, year: object=None, tmdb_id: object=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Series folder name: `Title (year) [tmdbid-N]` (year/id optional).
|
||||||
|
|
||||||
|
Reuses the movie convention when a year is present; otherwise falls back to
|
||||||
|
`Title [tmdbid-N]` or just `Title` so a year-less series still works.
|
||||||
|
|
||||||
|
### `is_canonical_episode`
|
||||||
|
|
||||||
|
*function · line 216*
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_canonical_episode(path: Path, series_title: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when `path` is already `SxxEyy - {series title}.ext` in its season.
|
||||||
|
|
||||||
|
### `scan_series`
|
||||||
|
|
||||||
|
*function · line 229*
|
||||||
|
|
||||||
|
```python
|
||||||
|
scan_series(lib_dir: Path, library: str, *, skip_movie_folders: bool=False) -> list[Series]
|
||||||
|
```
|
||||||
|
|
||||||
|
Scan one series library into a list of `Series`.
|
||||||
|
|
||||||
|
Each immediate subfolder of `lib_dir` is a series; its name yields the
|
||||||
|
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
|
||||||
|
parsed from the filename). Non-video sidecars are skipped.
|
||||||
|
|
||||||
|
`skip_movie_folders=True` (a `mixed` library, where a movie's version
|
||||||
|
folder and a series folder both use the identical
|
||||||
|
`Title (rok) [tmdbid]/` shape) additionally skips a canonical-shaped
|
||||||
|
folder with no `Season NN`/`Specials` inside it — that's a movie's
|
||||||
|
version folder instead, left for `FileManager.load_pool_movies` to
|
||||||
|
read. Left `False` (the default, a plain `series`-kind library, which
|
||||||
|
never holds movies) every subfolder is read as a series regardless of
|
||||||
|
shape — a raw, not-yet-renamed import may have loose episode files
|
||||||
|
sitting at the series root, with no season folder yet.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# `src/core/subtitles.py`
|
||||||
|
|
||||||
|
External subtitle sidecars — attach, discover, keep in sync on rename.
|
||||||
|
|
||||||
|
Curator does not mux/embed subtitles. Jellyfin plays an **external** subtitle
|
||||||
|
file natively as long as it shares the video's filename stem, with an optional
|
||||||
|
language code and flag suffix::
|
||||||
|
|
||||||
|
Title (rok) [tmdbid-N].mkv
|
||||||
|
Title (rok) [tmdbid-N].cs.srt # language
|
||||||
|
Title (rok) [tmdbid-N].cs.forced.srt # + a flag (forced / sdh / default)
|
||||||
|
|
||||||
|
Subtitle presence is a **filesystem fact**, like NFO — nothing is cached in the
|
||||||
|
pool index. What Curator does own: copying a picked file into place with the
|
||||||
|
right name (`attach_subtitle`), and moving a subtitle along whenever its
|
||||||
|
video gets renamed so it never silently detaches (`rename_subtitles_with`).
|
||||||
|
|
||||||
|
Source: [`src/core/subtitles.py`](../../src/core/subtitles.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `SubtitleFile`
|
||||||
|
|
||||||
|
*class · line 30*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SubtitleFile:
|
||||||
|
```
|
||||||
|
|
||||||
|
One subtitle sidecar found next to a video.
|
||||||
|
|
||||||
|
#### `SubtitleFile.label`
|
||||||
|
|
||||||
|
*property · line 38*
|
||||||
|
|
||||||
|
```python
|
||||||
|
label -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Human-readable `jazyk (flag)` for display, e.g. `cs (forced)`.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `subtitle_filename`
|
||||||
|
|
||||||
|
*function · line 44*
|
||||||
|
|
||||||
|
```python
|
||||||
|
subtitle_filename(video_stem: str, ext: str, language: Optional[str]=None, flag: Optional[str]=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical subtitle filename: `{video_stem}[.language][.flag]{ext}`.
|
||||||
|
|
||||||
|
### `_parse_subtitle_name`
|
||||||
|
|
||||||
|
*function · line 57*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_subtitle_name(video_stem: str, filename: str) -> Optional[tuple[Optional[str], Optional[str]]]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(language, flag)` if `filename` is a subtitle sidecar of `video_stem`.
|
||||||
|
|
||||||
|
### `find_subtitles`
|
||||||
|
|
||||||
|
*function · line 83*
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_subtitles(video_path: Path) -> list[SubtitleFile]
|
||||||
|
```
|
||||||
|
|
||||||
|
Every external subtitle sidecar sitting next to `video_path`, sorted.
|
||||||
|
|
||||||
|
### `attach_subtitle`
|
||||||
|
|
||||||
|
*function · line 101*
|
||||||
|
|
||||||
|
```python
|
||||||
|
attach_subtitle(video_path: Path, source: Path, *, language: Optional[str]=None, flag: Optional[str]=None, move: bool=False) -> Path
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy (or move) `source` next to `video_path`, named to match it.
|
||||||
|
|
||||||
|
An existing subtitle already at that exact name (e.g. replacing the Czech
|
||||||
|
track) is overwritten; nothing else next to the video is touched.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: `source`'s extension isn't a recognised subtitle format.
|
||||||
|
|
||||||
|
### `rename_subtitles_with`
|
||||||
|
|
||||||
|
*function · line 128*
|
||||||
|
|
||||||
|
```python
|
||||||
|
rename_subtitles_with(old_video_path: Path, new_video_path: Path) -> list[Path]
|
||||||
|
```
|
||||||
|
|
||||||
|
Move every subtitle sidecar of `old_video_path` to match `new_video_path`.
|
||||||
|
|
||||||
|
Call this wherever a video gets renamed/moved so an attached subtitle never
|
||||||
|
silently detaches (its stem would stop matching and Jellyfin would drop it).
|
||||||
|
No-op when the video's path didn't actually change. Returns the new paths.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# `src/core/tag.py`
|
||||||
|
|
||||||
|
The `Tag` value object: a `(category, name)` pair identified by its `full_path` (`Category/Name`). Hashable and comparable, so tags can live in sets and be compared across the tag manager and the files.
|
||||||
|
|
||||||
|
Source: [`src/core/tag.py`](../../src/core/tag.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `Tag`
|
||||||
|
|
||||||
|
*class · line 1*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Tag:
|
||||||
|
```
|
||||||
|
|
||||||
|
A tag: a category and a name. Two tags are equal when both parts match.
|
||||||
|
|
||||||
|
#### `Tag.__init__`
|
||||||
|
|
||||||
|
*method · line 2*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__(category: str, name: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a tag in `category` with the given `name`.
|
||||||
|
|
||||||
|
#### `Tag.full_path`
|
||||||
|
|
||||||
|
*property · line 7*
|
||||||
|
|
||||||
|
```python
|
||||||
|
full_path
|
||||||
|
```
|
||||||
|
|
||||||
|
The tag as `Category/Name`, the form used in the index and in strings.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# `src/core/tag_manager.py`
|
||||||
|
|
||||||
|
`TagManager`: the registry of all tag categories and their tags. Initialised with the default categories; the GUI tag tree and the AND-filter read from it.
|
||||||
|
|
||||||
|
Source: [`src/core/tag_manager.py`](../../src/core/tag_manager.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `TagManager`
|
||||||
|
|
||||||
|
*class · line 16*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TagManager:
|
||||||
|
```
|
||||||
|
|
||||||
|
Registry of tag categories (`tags_by_category`). Adding a tag also creates its category; removing the last tag of a category removes the category.
|
||||||
|
|
||||||
|
#### `TagManager.__init__`
|
||||||
|
|
||||||
|
*method · line 17*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__()
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the registry and fill it with the default tags.
|
||||||
|
|
||||||
|
#### `TagManager._init_default_tags`
|
||||||
|
|
||||||
|
*method · line 21*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_init_default_tags()
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialize default tags (ratings and colors)
|
||||||
|
|
||||||
|
#### `TagManager.add_category`
|
||||||
|
|
||||||
|
*method · line 27*
|
||||||
|
|
||||||
|
```python
|
||||||
|
add_category(category: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Create an empty category if it does not exist yet.
|
||||||
|
|
||||||
|
#### `TagManager.remove_category`
|
||||||
|
|
||||||
|
*method · line 31*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_category(category: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete a category and all its tags, if it exists.
|
||||||
|
|
||||||
|
#### `TagManager.add_tag`
|
||||||
|
|
||||||
|
*method · line 35*
|
||||||
|
|
||||||
|
```python
|
||||||
|
add_tag(category: str, name: str) -> Tag
|
||||||
|
```
|
||||||
|
|
||||||
|
Register a tag in `category` (the category is created when missing) and return it.
|
||||||
|
|
||||||
|
#### `TagManager.remove_tag`
|
||||||
|
|
||||||
|
*method · line 41*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_tag(category: str, name: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Unregister one tag. When it was the category's last tag, the category is removed too.
|
||||||
|
|
||||||
|
#### `TagManager.get_all_tags`
|
||||||
|
|
||||||
|
*method · line 48*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_all_tags()
|
||||||
|
```
|
||||||
|
|
||||||
|
Every registered tag as a `Category/Name` string.
|
||||||
|
|
||||||
|
#### `TagManager.get_categories`
|
||||||
|
|
||||||
|
*method · line 52*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_categories()
|
||||||
|
```
|
||||||
|
|
||||||
|
The names of all registered categories.
|
||||||
|
|
||||||
|
#### `TagManager.get_tags_in_category`
|
||||||
|
|
||||||
|
*method · line 55*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_tags_in_category(category: str) -> list[Tag]
|
||||||
|
```
|
||||||
|
|
||||||
|
Get tags in category, sorted by predefined order for default categories
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# `src/core/tmdb.py`
|
||||||
|
|
||||||
|
TMDb (The Movie Database) client — Curator's identity source.
|
||||||
|
|
||||||
|
Replaces ČSFD. Its only job is to turn a **TMDb id** (or a title/year search)
|
||||||
|
into a canonical **title + year** for naming the pool file. The rich metadata
|
||||||
|
and artwork are fetched by **Jellyfin itself** — Curator does not need them here.
|
||||||
|
|
||||||
|
Needs a free API key in `TMDB_API_KEY` (loaded from `.env`). Everything
|
||||||
|
degrades gracefully when the key or the network is missing: `api_available`
|
||||||
|
reports readiness and the lookups raise `TmdbError` with a clear message.
|
||||||
|
|
||||||
|
Source: [`src/core/tmdb.py`](../../src/core/tmdb.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `TmdbResult`
|
||||||
|
|
||||||
|
*class · line 29*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TmdbResult:
|
||||||
|
```
|
||||||
|
|
||||||
|
A resolved TMDb entry — just enough to name the pool file.
|
||||||
|
|
||||||
|
### `TmdbDetails`
|
||||||
|
|
||||||
|
*class · line 39*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TmdbDetails:
|
||||||
|
```
|
||||||
|
|
||||||
|
Full TMDb data used to (re)generate a movie's tags (genres etc.).
|
||||||
|
|
||||||
|
Exposes `field_values` so it plugs into the tag schema the same way the
|
||||||
|
NFO reader does (fields: genres / countries / year / rating).
|
||||||
|
|
||||||
|
#### `TmdbDetails.field_values`
|
||||||
|
|
||||||
|
*method · line 53*
|
||||||
|
|
||||||
|
```python
|
||||||
|
field_values(name: str) -> list[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The values of one tag source field (`genres`, `countries`, `year`, `rating`, …) as a list of strings, ready to become tags. Unknown field names return an empty list.
|
||||||
|
|
||||||
|
### `TmdbError`
|
||||||
|
|
||||||
|
*class · line 65*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TmdbError(RuntimeError):
|
||||||
|
```
|
||||||
|
|
||||||
|
A TMDb request could not be completed (no key, network, bad status).
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `api_key`
|
||||||
|
|
||||||
|
*function · line 69*
|
||||||
|
|
||||||
|
```python
|
||||||
|
api_key() -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The configured TMDb API key, or None.
|
||||||
|
|
||||||
|
### `api_available`
|
||||||
|
|
||||||
|
*function · line 75*
|
||||||
|
|
||||||
|
```python
|
||||||
|
api_available() -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when a TMDb API key is configured.
|
||||||
|
|
||||||
|
### `_get`
|
||||||
|
|
||||||
|
*function · line 80*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_get(path: str, params: dict) -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
GET a TMDb API path with the configured key and language. Raises `TmdbError` on a missing key, a network failure or an error response.
|
||||||
|
|
||||||
|
### `_year_of`
|
||||||
|
|
||||||
|
*function · line 98*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_year_of(date_str: Optional[str]) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The four-digit year at the start of a TMDb date (`YYYY-MM-DD`), or `None`.
|
||||||
|
|
||||||
|
### `lookup_movie`
|
||||||
|
|
||||||
|
*function · line 104*
|
||||||
|
|
||||||
|
```python
|
||||||
|
lookup_movie(tmdb_id: int) -> Optional[TmdbResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve a movie by its TMDb id → title + year, or None if it has no title.
|
||||||
|
|
||||||
|
### `movie_details`
|
||||||
|
|
||||||
|
*function · line 113*
|
||||||
|
|
||||||
|
```python
|
||||||
|
movie_details(tmdb_id: int) -> TmdbDetails
|
||||||
|
```
|
||||||
|
|
||||||
|
Full movie data from TMDb (genres/countries/rating/year), localized to cs.
|
||||||
|
|
||||||
|
### `lookup_tv`
|
||||||
|
|
||||||
|
*function · line 127*
|
||||||
|
|
||||||
|
```python
|
||||||
|
lookup_tv(tmdb_id: int) -> Optional[TmdbResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve a series by its TMDb id → name + first-air year, or None.
|
||||||
|
|
||||||
|
### `lookup`
|
||||||
|
|
||||||
|
*function · line 136*
|
||||||
|
|
||||||
|
```python
|
||||||
|
lookup(tmdb_id: int, kind: str='movie') -> Optional[TmdbResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve a TMDb id as a movie (default) or a series (`kind="tv"`).
|
||||||
|
|
||||||
|
### `search_movie`
|
||||||
|
|
||||||
|
*function · line 141*
|
||||||
|
|
||||||
|
```python
|
||||||
|
search_movie(query: str, year: Optional[str]=None, limit: int=5) -> list[TmdbResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
Search movies by title (optionally narrowed by year); best matches first.
|
||||||
|
|
||||||
|
### `search_tv`
|
||||||
|
|
||||||
|
*function · line 161*
|
||||||
|
|
||||||
|
```python
|
||||||
|
search_tv(query: str, year: Optional[str]=None, limit: int=5) -> list[TmdbResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
Search series by name (optionally narrowed by first-air year).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# `src/core/transforms.py`
|
||||||
|
|
||||||
|
Tag-schema value transforms for miniDLNA folder names.
|
||||||
|
|
||||||
|
Applied when building a folder name from a tag value — the tag keeps its exact
|
||||||
|
value (e.g. rating 87 → tag "Hodnocení/87"), while the folder groups it
|
||||||
|
(→ "…/80–89 %"). Config-driven and extensible via `TAG_TRANSFORMS`.
|
||||||
|
|
||||||
|
Source: [`src/core/transforms.py`](../../src/core/transforms.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `rating_band`
|
||||||
|
|
||||||
|
*function · line 11*
|
||||||
|
|
||||||
|
```python
|
||||||
|
rating_band(rating: int) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Bucket a 0–100 rating into a ten-point band label (e.g. "80–89 %").
|
||||||
|
|
||||||
|
The top bucket spans 90–100 % so a perfect 100 still lands in a band.
|
||||||
|
|
||||||
|
### `_decade_band`
|
||||||
|
|
||||||
|
*function · line 21*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_decade_band(value) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
`decade_band` transform: pull the leading integer and bucket it.
|
||||||
|
|
||||||
|
### `apply_transform`
|
||||||
|
|
||||||
|
*function · line 35*
|
||||||
|
|
||||||
|
```python
|
||||||
|
apply_transform(value: str, transform: Optional[str]) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply a named tag-schema transform to a value, returning a string.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# `src/core/utils.py`
|
||||||
|
|
||||||
|
Small filesystem helpers shared across the core modules.
|
||||||
|
|
||||||
|
Source: [`src/core/utils.py`](../../src/core/utils.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `list_files`
|
||||||
|
|
||||||
|
*function · line 3*
|
||||||
|
|
||||||
|
```python
|
||||||
|
list_files(folder_path: str | Path) -> list[Path]
|
||||||
|
```
|
||||||
|
|
||||||
|
All regular files below `folder_path`, searched recursively. Raises `NotADirectoryError` when the path is not a folder.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# `Curator.py`
|
||||||
|
|
||||||
|
Application entry point. Creates the shared `FileManager` and `TagManager`, applies the OS certificate store for HTTPS, and starts the Qt main window.
|
||||||
|
|
||||||
|
Source: [`Curator.py`](../../Curator.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `State`
|
||||||
|
|
||||||
|
*class · line 21*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class State:
|
||||||
|
```
|
||||||
|
|
||||||
|
Holds the application's shared services: one `TagManager` and one `FileManager` built on top of it. Created once in `main()`.
|
||||||
|
|
||||||
|
#### `State.__init__`
|
||||||
|
|
||||||
|
*method · line 22*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the tag manager and the file manager that uses it.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `_use_system_certificates`
|
||||||
|
|
||||||
|
*function · line 7*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_use_system_certificates() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Make HTTPS (ČSFD fetching) use the OS certificate store.
|
||||||
|
|
||||||
|
Required behind corporate SSL inspection, where the proxy's root CA is in the
|
||||||
|
Windows trust store but not in certifi's bundle. Best-effort; ignored if the
|
||||||
|
optional `truststore` package is unavailable.
|
||||||
|
|
||||||
|
### `main`
|
||||||
|
|
||||||
|
*function · line 27*
|
||||||
|
|
||||||
|
```python
|
||||||
|
main() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Program entry point: installs the system certificate store, builds the `State` and runs the Qt application on its file manager and tag manager.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+49
@@ -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"
|
||||||
@@ -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
@@ -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"
|
|
||||||
+3
-2
@@ -1,7 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from src.constants import VERSION
|
from src.constants import VERSION
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -52,8 +54,7 @@ if spec_path.exists():
|
|||||||
|
|
||||||
if f"console={not console_mode}" in spec_content:
|
if f"console={not console_mode}" in spec_content:
|
||||||
new_spec_content = spec_content.replace(
|
new_spec_content = spec_content.replace(
|
||||||
f"console={not console_mode}",
|
f"console={not console_mode}", f"console={console_mode}"
|
||||||
f"console={console_mode}"
|
|
||||||
)
|
)
|
||||||
with open(spec_path, "w", encoding="utf-8") as f:
|
with open(spec_path, "w", encoding="utf-8") as f:
|
||||||
f.write(new_spec_content)
|
f.write(new_spec_content)
|
||||||
|
|||||||
+17
-26
@@ -1,35 +1,26 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "curator"
|
name = "curator"
|
||||||
version = "1.12.0"
|
version = "1.14.0"
|
||||||
description = ""
|
requires-python = ">=3.14"
|
||||||
authors = [
|
|
||||||
{name = "jan.doubravsky@gmail.com"}
|
|
||||||
]
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.14,<3.15"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pyside6 (>=6.11.1,<7.0.0)",
|
"beautifulsoup4>=4.15.0",
|
||||||
"requests (>=2.34.2,<3.0.0)",
|
"loguru>=0.7.3",
|
||||||
"beautifulsoup4 (>=4.15.0,<5.0.0)",
|
"pyside6>=6.12.0",
|
||||||
"python-dotenv (>=1.2.2,<2.0.0)",
|
"python-dotenv>=1.2.4",
|
||||||
"pillow (>=12.2.0,<13.0.0)",
|
"requests>=2.34.2",
|
||||||
"loguru (>=0.7.3,<0.8.0)",
|
"truststore>=0.10.4",
|
||||||
"truststore (>=0.10.4,<0.11.0)"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry]
|
[dependency-groups]
|
||||||
package-mode = false
|
dev = [
|
||||||
|
"mypy>=2.4.0",
|
||||||
|
"pyinstaller>=6.22.3",
|
||||||
|
"pytest>=9.1.1",
|
||||||
|
"ruff>=0.17.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[build-system]
|
[tool.mypy]
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
files = ["Curator.py", "prebuild.py", "src", "tests", "scripts"]
|
||||||
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)"
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ leaving the tag name untouched. A timestamped backup of the index is written
|
|||||||
before saving.
|
before saving.
|
||||||
|
|
||||||
Usage:
|
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"
|
--old "Země" --new "Země původu"
|
||||||
|
|
||||||
If ``<pool_dir>`` is omitted, the pool from the global config is used.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import argparse
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import argparse
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from src.core.config import load_global_config # noqa: E402
|
from src.core.config import load_global_config
|
||||||
from src.core.pool_index import INDEX_FILENAME # noqa: E402
|
from src.core.pool_index import INDEX_FILENAME
|
||||||
|
|
||||||
|
|
||||||
def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int]:
|
def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int]:
|
||||||
|
|||||||
@@ -7,27 +7,27 @@ Británie"``), de-duplicating within each record. A timestamped backup of the
|
|||||||
index is written before saving.
|
index is written before saving.
|
||||||
|
|
||||||
Usage:
|
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.
|
If ``<pool_dir>`` is omitted, the pool from the global config is used.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import argparse
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import argparse
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from src.core.config import load_global_config # noqa: E402
|
from src.core.config import load_global_config
|
||||||
from src.core.pool_index import INDEX_FILENAME # noqa: E402
|
from src.core.pool_index import INDEX_FILENAME
|
||||||
|
|
||||||
|
|
||||||
def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
|
def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
|
||||||
@@ -46,7 +46,11 @@ def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
|
|||||||
result.append(tag)
|
result.append(tag)
|
||||||
|
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
if isinstance(tag, str) and tag.startswith(prefix) and "/" in tag[len(prefix):]:
|
if (
|
||||||
|
isinstance(tag, str)
|
||||||
|
and tag.startswith(prefix)
|
||||||
|
and "/" in tag[len(prefix) :]
|
||||||
|
):
|
||||||
value = tag[len(prefix) :]
|
value = tag[len(prefix) :]
|
||||||
countries = [c.strip() for c in value.split("/") if c.strip()]
|
countries = [c.strip() for c in value.split("/") if c.strip()]
|
||||||
for country in countries:
|
for country in countries:
|
||||||
|
|||||||
@@ -7,26 +7,26 @@ This removes the legacy band tags (``Hodnocení/<x>–<y> %``) so re-fetching fr
|
|||||||
backup of the index is written first.
|
backup of the index is written first.
|
||||||
|
|
||||||
Usage:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from src.core.config import load_global_config # noqa: E402
|
from src.core.config import load_global_config
|
||||||
from src.core.pool_index import INDEX_FILENAME # noqa: E402
|
from src.core.pool_index import INDEX_FILENAME
|
||||||
|
|
||||||
# A band tag looks like "Hodnocení/90–100 %" — value has a dash range and a "%".
|
# 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*%")
|
_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."""
|
"""Return (kept tags, removed count), dropping ``category`` band tags."""
|
||||||
prefix = f"{category}/"
|
prefix = f"{category}/"
|
||||||
kept = [
|
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))
|
if not (isinstance(t, str) and t.startswith(prefix) and _BAND_RE.search(t))
|
||||||
]
|
]
|
||||||
return kept, len(tags) - len(kept)
|
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
|
# also drop them from the ČSFD provenance set, if present
|
||||||
if isinstance(record.get("csfd_tags"), list):
|
if isinstance(record.get("csfd_tags"), list):
|
||||||
record["csfd_tags"] = [
|
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))
|
if not (t.startswith(f"{category}/") and _BAND_RE.search(t))
|
||||||
]
|
]
|
||||||
total_removed += removed
|
total_removed += removed
|
||||||
|
|||||||
@@ -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.
|
``tags`` lists are pruned. A timestamped backup of the index is written first.
|
||||||
|
|
||||||
Usage:
|
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"
|
--categories "Režie" "Herec"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import argparse
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import argparse
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from src.core.config import load_global_config # noqa: E402
|
from src.core.config import load_global_config
|
||||||
from src.core.pool_index import INDEX_FILENAME # noqa: E402
|
from src.core.pool_index import INDEX_FILENAME
|
||||||
|
|
||||||
|
|
||||||
def _strip(tags: list[str], prefixes: tuple[str, ...]) -> tuple[list[str], int]:
|
def _strip(tags: list[str], prefixes: tuple[str, ...]) -> tuple[list[str], int]:
|
||||||
|
|||||||
Regular → Executable
+26
-13
@@ -16,11 +16,11 @@ Examples:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
INDEX_FILENAME = ".Curator.!index"
|
INDEX_FILENAME = ".Curator.!index"
|
||||||
@@ -34,7 +34,7 @@ def _load_config_paths(config_path: Path) -> tuple[Path | None, Path | None]:
|
|||||||
try:
|
try:
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
cfg = json.load(f)
|
cfg = json.load(f)
|
||||||
except (OSError, json.JSONDecodeError):
|
except OSError, json.JSONDecodeError:
|
||||||
return None, None
|
return None, None
|
||||||
pool = cfg.get("pool_dir")
|
pool = cfg.get("pool_dir")
|
||||||
out = cfg.get("filmoteka_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:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Vygeneruje složku Tipy dne.")
|
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("--output", type=Path, help="Výstupní složka Filmotéky")
|
||||||
parser.add_argument("--count", type=int, default=DEFAULT_COUNT,
|
parser.add_argument(
|
||||||
help=f"Počet náhodných titulů (výchozí {DEFAULT_COUNT})")
|
"--count",
|
||||||
parser.add_argument("--subfolder", default=DEFAULT_SUBFOLDER,
|
type=int,
|
||||||
help=f'Název složky tipů (výchozí "{DEFAULT_SUBFOLDER}")')
|
default=DEFAULT_COUNT,
|
||||||
parser.add_argument("--config", type=Path,
|
help=f"Počet náhodných titulů (výchozí {DEFAULT_COUNT})",
|
||||||
help="Cesta ke global configu (.Curator.!gtag) pro doplnění cest")
|
)
|
||||||
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
pool, output = args.pool, args.output
|
pool, output = args.pool, args.output
|
||||||
if pool is None or output is None:
|
if pool is None or output is None:
|
||||||
config_path = args.config or (
|
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)
|
cfg_pool, cfg_out = _load_config_paths(config_path)
|
||||||
pool = pool or cfg_pool
|
pool = pool or cfg_pool
|
||||||
output = output or cfg_out
|
output = output or cfg_out
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
"""Auto-generated — do not edit manually."""
|
"""Auto-generated — do not edit manually."""
|
||||||
__version__ = "1.12.0"
|
__version__ = "1.14.0"
|
||||||
|
|||||||
+2
-1
@@ -38,12 +38,13 @@ def get_version() -> str:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return version
|
return version
|
||||||
except (FileNotFoundError, KeyError):
|
except FileNotFoundError, KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 2. generated _version.py
|
# 2. generated _version.py
|
||||||
try:
|
try:
|
||||||
from src._version import __version__ # type: ignore[import]
|
from src._version import __version__ # type: ignore[import]
|
||||||
|
|
||||||
return __version__
|
return __version__
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+28
-8
@@ -11,6 +11,7 @@ Two stores, by responsibility:
|
|||||||
describing a particular library: per-movie metadata **and** library-level
|
describing a particular library: per-movie metadata **and** library-level
|
||||||
settings (``tag_schema``, ``copyasis_folders``). It travels with the pool.
|
settings (``tag_schema``, ``copyasis_folders``). It travels with the pool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -40,13 +41,31 @@ GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
|
|||||||
# folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root.
|
# folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root.
|
||||||
# This lives in the code as the default; the effective schema is stored per-pool
|
# This lives in the code as the default; the effective schema is stored per-pool
|
||||||
# in the index (``PoolIndex.settings["tag_schema"]``).
|
# in the index (``PoolIndex.settings["tag_schema"]``).
|
||||||
DEFAULT_TAG_SCHEMA = [
|
DEFAULT_TAG_SCHEMA: list[dict] = [
|
||||||
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
|
{
|
||||||
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "- Dle roku"},
|
"category": "Žánr",
|
||||||
{"category": "Země původu", "csfd_field": "countries", "transform": None,
|
"csfd_field": "genres",
|
||||||
"filmoteka_root": "- Dle země původu"},
|
"transform": None,
|
||||||
{"category": "Hodnocení", "csfd_field": "rating", "transform": "decade_band",
|
"filmoteka_root": "",
|
||||||
"filmoteka_root": "- Dle hodnocení"},
|
},
|
||||||
|
{
|
||||||
|
"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í",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -76,7 +95,7 @@ def load_global_config() -> dict:
|
|||||||
if key not in config:
|
if key not in config:
|
||||||
config[key] = value
|
config[key] = value
|
||||||
return config
|
return config
|
||||||
except Exception:
|
except OSError, ValueError, TypeError:
|
||||||
return DEFAULT_GLOBAL_CONFIG.copy()
|
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||||
return DEFAULT_GLOBAL_CONFIG.copy()
|
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||||
|
|
||||||
@@ -91,6 +110,7 @@ def save_global_config(cfg: dict):
|
|||||||
# BACKWARDS COMPATIBILITY
|
# BACKWARDS COMPATIBILITY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
"""Legacy function - returns global config"""
|
"""Legacy function - returns global config"""
|
||||||
return load_global_config()
|
return load_global_config()
|
||||||
|
|||||||
@@ -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
|
||||||
+171
-21
@@ -1,4 +1,6 @@
|
|||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .tag import Tag
|
from .tag import Tag
|
||||||
|
|
||||||
|
|
||||||
@@ -33,15 +35,80 @@ class File:
|
|||||||
# free-form per-movie attributes (e.g. "collection_sort"); usable in
|
# free-form per-movie attributes (e.g. "collection_sort"); usable in
|
||||||
# miniDLNA filename templates via name_context.
|
# miniDLNA filename templates via name_context.
|
||||||
self.attributes: dict[str, str] = {}
|
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()
|
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:
|
def get_metadata(self) -> None:
|
||||||
record = self.index.get(self.file_path) if self.index is not None else None
|
"""Load this file's metadata from the movie record that owns it.
|
||||||
if record is None:
|
|
||||||
|
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._init_new_metadata()
|
||||||
self.save_metadata()
|
return
|
||||||
else:
|
|
||||||
|
found = self.index.find_movie_by_media(self.file_path)
|
||||||
|
if found is not None:
|
||||||
|
self._movie_key, record = found
|
||||||
self._apply_record(record)
|
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:
|
def _init_new_metadata(self) -> None:
|
||||||
self.new = True
|
self.new = True
|
||||||
@@ -60,7 +127,9 @@ class File:
|
|||||||
"new": self.new,
|
"new": self.new,
|
||||||
"ignored": self.ignored,
|
"ignored": self.ignored,
|
||||||
# ukládáme full_path tagů
|
# 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)
|
# which of the tags were derived automatically (from the NFO)
|
||||||
"auto_tags": sorted(self.auto_tag_paths),
|
"auto_tags": sorted(self.auto_tag_paths),
|
||||||
# date může být None
|
# date může být None
|
||||||
@@ -88,7 +157,9 @@ class File:
|
|||||||
raw_meta = data.get("meta")
|
raw_meta = data.get("meta")
|
||||||
if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
|
if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
|
||||||
old = data.get("csfd_cache") or {}
|
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
|
self.meta = dict(raw_meta) if raw_meta else None
|
||||||
|
|
||||||
if not self.tagmanager:
|
if not self.tagmanager:
|
||||||
@@ -101,9 +172,42 @@ class File:
|
|||||||
self.tags.append(tag)
|
self.tags.append(tag)
|
||||||
|
|
||||||
def save_metadata(self):
|
def save_metadata(self):
|
||||||
"""Persist metadata to the pool index; a no-op for an index-less File."""
|
"""Persist metadata into this file's movie record (no-op without index).
|
||||||
if self.index is not None:
|
|
||||||
self.index.set(self.file_path, self._build_record())
|
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:
|
def added_timestamp(self) -> float:
|
||||||
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
|
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
|
||||||
@@ -116,6 +220,7 @@ class File:
|
|||||||
if self.added:
|
if self.added:
|
||||||
try:
|
try:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
return datetime.fromisoformat(self.added).timestamp()
|
return datetime.fromisoformat(self.added).timestamp()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
@@ -141,20 +246,23 @@ class File:
|
|||||||
if year is None:
|
if year is None:
|
||||||
# Last resort: recover it from a canonical "Title (YYYY)" filename.
|
# Last resort: recover it from a canonical "Title (YYYY)" filename.
|
||||||
from .naming import parse_pool_stem
|
from .naming import parse_pool_stem
|
||||||
|
|
||||||
parsed = parse_pool_stem(self.file_path.stem)
|
parsed = parse_pool_stem(self.file_path.stem)
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
year = parsed[1]
|
year = parsed[1]
|
||||||
rating = cache.get("rating")
|
rating = cache.get("rating")
|
||||||
# user attributes first; core fields take precedence over same-named keys
|
# user attributes first; core fields take precedence over same-named keys
|
||||||
context = dict(self.attributes)
|
context = dict(self.attributes)
|
||||||
context.update({
|
context.update(
|
||||||
|
{
|
||||||
"title": self.title or self.file_path.stem,
|
"title": self.title or self.file_path.stem,
|
||||||
"year": "" if year is None else str(year),
|
"year": "" if year is None else str(year),
|
||||||
"rating": "" if rating is None else rating,
|
"rating": "" if rating is None else rating,
|
||||||
"ext": self.file_path.suffix,
|
"ext": self.file_path.suffix,
|
||||||
"stem": self.file_path.stem,
|
"stem": self.file_path.stem,
|
||||||
"filename": self.filename,
|
"filename": self.filename,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def set_attribute(self, key: str, value: str | None) -> None:
|
def set_attribute(self, key: str, value: str | None) -> None:
|
||||||
@@ -168,20 +276,57 @@ class File:
|
|||||||
self.attributes.pop(key, None)
|
self.attributes.pop(key, None)
|
||||||
self.save_metadata()
|
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:
|
def delete_metadata(self) -> None:
|
||||||
"""Remove this file's metadata from the pool index (no-op without one)."""
|
"""Drop this file from its movie record; the last one takes the record.
|
||||||
if self.index is not None:
|
|
||||||
self.index.delete(self.file_path)
|
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:
|
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.
|
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;
|
this only repoints the movie record's media entry (and the File), so a
|
||||||
call ``save_metadata()`` afterwards to write it back.
|
rename never detaches a file from its movie.
|
||||||
"""
|
"""
|
||||||
if self.index is not None:
|
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.file_path = Path(new_path)
|
||||||
self.filename = self.file_path.name
|
self.filename = self.file_path.name
|
||||||
|
|
||||||
@@ -210,6 +355,7 @@ class File:
|
|||||||
|
|
||||||
if schema is None:
|
if schema is None:
|
||||||
from .config import DEFAULT_TAG_SCHEMA
|
from .config import DEFAULT_TAG_SCHEMA
|
||||||
|
|
||||||
schema = DEFAULT_TAG_SCHEMA
|
schema = DEFAULT_TAG_SCHEMA
|
||||||
|
|
||||||
self.meta = {"year": nfo.year, "rating": nfo.rating}
|
self.meta = {"year": nfo.year, "rating": nfo.rating}
|
||||||
@@ -225,8 +371,11 @@ class File:
|
|||||||
continue # user-only category
|
continue # user-only category
|
||||||
category = entry["category"]
|
category = entry["category"]
|
||||||
for value in nfo.field_values(field):
|
for value in nfo.field_values(field):
|
||||||
tag_obj = (self.tagmanager.add_tag(category, value)
|
tag_obj = (
|
||||||
if self.tagmanager else Tag(category, value))
|
self.tagmanager.add_tag(category, value)
|
||||||
|
if self.tagmanager
|
||||||
|
else Tag(category, value)
|
||||||
|
)
|
||||||
if tag_obj not in self.tags:
|
if tag_obj not in self.tags:
|
||||||
self.tags.append(tag_obj)
|
self.tags.append(tag_obj)
|
||||||
tags_added.append(tag_obj.full_path)
|
tags_added.append(tag_obj.full_path)
|
||||||
@@ -241,6 +390,7 @@ class File:
|
|||||||
def add_tag(self, tag):
|
def add_tag(self, tag):
|
||||||
# tag může být Tag nebo string
|
# tag může být Tag nebo string
|
||||||
from .tag import Tag as TagClass
|
from .tag import Tag as TagClass
|
||||||
|
|
||||||
if isinstance(tag, str):
|
if isinstance(tag, str):
|
||||||
if "/" in tag and self.tagmanager:
|
if "/" in tag and self.tagmanager:
|
||||||
category, name = tag.split("/", 1)
|
category, name = tag.split("/", 1)
|
||||||
|
|||||||
+1500
-111
File diff suppressed because it is too large
Load Diff
+149
-95
@@ -17,11 +17,13 @@ Example:
|
|||||||
└── 1988/
|
└── 1988/
|
||||||
└── film.mkv (hardlink)
|
└── film.mkv (hardlink)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import shutil
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple, Optional, Dict, Set
|
|
||||||
from .file import File
|
from .file import File
|
||||||
from .naming import is_valid_year, parse_pool_stem
|
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):
|
class _SafeDict(dict):
|
||||||
"""dict for str.format_map that leaves unknown fields as an empty string."""
|
"""dict for str.format_map that leaves unknown fields as an empty string."""
|
||||||
|
|
||||||
def __missing__(self, key): # noqa: ANN001
|
def __missing__(self, key):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -53,14 +55,14 @@ class HardlinkManager:
|
|||||||
output_dir: Base directory where the tag-based structure will be created
|
output_dir: Base directory where the tag-based structure will be created
|
||||||
"""
|
"""
|
||||||
self.output_dir = Path(output_dir)
|
self.output_dir = Path(output_dir)
|
||||||
self.created_links: List[Path] = []
|
self.created_links: list[Path] = []
|
||||||
self.errors: List[Tuple[Path, str]] = []
|
self.errors: list[tuple[Path, str]] = []
|
||||||
|
|
||||||
def _resolve_roots(
|
def _resolve_roots(
|
||||||
self,
|
self,
|
||||||
categories: Optional[List[str]],
|
categories: list[str] | None,
|
||||||
category_roots: Optional[Dict[str, str]],
|
category_roots: dict[str, str] | None,
|
||||||
) -> Optional[Dict[str, str]]:
|
) -> dict[str, str] | None:
|
||||||
"""Normalize the two filter styles into a category → root-folder map.
|
"""Normalize the two filter styles into a category → root-folder map.
|
||||||
|
|
||||||
``None`` means "all categories", folder == category name.
|
``None`` means "all categories", folder == category name.
|
||||||
@@ -85,7 +87,7 @@ class HardlinkManager:
|
|||||||
"""Filmotéka hardlink name: the clean title + extension (no year)."""
|
"""Filmotéka hardlink name: the clean title + extension (no year)."""
|
||||||
return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}"
|
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."""
|
"""``Title (YYYY).ext`` used to disambiguate a name collision, or None."""
|
||||||
year = str(file_obj.name_context().get("year") or "").strip()
|
year = str(file_obj.name_context().get("year") or "").strip()
|
||||||
if not is_valid_year(year):
|
if not is_valid_year(year):
|
||||||
@@ -96,18 +98,18 @@ class HardlinkManager:
|
|||||||
"""Render a per-category filename template; fall back to the clean name."""
|
"""Render a per-category filename template; fall back to the clean name."""
|
||||||
try:
|
try:
|
||||||
rendered = template.format_map(_SafeDict(file_obj.name_context()))
|
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)
|
return self._default_link_name(file_obj)
|
||||||
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
|
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
|
||||||
return rendered or self._default_link_name(file_obj)
|
return rendered or self._default_link_name(file_obj)
|
||||||
|
|
||||||
def _plan_links(
|
def _plan_links(
|
||||||
self,
|
self,
|
||||||
files: List[File],
|
files: list[File],
|
||||||
roots: Optional[Dict[str, str]],
|
roots: dict[str, str] | None,
|
||||||
transforms: Optional[Dict[str, str]],
|
transforms: dict[str, str] | None,
|
||||||
templates: Optional[Dict[str, str]],
|
templates: dict[str, str] | None,
|
||||||
) -> List[Tuple[File, Path]]:
|
) -> list[tuple[File, Path]]:
|
||||||
"""Resolve the tag tree to a collision-free ``(file, link_path)`` plan.
|
"""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
|
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).
|
can never disagree on a name (which is what caused hardlink churn before).
|
||||||
"""
|
"""
|
||||||
# (file_obj, target_dir, name, is_templated), one per file+folder
|
# (file_obj, target_dir, name, is_templated), one per file+folder
|
||||||
entries: List[List] = []
|
entries: list[list] = []
|
||||||
seen_fd: set[tuple[str, str]] = set()
|
seen_fd: set[tuple[str, str]] = set()
|
||||||
for file_obj in files:
|
for file_obj in files:
|
||||||
for tag in file_obj.tags:
|
for tag in file_obj.tags:
|
||||||
@@ -135,10 +137,17 @@ class HardlinkManager:
|
|||||||
template = templates.get(tag.category) if templates else None
|
template = templates.get(tag.category) if templates else None
|
||||||
if template:
|
if template:
|
||||||
entries.append(
|
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:
|
else:
|
||||||
entries.append(
|
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.
|
# Disambiguate default-named collisions (different movies) by their year.
|
||||||
groups: dict[tuple[Path, str], set[str]] = defaultdict(set)
|
groups: dict[tuple[Path, str], set[str]] = defaultdict(set)
|
||||||
@@ -153,10 +162,10 @@ class HardlinkManager:
|
|||||||
entry[2] = year_name
|
entry[2] = year_name
|
||||||
|
|
||||||
# Final pass: guarantee unique paths (stable numeric suffix on any tie).
|
# 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:
|
for entry in entries:
|
||||||
by_path[(entry[1], entry[2])].append(entry)
|
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():
|
for (target_dir, name), group in by_path.items():
|
||||||
if len(group) == 1:
|
if len(group) == 1:
|
||||||
plan.append((group[0][0], target_dir / name))
|
plan.append((group[0][0], target_dir / name))
|
||||||
@@ -168,7 +177,7 @@ class HardlinkManager:
|
|||||||
plan.append((entry[0], target_dir / fname))
|
plan.append((entry[0], target_dir / fname))
|
||||||
return plan
|
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.
|
"""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
|
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:
|
if transforms and tag.category in transforms:
|
||||||
from .transforms import apply_transform
|
from .transforms import apply_transform
|
||||||
|
|
||||||
return apply_transform(tag.name, transforms[tag.category])
|
return apply_transform(tag.name, transforms[tag.category])
|
||||||
return tag.name
|
return tag.name
|
||||||
|
|
||||||
def _target_dir(
|
def _target_dir(
|
||||||
self, tag, roots: Optional[Dict[str, str]],
|
self,
|
||||||
transforms: Optional[Dict[str, str]] = None,
|
tag,
|
||||||
) -> Optional[Path]:
|
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."""
|
"""Output directory for a tag, or None if its category is excluded."""
|
||||||
if roots is None:
|
if roots is None:
|
||||||
folder = tag.category
|
folder = tag.category
|
||||||
@@ -194,10 +206,12 @@ class HardlinkManager:
|
|||||||
return base / self._folder_value(tag, transforms)
|
return base / self._folder_value(tag, transforms)
|
||||||
|
|
||||||
def _managed_top_dirs(
|
def _managed_top_dirs(
|
||||||
self, files: List[File], roots: Optional[Dict[str, str]],
|
self,
|
||||||
transforms: Optional[Dict[str, str]] = None,
|
files: list[File],
|
||||||
reserved_subfolders: Optional[Set[str]] = None,
|
roots: dict[str, str] | None,
|
||||||
) -> Optional[Set[str]]:
|
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).
|
"""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
|
A category with a non-empty root owns that root folder. A category placed
|
||||||
@@ -212,7 +226,7 @@ class HardlinkManager:
|
|||||||
return None
|
return None
|
||||||
grouping_roots = {folder for folder in roots.values() if folder}
|
grouping_roots = {folder for folder in roots.values() if folder}
|
||||||
has_root_level = any(not folder for folder in roots.values())
|
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
|
# genre folders currently present on the movies
|
||||||
for cat, folder in roots.items():
|
for cat, folder in roots.items():
|
||||||
@@ -227,20 +241,23 @@ class HardlinkManager:
|
|||||||
if has_root_level and self.output_dir.exists():
|
if has_root_level and self.output_dir.exists():
|
||||||
reserved = set(reserved_subfolders or set())
|
reserved = set(reserved_subfolders or set())
|
||||||
for entry in self.output_dir.iterdir():
|
for entry in self.output_dir.iterdir():
|
||||||
if (entry.is_dir() and entry.name not in grouping_roots
|
if (
|
||||||
and entry.name not in reserved):
|
entry.is_dir()
|
||||||
|
and entry.name not in grouping_roots
|
||||||
|
and entry.name not in reserved
|
||||||
|
):
|
||||||
tops.add(entry.name)
|
tops.add(entry.name)
|
||||||
return tops
|
return tops
|
||||||
|
|
||||||
def create_structure_for_files(
|
def create_structure_for_files(
|
||||||
self,
|
self,
|
||||||
files: List[File],
|
files: list[File],
|
||||||
categories: Optional[List[str]] = None,
|
categories: list[str] | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
category_roots: Optional[Dict[str, str]] = None,
|
category_roots: dict[str, str] | None = None,
|
||||||
category_transforms: Optional[Dict[str, str]] = None,
|
category_transforms: dict[str, str] | None = None,
|
||||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
category_filename_templates: dict[str, str] | None = None,
|
||||||
) -> Tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
Create hardlink structure for given files based on their tags.
|
Create hardlink structure for given files based on their tags.
|
||||||
|
|
||||||
@@ -266,7 +283,8 @@ class HardlinkManager:
|
|||||||
fail_count = 0
|
fail_count = 0
|
||||||
|
|
||||||
plan = self._plan_links(
|
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:
|
for file_obj, target_file in plan:
|
||||||
try:
|
try:
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
@@ -291,11 +309,8 @@ class HardlinkManager:
|
|||||||
return success_count, fail_count
|
return success_count, fail_count
|
||||||
|
|
||||||
def mirror_as_is(
|
def mirror_as_is(
|
||||||
self,
|
self, source_dir: Path, subfolder: str | None = None, dry_run: bool = False
|
||||||
source_dir: Path,
|
) -> tuple[int, int]:
|
||||||
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.
|
"""Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.
|
||||||
|
|
||||||
Recreates the exact directory hierarchy of ``source_dir`` under
|
Recreates the exact directory hierarchy of ``source_dir`` under
|
||||||
@@ -341,9 +356,29 @@ class HardlinkManager:
|
|||||||
|
|
||||||
return success_count, fail_count
|
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(
|
def _fill_special_folder(
|
||||||
self, chosen: List[File], subfolder: str, dry_run: bool
|
self, chosen: list[File], subfolder: str, dry_run: bool
|
||||||
) -> Tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""Empty ``subfolder`` and hardlink ``chosen`` files into it."""
|
"""Empty ``subfolder`` and hardlink ``chosen`` files into it."""
|
||||||
base = self.output_dir / subfolder
|
base = self.output_dir / subfolder
|
||||||
|
|
||||||
@@ -383,11 +418,11 @@ class HardlinkManager:
|
|||||||
|
|
||||||
def generate_recently_added(
|
def generate_recently_added(
|
||||||
self,
|
self,
|
||||||
files: List[File],
|
files: list[File],
|
||||||
count: int = 10,
|
count: int = 10,
|
||||||
subfolder: str = "- Nově přidané",
|
subfolder: str = "- Nově přidané",
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
) -> Tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""(Re)fill ``subfolder`` with the ``count`` most recently added movies.
|
"""(Re)fill ``subfolder`` with the ``count`` most recently added movies.
|
||||||
|
|
||||||
Ordering is by ``File.added_timestamp()`` (import time, ctime fallback),
|
Ordering is by ``File.added_timestamp()`` (import time, ctime fallback),
|
||||||
@@ -398,11 +433,11 @@ class HardlinkManager:
|
|||||||
|
|
||||||
def generate_random_tips(
|
def generate_random_tips(
|
||||||
self,
|
self,
|
||||||
files: List[File],
|
files: list[File],
|
||||||
count: int = 10,
|
count: int = 10,
|
||||||
subfolder: str = "Tipy dne",
|
subfolder: str = "Tipy dne",
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
) -> Tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""(Re)fill ``subfolder`` with ``count`` random files as hardlinks.
|
"""(Re)fill ``subfolder`` with ``count`` random files as hardlinks.
|
||||||
|
|
||||||
The folder is emptied first so each run picks a fresh random selection.
|
The folder is emptied first so each run picks a fresh random selection.
|
||||||
@@ -470,12 +505,12 @@ class HardlinkManager:
|
|||||||
|
|
||||||
def get_preview(
|
def get_preview(
|
||||||
self,
|
self,
|
||||||
files: List[File],
|
files: list[File],
|
||||||
categories: Optional[List[str]] = None,
|
categories: list[str] | None = None,
|
||||||
category_roots: Optional[Dict[str, str]] = None,
|
category_roots: dict[str, str] | None = None,
|
||||||
category_transforms: Optional[Dict[str, str]] = None,
|
category_transforms: dict[str, str] | None = None,
|
||||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
category_filename_templates: dict[str, str] | None = None,
|
||||||
) -> List[Tuple[Path, Path]]:
|
) -> list[tuple[Path, Path]]:
|
||||||
"""
|
"""
|
||||||
Get a preview of what links would be created.
|
Get a preview of what links would be created.
|
||||||
|
|
||||||
@@ -492,18 +527,19 @@ class HardlinkManager:
|
|||||||
"""
|
"""
|
||||||
roots = self._resolve_roots(categories, category_roots)
|
roots = self._resolve_roots(categories, category_roots)
|
||||||
plan = self._plan_links(
|
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]
|
return [(file_obj.file_path, target_file) for file_obj, target_file in plan]
|
||||||
|
|
||||||
def find_obsolete_links(
|
def find_obsolete_links(
|
||||||
self,
|
self,
|
||||||
files: List[File],
|
files: list[File],
|
||||||
categories: Optional[List[str]] = None,
|
categories: list[str] | None = None,
|
||||||
category_roots: Optional[Dict[str, str]] = None,
|
category_roots: dict[str, str] | None = None,
|
||||||
category_transforms: Optional[Dict[str, str]] = None,
|
category_transforms: dict[str, str] | None = None,
|
||||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
category_filename_templates: dict[str, str] | None = None,
|
||||||
reserved_subfolders: Optional[Set[str]] = None,
|
reserved_subfolders: set[str] | None = None,
|
||||||
) -> List[Tuple[Path, Path]]:
|
) -> list[tuple[Path, Path]]:
|
||||||
"""
|
"""
|
||||||
Find hardlinks in the output directory that no longer match file tags.
|
Find hardlinks in the output directory that no longer match file tags.
|
||||||
|
|
||||||
@@ -522,7 +558,7 @@ class HardlinkManager:
|
|||||||
Returns:
|
Returns:
|
||||||
List of tuples (link_path, source_path) for obsolete links
|
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():
|
if not self.output_dir.exists():
|
||||||
return obsolete
|
return obsolete
|
||||||
@@ -559,7 +595,8 @@ class HardlinkManager:
|
|||||||
# a tag was removed, the movie was replaced (orphan inode), or the folder
|
# a tag was removed, the movie was replaced (orphan inode), or the folder
|
||||||
# itself is a leftover from a renamed layout.
|
# itself is a leftover from a renamed layout.
|
||||||
top_dirs = self._managed_top_dirs(
|
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():
|
for top in self.output_dir.iterdir():
|
||||||
if not top.is_dir():
|
if not top.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -578,22 +615,20 @@ class HardlinkManager:
|
|||||||
if link_inode in expected.get(link_file, ()):
|
if link_inode in expected.get(link_file, ()):
|
||||||
continue
|
continue
|
||||||
source = inode_to_file.get(link_inode)
|
source = inode_to_file.get(link_inode)
|
||||||
obsolete.append(
|
obsolete.append((link_file, source.file_path if source else link_file))
|
||||||
(link_file, source.file_path if source else link_file)
|
|
||||||
)
|
|
||||||
|
|
||||||
return obsolete
|
return obsolete
|
||||||
|
|
||||||
def remove_obsolete_links(
|
def remove_obsolete_links(
|
||||||
self,
|
self,
|
||||||
files: List[File],
|
files: list[File],
|
||||||
categories: Optional[List[str]] = None,
|
categories: list[str] | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
category_roots: Optional[Dict[str, str]] = None,
|
category_roots: dict[str, str] | None = None,
|
||||||
category_transforms: Optional[Dict[str, str]] = None,
|
category_transforms: dict[str, str] | None = None,
|
||||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
category_filename_templates: dict[str, str] | None = None,
|
||||||
reserved_subfolders: Optional[Set[str]] = None,
|
reserved_subfolders: set[str] | None = None,
|
||||||
) -> Tuple[int, List[Path]]:
|
) -> tuple[int, list[Path]]:
|
||||||
"""
|
"""
|
||||||
Remove hardlinks that no longer match file tags.
|
Remove hardlinks that no longer match file tags.
|
||||||
|
|
||||||
@@ -612,8 +647,13 @@ class HardlinkManager:
|
|||||||
Tuple of (removed_count, list_of_removed_paths)
|
Tuple of (removed_count, list_of_removed_paths)
|
||||||
"""
|
"""
|
||||||
obsolete = self.find_obsolete_links(
|
obsolete = self.find_obsolete_links(
|
||||||
files, categories, category_roots, category_transforms,
|
files,
|
||||||
category_filename_templates, reserved_subfolders)
|
categories,
|
||||||
|
category_roots,
|
||||||
|
category_transforms,
|
||||||
|
category_filename_templates,
|
||||||
|
reserved_subfolders,
|
||||||
|
)
|
||||||
removed_paths = []
|
removed_paths = []
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -633,14 +673,14 @@ class HardlinkManager:
|
|||||||
|
|
||||||
def sync_structure(
|
def sync_structure(
|
||||||
self,
|
self,
|
||||||
files: List[File],
|
files: list[File],
|
||||||
categories: Optional[List[str]] = None,
|
categories: list[str] | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
category_roots: Optional[Dict[str, str]] = None,
|
category_roots: dict[str, str] | None = None,
|
||||||
category_transforms: Optional[Dict[str, str]] = None,
|
category_transforms: dict[str, str] | None = None,
|
||||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
category_filename_templates: dict[str, str] | None = None,
|
||||||
reserved_subfolders: Optional[Set[str]] = None,
|
reserved_subfolders: set[str] | None = None,
|
||||||
) -> Tuple[int, int, int, int]:
|
) -> tuple[int, int, int, int]:
|
||||||
"""
|
"""
|
||||||
Synchronize hardlink structure with current file tags.
|
Synchronize hardlink structure with current file tags.
|
||||||
|
|
||||||
@@ -663,31 +703,45 @@ class HardlinkManager:
|
|||||||
Tuple of (created, create_failed, removed, remove_failed)
|
Tuple of (created, create_failed, removed, remove_failed)
|
||||||
"""
|
"""
|
||||||
# First find how many obsolete links there are
|
# First find how many obsolete links there are
|
||||||
obsolete_count = len(self.find_obsolete_links(
|
obsolete_count = len(
|
||||||
files, categories, category_roots, category_transforms,
|
self.find_obsolete_links(
|
||||||
category_filename_templates, reserved_subfolders))
|
files,
|
||||||
|
categories,
|
||||||
|
category_roots,
|
||||||
|
category_transforms,
|
||||||
|
category_filename_templates,
|
||||||
|
reserved_subfolders,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Remove obsolete links
|
# Remove obsolete links
|
||||||
removed, removed_paths = self.remove_obsolete_links(
|
removed, _removed_paths = self.remove_obsolete_links(
|
||||||
files, categories, dry_run, category_roots, category_transforms,
|
files,
|
||||||
category_filename_templates, reserved_subfolders
|
categories,
|
||||||
|
dry_run,
|
||||||
|
category_roots,
|
||||||
|
category_transforms,
|
||||||
|
category_filename_templates,
|
||||||
|
reserved_subfolders,
|
||||||
)
|
)
|
||||||
remove_failed = obsolete_count - removed if not dry_run else 0
|
remove_failed = obsolete_count - removed if not dry_run else 0
|
||||||
|
|
||||||
# Then create new links
|
# Then create new links
|
||||||
created, create_failed = self.create_structure_for_files(
|
created, create_failed = self.create_structure_for_files(
|
||||||
files, categories, dry_run, category_roots, category_transforms,
|
files,
|
||||||
category_filename_templates
|
categories,
|
||||||
|
dry_run,
|
||||||
|
category_roots,
|
||||||
|
category_transforms,
|
||||||
|
category_filename_templates,
|
||||||
)
|
)
|
||||||
|
|
||||||
return created, create_failed, removed, remove_failed
|
return created, create_failed, removed, remove_failed
|
||||||
|
|
||||||
|
|
||||||
def create_hardlink_structure(
|
def create_hardlink_structure(
|
||||||
files: List[File],
|
files: list[File], output_dir: Path, categories: list[str] | None = None
|
||||||
output_dir: Path,
|
) -> tuple[int, int, list[tuple[Path, str]]]:
|
||||||
categories: Optional[List[str]] = None
|
|
||||||
) -> Tuple[int, int, List[Tuple[Path, str]]]:
|
|
||||||
"""
|
"""
|
||||||
Convenience function to create hardlink structure.
|
Convenience function to create hardlink structure.
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -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
|
Both tools ship with FFmpeg; ``ffmpeg_available`` reports whether they are on the
|
||||||
PATH so callers can degrade gracefully.
|
PATH so callers can degrade gracefully.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
FFMPEG = "ffmpeg"
|
FFMPEG = "ffmpeg"
|
||||||
FFPROBE = "ffprobe"
|
FFPROBE = "ffprobe"
|
||||||
@@ -48,7 +48,7 @@ def ffmpeg_available(deep: bool = True) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def check_video_integrity(
|
def check_video_integrity(
|
||||||
path: Path, deep: bool = True, timeout: Optional[int] = None
|
path: Path, deep: bool = True, timeout: int | None = None
|
||||||
) -> IntegrityResult:
|
) -> IntegrityResult:
|
||||||
"""Check a single video file for corruption.
|
"""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", "-"]
|
cmd = [FFMPEG, "-v", "error", "-xerror", "-i", str(path), "-f", "null", "-"]
|
||||||
else:
|
else:
|
||||||
cmd = [
|
cmd = [
|
||||||
FFPROBE, "-v", "error", "-select_streams", "v:0",
|
FFPROBE,
|
||||||
"-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path),
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=codec_type",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0",
|
||||||
|
str(path),
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
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:
|
except subprocess.TimeoutExpired:
|
||||||
return IntegrityResult(path, ok=False, error="Kontrola vypršela (timeout)")
|
return IntegrityResult(path, ok=False, error="Kontrola vypršela (timeout)")
|
||||||
|
|||||||
+31
-18
@@ -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
|
details come from ``.env`` — ``JELLYFIN_URL`` and ``JELLYFIN_API_KEY`` (created in
|
||||||
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
|
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import requests
|
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
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
_TIMEOUT = 30
|
_TIMEOUT = 30
|
||||||
|
|
||||||
@@ -27,22 +29,22 @@ class JellyfinMovie:
|
|||||||
|
|
||||||
jellyfin_id: str
|
jellyfin_id: str
|
||||||
title: str
|
title: str
|
||||||
year: Optional[int]
|
year: int | None
|
||||||
tmdb_id: Optional[int]
|
tmdb_id: int | None
|
||||||
path: Optional[str]
|
path: str | None
|
||||||
|
|
||||||
|
|
||||||
class JellyfinError(RuntimeError):
|
class JellyfinError(RuntimeError):
|
||||||
"""A Jellyfin request could not be completed (no config, network, status)."""
|
"""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."""
|
"""The configured Jellyfin base URL (no trailing slash), or None."""
|
||||||
url = os.getenv("JELLYFIN_URL")
|
url = os.getenv("JELLYFIN_URL")
|
||||||
return url.strip().rstrip("/") if url and url.strip() else None
|
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."""
|
"""The configured Jellyfin API key, or None."""
|
||||||
key = os.getenv("JELLYFIN_API_KEY")
|
key = os.getenv("JELLYFIN_API_KEY")
|
||||||
return key.strip() if key and key.strip() else None
|
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())
|
return bool(base_url() and api_key())
|
||||||
|
|
||||||
|
|
||||||
def _request(method: str, path: str, params: Optional[dict] = None,
|
def _request(
|
||||||
timeout: Optional[int] = None) -> requests.Response:
|
method: str, path: str, params: dict | None = None, timeout: int | None = None
|
||||||
|
) -> requests.Response:
|
||||||
url, key = base_url(), api_key()
|
url, key = base_url(), api_key()
|
||||||
if not (url and key):
|
if not (url and key):
|
||||||
raise JellyfinError("JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).")
|
raise JellyfinError("JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).")
|
||||||
try:
|
try:
|
||||||
resp = requests.request(
|
resp = requests.request(
|
||||||
method, f"{url}{path}", headers={"X-Emby-Token": key},
|
method,
|
||||||
params=params, timeout=timeout or _TIMEOUT)
|
f"{url}{path}",
|
||||||
|
headers={"X-Emby-Token": key},
|
||||||
|
params=params,
|
||||||
|
timeout=timeout or _TIMEOUT,
|
||||||
|
)
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise JellyfinError(f"Jellyfin nedostupný: {exc}") from exc
|
raise JellyfinError(f"Jellyfin nedostupný: {exc}") from exc
|
||||||
if resp.status_code in (401, 403):
|
if resp.status_code in (401, 403):
|
||||||
@@ -71,7 +78,7 @@ def _request(method: str, path: str, params: Optional[dict] = None,
|
|||||||
return resp
|
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)."""
|
"""Return the Jellyfin server version (a quick connectivity check)."""
|
||||||
data = _request("GET", "/System/Info", timeout=timeout).json()
|
data = _request("GET", "/System/Info", timeout=timeout).json()
|
||||||
return str(data.get("Version") or "?")
|
return str(data.get("Version") or "?")
|
||||||
@@ -84,22 +91,28 @@ def scan_all() -> None:
|
|||||||
|
|
||||||
def fetch_movies() -> list[JellyfinMovie]:
|
def fetch_movies() -> list[JellyfinMovie]:
|
||||||
"""Read every movie Jellyfin currently has, with its TMDb id and path."""
|
"""Read every movie Jellyfin currently has, with its TMDb id and path."""
|
||||||
data = _request("GET", "/Items", params={
|
data = _request(
|
||||||
|
"GET",
|
||||||
|
"/Items",
|
||||||
|
params={
|
||||||
"IncludeItemTypes": "Movie",
|
"IncludeItemTypes": "Movie",
|
||||||
"Recursive": "true",
|
"Recursive": "true",
|
||||||
"Fields": "ProviderIds,Path,ProductionYear",
|
"Fields": "ProviderIds,Path,ProductionYear",
|
||||||
"EnableTotalRecordCount": "false",
|
"EnableTotalRecordCount": "false",
|
||||||
}).json()
|
},
|
||||||
|
).json()
|
||||||
movies: list[JellyfinMovie] = []
|
movies: list[JellyfinMovie] = []
|
||||||
for item in data.get("Items", []):
|
for item in data.get("Items", []):
|
||||||
provider = item.get("ProviderIds") or {}
|
provider = item.get("ProviderIds") or {}
|
||||||
raw_tmdb = provider.get("Tmdb") or provider.get("tmdb")
|
raw_tmdb = provider.get("Tmdb") or provider.get("tmdb")
|
||||||
tmdb_id = int(raw_tmdb) if raw_tmdb and str(raw_tmdb).isdigit() else None
|
tmdb_id = int(raw_tmdb) if raw_tmdb and str(raw_tmdb).isdigit() else None
|
||||||
movies.append(JellyfinMovie(
|
movies.append(
|
||||||
|
JellyfinMovie(
|
||||||
jellyfin_id=str(item.get("Id")),
|
jellyfin_id=str(item.get("Id")),
|
||||||
title=item.get("Name") or "",
|
title=item.get("Name") or "",
|
||||||
year=item.get("ProductionYear"),
|
year=item.get("ProductionYear"),
|
||||||
tmdb_id=tmdb_id,
|
tmdb_id=tmdb_id,
|
||||||
path=item.get("Path"),
|
path=item.get("Path"),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
return movies
|
return movies
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import List
|
|
||||||
from .file import File
|
from .file import File
|
||||||
|
|
||||||
|
|
||||||
class ListManager:
|
class ListManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# 'name' nebo 'date'
|
# 'name' nebo 'date'
|
||||||
@@ -10,11 +10,12 @@ class ListManager:
|
|||||||
if mode in ("name", "date"):
|
if mode in ("name", "date"):
|
||||||
self.sort_mode = mode
|
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":
|
if self.sort_mode == "name":
|
||||||
return sorted(files, key=lambda f: f.filename.lower())
|
return sorted(files, key=lambda f: f.filename.lower())
|
||||||
else:
|
else:
|
||||||
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
|
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
|
||||||
def date_key(f):
|
def date_key(f):
|
||||||
return (f.date is None, f.date or "")
|
return (f.date is None, f.date or "")
|
||||||
|
|
||||||
return sorted(files, key=date_key)
|
return sorted(files, key=date_key)
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Module header
|
|
||||||
import sys
|
|
||||||
from .file import File
|
|
||||||
from .tag_manager import TagManager
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit("This module is not intended to be executed as the main program.")
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
Vyžaduje ffprobe (FFmpeg).
|
|
||||||
"""
|
|
||||||
path = str(file_obj.file_path)
|
|
||||||
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],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
res = result.stdout.strip() # např. "1920x1080"
|
|
||||||
if "x" not in res:
|
|
||||||
return
|
|
||||||
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}")
|
|
||||||
+6
-6
@@ -9,10 +9,10 @@ The classification is deliberately **conservative**: only an exact
|
|||||||
(diacritics-insensitive) title match with a matching year and a single candidate
|
(diacritics-insensitive) title match with a matching year and a single candidate
|
||||||
is ``auto``; everything else is ``ambiguous`` for the user to confirm.
|
is ``auto``; everything else is ``ambiguous`` for the user to confirm.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -22,11 +22,12 @@ class MatchProposal:
|
|||||||
file: object # core.file.File
|
file: object # core.file.File
|
||||||
current_name: str
|
current_name: str
|
||||||
title: str
|
title: str
|
||||||
year: Optional[str]
|
year: str | None
|
||||||
status: str # "auto" | "ambiguous" | "no_match" | "error"
|
status: str # "auto" | "ambiguous" | "no_match" | "error"
|
||||||
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
|
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
|
||||||
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
|
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
|
||||||
error: str = ""
|
error: str = ""
|
||||||
|
note: str = "" # e.g. "přes ČSFD: The Shawshank Redemption (1994)"
|
||||||
|
|
||||||
|
|
||||||
def normalize(text: str) -> str:
|
def normalize(text: str) -> str:
|
||||||
@@ -37,13 +38,13 @@ def normalize(text: str) -> str:
|
|||||||
return re.sub(r"\s+", " ", text).strip()
|
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()):
|
if not (a and b and str(a).isdigit() and str(b).isdigit()):
|
||||||
return False
|
return False
|
||||||
return abs(int(a) - int(b)) <= 1
|
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.
|
"""Return ``(status, chosen)`` for a title/year against TMDb candidates.
|
||||||
|
|
||||||
``auto`` requires an exact normalized-title match, a year within ±1, and that
|
``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
|
return "no_match", None
|
||||||
nt = normalize(title)
|
nt = normalize(title)
|
||||||
exact_year = [
|
exact_year = [
|
||||||
c for c in candidates
|
c for c in candidates if normalize(c.title) == nt and _year_close(c.year, year)
|
||||||
if normalize(c.title) == nt and _year_close(c.year, year)
|
|
||||||
]
|
]
|
||||||
if len(exact_year) == 1:
|
if len(exact_year) == 1:
|
||||||
return "auto", exact_year[0]
|
return "auto", exact_year[0]
|
||||||
|
|||||||
@@ -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
@@ -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
|
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
|
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
|
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
|
lets two same-named films coexist and makes the filename self-describing.
|
||||||
helpers build, validate and parse the stem (the filename without its extension).
|
|
||||||
|
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
|
import re
|
||||||
|
|
||||||
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]``. The
|
# Jellyfin's recognised 3D format tags (case-insensitive on read; Curator
|
||||||
# title is non-greedy so a title that itself contains parentheses keeps them:
|
# always writes upper-case). hsbs/fsbs = half/full side-by-side, htab/ftab =
|
||||||
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019.
|
# 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(
|
_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:
|
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()))
|
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
|
||||||
|
|
||||||
|
|
||||||
def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str:
|
def canonical_pool_stem(
|
||||||
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` if given).
|
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:
|
Raises:
|
||||||
ValueError: the title is empty, the year is not four digits, or a given
|
ValueError: the title is empty, the year is not four digits, a given
|
||||||
``tmdb_id`` is not a positive integer.
|
``tmdb_id`` is not a positive integer, or ``format_3d`` isn't one
|
||||||
|
of :data:`THREE_D_FORMATS`.
|
||||||
"""
|
"""
|
||||||
title = (title or "").strip()
|
title = (title or "").strip()
|
||||||
year_s = str(year).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():
|
if not id_s.isdigit():
|
||||||
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
|
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
|
||||||
stem += f" [tmdbid-{id_s}]"
|
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
|
return stem
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +96,15 @@ def parse_tmdb_id(stem: str) -> int | None:
|
|||||||
return int(match.group("id"))
|
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:
|
def is_canonical_pool_stem(stem: str) -> bool:
|
||||||
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
|
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
|
||||||
return _POOL_STEM_RE.match((stem or "").strip()) is not None
|
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)
|
return paren.group(1)
|
||||||
loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name)
|
loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name)
|
||||||
return loose[-1] if loose else None
|
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
@@ -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
|
Both are found. Missing or unparsable NFO → ``None`` (the movie simply has no
|
||||||
genres until Jellyfin has scanned it).
|
genres until Jellyfin has scanned it).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NfoData:
|
class NfoData:
|
||||||
"""The bits of a movie ``.nfo`` Curator uses."""
|
"""The bits of a movie ``.nfo`` Curator uses."""
|
||||||
|
|
||||||
title: Optional[str] = None
|
title: str | None = None
|
||||||
year: Optional[str] = None # four-digit string
|
year: str | None = None # four-digit string
|
||||||
genres: list[str] = field(default_factory=list)
|
genres: list[str] = field(default_factory=list)
|
||||||
countries: 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
|
rating: int | None = None # 0–100 (NFO 0–10 scaled up), or None
|
||||||
tmdb_id: Optional[int] = 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]:
|
def field_values(self, name: str) -> list[str]:
|
||||||
"""Values for a tag-schema source field (genres/countries/year/rating)."""
|
"""Values for a tag-schema source field (genres/countries/year/rating)."""
|
||||||
@@ -40,7 +41,7 @@ class NfoData:
|
|||||||
return []
|
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``."""
|
"""Locate the ``.nfo`` for a movie: sibling ``<stem>.nfo`` or ``movie.nfo``."""
|
||||||
video_path = Path(video_path)
|
video_path = Path(video_path)
|
||||||
sibling = video_path.with_suffix(".nfo")
|
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()]
|
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)."""
|
"""Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands)."""
|
||||||
raw = root.findtext("rating")
|
raw = root.findtext("rating")
|
||||||
if raw is None:
|
if raw is None:
|
||||||
@@ -70,7 +71,7 @@ def _parse_rating(root: ET.Element) -> Optional[int]:
|
|||||||
return None
|
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"):
|
for uid in root.findall("uniqueid"):
|
||||||
if uid.get("type") == "tmdb" and uid.text and uid.text.strip().isdigit():
|
if uid.get("type") == "tmdb" and uid.text and uid.text.strip().isdigit():
|
||||||
return int(uid.text.strip())
|
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
|
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."""
|
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
|
||||||
path = find_nfo(video_path)
|
path = find_nfo(video_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
root = ET.parse(path).getroot()
|
root = ET.parse(path).getroot()
|
||||||
except (ET.ParseError, OSError):
|
except ET.ParseError, OSError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
year = root.findtext("year")
|
year = root.findtext("year")
|
||||||
@@ -98,4 +122,5 @@ def read_nfo(video_path: Path) -> Optional[NfoData]:
|
|||||||
countries=_texts(root, "country"),
|
countries=_texts(root, "country"),
|
||||||
rating=_parse_rating(root),
|
rating=_parse_rating(root),
|
||||||
tmdb_id=_parse_tmdb_id(root),
|
tmdb_id=_parse_tmdb_id(root),
|
||||||
|
csfd_url=_parse_csfd_url(root),
|
||||||
)
|
)
|
||||||
|
|||||||
+139
-17
@@ -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
|
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.
|
(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),
|
Records are keyed by the movie's **identity** (``tmdb-<id>``, or a title/year
|
||||||
e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines.
|
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 json
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
INDEX_FILENAME = ".Curator.!index"
|
INDEX_FILENAME = ".Curator.!index"
|
||||||
|
INDEX_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
class PoolIndex:
|
class PoolIndex:
|
||||||
@@ -42,7 +52,11 @@ class PoolIndex:
|
|||||||
return path.as_posix()
|
return path.as_posix()
|
||||||
|
|
||||||
def load(self) -> None:
|
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():
|
if not self.index_path.exists():
|
||||||
self.records = {}
|
self.records = {}
|
||||||
self.settings = {}
|
self.settings = {}
|
||||||
@@ -52,17 +66,92 @@ class PoolIndex:
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
self.records = data.get("movies", {})
|
self.records = data.get("movies", {})
|
||||||
self.settings = data.get("settings", {})
|
self.settings = data.get("settings", {})
|
||||||
except (json.JSONDecodeError, OSError):
|
except json.JSONDecodeError, OSError:
|
||||||
self.records = {}
|
self.records = {}
|
||||||
self.settings = {}
|
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:
|
def save(self) -> None:
|
||||||
"""Persist the index to disk."""
|
"""Persist the index to disk."""
|
||||||
self.pool_dir.mkdir(parents=True, exist_ok=True)
|
self.pool_dir.mkdir(parents=True, exist_ok=True)
|
||||||
with open(self.index_path, "w", encoding="utf-8") as f:
|
with open(self.index_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(
|
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):
|
def get_setting(self, key: str, default=None):
|
||||||
@@ -74,16 +163,49 @@ class PoolIndex:
|
|||||||
self.settings[key] = value
|
self.settings[key] = value
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def get(self, file_path: Path) -> dict | None:
|
# ------------------------------------------------------------------
|
||||||
"""Return the record for a file, or None if it is not indexed."""
|
# Movie records (v2 — keyed by identity, own their media files)
|
||||||
return self.records.get(self._key(file_path))
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def set(self, file_path: Path, data: dict) -> None:
|
def get_movie(self, key: str) -> dict | None:
|
||||||
"""Upsert a record and persist the index."""
|
"""Return the movie record under ``key``, or None."""
|
||||||
self.records[self._key(file_path)] = data
|
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()
|
self.save()
|
||||||
|
|
||||||
def delete(self, file_path: Path) -> None:
|
def delete_movie(self, key: str) -> None:
|
||||||
"""Remove a record (if present) and persist the index."""
|
"""Remove a movie record (if present) and persist the index."""
|
||||||
if self.records.pop(self._key(file_path), None) is not None:
|
if self.records.pop(key, None) is not None:
|
||||||
self.save()
|
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
@@ -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
|
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.
|
series library into a ``Series → Season → Episode`` tree with a naming status.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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.
|
# 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).
|
# Files next to an episode that are not video (NFO / artwork / subtitles).
|
||||||
_SIDECARS = {
|
_SIDECARS = {
|
||||||
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn",
|
".nfo",
|
||||||
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt",
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".png",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".gif",
|
||||||
|
".tbn",
|
||||||
|
".srt",
|
||||||
|
".ass",
|
||||||
|
".ssa",
|
||||||
|
".sub",
|
||||||
|
".idx",
|
||||||
|
".vtt",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Episode:
|
class Episode:
|
||||||
path: Path
|
path: Path
|
||||||
season: Optional[int]
|
season: int | None
|
||||||
episode: Optional[int]
|
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
|
@property
|
||||||
def code(self) -> str:
|
def code(self) -> str:
|
||||||
if self.season is None or self.episode is None:
|
if self.season is None or self.episode is None:
|
||||||
return "?"
|
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
|
@dataclass
|
||||||
class Series:
|
class Series:
|
||||||
folder: Path
|
folder: Path
|
||||||
title: str
|
title: str
|
||||||
year: Optional[str]
|
year: str | None
|
||||||
tmdb_id: Optional[int]
|
tmdb_id: int | None
|
||||||
library: str
|
library: str
|
||||||
episodes: list[Episode] = field(default_factory=list)
|
episodes: list[Episode] = field(default_factory=list)
|
||||||
|
|
||||||
@@ -55,13 +95,15 @@ class Series:
|
|||||||
"""Episodes grouped by season number (``-1`` = unrecognised)."""
|
"""Episodes grouped by season number (``-1`` = unrecognised)."""
|
||||||
grouped: dict[int, list[Episode]] = {}
|
grouped: dict[int, list[Episode]] = {}
|
||||||
for ep in self.episodes:
|
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():
|
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()))
|
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``."""
|
"""Extract ``(season, episode)`` from a filename, or ``None``."""
|
||||||
match = _EPISODE_RE.search(name)
|
match = _EPISODE_RE.search(name)
|
||||||
if not match:
|
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))
|
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:
|
def season_folder_name(season: int) -> str:
|
||||||
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
|
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
|
||||||
return "Specials" if season == 0 else f"Season {season:02d}"
|
return "Specials" if season == 0 else f"Season {season:02d}"
|
||||||
|
|
||||||
|
|
||||||
def episode_body(stem: str) -> str:
|
def is_season_folder(name: str) -> bool:
|
||||||
"""The descriptive part of an episode filename, minus a leading ``SxxEyy`` code.
|
"""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
|
def looks_like_series_folder(folder: Path) -> bool:
|
||||||
don't double it. Returns ``""`` when the stem was only the code.
|
"""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
|
folder = Path(folder)
|
||||||
s = re.sub(r"([\s._-])[\s._-]+", r"\1", s) # collapse separator runs
|
if not folder.is_dir():
|
||||||
return s.strip(" ._-")
|
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:
|
# Characters not allowed in a filename on the common filesystems.
|
||||||
"""Canonical episode filename ``SxxEyy - {original name}.ext``.
|
_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}"
|
code = f"S{season:02d}E{episode:02d}"
|
||||||
body = episode_body(orig_stem)
|
body = sanitize_title(series_title)
|
||||||
return f"{code} - {body}{ext}" if body else f"{code}{ext}"
|
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:
|
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
|
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
|
||||||
|
|
||||||
|
|
||||||
def is_canonical_episode(path: Path) -> bool:
|
def is_canonical_episode(path: Path, series_title: str) -> bool:
|
||||||
"""True when ``path`` already follows ``SxxEyy - name`` in the right season."""
|
"""True when ``path`` is already ``SxxEyy - {series title}.ext`` in its season."""
|
||||||
parsed = parse_episode(path.name)
|
parsed = parse_episode(path.name)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return False
|
return False
|
||||||
season, episode = parsed
|
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:
|
if path.name != expected:
|
||||||
return False
|
return False
|
||||||
return path.parent.name == season_folder_name(season)
|
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`.
|
"""Scan one series library into a list of :class:`Series`.
|
||||||
|
|
||||||
Each immediate subfolder of ``lib_dir`` is a series; its name yields the
|
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
|
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
|
||||||
parsed from the filename). Non-video sidecars are skipped.
|
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)
|
lib_dir = Path(lib_dir)
|
||||||
if not lib_dir.is_dir():
|
if not lib_dir.is_dir():
|
||||||
return []
|
return []
|
||||||
series_list: list[Series] = []
|
series_list: list[Series] = []
|
||||||
for folder in sorted(p for p in lib_dir.iterdir() if p.is_dir()):
|
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)
|
parsed = parse_pool_stem(folder.name)
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
title, year = parsed[0], str(parsed[1])
|
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:
|
if not f.is_file() or f.suffix.lower() in _SIDECARS:
|
||||||
continue
|
continue
|
||||||
code = parse_episode(f.name)
|
code = parse_episode(f.name)
|
||||||
episodes.append(Episode(
|
episodes.append(
|
||||||
f, code[0] if code else None, code[1] if code else None))
|
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))
|
series_list.append(Series(folder, title, year, tmdb_id, library, episodes))
|
||||||
return series_list
|
return series_list
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -47,7 +47,9 @@ class TagManager:
|
|||||||
|
|
||||||
def get_all_tags(self):
|
def get_all_tags(self):
|
||||||
"""Vrací list všech tagů full_path"""
|
"""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):
|
def get_categories(self):
|
||||||
return list(self.tags_by_category.keys())
|
return list(self.tags_by_category.keys())
|
||||||
|
|||||||
+75
-20
@@ -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`
|
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.
|
reports readiness and the lookups raise :class:`TmdbError` with a clear message.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import requests
|
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
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
except Exception: # noqa: BLE001 — dotenv is a convenience, never required
|
|
||||||
pass
|
|
||||||
|
|
||||||
_BASE = "https://api.themoviedb.org/3"
|
_BASE = "https://api.themoviedb.org/3"
|
||||||
_LANG = os.getenv("TMDB_LANGUAGE", "cs-CZ")
|
_LANG = os.getenv("TMDB_LANGUAGE", "cs-CZ")
|
||||||
@@ -31,15 +33,42 @@ class TmdbResult:
|
|||||||
|
|
||||||
tmdb_id: int
|
tmdb_id: int
|
||||||
title: str
|
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"
|
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):
|
class TmdbError(RuntimeError):
|
||||||
"""A TMDb request could not be completed (no key, network, bad status)."""
|
"""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."""
|
"""The configured TMDb API key, or None."""
|
||||||
key = os.getenv("TMDB_API_KEY")
|
key = os.getenv("TMDB_API_KEY")
|
||||||
return key.strip() if key and key.strip() else None
|
return key.strip() if key and key.strip() else None
|
||||||
@@ -68,37 +97,61 @@ def _get(path: str, params: dict) -> dict:
|
|||||||
return resp.json()
|
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():
|
if date_str and len(date_str) >= 4 and date_str[:4].isdigit():
|
||||||
return date_str[:4]
|
return date_str[:4]
|
||||||
return None
|
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."""
|
"""Resolve a movie by its TMDb id → title + year, or None if it has no title."""
|
||||||
data = _get(f"/movie/{int(tmdb_id)}", {})
|
data = _get(f"/movie/{int(tmdb_id)}", {})
|
||||||
title = data.get("title") or data.get("original_title")
|
title = data.get("title") or data.get("original_title")
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
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."""
|
"""Resolve a series by its TMDb id → name + first-air year, or None."""
|
||||||
data = _get(f"/tv/{int(tmdb_id)}", {})
|
data = _get(f"/tv/{int(tmdb_id)}", {})
|
||||||
title = data.get("name") or data.get("original_name")
|
title = data.get("name") or data.get("original_name")
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
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"``)."""
|
"""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)
|
return lookup_tv(tmdb_id) if kind == "tv" else lookup_movie(tmdb_id)
|
||||||
|
|
||||||
|
|
||||||
def search_movie(
|
def search_movie(
|
||||||
query: str, year: Optional[str] = None, limit: int = 5
|
query: str, year: str | None = None, limit: int = 5
|
||||||
) -> list[TmdbResult]:
|
) -> list[TmdbResult]:
|
||||||
"""Search movies by title (optionally narrowed by year); best matches first."""
|
"""Search movies by title (optionally narrowed by year); best matches first."""
|
||||||
if not query.strip():
|
if not query.strip():
|
||||||
@@ -112,14 +165,14 @@ def search_movie(
|
|||||||
title = item.get("title") or item.get("original_title")
|
title = item.get("title") or item.get("original_title")
|
||||||
if title and item.get("id"):
|
if title and item.get("id"):
|
||||||
results.append(
|
results.append(
|
||||||
TmdbResult(int(item["id"]), title,
|
TmdbResult(
|
||||||
_year_of(item.get("release_date")), "movie"))
|
int(item["id"]), title, _year_of(item.get("release_date")), "movie"
|
||||||
|
)
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def search_tv(
|
def search_tv(query: str, year: str | None = None, limit: int = 5) -> list[TmdbResult]:
|
||||||
query: str, year: Optional[str] = None, limit: int = 5
|
|
||||||
) -> list[TmdbResult]:
|
|
||||||
"""Search series by name (optionally narrowed by first-air year)."""
|
"""Search series by name (optionally narrowed by first-air year)."""
|
||||||
if not query.strip():
|
if not query.strip():
|
||||||
return []
|
return []
|
||||||
@@ -132,6 +185,8 @@ def search_tv(
|
|||||||
title = item.get("name") or item.get("original_name")
|
title = item.get("name") or item.get("original_name")
|
||||||
if title and item.get("id"):
|
if title and item.get("id"):
|
||||||
results.append(
|
results.append(
|
||||||
TmdbResult(int(item["id"]), title,
|
TmdbResult(
|
||||||
_year_of(item.get("first_air_date")), "tv"))
|
int(item["id"]), title, _year_of(item.get("first_air_date")), "tv"
|
||||||
|
)
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ 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
|
value (e.g. rating 87 → tag "Hodnocení/87"), while the folder groups it
|
||||||
(→ "…/80–89 %"). Config-driven and extensible via ``TAG_TRANSFORMS``.
|
(→ "…/80–89 %"). Config-driven and extensible via ``TAG_TRANSFORMS``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
def rating_band(rating: int) -> str:
|
def rating_band(rating: int) -> str:
|
||||||
@@ -18,24 +19,24 @@ def rating_band(rating: int) -> str:
|
|||||||
return f"{low}–{high} %"
|
return f"{low}–{high} %"
|
||||||
|
|
||||||
|
|
||||||
def _decade_band(value) -> str:
|
def _decade_band(value: str) -> str:
|
||||||
"""``decade_band`` transform: pull the leading integer and bucket it."""
|
"""``decade_band`` transform: pull the leading integer and bucket it."""
|
||||||
match = re.search(r"\d+", str(value))
|
match = re.search(r"\d+", str(value))
|
||||||
return rating_band(int(match.group())) if match else str(value)
|
return rating_band(int(match.group())) if match else str(value)
|
||||||
|
|
||||||
|
|
||||||
# Named value transforms usable from the tag schema (config-driven, extensible).
|
# Named value transforms usable from the tag schema (config-driven, extensible).
|
||||||
TAG_TRANSFORMS = {
|
TAG_TRANSFORMS: dict[str | None, Callable[[str], str]] = {
|
||||||
None: str,
|
None: str,
|
||||||
"identity": str,
|
"identity": str,
|
||||||
"decade_band": _decade_band,
|
"decade_band": _decade_band,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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."""
|
"""Apply a named tag-schema transform to a value, returning a string."""
|
||||||
fn = TAG_TRANSFORMS.get(transform, str)
|
fn = TAG_TRANSFORMS.get(transform, str)
|
||||||
try:
|
try:
|
||||||
return str(fn(value))
|
return str(fn(value))
|
||||||
except (ValueError, TypeError, AttributeError):
|
except ValueError, TypeError, AttributeError:
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def list_files(folder_path: str | Path) -> list[Path]:
|
def list_files(folder_path: str | Path) -> list[Path]:
|
||||||
folder = Path(folder_path)
|
folder = Path(folder_path)
|
||||||
if not folder.is_dir():
|
if not folder.is_dir():
|
||||||
|
|||||||
+2543
-451
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -1,11 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Konfigurace pytest - sdílené fixtures a nastavení pro všechny testy
|
Konfigurace pytest - sdílené fixtures a nastavení pro všechny testy
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
import tempfile
|
import contextlib
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def session_temp_dir():
|
def session_temp_dir():
|
||||||
@@ -22,7 +25,5 @@ def cleanup_config_files():
|
|||||||
# Cleanup po testu
|
# Cleanup po testu
|
||||||
config_file = Path("config.json")
|
config_file = Path("config.json")
|
||||||
if config_file.exists():
|
if config_file.exists():
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
config_file.unlink()
|
config_file.unlink()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|||||||
+16
-11
@@ -1,8 +1,13 @@
|
|||||||
import pytest
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.core.config import (
|
from src.core.config import (
|
||||||
load_global_config, save_global_config, DEFAULT_GLOBAL_CONFIG,
|
DEFAULT_GLOBAL_CONFIG,
|
||||||
load_config, save_config # Legacy functions
|
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"""
|
"""Fixture pro dočasný globální config soubor"""
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
import src.core.config as config_module
|
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
|
return config_path
|
||||||
|
|
||||||
def test_default_global_config_structure(self):
|
def test_default_global_config_structure(self):
|
||||||
@@ -136,7 +142,8 @@ class TestLegacyFunctions:
|
|||||||
"""Fixture pro dočasný globální config soubor"""
|
"""Fixture pro dočasný globální config soubor"""
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
import src.core.config as config_module
|
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
|
return config_path
|
||||||
|
|
||||||
def test_load_config_legacy(self, temp_global_config):
|
def test_load_config_legacy(self, temp_global_config):
|
||||||
@@ -166,14 +173,15 @@ class TestConfigEdgeCases:
|
|||||||
"""Fixture pro dočasný globální config soubor"""
|
"""Fixture pro dočasný globální config soubor"""
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
import src.core.config as config_module
|
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
|
return config_path
|
||||||
|
|
||||||
def test_config_path_with_spaces(self, temp_global_config):
|
def test_config_path_with_spaces(self, temp_global_config):
|
||||||
"""Test s cestou obsahující mezery"""
|
"""Test s cestou obsahující mezery"""
|
||||||
test_config = {
|
test_config = {
|
||||||
**DEFAULT_GLOBAL_CONFIG,
|
**DEFAULT_GLOBAL_CONFIG,
|
||||||
"last_folder": "/path/with spaces/in name"
|
"last_folder": "/path/with spaces/in name",
|
||||||
}
|
}
|
||||||
|
|
||||||
save_global_config(test_config)
|
save_global_config(test_config)
|
||||||
@@ -215,10 +223,7 @@ class TestConfigEdgeCases:
|
|||||||
|
|
||||||
def test_config_ensure_ascii_false(self, temp_global_config):
|
def test_config_ensure_ascii_false(self, temp_global_config):
|
||||||
"""Test že ensure_ascii=False funguje správně"""
|
"""Test že ensure_ascii=False funguje správně"""
|
||||||
test_config = {
|
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/cesta/čeština"}
|
||||||
**DEFAULT_GLOBAL_CONFIG,
|
|
||||||
"last_folder": "/cesta/čeština"
|
|
||||||
}
|
|
||||||
|
|
||||||
save_global_config(test_config)
|
save_global_config(test_config)
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -15,7 +15,6 @@ from src.constants import (
|
|||||||
get_version,
|
get_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# get_version()
|
# get_version()
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -42,14 +41,18 @@ def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None:
|
|||||||
assert len(result) > 0
|
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."""
|
"""get_version() returns '0.0.0-unknown' when all sources are unavailable."""
|
||||||
missing = tmp_path / "nonexistent.toml"
|
missing = tmp_path / "nonexistent.toml"
|
||||||
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
|
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
|
||||||
|
|
||||||
# Patch _version import to also fail
|
# Patch _version import to also fail
|
||||||
with patch("src.constants.Path.write_text", side_effect=OSError):
|
with (
|
||||||
with patch.dict("sys.modules", {"src._version": None}):
|
patch("src.constants.Path.write_text", side_effect=OSError),
|
||||||
|
patch.dict("sys.modules", {"src._version": None}),
|
||||||
|
):
|
||||||
result = get_version()
|
result = get_version()
|
||||||
|
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
@@ -131,7 +134,9 @@ def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> Non
|
|||||||
assert title.endswith("-DEV")
|
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."""
|
"""APP_TITLE does not end with '-DEV' when ENV_DEBUG is False."""
|
||||||
import src.constants as consts
|
import src.constants as consts
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+36
-15
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.file import File
|
from src.core.file import File
|
||||||
from src.core.pool_index import PoolIndex
|
from src.core.pool_index import PoolIndex
|
||||||
from src.core.tag import Tag
|
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):
|
def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
|
||||||
"""Metadata jsou zapsána do indexu už při vytvoření."""
|
"""Metadata jsou zapsána do indexu už při vytvoření."""
|
||||||
file_obj = File(test_file, tag_manager, index=index)
|
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):
|
def test_file_no_sidecar_written(self, test_file, tag_manager, index):
|
||||||
"""Žádný .!tag sidecar už nevzniká."""
|
"""Žádný .!tag sidecar už nevzniká."""
|
||||||
@@ -57,7 +58,7 @@ class TestFile:
|
|||||||
file_obj.ignored = True
|
file_obj.ignored = True
|
||||||
file_obj.save_metadata()
|
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["new"] is False
|
||||||
assert data["ignored"] is True
|
assert data["ignored"] is True
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ class TestFile:
|
|||||||
file_obj = File(test_file, tag_manager, index=index)
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
file_obj.set_date("2025-12-25")
|
file_obj.set_date("2025-12-25")
|
||||||
assert file_obj.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):
|
def test_file_set_date_to_none(self, test_file, tag_manager, index):
|
||||||
file_obj = File(test_file, tag_manager, index=index)
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
@@ -130,7 +131,9 @@ class TestFile:
|
|||||||
file_obj.remove_tag("Video/HD")
|
file_obj.remove_tag("Video/HD")
|
||||||
assert "Video/HD" not in {t.full_path for t in file_obj.tags}
|
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 = File(test_file, tag_manager, index=index)
|
||||||
file_obj.add_tag("SimpleTag")
|
file_obj.add_tag("SimpleTag")
|
||||||
file_obj.remove_tag("SimpleTag")
|
file_obj.remove_tag("SimpleTag")
|
||||||
@@ -169,7 +172,7 @@ class TestFile:
|
|||||||
file_obj.add_tag("Test/Tag")
|
file_obj.add_tag("Test/Tag")
|
||||||
file_obj.set_date("2025-06-15")
|
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 "new" in data
|
||||||
assert "ignored" in data
|
assert "ignored" in data
|
||||||
assert "tags" in data
|
assert "tags" in data
|
||||||
@@ -227,8 +230,13 @@ class TestApplyNfoTags:
|
|||||||
def test_apply_nfo_assigns_expected_categories(self, movie_file):
|
def test_apply_nfo_assigns_expected_categories(self, movie_file):
|
||||||
from src.core.nfo import NfoData
|
from src.core.nfo import NfoData
|
||||||
|
|
||||||
nfo = NfoData(title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
|
nfo = NfoData(
|
||||||
countries=["USA"], rating=90)
|
title="Matrix",
|
||||||
|
year="1999",
|
||||||
|
genres=["Akční", "Sci-Fi"],
|
||||||
|
countries=["USA"],
|
||||||
|
rating=90,
|
||||||
|
)
|
||||||
result = movie_file.apply_nfo(nfo)
|
result = movie_file.apply_nfo(nfo)
|
||||||
|
|
||||||
assert result["success"]
|
assert result["success"]
|
||||||
@@ -263,12 +271,16 @@ class TestApplyNfoTags:
|
|||||||
def test_added_timestamp_uses_stored_added(self, movie_file):
|
def test_added_timestamp_uses_stored_added(self, movie_file):
|
||||||
movie_file.added = "2026-06-16T12:00:00"
|
movie_file.added = "2026-06-16T12:00:00"
|
||||||
from datetime import datetime
|
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):
|
def test_added_timestamp_falls_back_to_mtime(self, movie_file):
|
||||||
movie_file.added = None
|
movie_file.added = None
|
||||||
import os
|
import os
|
||||||
|
|
||||||
expected = os.stat(movie_file.file_path).st_mtime
|
expected = os.stat(movie_file.file_path).st_mtime
|
||||||
assert movie_file.added_timestamp() == expected
|
assert movie_file.added_timestamp() == expected
|
||||||
|
|
||||||
@@ -277,8 +289,9 @@ class TestApplyNfoTags:
|
|||||||
assert movie_file.attributes["collection_sort"] == "03"
|
assert movie_file.attributes["collection_sort"] == "03"
|
||||||
assert movie_file.name_context()["collection_sort"] == "03"
|
assert movie_file.name_context()["collection_sort"] == "03"
|
||||||
# reload (from index) keeps it
|
# reload (from index) keeps it
|
||||||
reloaded = File(movie_file.file_path, movie_file.tagmanager,
|
reloaded = File(
|
||||||
index=movie_file.index)
|
movie_file.file_path, movie_file.tagmanager, index=movie_file.index
|
||||||
|
)
|
||||||
assert reloaded.attributes["collection_sort"] == "03"
|
assert reloaded.attributes["collection_sort"] == "03"
|
||||||
|
|
||||||
def test_set_attribute_empty_removes_it(self, movie_file):
|
def test_set_attribute_empty_removes_it(self, movie_file):
|
||||||
@@ -290,15 +303,23 @@ class TestApplyNfoTags:
|
|||||||
movie_file.title = "Dr. No"
|
movie_file.title = "Dr. No"
|
||||||
movie_file.set_attribute("collection_sort", "01")
|
movie_file.set_attribute("collection_sort", "01")
|
||||||
ctx = movie_file.name_context()
|
ctx = movie_file.name_context()
|
||||||
assert "{collection_sort} - {title}{ext}".format(**ctx) == \
|
assert (
|
||||||
f"01 - Dr. No{ctx['ext']}"
|
"{collection_sort} - {title}{ext}".format(**ctx)
|
||||||
|
== f"01 - Dr. No{ctx['ext']}"
|
||||||
|
)
|
||||||
|
|
||||||
def test_apply_nfo_honors_custom_schema(self, movie_file):
|
def test_apply_nfo_honors_custom_schema(self, movie_file):
|
||||||
"""A schema without the rating entry produces no Hodnocení tags."""
|
"""A schema without the rating entry produces no Hodnocení tags."""
|
||||||
from src.core.nfo import NfoData
|
from src.core.nfo import NfoData
|
||||||
|
|
||||||
schema = [{"category": "Žánr", "csfd_field": "genres",
|
schema = [
|
||||||
"transform": None, "filmoteka_root": ""}]
|
{
|
||||||
|
"category": "Žánr",
|
||||||
|
"csfd_field": "genres",
|
||||||
|
"transform": None,
|
||||||
|
"filmoteka_root": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
|
movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
|
||||||
|
|
||||||
paths = {t.full_path for t in movie_file.tags}
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
|
|||||||
+1861
-51
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
|||||||
import pytest
|
|
||||||
import os
|
import os
|
||||||
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.core.file import File
|
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 import Tag
|
||||||
from src.core.tag_manager import TagManager
|
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 / "rok" / "1988" / "file1.txt").exists()
|
||||||
assert (temp_output_dir / "žánr" / "Drama" / "file2.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)"""
|
"""Test že vytvořené soubory jsou opravdu hardlinky (stejný inode)"""
|
||||||
manager = HardlinkManager(temp_output_dir)
|
manager = HardlinkManager(temp_output_dir)
|
||||||
manager.create_structure_for_files(files_with_tags)
|
manager.create_structure_for_files(files_with_tags)
|
||||||
@@ -95,10 +99,14 @@ class TestHardlinkManager:
|
|||||||
# Same inode = hardlink
|
# Same inode = hardlink
|
||||||
assert original.stat().st_ino == hardlink.stat().st_ino
|
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"""
|
"""Test vytvoření struktury jen pro vybrané kategorie"""
|
||||||
manager = HardlinkManager(temp_output_dir)
|
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)
|
# Only "žánr" tags should be processed (3 links)
|
||||||
assert success == 3
|
assert success == 3
|
||||||
@@ -107,7 +115,9 @@ class TestHardlinkManager:
|
|||||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||||
assert not (temp_output_dir / "rok").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'."""
|
"""category_roots: genres sit at the output root, rok under 'Dle roku'."""
|
||||||
manager = HardlinkManager(temp_output_dir)
|
manager = HardlinkManager(temp_output_dir)
|
||||||
roots = {"žánr": "", "rok": "Dle roku"}
|
roots = {"žánr": "", "rok": "Dle roku"}
|
||||||
@@ -137,8 +147,8 @@ class TestHardlinkManager:
|
|||||||
os.link(temp_source_dir / "file1.txt", mirror_link)
|
os.link(temp_source_dir / "file1.txt", mirror_link)
|
||||||
|
|
||||||
manager.sync_structure(
|
manager.sync_structure(
|
||||||
files_with_tags, category_roots=roots,
|
files_with_tags, category_roots=roots, reserved_subfolders={"Seriály"}
|
||||||
reserved_subfolders={"Seriály"})
|
)
|
||||||
|
|
||||||
# The mirror (reserved folder) is left alone
|
# The mirror (reserved folder) is left alone
|
||||||
assert mirror_link.exists()
|
assert mirror_link.exists()
|
||||||
@@ -208,9 +218,7 @@ class TestHardlinkManager:
|
|||||||
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
|
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
|
||||||
assert (created2, removed2) == (0, 0)
|
assert (created2, removed2) == (0, 0)
|
||||||
|
|
||||||
def test_link_name_strips_year_by_default(
|
def test_link_name_strips_year_by_default(self, tmp_path, tag_manager):
|
||||||
self, tmp_path, tag_manager
|
|
||||||
):
|
|
||||||
"""A pooled 'Title (YYYY).ext' hardlinks into the tag tree as 'Title.ext'."""
|
"""A pooled 'Title (YYYY).ext' hardlinks into the tag tree as 'Title.ext'."""
|
||||||
source = tmp_path / "source"
|
source = tmp_path / "source"
|
||||||
source.mkdir()
|
source.mkdir()
|
||||||
@@ -242,9 +250,7 @@ class TestHardlinkManager:
|
|||||||
|
|
||||||
assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped
|
assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped
|
||||||
|
|
||||||
def test_link_name_keeps_year_on_collision(
|
def test_link_name_keeps_year_on_collision(self, tmp_path, tag_manager):
|
||||||
self, tmp_path, tag_manager
|
|
||||||
):
|
|
||||||
"""Two same-title films in one folder keep the year to disambiguate."""
|
"""Two same-title films in one folder keep the year to disambiguate."""
|
||||||
source = tmp_path / "source"
|
source = tmp_path / "source"
|
||||||
source.mkdir()
|
source.mkdir()
|
||||||
@@ -276,9 +282,7 @@ class TestHardlinkManager:
|
|||||||
assert (output / "Dle roku" / "1972" / "Solaris.mkv").exists()
|
assert (output / "Dle roku" / "1972" / "Solaris.mkv").exists()
|
||||||
assert (output / "Dle roku" / "2002" / "Solaris.mkv").exists()
|
assert (output / "Dle roku" / "2002" / "Solaris.mkv").exists()
|
||||||
|
|
||||||
def test_clean_naming_cleanup_is_consistent(
|
def test_clean_naming_cleanup_is_consistent(self, tmp_path, tag_manager):
|
||||||
self, tmp_path, tag_manager
|
|
||||||
):
|
|
||||||
"""Clean-named links are stable: a second sync removes/creates nothing."""
|
"""Clean-named links are stable: a second sync removes/creates nothing."""
|
||||||
source = tmp_path / "source"
|
source = tmp_path / "source"
|
||||||
source.mkdir()
|
source.mkdir()
|
||||||
@@ -349,7 +353,9 @@ class TestHardlinkManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Templated name inside the collection folder
|
# 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)
|
# Other categories use the default clean title (no year, not the pool name)
|
||||||
assert (temp_output_dir / "Akční" / "Dr. No.txt").exists()
|
assert (temp_output_dir / "Akční" / "Dr. No.txt").exists()
|
||||||
|
|
||||||
@@ -365,9 +371,12 @@ class TestHardlinkManager:
|
|||||||
roots = {"Kolekce": "Dle kolekce"}
|
roots = {"Kolekce": "Dle kolekce"}
|
||||||
templates = {"Kolekce": "{year} - {title}{ext}"}
|
templates = {"Kolekce": "{year} - {title}{ext}"}
|
||||||
manager = HardlinkManager(temp_output_dir)
|
manager = HardlinkManager(temp_output_dir)
|
||||||
manager.sync_structure([f], category_roots=roots, category_filename_templates=templates)
|
manager.sync_structure(
|
||||||
created, _, removed, _ = manager.sync_structure(
|
[f], category_roots=roots, category_filename_templates=templates
|
||||||
[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"
|
folder = temp_output_dir / "Dle kolekce" / "James Bond"
|
||||||
assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"]
|
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"
|
files_with_tags[2].added = "2026-01-01T00:00:00"
|
||||||
manager = HardlinkManager(temp_output_dir)
|
manager = HardlinkManager(temp_output_dir)
|
||||||
created, fail = manager.generate_recently_added(
|
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é"
|
recent = temp_output_dir / "- Nově přidané"
|
||||||
assert created == 2 and fail == 0
|
assert created == 2 and fail == 0
|
||||||
@@ -427,7 +437,9 @@ class TestHardlinkManager:
|
|||||||
def test_dry_run(self, files_with_tags, temp_output_dir):
|
def test_dry_run(self, files_with_tags, temp_output_dir):
|
||||||
"""Test dry run (bez skutečného vytváření)"""
|
"""Test dry run (bez skutečného vytváření)"""
|
||||||
manager = HardlinkManager(temp_output_dir)
|
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 success == 4
|
||||||
assert fail == 0
|
assert fail == 0
|
||||||
@@ -497,7 +509,7 @@ class TestHardlinkManager:
|
|||||||
manager = HardlinkManager(temp_output_dir)
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
|
||||||
# Create first time
|
# 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
|
# Create second time - should skip existing
|
||||||
manager2 = HardlinkManager(temp_output_dir)
|
manager2 = HardlinkManager(temp_output_dir)
|
||||||
@@ -507,7 +519,9 @@ class TestHardlinkManager:
|
|||||||
assert success2 == 0
|
assert success2 == 0
|
||||||
assert fail2 == 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
|
"""Two different movies with the same clean name (no year to tell them
|
||||||
apart) in one folder get distinct links via a numeric suffix."""
|
apart) in one folder get distinct links via a numeric suffix."""
|
||||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
@@ -530,7 +544,9 @@ class TestHardlinkManager:
|
|||||||
assert (folder / "file1.txt").exists()
|
assert (folder / "file1.txt").exists()
|
||||||
assert (folder / "file1_2.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ů"""
|
"""Test českých znaků v názvech tagů"""
|
||||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
f1.tags.clear()
|
f1.tags.clear()
|
||||||
@@ -593,7 +609,9 @@ class TestConvenienceFunction:
|
|||||||
output = tmp_path / "output"
|
output = tmp_path / "output"
|
||||||
output.mkdir()
|
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 success == 1
|
||||||
assert (output / "include" / "yes" / "file.txt").exists()
|
assert (output / "include" / "yes" / "file.txt").exists()
|
||||||
@@ -677,7 +695,7 @@ class TestSyncStructure:
|
|||||||
f.add_tag(Tag("cat", "tag1"))
|
f.add_tag(Tag("cat", "tag1"))
|
||||||
|
|
||||||
# Remove obsolete links
|
# Remove obsolete links
|
||||||
removed, paths = manager.remove_obsolete_links([f])
|
removed, _paths = manager.remove_obsolete_links([f])
|
||||||
|
|
||||||
assert removed == 1
|
assert removed == 1
|
||||||
assert not (output / "cat" / "tag2" / "file.txt").exists()
|
assert not (output / "cat" / "tag2" / "file.txt").exists()
|
||||||
@@ -699,7 +717,7 @@ class TestSyncStructure:
|
|||||||
f.tags.clear()
|
f.tags.clear()
|
||||||
f.add_tag(Tag("cat", "tag1"))
|
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
|
assert removed == 1
|
||||||
# File should still exist (dry run)
|
# File should still exist (dry run)
|
||||||
@@ -747,7 +765,7 @@ class TestSyncStructure:
|
|||||||
manager.create_structure_for_files([f])
|
manager.create_structure_for_files([f])
|
||||||
|
|
||||||
# Sync again without changes
|
# 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)
|
# Nothing should change (existing links are skipped)
|
||||||
assert removed == 0
|
assert removed == 0
|
||||||
@@ -821,7 +839,7 @@ class TestEdgeCases:
|
|||||||
# output doesn't exist
|
# output doesn't exist
|
||||||
|
|
||||||
manager = HardlinkManager(output)
|
manager = HardlinkManager(output)
|
||||||
success, fail = manager.create_structure_for_files([f])
|
success, _fail = manager.create_structure_for_files([f])
|
||||||
|
|
||||||
assert success == 1
|
assert success == 1
|
||||||
assert (output / "cat" / "tag" / "file.txt").exists()
|
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||||
@@ -842,7 +860,7 @@ class TestEdgeCases:
|
|||||||
output.mkdir()
|
output.mkdir()
|
||||||
|
|
||||||
manager = HardlinkManager(output)
|
manager = HardlinkManager(output)
|
||||||
success, fail = manager.create_structure_for_files([f])
|
success, _fail = manager.create_structure_for_files([f])
|
||||||
|
|
||||||
assert success == 1
|
assert success == 1
|
||||||
assert (output / "test" / "tag" / "file with spaces (part 2).txt").exists()
|
assert (output / "test" / "tag" / "file with spaces (part 2).txt").exists()
|
||||||
@@ -862,7 +880,7 @@ class TestEdgeCases:
|
|||||||
|
|
||||||
manager = HardlinkManager(output)
|
manager = HardlinkManager(output)
|
||||||
# Empty list = no categories = no links
|
# 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
|
assert success == 0
|
||||||
|
|
||||||
@@ -942,3 +960,30 @@ class TestMirrorAsIs:
|
|||||||
"""Neexistující zdroj nic neudělá"""
|
"""Neexistující zdroj nic neudělá"""
|
||||||
manager = HardlinkManager(tmp_path / "out")
|
manager = HardlinkManager(tmp_path / "out")
|
||||||
assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0)
|
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
@@ -3,6 +3,7 @@
|
|||||||
The FFmpeg subprocess call is monkeypatched so the tests don't need ffmpeg
|
The FFmpeg subprocess call is monkeypatched so the tests don't need ffmpeg
|
||||||
installed and don't touch real video data.
|
installed and don't touch real video data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -23,6 +24,7 @@ def video_file(tmp_path) -> Path:
|
|||||||
def _fake_run(returncode=0, stdout="", stderr=""):
|
def _fake_run(returncode=0, stdout="", stderr=""):
|
||||||
def run(cmd, **kwargs):
|
def run(cmd, **kwargs):
|
||||||
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
|
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||||
|
|
||||||
return run
|
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):
|
def test_deep_reports_decode_errors(monkeypatch, video_file):
|
||||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
integrity.subprocess, "run",
|
integrity.subprocess,
|
||||||
_fake_run(1, "", "[matroska @ 0x..] Read error\nmore noise"))
|
"run",
|
||||||
|
_fake_run(1, "", "[matroska @ 0x..] Read error\nmore noise"),
|
||||||
|
)
|
||||||
result = check_video_integrity(video_file, deep=True)
|
result = check_video_integrity(video_file, deep=True)
|
||||||
assert not result.ok and result.checked
|
assert not result.ok and result.checked
|
||||||
assert result.error == "[matroska @ 0x..] Read error" # first line only
|
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):
|
def test_deep_stderr_without_nonzero_rc_still_fails(monkeypatch, video_file):
|
||||||
"""ffmpeg can print decode errors while returning rc 0 — must still fail."""
|
"""ffmpeg can print decode errors while returning rc 0 — must still fail."""
|
||||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", "corrupt packet"))
|
||||||
integrity.subprocess, "run", _fake_run(0, "", "corrupt packet"))
|
|
||||||
result = check_video_integrity(video_file, deep=True)
|
result = check_video_integrity(video_file, deep=True)
|
||||||
assert not result.ok
|
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):
|
def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
|
||||||
"""FileManager.scan_video_integrity aggregates per-file results."""
|
"""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 import File
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
good = tmp_path / "good.mkv"
|
good = tmp_path / "good.mkv"
|
||||||
bad = tmp_path / "bad.mkv"
|
bad = tmp_path / "bad.mkv"
|
||||||
@@ -121,7 +124,8 @@ def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
seen: list[int] = []
|
seen: list[int] = []
|
||||||
result = fm.scan_video_integrity(
|
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 result["ok"] and result["checked"] == 2
|
||||||
assert [r.path.name for r in result["problems"]] == ["bad.mkv"]
|
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):
|
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 import File
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
files = []
|
files = []
|
||||||
for i in range(3):
|
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.ffmpeg_available", lambda deep: True)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"src.core.file_manager.check_video_integrity",
|
"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())
|
fm = FileManager(TagManager())
|
||||||
result = fm.scan_video_integrity(files, should_cancel=lambda: True)
|
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):
|
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.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: False)
|
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: False)
|
||||||
fm = FileManager(TagManager())
|
fm = FileManager(TagManager())
|
||||||
|
|||||||
+28
-12
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for the Jellyfin client + pool verification. Network is monkeypatched."""
|
"""Tests for the Jellyfin client + pool verification. Network is monkeypatched."""
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -14,8 +15,9 @@ def _configured(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def _resp(status=200, payload=None):
|
def _resp(status=200, payload=None):
|
||||||
return SimpleNamespace(status_code=status, ok=(200 <= status < 300),
|
return SimpleNamespace(
|
||||||
json=lambda: (payload or {}))
|
status_code=status, ok=(200 <= status < 300), json=lambda: payload or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_base_url_strips_trailing_slash():
|
def test_base_url_strips_trailing_slash():
|
||||||
@@ -29,8 +31,9 @@ def test_api_available(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_server_version(monkeypatch):
|
def test_server_version(monkeypatch):
|
||||||
monkeypatch.setattr(jellyfin.requests, "request",
|
monkeypatch.setattr(
|
||||||
lambda *a, **k: _resp(200, {"Version": "10.9.11"}))
|
jellyfin.requests, "request", lambda *a, **k: _resp(200, {"Version": "10.9.11"})
|
||||||
|
)
|
||||||
assert jellyfin.server_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):
|
def fake(method, url, **k):
|
||||||
seen["method"], seen["url"] = method, url
|
seen["method"], seen["url"] = method, url
|
||||||
return _resp(200, {})
|
return _resp(200, {})
|
||||||
|
|
||||||
monkeypatch.setattr(jellyfin.requests, "request", fake)
|
monkeypatch.setattr(jellyfin.requests, "request", fake)
|
||||||
jellyfin.scan_all()
|
jellyfin.scan_all()
|
||||||
assert seen["method"] == "POST" and seen["url"].endswith("/Library/Refresh")
|
assert seen["method"] == "POST" and seen["url"].endswith("/Library/Refresh")
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_movies_maps_tmdb_id(monkeypatch):
|
def test_fetch_movies_maps_tmdb_id(monkeypatch):
|
||||||
payload = {"Items": [
|
payload = {
|
||||||
{"Id": "a1", "Name": "Matrix", "ProductionYear": 1999,
|
"Items": [
|
||||||
"ProviderIds": {"Tmdb": "603"}, "Path": "/m/Matrix (1999) [tmdbid-603].mkv"},
|
{
|
||||||
|
"Id": "a1",
|
||||||
|
"Name": "Matrix",
|
||||||
|
"ProductionYear": 1999,
|
||||||
|
"ProviderIds": {"Tmdb": "603"},
|
||||||
|
"Path": "/m/Matrix (1999) [tmdbid-603].mkv",
|
||||||
|
},
|
||||||
{"Id": "a2", "Name": "Bez ID", "ProductionYear": 2000, "ProviderIds": {}},
|
{"Id": "a2", "Name": "Bez ID", "ProductionYear": 2000, "ProviderIds": {}},
|
||||||
]}
|
]
|
||||||
monkeypatch.setattr(jellyfin.requests, "request", lambda *a, **k: _resp(200, payload))
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
jellyfin.requests, "request", lambda *a, **k: _resp(200, payload)
|
||||||
|
)
|
||||||
movies = jellyfin.fetch_movies()
|
movies = jellyfin.fetch_movies()
|
||||||
assert movies[0] == JellyfinMovie("a1", "Matrix", 1999, 603,
|
assert movies[0] == JellyfinMovie(
|
||||||
"/m/Matrix (1999) [tmdbid-603].mkv")
|
"a1", "Matrix", 1999, 603, "/m/Matrix (1999) [tmdbid-603].mkv"
|
||||||
|
)
|
||||||
assert movies[1].tmdb_id is None
|
assert movies[1].tmdb_id is None
|
||||||
|
|
||||||
|
|
||||||
@@ -74,9 +88,11 @@ class TestVerifyAgainstJellyfin:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def file_manager(self, tmp_path, monkeypatch):
|
def file_manager(self, tmp_path, monkeypatch):
|
||||||
import src.core.config as config_module
|
import src.core.config as config_module
|
||||||
|
|
||||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
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.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
fm = FileManager(TagManager())
|
fm = FileManager(TagManager())
|
||||||
fm.set_pool_dir(tmp_path / "pool")
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
return fm
|
return fm
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.core.media_utils import load_icon
|
|
||||||
from PIL import Image, ImageTk
|
|
||||||
import tkinter as tk
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def tk_root():
|
|
||||||
"""Fixture pro inicializaci Tkinteru (nutné pro ImageTk)."""
|
|
||||||
root = tk.Tk()
|
|
||||||
yield root
|
|
||||||
root.destroy()
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_icon_returns_photoimage(tk_root):
|
|
||||||
"""Test že load_icon vrací PhotoImage"""
|
|
||||||
# vytvoříme dočasný obrázek
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
|
||||||
tmp_path = Path(tmp.name)
|
|
||||||
try:
|
|
||||||
# vytvoříme 100x100 červený obrázek
|
|
||||||
img = Image.new("RGB", (100, 100), color="red")
|
|
||||||
img.save(tmp_path)
|
|
||||||
|
|
||||||
icon = load_icon(tmp_path)
|
|
||||||
|
|
||||||
# musí být PhotoImage
|
|
||||||
assert isinstance(icon, ImageTk.PhotoImage)
|
|
||||||
|
|
||||||
# ověříme velikost 16x16
|
|
||||||
assert icon.width() == 16
|
|
||||||
assert icon.height() == 16
|
|
||||||
finally:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_icon_resizes_image(tk_root):
|
|
||||||
"""Test že load_icon správně změní velikost obrázku"""
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
|
||||||
tmp_path = Path(tmp.name)
|
|
||||||
try:
|
|
||||||
# vytvoříme velký obrázek 500x500
|
|
||||||
img = Image.new("RGB", (500, 500), color="blue")
|
|
||||||
img.save(tmp_path)
|
|
||||||
|
|
||||||
icon = load_icon(tmp_path)
|
|
||||||
|
|
||||||
# i velký obrázek by měl být zmenšen na 16x16
|
|
||||||
assert icon.width() == 16
|
|
||||||
assert icon.height() == 16
|
|
||||||
finally:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_icon_different_formats(tk_root):
|
|
||||||
"""Test načítání různých formátů obrázků"""
|
|
||||||
formats = [".png", ".jpg", ".bmp"]
|
|
||||||
|
|
||||||
for fmt in formats:
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=fmt, delete=False) as tmp:
|
|
||||||
tmp_path = Path(tmp.name)
|
|
||||||
try:
|
|
||||||
img = Image.new("RGB", (32, 32), color="green")
|
|
||||||
img.save(tmp_path)
|
|
||||||
|
|
||||||
icon = load_icon(tmp_path)
|
|
||||||
|
|
||||||
assert isinstance(icon, ImageTk.PhotoImage)
|
|
||||||
assert icon.width() == 16
|
|
||||||
assert icon.height() == 16
|
|
||||||
finally:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
+14
-7
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for pool → TMDb migration (src.core.migrate + FileManager)."""
|
"""Tests for pool → TMDb migration (src.core.migrate + FileManager)."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.migrate import classify, normalize
|
from src.core.migrate import classify, normalize
|
||||||
@@ -49,9 +50,11 @@ class TestFileManagerMigrate:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def file_manager(self, tmp_path, monkeypatch):
|
def file_manager(self, tmp_path, monkeypatch):
|
||||||
import src.core.config as config_module
|
import src.core.config as config_module
|
||||||
|
|
||||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
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.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
fm = FileManager(TagManager())
|
fm = FileManager(TagManager())
|
||||||
fm.set_pool_dir(tmp_path / "pool")
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
return fm
|
return fm
|
||||||
@@ -61,10 +64,13 @@ class TestFileManagerMigrate:
|
|||||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
|
||||||
assert "[tmdbid" not in movie.file_path.name # not migrated yet
|
assert "[tmdbid" not in movie.file_path.name # not migrated yet
|
||||||
|
|
||||||
import src.core.tmdb as tmdb
|
from src.core import tmdb
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
tmdb, "search_movie",
|
tmdb,
|
||||||
lambda q, year=None, limit=5: [TmdbResult(603, "Matrix", "1999", "movie")])
|
"search_movie",
|
||||||
|
lambda q, year=None, limit=5: [TmdbResult(603, "Matrix", "1999", "movie")],
|
||||||
|
)
|
||||||
|
|
||||||
proposals = file_manager.match_pool_to_tmdb()
|
proposals = file_manager.match_pool_to_tmdb()
|
||||||
assert len(proposals) == 1
|
assert len(proposals) == 1
|
||||||
@@ -78,11 +84,12 @@ class TestFileManagerMigrate:
|
|||||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
|
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 = []
|
called = []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
tmdb, "search_movie",
|
tmdb, "search_movie", lambda q, year=None, limit=5: called.append(q) or []
|
||||||
lambda q, year=None, limit=5: called.append(q) or [])
|
)
|
||||||
proposals = file_manager.match_pool_to_tmdb()
|
proposals = file_manager.match_pool_to_tmdb()
|
||||||
assert proposals == [] and called == [] # already has id → not searched
|
assert proposals == [] and called == [] # already has id → not searched
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Tests for the movie object and the pool layout it derives (src.core.movie)."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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: Any) -> Movie:
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"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
|
||||||
+76
-6
@@ -1,20 +1,32 @@
|
|||||||
"""Tests for the pool naming convention helpers (src.core.naming)."""
|
"""Tests for the pool naming convention helpers (src.core.naming)."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.naming import (
|
from src.core.naming import (
|
||||||
|
THREE_D_FORMATS,
|
||||||
canonical_pool_stem,
|
canonical_pool_stem,
|
||||||
is_canonical_pool_stem,
|
is_canonical_pool_stem,
|
||||||
is_valid_year,
|
is_valid_year,
|
||||||
|
parse_3d_format,
|
||||||
parse_pool_stem,
|
parse_pool_stem,
|
||||||
parse_tmdb_id,
|
parse_tmdb_id,
|
||||||
|
three_d_format_from_filename,
|
||||||
year_from_filename,
|
year_from_filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("year,ok", [
|
@pytest.mark.parametrize(
|
||||||
("1968", True), (1968, True), ("968", False), ("19688", False),
|
"year,ok",
|
||||||
("abcd", False), ("", False), (" 1999 ", True),
|
[
|
||||||
])
|
("1968", True),
|
||||||
|
(1968, True),
|
||||||
|
("968", False),
|
||||||
|
("19688", False),
|
||||||
|
("abcd", False),
|
||||||
|
("", False),
|
||||||
|
(" 1999 ", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_is_valid_year(year, ok):
|
def test_is_valid_year(year, ok):
|
||||||
assert is_valid_year(year) is 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")
|
assert not is_canonical_pool_stem("Matrix")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name,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"),
|
("Matrix.1999.1080p.BluRay.mkv", "1999"),
|
||||||
("Šíleně smutná princezna (1968).mkv", "1968"),
|
("Šíleně smutná princezna (1968).mkv", "1968"),
|
||||||
("Movie 2001 2010 remux.mkv", "2010"), # last standalone year wins
|
("Movie 2001 2010 remux.mkv", "2010"), # last standalone year wins
|
||||||
("No Year Here.mkv", None),
|
("No Year Here.mkv", None),
|
||||||
("Film 12345.mkv", None), # not a bare 4-digit year
|
("Film 12345.mkv", None), # not a bare 4-digit year
|
||||||
])
|
],
|
||||||
|
)
|
||||||
def test_year_from_filename(name, year):
|
def test_year_from_filename(name, year):
|
||||||
assert 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
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for the NFO reader (src.core.nfo)."""
|
"""Tests for the NFO reader (src.core.nfo)."""
|
||||||
|
|
||||||
from src.core.nfo import NfoData, find_nfo, read_nfo
|
from src.core.nfo import NfoData, find_nfo, read_nfo
|
||||||
|
|
||||||
MOVIE_NFO = """<?xml version="1.0" encoding="utf-8"?>
|
MOVIE_NFO = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
@@ -26,14 +27,20 @@ def test_read_nfo_sibling(tmp_path):
|
|||||||
|
|
||||||
nfo = read_nfo(video)
|
nfo = read_nfo(video)
|
||||||
assert nfo == NfoData(
|
assert nfo == NfoData(
|
||||||
title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
|
title="Matrix",
|
||||||
countries=["USA"], rating=87, tmdb_id=603)
|
year="1999",
|
||||||
|
genres=["Akční", "Sci-Fi"],
|
||||||
|
countries=["USA"],
|
||||||
|
rating=87,
|
||||||
|
tmdb_id=603,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_rating_scaled_to_percent(tmp_path):
|
def test_rating_scaled_to_percent(tmp_path):
|
||||||
video = _movie(tmp_path)
|
video = _movie(tmp_path)
|
||||||
video.with_suffix(".nfo").write_text(
|
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
|
assert read_nfo(video).rating == 73
|
||||||
|
|
||||||
|
|
||||||
@@ -41,17 +48,54 @@ def test_rating_from_ratings_block(tmp_path):
|
|||||||
video = _movie(tmp_path)
|
video = _movie(tmp_path)
|
||||||
video.with_suffix(".nfo").write_text(
|
video.with_suffix(".nfo").write_text(
|
||||||
"<movie><ratings><rating name='tmdb'><value>9.1</value></rating>"
|
"<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
|
assert read_nfo(video).rating == 91
|
||||||
|
|
||||||
|
|
||||||
def test_tmdbid_from_tmdbid_tag(tmp_path):
|
def test_tmdbid_from_tmdbid_tag(tmp_path):
|
||||||
video = _movie(tmp_path)
|
video = _movie(tmp_path)
|
||||||
video.with_suffix(".nfo").write_text(
|
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
|
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):
|
def test_find_prefers_sibling_over_movie_nfo(tmp_path):
|
||||||
folder = tmp_path / "Matrix (1999)"
|
folder = tmp_path / "Matrix (1999)"
|
||||||
folder.mkdir()
|
folder.mkdir()
|
||||||
@@ -86,9 +130,10 @@ def test_field_values():
|
|||||||
|
|
||||||
def test_refresh_tags_from_nfo(tmp_path, monkeypatch):
|
def test_refresh_tags_from_nfo(tmp_path, monkeypatch):
|
||||||
import src.core.config as config_module
|
import src.core.config as config_module
|
||||||
|
|
||||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
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.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
fm = FileManager(TagManager())
|
fm = FileManager(TagManager())
|
||||||
fm.set_pool_dir(tmp_path / "pool")
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
|
|||||||
+175
-23
@@ -1,48 +1,72 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from src.core.pool_index import PoolIndex, INDEX_FILENAME
|
|
||||||
from src.core.file import File
|
from src.core.file import File
|
||||||
|
from src.core.pool_index import INDEX_FILENAME, INDEX_VERSION, PoolIndex
|
||||||
from src.core.tag_manager import TagManager
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
|
|
||||||
class TestPoolIndex:
|
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):
|
def test_empty_index_when_no_file(self, tmp_path):
|
||||||
index = PoolIndex(tmp_path)
|
index = PoolIndex(tmp_path)
|
||||||
assert index.records == {}
|
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)
|
index = PoolIndex(tmp_path)
|
||||||
path = tmp_path / "Filmy" / "Matrix.mkv"
|
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||||
index.set(path, {"title": "Matrix"})
|
|
||||||
|
|
||||||
assert index.get(path) == {"title": "Matrix"}
|
assert index.get_movie("tmdb-603") == {"title": "Matrix", "media": []}
|
||||||
# key is the pool-relative POSIX path
|
assert "tmdb-603" in index.records
|
||||||
assert "Filmy/Matrix.mkv" 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 = 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"))
|
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):
|
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)
|
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)
|
index = PoolIndex(tmp_path)
|
||||||
path = tmp_path / "Filmy" / "A.mkv"
|
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||||
index.set(path, {"title": "A"})
|
index.delete_movie("tmdb-603")
|
||||||
index.delete(path)
|
|
||||||
|
|
||||||
assert index.get(path) is None
|
assert index.get_movie("tmdb-603") is None
|
||||||
assert PoolIndex(tmp_path).get(path) 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):
|
def test_corrupt_index_loads_empty(self, tmp_path):
|
||||||
(tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8")
|
(tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8")
|
||||||
@@ -50,8 +74,116 @@ class TestPoolIndex:
|
|||||||
assert index.records == {}
|
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:
|
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):
|
def test_index_backed_file_writes_no_sidecar(self, tmp_path):
|
||||||
index = PoolIndex(tmp_path)
|
index = PoolIndex(tmp_path)
|
||||||
@@ -62,7 +194,7 @@ class TestFileWithIndex:
|
|||||||
f = File(movie, TagManager(), index=index)
|
f = File(movie, TagManager(), index=index)
|
||||||
|
|
||||||
assert not (movie.parent / ".Matrix.mkv.!tag").exists() # no sidecar
|
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
|
assert f.tags == [] # no automatic tags
|
||||||
|
|
||||||
def test_index_backed_metadata_persists_across_reload(self, tmp_path):
|
def test_index_backed_metadata_persists_across_reload(self, tmp_path):
|
||||||
@@ -94,4 +226,24 @@ class TestFileWithIndex:
|
|||||||
|
|
||||||
f = File(movie, TagManager(), index=index)
|
f = File(movie, TagManager(), index=index)
|
||||||
f.delete_metadata()
|
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}
|
||||||
|
|||||||
+410
-33
@@ -1,24 +1,34 @@
|
|||||||
"""Tests for series naming/scanning (src.core.series) + FileManager episodes."""
|
"""Tests for series naming/scanning (src.core.series) + FileManager episodes."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.series import (
|
from src.core.series import (
|
||||||
|
Episode,
|
||||||
|
collect_episode_files,
|
||||||
episode_filename,
|
episode_filename,
|
||||||
is_canonical_episode,
|
is_canonical_episode,
|
||||||
|
is_season_folder,
|
||||||
parse_episode,
|
parse_episode,
|
||||||
|
parse_episode_code,
|
||||||
|
parse_episode_part,
|
||||||
|
sanitize_title,
|
||||||
scan_series,
|
scan_series,
|
||||||
season_folder_name,
|
season_folder_name,
|
||||||
series_folder_stem,
|
series_folder_stem,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name,expected", [
|
@pytest.mark.parametrize(
|
||||||
|
"name,expected",
|
||||||
|
[
|
||||||
("Breaking Bad S01E02.mkv", (1, 2)),
|
("Breaking Bad S01E02.mkv", (1, 2)),
|
||||||
("bb.s1e2.mkv", (1, 2)),
|
("bb.s1e2.mkv", (1, 2)),
|
||||||
("Show 01x02 x264.mkv", (1, 2)),
|
("Show 01x02 x264.mkv", (1, 2)),
|
||||||
("Series.S01E02E03.mkv", (1, 2)), # multi-ep → first
|
("Series.S01E02E03.mkv", (1, 2)), # multi-ep → first
|
||||||
("Series S12E145.mkv", (12, 145)),
|
("Series S12E145.mkv", (12, 145)),
|
||||||
("No code here.mkv", None),
|
("No code here.mkv", None),
|
||||||
])
|
],
|
||||||
|
)
|
||||||
def test_parse_episode(name, expected):
|
def test_parse_episode(name, expected):
|
||||||
assert 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"
|
assert season_folder_name(12) == "Season 12"
|
||||||
|
|
||||||
|
|
||||||
def test_episode_filename_keeps_original_name():
|
def test_episode_filename_is_code_plus_series_title():
|
||||||
assert episode_filename(1, 2, "bb.pilot.hdtv", ".mkv") == "S01E02 - bb.pilot.hdtv.mkv"
|
assert episode_filename(1, 2, "Most!", ".mkv") == "S01E02 - Most!.mkv"
|
||||||
# already-canonical stem → no doubled prefix
|
assert episode_filename(0, 3, "Most!", ".mkv") == "S00E03 - Most!.mkv"
|
||||||
assert episode_filename(1, 2, "S01E02 - bb.pilot.hdtv", ".mkv") == \
|
# release string is NOT kept — only the code + series title
|
||||||
"S01E02 - bb.pilot.hdtv.mkv"
|
assert (
|
||||||
# stem that was only the code → collapses to just the code
|
episode_filename(12, 145, "Breaking Bad", ".mkv")
|
||||||
assert episode_filename(3, 4, "S03E04", ".mkv") == "S03E04.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():
|
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("Neznámý", None, 42) == "Neznámý [tmdbid-42]"
|
||||||
assert series_folder_stem("Jen název", None, None) == "Jen název"
|
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):
|
def test_is_canonical_episode(tmp_path):
|
||||||
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
||||||
folder.mkdir(parents=True)
|
folder.mkdir(parents=True)
|
||||||
good = folder / "S01E02 - bb.pilot.mkv"
|
good = folder / "S01E02 - Breaking Bad.mkv"
|
||||||
good.write_bytes(b"x")
|
good.write_bytes(b"x")
|
||||||
bad = folder / "bb.s01e02.mkv"
|
bad = folder / "bb.s01e02.mkv"
|
||||||
bad.write_bytes(b"x")
|
bad.write_bytes(b"x")
|
||||||
assert is_canonical_episode(good)
|
wrong_body = folder / "S01E02 - old release.mkv"
|
||||||
assert not is_canonical_episode(bad)
|
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):
|
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
|
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:
|
class TestFileManagerSeries:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def file_manager(self, tmp_path, monkeypatch):
|
def file_manager(self, tmp_path, monkeypatch):
|
||||||
import src.core.config as config_module
|
import src.core.config as config_module
|
||||||
|
|
||||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
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.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
fm = FileManager(TagManager())
|
fm = FileManager(TagManager())
|
||||||
fm.set_pool_dir(tmp_path / "pool")
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
return fm
|
return fm
|
||||||
@@ -89,24 +219,42 @@ class TestFileManagerSeries:
|
|||||||
(tmp_path / n).write_bytes(b"x")
|
(tmp_path / n).write_bytes(b"x")
|
||||||
result = file_manager.import_episodes(
|
result = file_manager.import_episodes(
|
||||||
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S01E02.mkv"],
|
[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
|
assert result["imported"] == 2
|
||||||
base = (tmp_path / "pool" / "Seriály"
|
base = (
|
||||||
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
|
tmp_path
|
||||||
assert (base / "S01E01 - bb.mkv").exists()
|
/ "pool"
|
||||||
assert (base / "S01E02 - bb.mkv").exists()
|
/ "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):
|
def test_import_episodes_into_mixed_library(self, file_manager, tmp_path):
|
||||||
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||||
result = file_manager.import_episodes(
|
result = file_manager.import_episodes(
|
||||||
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396,
|
[tmp_path / "bb.S01E01.mkv"],
|
||||||
library="Hlavní")
|
"Breaking Bad",
|
||||||
|
year=2008,
|
||||||
|
tmdb_id=1396,
|
||||||
|
library="Hlavní",
|
||||||
|
)
|
||||||
|
|
||||||
assert result["imported"] == 1
|
assert result["imported"] == 1
|
||||||
assert (tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]"
|
assert (
|
||||||
/ "Season 01" / "S01E01 - bb.mkv").exists()
|
tmp_path
|
||||||
|
/ "pool"
|
||||||
|
/ "Hlavní"
|
||||||
|
/ "Breaking Bad (2008) [tmdbid-1396]"
|
||||||
|
/ "Season 01"
|
||||||
|
/ "S01E01 - Breaking Bad.mkv"
|
||||||
|
).exists()
|
||||||
# a mixed library is scanned for series too
|
# a mixed library is scanned for series too
|
||||||
found = file_manager.load_series()
|
found = file_manager.load_series()
|
||||||
assert [s.title for s in found] == ["Breaking Bad"]
|
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):
|
def test_import_episodes_skips_uncoded(self, file_manager, tmp_path):
|
||||||
(tmp_path / "random.mkv").write_bytes(b"x")
|
(tmp_path / "random.mkv").write_bytes(b"x")
|
||||||
result = file_manager.import_episodes(
|
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["imported"] == 0
|
||||||
assert result["skipped_no_code"] == ["random.mkv"]
|
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):
|
def test_rename_episodes_to_canonical(self, file_manager, tmp_path):
|
||||||
# a mis-named episode sitting in the wrong place
|
# a mis-named episode sitting in the wrong place
|
||||||
folder = (tmp_path / "pool" / "Seriály"
|
folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
|
||||||
/ "Breaking Bad (2008) [tmdbid-1396]")
|
|
||||||
folder.mkdir(parents=True)
|
folder.mkdir(parents=True)
|
||||||
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
|
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
|
||||||
|
|
||||||
@@ -129,20 +436,90 @@ class TestFileManagerSeries:
|
|||||||
assert len(series_list) == 1
|
assert len(series_list) == 1
|
||||||
result = file_manager.rename_episodes_to_canonical(series_list[0])
|
result = file_manager.rename_episodes_to_canonical(series_list[0])
|
||||||
|
|
||||||
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - bb.HDTV.mkv")]
|
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
|
||||||
assert (folder / "Season 01" / "S01E03 - bb.HDTV.mkv").exists()
|
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 = tmp_path / "pool" / "Seriály" / "Wrong Name"
|
||||||
(folder / "Season 01").mkdir(parents=True)
|
(folder / "Season 01").mkdir(parents=True)
|
||||||
(folder / "Season 01" / "old S01E01.mkv").write_bytes(b"x")
|
(folder / "Season 01" / "old S01E01.mkv").write_bytes(b"x")
|
||||||
|
|
||||||
ser = file_manager.load_series()[0]
|
ser = file_manager.load_series()[0]
|
||||||
result = file_manager.apply_series_edits(
|
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"
|
new_folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
|
||||||
/ "Breaking Bad (2008) [tmdbid-1396]")
|
|
||||||
assert result["folder"] == new_folder
|
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()
|
assert not folder.exists()
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
import pytest
|
|
||||||
from src.core.tag import Tag
|
from src.core.tag import Tag
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from src.core.tag_manager import TagManager, DEFAULT_TAGS
|
|
||||||
from src.core.tag import Tag
|
from src.core.tag import Tag
|
||||||
|
from src.core.tag_manager import DEFAULT_TAGS, TagManager
|
||||||
|
|
||||||
|
|
||||||
class TestTagManager:
|
class TestTagManager:
|
||||||
@@ -55,7 +56,7 @@ class TestTagManager:
|
|||||||
|
|
||||||
def test_add_tag_creates_category(self, tag_manager):
|
def test_add_tag_creates_category(self, tag_manager):
|
||||||
"""Test že add_tag vytvoří kategorii pokud neexistuje"""
|
"""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
|
assert "NovaKategorie" in tag_manager.tags_by_category
|
||||||
|
|
||||||
def test_add_multiple_tags_same_category(self, tag_manager):
|
def test_add_multiple_tags_same_category(self, tag_manager):
|
||||||
|
|||||||
+89
-12
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for the TMDb client (src.core.tmdb). Network is monkeypatched."""
|
"""Tests for the TMDb client (src.core.tmdb). Network is monkeypatched."""
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -13,8 +14,9 @@ def _with_key(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def _resp(status=200, payload=None):
|
def _resp(status=200, payload=None):
|
||||||
return SimpleNamespace(status_code=status, ok=(200 <= status < 300),
|
return SimpleNamespace(
|
||||||
json=lambda: (payload or {}))
|
status_code=status, ok=(200 <= status < 300), json=lambda: payload or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_api_available_reflects_key(monkeypatch):
|
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):
|
def test_lookup_movie_returns_title_and_year(monkeypatch):
|
||||||
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
|
monkeypatch.setattr(
|
||||||
200, {"id": 603, "title": "Matrix", "release_date": "1999-03-31"}))
|
tmdb.requests,
|
||||||
|
"get",
|
||||||
|
lambda *a, **k: _resp(
|
||||||
|
200, {"id": 603, "title": "Matrix", "release_date": "1999-03-31"}
|
||||||
|
),
|
||||||
|
)
|
||||||
result = tmdb.lookup_movie(603)
|
result = tmdb.lookup_movie(603)
|
||||||
assert result == TmdbResult(603, "Matrix", "1999", "movie")
|
assert result == TmdbResult(603, "Matrix", "1999", "movie")
|
||||||
|
|
||||||
|
|
||||||
def test_lookup_movie_falls_back_to_original_title(monkeypatch):
|
def test_lookup_movie_falls_back_to_original_title(monkeypatch):
|
||||||
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
|
monkeypatch.setattr(
|
||||||
200, {"id": 1, "original_title": "Solyaris", "release_date": "1972-01-01"}))
|
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"
|
assert tmdb.lookup_movie(1).title == "Solyaris"
|
||||||
|
|
||||||
|
|
||||||
def test_lookup_movie_missing_date_gives_no_year(monkeypatch):
|
def test_lookup_movie_missing_date_gives_no_year(monkeypatch):
|
||||||
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
|
monkeypatch.setattr(
|
||||||
200, {"id": 2, "title": "Bez data", "release_date": ""}))
|
tmdb.requests,
|
||||||
|
"get",
|
||||||
|
lambda *a, **k: _resp(200, {"id": 2, "title": "Bez data", "release_date": ""}),
|
||||||
|
)
|
||||||
assert tmdb.lookup_movie(2).year is None
|
assert tmdb.lookup_movie(2).year is None
|
||||||
|
|
||||||
|
|
||||||
def test_lookup_tv_uses_name_and_first_air(monkeypatch):
|
def test_lookup_tv_uses_name_and_first_air(monkeypatch):
|
||||||
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
|
monkeypatch.setattr(
|
||||||
200, {"id": 1396, "name": "Breaking Bad", "first_air_date": "2008-01-20"}))
|
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")
|
result = tmdb.lookup(1396, kind="tv")
|
||||||
assert result == TmdbResult(1396, "Breaking Bad", "2008", "tv")
|
assert result == TmdbResult(1396, "Breaking Bad", "2008", "tv")
|
||||||
|
|
||||||
|
|
||||||
def test_search_movie_maps_results(monkeypatch):
|
def test_search_movie_maps_results(monkeypatch):
|
||||||
payload = {"results": [
|
payload = {
|
||||||
|
"results": [
|
||||||
{"id": 603, "title": "Matrix", "release_date": "1999-03-31"},
|
{"id": 603, "title": "Matrix", "release_date": "1999-03-31"},
|
||||||
{"id": 605, "title": "Matrix Revolutions", "release_date": "2003-11-05"},
|
{"id": 605, "title": "Matrix Revolutions", "release_date": "2003-11-05"},
|
||||||
]}
|
]
|
||||||
|
}
|
||||||
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload))
|
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload))
|
||||||
results = tmdb.search_movie("Matrix", year="1999")
|
results = tmdb.search_movie("Matrix", year="1999")
|
||||||
assert [r.tmdb_id for r in results] == [603, 605]
|
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 test_search_movie_empty_query_skips_network(monkeypatch):
|
||||||
def boom(*a, **k):
|
def boom(*a, **k):
|
||||||
raise AssertionError("should not hit network")
|
raise AssertionError("should not hit network")
|
||||||
|
|
||||||
monkeypatch.setattr(tmdb.requests, "get", boom)
|
monkeypatch.setattr(tmdb.requests, "get", boom)
|
||||||
assert tmdb.search_movie(" ") == []
|
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):
|
def test_missing_key_raises(monkeypatch):
|
||||||
monkeypatch.delenv("TMDB_API_KEY", raising=False)
|
monkeypatch.delenv("TMDB_API_KEY", raising=False)
|
||||||
with pytest.raises(TmdbError):
|
with pytest.raises(TmdbError):
|
||||||
@@ -84,6 +160,7 @@ def test_network_error_raises(monkeypatch):
|
|||||||
|
|
||||||
def boom(*a, **k):
|
def boom(*a, **k):
|
||||||
raise requests.ConnectionError("down")
|
raise requests.ConnectionError("down")
|
||||||
|
|
||||||
monkeypatch.setattr(tmdb.requests, "get", boom)
|
monkeypatch.setattr(tmdb.requests, "get", boom)
|
||||||
with pytest.raises(TmdbError):
|
with pytest.raises(TmdbError):
|
||||||
tmdb.lookup_movie(603)
|
tmdb.lookup_movie(603)
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,7 @@
|
|||||||
import pytest
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.core.utils import list_files
|
from src.core.utils import list_files
|
||||||
|
|
||||||
|
|
||||||
@@ -171,7 +173,7 @@ class TestUtils:
|
|||||||
files = list_files(temp_dir)
|
files = list_files(temp_dir)
|
||||||
|
|
||||||
# Najdeme soubor v subdir2
|
# 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
|
# Cesta by měla obsahovat obě složky
|
||||||
assert "subdir1" in str(file5)
|
assert "subdir1" in str(file5)
|
||||||
|
|||||||
@@ -0,0 +1,711 @@
|
|||||||
|
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.14.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
|
{ name = "loguru" },
|
||||||
|
{ 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 = "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 = "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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user