Compare commits

9 Commits
128 changed files with 25404 additions and 11540 deletions
+9 -5
View File
@@ -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/
+529 -48
View File
@@ -16,71 +16,552 @@ Each version entry uses these sections (include only those that apply):
- Follow semantic versioning: `MAJOR.MINOR.PATCH` - Follow semantic versioning: `MAJOR.MINOR.PATCH`
- Newest version goes at the top - Newest version goes at the top
- Always update this file before bumping the version in `pyproject.toml` - Always update this file before bumping the version in `Cargo.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 ## Unreleased
### Changed
- **The release binary is 2.5× smaller** (`curator` 42.7 → 17 MB, `tipy-dne`
6 → 1.8 MB): the release profile strips debug symbols, uses LTO with a single
codegen unit and optimises for size (`opt-level = "s"`), and the GUI renders
through OpenGL (`glow`) instead of `wgpu`.
### Dependencies
- `eframe` with the `glow` renderer only (default features off; `wgpu` and the
web screen-reader feature dropped).
## 2.0.0 — 2026-10-17
### Changed
- **Curator is now a Rust application.** The whole app — core and GUI — was
rewritten in Rust; the GUI is egui/eframe instead of PySide6. Behaviour and
on-disk formats are unchanged: the pool index (v2), tag schema, file naming,
versions/parts, series layout and miniDLNA output are the same, so an
existing pool opens as-is. Every menu action and dialog of the Python version
is there.
- Long operations (import, TMDb refresh, integrity scan, bulk renames, miniDLNA
generation) run in the background with a progress bar and a cancel button;
TMDb / ČSFD / Jellyfin lookups no longer freeze the window.
- **The global config moved** from `.Curator.!gtag` next to the script to the
platform config directory (`~/.config/curator/.Curator.!gtag` on Linux);
`CURATOR_CONFIG` overrides the location. An old `.Curator.!gtag` in the
working directory is read on first run, so the pool and output paths carry
over.
- **`tipy-dne` is a Rust binary** (built next to `curator`) replacing
`scripts/tipy_dne.py`; `scripts/tipy-dne.service` now runs it. Tips links are
named by the clean movie title, the same as the app's own tips folder.
- The status-bar paths are truncated (full path on hover) instead of
overlapping the status text.
### Fixed
- **"Tipy dne" from the systemd timer found no movies** since the pool index
moved to v2 — the script read index keys as file paths. `tipy-dne` reads the
media files of each movie record.
### Removed
- The Python sources, PyInstaller spec, `prebuild.py` and the committed
`dist/Curator` binary (a release build is `target/release/curator`).
- The one-off Python migration scripts for old ČSFD-era tag formats
(`migrate_tag_category`, `split_country_tags`, `strip_rating_bands`,
`strip_tag_categories`); they remain in the history of `devel`.
- The detailed per-function pages in `docs/modules/`; `cargo doc` generates the
API reference from the source.
### Dependencies
- Rust crates replace the Python stack: `eframe`/`egui`/`egui_extras` (GUI),
`rfd` (file dialogs), `reqwest` (HTTP, OS certificate store), `scraper` +
`ego-tree` (ČSFD), `sha2` (Anubis proof-of-work), `roxmltree` (NFO),
`serde`/`serde_json`, `regex`, `unicode-normalization`, `chrono`, `rand`,
`dirs`, `dotenvy`, `tracing`, `thiserror`/`anyhow`, `file-id`, `indexmap`;
dev: `tempfile`, `egui_kittest`.
## 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
library filter offered only `movie`-kind libraries (falling back to a default library filter offered only `movie`-kind libraries (falling back to a default
"Filmy"), so a pool of only mixed/series/homevideo libraries had nowhere to put "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
Generated
+6161
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "curator"
version = "2.0.0"
edition = "2024"
description = "Jellyfin-first movie and series library manager"
authors = ["Jan Doubravský <jan.doubravsky@gmail.com>"]
default-run = "curator"
[dependencies]
anyhow = "1.0.104"
chrono = "0.4.45"
dirs = "7.0.0"
dotenvy = "0.15.7"
eframe = { version = "0.37.0", default-features = false, features = ["accesskit", "clipboard", "default_fonts", "links", "system_fonts", "wayland", "x11", "glow"] }
ego-tree = "0.11.0"
egui = "0.37.0"
egui_extras = "0.37.0"
file-id = "0.2.3"
indexmap = "2.14.2"
rand = "0.10.3"
regex = "1.13.1"
reqwest = { version = "0.13.5", features = ["blocking", "cookies", "json", "query"] }
rfd = "0.17.2"
roxmltree = "0.21.1"
scraper = "0.27.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.154", features = ["preserve_order"] }
sha2 = "0.11.1"
thiserror = "2.0.21"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
unicode-normalization = "0.1.26"
url = "2.5.8"
[dev-dependencies]
egui_kittest = { version = "0.37.0", features = ["eframe", "wgpu", "snapshot"] }
tempfile = "3.27.0"
[profile.release]
# smaller binary: no debug symbols, whole-program optimisation
strip = true
lto = true
codegen-units = 1
opt-level = "s"
-35
View File
@@ -1,35 +0,0 @@
# Imports
from src.ui.qt_app import run
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
def _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.
"""
try:
import truststore
truststore.inject_into_ssl()
except Exception:
pass
class State:
def __init__(self) -> None:
self.tagmanager = TagManager()
self.filehandler = FileManager(self.tagmanager)
def main() -> None:
_use_system_certificates()
state = State()
run(state.filehandler, state.tagmanager)
if __name__ == "__main__":
main()
-45
View File
@@ -1,45 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['Curator.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='Curator',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
onefile=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='Curator',
)
-232
View File
@@ -1,232 +0,0 @@
# Python Development Guidelines
**Document Version:** v11
> **Note on Versioning:**
> - This document version is independent — reused across projects
> - **Project version** source of truth: `pyproject.toml`
> - Version propagates: `pyproject.toml` → `constants.py` → code
> - `CHANGELOG.md` uses project version from `pyproject.toml`
## Related Documents
- **README.md** — Project overview, tool descriptions, build instructions
- **AGENTS.md** — Rules for AI assistants
- **PROJECT.md** — Project goals and current state
- **CHANGELOG.md** — Version history
### Documentation Organization
All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root.
The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_PYTHON.md`, `PROJECT.md`, `CHANGELOG.md`.
---
## 1. Code Style
- **PEP8** with 120-character lines (Ruff)
- **4 spaces** indentation
- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants
- **Type hints** required on all functions
- **Import order**: stdlib → third-party → local
---
## 2. SOLID Principles
- **SRP** — One class = one responsibility
- **OCP** — Open for extension, closed for modification
- **LSP** — Subclasses substitutable for parents
- **ISP** — Small interfaces over large ones
- **DIP** — Depend on abstractions
---
## 3. Dependency Injection
Pass dependencies via constructor. Never instantiate dependencies inside a class.
---
## 4. Protocols Over Inheritance
Prefer `typing.Protocol` and composition over class inheritance.
---
## 5. Data Classes
Use `@dataclass` for internal data structures, `pydantic.BaseModel` for data that requires validation.
---
## 6. Logging and Console Output
### Logging — loguru
Use **loguru** for all internal logging. Never log secrets, passwords, tokens, or API keys.
#### Log sinks
| Sink | Level | Format |
|------|-------|--------|
| File `logs/{AppName}_{time}.log` | `DEBUG` | full (timestamp + level + message) |
| stdout | `INFO` | full |
File sink retains **max 10 log files** (`retention=10`). No rotation by size — each run creates a new file via `{time}` in the filename.
The `DEBUG` sink is only active when `constants.DEFAULT_DEBUG` is `True` (controlled by `ENV_DEBUG=true` in `.env`).
Additional sinks (e.g. GUI log panels) may be added per project as needed.
#### Log levels
| Level | When to use |
|-------|-------------|
| `DEBUG` | Per-item detail: individual file reads/writes, cache hits, per-row operations |
| `INFO` | User-visible milestones: file loaded, process started/finished, file saved |
| `WARNING` | Recoverable issues: fallback used, unexpected but non-fatal state |
| `ERROR` | Failures the user must know about: missing file, failed save, missing config — operation cannot continue |
### Console output — print()
`print()` is **allowed** for direct user-facing console communication: input prompts, progress lines, and result summaries. This applies to console tools that interact with the user through stdin/stdout.
`print()` is **not** a substitute for loguru. It must not be used for debugging or internal event tracking.
---
## 7. Environment and Secrets
- Secrets in `.env` file; controlled by `ENV_DEBUG=true/false`
- Load via `python-dotenv` and `os.getenv()`
- Never commit `.env`
---
## 8. Error Handling
Define specific exception types. Use a fail-fast approach — surface errors early rather than silently continuing.
---
## 9. Testing
- **pytest** only — no `unittest`, no `TestCase` classes, no `self.assert*`
- Arrange-Act-Assert pattern
- Test naming: `test_<action>_<context>`
---
## 10. Tooling
| Tool | Purpose |
|------|---------|
| **Ruff** | Formatting and linting |
| **mypy** | Static type checking |
| **pytest** | Testing |
Run before every commit:
```bash
poetry run ruff check
poetry run mypy
poetry run pytest
```
---
## 11. Poetry
```bash
poetry install # Install all dependencies
poetry add <pkg> # Add runtime dependency
poetry add --group dev <pkg> # Add dev dependency
poetry remove <pkg> # Remove dependency
poetry run <cmd> # Run command in virtualenv
```
Never edit `pyproject.toml` directly to add or remove dependencies.
### poetry.lock
`poetry.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible.
---
## 12. Project Structure
```
project/
├── entry_point.py # Entry point(s) — named by purpose or tool name
├── src/ # All application modules
├── tests/ # Tests
├── docs/ # Detailed documentation
├── .venv/ # Virtual environment (managed by Poetry)
└── pyproject.toml # Project config and dependencies
```
A project may have multiple entry points (e.g. `cli.py`, `gui.py`, or per-tool scripts).
---
## 13. Distribution and Deployment
When a project is distributed as a standalone executable (no Python required on target machine):
- Use **PyInstaller** to compile each entry point into a single `.exe`
- Each tool has its own `.spec` file in the project root
- **Console tools use `console=True`** in the `.spec` — they rely on `input()` and `print()` for user interaction. GUI applications turn the console off with `ENV_BUILD_CONSOLE=false`; that flag is independent of `ENV_DEBUG`
- Compiled executables are stored in `dist/` and **committed to the repository** — the repository serves as the distribution channel for internal teams
- `.gitignore` must **not** exclude `dist/` in projects that use this deployment model
### prebuild.py
`prebuild.py` runs before PyInstaller. It verifies that the active interpreter is the project `.venv`, prints the resolved version, and rewrites the `console=` line in the `.spec` to match `.env`.
| Variable | Default | Effect |
|----------|---------|--------|
| `ENV_BUILD_CONSOLE` | `true` | Value written into `console=` in the `.spec` |
| `ENV_BUILD_SPEC` | `<project folder>.spec` | Which `.spec` file to update |
`prebuild.py` touches one `.spec` per run — with several entry points, set `ENV_BUILD_SPEC` for each build.
Build commands:
```bash
poetry run python prebuild.py
poetry run pyinstaller ToolName.spec
```
> This section applies only to projects that produce standalone executables. Skip for libraries or web services.
---
## 14. Versioning
- Follow **semantic versioning**: `MAJOR.MINOR.PATCH`
- Version is defined in `pyproject.toml` under `[project]`
- Always ask before bumping the version — never increment automatically
- Update `CHANGELOG.md` before bumping the version
---
## 15. Documentation and Task Management
- Keep `PROJECT.md` and `CHANGELOG.md` up to date when making changes
- Document architectural changes in this file or in `docs/`
### Task notation
Tasks are written as single-line comments directly in code, using the **Todo Tree** tags defined in `AGENTS.md` (`TODO`, `FIXME`, `BUG`, `HACK`, `NOTE`). `PROJECT.md` carries only cross-cutting tasks that have no single place in the code.
```python
# TODO: extract this into a separate loader
# FIXME: crashes on an empty file
# BUG: rounding is off by one cent on negative amounts
# HACK: temporary workaround until the API adds paging
# NOTE: order matters here, the parser is stateful
```
No other task format is used — **no checkboxes, no numbered lists in documentation**.
If a tag already exists at a specific location in code, do not repeat it in `PROJECT.md`.
+223
View File
@@ -0,0 +1,223 @@
# Rust Application Development Guidelines
**Document Version:** v4
> **Note on Versioning:**
> - This document version is independent — reused across projects
> - **Project version** source of truth: `Cargo.toml` under `[package]`
> - `CHANGELOG.md` uses project version from `Cargo.toml`
## Related Documents
- **README.md** — Project overview, build instructions, usage
- **AGENTS.md** — Rules for AI assistants
- **PROJECT.md** — Project goals and current state
- **CHANGELOG.md** — Version history
### Documentation Organization
All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root.
The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_RUST.md`, `PROJECT.md`, `CHANGELOG.md`.
---
## 1. Code Style
- **Rust edition:** 2024 (requires Rust 1.85 or newer)
- **100-character lines** (rustfmt default `max_width`)
- Format with **rustfmt** — run `cargo fmt` before every commit
- Lint with **clippy** — run `cargo clippy -- -D warnings` before every commit
- **snake_case** functions/variables/modules, **PascalCase** types/traits, **SCREAMING_SNAKE_CASE** constants
- Keep functions short and focused — one responsibility per function
---
## 2. Cargo
```bash
cargo add <crate> # Add runtime dependency
cargo add --dev <crate> # Add dev dependency
cargo remove <crate> # Remove dependency
cargo build # Debug build
cargo build --release # Release build
cargo run # Run debug build
cargo run --release # Run release build
cargo test # Run all tests
cargo fmt # Format code
cargo clippy -- -D warnings # Lint (treat warnings as errors)
```
Never edit `Cargo.toml` dependency versions by hand — use `cargo add`.
---
## 3. Project Structure
```
project/
├── src/
│ ├── main.rs # Entry point
│ ├── lib.rs # Optional: shared library code
│ └── <module>/
│ └── mod.rs
├── tests/ # Integration tests
├── docs/ # Detailed documentation
├── Cargo.toml
└── Cargo.lock # Commit for applications, not for libraries
```
A project may have multiple binaries under `src/bin/`.
---
## 4. Error Handling
- Use **thiserror** for defining typed errors in library/domain code
- Use **anyhow** for application-level error propagation (`main`, CLI handlers)
- Never use `.unwrap()` in production code — use `?` or explicit handling
- `.expect("reason")` is acceptable only when the invariant is guaranteed and documented
---
## 5. Logging
Use **tracing** for structured logging.
```toml
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
```
```rust
use tracing::{debug, info, warn, error};
fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
}
```
Control log level via `RUST_LOG` environment variable:
```bash
RUST_LOG=debug cargo run
RUST_LOG=my_app=info,warn cargo run
```
#### Log levels
| Level | When to use |
|-------|-------------|
| `debug` | Per-item detail: internal state, cache hits, per-item operations |
| `info` | User-visible milestones: process started/finished, file saved |
| `warn` | Recoverable issues: fallback used, unexpected but non-fatal state |
| `error` | Failures the user must know about — operation cannot continue |
Never use `println!` for debugging or internal logging.
---
## 6. Environment and Secrets
- Store secrets in `.env` file
- Load via **dotenvy** crate and `std::env::var()`
- Never commit `.env`
```rust
dotenvy::dotenv().ok();
let api_key = std::env::var("API_KEY").expect("API_KEY must be set");
```
---
## 7. Testing
- Use Rust's built-in test framework — `#[test]` and `#[cfg(test)]`
- Unit tests live in the same file as the code, in a `mod tests` block
- Integration tests live in `tests/`
- Use `assert_eq!`, `assert!`, `assert_ne!` — no external test frameworks needed
- Test naming: `test_<action>_<context>`
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_input() {
assert_eq!(parse("42"), Ok(42));
}
}
```
---
## 8. Tooling
| Tool | Purpose |
|------|---------|
| **rustfmt** | Code formatting |
| **clippy** | Linting |
| **cargo test** | Testing |
| **cargo build --release** | Production build |
Run before every commit:
```bash
cargo fmt
cargo clippy -- -D warnings
cargo test
```
---
## 9. Distribution
For standalone executables:
```bash
cargo build --release
# Binary is at: target/release/<project-name>
```
For cross-compilation (e.g. Windows from Linux):
```bash
cargo install cross
cross build --release --target x86_64-pc-windows-gnu
```
`Cargo.lock` **is committed** for applications — it ensures reproducible builds.
---
## 10. Versioning
- Follow **semantic versioning**: `MAJOR.MINOR.PATCH`
- Version is defined in `Cargo.toml` under `[package]`
- Always ask before bumping the version — never increment automatically
- Update `CHANGELOG.md` before bumping the version
---
## 11. Documentation and Task Management
- Keep `PROJECT.md` and `CHANGELOG.md` up to date when making changes
- Document architectural changes in this file or in `docs/`
### Task notation
Tasks are written as single-line comments directly in code, using the **Todo Tree** tags defined in `AGENTS.md` (`TODO`, `FIXME`, `BUG`, `HACK`, `NOTE`). `PROJECT.md` carries only cross-cutting tasks that have no single place in the code.
```rust
// TODO: extract this into a separate module
// FIXME: panics on an empty slice
// BUG: off-by-one when the buffer is exactly full
// HACK: temporary workaround until the crate adds paging
// NOTE: order matters here, the parser is stateful
```
No other task format is used — **no checkboxes, no numbered lists in documentation**.
If a tag already exists at a specific location in code, do not repeat it in `PROJECT.md`.
+198 -45
View File
@@ -39,33 +39,43 @@ Curator manages a personal movie library based on two folders:
## Current state ## Current state
- Inherited from Tagger: `Tag`, `TagManager`, `File` (sidecar metadata), - **Rust rewrite (branch `rust`).** Curator is a Rust application: the core
`FileManager` (folder scan, filtering, ignore patterns), 3-level config, (`src/core/`) is a library with the same behaviour and on-disk formats as the
`HardlinkManager` (create/sync/cleanup), pytest suite. Python version (pool index v2, tag schema, naming, global config keys), and
- Rename Tagger → Curator done across code, spec, config filenames the GUI is egui/eframe (`src/ui/`). An existing pool opens unchanged. The
(`.Curator.!gtag` / `.Curator.!ftag`) and tests. Python sources live on in the history of `devel` as the reference.
- **PySide6 GUI** (`src/ui/qt_app.py`) reframed around the Filmotéka workflow is - `FileManager` is the service layer; `File` is a view on one media entry of an
the entry point; the old tkinter `src/ui/gui.py` is retained for reference. identity-keyed movie record in `<pool>/.Curator.!index`; series are scanned
- **Pool + Filmotéka wired up:** global config holds `pool_dir` / `filmoteka_dir`; live from disk.
`FileManager` creates `Filmy`/`Seriály`, imports movies (copy → `Title.ext`), - The standalone `tipy-dne` binary replaces `scripts/tipy_dne.py` (which had
loads the pool, and the GUI generates the Filmotéka tree via `HardlinkManager`. stopped finding movies after the index moved to v2). The one-off Python
- `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index** migration scripts for old ČSFD tag formats were not ported.
(`<pool>/.Curator.!index`, see `pool_index.py`); `File` writes there when an - Global config moved from `.Curator.!gtag` next to the script to the platform
index is injected. Without an index a `File` is an in-memory object only — the config directory (`CURATOR_CONFIG` overrides it); an old `.Curator.!gtag` in
old per-file `.!tag` sidecars and the per-folder `.!ftag` config have been the working directory is picked up on first run.
removed.
### GUI decision ### GUI decision
The GUI was **reframed around the Filmotéka** (not kept as a generic tagger) and The GUI is **egui** (eframe): pure Rust, one static binary, straightforward
**rewritten in PySide6**: Pool/Filmotéka setup, Import movie, tag-filter sidebar, Windows builds. It mirrors the PySide6 layout — menu, tag-filter sidebar, movie
movie table, and one-click Filmotéka generation. and series table, detail panel, status bar — and every dialog of the Python
version. Long operations run in background tasks with progress and cancel.
## Design decisions ## Design decisions
- **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
+68 -37
View File
@@ -1,60 +1,91 @@
# Curator # Curator
Curator is a desktop **movie-library manager (Filmotéka)**. You import video Curator is a 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
**Filmotéka** — a directory tree of hardlinks into the pool — without ever them 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** tree of hardlinks for simple 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
hardlink-tree machinery is inherited and extended into a full library workflow. files, their names and the library structure. Written in Rust with an egui GUI.
## 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-N].ext`, or a folder once a film has several versions |
| `series` | series | `Series (rok) [tmdbid-N]/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 genres / years / countries / ratings. It is disposable: delete it and
[ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země původu** tags 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 **TMDb id**; Czech titles come from TMDb,
`Seriály` are mirrored 1:1 as hardlinks. 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,
index/disk consistency check.
- Jellyfin integration: trigger a library scan, verify the pool against it.
- miniDLNA tree scoped per library, with "Tipy dne" and "Nově přidané" folders.
## Requirements
- Rust 1.85+ (`rustup`), for building
- `ffmpeg` / `ffprobe` on `PATH` — optional, for the integrity check
## Configuration
Secrets and settings are read from a `.env` file in the working directory
(never committed):
| Variable | Purpose |
|----------|---------|
| `TMDB_API_KEY` | TMDb API key — needed to identify 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 logging and the `-DEV` title |
The pool and output folders are chosen in the app (*Pool* menu) and stored in
the global config (`~/.config/curator/.Curator.!gtag` on Linux, overridable with
`CURATOR_CONFIG`).
## Running ## Running
```bash ```bash
poetry install cargo run --release
poetry run python Curator.py
``` ```
The GUI is built with **PySide6**.
## Development ## Development
```bash ```bash
poetry run pytest # tests cargo fmt # format
poetry run ruff check # lint / format cargo clippy -- -D warnings # lint
poetry run mypy # type checking cargo test # unit tests + headless UI tests
cargo doc --open # API reference
``` ```
## Building a standalone executable `cargo run --example screenshot -- out.png [--action edit]` renders the window
offscreen to a PNG. Architecture and module overview: [`docs/`](docs/README.md).
## Building
```bash ```bash
poetry run pyinstaller Curator.spec cargo build --release
``` ```
The resulting `dist/Curator` is the distribution artifact. The result is `target/release/curator`, plus `target/release/tipy-dne` — a
standalone "Tipy dne" refresher meant for a systemd timer (see
`scripts/tipy-dne.service` / `.timer`).
+44
View File
@@ -0,0 +1,44 @@
# Curator documentation
Curator is a Rust desktop application (egui) 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
tree of hardlinks.
- [Architecture](architecture.md) — layers, on-disk layout, the pool index and the
rules about versions, series and subtitles. Read this first.
- **API reference** — every public type and function is documented in the source;
`cargo doc --open` renders it.
## Module overview
| Source | Purpose |
|--------|---------|
| [`src/main.rs`](../src/main.rs) | Entry point: `.env`, logging, opens the window. |
| [`src/bin/tipy-dne.rs`](../src/bin/tipy-dne.rs) | Standalone "Tipy dne" generator for a systemd timer. |
| [`src/constants.rs`](../src/constants.rs) | App name, version (from `Cargo.toml`), debug flag, window title. |
| [`src/core/config.rs`](../src/core/config.rs) | Global config (window, pool and output paths) and the default tag schema. |
| [`src/core/error.rs`](../src/core/error.rs) | `CoreError`, the error type of every core operation. |
| [`src/core/paths.rs`](../src/core/paths.rs) | `pathlib`-style helpers: suffix/stem, POSIX-relative paths, recursive walk, moves. |
| [`src/core/naming.rs`](../src/core/naming.rs) | The `Title (YYYY) [tmdbid-N].3D.FMT` naming convention: build, parse, guess from filenames. |
| [`src/core/movie.rs`](../src/core/movie.rs) | The movie record (`Movie`, `MediaFile`), identity keys, version/part labels. |
| [`src/core/file.rs`](../src/core/file.rs) | `File`: one pooled video, a view on one media entry of a movie record. |
| [`src/core/pool_index.rs`](../src/core/pool_index.rs) | `PoolIndex`: the `.Curator.!index` JSON store (v1 → v2 migration). |
| [`src/core/series.rs`](../src/core/series.rs) | Series naming (`SxxEyy`, `Season NN`, `.ptN`) and scanning. |
| [`src/core/subtitles.rs`](../src/core/subtitles.rs) | Subtitle sidecars: find, attach, follow their video on rename. |
| [`src/core/tag.rs`](../src/core/tag.rs), [`tag_manager.rs`](../src/core/tag_manager.rs) | Tags (`Category/Name`) and the registry of known tags. |
| [`src/core/transforms.rs`](../src/core/transforms.rs) | Folder-name transforms (rating → ten-point band). |
| [`src/core/nfo.rs`](../src/core/nfo.rs) | Read-only NFO reader (genres, year, countries, rating, ids). |
| [`src/core/legacy_meta.rs`](../src/core/legacy_meta.rs) | Recovery of ČSFD link / title / year from pre-index layouts. |
| [`src/core/tmdb.rs`](../src/core/tmdb.rs) | TMDb client: lookups, searches, details for tags. |
| [`src/core/csfd.rs`](../src/core/csfd.rs) | ČSFD scraper with the Anubis proof-of-work solver — the title bridge to TMDb. |
| [`src/core/jellyfin.rs`](../src/core/jellyfin.rs) | Jellyfin client: library scan, read back movies. |
| [`src/core/http.rs`](../src/core/http.rs) | Shared blocking HTTP client (OS certificate store). |
| [`src/core/integrity.rs`](../src/core/integrity.rs) | Video integrity check via ffmpeg / ffprobe. |
| [`src/core/migrate.rs`](../src/core/migrate.rs) | Classification of TMDb match proposals for an old pool. |
| [`src/core/hardlink_manager.rs`](../src/core/hardlink_manager.rs) | The miniDLNA hardlink tree, mirrors, special folders, obsolete-link sweep. |
| [`src/core/file_manager/`](../src/core/file_manager/) | `FileManager`, the service layer: libraries and settings (`mod.rs`), movies (`movies.rs`), versions and parts (`versions.rs`), series (`series_ops.rs`), tools (`tools.rs`), miniDLNA generation (`minidlna.rs`). |
| [`src/ui/app.rs`](../src/ui/app.rs) | The main window: menu, tag filter, table, detail panel, status bar, actions. |
| [`src/ui/dialogs/`](../src/ui/dialogs/) | One file per dialog (import, edit, series, duplicates, libraries, migration, tags, schema, stats). |
| [`src/ui/common.rs`](../src/ui/common.rs), [`task.rs`](../src/ui/task.rs) | Message boxes, prompts, background jobs and tasks with progress. |
| [`examples/screenshot.rs`](../examples/screenshot.rs) | Developer tool: render the window offscreen to a PNG. |
+228
View File
@@ -0,0 +1,228 @@
# 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 API
reference is generated from the source with `cargo doc --open`.
## Layers
```
src/main.rs entry point — .env, logging, ui::run()
│
▼
src/ui/ egui GUI: CuratorApp (main window) + dialogs/
│ never touches the filesystem directly
▼
src/core/file_manager/ FileManager — the service layer
│ owns: filelist, tag manager, pool index, global config
│
├── domain model file.rs (File), movie.rs (Movie, MediaFile),
│ series.rs (Series, Episode), tag.rs, tag_manager.rs
├── persistence pool_index.rs (.Curator.!index), config.rs
│ legacy_meta.rs (reads pre-index layouts)
├── naming naming.rs (movie stems, 3D), series.rs (SxxEyy),
│ subtitles.rs (sidecar names), movie::identity_key
├── external services tmdb.rs, csfd.rs, jellyfin.rs (over http.rs),
│ nfo.rs (read-only)
└── output and checks hardlink_manager.rs (miniDLNA tree),
transforms.rs, integrity.rs (ffmpeg), migrate.rs
```
Dependencies only point downwards. The GUI calls `FileManager` methods and gets
plain values back; `core` never depends on egui. Files are addressed by their
current path: methods take a `&Path` and return the new path when they move it.
### Entry point
`main.rs` loads `.env`, sets up `tracing` (`RUST_LOG`, or debug when `ENV_DEBUG`
is set), builds a `FileManager` and hands it to `ui::run`. HTTPS uses the OS
certificate store (reqwest + rustls platform verifier), which matters behind
corporate SSL inspection.
### GUI
`CuratorApp` (`ui/app.rs`) 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. It keeps a small view model (rows, sidebar counts) recomputed
after every change. Each menu action is a method on the app; most open a dialog
from `ui/dialogs/` or ask a `Prompt`, then call one `FileManager` method.
Dialogs own their form state and talk back through `Env` (the manager, the
message queue and `Event`s such as *refresh*, *reload series* or *open another
dialog*). Questions inside a dialog (version popisek, confirmations, choices)
are `common::Prompt`s stacked on top of it.
Long operations run off the UI thread so the window keeps repainting:
- **Tasks** (`ui/task.rs`) need the manager — import, TMDb refresh, integrity
scan, bulk renames, miniDLNA generation. The `FileManager` is moved into a
worker thread and handed back with the result; a modal shows progress and a
cancel button meanwhile.
- **Jobs** (`common::Job`) are network calls that don't — TMDb / ČSFD lookups in
dialogs, Jellyfin checks.
### 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`, `generate_minidlna` and
`find_duplicates`. Methods that change files also keep the pool index and the
in-memory `filelist` in step (`with_file` runs an operation on the loaded file,
or on a freshly loaded one when it isn't in the list).
## 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.rs` 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.rs`).
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::open` 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** — `FileManager::minidlna_scope` decides which libraries take part;
`FileManager::generate_minidlna` builds the genre tree through
`HardlinkManager`, mirrors the series / homevideo libraries, prunes mirrors
that fell out of scope and refills "- Tipy dne" and "- Nově přidané". Libraries
left out of scope have their old links swept on the next run. The standalone
`tipy-dne` binary refreshes only the tips folder (for a systemd timer).
- **TMDb** (`tmdb.rs`) — the identity source. Turns a TMDb id or a title/year
search into a canonical title and year, and supplies genres, countries, year
and rating for tags.
- **ČSFD** (`csfd.rs`) — a bridge for Czech titles: filename → ČSFD film →
original title and year → TMDb. Solves ČSFD's Anubis proof-of-work wall.
- **Jellyfin** (`jellyfin.rs`) — library scan trigger and a read-back of what
Jellyfin sees, used to verify the pool.
- **NFO** (`nfo.rs`) — read-only metadata written by Jellyfin, used for tags and
to pre-fill imports.
## Configuration
Two stores, by responsibility (see `config.rs`):
- the **global config** holds application state only — window size and the pool
and output paths. It lives in the platform config directory
(`~/.config/curator/.Curator.!gtag` on Linux), or wherever `CURATOR_CONFIG`
points. On first run a `.Curator.!gtag` left by the Python version in the
working directory is picked up, so the paths carry over;
- the **pool index** holds everything that describes a pool — libraries, tag
schema, miniDLNA selection and all movie records. Its format is unchanged from
the Python version, so an existing pool opens as-is.
Secrets come from `.env`: `TMDB_API_KEY`, `TMDB_LANGUAGE`, `JELLYFIN_URL`,
`JELLYFIN_API_KEY`, and `ENV_DEBUG`.
## Testing
`cargo test` runs:
- unit tests next to the code (`#[cfg(test)] mod tests`), core modules tested
against temporary pools;
- `tests/ui.rs` — the GUI driven headlessly with `egui_kittest`: open a dialog,
type, click, then check the pool on disk.
`examples/screenshot.rs` renders the window (optionally with a dialog open) to a
PNG without showing it, for checking layout.
+39
View File
@@ -0,0 +1,39 @@
//! Developer tool: render Curator offscreen (no window) against the pool in
//! `$CURATOR_CONFIG` and save a PNG. `--action <name>` opens a dialog first
//! (`edit`, `stats`, `schema`, `libraries`, `series`, `duplicates`, `tags`).
//!
//! ```text
//! CURATOR_CONFIG=/tmp/cfg.json cargo run --example screenshot -- /tmp/shot.png --action edit
//! ```
use std::path::PathBuf;
use curator::core::file_manager::FileManager;
use curator::ui::app::CuratorApp;
use egui_kittest::Harness;
fn main() -> Result<(), String> {
let args: Vec<String> = std::env::args().collect();
let out = PathBuf::from(
args.get(1)
.cloned()
.unwrap_or_else(|| "screenshot.png".into()),
);
let action = args.iter().skip_while(|a| *a != "--action").nth(1).cloned();
let fm = FileManager::new();
let mut harness = Harness::builder()
.with_size(egui::vec2(1300.0, 800.0))
.allow_missing_glyphs()
.with_accessibility_check(false)
.wgpu()
.build_eframe(|cc| CuratorApp::new(fm, cc.egui_ctx.clone()));
harness.run_steps(3);
if let Some(a) = action {
harness.state_mut().debug_action(&a);
harness.run_steps(3);
}
let image = harness.render()?;
image.save(&out).map_err(|e| e.to_string())?;
println!("saved {}", out.display());
Ok(())
}
+23
View File
@@ -0,0 +1,23 @@
[Unit]
Description=HTTP server pro HLS složku Makov webkamery (pro Jellyfin M3U tuner)
After=network-online.target makov-cam.service
Wants=network-online.target
BindsTo=makov-cam.service
[Service]
Type=simple
# WorkingDirectory je rodič hls/, aby šel přes HTTP servírovat i makov.m3u
# (playlist pro Jellyfin), ne jen samotné HLS segmenty.
WorkingDirectory=/var/lib/jellyfin-iptv
# Bind na IP brány docker sítě, na které běží Jellyfin kontejner (ne na
# 127.0.0.1 hostitele — to by z kontejneru bylo jeho vlastní loopback, ne
# hostitelův). Konkrétní hodnota je specifická pro tenhle server, viz
# setup.md.
ExecStart=/usr/bin/python3 -m http.server 8888 --bind 172.19.0.1
Restart=always
RestartSec=5
User=makov-cam
Group=makov-cam
[Install]
WantedBy=multi-user.target
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Restream Makov webkamery (Krmítko) do lokálního HLS pro Jellyfin
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/makov-cam.sh
Restart=always
RestartSec=5
# Necháme běžet pod samostatným účtem, ne pod root.
User=makov-cam
Group=makov-cam
# Pokud je yt-dlp nainstalované přes snap, ignoruje $HOME nastavené tady —
# zjišťuje domovský adresář přímo z /etc/passwd. Účet je --no-create-home,
# takže je potřeba /home/makov-cam vytvořit a vlastnit ručně (viz setup.md).
# Skript si sám zakládá /var/lib/jellyfin-iptv/hls — účet tam musí mít zápis
# (viz setup.md, sekce "Uživatel a oprávnění").
[Install]
WantedBy=multi-user.target
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# Restreams the Makov webcam (YouTube live "Krmítko") into a local HLS
# playlist that never goes stale for the viewer — Jellyfin always points at
# this fixed local path, not at YouTube's own signed/expiring URL.
#
# When ffmpeg dies (the YouTube URL expired, network hiccup, stream briefly
# ended, ...) this script simply exits; systemd (Restart=always) starts it
# again, which resolves a *fresh* URL via yt-dlp. Nothing needs to be done
# by hand.
set -euo pipefail
YOUTUBE_URL="https://www.youtube.com/watch?v=F0fsV1GGZ3M"
OUT_DIR="/var/lib/jellyfin-iptv/hls"
OUT_FILE="${OUT_DIR}/makov.m3u8"
mkdir -p "$OUT_DIR"
# This live stream only serves separate video-only/audio-only HLS renditions
# (no combined progressive format), so we resolve both and let ffmpeg mux
# them. "bestvideo+bestaudio" makes yt-dlp -g print exactly two URLs, video
# then audio; fall back to "best" in case a progressive format reappears.
mapfile -t STREAM_URLS < <(yt-dlp -g -f 'bestvideo+bestaudio/best' "$YOUTUBE_URL")
VIDEO_URL="${STREAM_URLS[0]:-}"
AUDIO_URL="${STREAM_URLS[1]:-}"
if [ -z "$VIDEO_URL" ]; then
echo "yt-dlp nevrátilo žádnou URL (stream offline, nebo se YouTube brání) — končím, systemd to za chvíli zkusí znovu." >&2
exit 1
fi
if [ -z "$AUDIO_URL" ]; then
# Progressive fallback matched: single URL carries both video and audio.
exec ffmpeg -nostdin -loglevel warning \
-re -i "$VIDEO_URL" \
-c copy \
-f hls -hls_time 4 -hls_list_size 6 \
-hls_flags delete_segments+omit_endlist \
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
"$OUT_FILE"
fi
exec ffmpeg -nostdin -loglevel warning \
-re -i "$VIDEO_URL" -re -i "$AUDIO_URL" \
-map 0:v -map 1:a \
-c copy \
-f hls -hls_time 4 -hls_list_size 6 \
-hls_flags delete_segments+omit_endlist \
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
"$OUT_FILE"
+175
View File
@@ -0,0 +1,175 @@
# Makov webkamera → Jellyfin (Live TV)
Restreamuje YouTube live přenos ("Krmítko", https://www.youtube.com/watch?v=F0fsV1GGZ3M)
do lokálního HLS, na který se Jellyfin dívá přes stabilní adresu. Když YouTube
odkaz vyprší nebo spadne, systemd proces sám restartuje a natáhne novou URL —
divák/Jellyfin si ničeho nevšimne.
## 0) Kam skript patří
Toto **musí běžet na stejném stroji (resp. za stejnou veřejnou IP), jako
Jellyfin** — YouTube vrací streamovací URL svázanou s IP adresou, ze které
byla vyžádána (`yt-dlp -g`). Pokud poběží jinde než Jellyfin, `ffmpeg` dostane
403 a nikdy se nerozjede.
## 1) Závislosti
```bash
sudo apt install -y ffmpeg python3
sudo pip install -U yt-dlp # nebo pipx install yt-dlp — apt verze bývá stará
# a YouTube ji rád rozbije
```
Ověř, že `yt-dlp` a `ffmpeg` jedou z shellu bez chyby:
```bash
yt-dlp --version
ffmpeg -version
```
## 2) Uživatel a oprávnění
Vlastní, neprivilegovaný systémový účet — obě služby pod ním běží:
```bash
sudo useradd --system --no-create-home --shell /usr/sbin/nologin makov-cam
sudo mkdir -p /var/lib/jellyfin-iptv/hls
sudo chown -R makov-cam:makov-cam /var/lib/jellyfin-iptv
```
Pokud je `yt-dlp` nainstalované přes **snap** (`which yt-dlp` ukazuje
`/snap/bin/yt-dlp`), potřebuje při startu zapisovatelný domovský adresář —
a to i přes `--no-create-home`, protože domov si zjišťuje přímo z
`/etc/passwd`, ne z proměnné `$HOME` (tu přenastavenou v service souboru
ignoruje). Bez toho skončí na `Permission denied: mkdir /home/makov-cam`.
Vytvoř mu ho ručně:
```bash
sudo mkdir -p /home/makov-cam
sudo chown makov-cam:makov-cam /home/makov-cam
sudo chmod 750 /home/makov-cam
```
(Pokud `yt-dlp` instaluješ přes `pip`/`pipx` dle kroku 1, tenhle problém
nenastává a krok lze přeskočit.)
## 3) Nasazení souborů
```bash
sudo install -m 755 makov-cam.sh /usr/local/bin/makov-cam.sh
sudo install -m 644 makov-cam.service /etc/systemd/system/
sudo install -m 644 makov-cam-http.service /etc/systemd/system/
sudo systemctl daemon-reload
```
## 4) Spuštění
```bash
sudo systemctl enable --now makov-cam.service
sudo systemctl enable --now makov-cam-http.service
```
Ověření:
```bash
systemctl status makov-cam.service makov-cam-http.service
journalctl -u makov-cam.service -f # živě sleduj log ffmpeg/yt-dlp
# do pár vteřin by se měl objevit playlist a segmenty:
ls -la /var/lib/jellyfin-iptv/hls/
# a mělo by jít stáhnout přes HTTP:
curl -sI http://127.0.0.1:8888/hls/makov.m3u8
```
`makov-cam-http.service` je svázaná (`BindsTo=`) na `makov-cam.service` — když
restream spadne/restartuje, HTTP server padne/naskočí s ním, ať nikdy
neservíruje mrtvý/prázdný playlist. Pozor: `BindsTo=` jen zajišťuje, že HTTP
server *spadne*, když spadne hlavní služba — nezajišťuje, že se s ní i sám
*nastartuje*. Po `enable --now` obou služeb to není problém (obě se spustí),
ale pokud jen restartuješ `makov-cam.service` ručně, zkontroluj/restartuj i
`makov-cam-http.service`.
HTTP server poslouchá jen na jedné IP (`--bind`), ne na `0.0.0.0` — nemá cenu
ho vystavovat celé síti, stačí odkud se na něj bude dívat Jellyfin:
- **Jellyfin běží přímo na hostu** (ne v kontejneru): `--bind 127.0.0.1`
stačí.
- **Jellyfin běží v Docker kontejneru na tomtéž hostu** (běžný případ) —
`127.0.0.1` uvnitř kontejneru je jeho *vlastní* loopback, ne hostitelův,
takže na hostitelův HTTP server se tak nedostane. Zjisti gateway IP té
docker sítě, na které kontejner běží, a naviaž se na ni:
```bash
docker inspect <jellyfin-container> --format '{{.HostConfig.NetworkMode}}'
docker inspect <jellyfin-container> --format '{{json .NetworkSettings.Networks}}'
# z výstupu vezmi "Gateway", např. 172.19.0.1 — tam se z kontejneru dostaneš
```
a použij `--bind <ta-gateway-IP>` (viz `makov-cam-http.service`). Pokud by
Jellyfin běžel v jiné VM, změň `--bind` na IP, na které je pro ni
dosažitelný, a případně otevři firewall jen pro tenhle port a jen z její
IP.
## 5) Playlist pro Jellyfin
`WorkingDirectory` HTTP služby je `/var/lib/jellyfin-iptv` (rodič `hls/`), aby
šel přes stejný server servírovat i samotný `.m3u` playlist — to se hodí,
pokud Jellyfin (typicky v kontejneru) nevidí souborový systém hostitele a
tuner tak musí načíst playlist přes HTTP URL, ne lokální cestou.
Vytvoř `/var/lib/jellyfin-iptv/makov.m3u`:
```
#EXTM3U
#EXTINF:-1 tvg-id="makov-krmitko" tvg-name="Makov - Krmítko",Makov - Krmítko
http://<BIND_IP>:8888/hls/makov.m3u8
```
kde `<BIND_IP>` je stejná adresa, na kterou je navázaný `makov-cam-http.service`
(viz krok 4).
## 6) Nastavení v Jellyfinu
1. **Dashboard → Live TV → Tuner Devices → +** (Add)
2. Typ: **M3U Tuner**
3. File or URL:
- Jellyfin na hostu s přístupem k souborovému systému: lokální cesta
`/var/lib/jellyfin-iptv/makov.m3u`.
- Jellyfin v kontejneru (nevidí hostitelův FS): HTTP URL
`http://<BIND_IP>:8888/makov.m3u` (viz krok 5).
4. **Save** → Jellyfin načte kanál "Makov - Krmítko" do Live TV
5. Doporučeno: **Dashboard → Live TV → Guide Providers** — pro tenhle kanál
žádného není třeba (žádný EPG), Jellyfin ho ukáže jako kanál bez programu,
což nevadí.
6. Zkontroluj přehrávání v **Live TV** sekci hlavního rozhraní.
Protože kanál pořád ukazuje na stejnou lokální adresu, odpadá starost o
obnovování guide dat nebo expiraci URL na Jellyfin straně — o to se stará jen
`makov-cam.service` na pozadí.
## 7) Autostart po restartu serveru
`enable` v kroku 4 už zajistil start při bootu. Rychlá kontrola:
```bash
systemctl is-enabled makov-cam.service makov-cam-http.service
```
## Řešení problémů
- **`ffmpeg` pořád padá / v logu 403 od googlevideo.com** — nejčastěji IP
mismatch (viz bod 0): stroj, co spouští `yt-dlp -g`/`ffmpeg`, musí mít
stejnou veřejnou IP jako to, odkud by se stream normálně přehrával.
Zkontroluj `journalctl -u makov-cam.service -n 50`.
- **`yt-dlp: command not found` v service logu** — systemd nepoužívá tvůj
interaktivní `$PATH`; ověř `which yt-dlp` a případně uprav `ExecStart` v
`makov-cam.sh`/skriptu na plnou cestu (`/usr/local/bin/yt-dlp` apod.), nebo
nainstaluj přes `sudo pip install`, aby šel do `/usr/local/bin`.
- **Playlist v Jellyfinu jede, ale sekundy/minuty výpadku při refreshi** —
normální, `hls_list_size 6` × `hls_time 4s` drží ~24s okno; při restartu
streamu je pár vteřin díra, než naběhnou nové segmenty. Jde zkrátit/prodloužit
úpravou těch dvou hodnot ve skriptu.
- **YouTube někdy dočasně rozbije `yt-dlp` novou verzí přehrávače** — drž
`yt-dlp` aktuální (`sudo pip install -U yt-dlp`, klidně jako týdenní cron).
- **`ERROR: Requested format is not available`** — stream v tu chvíli nenabízí
žádný kombinovaný (progresivní) formát, jen oddělené video-only/audio-only
HLS řady (ověř přes `yt-dlp -F <url>`). Skript proto primárně používá
`-f 'bestvideo+bestaudio/best'` a při dvou vrácených URL pustí `ffmpeg` se
dvěma `-i` vstupy (`-map 0:v -map 1:a`); jedna URL = fallback na progresivní
formát, pokud se zase objeví.
Generated
-861
View File
@@ -1,861 +0,0 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "ast-serialize"
version = "0.5.0"
description = "Python bindings for mypy AST serialization"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a"},
{file = "ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937"},
{file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c"},
{file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b"},
{file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab"},
{file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3"},
{file = "ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38"},
{file = "ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c"},
{file = "ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb"},
{file = "ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101"},
{file = "ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934"},
{file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759"},
{file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887"},
{file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27"},
{file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d"},
{file = "ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a"},
{file = "ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590"},
{file = "ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642"},
{file = "ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6"},
]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
groups = ["main"]
files = [
{file = "beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9"},
{file = "beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7"},
]
[package.dependencies]
soupsieve = ">=1.6.1"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "certifi"
version = "2026.5.20"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"},
{file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
{file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
{file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
{file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
{file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
{file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
{file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
]
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "idna"
version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
]
[package.extras]
all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "librt"
version = "0.11.0"
description = "Mypyc runtime library"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"},
{file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"},
{file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"},
{file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"},
{file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"},
{file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"},
{file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"},
{file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"},
{file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"},
{file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"},
{file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"},
{file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"},
{file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"},
{file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"},
{file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"},
{file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"},
{file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"},
{file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"},
{file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"},
{file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"},
{file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"},
{file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"},
{file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"},
{file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"},
{file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"},
{file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"},
{file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"},
{file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"},
{file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"},
{file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"},
{file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"},
{file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"},
{file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"},
{file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"},
{file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"},
{file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"},
{file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"},
{file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"},
{file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"},
{file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"},
{file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"},
{file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"},
{file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"},
{file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"},
{file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"},
{file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"},
{file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"},
{file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"},
{file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"},
{file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"},
{file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"},
{file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"},
{file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"},
{file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"},
{file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"},
{file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"},
{file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"},
{file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"},
{file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"},
{file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"},
{file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"},
{file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"},
]
[[package]]
name = "loguru"
version = "0.7.3"
description = "Python logging made (stupidly) simple"
optional = false
python-versions = "<4.0,>=3.5"
groups = ["main"]
files = [
{file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"},
{file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"},
]
[package.dependencies]
colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""]
[[package]]
name = "mypy"
version = "2.1.0"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc"},
{file = "mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849"},
{file = "mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd"},
{file = "mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166"},
{file = "mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8"},
{file = "mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8"},
{file = "mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e"},
{file = "mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41"},
{file = "mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca"},
{file = "mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538"},
{file = "mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398"},
{file = "mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563"},
{file = "mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389"},
{file = "mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666"},
{file = "mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af"},
{file = "mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6"},
{file = "mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211"},
{file = "mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b"},
{file = "mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22"},
{file = "mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b"},
{file = "mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8"},
{file = "mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5"},
{file = "mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e"},
{file = "mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e"},
{file = "mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285"},
{file = "mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5"},
{file = "mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65"},
{file = "mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d"},
{file = "mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2"},
{file = "mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f"},
{file = "mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4"},
{file = "mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef"},
{file = "mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135"},
{file = "mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21"},
{file = "mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57"},
{file = "mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e"},
{file = "mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780"},
{file = "mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd"},
{file = "mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08"},
{file = "mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081"},
{file = "mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7"},
{file = "mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6"},
{file = "mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289"},
{file = "mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633"},
]
[package.dependencies]
ast-serialize = ">=0.3.0,<1.0.0"
librt = {version = ">=0.11.0", markers = "platform_python_implementation != \"PyPy\""}
mypy_extensions = ">=1.0.0"
pathspec = ">=1.0.0"
typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.15\""}
[package.extras]
dmypy = ["psutil (>=4.0)"]
faster-cache = ["orjson"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "packaging"
version = "26.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
]
[[package]]
name = "pathspec"
version = "1.1.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"},
{file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"},
]
[package.extras]
hyperscan = ["hyperscan (>=0.7)"]
optional = ["typing-extensions (>=4)"]
re2 = ["google-re2 (>=1.1)"]
[[package]]
name = "pillow"
version = "12.2.0"
description = "Python Imaging Library (fork)"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"},
{file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"},
{file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"},
{file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"},
{file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"},
{file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"},
{file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"},
{file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"},
{file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"},
{file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"},
{file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"},
{file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"},
{file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"},
{file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"},
{file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"},
{file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"},
{file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"},
{file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"},
{file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"},
{file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"},
{file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"},
{file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"},
{file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"},
{file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"},
{file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"},
{file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"},
{file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"},
{file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"},
{file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"},
{file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"},
{file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"},
{file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"},
{file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"},
{file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"},
{file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"},
{file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"},
{file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"},
{file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"},
{file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"},
{file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"},
{file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"},
{file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"},
{file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"},
{file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"},
{file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"},
{file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"},
{file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"},
{file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"},
{file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"},
{file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"},
{file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"},
{file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"},
{file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"},
{file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"},
{file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"},
{file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"},
{file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"},
{file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"},
{file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"},
{file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"},
{file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"},
{file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"},
{file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"},
{file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"},
{file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"},
{file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"},
{file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"},
{file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"},
{file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"},
{file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyside6"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6"},
{file = "pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2"},
{file = "pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84"},
{file = "pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6"},
{file = "pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805"},
]
[package.dependencies]
PySide6_Addons = "6.11.1"
PySide6_Essentials = "6.11.1"
shiboken6 = "6.11.1"
[[package]]
name = "pyside6-addons"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9"},
{file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f"},
{file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993"},
{file = "pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923"},
{file = "pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37"},
]
[package.dependencies]
PySide6_Essentials = "6.11.1"
shiboken6 = "6.11.1"
[[package]]
name = "pyside6-essentials"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f"},
]
[package.dependencies]
shiboken6 = "6.11.1"
[[package]]
name = "pytest"
version = "9.0.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "python-dotenv"
version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "requests"
version = "2.34.2"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
]
[package.dependencies]
certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "ruff"
version = "0.15.17"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f"},
{file = "ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7"},
{file = "ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719"},
{file = "ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4"},
{file = "ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7"},
{file = "ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f"},
{file = "ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f"},
{file = "ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8"},
{file = "ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392"},
{file = "ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084"},
{file = "ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456"},
{file = "ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219"},
]
[[package]]
name = "shiboken6"
version = "6.11.1"
description = "Python/C++ bindings helper module"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02"},
{file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe"},
{file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f"},
{file = "shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7"},
{file = "shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d"},
]
[[package]]
name = "soupsieve"
version = "2.8.4"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65"},
{file = "soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e"},
]
[[package]]
name = "truststore"
version = "0.10.4"
description = "Verify certificates using native system trust stores"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"},
{file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"},
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "urllib3"
version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
]
[package.extras]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "win32-setctime"
version = "1.2.0"
description = "A small Python utility to set file creation time on Windows"
optional = false
python-versions = ">=3.5"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"},
{file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"},
]
[package.extras]
dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.14,<3.15"
content-hash = "5906affa631fe91b3b93e881e52420a2e591110023722a7a2d5264c96706dcf2"
-66
View File
@@ -1,66 +0,0 @@
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from src.constants import VERSION
load_dotenv()
print("=" * 50)
print("PREBUILD CONFIGURATION")
print("=" * 50)
# Check if running in virtual environment
project_root = Path(__file__).parent
expected_venv_path = project_root / ".venv"
current_executable = Path(sys.executable)
print(f"\nPython executable: {sys.executable}")
is_correct_venv = False
try:
current_executable.relative_to(expected_venv_path)
is_correct_venv = True
except ValueError:
is_correct_venv = False
if is_correct_venv:
print("✓ Correct environment selected for building")
else:
print("✗ Wrong environment selected")
print(f" Expected: {expected_venv_path}")
print(f" Current: {current_executable.parent.parent}")
print(f"✓ Version: {VERSION}")
env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true"
console_mode = env_debug
default_spec = Path(__file__).parent.name + ".spec"
spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec)
print(f"\n{'-' * 50}")
print("BUILD SETTINGS")
print(f"{'-' * 50}")
print(f"ENV_DEBUG: {env_debug}")
print(f"Console mode: {console_mode}")
print(f"Spec file: {spec_filename}")
spec_path = Path(__file__).parent / spec_filename
if spec_path.exists():
with open(spec_path, "r", encoding="utf-8") as f:
spec_content = f.read()
if f"console={not console_mode}" in spec_content:
new_spec_content = spec_content.replace(
f"console={not console_mode}",
f"console={console_mode}"
)
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_spec_content)
print(f"✓ Updated {spec_filename}: console={console_mode}")
else:
print(f"✓ {spec_filename} already configured: console={console_mode}")
else:
print(f"✗ {spec_filename} not found!")
print(f"{'-' * 50}\n")
-35
View File
@@ -1,35 +0,0 @@
[project]
name = "curator"
version = "1.12.0"
description = ""
authors = [
{name = "jan.doubravsky@gmail.com"}
]
readme = "README.md"
requires-python = ">=3.14,<3.15"
dependencies = [
"pyside6 (>=6.11.1,<7.0.0)",
"requests (>=2.34.2,<3.0.0)",
"beautifulsoup4 (>=4.15.0,<5.0.0)",
"python-dotenv (>=1.2.2,<2.0.0)",
"pillow (>=12.2.0,<13.0.0)",
"loguru (>=0.7.3,<0.8.0)",
"truststore (>=0.10.4,<0.11.0)"
]
[tool.poetry]
package-mode = false
[tool.pytest.ini_options]
testpaths = ["tests"]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[dependency-groups]
dev = [
"ruff (>=0.15.17,<0.16.0)",
"mypy (>=2.1.0,<3.0.0)",
"pytest (>=9.0.3,<10.0.0)"
]
-107
View File
@@ -1,107 +0,0 @@
"""One-off migration: rename a tag category inside a pool's metadata index.
Tags are stored in ``<pool>/.Curator.!index`` as ``"Category/Name"`` strings.
This rewrites every tag whose category matches ``--old`` to use ``--new``,
leaving the tag name untouched. A timestamped backup of the index is written
before saving.
Usage:
poetry run python scripts/migrate_tag_category.py <pool_dir> \
--old "Země" --new "Země původu"
If ``<pool_dir>`` is omitted, the pool from the global config is used.
"""
from __future__ import annotations
import sys
import json
import shutil
import argparse
from pathlib import Path
from datetime import datetime
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config # noqa: E402
from src.core.pool_index import INDEX_FILENAME # noqa: E402
def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int]:
"""Return (rewritten tags, number of tags changed) for one record."""
prefix = f"{old}/"
changed = 0
result: list[str] = []
for tag in tags:
if isinstance(tag, str) and tag.startswith(prefix):
result.append(f"{new}/{tag[len(prefix):]}")
changed += 1
else:
result.append(tag)
return result, changed
def migrate(index_path: Path, old: str, new: str) -> int:
"""Rewrite the category in place and return the number of tags changed."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_changed = 0
affected_records = 0
for key, record in movies.items():
tags = record.get("tags", [])
new_tags, changed = _rename_in_tags(tags, old, new)
if changed:
record["tags"] = new_tags
total_changed += changed
affected_records += 1
logger.debug(f"{key}: {changed} tag(s) renamed")
if total_changed == 0:
logger.info(f"No '{old}/…' tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Migrated '{old}' → '{new}': {total_changed} tag(s) "
f"across {affected_records} record(s)"
)
return total_changed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument("--old", default="Země", help="Category to rename from")
parser.add_argument("--new", default="Země původu", help="Category to rename to")
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.old, args.new)
if __name__ == "__main__":
main()
-120
View File
@@ -1,120 +0,0 @@
"""One-off migration: split combined country tags in a pool's metadata index.
Before multi-country support, a co-production fetched from ČSFD was stored as a
single ``"Země původu/USA / Velká Británie"`` tag. This rewrites each such tag
into one tag per country (``"Země původu/USA"`` + ``"Země původu/Velká
Británie"``), de-duplicating within each record. A timestamped backup of the
index is written before saving.
Usage:
poetry run python scripts/split_country_tags.py [<pool_dir>] [--category "Země původu"]
If ``<pool_dir>`` is omitted, the pool from the global config is used.
"""
from __future__ import annotations
import sys
import json
import shutil
import argparse
from pathlib import Path
from datetime import datetime
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config # noqa: E402
from src.core.pool_index import INDEX_FILENAME # noqa: E402
def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
"""Return (rewritten tags, number of combined tags split) for one record.
Order is preserved; duplicates produced by the split are dropped.
"""
prefix = f"{category}/"
result: list[str] = []
seen: set[str] = set()
split_count = 0
def _add(tag: str) -> None:
if tag not in seen:
seen.add(tag)
result.append(tag)
for tag in tags:
if isinstance(tag, str) and tag.startswith(prefix) and "/" in tag[len(prefix):]:
value = tag[len(prefix):]
countries = [c.strip() for c in value.split("/") if c.strip()]
for country in countries:
_add(f"{prefix}{country}")
split_count += 1
else:
_add(tag)
return result, split_count
def migrate(index_path: Path, category: str) -> int:
"""Split combined ``category`` tags in place; return number of tags split."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_split = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
new_tags, split_count = _split_record_tags(tags, category)
if split_count:
record["tags"] = new_tags
total_split += split_count
affected += 1
logger.debug(f"{key}: {split_count} combined tag(s) split")
if total_split == 0:
logger.info(f"No combined '{category}/…' tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Split {total_split} combined '{category}' tag(s) across {affected} record(s)"
)
return total_split
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument(
"--category", default="Země původu", help="Tag category to split"
)
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.category)
if __name__ == "__main__":
main()
-109
View File
@@ -1,109 +0,0 @@
"""One-off migration: drop the old decade-band rating tags from a pool index.
Earlier the ČSFD rating was stored bucketed (e.g. ``Hodnocení/90–100 %``). Now
the tag carries the exact value (``Hodnocení/90``) and the band is only a folder.
This removes the legacy band tags (``Hodnocení/<x>–<y> %``) so re-fetching from
ČSFD leaves only the exact ratings. Exact rating tags are kept. A timestamped
backup of the index is written first.
Usage:
poetry run python scripts/strip_rating_bands.py [<pool_dir>] [--category "Hodnocení"]
"""
from __future__ import annotations
import re
import sys
import json
import shutil
import argparse
from pathlib import Path
from datetime import datetime
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config # noqa: E402
from src.core.pool_index import INDEX_FILENAME # noqa: E402
# 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*%")
def _strip_bands(tags: list[str], category: str) -> tuple[list[str], int]:
"""Return (kept tags, removed count), dropping ``category`` band tags."""
prefix = f"{category}/"
kept = [
t for t in tags
if not (isinstance(t, str) and t.startswith(prefix) and _BAND_RE.search(t))
]
return kept, len(tags) - len(kept)
def migrate(index_path: Path, category: str) -> int:
"""Remove band rating tags in place; return number of tags removed."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_removed = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
kept, removed = _strip_bands(tags, category)
if removed:
record["tags"] = kept
# also drop them from the ČSFD provenance set, if present
if isinstance(record.get("csfd_tags"), list):
record["csfd_tags"] = [
t for t in record["csfd_tags"]
if not (t.startswith(f"{category}/") and _BAND_RE.search(t))
]
total_removed += removed
affected += 1
logger.debug(f"{key}: removed {removed} band tag(s)")
if total_removed == 0:
logger.info(f"No '{category}/…–… %' band tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Removed {total_removed} band '{category}' tag(s) across {affected} record(s)"
)
return total_removed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument("--category", default="Hodnocení", help="Rating category")
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.category)
if __name__ == "__main__":
main()
-101
View File
@@ -1,101 +0,0 @@
"""One-off migration: drop all tags of given categories from a pool's index.
Used to remove tag categories that turned out to be a bad idea (e.g. Režie /
Herec produced far too many folders). Cached ČSFD data is left intact — only the
``tags`` lists are pruned. A timestamped backup of the index is written first.
Usage:
poetry run python scripts/strip_tag_categories.py [<pool_dir>] \
--categories "Režie" "Herec"
"""
from __future__ import annotations
import sys
import json
import shutil
import argparse
from pathlib import Path
from datetime import datetime
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config # noqa: E402
from src.core.pool_index import INDEX_FILENAME # noqa: E402
def _strip(tags: list[str], prefixes: tuple[str, ...]) -> tuple[list[str], int]:
"""Return (kept tags, number removed) dropping tags under any prefix."""
kept = [t for t in tags if not (isinstance(t, str) and t.startswith(prefixes))]
return kept, len(tags) - len(kept)
def migrate(index_path: Path, categories: list[str]) -> int:
"""Remove all tags of ``categories`` in place; return number of tags removed."""
prefixes = tuple(f"{c}/" for c in categories)
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_removed = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
kept, removed = _strip(tags, prefixes)
if removed:
record["tags"] = kept
total_removed += removed
affected += 1
logger.debug(f"{key}: removed {removed} tag(s)")
if total_removed == 0:
logger.info(f"No tags in {categories} found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Removed {total_removed} tag(s) of {categories} across {affected} record(s)"
)
return total_removed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument(
"--categories",
nargs="+",
default=["Režie", "Herec"],
help="Tag categories to strip",
)
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.categories)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -7,6 +7,6 @@ RequiresMountsFor=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0
Type=oneshot Type=oneshot
User=honza User=honza
Group=honza Group=honza
ExecStart=/usr/bin/python3 /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka/Tools/tipy_dne.py --pool /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka --output /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmy --count 15 ExecStart=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka/Tools/tipy-dne --pool /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka --output /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmy --count 15
Nice=10 Nice=10
NoNewPrivileges=true NoNewPrivileges=true
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env python3
"""Standalone "Tipy dne" generator — deployable next to a Filmotéka (e.g. cron).
On each run it reads the pool index (``<pool>/.Curator.!index``), picks N random
pool movies, clears the tips folder in the output and recreates it as hardlinks.
It does not import Curator — only the Python standard library — so it can live on
the server with the Filmotéka, independent of the app.
Paths come from CLI args, or fall back to the global config (``.Curator.!gtag``,
keys ``pool_dir`` / ``filmoteka_dir``) when ``--config`` is given or one is found.
Examples:
python scripts/tipy_dne.py --pool /mnt/srv/Filmoteka --output /mnt/srv/Filmy/Filmoteka_v2
python scripts/tipy_dne.py --count 15 # uses the repo's global config
"""
from __future__ import annotations
import os
import sys
import json
import random
import argparse
from pathlib import Path
INDEX_FILENAME = ".Curator.!index"
GLOBAL_CONFIG_FILENAME = ".Curator.!gtag"
DEFAULT_SUBFOLDER = "- Tipy dne" # "- " prefix sorts it first in DLNA/TV browsers
DEFAULT_COUNT = 15
def _load_config_paths(config_path: Path) -> tuple[Path | None, Path | None]:
"""Read (pool_dir, filmoteka_dir) from a global config file, if present."""
try:
with open(config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except (OSError, json.JSONDecodeError):
return None, None
pool = cfg.get("pool_dir")
out = cfg.get("filmoteka_dir")
return (Path(pool) if pool else None, Path(out) if out else None)
def _pool_movies(pool_dir: Path) -> list[Path]:
"""Existing pool movie files listed in the index (copy-as-is folders aren't
indexed, so this is the pool/Filmy set only)."""
index_path = pool_dir / INDEX_FILENAME
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: list[Path] = []
for rel in data.get("movies", {}):
path = pool_dir / rel
if path.is_file():
movies.append(path)
return movies
def _clear_folder(folder: Path) -> int:
"""Remove the files in ``folder`` (the old tips). Returns how many removed."""
removed = 0
if folder.exists():
for child in folder.iterdir():
if child.is_file() or child.is_symlink():
try:
child.unlink()
removed += 1
except OSError as exc:
print(f" ! nelze smazat {child}: {exc}", file=sys.stderr)
return removed
def generate_tips(pool_dir: Path, output_dir: Path, count: int, subfolder: str) -> int:
"""Refresh the tips folder with ``count`` random pool movies as hardlinks."""
movies = _pool_movies(pool_dir)
if not movies:
print("Index neobsahuje žádné filmy.", file=sys.stderr)
return 0
tips_dir = output_dir / subfolder
cleared = _clear_folder(tips_dir)
tips_dir.mkdir(parents=True, exist_ok=True)
chosen = random.sample(movies, min(count, len(movies)))
created = 0
for src in chosen:
target = tips_dir / src.name
try:
if target.exists():
target.unlink()
os.link(src, target)
created += 1
print(f" + {src.name}")
except OSError as exc:
print(f" ! {src.name}: {exc}", file=sys.stderr)
print(f"\nTipy dne: smazáno {cleared}, vytvořeno {created} (z {len(movies)} filmů)")
return created
def main() -> None:
parser = argparse.ArgumentParser(description="Vygeneruje složku Tipy dne.")
parser.add_argument("--pool", type=Path, help="Pool root (obsahuje .Curator.!index)")
parser.add_argument("--output", type=Path, help="Výstupní složka Filmotéky")
parser.add_argument("--count", type=int, default=DEFAULT_COUNT,
help=f"Počet náhodných titulů (výchozí {DEFAULT_COUNT})")
parser.add_argument("--subfolder", default=DEFAULT_SUBFOLDER,
help=f'Název složky tipů (výchozí "{DEFAULT_SUBFOLDER}")')
parser.add_argument("--config", type=Path,
help="Cesta ke global configu (.Curator.!gtag) pro doplnění cest")
args = parser.parse_args()
pool, output = args.pool, args.output
if pool is None or output is None:
config_path = args.config or (
Path(__file__).resolve().parent.parent / GLOBAL_CONFIG_FILENAME)
cfg_pool, cfg_out = _load_config_paths(config_path)
pool = pool or cfg_pool
output = output or cfg_out
if pool is None or output is None:
parser.error("Chybí --pool a/nebo --output (a nenalezeny v global configu).")
if not (pool / INDEX_FILENAME).is_file():
parser.error(f"Index nenalezen: {pool / INDEX_FILENAME}")
created = generate_tips(pool, output, args.count, args.subfolder)
sys.exit(0 if created else 1)
if __name__ == "__main__":
main()
View File
-2
View File
@@ -1,2 +0,0 @@
"""Auto-generated — do not edit manually."""
__version__ = "1.12.0"
+159
View File
@@ -0,0 +1,159 @@
//! Standalone "Tipy dne" generator — deployable next to the miniDLNA output
//! (e.g. a systemd timer). Each run reads the pool index, picks N random
//! movies, empties the tips folder and refills it with hardlinks.
//!
//! ```text
//! tipy-dne --pool /mnt/srv/Filmoteka --output /mnt/srv/Filmy --count 15
//! tipy-dne --count 15 # pool/output from the global config
//! ```
use std::path::PathBuf;
use std::process::ExitCode;
use curator::core::config::{global_config_path, load_global_config_from};
use curator::core::file::File;
use curator::core::file_manager::TIPS_SUBFOLDER;
use curator::core::hardlink_manager::HardlinkManager;
use curator::core::pool_index::INDEX_FILENAME;
const DEFAULT_COUNT: usize = 15;
struct Args {
pool: Option<PathBuf>,
output: Option<PathBuf>,
count: usize,
subfolder: String,
config: Option<PathBuf>,
}
fn usage() -> String {
format!(
"Vygeneruje složku Tipy dne.\n\n\
Použití: tipy-dne [--pool DIR] [--output DIR] [--count N] [--subfolder NAME] [--config FILE]\n\n\
--pool pool (obsahuje {INDEX_FILENAME})\n\
--output výstupní složka miniDLNA\n\
--count počet náhodných titulů (výchozí {DEFAULT_COUNT})\n\
--subfolder název složky tipů (výchozí \"{TIPS_SUBFOLDER}\")\n\
--config global config pro doplnění cest (výchozí: config Curatoru)"
)
}
fn parse_args() -> Result<Args, String> {
let mut args = Args {
pool: None,
output: None,
count: DEFAULT_COUNT,
subfolder: TIPS_SUBFOLDER.into(),
config: None,
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
let mut value = || it.next().ok_or_else(|| format!("{arg} potřebuje hodnotu"));
match arg.as_str() {
"--pool" => args.pool = Some(value()?.into()),
"--output" => args.output = Some(value()?.into()),
"--count" => {
args.count = value()?
.parse()
.map_err(|_| "--count musí být číslo".to_string())?
}
"--subfolder" => args.subfolder = value()?,
"--config" => args.config = Some(value()?.into()),
"-h" | "--help" => return Err(usage()),
other => return Err(format!("Neznámý argument: {other}\n\n{}", usage())),
}
}
Ok(args)
}
/// Pool movies listed in the index (v2: every media file of every record;
/// v1: the keys themselves are pool-relative paths). Missing files are skipped.
fn pool_movies(pool: &std::path::Path) -> Result<Vec<File>, String> {
let text = std::fs::read_to_string(pool.join(INDEX_FILENAME))
.map_err(|e| format!("Index nelze přečíst: {e}"))?;
let data: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("Index je poškozený: {e}"))?;
let Some(movies) = data.get("movies").and_then(|m| m.as_object()) else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for (key, record) in movies {
let title = record
.get("title")
.and_then(|t| t.as_str())
.filter(|t| !t.is_empty())
.map(String::from);
let files: Vec<String> = match record.get("media").and_then(|m| m.as_array()) {
Some(media) => media
.iter()
.filter_map(|m| m.get("file").and_then(|f| f.as_str()).map(String::from))
.take(1)
.collect(),
None => vec![key.clone()],
};
for rel in files {
let path = curator::core::paths::join_posix(pool, &rel);
if path.is_file() {
let mut f = File::bare(&path);
f.title = title.clone();
out.push(f);
}
}
}
Ok(out)
}
fn main() -> ExitCode {
let mut args = match parse_args() {
Ok(a) => a,
Err(msg) => {
eprintln!("{msg}");
return ExitCode::from(2);
}
};
if args.pool.is_none() || args.output.is_none() {
let cfg = load_global_config_from(&args.config.clone().unwrap_or_else(global_config_path));
args.pool = args.pool.or(cfg.pool_dir.map(PathBuf::from));
args.output = args.output.or(cfg.filmoteka_dir.map(PathBuf::from));
}
let (Some(pool), Some(output)) = (args.pool, args.output) else {
eprintln!(
"Chybí --pool a/nebo --output (a nenalezeny v global configu).\n\n{}",
usage()
);
return ExitCode::from(2);
};
if !pool.join(INDEX_FILENAME).is_file() {
eprintln!("Index nenalezen: {}", pool.join(INDEX_FILENAME).display());
return ExitCode::from(2);
}
let movies = match pool_movies(&pool) {
Ok(m) => m,
Err(e) => {
eprintln!("{e}");
return ExitCode::FAILURE;
}
};
if movies.is_empty() {
eprintln!("Index neobsahuje žádné filmy.");
return ExitCode::FAILURE;
}
let mut manager = HardlinkManager::new(&output);
let (created, failed) =
manager.generate_random_tips(&movies, args.count, &args.subfolder, false);
for link in &manager.created_links {
println!(" + {}", curator::core::paths::name(link));
}
for (src, err) in &manager.errors {
eprintln!(" ! {}: {err}", curator::core::paths::name(src));
}
println!(
"\nTipy dne: vytvořeno {created}, selhalo {failed} (z {} filmů)",
movies.len()
);
if created > 0 {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
-68
View File
@@ -1,68 +0,0 @@
"""
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`).
"""
import os
import tomllib
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
_PYPROJECT_PATH = Path(__file__).parent.parent / "pyproject.toml"
_VERSION_FILE = Path(__file__).parent / "_version.py"
APP_NAME: str = "Curator"
def get_version() -> str:
"""Return the project version (pyproject → _version.py → unknown)."""
# 1. pyproject.toml (preferred)
try:
with open(_PYPROJECT_PATH, "rb") as f:
version = tomllib.load(f)["project"]["version"]
# Cache a fallback for frozen/PyInstaller builds (best-effort).
try:
_VERSION_FILE.write_text(
f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n',
encoding="utf-8",
)
except OSError:
pass
return version
except (FileNotFoundError, KeyError):
pass
# 2. generated _version.py
try:
from src._version import __version__ # type: ignore[import]
return __version__
except ImportError:
pass
# 3. last resort
return "0.0.0-unknown"
def get_debug_mode() -> bool:
"""Return True when ENV_DEBUG is set to a truthy value in the environment."""
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
APP_VERSION: str = get_version()
ENV_DEBUG: bool = get_debug_mode()
APP_TITLE: str = f"{APP_NAME} v{APP_VERSION}" + ("-DEV" if ENV_DEBUG else "")
# Backwards-compatible alias used by prebuild.py
VERSION: str = APP_VERSION
# Default main-window geometry (used when no saved geometry exists)
APP_VIEWPORT: str = "1000x700"
+24
View File
@@ -0,0 +1,24 @@
//! Application-level constants (name, version, debug flag, window title).
use std::env;
pub const APP_NAME: &str = "Curator";
/// The project version — `Cargo.toml` is the single source of truth.
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Default main-window size when no geometry has been saved yet.
pub const DEFAULT_WINDOW_SIZE: (f32, f32) = (1200.0, 800.0);
/// True when `ENV_DEBUG` is set to a truthy value (`true` / `1` / `yes`).
pub fn debug_mode() -> bool {
env::var("ENV_DEBUG")
.map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
.unwrap_or(false)
}
/// `Curator v1.2.3`, with a `-DEV` suffix in debug mode.
pub fn app_title() -> String {
let suffix = if debug_mode() { "-DEV" } else { "" };
format!("{APP_NAME} v{APP_VERSION}{suffix}")
}
View File
-101
View File
@@ -1,101 +0,0 @@
"""
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.
"""
import json
from pathlib import Path
# Global config file (next to the main script)
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
# =============================================================================
# TAG SCHEMA - default library settings (stored per-pool in the index)
# =============================================================================
# Tag schema: the single source of truth for which tag categories exist, how
# they are derived from ČSFD, and how they map to the Filmotéka folder tree.
# Each entry:
# category — tag category name (e.g. "Žánr")
# csfd_field — NFO source field feeding it (genres / year / countries /
# rating), or None for a user-only category. (Key name kept for
# index compatibility; the data now comes from the NFO.)
# transform — named value transform: None (str) or "decade_band" (rating)
# filmoteka_root — folder under the output: "" = at the root (e.g. genres),
# "Dle X" = grouping folder, None = filterable but no folders
# filename_template — optional per-category hardlink name used only inside this
# category's folders (e.g. "{year} - {title}{ext}"); fields:
# title / year / rating / ext / stem / filename. Absent/None =
# keep the pool filename. Pool files are never renamed by this.
# Grouping folders are prefixed with "- " so DLNA/TV browsers sort the special
# 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
# in the index (``PoolIndex.settings["tag_schema"]``).
DEFAULT_TAG_SCHEMA = [
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "- Dle roku"},
{"category": "Země původu", "csfd_field": "countries", "transform": None,
"filmoteka_root": "- Dle země původu"},
{"category": "Hodnocení", "csfd_field": "rating", "transform": "decade_band",
"filmoteka_root": "- Dle hodnocení"},
]
# =============================================================================
# GLOBAL CONFIG - Application settings only
# =============================================================================
DEFAULT_GLOBAL_CONFIG = {
"window_geometry": "1200x800",
"window_maximized": False,
"last_folder": None,
"sidebar_width": 250,
"recent_folders": [],
"pool_dir": None, # managed pool root (single source of truth)
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
}
def load_global_config() -> dict:
"""Load global application config"""
if GLOBAL_CONFIG_FILE.exists():
try:
with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Merge with defaults for any missing keys
for key, value in DEFAULT_GLOBAL_CONFIG.items():
if key not in config:
config[key] = value
return config
except Exception:
return DEFAULT_GLOBAL_CONFIG.copy()
return DEFAULT_GLOBAL_CONFIG.copy()
def save_global_config(cfg: dict):
"""Save global application config"""
with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
# =============================================================================
# BACKWARDS COMPATIBILITY
# =============================================================================
def load_config():
"""Legacy function - returns global config"""
return load_global_config()
def save_config(cfg: dict):
"""Legacy function - saves global config"""
save_global_config(cfg)
+220
View File
@@ -0,0 +1,220 @@
//! Configuration for Curator.
//!
//! Two stores, by responsibility:
//!
//! 1. Global config (per user, in the platform config directory) — **application
//! data only**: window state and the pointers to where the pool and the
//! miniDLNA 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`) — everything
//! describing a particular library: per-movie metadata **and** library-level
//! settings (`tag_schema`, …). It travels with the pool.
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::warn;
use super::error::CoreResult;
/// File name of the global config (the same name the Python version used, so an
/// old config can simply be copied over).
pub const GLOBAL_CONFIG_NAME: &str = ".Curator.!gtag";
/// One tag-schema entry: which tag category exists, which NFO field feeds it,
/// and how it maps to the miniDLNA folder tree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TagSchemaEntry {
/// Tag category name (e.g. "Žánr").
pub category: String,
/// NFO source field (genres / year / countries / rating), or `None` for a
/// user-only category. The key name is kept for index compatibility.
#[serde(default)]
pub csfd_field: Option<String>,
/// Named value transform (`None` = as-is, `"decade_band"` for ratings).
#[serde(default)]
pub transform: Option<String>,
/// Folder under the output: `""` = at the root (genres), `"- Dle X"` =
/// grouping folder, `None` = filterable but no folders.
#[serde(default)]
pub filmoteka_root: Option<String>,
/// Optional hardlink-name template used only inside this category's folders
/// (e.g. `"{year} - {title}{ext}"`). `None` keeps the pool filename.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename_template: Option<String>,
}
impl TagSchemaEntry {
fn new(category: &str, field: &str, transform: Option<&str>, root: &str) -> Self {
Self {
category: category.into(),
csfd_field: Some(field.into()),
transform: transform.map(Into::into),
filmoteka_root: Some(root.into()),
filename_template: None,
}
}
}
/// The built-in schema. Grouping folders are prefixed with "- " so DLNA/TV
/// browsers sort the special folders before the genre folders at the root. The
/// effective schema is stored per pool in the index.
pub fn default_tag_schema() -> Vec<TagSchemaEntry> {
vec![
TagSchemaEntry::new("Žánr", "genres", None, ""),
TagSchemaEntry::new("Rok", "year", None, "- Dle roku"),
TagSchemaEntry::new("Země původu", "countries", None, "- Dle země původu"),
TagSchemaEntry::new(
"Hodnocení",
"rating",
Some("decade_band"),
"- Dle hodnocení",
),
]
}
/// Global application settings. Unknown keys are kept and written back.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GlobalConfig {
#[serde(default = "default_geometry")]
pub window_geometry: String,
#[serde(default)]
pub window_maximized: bool,
/// Managed pool root (single source of truth).
#[serde(default)]
pub pool_dir: Option<String>,
/// Generated miniDLNA output (hardlink tree).
#[serde(default)]
pub filmoteka_dir: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
fn default_geometry() -> String {
"1200x800".into()
}
impl Default for GlobalConfig {
fn default() -> Self {
Self {
window_geometry: default_geometry(),
window_maximized: false,
pool_dir: None,
filmoteka_dir: None,
extra: Map::new(),
}
}
}
/// Where the global config lives: `$CURATOR_CONFIG` if set, otherwise
/// `<platform config dir>/curator/.Curator.!gtag`.
pub fn global_config_path() -> PathBuf {
if let Ok(p) = std::env::var("CURATOR_CONFIG") {
return PathBuf::from(p);
}
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("curator")
.join(GLOBAL_CONFIG_NAME)
}
/// Load the config from `path`; a missing or unreadable file gives the defaults.
pub fn load_global_config_from(path: &Path) -> GlobalConfig {
let Ok(text) = fs::read_to_string(path) else {
return GlobalConfig::default();
};
serde_json::from_str(&text).unwrap_or_else(|e| {
warn!(
"global config {} is invalid ({e}); using defaults",
path.display()
);
GlobalConfig::default()
})
}
/// Load the global config. On first run, a `.Curator.!gtag` left by the Python
/// version in the working directory is picked up so the pool paths carry over.
pub fn load_global_config() -> GlobalConfig {
let path = global_config_path();
if !path.exists() {
let legacy = PathBuf::from(GLOBAL_CONFIG_NAME);
if legacy.is_file() {
return load_global_config_from(&legacy);
}
}
load_global_config_from(&path)
}
pub fn save_global_config_to(cfg: &GlobalConfig, path: &Path) -> CoreResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_string_pretty(cfg)?)?;
Ok(())
}
pub fn save_global_config(cfg: &GlobalConfig) -> CoreResult<()> {
save_global_config_to(cfg, &global_config_path())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_missing_file_gives_defaults() {
let dir = tempfile::tempdir().unwrap();
let cfg = load_global_config_from(&dir.path().join("nope"));
assert_eq!(cfg, GlobalConfig::default());
}
#[test]
fn test_load_invalid_json_gives_defaults() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("cfg");
fs::write(&p, "{not json").unwrap();
assert_eq!(load_global_config_from(&p), GlobalConfig::default());
}
#[test]
fn test_save_and_load_roundtrip_keeps_unknown_keys() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("sub").join("cfg");
fs::write(dir.path().join("x"), "").unwrap();
let mut cfg = GlobalConfig {
pool_dir: Some("/pool".into()),
..Default::default()
};
cfg.extra.insert("sidebar_width".into(), Value::from(250));
save_global_config_to(&cfg, &p).unwrap();
assert_eq!(load_global_config_from(&p), cfg);
}
#[test]
fn test_load_python_config_merges_missing_keys() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("cfg");
fs::write(&p, r#"{"pool_dir": "/mnt/pool", "recent_folders": []}"#).unwrap();
let cfg = load_global_config_from(&p);
assert_eq!(cfg.pool_dir.as_deref(), Some("/mnt/pool"));
assert_eq!(cfg.window_geometry, "1200x800");
assert!(cfg.extra.contains_key("recent_folders"));
}
#[test]
fn test_default_schema_entries() {
let schema = default_tag_schema();
assert_eq!(schema.len(), 4);
assert_eq!(schema[0].filmoteka_root.as_deref(), Some(""));
assert_eq!(schema[3].transform.as_deref(), Some("decade_band"));
}
#[test]
fn test_schema_entry_missing_root_is_none() {
let e: TagSchemaEntry = serde_json::from_str(r#"{"category": "Vlastní"}"#).unwrap();
assert_eq!(e.filmoteka_root, None);
assert_eq!(e.csfd_field, None);
}
}
+694
View File
@@ -0,0 +1,694 @@
//! ČSFD.cz scraper — a *title bridge* to TMDb.
//!
//! A direct TMDb search on a Czech distribution title is unreliable. ČSFD indexes
//! Czech release names well **and** lists a film's *original* title, so:
//!
//! ```text
//! filename → ČSFD search → ČSFD film page → original title + year
//! → TMDb search → tmdbid
//! ```
//!
//! ČSFD serves an **Anubis** proof-of-work wall in front of every page. We detect
//! it, solve the SHA-256 PoW the way the bundled worker JS does, and replay the
//! request through a shared session (so a batch pays the PoW cost only once).
use std::path::Path;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use ego_tree::NodeRef;
use regex::Regex;
use reqwest::blocking::{Client, Response};
use scraper::{ElementRef, Html, Node, Selector};
use serde_json::Value;
use sha2::{Digest, Sha256};
use tracing::debug;
use url::Url;
use super::error::CoreError;
use super::http::new_session;
pub const CSFD_BASE_URL: &str = "https://www.csfd.cz";
pub const CSFD_SEARCH_URL: &str = "https://www.csfd.cz/hledat/";
const ANUBIS_CHALLENGE_MARKER: &str = r#"id="anubis_challenge""#;
const ANUBIS_PASS_PATH: &str = "/.within.website/x/cmd/anubis/api/pass-challenge";
/// Safety cap so a difficulty bump can never spin forever.
const ANUBIS_MAX_NONCE: u64 = 50_000_000;
const TIMEOUT: Duration = Duration::from_secs(10);
/// ČSFD flags older Czech films "Československo" while `.origin` may read "Česko".
const CZ_ORIGIN_ALIASES: &[&str] = &["česko", "československo"];
/// Release-name tokens that mark the end of the actual title in a filename.
const RELEASE_MARKERS: &[&str] = &[
"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",
];
macro_rules! re {
($name:ident, $pat:expr) => {
static $name: LazyLock<Regex> = LazyLock::new(|| Regex::new($pat).expect("valid regex"));
};
}
re!(CSFD_ID_RE, r"/film/(\d+)");
re!(YEAR_ANY_RE, r"(19|20)\d{2}");
re!(YEAR_FULL_RE, r"^(19|20)\d{2}$");
re!(LETTER_RE, r"[^\W\d_]");
re!(RESOLUTION_RE, r"(?i)^\d{3,4}p$|^[24]k$");
re!(SPLIT_RE, r"[.\s_]+");
re!(TRAILING_YEAR_RE, r"\s+(?:19|20)\d{2}$");
fn sel(css: &str) -> Selector {
Selector::parse(css).expect("valid selector")
}
/// The slice of a ČSFD film page Curator needs to reach a TMDb id.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CsfdFilm {
pub url: String,
pub csfd_id: Option<i64>,
pub czech_title: String,
pub original_title: String,
pub year: Option<String>,
}
impl CsfdFilm {
/// `(title, year)` best suited for a TMDb search — original title first.
pub fn tmdb_query(&self) -> (String, Option<String>) {
let title = if self.original_title.is_empty() {
&self.czech_title
} else {
&self.original_title
};
(title.clone(), self.year.clone())
}
}
fn err(msg: impl Into<String>) -> CoreError {
CoreError::Remote(msg.into())
}
/// Pull the numeric film id out of a ČSFD URL.
pub fn extract_csfd_id(url: &str) -> Option<i64> {
CSFD_ID_RE.captures(url)?[1].parse().ok()
}
// ---------------------------------------------------------------------------
// Anubis proof-of-work
// ---------------------------------------------------------------------------
/// The parsed JSON of an Anubis `<script id=... type="application/json">` blob.
fn extract_json_blob(html: &str, element_id: &str) -> Option<Value> {
let pattern = format!(
r#"(?s)<script id="{}" type="application/json">(.*?)</script>"#,
regex::escape(element_id)
);
let caps = Regex::new(&pattern).ok()?.captures(html)?;
serde_json::from_str(&caps[1]).ok()
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Find the smallest `nonce` such that `sha256(random_data + nonce)` has
/// `difficulty` leading zero nibbles. Returns `(hash_hex, nonce, elapsed_ms)`.
pub fn solve_anubis_pow(
random_data: &str,
difficulty: usize,
) -> Result<(String, u64, u128), CoreError> {
let full_zero_bytes = difficulty / 2;
let needs_half_byte = !difficulty.is_multiple_of(2);
let start = Instant::now();
for nonce in 0..ANUBIS_MAX_NONCE {
let digest = Sha256::digest(format!("{random_data}{nonce}").as_bytes());
if digest[..full_zero_bytes].iter().any(|b| *b != 0) {
continue;
}
if needs_half_byte && digest[full_zero_bytes] >> 4 != 0 {
continue;
}
return Ok((hex(&digest), nonce, start.elapsed().as_millis()));
}
Err(err(format!(
"Anubis PoW nevyřešen do {ANUBIS_MAX_NONCE} pokusů (obtížnost {difficulty})"
)))
}
fn http_get(session: &Client, url: &str, query: &[(&str, String)]) -> Result<String, CoreError> {
let resp: Response = session
.get(url)
.header("Accept-Language", "cs,en;q=0.9")
.query(query)
.timeout(TIMEOUT)
.send()
.map_err(|e| err(format!("Nelze načíst ČSFD stránku: {e}")))?
.error_for_status()
.map_err(|e| err(format!("Nelze načíst ČSFD stránku: {e}")))?;
resp.text()
.map_err(|e| err(format!("Nelze načíst ČSFD stránku: {e}")))
}
/// Solve the Anubis challenge in `html` and return the real page.
fn solve_anubis_challenge(session: &Client, html: &str, url: &str) -> Result<String, CoreError> {
let payload = extract_json_blob(html, "anubis_challenge")
.ok_or_else(|| err("ČSFD anti-bot stránka bez čitelné Anubis challenge."))?;
let challenge = payload.get("challenge").cloned().unwrap_or(Value::Null);
let random_data = challenge
.get("randomData")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| err("Anubis challenge neobsahuje randomData."))?;
let difficulty = payload
.get("rules")
.and_then(|r| r.get("difficulty"))
.and_then(|d| {
d.as_u64()
.or_else(|| d.as_str().and_then(|s| s.parse().ok()))
})
.unwrap_or(1) as usize;
let base_prefix = extract_json_blob(html, "anubis_base_prefix")
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
debug!("Solving Anubis challenge (difficulty {difficulty}) for {url}");
let (hash_hex, nonce, elapsed_ms) = solve_anubis_pow(random_data, difficulty)?;
debug!("Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms");
let pass_url = Url::parse(CSFD_BASE_URL)
.and_then(|b| b.join(&format!("{base_prefix}{ANUBIS_PASS_PATH}")))
.map_err(|e| err(format!("Neplatná Anubis adresa: {e}")))?;
let id = challenge
.get("id")
.map(|v| v.as_str().map_or_else(|| v.to_string(), String::from))
.unwrap_or_default();
let text = http_get(
session,
pass_url.as_str(),
&[
("id", id),
("response", hash_hex),
("nonce", nonce.to_string()),
("redir", url.to_string()),
("elapsedTime", elapsed_ms.to_string()),
],
)?;
if text.contains(ANUBIS_CHALLENGE_MARKER) {
return Err(err(
"ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto).",
));
}
Ok(text)
}
/// GET `url` through `session`, transparently clearing an Anubis wall.
fn get_page(session: &Client, url: &str) -> Result<String, CoreError> {
let text = http_get(session, url, &[])?;
if text.contains(ANUBIS_CHALLENGE_MARKER) {
return solve_anubis_challenge(session, &text, url);
}
Ok(text)
}
// ---------------------------------------------------------------------------
// Page parsing
// ---------------------------------------------------------------------------
/// Text nodes of `el` stripped and joined with `sep`, skipping empty ones
/// (BeautifulSoup's `get_text(separator, strip=True)`).
fn joined_text(el: ElementRef<'_>, sep: &str) -> String {
el.text()
.map(str::trim)
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(sep)
}
/// `(name, year)` from the film page's JSON-LD (name is the Czech title).
fn parse_json_ld(doc: &Html) -> (String, Option<String>) {
for script in doc.select(&sel(r#"script[type="application/ld+json"]"#)) {
let Ok(mut data) = serde_json::from_str::<Value>(&script.text().collect::<String>()) else {
continue;
};
if let Value::Array(items) = &data {
data = items
.iter()
.find(|d| d.get("@type").and_then(Value::as_str) == Some("Movie"))
.cloned()
.unwrap_or(Value::Null);
}
if data.get("@type").and_then(Value::as_str) != Some("Movie") {
continue;
}
let name = data
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
let year = ["dateCreated", "datePublished"].iter().find_map(|k| {
let raw = data
.get(*k)
.map(|v| v.as_str().map_or_else(|| v.to_string(), String::from))
.unwrap_or_default();
YEAR_ANY_RE.find(&raw).map(|m| m.as_str().to_string())
});
return (name, year);
}
(String::new(), None)
}
/// `(origin_country, year)` from the `.origin` line (`USA • 1999 • 136 min`).
/// For a co-production only the first country is returned.
fn parse_origin(doc: &Html) -> (Option<String>, Option<String>) {
let Some(elem) = doc.select(&sel(".origin")).next() else {
return (None, None);
};
let raw = joined_text(elem, "|");
let mut country = None;
let mut year = None;
for token in raw.split('|').flat_map(|p| p.split(',')).map(str::trim) {
if token.is_empty() {
continue;
}
if year.is_none() && YEAR_FULL_RE.is_match(token) {
year = Some(token.to_string());
continue;
}
let starts_digit = token.chars().next().is_some_and(|c| c.is_numeric());
if country.is_none() && !starts_digit && LETTER_RE.is_match(token) {
country = Some(token.split('/').next().unwrap_or("").trim().to_string());
}
}
(country, year)
}
/// Text of `node`'s descendants, skipping anything inside `<span>` / `<img>`.
fn text_without_spans(node: NodeRef<'_, Node>, out: &mut Vec<String>) {
for child in node.children() {
match child.value() {
Node::Text(t) => out.push(t.to_string()),
Node::Element(e) if matches!(e.name(), "span" | "img") => {}
Node::Element(_) => text_without_spans(child, out),
_ => {}
}
}
}
/// The film's original title from `.film-names` — the entry whose flag matches
/// the country of origin — or `None` to fall back to the Czech title.
fn parse_original_title(doc: &Html, origin_country: Option<&str>) -> Option<String> {
let flag_sel = sel("img.flag");
let mut names: Vec<(String, String)> = Vec::new();
for li in doc.select(&sel(".film-names li")) {
let country = li
.select(&flag_sel)
.next()
.and_then(|f| f.value().attr("title"))
.unwrap_or("")
.trim()
.to_string();
let mut parts = Vec::new();
text_without_spans(*li, &mut parts);
let text = parts
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let text = text.trim_matches(|c| c == ' ' || c == '·').to_string();
if !text.is_empty() {
names.push((country, text));
}
}
let wanted = origin_country?.to_lowercase();
if names.is_empty() {
return None;
}
names.into_iter().find_map(|(country, text)| {
let here = country.to_lowercase();
let both_cz = CZ_ORIGIN_ALIASES.contains(&wanted.as_str())
&& CZ_ORIGIN_ALIASES.contains(&here.as_str());
(here == wanted || both_cz).then_some(text)
})
}
/// Parse a film page's HTML.
pub fn parse_film_page(html: &str, url: &str) -> CsfdFilm {
let doc = Html::parse_document(html);
let (mut czech_title, year) = parse_json_ld(&doc);
let (origin_country, origin_year) = parse_origin(&doc);
let year = year.or(origin_year);
if czech_title.is_empty() {
czech_title = doc
.select(&sel(".film-header-name h1"))
.next()
.map(|h| joined_text(h, ""))
.unwrap_or_default();
}
let original_title = parse_original_title(&doc, origin_country.as_deref())
.unwrap_or_else(|| czech_title.clone());
CsfdFilm {
url: url.into(),
csfd_id: extract_csfd_id(url),
czech_title,
original_title,
year,
}
}
/// Fetch a ČSFD film page. Pass a shared session to reuse the Anubis cookie.
pub fn fetch_film(url: &str, session: Option<&Client>) -> Result<CsfdFilm, CoreError> {
let own;
let session = match session {
Some(s) => s,
None => {
own = new_session();
&own
}
};
Ok(parse_film_page(&get_page(session, url)?, url))
}
/// True for a film URL `/film/<id>-slug/` — a series episode has two segments.
pub fn is_film_href(href: &str) -> bool {
let parts: Vec<&str> = href
.split('/')
.filter(|p| !p.is_empty() && *p != "prehled")
.collect();
parts.len() == 2 && parts[0] == "film"
}
/// Parse a search page's HTML — film hits only, scoped to the *Filmy* section.
pub fn parse_search_page(html: &str, limit: usize) -> Vec<CsfdFilm> {
let doc = Html::parse_document(html);
let header_sel = sel(".box-header h2");
let h2_sel = sel("h2");
let anchor_sel = sel("a.film-title-name");
let films_section = doc.select(&sel("section")).find(|section| {
let header = section
.select(&header_sel)
.next()
.or_else(|| section.select(&h2_sel).next());
header.is_some_and(|h| joined_text(h, "").to_lowercase() == "filmy")
});
let anchors: Vec<ElementRef<'_>> = match films_section {
Some(section) => section.select(&anchor_sel).collect(),
None => doc.select(&anchor_sel).collect(),
};
let base = Url::parse(CSFD_BASE_URL).expect("valid base url");
anchors
.into_iter()
.filter_map(|a| {
let href = a.value().attr("href").unwrap_or("");
if !is_film_href(href) {
return None;
}
Some(CsfdFilm {
url: base
.join(href)
.map(String::from)
.unwrap_or_else(|_| href.to_string()),
csfd_id: extract_csfd_id(href),
czech_title: joined_text(a, ""),
..Default::default()
})
})
.take(limit)
.collect()
}
/// Search ČSFD for `query`; film hits only, best first.
pub fn search_films(
query: &str,
limit: usize,
session: Option<&Client>,
) -> Result<Vec<CsfdFilm>, CoreError> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let mut url = Url::parse(CSFD_SEARCH_URL).expect("valid search url");
url.query_pairs_mut().append_pair("q", query);
let own;
let session = match session {
Some(s) => s,
None => {
own = new_session();
&own
}
};
let html =
get_page(session, url.as_str()).map_err(|e| err(format!("ČSFD hledání selhalo: {e}")))?;
Ok(parse_search_page(&html, limit))
}
/// The first ČSFD film URL matching `query`. A trailing year that makes the
/// search miss is retried without it.
pub fn find_csfd_url(query: &str, session: Option<&Client>) -> Result<Option<String>, CoreError> {
let query = query.trim();
if query.is_empty() {
return Ok(None);
}
let mut films = search_films(query, 1, session)?;
if films.is_empty() {
let without_year = TRAILING_YEAR_RE.replace(query, "").to_string();
if without_year != query {
films = search_films(&without_year, 1, session)?;
}
}
Ok(films.into_iter().next().map(|f| f.url))
}
/// Turn a (possibly release-named) filename into a ČSFD search query:
/// `"Matrix.1999.1080p.BluRay.x264-GROUP.mkv"` → `"Matrix 1999"`.
pub fn clean_filename_to_query(filename: &str) -> String {
let stem = super::paths::stem(Path::new(filename));
let mut title_words = Vec::new();
let mut year = None;
for token in SPLIT_RE.split(&stem).filter(|t| !t.is_empty()) {
let bare = token.trim_matches(|c| "()[]{}".contains(c));
if YEAR_FULL_RE.is_match(bare) {
year = Some(bare.to_string());
break;
}
if RESOLUTION_RE.is_match(bare) || RELEASE_MARKERS.contains(&bare.to_lowercase().as_str()) {
break;
}
title_words.push(token);
}
let mut title = title_words.join(" ").trim().to_string();
if title.is_empty() {
title = SPLIT_RE.replace_all(&stem, " ").trim().to_string();
}
match year {
Some(y) => format!("{title} {y}").trim().to_string(),
None => title,
}
}
#[cfg(test)]
mod tests {
use super::*;
const FILM_HTML: &str = r#"
<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>
"#;
const CZECH_FILM_HTML: &str = r#"
<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>
"#;
const SEARCH_HTML: &str = r#"
<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>
"#;
#[test]
fn test_clean_filename_to_query_cases() {
assert_eq!(
clean_filename_to_query("Matrix.1999.1080p.BluRay.x264-GROUP.mkv"),
"Matrix 1999"
);
assert_eq!(
clean_filename_to_query("Forrest Gump (1994) 2160p HDR.mkv"),
"Forrest Gump 1994"
);
assert_eq!(clean_filename_to_query("Amelie.mkv"), "Amelie");
assert_eq!(clean_filename_to_query("Sam_doma_720p.mkv"), "Sam doma");
assert_eq!(clean_filename_to_query("1080p.mkv"), "1080p");
}
#[test]
fn test_extract_csfd_id_cases() {
assert_eq!(
extract_csfd_id("https://www.csfd.cz/film/2294-vykoupeni/prehled/"),
Some(2294)
);
assert_eq!(extract_csfd_id("/film/999/"), Some(999));
assert_eq!(extract_csfd_id("https://www.csfd.cz/"), None);
}
#[test]
fn test_solve_anubis_pow_difficulty() {
let (hash_hex, nonce, _) = solve_anubis_pow("abc123", 1).unwrap();
assert!(hash_hex.starts_with('0'));
assert_eq!(
hex(&Sha256::digest(format!("abc123{nonce}").as_bytes())),
hash_hex
);
let (hash_hex, _, _) = solve_anubis_pow("seed", 2).unwrap();
assert!(hash_hex.starts_with("00"));
}
#[test]
fn test_parse_origin_cases() {
assert_eq!(
parse_origin(&Html::parse_document(FILM_HTML)),
(Some("USA".into()), Some("1994".into()))
);
let html = r#"<div class="origin">Francie / Německo <span class="bullet"></span><span>2001</span> 120 min</div>"#;
assert_eq!(
parse_origin(&Html::parse_document(html)),
(Some("Francie".into()), Some("2001".into()))
);
}
#[test]
fn test_parse_film_page_foreign_film() {
let film = parse_film_page(FILM_HTML, "https://www.csfd.cz/film/2294-vykoupeni/");
assert_eq!(film.czech_title, "Vykoupení z věznice Shawshank");
assert_eq!(film.original_title, "The Shawshank Redemption");
assert_eq!(film.year.as_deref(), Some("1994"));
assert_eq!(film.csfd_id, Some(2294));
assert_eq!(
film.tmdb_query(),
("The Shawshank Redemption".into(), Some("1994".into()))
);
}
#[test]
fn test_parse_film_page_czech_film_falls_back_to_h1() {
let film = parse_film_page(CZECH_FILM_HTML, "https://www.csfd.cz/film/1/");
assert_eq!(film.original_title, "Pelíšky");
assert_eq!(film.year.as_deref(), Some("1999"));
}
#[test]
fn test_parse_search_page_scopes_to_films() {
let films = parse_search_page(SEARCH_HTML, 5);
let urls: Vec<&str> = films.iter().map(|f| f.url.as_str()).collect();
assert_eq!(
urls,
vec![
"https://www.csfd.cz/film/2294-vykoupeni-z-veznice-shawshank/prehled/",
"https://www.csfd.cz/film/248803-film-o-filmu/prehled/",
]
);
assert_eq!(films[0].czech_title, "Vykoupení z věznice Shawshank");
assert_eq!(parse_search_page(SEARCH_HTML, 1).len(), 1);
}
#[test]
fn test_is_film_href_cases() {
assert!(is_film_href("/film/2294-vykoupeni/prehled/"));
assert!(!is_film_href("/film/318290-arrow/620181-x/prehled/"));
assert!(!is_film_href("/tvurce/1/"));
}
#[test]
fn test_extract_json_blob() {
let html = r#"<script id="anubis_challenge" type="application/json">{"rules":{"difficulty":2}}</script>"#;
assert_eq!(
extract_json_blob(html, "anubis_challenge").unwrap()["rules"]["difficulty"],
2
);
assert!(extract_json_blob(html, "other").is_none());
}
}
+33
View File
@@ -0,0 +1,33 @@
//! The error type shared by the core modules.
use std::io;
/// Everything a core operation can fail with. The `Display` text is shown to the
/// user as-is, so validation messages are written in Czech like the rest of the UI.
#[derive(Debug, thiserror::Error)]
pub enum CoreError {
/// Invalid input (bad year, empty title, unknown 3D format, …).
#[error("{0}")]
Invalid(String),
/// The target already exists and the caller asked not to overwrite it.
#[error("{0}")]
Exists(String),
/// Something expected on disk is missing.
#[error("{0}")]
NotFound(String),
/// A remote service (TMDb, ČSFD, Jellyfin) failed.
#[error("{0}")]
Remote(String),
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
impl CoreError {
pub fn invalid(msg: impl Into<String>) -> Self {
Self::Invalid(msg.into())
}
}
pub type CoreResult<T> = Result<T, CoreError>;
-272
View File
@@ -1,272 +0,0 @@
from pathlib import Path
from .tag import Tag
class File:
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
self.file_path = file_path
self.filename = file_path.name
# Unified pool index; when set, metadata is persisted there (see
# PoolIndex). Without an index a File is an in-memory object only — no
# metadata is written to disk (there are no per-file sidecars anymore).
self.index = index
self.new = True
self.ignored = False
self.tags: list[Tag] = []
self.tagmanager = tagmanager
# new: optional date string "YYYY-MM-DD" (assigned manually)
self.date: str | None = None
# movie-library fields
self.title: str | None = None
# Optional ČSFD film URL — a second database pointer kept alongside the
# TMDb id (which lives in the filename). Stored only, not fetched.
self.csfd_url: str | None = None
# ISO timestamp of when the movie was added to the pool (set on import);
# None for older/unknown → falls back to the file's ctime.
self.added: str | None = None
# Cached metadata used for folder grouping (year, rating) — filled from
# the movie's NFO (which Jellyfin writes).
self.meta: dict | None = None
# full_paths of tags derived automatically from the NFO (vs. user-added);
# only these are regenerated on refresh — user tags are preserved.
self.auto_tag_paths: set[str] = set()
# free-form per-movie attributes (e.g. "collection_sort"); usable in
# miniDLNA filename templates via name_context.
self.attributes: dict[str, str] = {}
self.get_metadata()
def get_metadata(self) -> None:
record = self.index.get(self.file_path) if self.index is not None else None
if record is None:
self._init_new_metadata()
self.save_metadata()
else:
self._apply_record(record)
def _init_new_metadata(self) -> None:
self.new = True
self.ignored = False
self.tags = []
self.date = None
self.title = None
self.csfd_url = None
self.meta = None
self.auto_tag_paths = set()
self.attributes = {}
self.added = None
def _build_record(self) -> dict:
data = {
"new": self.new,
"ignored": self.ignored,
# ukládáme full_path tagů
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
# which of the tags were derived automatically (from the NFO)
"auto_tags": sorted(self.auto_tag_paths),
# date může být None
"date": self.date,
"title": self.title,
"csfd_url": self.csfd_url,
"attributes": self.attributes,
"added": self.added,
}
if self.meta is not None:
data["meta"] = dict(self.meta)
return data
def _apply_record(self, data: dict) -> None:
self.new = data.get("new", True)
self.ignored = data.get("ignored", False)
self.tags = []
self.date = data.get("date", None)
self.title = data.get("title", None)
self.csfd_url = data.get("csfd_url", None)
# provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored
self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", [])))
self.attributes = dict(data.get("attributes", {}))
self.added = data.get("added", None)
raw_meta = data.get("meta")
if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
old = data.get("csfd_cache") or {}
raw_meta = {"year": old.get("year"), "rating": old.get("rating")} if old else None
self.meta = dict(raw_meta) if raw_meta else None
if not self.tagmanager:
return
for tag_str in data.get("tags", []):
if "/" in tag_str:
category, name = tag_str.split("/", 1)
tag = self.tagmanager.add_tag(category, name)
self.tags.append(tag)
def save_metadata(self):
"""Persist metadata to the pool index; a no-op for an index-less File."""
if self.index is not None:
self.index.set(self.file_path, self._build_record())
def added_timestamp(self) -> 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.
"""
if self.added:
try:
from datetime import datetime
return datetime.fromisoformat(self.added).timestamp()
except ValueError:
pass
try:
return self.file_path.stat().st_mtime
except OSError:
return 0.0
def name_context(self) -> 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.
"""
cache = self.meta or {}
year = cache.get("year")
if year is None:
for t in self.tags:
if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit():
year = t.name
break
if year is None:
# Last resort: recover it from a canonical "Title (YYYY)" filename.
from .naming import parse_pool_stem
parsed = parse_pool_stem(self.file_path.stem)
if parsed is not None:
year = parsed[1]
rating = cache.get("rating")
# user attributes first; core fields take precedence over same-named keys
context = dict(self.attributes)
context.update({
"title": self.title or self.file_path.stem,
"year": "" if year is None else str(year),
"rating": "" if rating is None else rating,
"ext": self.file_path.suffix,
"stem": self.file_path.stem,
"filename": self.filename,
})
return context
def set_attribute(self, key: str, value: str | None) -> None:
"""Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it."""
key = key.strip()
if not key:
return
if value:
self.attributes[key] = value
else:
self.attributes.pop(key, None)
self.save_metadata()
def delete_metadata(self) -> None:
"""Remove this file's metadata from the pool index (no-op without one)."""
if self.index is not None:
self.index.delete(self.file_path)
def relocate(self, new_path: Path) -> None:
"""Point this File at a new path, moving its metadata along.
The physical file must already have been moved/renamed by the caller.
Drops the metadata under the old index key and rebinds to the new path;
call ``save_metadata()`` afterwards to write it back.
"""
if self.index is not None:
self.index.delete(self.file_path)
self.file_path = Path(new_path)
self.filename = self.file_path.name
def set_date(self, date_str: str | None):
"""Nastaví datum (např. '2025-09-25') nebo None pro smazání."""
if date_str is None or date_str == "":
self.date = None
else:
# neprvádíme složitou validaci zde; očekáváme 'YYYY-MM-DD'
self.date = date_str
self.save_metadata()
def apply_nfo(self, 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'.
"""
if nfo is None:
return {"success": False, "error": "NFO nenalezeno", "tags_added": []}
if schema is None:
from .config import DEFAULT_TAG_SCHEMA
schema = DEFAULT_TAG_SCHEMA
self.meta = {"year": nfo.year, "rating": nfo.rating}
# Drop previous auto (NFO) tags; keep user-added ones.
self.tags = [t for t in self.tags if t.full_path not in self.auto_tag_paths]
self.auto_tag_paths = set()
tags_added: list[str] = []
for entry in schema:
field = entry.get("csfd_field") # schema key = source field name
if not field:
continue # user-only category
category = entry["category"]
for value in nfo.field_values(field):
tag_obj = (self.tagmanager.add_tag(category, value)
if self.tagmanager else Tag(category, value))
if tag_obj not in self.tags:
self.tags.append(tag_obj)
tags_added.append(tag_obj.full_path)
self.auto_tag_paths.add(tag_obj.full_path)
if nfo.title and not self.title:
self.title = nfo.title
self.save_metadata()
return {"success": True, "tags_added": tags_added}
def add_tag(self, tag):
# tag může být Tag nebo string
from .tag import Tag as TagClass
if isinstance(tag, str):
if "/" in tag and self.tagmanager:
category, name = tag.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
else:
tag_obj = TagClass("default", tag)
elif isinstance(tag, TagClass):
tag_obj = tag
else:
return
if tag_obj not in self.tags:
self.tags.append(tag_obj)
self.save_metadata()
def remove_tag(self, tag):
# tag může být Tag nebo string (full_path)
if isinstance(tag, str):
if "/" in tag:
category, name = tag.split("/", 1)
tag_obj = Tag(category, name)
else:
tag_obj = Tag("default", tag)
elif isinstance(tag, Tag):
tag_obj = tag
else:
return
if tag_obj in self.tags:
self.tags.remove(tag_obj)
self.save_metadata()
+736
View File
@@ -0,0 +1,736 @@
//! One pooled video file — a *view* on one media entry of a movie record.
//!
//! The pool index is identity-keyed (see `movie`), so every version of the same
//! film 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), otherwise it starts a new record.
//!
//! Without an index a `File` is an in-memory object only.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone};
use regex::Regex;
use serde_json::{Map, Value, json};
use super::config::TagSchemaEntry;
use super::error::CoreResult;
use super::movie::{ROLE_VIDEO, identity_key_for};
use super::naming::{is_canonical_pool_stem, parse_3d_format, parse_pool_stem, parse_tmdb_id};
use super::nfo::NfoData;
use super::paths;
use super::pool_index::{PoolIndex, truthy};
use super::tag::Tag;
use super::tag_manager::TagManager;
use super::tmdb::TmdbDetails;
/// Where auto tags come from — an NFO or TMDb details.
pub trait TagSource {
fn year(&self) -> Option<String>;
fn rating(&self) -> Option<i64>;
fn title(&self) -> Option<String>;
fn field_values(&self, name: &str) -> Vec<String>;
}
impl TagSource for NfoData {
fn year(&self) -> Option<String> {
self.year.clone()
}
fn rating(&self) -> Option<i64> {
self.rating
}
fn title(&self) -> Option<String> {
self.title.clone()
}
fn field_values(&self, name: &str) -> Vec<String> {
NfoData::field_values(self, name)
}
}
impl TagSource for TmdbDetails {
fn year(&self) -> Option<String> {
self.year.clone()
}
fn rating(&self) -> Option<i64> {
self.rating
}
fn title(&self) -> Option<String> {
Some(self.title.clone()).filter(|t| !t.is_empty())
}
fn field_values(&self, name: &str) -> Vec<String> {
TmdbDetails::field_values(self, name)
}
}
/// What a file's location implies about the movie it belongs to.
#[derive(Debug, Clone, PartialEq)]
pub struct PathIdentity {
pub key: String,
pub title: String,
pub year: Option<String>,
pub tmdb_id: Option<i64>,
/// Version label implied by the filename (`""` for a plain file).
pub label: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct File {
pub file_path: PathBuf,
pub filename: String,
pub new: bool,
pub ignored: bool,
pub tags: Vec<Tag>,
/// Optional date string "YYYY-MM-DD" (assigned manually).
pub date: Option<String>,
pub title: Option<String>,
/// ČSFD film URL — a second database pointer kept alongside the TMDb id.
pub csfd_url: Option<String>,
/// ISO timestamp of when the movie was added to the pool.
pub added: Option<String>,
/// Cached `year` / `rating` used for folder grouping (from the NFO).
pub meta: Option<Map<String, Value>>,
/// Tags derived automatically (vs. user-added); only these are regenerated.
pub auto_tag_paths: BTreeSet<String>,
/// Free-form per-movie attributes usable in miniDLNA filename templates.
pub attributes: BTreeMap<String, String>,
/// Identity key of the movie record this file belongs to.
pub movie_key: Option<String>,
}
fn opt_str(v: Option<&Value>) -> Option<String> {
match v? {
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
impl File {
/// A bare in-memory file (no metadata loaded).
pub fn bare(path: &Path) -> Self {
Self {
file_path: path.to_path_buf(),
filename: paths::name(path),
new: true,
ignored: false,
tags: Vec::new(),
date: None,
title: None,
csfd_url: None,
added: None,
meta: None,
auto_tag_paths: BTreeSet::new(),
attributes: BTreeMap::new(),
movie_key: None,
}
}
/// Load a file's metadata from the movie record that owns it.
pub fn load(
path: &Path,
tm: &mut TagManager,
index: Option<&mut PoolIndex>,
) -> CoreResult<Self> {
let mut f = Self::bare(path);
if let Some(index) = index {
f.get_metadata(tm, index)?;
}
Ok(f)
}
/// (Re)read this file's metadata from the index; an unknown file joins the
/// movie of the same identity (or starts a new record) and is saved.
pub fn get_metadata(&mut self, tm: &mut TagManager, index: &mut PoolIndex) -> CoreResult<()> {
if let Some((key, record)) = index.find_movie_by_media(&self.file_path) {
let record = record.clone();
self.movie_key = Some(key);
self.apply_record(&record, tm);
return Ok(());
}
self.init_new_metadata();
let key = self.identity_from_path().key;
if let Some(existing) = index.get_movie(&key).cloned() {
self.movie_key = Some(key);
self.apply_record(&existing, tm);
}
self.save_metadata(index)
}
fn init_new_metadata(&mut self) {
let path = self.file_path.clone();
*self = Self::bare(&path);
}
/// Identity 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 label.
pub fn identity_from_path(&self) -> PathIdentity {
let parent = self.file_path.parent().map(paths::name).unwrap_or_default();
let stem = paths::stem(&self.file_path);
let grouped = is_canonical_pool_stem(&parent);
let identity_stem = if grouped {
parent.clone()
} else {
stem.clone()
};
let parsed = parse_pool_stem(&identity_stem);
let year = parsed.as_ref().map(|p| p.1.to_string());
let tmdb_id = parse_tmdb_id(&identity_stem);
let title = self
.title
.clone()
.filter(|t| !t.is_empty())
.unwrap_or_else(|| {
parsed
.as_ref()
.map_or_else(|| stem.clone(), |p| p.0.clone())
});
let part = if grouped {
Regex::new(&format!(
r"(?i)^{}[\s._-]*(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{{1,2}})$",
regex::escape(&parent)
))
.ok()
.and_then(|re| re.captures(&stem).and_then(|c| c[1].parse::<u32>().ok()))
} else {
None
};
let label = if let Some(n) = part {
format!("part{n}")
} else if let Some(rest) = grouped
.then(|| stem.strip_prefix(&format!("{parent} - ")))
.flatten()
{
rest.to_string()
} else {
parse_3d_format(&stem).unwrap_or_default()
};
PathIdentity {
key: identity_key_for(tmdb_id, &title, year.as_deref()),
title,
year,
tmdb_id,
label,
}
}
fn build_record(&self) -> Map<String, Value> {
let mut data = json!({
"new": self.new,
"ignored": self.ignored,
"tags": self.tags.iter().map(Tag::full_path).collect::<Vec<_>>(),
"auto_tags": self.auto_tag_paths.iter().collect::<Vec<_>>(),
"date": self.date,
"title": self.title,
"csfd_url": self.csfd_url,
"attributes": self.attributes,
"added": self.added,
});
if let (Some(meta), Some(obj)) = (&self.meta, data.as_object_mut()) {
obj.insert("meta".into(), Value::Object(meta.clone()));
}
match data {
Value::Object(m) => m,
_ => Map::new(),
}
}
fn apply_record(&mut self, data: &Value, tm: &mut TagManager) {
self.new = data.get("new").and_then(Value::as_bool).unwrap_or(true);
self.ignored = data
.get("ignored")
.and_then(Value::as_bool)
.unwrap_or(false);
self.tags.clear();
self.date = opt_str(data.get("date"));
self.title = opt_str(data.get("title"));
self.csfd_url = opt_str(data.get("csfd_url"));
let auto = data.get("auto_tags").or_else(|| data.get("csfd_tags"));
self.auto_tag_paths = auto
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
self.attributes = data
.get("attributes")
.and_then(Value::as_object)
.map(|o| {
o.iter()
.map(|(k, v)| {
(
k.clone(),
v.as_str().map_or_else(|| v.to_string(), String::from),
)
})
.collect()
})
.unwrap_or_default();
self.added = opt_str(data.get("added"));
let raw_meta = match data.get("meta") {
None | Some(Value::Null) => data
.get("csfd_cache")
.and_then(Value::as_object)
.filter(|o| !o.is_empty())
.map(|old| {
let mut m = Map::new();
m.insert(
"year".into(),
old.get("year").cloned().unwrap_or(Value::Null),
);
m.insert(
"rating".into(),
old.get("rating").cloned().unwrap_or(Value::Null),
);
m
}),
Some(v) => v.as_object().cloned(),
};
self.meta = raw_meta.filter(|m| !m.is_empty());
for full in data
.get("tags")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(tag) = full.as_str().and_then(Tag::from_full_path) {
self.tags.push(tm.add_tag(&tag.category, &tag.name));
}
}
}
/// Top-level pool folder this file sits in (its library), or `""`.
fn library_name(&self, index: &PoolIndex) -> String {
let rel = index.rel_key(&self.file_path);
let parts: Vec<&str> = rel.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() > 1 {
parts[0].to_string()
} else {
String::new()
}
}
/// Persist metadata into this file's movie record, listing the file among
/// its media exactly once with the label its name implies.
pub fn save_metadata(&mut self, index: &mut PoolIndex) -> CoreResult<()> {
let identity = self.identity_from_path();
let key = self
.movie_key
.clone()
.unwrap_or_else(|| identity.key.clone());
let mut record = index
.get_movie(&key)
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for (k, v) in self.build_record() {
record.insert(k, v);
}
record.insert("library".into(), Value::from(self.library_name(index)));
record.insert("title".into(), Value::from(identity.title));
record.insert("year".into(), json!(identity.year));
record.insert("tmdb_id".into(), json!(identity.tmdb_id));
let rel = index.rel_key(&self.file_path);
let mut media: Vec<Value> = record
.get("media")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter(|m| m.get("file").and_then(Value::as_str) != Some(rel.as_str()))
.cloned()
.collect()
})
.unwrap_or_default();
media.push(json!({"role": ROLE_VIDEO, "label": identity.label, "file": rel}));
record.insert("media".into(), Value::Array(media));
index.set_movie(&key, Value::Object(record))?;
self.movie_key = Some(key);
Ok(())
}
/// Persist a rename/move without touching shared metadata: re-read the
/// record first so a stale in-memory copy can't overwrite newer fields.
pub fn save_location(&mut self, tm: &mut TagManager, index: &mut PoolIndex) -> CoreResult<()> {
if index.find_movie_by_media(&self.file_path).is_some() {
self.get_metadata(tm, index)?;
}
self.save_metadata(index)
}
/// Drop this file from its movie record; the last one takes the record.
pub fn delete_metadata(&self, index: &mut PoolIndex) -> CoreResult<()> {
let Some((key, record)) = index.find_movie_by_media(&self.file_path) else {
return Ok(());
};
let mut record = record.clone();
let rel = index.rel_key(&self.file_path);
let remaining: Vec<Value> = record
.get("media")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter(|m| m.get("file").and_then(Value::as_str) != Some(rel.as_str()))
.cloned()
.collect()
})
.unwrap_or_default();
if remaining.is_empty() {
index.delete_movie(&key)
} else {
if let Some(obj) = record.as_object_mut() {
obj.insert("media".into(), Value::Array(remaining));
}
index.set_movie(&key, record)
}
}
/// Point this file at a new path, moving its media entry along. The physical
/// file must already have been moved by the caller.
pub fn relocate(&mut self, new_path: &Path, index: Option<&mut PoolIndex>) -> CoreResult<()> {
if let Some(index) = index
&& let Some((key, record)) = index.find_movie_by_media(&self.file_path)
{
let mut record = record.clone();
let old_rel = index.rel_key(&self.file_path);
let new_rel = index.rel_key(new_path);
if let Some(media) = record.get_mut("media").and_then(Value::as_array_mut) {
for m in media.iter_mut() {
if m.get("file").and_then(Value::as_str) == Some(old_rel.as_str()) {
m["file"] = Value::from(new_rel.clone());
}
}
}
index.set_movie(&key, record)?;
self.movie_key = Some(key);
}
self.file_path = new_path.to_path_buf();
self.filename = paths::name(new_path);
Ok(())
}
/// Epoch seconds for "date added" — the stored `added` or the file mtime
/// (not ctime: creating hardlinks bumps the source's ctime).
pub fn added_timestamp(&self) -> f64 {
if let Some(ts) = self.added.as_deref().and_then(parse_iso_local) {
return ts;
}
std::fs::metadata(&self.file_path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0.0, |d| d.as_secs_f64())
}
/// The movie's year: NFO cache → a `Rok` tag → the canonical filename.
pub fn year(&self) -> Option<String> {
let cached = self
.meta
.as_ref()
.and_then(|m| m.get("year"))
.filter(|v| !v.is_null());
if let Some(v) = cached {
return Some(v.as_str().map_or_else(|| v.to_string(), String::from));
}
if let Some(t) = self.tags.iter().find(|t| {
t.category == "Rok" && !t.name.is_empty() && t.name.chars().all(|c| c.is_ascii_digit())
}) {
return Some(t.name.clone());
}
parse_pool_stem(&paths::stem(&self.file_path)).map(|p| p.1.to_string())
}
/// The cached 0–100 rating, if any.
pub fn rating(&self) -> Option<f64> {
self.meta.as_ref()?.get("rating")?.as_f64()
}
/// Fields for a miniDLNA filename template: title / year / rating / ext /
/// stem / filename (+ user attributes, which core fields override).
pub fn name_context(&self) -> BTreeMap<String, String> {
let mut ctx = self.attributes.clone();
let stem = paths::stem(&self.file_path);
let rating = self
.meta
.as_ref()
.and_then(|m| m.get("rating"))
.filter(|v| !v.is_null())
.map(|v| v.as_str().map_or_else(|| v.to_string(), String::from))
.unwrap_or_default();
ctx.insert(
"title".into(),
self.title
.clone()
.filter(|t| !t.is_empty())
.unwrap_or_else(|| stem.clone()),
);
ctx.insert("year".into(), self.year().unwrap_or_default());
ctx.insert("rating".into(), rating);
ctx.insert("ext".into(), paths::suffix(&self.file_path));
ctx.insert("stem".into(), stem);
ctx.insert("filename".into(), self.filename.clone());
ctx
}
/// Regenerate the auto tags from an NFO / TMDb source per the tag schema;
/// user-added tags are kept. Returns the newly added tag paths.
pub fn apply_tags_from(
&mut self,
source: &dyn TagSource,
schema: &[TagSchemaEntry],
tm: &mut TagManager,
index: Option<&mut PoolIndex>,
) -> CoreResult<Vec<String>> {
let mut meta = Map::new();
meta.insert("year".into(), json!(source.year()));
meta.insert("rating".into(), json!(source.rating()));
self.meta = Some(meta);
let old_auto = std::mem::take(&mut self.auto_tag_paths);
self.tags.retain(|t| !old_auto.contains(&t.full_path()));
let mut added = Vec::new();
for entry in schema {
let Some(field) = entry.csfd_field.as_deref().filter(|f| !f.is_empty()) else {
continue;
};
for value in source.field_values(field) {
let tag = tm.add_tag(&entry.category, &value);
if !self.tags.contains(&tag) {
added.push(tag.full_path());
self.tags.push(tag.clone());
}
self.auto_tag_paths.insert(tag.full_path());
}
}
if let Some(t) = source
.title()
.filter(|_| self.title.as_deref().is_none_or(str::is_empty))
{
self.title = Some(t);
}
if let Some(index) = index {
self.save_metadata(index)?;
}
Ok(added)
}
pub fn has_tag(&self, tag: &Tag) -> bool {
self.tags.contains(tag)
}
pub fn tag_paths(&self) -> BTreeSet<String> {
self.tags.iter().map(Tag::full_path).collect()
}
}
/// Parse a Python-style ISO timestamp (naive = local time) to epoch seconds.
fn parse_iso_local(s: &str) -> Option<f64> {
let naive = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f"))
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M"))
.ok()
.or_else(|| {
NaiveDate::parse_from_str(s, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
})?;
Local
.from_local_datetime(&naive)
.earliest()
.map(|dt| dt.timestamp() as f64)
}
/// Current local time as `YYYY-MM-DDTHH:MM:SS` (what `added` stores).
pub fn now_iso() -> String {
Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()
}
/// Is a tag value considered set (Python truthiness on JSON)?
pub fn json_truthy(v: &Value) -> bool {
truthy(v)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn touch(p: &Path) {
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, "x").unwrap();
}
#[test]
fn test_identity_flat_and_grouped() {
let f = File::bare(Path::new(
"/pool/Filmy/Matrix (1999) [tmdbid-603].3D.HSBS.mkv",
));
let id = f.identity_from_path();
assert_eq!(
(id.key.as_str(), id.title.as_str(), id.label.as_str()),
("tmdb-603", "Matrix", "HSBS")
);
let g = File::bare(Path::new(
"/pool/Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995] - 4K.mkv",
));
assert_eq!(g.identity_from_path().label, "4K");
let p = File::bare(Path::new(
"/pool/Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995]-part2.mkv",
));
assert_eq!(p.identity_from_path().label, "part2");
let plain = File::bare(Path::new(
"/pool/Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995].mkv",
));
assert_eq!(plain.identity_from_path().label, "");
}
#[test]
fn test_save_and_reload_metadata_through_index() {
let dir = tempfile::tempdir().unwrap();
let video = dir.path().join("Filmy/Matrix (1999) [tmdbid-603].mkv");
touch(&video);
let mut index = PoolIndex::open(dir.path());
let mut tm = TagManager::new();
let mut f = File::load(&video, &mut tm, Some(&mut index)).unwrap();
f.title = Some("Matrix".into());
f.csfd_url = Some("https://www.csfd.cz/film/9499/".into());
f.tags.push(tm.add_tag("Žánr", "Sci-Fi"));
f.save_metadata(&mut index).unwrap();
let mut index2 = PoolIndex::open(dir.path());
let mut tm2 = TagManager::new();
let back = File::load(&video, &mut tm2, Some(&mut index2)).unwrap();
assert_eq!(
back.csfd_url.as_deref(),
Some("https://www.csfd.cz/film/9499/")
);
assert_eq!(
back.tag_paths(),
["Žánr/Sci-Fi".to_string()].into_iter().collect()
);
let rec = index2.get_movie("tmdb-603").unwrap();
assert_eq!(rec["library"], "Filmy");
assert_eq!(rec["year"], "1999");
assert_eq!(
rec["media"][0]["file"],
"Filmy/Matrix (1999) [tmdbid-603].mkv"
);
}
#[test]
fn test_two_versions_share_one_record() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("Filmy/Avatar (2009) [tmdbid-19995]");
let a = folder.join("Avatar (2009) [tmdbid-19995].mkv");
let b = folder.join("Avatar (2009) [tmdbid-19995].3D.HSBS.mkv");
touch(&a);
touch(&b);
let mut index = PoolIndex::open(dir.path());
let mut tm = TagManager::new();
let mut fa = File::load(&a, &mut tm, Some(&mut index)).unwrap();
fa.csfd_url = Some("u".into());
fa.save_metadata(&mut index).unwrap();
let fb = File::load(&b, &mut tm, Some(&mut index)).unwrap();
assert_eq!(fb.csfd_url.as_deref(), Some("u"));
assert_eq!(index.records.len(), 1);
assert_eq!(
index.get_movie("tmdb-19995").unwrap()["media"]
.as_array()
.unwrap()
.len(),
2
);
fb.delete_metadata(&mut index).unwrap();
assert_eq!(
index.get_movie("tmdb-19995").unwrap()["media"]
.as_array()
.unwrap()
.len(),
1
);
fa.delete_metadata(&mut index).unwrap();
assert!(index.records.is_empty());
}
#[test]
fn test_relocate_moves_media_entry() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("Filmy/Old (1999).mkv");
touch(&old);
let mut index = PoolIndex::open(dir.path());
let mut tm = TagManager::new();
let mut f = File::load(&old, &mut tm, Some(&mut index)).unwrap();
let new = dir.path().join("Kids/Old (1999).mkv");
f.relocate(&new, Some(&mut index)).unwrap();
assert!(index.find_movie_by_media(&new).is_some());
assert_eq!(f.filename, "Old (1999).mkv");
}
#[test]
fn test_name_context_and_year_fallbacks() {
let mut f = File::bare(Path::new("/p/Filmy/Matrix (1999).mkv"));
f.attributes.insert("collection_sort".into(), "01".into());
let ctx = f.name_context();
assert_eq!(ctx["year"], "1999");
assert_eq!(ctx["title"], "Matrix (1999)");
assert_eq!(ctx["ext"], ".mkv");
assert_eq!(ctx["collection_sort"], "01");
f.tags.push(Tag::new("Rok", "2001"));
assert_eq!(f.year().as_deref(), Some("2001"));
let mut meta = Map::new();
meta.insert("year".into(), Value::from("1998"));
meta.insert("rating".into(), Value::from(87));
f.meta = Some(meta);
assert_eq!(f.name_context()["year"], "1998");
assert_eq!(f.name_context()["rating"], "87");
}
#[test]
fn test_apply_tags_from_nfo_keeps_user_tags() {
let mut tm = TagManager::new();
let mut f = File::bare(Path::new("/p/Filmy/Matrix (1999).mkv"));
f.tags.push(tm.add_tag("Vlastní", "Oblíbené"));
f.tags.push(tm.add_tag("Žánr", "Starý"));
f.auto_tag_paths.insert("Žánr/Starý".into());
let nfo = NfoData {
title: Some("Matrix".into()),
year: Some("1999".into()),
genres: vec!["Akční".into(), "Sci-Fi".into()],
countries: vec!["USA".into()],
rating: Some(87),
..Default::default()
};
let schema = crate::core::config::default_tag_schema();
f.apply_tags_from(&nfo, &schema, &mut tm, None).unwrap();
let paths = f.tag_paths();
for p in [
"Vlastní/Oblíbené",
"Žánr/Akční",
"Žánr/Sci-Fi",
"Rok/1999",
"Země původu/USA",
"Hodnocení/87",
] {
assert!(paths.contains(p), "{p}");
}
assert!(!paths.contains("Žánr/Starý"));
assert!(!f.auto_tag_paths.contains("Vlastní/Oblíbené"));
assert_eq!(f.title.as_deref(), Some("Matrix"));
assert_eq!(f.meta.as_ref().unwrap()["rating"], 87);
}
#[test]
fn test_added_timestamp_parses_iso() {
let mut f = File::bare(Path::new("/nonexistent.mkv"));
assert_eq!(f.added_timestamp(), 0.0);
f.added = Some("2026-01-02T03:04:05".into());
assert!(f.added_timestamp() > 1.7e9);
}
}
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
//! Generating the miniDLNA / Filmotéka output from the pool.
use std::collections::HashSet;
use std::path::Path;
use super::FileManager;
use crate::core::error::{CoreError, CoreResult};
use crate::core::hardlink_manager::HardlinkManager;
use crate::core::paths;
/// Random selection, refreshed each run. The "- " prefix sorts the special
/// folders ahead of the genre folders on DLNA/TV browsers.
pub const TIPS_SUBFOLDER: &str = "- Tipy dne";
/// Most recently added movies.
pub const RECENT_SUBFOLDER: &str = "- Nově přidané";
pub const TIPS_COUNT: usize = 15;
pub const RECENT_COUNT: usize = 10;
/// Where series found inside a mixed library are hardcopied.
pub const MIXED_SERIES_SUBFOLDER: &str = "Seriály";
/// Outcome of one generation run.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GenerateReport {
pub created: usize,
pub removed: usize,
pub mirrored: usize,
pub pruned: usize,
pub tips: usize,
pub recent: usize,
pub failed: usize,
/// Number of libraries the run was scoped to (`None` = all).
pub scoped_to: Option<usize>,
}
impl FileManager {
/// Build the output tree into `out`: the tag tree (with obsolete links
/// swept), mirrored series/homevideo libraries, mixed-library series, the
/// pruning of mirrors that fell out of scope, and the special folders.
pub fn generate_minidlna(&self, out: &Path) -> CoreResult<GenerateReport> {
let scope = self.minidlna_scope();
let mut report = GenerateReport {
scoped_to: self.minidlna_libraries().map(|l| l.len()),
..Default::default()
};
if scope.movies.is_empty() && scope.mirror_libs.is_empty() && scope.mixed_series.is_empty()
{
return Err(CoreError::invalid(if report.scoped_to.is_none() {
"Pool je prázdný."
} else {
"Vybrané knihovny neobsahují nic k vygenerování — zkontroluj miniDLNA → „Knihovny pro generování…“."
}));
}
let mut manager = HardlinkManager::new(out);
// Every mirrored library stays reserved (protected from the tag-tree
// cleanup) even when this run skips mirroring it.
let mut reserved: HashSet<String> = self.mirror_as_is_libraries().into_iter().collect();
reserved
.extend([TIPS_SUBFOLDER, RECENT_SUBFOLDER, MIXED_SERIES_SUBFOLDER].map(String::from));
let (created, create_fail, removed, remove_fail) =
manager.sync_structure(&scope.movies, &self.tree_spec(), &reserved, false);
report.created = created;
report.removed = removed;
report.failed += create_fail + remove_fail;
let pool = self.pool_dir();
for name in &scope.mirror_libs {
let Some(src) = pool.as_ref().map(|p| p.join(name)) else {
continue;
};
if src.is_dir() && !paths::is_empty_dir(&src) {
let (ok, fail) = manager.mirror_as_is(&src, Some(name), false);
report.mirrored += ok;
report.failed += fail;
}
}
for ser in &scope.mixed_series {
if ser.folder.is_dir() {
let sub = format!("{MIXED_SERIES_SUBFOLDER}/{}", paths::name(&ser.folder));
let (ok, fail) = manager.mirror_as_is(&ser.folder, Some(&sub), false);
report.mirrored += ok;
report.failed += fail;
}
}
// Mirrors have no obsolete-link cleanup of their own — remove exactly
// what fell out of scope.
let (stale, valid_bucket) = self.minidlna_prune_targets(
&scope.mirror_libs,
&scope.mixed_series,
MIXED_SERIES_SUBFOLDER,
);
report.pruned = stale.iter().filter(|n| manager.remove_mirror(n)).count();
let bucket = out.join(MIXED_SERIES_SUBFOLDER);
if bucket.is_dir() {
for entry in paths::entries(&bucket) {
let name = paths::name(&entry);
if !valid_bucket.contains(&name)
&& manager.remove_mirror(&format!("{MIXED_SERIES_SUBFOLDER}/{name}"))
{
report.pruned += 1;
}
}
}
let (tips, tips_fail) =
manager.generate_random_tips(&scope.movies, TIPS_COUNT, TIPS_SUBFOLDER, false);
let (recent, recent_fail) =
manager.generate_recently_added(&scope.movies, RECENT_COUNT, RECENT_SUBFOLDER, false);
report.tips = tips;
report.recent = recent;
report.failed += tips_fail + recent_fail;
Ok(report)
}
}
#[cfg(test)]
mod tests {
use super::super::movies::ImportOptions;
use super::super::test_support::Fixture;
use super::super::{Library, LibraryKind};
use super::*;
use std::fs;
#[test]
fn test_generate_builds_tree_mirrors_and_special_folders() {
let mut fx = Fixture::new();
let out = fx.dir.path().join("out");
let p = fx
.fm
.import_movie(
&fx.source("a.mkv"),
"Matrix",
ImportOptions {
year: Some("1999".into()),
..Default::default()
},
)
.unwrap()
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&p), "Žánr/Sci-Fi")
.unwrap();
let ep = fx.pool().join("Seriály/Show/Season 01/S01E01 - Show.mkv");
fs::create_dir_all(ep.parent().unwrap()).unwrap();
fs::write(&ep, "e").unwrap();
let r = fx.fm.generate_minidlna(&out).unwrap();
assert_eq!(
(r.created, r.mirrored, r.tips, r.recent, r.failed),
(1, 1, 1, 1, 0)
);
assert!(out.join("Sci-Fi/Matrix.mkv").exists());
assert!(
out.join("Seriály/Show/Season 01/S01E01 - Show.mkv")
.exists()
);
assert!(out.join(TIPS_SUBFOLDER).join("Matrix.mkv").exists());
// deselecting the series library prunes its mirror
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Seriály", LibraryKind::Series),
Library::new("Doma", LibraryKind::HomeVideo),
])
.unwrap();
fx.fm
.set_minidlna_libraries(Some(vec!["Filmy".into(), "Doma".into()]))
.unwrap();
fs::create_dir_all(fx.pool().join("Doma")).unwrap();
fs::write(fx.pool().join("Doma/v.mp4"), "v").unwrap();
let r2 = fx.fm.generate_minidlna(&out).unwrap();
assert!(r2.pruned >= 1, "{r2:?}");
assert!(!out.join("Seriály/Show").exists());
assert!(out.join("Doma/v.mp4").exists());
assert_eq!(r2.scoped_to, Some(2));
}
#[test]
fn test_generate_errors_when_scope_is_empty() {
let mut fx = Fixture::new();
// a fresh pool still has the (empty) Seriály mirror in scope
assert_eq!(
fx.fm
.generate_minidlna(&fx.dir.path().join("out"))
.unwrap()
.created,
0
);
fx.fm
.set_minidlna_libraries(Some(vec!["Filmy".into()]))
.unwrap();
assert!(fx.fm.generate_minidlna(&fx.dir.path().join("out")).is_err());
}
}
+870
View File
@@ -0,0 +1,870 @@
//! `FileManager` — the service layer and the only thing that changes the pool.
//!
//! Everything the user can do maps onto one of its public methods. Methods that
//! change files also keep the pool index and the in-memory `filelist` in step.
//! Files are addressed by their current path.
mod minidlna;
mod movies;
mod series_ops;
mod tools;
mod versions;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::config::{
GlobalConfig, TagSchemaEntry, default_tag_schema, global_config_path, load_global_config_from,
save_global_config_to,
};
use super::error::{CoreError, CoreResult};
use super::file::File;
use super::naming::is_canonical_pool_stem;
use super::paths;
use super::pool_index::PoolIndex;
use super::series::{Series, looks_like_series_folder, scan_series};
use super::tag::Tag;
use super::tag_manager::TagManager;
pub use minidlna::{GenerateReport, MIXED_SERIES_SUBFOLDER, RECENT_SUBFOLDER, TIPS_SUBFOLDER};
pub use movies::{ImportOptions, MovieEdits, OnConflict};
pub use series_ops::{
EpisodeCode, ImportEpisodesResult, RenameAllSeriesResult, RenameEpisodesResult,
};
pub use tools::{
ConsistencyReport, DuplicateGroup, IntegrityReport, JellyfinReport, MinidlnaScope,
RefreshReport, RenameAllReport, ResolveResult, Stats,
};
pub use versions::{GroupMode, label_of, version_filename};
/// Top-level folders of a fresh pool.
pub const POOL_MOVIES: &str = "Filmy";
pub const POOL_SERIES: &str = "Seriály";
/// Curator metadata files that must never be treated as content.
pub const METADATA_SUFFIXES: &[&str] = &[".!tag", ".!ftag", ".!gtag", ".!index"];
/// Sidecars Jellyfin writes next to a movie (NFO + artwork / subtitles).
pub const SIDECAR_SUFFIXES: &[&str] = &[
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn", ".srt", ".ass", ".ssa",
".sub", ".idx", ".vtt",
];
/// True for files that are pool content (not Curator metadata / Jellyfin sidecars).
pub fn is_media_file(path: &Path) -> bool {
let name = paths::name(path);
!METADATA_SUFFIXES.iter().any(|s| name.ends_with(s))
&& !SIDECAR_SUFFIXES.contains(&paths::suffix_lower(path).as_str())
}
/// The kind of a pool library (top-level folder).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LibraryKind {
/// Single-file movies, TMDb naming.
Movie,
/// Series/season/episode tree (Jellyfin Shows).
Series,
/// Movies at the top level, series in subfolders.
Mixed,
/// Files without TMDb (home videos), mirrored as-is.
HomeVideo,
}
impl LibraryKind {
pub const ALL: [LibraryKind; 4] = [Self::Movie, Self::Series, Self::Mixed, Self::HomeVideo];
pub fn as_str(self) -> &'static str {
match self {
Self::Movie => "movie",
Self::Series => "series",
Self::Mixed => "mixed",
Self::HomeVideo => "homevideo",
}
}
/// Unknown kinds read as `movie`, like the Python version.
pub fn parse(s: &str) -> Self {
match s {
"series" => Self::Series,
"mixed" => Self::Mixed,
"homevideo" => Self::HomeVideo,
_ => Self::Movie,
}
}
/// Czech label for the library organiser.
pub fn label(self) -> &'static str {
match self {
Self::Movie => "Filmy (jednotlivé filmy)",
Self::Series => "Seriály",
Self::Mixed => "Mix (filmy + seriály)",
Self::HomeVideo => "Domácí videa (bez TMDb)",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Library {
pub name: String,
pub kind: LibraryKind,
}
impl Library {
pub fn new(name: &str, kind: LibraryKind) -> Self {
Self {
name: name.into(),
kind,
}
}
}
fn default_libraries() -> Vec<Library> {
vec![
Library::new(POOL_MOVIES, LibraryKind::Movie),
Library::new(POOL_SERIES, LibraryKind::Series),
]
}
/// Library settings that moved from the old global config into the index.
const LEGACY_LIBRARY_SETTINGS: &[&str] = &["tag_schema", "copyasis_folders", "movie_libraries"];
fn clean_libraries(specs: impl IntoIterator<Item = Library>) -> Vec<Library> {
let mut seen = HashSet::new();
specs
.into_iter()
.filter_map(|l| {
let name = l.name.trim().to_string();
(!name.is_empty() && seen.insert(name.to_lowercase()))
.then_some(Library { name, kind: l.kind })
})
.collect()
}
fn str_list(v: Option<&Value>) -> Option<Vec<String>> {
v.and_then(Value::as_array).map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
}
pub struct FileManager {
pub filelist: Vec<File>,
pub tagmanager: TagManager,
pub global_config: GlobalConfig,
/// Unified pool metadata + library settings.
pub index: Option<PoolIndex>,
config_path: PathBuf,
}
impl FileManager {
/// A manager using the per-user global config.
pub fn new() -> Self {
Self::with_config_path(global_config_path())
}
/// A manager reading/writing its global config at `config_path` (tests use
/// a temporary one). Falls back to a legacy `.Curator.!gtag` in the working
/// directory on first run.
pub fn with_config_path(config_path: PathBuf) -> Self {
let global_config = if config_path.exists() {
load_global_config_from(&config_path)
} else if config_path == global_config_path() {
super::config::load_global_config()
} else {
GlobalConfig::default()
};
Self {
filelist: Vec::new(),
tagmanager: TagManager::new(),
global_config,
index: None,
config_path,
}
}
pub fn save_global_config(&self) -> CoreResult<()> {
save_global_config_to(&self.global_config, &self.config_path)
}
// ------------------------------------------------------------------
// Pool and output folders
// ------------------------------------------------------------------
pub fn pool_dir(&self) -> Option<PathBuf> {
self.global_config
.pool_dir
.as_deref()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
pub fn filmoteka_dir(&self) -> Option<PathBuf> {
self.global_config
.filmoteka_dir
.as_deref()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
pub(crate) fn require_pool(&self) -> CoreResult<PathBuf> {
self.pool_dir()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))
}
/// Set the pool root and create every library's top-level folder.
pub fn set_pool_dir(&mut self, pool_dir: &Path) -> CoreResult<()> {
self.global_config.pool_dir = Some(pool_dir.to_string_lossy().into_owned());
self.save_global_config()?;
self.open_index(pool_dir)?;
for lib in self.libraries() {
fs::create_dir_all(pool_dir.join(&lib.name))?;
}
Ok(())
}
/// Set the miniDLNA output folder.
pub fn set_filmoteka_dir(&mut self, dir: &Path) -> CoreResult<()> {
self.global_config.filmoteka_dir = Some(dir.to_string_lossy().into_owned());
self.save_global_config()
}
// ------------------------------------------------------------------
// Pool index + library-level settings
// ------------------------------------------------------------------
/// (Re)load the pool index and migrate legacy global settings into it.
fn open_index(&mut self, pool: &Path) -> CoreResult<()> {
self.index = Some(PoolIndex::open(pool));
self.migrate_global_settings()
}
pub(crate) fn ensure_index(&mut self) -> Option<&mut PoolIndex> {
if self.index.is_none()
&& let Some(pool) = self.pool_dir()
{
let _ = self.open_index(&pool);
}
self.index.as_mut()
}
fn index_ref(&self) -> Option<&PoolIndex> {
self.index.as_ref()
}
/// Move library settings from the old global config into the index once.
fn migrate_global_settings(&mut self) -> CoreResult<()> {
let Some(index) = self.index.as_mut() else {
return Ok(());
};
let mut moved = false;
for key in LEGACY_LIBRARY_SETTINGS {
if let Some(value) = self.global_config.extra.shift_remove(*key) {
if !index.settings.contains_key(*key) {
index.set_setting(key, value)?;
}
moved = true;
}
}
if moved {
self.save_global_config()?;
}
Ok(())
}
fn setting(&self, key: &str) -> Option<&Value> {
self.index_ref()?.get_setting(key)
}
fn set_setting(&mut self, key: &str, value: Value) -> CoreResult<()> {
let index = self
.ensure_index()
.ok_or_else(|| CoreError::invalid("Pool není nastaven — není kam uložit nastavení."))?;
index.set_setting(key, value)
}
/// All pool libraries (source of truth). Old pools are synthesised from the
/// legacy `movie_libraries` + `copyasis_folders` settings.
pub fn libraries(&self) -> Vec<Library> {
let raw: Vec<Library> = match self.setting("libraries").and_then(Value::as_array) {
Some(arr) => arr
.iter()
.map(|spec| Library {
name: spec
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
kind: LibraryKind::parse(
spec.get("kind").and_then(Value::as_str).unwrap_or("movie"),
),
})
.collect(),
None => {
let movies = str_list(self.setting("movie_libraries"))
.unwrap_or_else(|| vec![POOL_MOVIES.into()]);
let series = str_list(self.setting("copyasis_folders"))
.unwrap_or_else(|| vec![POOL_SERIES.into()]);
movies
.iter()
.map(|n| Library::new(n, LibraryKind::Movie))
.chain(series.iter().map(|n| Library::new(n, LibraryKind::Series)))
.collect()
}
};
let out = clean_libraries(raw);
if out.is_empty() {
default_libraries()
} else {
out
}
}
/// Replace the library list and persist it.
pub fn set_libraries(&mut self, specs: Vec<Library>) -> CoreResult<()> {
let mut cleaned = clean_libraries(specs);
if cleaned.is_empty() {
cleaned = default_libraries();
}
let value = Value::Array(
cleaned
.iter()
.map(|l| json!({"name": l.name, "kind": l.kind.as_str()}))
.collect(),
);
self.set_setting("libraries", value)
}
pub fn libraries_of_kind(&self, kinds: &[LibraryKind]) -> Vec<String> {
self.libraries()
.into_iter()
.filter(|l| kinds.contains(&l.kind))
.map(|l| l.name)
.collect()
}
pub fn library_kind(&self, name: &str) -> Option<LibraryKind> {
self.libraries()
.into_iter()
.find(|l| l.name == name)
.map(|l| l.kind)
}
/// Libraries holding single-file movies (never empty).
pub fn movie_libraries(&self) -> Vec<String> {
let libs = self.libraries_of_kind(&[LibraryKind::Movie]);
if libs.is_empty() {
vec![POOL_MOVIES.into()]
} else {
libs
}
}
/// Libraries a movie can live in — kind movie or mixed (never empty).
pub fn libraries_accepting_movies(&self) -> Vec<String> {
let libs = self.libraries_of_kind(&[LibraryKind::Movie, LibraryKind::Mixed]);
if libs.is_empty() {
vec![POOL_MOVIES.into()]
} else {
libs
}
}
/// Libraries that can hold series — kind series or mixed.
pub fn series_libraries(&self) -> Vec<String> {
self.libraries_of_kind(&[LibraryKind::Series, LibraryKind::Mixed])
}
/// The series (copy-as-is) libraries.
pub fn copyasis_folders(&self) -> Vec<String> {
self.libraries_of_kind(&[LibraryKind::Series])
}
/// Libraries hardcopied 1:1 into miniDLNA — series and homevideo.
pub fn mirror_as_is_libraries(&self) -> Vec<String> {
self.libraries_of_kind(&[LibraryKind::Series, LibraryKind::HomeVideo])
}
/// Libraries included in miniDLNA generation, `None` = all. Unknown names
/// are dropped on read.
pub fn minidlna_libraries(&self) -> Option<Vec<String>> {
let raw = str_list(self.setting("minidlna_libraries"))?;
let known: HashSet<String> = self.libraries().into_iter().map(|l| l.name).collect();
Some(raw.into_iter().filter(|n| known.contains(n)).collect())
}
/// Restrict miniDLNA generation to `names`; `None` resets to "all".
pub fn set_minidlna_libraries(&mut self, names: Option<Vec<String>>) -> CoreResult<()> {
let value = match names {
None => Value::Null,
Some(list) => {
let mut seen = HashSet::new();
Value::Array(
list.into_iter()
.filter(|n| seen.insert(n.clone()))
.map(Value::from)
.collect(),
)
}
};
if value.is_null() {
let index = self.ensure_index().ok_or_else(|| {
CoreError::invalid("Pool není nastaven — není kam uložit nastavení.")
})?;
index
.settings
.insert("minidlna_libraries".into(), Value::Null);
return index.save();
}
self.set_setting("minidlna_libraries", value)
}
/// Top-level pool folders that aren't a configured library: `(name, is_empty)`.
pub fn orphan_pool_folders(&self) -> Vec<(String, bool)> {
let Some(pool) = self.pool_dir().filter(|p| p.is_dir()) else {
return Vec::new();
};
let known: HashSet<String> = self.libraries().into_iter().map(|l| l.name).collect();
paths::subdirs(&pool)
.into_iter()
.filter(|d| !known.contains(&paths::name(d)))
.map(|d| (paths::name(&d), paths::is_empty_dir(&d)))
.collect()
}
/// Delete an **empty** orphan folder in the pool root.
pub fn remove_empty_pool_folder(&self, name: &str) -> bool {
let Some(pool) = self.pool_dir() else {
return false;
};
let known = self.libraries().iter().any(|l| l.name == name);
let target = pool.join(name);
!known && paths::is_empty_dir(&target) && fs::remove_dir(&target).is_ok()
}
/// Which library (top-level pool folder) a pooled path lives in.
pub fn library_of(&self, path: &Path) -> Option<String> {
let pool = self.pool_dir()?;
let rel = path.strip_prefix(&pool).ok()?;
rel.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
}
pub fn tag_schema(&self) -> Vec<TagSchemaEntry> {
self.setting("tag_schema")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(default_tag_schema)
}
pub fn set_tag_schema(&mut self, schema: &[TagSchemaEntry]) -> CoreResult<()> {
self.set_setting("tag_schema", serde_json::to_value(schema)?)
}
/// The miniDLNA tree spec derived from the tag schema.
pub fn tree_spec(&self) -> super::hardlink_manager::TreeSpec {
let schema = self.tag_schema();
let mut spec = super::hardlink_manager::TreeSpec {
roots: Some(
schema
.iter()
.filter_map(|e| Some((e.category.clone(), e.filmoteka_root.clone()?)))
.collect(),
),
..Default::default()
};
for e in schema.iter().filter(|e| e.filmoteka_root.is_some()) {
if let Some(t) = e.transform.clone().filter(|t| !t.is_empty()) {
spec.transforms.insert(e.category.clone(), t);
}
if let Some(t) = e.filename_template.clone().filter(|t| !t.is_empty()) {
spec.templates.insert(e.category.clone(), t);
}
}
spec
}
// ------------------------------------------------------------------
// Loading
// ------------------------------------------------------------------
/// Reload the movie list from every movie/mixed library via the index.
pub fn load_pool_movies(&mut self) -> CoreResult<()> {
self.filelist.clear();
self.tagmanager.clear();
let Some(pool) = self.pool_dir() else {
return Ok(());
};
self.open_index(&pool)?;
let mut candidates = Vec::new();
for lib in self.libraries() {
let lib_dir = pool.join(&lib.name);
if !lib_dir.is_dir() {
continue;
}
match lib.kind {
LibraryKind::Movie => candidates.extend(paths::walk_files(&lib_dir)),
LibraryKind::Mixed => {
// A mixed library keeps movies at the top level and series in
// subfolders; a movie's version folder has the same shape as a
// series folder but never contains a Season folder.
candidates.extend(paths::entries(&lib_dir).into_iter().filter(|p| p.is_file()));
for sub in paths::subdirs(&lib_dir) {
if is_canonical_pool_stem(&paths::name(&sub))
&& !looks_like_series_folder(&sub)
{
candidates
.extend(paths::entries(&sub).into_iter().filter(|p| p.is_file()));
}
}
}
_ => {}
}
}
let index = self
.index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
for path in candidates.into_iter().filter(|p| is_media_file(p)) {
self.filelist
.push(File::load(&path, &mut self.tagmanager, Some(index))?);
}
Ok(())
}
/// Scan every series library.
pub fn load_series(&self) -> Vec<Series> {
let Some(pool) = self.pool_dir() else {
return Vec::new();
};
self.libraries()
.into_iter()
.filter(|l| matches!(l.kind, LibraryKind::Series | LibraryKind::Mixed))
.flat_map(|l| scan_series(&pool.join(&l.name), &l.name, l.kind == LibraryKind::Mixed))
.collect()
}
// ------------------------------------------------------------------
// File lookup helpers
// ------------------------------------------------------------------
pub fn position(&self, path: &Path) -> Option<usize> {
self.filelist.iter().position(|f| f.file_path == path)
}
pub fn file(&self, path: &Path) -> Option<&File> {
self.filelist.iter().find(|f| f.file_path == path)
}
/// Run `op` on the loaded file at `path`, or on a freshly loaded transient
/// one when it isn't in `filelist`.
pub(crate) fn with_file<R>(
&mut self,
path: &Path,
op: impl FnOnce(&mut File, &mut TagManager, &mut PoolIndex) -> CoreResult<R>,
) -> CoreResult<R> {
if self.ensure_index().is_none() {
return Err(CoreError::invalid("Pool není nastaven."));
}
let Self {
filelist,
tagmanager,
index,
..
} = self;
let index = index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
match filelist.iter_mut().find(|f| f.file_path == path) {
Some(f) => op(f, tagmanager, index),
None => {
let mut f = File::load(path, tagmanager, Some(index))?;
op(&mut f, tagmanager, index)
}
}
}
/// Set (or clear with `None`) the manual date of files.
pub fn set_date(&mut self, files: &[PathBuf], date: Option<&str>) -> CoreResult<()> {
let date = date
.map(str::trim)
.filter(|d| !d.is_empty())
.map(String::from);
for path in files {
let d = date.clone();
self.with_file(path, |f, _, index| {
f.date = d;
f.save_metadata(index)
})?;
}
Ok(())
}
/// Persist a loaded file's metadata.
pub fn save_file(&mut self, path: &Path) -> CoreResult<()> {
self.with_file(path, |f, _, index| f.save_metadata(index))
}
// ------------------------------------------------------------------
// Tags
// ------------------------------------------------------------------
/// Assign a `Category/Name` tag (no category → "default") to files.
pub fn assign_tag(&mut self, files: &[PathBuf], tag: &str) -> CoreResult<()> {
let tag = Tag::from_full_path(tag).unwrap_or_else(|| Tag::new("default", tag));
for path in files {
self.with_file(path, |f, tm, index| {
let t = tm.add_tag(&tag.category, &tag.name);
if !f.tags.contains(&t) {
f.tags.push(t);
f.save_metadata(index)?;
}
Ok(())
})?;
}
Ok(())
}
/// Remove a tag from files.
pub fn remove_tag(&mut self, files: &[PathBuf], tag: &str) -> CoreResult<()> {
let tag = Tag::from_full_path(tag).unwrap_or_else(|| Tag::new("default", tag));
for path in files {
self.with_file(path, |f, _, index| {
if let Some(i) = f.tags.iter().position(|t| *t == tag) {
f.tags.remove(i);
f.save_metadata(index)?;
}
Ok(())
})?;
}
Ok(())
}
/// Files carrying **all** of `tags` (full paths); empty = all files.
pub fn filter_files_by_tags(&self, tags: &HashSet<String>) -> Vec<&File> {
self.filelist
.iter()
.filter(|f| {
tags.iter()
.all(|t| f.tags.iter().any(|ft| &ft.full_path() == t))
})
.collect()
}
}
impl Default for FileManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
/// A manager with a temporary config and pool (`<tmp>/pool`).
pub struct Fixture {
pub dir: tempfile::TempDir,
pub fm: FileManager,
}
impl Fixture {
pub fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let mut fm = FileManager::with_config_path(dir.path().join("cfg.json"));
fm.set_pool_dir(&dir.path().join("pool")).unwrap();
Self { dir, fm }
}
pub fn pool(&self) -> PathBuf {
self.dir.path().join("pool")
}
/// A source file outside the pool.
pub fn source(&self, name: &str) -> PathBuf {
let p = self.dir.path().join("src").join(name);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, name.as_bytes()).unwrap();
p
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::Fixture;
use super::*;
#[test]
fn test_set_pool_creates_top_level_folders() {
let fx = Fixture::new();
assert!(fx.pool().join("Filmy").is_dir());
assert!(fx.pool().join("Seriály").is_dir());
}
#[test]
fn test_libraries_default_and_set_all_kinds() {
let mut fx = Fixture::new();
assert_eq!(fx.fm.libraries(), default_libraries());
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Kids", LibraryKind::Mixed),
Library::new("Domácí", LibraryKind::HomeVideo),
Library::new("filmy", LibraryKind::Series),
Library::new(" ", LibraryKind::Movie),
])
.unwrap();
let names: Vec<String> = fx.fm.libraries().into_iter().map(|l| l.name).collect();
assert_eq!(names, vec!["Filmy", "Kids", "Domácí"]);
assert_eq!(fx.fm.libraries_accepting_movies(), vec!["Filmy", "Kids"]);
assert_eq!(fx.fm.mirror_as_is_libraries(), vec!["Domácí"]);
assert!(fx.fm.copyasis_folders().is_empty());
}
#[test]
fn test_library_settings_stored_in_index_not_global() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![Library::new("A", LibraryKind::Movie)])
.unwrap();
let raw = fs::read_to_string(fx.pool().join(".Curator.!index")).unwrap();
assert!(raw.contains("\"libraries\""));
let cfg = fs::read_to_string(fx.dir.path().join("cfg.json")).unwrap();
assert!(!cfg.contains("libraries"));
}
#[test]
fn test_migrates_legacy_global_settings_into_index() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("cfg.json");
let pool = dir.path().join("pool");
fs::write(
&cfg,
json!({"pool_dir": pool, "copyasis_folders": ["Seriály", "Dokumenty"]}).to_string(),
)
.unwrap();
let mut fm = FileManager::with_config_path(cfg.clone());
fm.load_pool_movies().unwrap();
assert_eq!(fm.copyasis_folders(), vec!["Seriály", "Dokumenty"]);
assert!(
!fs::read_to_string(&cfg)
.unwrap()
.contains("copyasis_folders")
);
}
#[test]
fn test_minidlna_libraries_default_set_and_unknown() {
let mut fx = Fixture::new();
assert_eq!(fx.fm.minidlna_libraries(), None);
fx.fm
.set_minidlna_libraries(Some(vec!["Filmy".into(), "Gone".into(), "Filmy".into()]))
.unwrap();
assert_eq!(fx.fm.minidlna_libraries(), Some(vec!["Filmy".to_string()]));
fx.fm.set_minidlna_libraries(None).unwrap();
assert_eq!(fx.fm.minidlna_libraries(), None);
}
#[test]
fn test_orphan_folder_cleanup() {
let fx = Fixture::new();
fs::create_dir_all(fx.pool().join("Empty")).unwrap();
fs::create_dir_all(fx.pool().join("Full")).unwrap();
fs::write(fx.pool().join("Full/x.mkv"), "").unwrap();
assert_eq!(
fx.fm.orphan_pool_folders(),
vec![("Empty".to_string(), true), ("Full".to_string(), false)]
);
assert!(fx.fm.remove_empty_pool_folder("Empty"));
assert!(!fx.fm.remove_empty_pool_folder("Full"));
assert!(!fx.fm.remove_empty_pool_folder("Filmy"));
}
#[test]
fn test_tree_spec_from_schema() {
let fx = Fixture::new();
let spec = fx.fm.tree_spec();
let roots = spec.roots.unwrap();
assert_eq!(roots["Žánr"], "");
assert_eq!(roots["Rok"], "- Dle roku");
assert_eq!(spec.transforms["Hodnocení"], "decade_band");
}
#[test]
fn test_tag_assign_remove_and_filter() {
let mut fx = Fixture::new();
let a = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", Default::default())
.unwrap()
.unwrap();
let b = fx
.fm
.import_movie(&fx.source("b.mkv"), "B", Default::default())
.unwrap()
.unwrap();
fx.fm
.assign_tag(&[a.clone(), b.clone()], "Žánr/Drama")
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&a), "Rok/1999")
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&a), "Rok/1999")
.unwrap();
assert_eq!(fx.fm.file(&a).unwrap().tags.len(), 2);
let want: HashSet<String> = ["Žánr/Drama".to_string(), "Rok/1999".to_string()]
.into_iter()
.collect();
assert_eq!(fx.fm.filter_files_by_tags(&want).len(), 1);
assert_eq!(fx.fm.filter_files_by_tags(&HashSet::new()).len(), 2);
fx.fm
.remove_tag(std::slice::from_ref(&a), "Žánr/Drama")
.unwrap();
assert_eq!(fx.fm.file(&a).unwrap().tags.len(), 1);
fx.fm
.assign_tag(std::slice::from_ref(&b), "bezkategorie")
.unwrap();
assert!(
fx.fm
.file(&b)
.unwrap()
.tags
.contains(&Tag::new("default", "bezkategorie"))
);
}
#[test]
fn test_load_scans_movie_and_mixed_libraries_and_skips_sidecars() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Mix", LibraryKind::Mixed),
Library::new("Seriály", LibraryKind::Series),
])
.unwrap();
let pool = fx.pool();
for rel in [
"Filmy/A (2000).mkv",
"Filmy/A (2000).nfo",
"Filmy/A (2000).cs.srt",
"Filmy/sub/B (2001).mkv",
"Mix/C (2002).mkv",
"Mix/Show (2010) [tmdbid-1]/Season 01/S01E01 - Show.mkv",
"Seriály/X/Season 01/S01E01 - X.mkv",
] {
fs::create_dir_all(pool.join(rel).parent().unwrap()).unwrap();
fs::write(pool.join(rel), "x").unwrap();
}
fx.fm.load_pool_movies().unwrap();
let names: Vec<String> = fx.fm.filelist.iter().map(|f| f.filename.clone()).collect();
assert_eq!(names, vec!["A (2000).mkv", "B (2001).mkv", "C (2002).mkv"]);
let series: Vec<String> = fx.fm.load_series().into_iter().map(|s| s.title).collect();
assert_eq!(series, vec!["Show", "X"]);
}
}
+786
View File
@@ -0,0 +1,786 @@
//! Importing, renaming, moving and editing single movies.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use super::FileManager;
use crate::core::error::{CoreError, CoreResult};
use crate::core::file::{File, now_iso};
use crate::core::naming::{
canonical_pool_stem, is_valid_year, parse_3d_format, parse_pool_stem, parse_tmdb_id,
};
use crate::core::paths;
use crate::core::subtitles;
use crate::core::tag::Tag;
static TRAILING_3D_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\.3D\.(?:HSBS|FSBS|HTAB|FTAB|MVC)$").expect("valid regex"));
/// What to do when a movie of the same name is already pooled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OnConflict {
/// Keep both; the new file gets a `_N` suffix.
#[default]
Suffix,
/// Evict the existing movie (all its versions) first.
Replace,
/// Don't import.
Skip,
/// Add the file as another version of the pooled film.
Version,
}
/// Optional parameters of [`FileManager::import_movie`].
#[derive(Debug, Clone, Default)]
pub struct ImportOptions {
/// Move the source instead of copying it.
pub move_source: bool,
pub on_conflict: OnConflict,
pub year: Option<String>,
pub tmdb_id: Option<String>,
pub library: Option<String>,
pub csfd_url: Option<String>,
pub format_3d: Option<String>,
/// Popisek when `on_conflict == Version` (else derived from `format_3d`).
pub version_label: Option<String>,
}
/// Every editable parameter of a movie at once ([`FileManager::apply_movie_edits`]).
/// `None` fields are left unchanged.
#[derive(Debug, Clone, Default)]
pub struct MovieEdits {
pub title: String,
pub year: Option<String>,
pub tmdb_id: Option<String>,
pub library: Option<String>,
pub date: Option<String>,
pub attributes: Option<std::collections::BTreeMap<String, String>>,
/// Full `["Category/Name", …]` list.
pub tags: Option<Vec<String>>,
pub csfd_url: Option<String>,
/// `None` keeps the current 3D tag, `Some("")` clears it.
pub format_3d: Option<String>,
}
impl FileManager {
/// The movie-identity stem a pooled path belongs to: a version's folder
/// name, or a flat file's stem minus any `.3D.<FORMAT>` tag.
pub(crate) fn identity_stem(&self, path: &Path) -> String {
if self.is_movie_group_member(path) {
return path.parent().map(paths::name).unwrap_or_default();
}
TRAILING_3D_RE.replace(&paths::stem(path), "").to_string()
}
/// Pooled files of the movie identity `title` (case-insensitive).
pub fn pooled_with_stem(&self, title: &str) -> Vec<PathBuf> {
let key = title.trim().to_lowercase();
self.filelist
.iter()
.filter(|f| self.identity_stem(&f.file_path).to_lowercase() == key)
.map(|f| f.file_path.clone())
.collect()
}
/// Bring a video into a movie library and index it. Returns the pooled path,
/// or `None` when skipped.
///
/// Named `Title (YYYY) [tmdbid-N].ext` when a year is given (else
/// `Title.ext`); `format_3d` adds `.3D.<FORMAT>`. The source is copied unless
/// `move_source` is set.
pub fn import_movie(
&mut self,
source: &Path,
title: &str,
opts: ImportOptions,
) -> CoreResult<Option<PathBuf>> {
let pool = self.require_pool()?;
let lib = opts
.library
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.unwrap_or_else(|| self.movie_libraries()[0].clone());
let movies = pool.join(&lib);
fs::create_dir_all(&movies)?;
self.ensure_index();
let safe_title = if title.trim().is_empty() {
paths::stem(source)
} else {
title.trim().to_string()
};
let year = opts
.year
.as_deref()
.map(str::trim)
.filter(|y| !y.is_empty());
let fmt = opts.format_3d.as_deref().filter(|f| !f.is_empty());
let (identity, stem) = match year {
Some(y) => (
canonical_pool_stem(&safe_title, y, opts.tmdb_id.as_deref(), None)?,
canonical_pool_stem(&safe_title, y, opts.tmdb_id.as_deref(), fmt)?,
),
None => (safe_title.clone(), safe_title.clone()),
};
let ext = paths::suffix(source);
let mut target = movies.join(format!("{stem}{ext}"));
let existing = self.pooled_with_stem(&identity);
if !existing.is_empty() && opts.on_conflict == OnConflict::Version {
let label = opts.version_label.clone().unwrap_or_else(|| {
fmt.map(|f| format!("3D {}", f.to_uppercase()))
.unwrap_or_default()
});
return self
.add_movie_version(&existing[0], source, &label, opts.move_source, None)
.map(Some);
}
let conflict = !existing.is_empty() || target.exists() || movies.join(&identity).is_dir();
if conflict && opts.on_conflict == OnConflict::Skip {
return Ok(None);
}
if conflict && opts.on_conflict == OnConflict::Replace {
for path in existing {
if path.exists() {
self.remove_movie(&path)?;
}
}
if target.exists() {
fs::remove_file(&target)?;
}
} else {
let mut candidate = stem.clone();
let mut counter = 1;
while movies.join(format!("{candidate}{ext}")).exists()
|| movies.join(&candidate).is_dir()
{
candidate = format!("{stem}_{counter}");
counter += 1;
}
target = movies.join(format!("{candidate}{ext}"));
}
paths::transfer(source, &target, opts.move_source)?;
let csfd = opts.csfd_url.filter(|u| !u.is_empty());
let Self {
tagmanager,
index,
filelist,
..
} = self;
let index = index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
let mut file = File::load(&target, tagmanager, Some(index))?;
file.title = Some(safe_title);
file.csfd_url = csfd;
file.added = Some(now_iso());
file.save_metadata(index)?;
filelist.push(file);
Ok(Some(target))
}
/// Move a file on disk, carry its subtitles along and repoint its record.
pub(crate) fn relocate_file(
&mut self,
old: &Path,
new: &Path,
rename_only: bool,
) -> CoreResult<()> {
if rename_only {
fs::rename(old, new)?;
} else {
if let Some(parent) = new.parent() {
fs::create_dir_all(parent)?;
}
paths::move_file(old, new)?;
}
subtitles::rename_subtitles_with(old, new)?;
self.with_file(old, |f, _, index| f.relocate(new, Some(index)))?;
Ok(())
}
/// Rename a pooled movie's file to `<new_title>.<ext>`.
pub fn rename_movie(&mut self, path: &Path, new_title: &str) -> CoreResult<PathBuf> {
let new_title = new_title.trim();
if new_title.is_empty() {
return Err(CoreError::invalid("Název nesmí být prázdný."));
}
if new_title.contains(['/', '\\']) {
return Err(CoreError::invalid("Název nesmí obsahovat lomítka."));
}
if self.is_movie_group_member(path) {
return Err(CoreError::invalid(
"Film s více verzemi zatím nelze takhle přejmenovat — odeber verzi nebo uprav soubory ve složce filmu ručně.",
));
}
let new_path = path.with_file_name(format!("{new_title}{}", paths::suffix(path)));
if new_path == path {
return Ok(new_path);
}
if new_path.exists() {
return Err(CoreError::Exists(format!(
"Soubor „{}“ už v poolu existuje.",
paths::name(&new_path)
)));
}
self.relocate_file(path, &new_path, true)?;
let title = new_title.to_string();
self.with_file(&new_path, |f, _, index| {
f.title = Some(title);
f.save_metadata(index)
})?;
Ok(new_path)
}
/// The `Title (YYYY) [tmdbid-N]` stem a movie should have, or `None` when no
/// four-digit year is known. An existing tmdbid / 3D tag is kept
/// (`include_3d = false` drops the 3D tag — used for a version folder).
pub(crate) fn canonical_name_for(&self, file: &File, include_3d: bool) -> Option<String> {
let ctx = file.name_context();
let stem = paths::stem(&file.file_path);
let mut title = file
.title
.clone()
.filter(|t| !t.is_empty())
.or_else(|| ctx.get("title").cloned().filter(|t| !t.is_empty()))
.unwrap_or_else(|| stem.clone())
.trim()
.to_string();
let mut year = ctx
.get("year")
.cloned()
.unwrap_or_default()
.trim()
.to_string();
if let Some((t, y)) = parse_pool_stem(&title) {
title = t;
if year.is_empty() {
year = y.to_string();
}
}
let tmdb_id = parse_tmdb_id(&stem).map(|i| i.to_string());
let format_3d = if include_3d {
parse_3d_format(&stem)
} else {
None
};
let title = title.replace(['/', '\\'], "-").trim().to_string();
if title.is_empty() || !is_valid_year(&year) {
return None;
}
canonical_pool_stem(&title, &year, tmdb_id.as_deref(), format_3d.as_deref()).ok()
}
/// Rename a pooled movie to `Title (YYYY).ext` from its metadata. `Ok(None)`
/// when no year is known or the movie has several versions.
pub fn rename_to_canonical(&mut self, path: &Path) -> CoreResult<Option<PathBuf>> {
if self.is_movie_group_member(path) {
return Ok(None);
}
let file = match self.file(path) {
Some(f) => f.clone(),
None => return Ok(None),
};
let Some(new_stem) = self.canonical_name_for(&file, true) else {
return Ok(None);
};
let new_path = path.with_file_name(format!("{new_stem}{}", paths::suffix(path)));
if new_path == path {
return Ok(Some(new_path));
}
if new_path.exists() {
return Err(CoreError::Exists(format!(
"Soubor „{}“ už v poolu existuje.",
paths::name(&new_path)
)));
}
self.relocate_file(path, &new_path, true)?;
let parsed = parse_pool_stem(&new_stem);
self.with_file(&new_path, |f, _, index| {
if let Some((t, _)) = parsed {
f.title = Some(t);
}
f.save_metadata(index)
})?;
Ok(Some(new_path))
}
/// Move a pooled movie into another library.
pub fn move_to_library(&mut self, path: &Path, new_library: &str) -> CoreResult<PathBuf> {
let pool = self.require_pool()?;
let new_library = new_library.trim();
if !self
.libraries_accepting_movies()
.iter()
.any(|l| l == new_library)
{
return Err(CoreError::invalid(format!(
"Neznámá knihovna „{new_library}“."
)));
}
if self.is_movie_group_member(path) {
return Err(CoreError::invalid(
"Film s více verzemi zatím nelze takhle přesunout — odeber verzi, nebo přesuň celou složku filmu ručně.",
));
}
let new_path = pool.join(new_library).join(paths::name(path));
if new_path == path {
return Ok(new_path);
}
if new_path.exists() {
return Err(CoreError::Exists(format!(
"V knihovně „{new_library}“ už „{}“ je.",
paths::name(path)
)));
}
self.relocate_file(path, &new_path, false)?;
self.save_file(&new_path)?;
Ok(new_path)
}
/// Apply every editable parameter of a movie at once (rename/move + metadata).
pub fn apply_movie_edits(&mut self, path: &Path, edits: MovieEdits) -> CoreResult<PathBuf> {
let pool = self.require_pool()?;
let title = if edits.title.trim().is_empty() {
paths::stem(path)
} else {
edits.title.trim().to_string()
};
let year = edits
.year
.as_deref()
.map(str::trim)
.unwrap_or("")
.to_string();
let grouped = self.is_movie_group_member(path);
let format_3d = match edits.format_3d {
Some(f) => Some(f),
None if grouped => None,
None => parse_3d_format(&paths::stem(path)),
};
let stem = if is_valid_year(&year) {
canonical_pool_stem(
&title,
&year,
edits.tmdb_id.as_deref(),
format_3d.as_deref(),
)?
} else {
title.clone()
};
let current_lib = self.library_of(path);
let lib = edits
.library
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.or_else(|| current_lib.clone())
.unwrap_or_else(|| self.movie_libraries()[0].clone());
let mut new_path = path.to_path_buf();
if grouped {
let folder = path.parent().map(paths::name).unwrap_or_default();
if stem != folder || Some(&lib) != current_lib.as_ref() {
return Err(CoreError::invalid(
"Film s více verzemi zatím nelze takhle přejmenovat/přesunout (rok/název/TMDb ID/knihovna) — odeber verzi, nebo uprav soubory ve složce filmu ručně. Datum/atributy/tagy/ČSFD odkaz upravit lze.",
));
}
} else {
let target = pool
.join(&lib)
.join(format!("{stem}{}", paths::suffix(path)));
if target != path {
if target.exists() {
return Err(CoreError::Exists(format!(
"Soubor „{}“ už existuje.",
paths::name(&target)
)));
}
self.relocate_file(path, &target, false)?;
new_path = target;
}
}
self.with_file(&new_path, |f, tm, index| {
f.title = Some(title);
if let Some(d) = edits.date {
f.date = Some(d).filter(|d| !d.is_empty());
}
if let Some(u) = edits.csfd_url {
f.csfd_url = Some(u).filter(|u| !u.is_empty());
}
if let Some(a) = edits.attributes {
f.attributes = a;
}
if let Some(tags) = edits.tags {
let mut seen = std::collections::HashSet::new();
let mut new_tags = Vec::new();
for full in tags {
let full = full.trim().to_string();
let Some(tag) = Tag::from_full_path(&full) else {
continue;
};
if !seen.insert(full) {
continue;
}
new_tags.push(tm.add_tag(tag.category.trim(), tag.name.trim()));
}
f.tags = new_tags;
f.auto_tag_paths.retain(|p| seen.contains(p));
}
f.save_metadata(index)
})?;
Ok(new_path)
}
/// Attach a TMDb id to a movie and rename it to the canonical convention;
/// `year` (from the TMDb match) fills a missing local year.
pub fn assign_tmdb_id(
&mut self,
path: &Path,
tmdb_id: i64,
year: Option<&str>,
) -> CoreResult<PathBuf> {
let file = self.file(path).cloned().unwrap_or_else(|| File::bare(path));
let ctx = file.name_context();
let mut title = file
.title
.clone()
.filter(|t| !t.is_empty())
.or_else(|| ctx.get("title").cloned())
.unwrap_or_else(|| paths::stem(path))
.trim()
.to_string();
if let Some((t, _)) = parse_pool_stem(&title) {
title = t;
}
let year = year
.filter(|y| !y.is_empty())
.map(String::from)
.or_else(|| ctx.get("year").cloned())
.unwrap_or_default();
self.apply_movie_edits(
path,
MovieEdits {
title,
year: Some(year).filter(|y| !y.is_empty()),
tmdb_id: Some(tmdb_id.to_string()),
..Default::default()
},
)
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::Fixture;
use super::*;
use crate::core::file_manager::{Library, LibraryKind};
fn opts_year(year: &str, id: Option<&str>) -> ImportOptions {
ImportOptions {
year: Some(year.into()),
tmdb_id: id.map(String::from),
..Default::default()
}
}
#[test]
fn test_import_movie_copies_and_indexes() {
let mut fx = Fixture::new();
let src = fx.source("raw.mkv");
let p = fx
.fm
.import_movie(&src, "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
assert_eq!(p, fx.pool().join("Filmy/Matrix.mkv"));
assert!(src.exists());
let f = fx.fm.file(&p).unwrap();
assert_eq!(f.title.as_deref(), Some("Matrix"));
assert!(f.added.is_some());
assert!(
fx.fm
.index
.as_ref()
.unwrap()
.find_movie_by_media(&p)
.is_some()
);
}
#[test]
fn test_import_movie_move_and_naming_convention() {
let mut fx = Fixture::new();
let src = fx.source("raw.mkv");
let opts = ImportOptions {
move_source: true,
format_3d: Some("hsbs".into()),
..opts_year("1999", Some("603"))
};
let p = fx.fm.import_movie(&src, "Matrix", opts).unwrap().unwrap();
assert!(!src.exists());
assert_eq!(paths::name(&p), "Matrix (1999) [tmdbid-603].3D.HSBS.mkv");
assert!(
fx.fm
.import_movie(&fx.source("x.mkv"), "Matrix", opts_year("99", None))
.is_err()
);
}
#[test]
fn test_import_conflicts_suffix_replace_skip() {
let mut fx = Fixture::new();
fx.fm
.import_movie(&fx.source("a.mkv"), "Matrix", ImportOptions::default())
.unwrap();
let p2 = fx
.fm
.import_movie(&fx.source("b.mkv"), "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
assert_eq!(paths::name(&p2), "Matrix_1.mkv");
let skip = ImportOptions {
on_conflict: OnConflict::Skip,
..Default::default()
};
assert_eq!(
fx.fm
.import_movie(&fx.source("c.mkv"), "Matrix", skip)
.unwrap(),
None
);
let mut fx2 = Fixture::new();
fx2.fm
.import_movie(&fx2.source("a.mkv"), "Matrix", ImportOptions::default())
.unwrap();
let replace = ImportOptions {
on_conflict: OnConflict::Replace,
..Default::default()
};
fx2.fm
.import_movie(&fx2.source("b.mp4"), "Matrix", replace)
.unwrap();
let names: Vec<String> = fx2.fm.filelist.iter().map(|f| f.filename.clone()).collect();
assert_eq!(names, vec!["Matrix.mp4"]);
assert!(!fx2.pool().join("Filmy/Matrix.mkv").exists());
}
#[test]
fn test_same_title_different_year_coexist() {
let mut fx = Fixture::new();
fx.fm
.import_movie(&fx.source("a.mkv"), "Matrix", opts_year("1999", None))
.unwrap();
let p = fx
.fm
.import_movie(&fx.source("b.mkv"), "Matrix", opts_year("2021", None))
.unwrap()
.unwrap();
assert_eq!(paths::name(&p), "Matrix (2021).mkv");
}
#[test]
fn test_rename_movie_and_rejections() {
let mut fx = Fixture::new();
let p = fx
.fm
.import_movie(&fx.source("a.mkv"), "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
fx.fm
.import_movie(&fx.source("b.mkv"), "Other", ImportOptions::default())
.unwrap();
fs::write(fx.pool().join("Filmy/Matrix.cs.srt"), "s").unwrap();
let new = fx.fm.rename_movie(&p, "Matrix Reloaded").unwrap();
assert_eq!(paths::name(&new), "Matrix Reloaded.mkv");
assert!(fx.pool().join("Filmy/Matrix Reloaded.cs.srt").exists());
assert_eq!(
fx.fm.file(&new).unwrap().title.as_deref(),
Some("Matrix Reloaded")
);
assert!(
fx.fm
.index
.as_ref()
.unwrap()
.find_movie_by_media(&new)
.is_some()
);
assert!(fx.fm.rename_movie(&new, "Other").is_err());
assert!(fx.fm.rename_movie(&new, " ").is_err());
assert!(fx.fm.rename_movie(&new, "a/b").is_err());
}
#[test]
fn test_rename_to_canonical_preserves_id_and_3d() {
let mut fx = Fixture::new();
let src = fx.source("raw.mkv");
let p = fx
.fm
.import_movie(&src, "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
// no year known → skipped
assert_eq!(fx.fm.rename_to_canonical(&p).unwrap(), None);
fx.fm
.assign_tag(std::slice::from_ref(&p), "Rok/1999")
.unwrap();
let new = fx.fm.rename_to_canonical(&p).unwrap().unwrap();
assert_eq!(paths::name(&new), "Matrix (1999).mkv");
let mut fx2 = Fixture::new();
let opts = ImportOptions {
format_3d: Some("MVC".into()),
..opts_year("1999", Some("603"))
};
let p2 = fx2
.fm
.import_movie(&fx2.source("x.mkv"), "Matrix", opts)
.unwrap()
.unwrap();
let same = fx2.fm.rename_to_canonical(&p2).unwrap().unwrap();
assert_eq!(same, p2);
}
#[test]
fn test_move_to_library_reindexes_and_rejects_unknown() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Kids", LibraryKind::Mixed),
])
.unwrap();
let p = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap()
.unwrap();
fs::write(fx.pool().join("Filmy/A.srt"), "s").unwrap();
let new = fx.fm.move_to_library(&p, "Kids").unwrap();
assert_eq!(new, fx.pool().join("Kids/A.mkv"));
assert!(fx.pool().join("Kids/A.srt").exists());
let rec = fx
.fm
.index
.as_ref()
.unwrap()
.find_movie_by_media(&new)
.unwrap()
.1
.clone();
assert_eq!(rec["library"], "Kids");
assert!(fx.fm.move_to_library(&new, "Nope").is_err());
}
#[test]
fn test_apply_movie_edits_all_at_once_and_3d_handling() {
let mut fx = Fixture::new();
let p = fx
.fm
.import_movie(&fx.source("a.mkv"), "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&p), "Žánr/Akční")
.unwrap();
let edits = MovieEdits {
title: "Matrix".into(),
year: Some("1999".into()),
tmdb_id: Some("603".into()),
date: Some("2026-01-01".into()),
tags: Some(vec![
"Žánr/Sci-Fi".into(),
"Žánr/Sci-Fi".into(),
"bad".into(),
]),
csfd_url: Some("https://www.csfd.cz/film/9499/".into()),
format_3d: Some("hsbs".into()),
..Default::default()
};
let new = fx.fm.apply_movie_edits(&p, edits).unwrap();
assert_eq!(paths::name(&new), "Matrix (1999) [tmdbid-603].3D.HSBS.mkv");
let f = fx.fm.file(&new).unwrap();
assert_eq!(
f.tag_paths().into_iter().collect::<Vec<_>>(),
vec!["Žánr/Sci-Fi"]
);
assert_eq!(f.date.as_deref(), Some("2026-01-01"));
assert_eq!(
f.csfd_url.as_deref(),
Some("https://www.csfd.cz/film/9499/")
);
// omitted 3D keeps it, empty clears it
let kept = fx
.fm
.apply_movie_edits(
&new,
MovieEdits {
title: "Matrix".into(),
year: Some("1999".into()),
tmdb_id: Some("603".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(kept, new);
let cleared = fx
.fm
.apply_movie_edits(
&new,
MovieEdits {
title: "Matrix".into(),
year: Some("1999".into()),
tmdb_id: Some("603".into()),
format_3d: Some(String::new()),
..Default::default()
},
)
.unwrap();
assert_eq!(paths::name(&cleared), "Matrix (1999) [tmdbid-603].mkv");
}
#[test]
fn test_apply_movie_edits_rejects_name_clash() {
let mut fx = Fixture::new();
let a = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap()
.unwrap();
fx.fm
.import_movie(&fx.source("b.mkv"), "B", ImportOptions::default())
.unwrap();
assert!(matches!(
fx.fm.apply_movie_edits(
&a,
MovieEdits {
title: "B".into(),
..Default::default()
}
),
Err(CoreError::Exists(_))
));
}
#[test]
fn test_assign_tmdb_id_renames() {
let mut fx = Fixture::new();
let p = fx
.fm
.import_movie(&fx.source("a.mkv"), "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
let new = fx.fm.assign_tmdb_id(&p, 603, Some("1999")).unwrap();
assert_eq!(paths::name(&new), "Matrix (1999) [tmdbid-603].mkv");
}
}
+539
View File
@@ -0,0 +1,539 @@
//! Series operations (Jellyfin TV layout). Series carry no index metadata —
//! they are scanned live from disk, so every operation is plain file work.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use super::movies::OnConflict;
use super::{FileManager, POOL_SERIES};
use crate::core::error::{CoreError, CoreResult};
use crate::core::paths;
use crate::core::series::{
Episode, Series, episode_filename, is_season_folder, parse_episode, parse_episode_part,
scan_series, season_folder_name, series_folder_stem,
};
use crate::core::subtitles;
/// An explicit `(season, episode, part)` override set at import time.
pub type EpisodeCode = (u32, u32, Option<u32>);
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ImportEpisodesResult {
pub imported: usize,
pub skipped_no_code: Vec<String>,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RenameEpisodesResult {
pub renamed: Vec<(String, String)>,
pub skipped: Vec<String>,
pub conflicts: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RenameAllSeriesResult {
pub series: usize,
/// `(series, old, new)`.
pub renamed: Vec<(String, String, String)>,
pub skipped_no_code: Vec<(String, String)>,
pub conflicts: Vec<(String, String, String)>,
pub empty_dirs_removed: Vec<String>,
pub cancelled: bool,
}
impl FileManager {
/// Import episode files into `<library>/Series (rok) [tmdbid]/Season NN/`.
/// Each file's code comes from `codes` or is parsed from its name; files
/// with neither are skipped.
#[allow(clippy::too_many_arguments)]
pub fn import_episodes(
&mut self,
sources: &[PathBuf],
title: &str,
year: Option<&str>,
tmdb_id: Option<i64>,
library: Option<&str>,
move_source: bool,
on_conflict: OnConflict,
codes: &HashMap<PathBuf, EpisodeCode>,
) -> CoreResult<ImportEpisodesResult> {
let pool = self.require_pool()?;
let lib = library
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.or_else(|| self.series_libraries().into_iter().next())
.unwrap_or_else(|| POOL_SERIES.into());
let stem = series_folder_stem(title, year, tmdb_id)?;
let mut result = ImportEpisodesResult::default();
for src in sources {
let (season, episode, part) = match codes.get(src) {
Some(code) => *code,
None => match parse_episode(&paths::name(src)) {
Some((s, e)) => (s, e, parse_episode_part(&paths::name(src))),
None => {
result.skipped_no_code.push(paths::name(src));
continue;
}
},
};
let dest_dir = pool.join(&lib).join(&stem).join(season_folder_name(season));
fs::create_dir_all(&dest_dir)?;
let ext = paths::suffix(src);
let mut target = dest_dir.join(episode_filename(season, episode, title, &ext, part));
if target.exists() {
match on_conflict {
OnConflict::Skip => continue,
OnConflict::Replace => fs::remove_file(&target)?,
_ => {
let base = episode_filename(season, episode, title, "", part);
let mut counter = 1;
while target.exists() {
target = dest_dir.join(format!("{base}_{counter}{ext}"));
counter += 1;
}
}
}
}
match paths::transfer(src, &target, move_source) {
Ok(()) => result.imported += 1,
Err(e) => result.errors.push(format!("{}: {e}", paths::name(src))),
}
}
Ok(result)
}
/// Rename a series' episodes to `SxxEyy - {title}.ext` in their season folder.
pub fn rename_episodes_to_canonical(&self, series: &mut Series) -> RenameEpisodesResult {
let mut result = RenameEpisodesResult::default();
for ep in &mut series.episodes {
let (Some(season), Some(episode)) = (ep.season, ep.episode) else {
result.skipped.push(paths::name(&ep.path));
continue;
};
let dest_dir = series.folder.join(season_folder_name(season));
let new_path = dest_dir.join(episode_filename(
season,
episode,
&series.title,
&paths::suffix(&ep.path),
ep.part,
));
if new_path == ep.path {
continue;
}
if new_path.exists() {
result.conflicts.push((
paths::name(&ep.path),
format!("„{}“ už existuje", paths::name(&new_path)),
));
continue;
}
let moved = fs::create_dir_all(&dest_dir)
.and_then(|_| fs::rename(&ep.path, &new_path))
.map_err(CoreError::from)
.and_then(|_| subtitles::rename_subtitles_with(&ep.path, &new_path).map(|_| ()));
match moved {
Ok(()) => {
result
.renamed
.push((paths::name(&ep.path), paths::name(&new_path)));
ep.path = new_path;
}
Err(e) => result
.conflicts
.push((paths::name(&ep.path), e.to_string())),
}
}
result
}
/// Canonicalise every series' episode filenames across the pool and drop
/// season folders left empty.
pub fn rename_all_series_to_canonical(
&self,
on_progress: &mut dyn FnMut(usize, usize, &str),
should_cancel: &dyn Fn() -> bool,
) -> CoreResult<RenameAllSeriesResult> {
let pool = self.require_pool()?;
let mut all = self.load_series();
let mut out = RenameAllSeriesResult {
series: all.len(),
..Default::default()
};
let total = all.len();
for (i, ser) in all.iter_mut().enumerate() {
if should_cancel() {
out.cancelled = true;
break;
}
on_progress(i + 1, total, &ser.title);
let res = self.rename_episodes_to_canonical(ser);
out.renamed.extend(
res.renamed
.into_iter()
.map(|(o, n)| (ser.title.clone(), o, n)),
);
out.skipped_no_code
.extend(res.skipped.into_iter().map(|n| (ser.title.clone(), n)));
out.conflicts.extend(
res.conflicts
.into_iter()
.map(|(n, m)| (ser.title.clone(), n, m)),
);
for sub in paths::subdirs(&ser.folder) {
if is_season_folder(&paths::name(&sub))
&& paths::is_empty_dir(&sub)
&& fs::remove_dir(&sub).is_ok()
{
out.empty_dirs_removed
.push(paths::posix_relative(&sub, &pool));
}
}
}
Ok(out)
}
/// Rename/move a series folder to `Title (rok) [tmdbid]` and re-title its
/// episodes to match. Returns the new folder and the episode renames.
pub fn apply_series_edits(
&self,
series: &Series,
title: &str,
year: Option<&str>,
tmdb_id: Option<i64>,
library: Option<&str>,
) -> CoreResult<(PathBuf, RenameEpisodesResult)> {
let pool = self.require_pool()?;
let title = if title.trim().is_empty() {
series.title.clone()
} else {
title.trim().to_string()
};
let new_stem = series_folder_stem(&title, year.filter(|y| !y.is_empty()), tmdb_id)?;
let lib = library
.map(str::trim)
.filter(|l| !l.is_empty())
.unwrap_or(&series.library)
.to_string();
let new_folder = pool.join(&lib).join(&new_stem);
if new_folder != series.folder {
if new_folder.exists() {
return Err(CoreError::Exists(format!(
"Složka „{new_stem}“ už existuje."
)));
}
if let Some(parent) = new_folder.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(&series.folder, &new_folder)?;
}
let target = scan_series(new_folder.parent().unwrap_or(&pool), &lib, false)
.into_iter()
.find(|s| s.folder == new_folder);
let result = match target {
Some(mut s) => self.rename_episodes_to_canonical(&mut s),
None => RenameEpisodesResult::default(),
};
Ok((new_folder, result))
}
/// Delete a whole series folder (refuses a folder outside the pool).
pub fn remove_series(&self, series: &Series) -> CoreResult<()> {
let pool = self.require_pool()?;
let inside = match (series.folder.canonicalize(), pool.canonicalize()) {
(Ok(f), Ok(p)) => f.starts_with(&p) && f != p,
_ => false,
};
if !inside {
return Err(CoreError::invalid(format!(
"Složka „{}“ není v poolu.",
series.folder.display()
)));
}
if series.folder.is_dir() {
fs::remove_dir_all(&series.folder)?;
}
Ok(())
}
/// Delete episode files (+ subtitles); a season folder left empty goes too.
pub fn remove_episodes(&self, episodes: &[Episode]) {
let mut touched: HashSet<PathBuf> = HashSet::new();
for ep in episodes {
for sub in subtitles::find_subtitles(&ep.path) {
let _ = fs::remove_file(&sub.path);
}
let _ = fs::remove_file(&ep.path);
if let Some(parent) = ep.path.parent() {
touched.insert(parent.to_path_buf());
}
}
for folder in touched {
if is_season_folder(&paths::name(&folder)) && paths::is_empty_dir(&folder) {
let _ = fs::remove_dir(&folder);
}
}
}
/// Delete every episode of one season (`-1` = unrecognised episodes).
pub fn remove_season(&self, series: &Series, season: i64) -> CoreResult<()> {
let eps = series.seasons().remove(&season).unwrap_or_default();
if eps.is_empty() {
return Err(CoreError::invalid("Tahle sezóna už žádné epizody nemá."));
}
self.remove_episodes(&eps);
Ok(())
}
/// The series currently at `folder`, freshly scanned.
pub fn series_at(&self, folder: &Path) -> Option<Series> {
self.load_series().into_iter().find(|s| s.folder == folder)
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::Fixture;
use super::super::{Library, LibraryKind};
use super::*;
fn none() -> HashMap<PathBuf, EpisodeCode> {
HashMap::new()
}
#[test]
fn test_import_episodes_places_and_skips() {
let mut fx = Fixture::new();
let srcs = vec![
fx.source("bb.S01E01.mkv"),
fx.source("bb.s01e02.mkv"),
fx.source("random.mkv"),
];
let r = fx
.fm
.import_episodes(
&srcs,
"Breaking Bad",
Some("2008"),
Some(1396),
None,
false,
OnConflict::Suffix,
&none(),
)
.unwrap();
assert_eq!(r.imported, 2);
assert_eq!(r.skipped_no_code, vec!["random.mkv"]);
let base = fx
.pool()
.join("Seriály/Breaking Bad (2008) [tmdbid-1396]/Season 01");
assert!(base.join("S01E01 - Breaking Bad.mkv").exists());
assert!(base.join("S01E02 - Breaking Bad.mkv").exists());
}
#[test]
fn test_import_episodes_overrides_and_parts() {
let mut fx = Fixture::new();
let a = fx.source("a.mkv");
let b = fx.source("b.mkv");
let c = fx.source("Show.S01E01.pt2.mkv");
let codes: HashMap<PathBuf, EpisodeCode> =
[(a.clone(), (1, 1, Some(1))), (b.clone(), (1, 1, Some(2)))]
.into_iter()
.collect();
let r = fx
.fm
.import_episodes(
&[a, b],
"Breaking Bad",
Some("2008"),
Some(1396),
None,
false,
OnConflict::Suffix,
&codes,
)
.unwrap();
assert_eq!(r.imported, 2);
let base = fx
.pool()
.join("Seriály/Breaking Bad (2008) [tmdbid-1396]/Season 01");
assert!(base.join("S01E01 - Breaking Bad.pt1.mkv").exists());
assert!(base.join("S01E01 - Breaking Bad.pt2.mkv").exists());
fx.fm
.import_episodes(
&[c],
"Show",
None,
None,
None,
false,
OnConflict::Suffix,
&none(),
)
.unwrap();
assert!(
fx.pool()
.join("Seriály/Show/Season 01/S01E01 - Show.pt2.mkv")
.exists()
);
}
#[test]
fn test_import_episodes_into_mixed_library() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![Library::new("Mix", LibraryKind::Mixed)])
.unwrap();
let src = fx.source("bb.S01E01.mkv");
let r = fx
.fm
.import_episodes(
&[src],
"Breaking Bad",
Some("2008"),
Some(1396),
Some("Mix"),
false,
OnConflict::Suffix,
&none(),
)
.unwrap();
assert_eq!(r.imported, 1);
let found: Vec<String> = fx.fm.load_series().into_iter().map(|s| s.title).collect();
assert_eq!(found, vec!["Breaking Bad"]);
}
#[test]
fn test_rename_episodes_carries_subtitles_and_parts() {
let fx = Fixture::new();
let folder = fx.pool().join("Seriály/Breaking Bad (2008) [tmdbid-1396]");
fs::create_dir_all(&folder).unwrap();
fs::write(folder.join("bb.1x03.HDTV.mkv"), "x").unwrap();
fs::write(folder.join("bb.1x03.HDTV.cs.srt"), "s").unwrap();
fs::write(folder.join("bb.1x04.HDTV-part2.mkv"), "x").unwrap();
let mut ser = fx.fm.load_series().remove(0);
let r = fx.fm.rename_episodes_to_canonical(&mut ser);
assert_eq!(
r.renamed,
vec![
(
"bb.1x03.HDTV.mkv".into(),
"S01E03 - Breaking Bad.mkv".into()
),
(
"bb.1x04.HDTV-part2.mkv".into(),
"S01E04 - Breaking Bad.pt2.mkv".into()
),
]
);
assert!(
folder
.join("Season 01/S01E03 - Breaking Bad.cs.srt")
.exists()
);
assert!(!folder.join("bb.1x03.HDTV.cs.srt").exists());
}
#[test]
fn test_rename_all_series_bulk_and_empty_dirs() {
let fx = Fixture::new();
let seri = fx.pool().join("Seriály");
let fargo = seri.join("Fargo (2014) [tmdbid-60622]/Season 1");
fs::create_dir_all(&fargo).unwrap();
fs::write(fargo.join("fargo.1x01.1080p.WEB.mkv"), "x").unwrap();
let most = seri.join("Most! (2019)");
fs::create_dir_all(&most).unwrap();
fs::write(most.join("necoZvlastniho.mkv"), "x").unwrap();
let r = fx
.fm
.rename_all_series_to_canonical(&mut |_, _, _| {}, &|| false)
.unwrap();
assert_eq!(r.series, 2);
assert!(r.renamed.contains(&(
"Fargo".into(),
"fargo.1x01.1080p.WEB.mkv".into(),
"S01E01 - Fargo.mkv".into()
)));
assert!(
seri.join("Fargo (2014) [tmdbid-60622]/Season 01/S01E01 - Fargo.mkv")
.exists()
);
assert!(!fargo.exists());
assert!(
r.skipped_no_code
.contains(&("Most!".into(), "necoZvlastniho.mkv".into()))
);
}
#[test]
fn test_apply_series_edits_renames_folder_and_episodes() {
let fx = Fixture::new();
let folder = fx.pool().join("Seriály/bb");
fs::create_dir_all(&folder).unwrap();
fs::write(folder.join("bb.S01E01.mkv"), "x").unwrap();
let ser = fx.fm.load_series().remove(0);
let (new_folder, _) = fx
.fm
.apply_series_edits(&ser, "Breaking Bad", Some("2008"), Some(1396), None)
.unwrap();
assert_eq!(
new_folder,
fx.pool().join("Seriály/Breaking Bad (2008) [tmdbid-1396]")
);
assert!(
new_folder
.join("Season 01/S01E01 - Breaking Bad.mkv")
.exists()
);
assert!(!folder.exists());
}
#[test]
fn test_remove_series_season_and_episodes() {
let fx = Fixture::new();
let folder = fx.pool().join("Seriály/Show (2010)");
for rel in [
"Season 01/S01E01 - Show.mkv",
"Season 01/S01E01 - Show.cs.srt",
"Season 01/S01E02 - Show.mkv",
"Season 02/S02E01 - Show.mkv",
] {
fs::create_dir_all(folder.join(rel).parent().unwrap()).unwrap();
fs::write(folder.join(rel), "x").unwrap();
}
let ser = fx.fm.series_at(&folder).unwrap();
let e1 = ser
.episodes
.iter()
.find(|e| e.episode == Some(1) && e.season == Some(1))
.unwrap()
.clone();
fx.fm.remove_episodes(std::slice::from_ref(&e1));
assert!(!e1.path.exists());
assert!(!folder.join("Season 01/S01E01 - Show.cs.srt").exists());
assert!(folder.join("Season 01").is_dir());
let ser = fx.fm.series_at(&folder).unwrap();
fx.fm.remove_season(&ser, 1).unwrap();
assert!(!folder.join("Season 01").exists());
let ser = fx.fm.series_at(&folder).unwrap();
assert!(fx.fm.remove_season(&ser, 7).is_err());
fx.fm.remove_series(&ser).unwrap();
assert!(!folder.exists());
assert!(fx.fm.load_series().is_empty());
let outside = Series {
folder: fx.dir.path().join("src"),
title: "x".into(),
year: None,
tmdb_id: None,
library: "Seriály".into(),
episodes: vec![],
};
fs::create_dir_all(&outside.folder).unwrap();
assert!(fx.fm.remove_series(&outside).is_err());
assert!(outside.folder.is_dir());
}
}
+949
View File
@@ -0,0 +1,949 @@
//! Pool-wide tools: consistency, statistics, duplicates, integrity, TMDb/ČSFD
//! lookups, subtitles and the miniDLNA scope.
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use super::{FileManager, LibraryKind, is_media_file};
use crate::core::csfd::{self, CsfdFilm};
use crate::core::error::CoreResult;
use crate::core::file::File;
use crate::core::integrity::{IntegrityResult, check_video_integrity, ffmpeg_available};
use crate::core::jellyfin::JellyfinMovie;
use crate::core::migrate::{MatchProposal, MatchStatus, classify};
use crate::core::movie::identity_key_for;
use crate::core::naming::{parse_pool_stem, parse_tmdb_id};
use crate::core::nfo::read_nfo;
use crate::core::paths;
use crate::core::pool_index::PoolIndex;
use crate::core::series::Series;
use crate::core::subtitles::{self, SubtitleFile};
use crate::core::tmdb::{self, TmdbResult};
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RenameAllReport {
pub renamed: Vec<(String, String)>,
pub skipped_no_year: Vec<String>,
pub conflicts: Vec<(String, String)>,
pub unchanged: usize,
pub cancelled: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConsistencyReport {
/// Indexed media files that no longer exist on disk.
pub missing: Vec<String>,
/// Pool files (pool-relative) with no index entry.
pub untracked: Vec<String>,
pub index_count: usize,
pub disk_count: usize,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct JellyfinReport {
pub matched: usize,
pub pool_only: Vec<String>,
pub jellyfin_only: Vec<String>,
pub pool_total: usize,
pub jellyfin_total: usize,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct IntegrityReport {
pub problems: Vec<IntegrityResult>,
pub checked: usize,
pub cancelled: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Stats {
pub count: usize,
pub total_size: u64,
pub with_nfo: usize,
pub without_nfo: usize,
pub untagged: usize,
pub avg_rating: Option<f64>,
/// Category → `(tag, count)` sorted by frequency (descending).
pub categories: BTreeMap<String, Vec<(String, usize)>>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DuplicateGroup {
/// The primary version of each movie, path-sorted.
pub movies: Vec<PathBuf>,
pub reasons: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RefreshReport {
pub updated: usize,
pub skipped: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ResolveResult {
pub csfd_url: Option<String>,
pub film: Option<CsfdFilm>,
pub tmdb: Option<TmdbResult>,
}
/// Movies + mirrored libraries + mixed-library series for one miniDLNA run.
#[derive(Debug, Clone, Default)]
pub struct MinidlnaScope {
pub movies: Vec<File>,
pub mirror_libs: Vec<String>,
pub mixed_series: Vec<Series>,
}
impl FileManager {
/// Rename each movie to `Title (YYYY).ext`; reports conflicts without aborting.
pub fn rename_all_to_canonical(
&mut self,
files: &[PathBuf],
on_progress: &mut dyn FnMut(usize, usize),
should_cancel: &dyn Fn() -> bool,
) -> RenameAllReport {
let mut report = RenameAllReport::default();
for (i, path) in files.iter().enumerate() {
if should_cancel() {
report.cancelled = true;
break;
}
let old_name = paths::name(path);
match self.rename_to_canonical(path) {
Err(e) => report.conflicts.push((old_name, e.to_string())),
Ok(None) => report.skipped_no_year.push(old_name),
Ok(Some(new)) if new != *path => report.renamed.push((old_name, paths::name(&new))),
Ok(Some(_)) => report.unchanged += 1,
}
on_progress(i + 1, files.len());
}
report
}
/// Compare the pool index against the actual files in the movie libraries.
pub fn check_data_consistency(&self) -> CoreResult<ConsistencyReport> {
let pool = self.require_pool()?;
let opened;
let index = match &self.index {
Some(i) => i,
None => {
opened = PoolIndex::open(&pool);
&opened
}
};
let index_keys: BTreeSet<String> = index
.records
.values()
.flat_map(|r| {
r.get("media")
.and_then(|m| m.as_array())
.cloned()
.unwrap_or_default()
})
.filter_map(|m| {
m.get("file")
.and_then(|f| f.as_str())
.filter(|s| !s.is_empty())
.map(String::from)
})
.collect();
let mut disk_keys = BTreeSet::new();
for lib in self.libraries() {
let lib_dir = pool.join(&lib.name);
if !lib_dir.is_dir() {
continue;
}
let files = match lib.kind {
LibraryKind::Movie => paths::walk_files(&lib_dir),
LibraryKind::Mixed => paths::entries(&lib_dir)
.into_iter()
.filter(|p| p.is_file())
.collect(),
_ => continue,
};
for f in files.into_iter().filter(|f| is_media_file(f)) {
disk_keys.insert(paths::posix_relative(&f, &pool));
}
}
Ok(ConsistencyReport {
missing: index_keys
.iter()
.filter(|k| !paths::join_posix(&pool, k).is_file())
.cloned()
.collect(),
untracked: disk_keys.difference(&index_keys).cloned().collect(),
index_count: index_keys.len(),
disk_count: disk_keys.len(),
})
}
/// Compare the pool against what Jellyfin sees (by TMDb id, else title+year).
pub fn verify_against_jellyfin(&self, jf: &[JellyfinMovie]) -> JellyfinReport {
let by_tmdb: HashMap<i64, &JellyfinMovie> =
jf.iter().filter_map(|m| Some((m.tmdb_id?, m))).collect();
let by_name: HashMap<(String, Option<i64>), &JellyfinMovie> = jf
.iter()
.filter(|m| !m.title.is_empty())
.map(|m| ((m.title.trim().to_lowercase(), m.year), m))
.collect();
let mut matched_ids = HashSet::new();
let mut pool_only = Vec::new();
for f in &self.filelist {
let tmdb_id = parse_tmdb_id(&paths::stem(&f.file_path));
let ctx = f.name_context();
let title = f
.title
.clone()
.filter(|t| !t.is_empty())
.or_else(|| ctx.get("title").cloned())
.unwrap_or_default()
.trim()
.to_lowercase();
let year = ctx.get("year").and_then(|y| y.parse::<i64>().ok());
let hit = tmdb_id
.and_then(|id| by_tmdb.get(&id))
.or_else(|| by_name.get(&(title, year)));
match hit {
Some(m) => {
matched_ids.insert(m.jellyfin_id.clone());
}
None => pool_only.push(f.filename.clone()),
}
}
pool_only.sort();
let mut jellyfin_only: Vec<String> = jf
.iter()
.filter(|m| !matched_ids.contains(&m.jellyfin_id))
.map(|m| m.title.clone())
.collect();
jellyfin_only.sort();
JellyfinReport {
matched: self.filelist.len() - pool_only.len(),
pool_only,
jellyfin_only,
pool_total: self.filelist.len(),
jellyfin_total: jf.len(),
}
}
/// Check `files` for corrupted video. Errors when the FFmpeg tool is missing.
pub fn scan_video_integrity(
files: &[PathBuf],
deep: bool,
on_progress: &mut dyn FnMut(usize, usize, &Path),
should_cancel: &dyn Fn() -> bool,
) -> Result<IntegrityReport, String> {
if !ffmpeg_available(deep) {
let tool = if deep { "ffmpeg" } else { "ffprobe" };
return Err(format!("{tool} není k dispozici (nainstaluj FFmpeg)."));
}
let mut report = IntegrityReport::default();
for (i, path) in files.iter().enumerate() {
if should_cancel() {
report.cancelled = true;
break;
}
let result = check_video_integrity(path, deep, None);
report.checked += 1;
if !result.ok {
report.problems.push(result);
}
on_progress(i + 1, files.len(), path);
}
Ok(report)
}
/// Aggregate stats over the loaded movies.
pub fn statistics(&self) -> Stats {
let mut stats = Stats {
count: self.filelist.len(),
..Default::default()
};
let mut ratings = Vec::new();
let mut cats: BTreeMap<String, HashMap<String, usize>> = BTreeMap::new();
for f in &self.filelist {
stats.total_size += fs::metadata(&f.file_path).map(|m| m.len()).unwrap_or(0);
if f.meta.is_some() {
stats.with_nfo += 1;
}
if f.tags.is_empty() {
stats.untagged += 1;
}
for t in &f.tags {
*cats
.entry(t.category.clone())
.or_default()
.entry(t.name.clone())
.or_default() += 1;
}
if let Some(r) = f.rating() {
ratings.push(r);
}
}
stats.without_nfo = stats.count - stats.with_nfo;
if !ratings.is_empty() {
let avg = ratings.iter().sum::<f64>() / ratings.len() as f64;
stats.avg_rating = Some((avg * 10.0).round() / 10.0);
}
stats.categories = cats
.into_iter()
.map(|(c, counts)| {
let mut v: Vec<(String, usize)> = counts.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
(c, v)
})
.collect();
stats
}
/// `filelist` grouped per movie: one list per flat file / version folder.
fn movie_units(&self) -> Vec<Vec<PathBuf>> {
let mut units: indexmap::IndexMap<PathBuf, Vec<PathBuf>> = indexmap::IndexMap::new();
for f in &self.filelist {
let anchor = if self.is_movie_group_member(&f.file_path) {
f.file_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
} else {
f.file_path.clone()
};
units.entry(anchor).or_default().push(f.file_path.clone());
}
units.into_values().collect()
}
/// Movies that show up in the pool more than once — same TMDb id, same
/// title + year, or a byte-identical file size. Overlapping hits merge.
pub fn find_duplicates(&self) -> Vec<DuplicateGroup> {
let units = self.movie_units();
let reps: Vec<PathBuf> = units.iter().map(|u| self.primary_version(u)).collect();
let mut parent: Vec<usize> = (0..units.len()).collect();
fn find(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
let mut reasons: Vec<(BTreeSet<usize>, String)> = Vec::new();
let mut flag = |parent: &mut Vec<usize>, idx: &[usize], reason: String| {
if idx.len() < 2 {
return;
}
for &i in &idx[1..] {
let (a, b) = (find(parent, i), find(parent, idx[0]));
parent[a] = b;
}
reasons.push((idx.iter().copied().collect(), reason));
};
let mut by_tmdb: indexmap::IndexMap<i64, Vec<usize>> = indexmap::IndexMap::new();
let mut by_name: indexmap::IndexMap<String, Vec<usize>> = indexmap::IndexMap::new();
let mut by_size: indexmap::IndexMap<u64, Vec<usize>> = indexmap::IndexMap::new();
let mut tmdb_of = Vec::new();
for (i, (unit, rep)) in units.iter().zip(&reps).enumerate() {
let file = self.file(rep).cloned().unwrap_or_else(|| File::bare(rep));
let id = file.identity_from_path();
tmdb_of.push(id.tmdb_id);
if let Some(t) = id.tmdb_id {
by_tmdb.entry(t).or_default().push(i);
}
if let Some(y) = id.year.as_deref().filter(|_| !id.title.is_empty()) {
by_name
.entry(identity_key_for(None, &id.title, Some(y)))
.or_default()
.push(i);
}
for p in unit {
let Ok(size) = fs::metadata(p).map(|m| m.len()) else {
continue;
};
let list = by_size.entry(size).or_default();
if size > 0 && !list.contains(&i) {
list.push(i);
}
}
}
for (t, idx) in &by_tmdb {
flag(&mut parent, idx, format!("stejné TMDb ID {t}"));
}
for idx in by_name.values() {
let ids: HashSet<Option<i64>> = idx.iter().map(|&i| tmdb_of[i]).collect();
if ids.len() == 1 && !ids.contains(&None) {
continue;
}
flag(&mut parent, idx, "stejný název a rok".into());
}
for idx in by_size.values() {
flag(&mut parent, idx, "shodná velikost souboru".into());
}
let mut groups: indexmap::IndexMap<usize, (BTreeSet<usize>, BTreeSet<String>)> =
indexmap::IndexMap::new();
for (members, why) in reasons {
let root = find(&mut parent, *members.iter().next().unwrap_or(&0));
let g = groups.entry(root).or_default();
g.0.extend(members);
g.1.insert(why);
}
let mut result: Vec<DuplicateGroup> = groups
.into_values()
.map(|(idx, why)| {
let mut movies: Vec<PathBuf> = idx.into_iter().map(|i| reps[i].clone()).collect();
movies.sort_by_key(|p| p.to_string_lossy().to_lowercase());
DuplicateGroup {
movies,
reasons: why.into_iter().collect(),
}
})
.collect();
result.sort_by_key(|g| {
let f = self.file(&g.movies[0]);
f.and_then(|f| f.title.clone())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| paths::name(&g.movies[0]))
.to_lowercase()
});
result
}
pub fn list_subtitles(&self, path: &Path) -> Vec<SubtitleFile> {
subtitles::find_subtitles(path)
}
pub fn attach_subtitle(
&self,
path: &Path,
source: &Path,
language: Option<&str>,
flag: Option<&str>,
move_source: bool,
) -> CoreResult<PathBuf> {
subtitles::attach_subtitle(path, source, language, flag, move_source)
}
pub fn remove_subtitle(&self, subtitle: &Path) -> CoreResult<()> {
fs::remove_file(subtitle)?;
Ok(())
}
/// (Re)derive tags from TMDb by the tmdbid in each filename.
pub fn refresh_tags_from_tmdb(
&mut self,
files: &[PathBuf],
on_progress: &mut dyn FnMut(usize, usize),
should_cancel: &dyn Fn() -> bool,
) -> Result<RefreshReport, String> {
if !tmdb::api_available() {
return Err("TMDB_API_KEY není nastaven (.env).".into());
}
let schema = self.tag_schema();
let mut report = RefreshReport::default();
for (i, path) in files.iter().enumerate() {
if should_cancel() {
break;
}
match parse_tmdb_id(&paths::stem(path)) {
None => report.skipped += 1,
Some(id) => match tmdb::movie_details(id) {
Ok(details) => {
let r = self.with_file(path, |f, tm, index| {
f.apply_tags_from(&details, &schema, tm, Some(index))
});
match r {
Ok(_) => report.updated += 1,
Err(e) => report.errors.push(format!("{}: {e}", paths::name(path))),
}
}
Err(e) => report.errors.push(format!("{}: {e}", paths::name(path))),
},
}
on_progress(i + 1, files.len());
}
Ok(report)
}
/// (Re)derive auto tags from each movie's NFO. `skipped` = without NFO.
pub fn refresh_tags_from_nfo(&mut self, files: &[PathBuf]) -> CoreResult<RefreshReport> {
let schema = self.tag_schema();
let mut report = RefreshReport::default();
for path in files {
let Some(nfo) = read_nfo(path) else {
report.skipped += 1;
continue;
};
self.with_file(path, |f, tm, index| {
f.apply_tags_from(&nfo, &schema, tm, Some(index))
})?;
report.updated += 1;
}
Ok(report)
}
/// ČSFD → TMDb bridge for one movie (best-effort: failures are `None`s).
pub fn resolve_via_csfd(
csfd_url: Option<&str>,
query: Option<&str>,
session: Option<&reqwest::blocking::Client>,
) -> ResolveResult {
let mut result = ResolveResult {
csfd_url: csfd_url.map(String::from),
..Default::default()
};
let url = match csfd_url.filter(|u| !u.is_empty()) {
Some(u) => Some(u.to_string()),
None => query
.filter(|q| !q.is_empty())
.and_then(|q| csfd::find_csfd_url(q, session).ok().flatten()),
};
let Some(url) = url else { return result };
result.csfd_url = Some(url.clone());
let Ok(film) = csfd::fetch_film(&url, session) else {
return result;
};
let (title, year) = film.tmdb_query();
if !title.is_empty() && tmdb::api_available() {
let mut hits = tmdb::search_movie(&title, year.as_deref(), 1).unwrap_or_default();
if hits.is_empty() && !film.czech_title.is_empty() && film.czech_title != title {
hits =
tmdb::search_movie(&film.czech_title, year.as_deref(), 1).unwrap_or_default();
}
result.tmdb = hits.into_iter().next();
}
result.film = Some(film);
result
}
/// Propose a TMDb id for every movie without one (ČSFD-bridged when the
/// movie carries a ČSFD link).
pub fn match_pool_to_tmdb(
&self,
files: &[PathBuf],
on_progress: &mut dyn FnMut(usize, usize),
should_cancel: &dyn Fn() -> bool,
) -> Vec<MatchProposal> {
let session = crate::core::http::new_session();
let mut proposals = Vec::new();
for (i, path) in files.iter().enumerate() {
if should_cancel() {
break;
}
if parse_tmdb_id(&paths::stem(path)).is_some() {
on_progress(i + 1, files.len());
continue;
}
let file = self.file(path).cloned().unwrap_or_else(|| File::bare(path));
let ctx = file.name_context();
let mut title = file
.title
.clone()
.filter(|t| !t.is_empty())
.or_else(|| ctx.get("title").cloned())
.unwrap_or_else(|| paths::stem(path))
.trim()
.to_string();
if let Some((t, _)) = parse_pool_stem(&title) {
title = t;
}
let mut year = ctx.get("year").cloned().filter(|y| !y.trim().is_empty());
let mut note = String::new();
if let Some(url) = &file.csfd_url
&& let Ok(film) = csfd::fetch_film(url, Some(&session))
{
let ft = if film.original_title.is_empty() {
film.czech_title.clone()
} else {
film.original_title.clone()
};
if !ft.is_empty() {
title = ft;
year = film.year.clone().or(year);
note = format!("přes ČSFD: {title}")
+ &film.year.map(|y| format!(" ({y})")).unwrap_or_default();
}
}
let key = file
.movie_key
.clone()
.unwrap_or_else(|| file.identity_from_path().key);
let proposal = match tmdb::search_movie(&title, year.as_deref(), 5) {
Ok(candidates) => {
let (status, chosen) = classify(&title, year.as_deref(), &candidates);
MatchProposal {
movie_key: key,
current_name: file.filename.clone(),
title,
year,
status,
chosen,
candidates,
error: String::new(),
note,
}
}
Err(e) => MatchProposal {
movie_key: key,
current_name: file.filename.clone(),
title,
year,
status: MatchStatus::Error,
chosen: None,
candidates: Vec::new(),
error: e.to_string(),
note,
},
};
proposals.push(proposal);
on_progress(i + 1, files.len());
}
proposals
}
/// The pooled path whose filename is `name` (used to map proposals back).
pub fn path_by_filename(&self, name: &str) -> Option<PathBuf> {
self.filelist
.iter()
.find(|f| f.filename == name)
.map(|f| f.file_path.clone())
}
/// What one miniDLNA run covers, narrowed to `minidlna_libraries` when set.
pub fn minidlna_scope(&self) -> MinidlnaScope {
let selected = self.minidlna_libraries();
let mut mixed: HashSet<String> = self
.libraries_of_kind(&[LibraryKind::Mixed])
.into_iter()
.collect();
if let Some(sel) = &selected {
mixed.retain(|m| sel.contains(m));
}
let mixed_series = if mixed.is_empty() {
Vec::new()
} else {
self.load_series()
.into_iter()
.filter(|s| mixed.contains(&s.library))
.collect()
};
let mirrors = self.mirror_as_is_libraries();
match selected {
None => MinidlnaScope {
movies: self.filelist.clone(),
mirror_libs: mirrors,
mixed_series,
},
Some(sel) => MinidlnaScope {
movies: self
.filelist
.iter()
.filter(|f| {
self.library_of(&f.file_path)
.is_some_and(|l| sel.contains(&l))
})
.cloned()
.collect(),
mirror_libs: mirrors.into_iter().filter(|m| sel.contains(m)).collect(),
mixed_series,
},
}
}
/// Mirrored output to remove for libraries that fell out of scope:
/// `(stale library folders, valid children of the mixed-series bucket)`.
pub fn minidlna_prune_targets(
&self,
mirror_libs: &[String],
mixed_series: &[Series],
bucket: &str,
) -> (Vec<String>, HashSet<String>) {
let in_scope: HashSet<&String> = mirror_libs.iter().collect();
let stale = self
.mirror_as_is_libraries()
.into_iter()
.filter(|n| !in_scope.contains(n) && n != bucket)
.collect();
let mut valid: HashSet<String> = mixed_series
.iter()
.map(|s| paths::name(&s.folder))
.collect();
if in_scope.contains(&bucket.to_string())
&& let Some(pool) = self.pool_dir()
{
valid.extend(
paths::subdirs(&pool.join(bucket))
.iter()
.map(|p| paths::name(p)),
);
}
(stale, valid)
}
}
#[cfg(test)]
mod tests {
use super::super::movies::ImportOptions;
use super::super::test_support::Fixture;
use super::super::{Library, LibraryKind};
use super::*;
#[test]
fn test_statistics_and_consistency() {
let mut fx = Fixture::new();
let a = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap()
.unwrap();
fx.fm
.import_movie(&fx.source("bb.mkv"), "B", ImportOptions::default())
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&a), "Žánr/Drama")
.unwrap();
let s = fx.fm.statistics();
assert_eq!((s.count, s.untagged, s.with_nfo), (2, 1, 0));
assert_eq!(s.total_size, 5 + 6);
assert_eq!(s.categories["Žánr"], vec![("Drama".to_string(), 1)]);
let c = fx.fm.check_data_consistency().unwrap();
assert!(c.missing.is_empty() && c.untracked.is_empty());
fs::remove_file(&a).unwrap();
fs::write(fx.pool().join("Filmy/stray.mkv"), "x").unwrap();
let c = fx.fm.check_data_consistency().unwrap();
assert_eq!(c.missing, vec!["Filmy/A.mkv"]);
assert_eq!(c.untracked, vec!["Filmy/stray.mkv"]);
}
#[test]
fn test_find_duplicates_cases() {
let mut fx = Fixture::new();
let y = |y: &str, id: Option<&str>| ImportOptions {
year: Some(y.into()),
tmdb_id: id.map(String::from),
..Default::default()
};
fx.fm
.import_movie(&fx.source("a.mkv"), "Matrix", y("1999", Some("603")))
.unwrap();
fx.fm
.import_movie(
&fx.source("b.mkv"),
"Matrix",
ImportOptions {
format_3d: Some("HSBS".into()),
..y("1999", Some("603"))
},
)
.unwrap();
fx.fm
.import_movie(&fx.source("cc.mkv"), "Pelíšky", y("1999", None))
.unwrap();
fx.fm
.import_movie(&fx.source("dd.mkv"), "Pelisky", y("1999", Some("1")))
.unwrap();
fx.fm
.import_movie(&fx.source("unique-size.mkv"), "Jiný", y("2000", None))
.unwrap();
let groups = fx.fm.find_duplicates();
assert_eq!(groups.len(), 2, "{groups:?}");
assert!(
groups
.iter()
.any(|g| g.reasons.contains(&"stejné TMDb ID 603".to_string()))
);
assert!(
groups
.iter()
.any(|g| g.reasons.contains(&"stejný název a rok".to_string()))
);
}
#[test]
fn test_versions_are_not_duplicates() {
let mut fx = Fixture::new();
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
..Default::default()
};
let m = fx
.fm
.import_movie(&fx.source("h1.mkv"), "Hobit", opts)
.unwrap()
.unwrap();
fx.fm
.add_movie_version(&m, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
assert!(fx.fm.find_duplicates().is_empty());
}
#[test]
fn test_verify_against_jellyfin() {
let mut fx = Fixture::new();
let opts = ImportOptions {
year: Some("1999".into()),
tmdb_id: Some("603".into()),
..Default::default()
};
fx.fm
.import_movie(&fx.source("a.mkv"), "Matrix", opts)
.unwrap();
fx.fm
.import_movie(
&fx.source("b.mkv"),
"Pelíšky",
ImportOptions {
year: Some("1999".into()),
..Default::default()
},
)
.unwrap();
fx.fm
.import_movie(&fx.source("c.mkv"), "Chybí", ImportOptions::default())
.unwrap();
let jm = |id: &str, t: &str, y: Option<i64>, tmdb: Option<i64>| JellyfinMovie {
jellyfin_id: id.into(),
title: t.into(),
year: y,
tmdb_id: tmdb,
path: None,
};
let r = fx.fm.verify_against_jellyfin(&[
jm("1", "The Matrix", Some(1999), Some(603)),
jm("2", "Pelíšky", Some(1999), None),
jm("3", "Navíc", None, None),
]);
assert_eq!(r.matched, 2);
assert_eq!(r.pool_only, vec!["Chybí.mkv"]);
assert_eq!(r.jellyfin_only, vec!["Navíc"]);
}
#[test]
fn test_minidlna_scope_and_prune() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Kids", LibraryKind::Movie),
Library::new("Seriály", LibraryKind::Series),
Library::new("Domácí", LibraryKind::HomeVideo),
Library::new("Mix", LibraryKind::Mixed),
])
.unwrap();
fx.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap();
fx.fm
.import_movie(
&fx.source("b.mkv"),
"B",
ImportOptions {
library: Some("Kids".into()),
..Default::default()
},
)
.unwrap();
let ep = fx
.pool()
.join("Mix/Show (2010)/Season 01/S01E01 - Show.mkv");
fs::create_dir_all(ep.parent().unwrap()).unwrap();
fs::write(&ep, "e").unwrap();
let all = fx.fm.minidlna_scope();
assert_eq!(all.movies.len(), 2);
assert_eq!(all.mirror_libs, vec!["Seriály", "Domácí"]);
assert_eq!(all.mixed_series.len(), 1);
fx.fm
.set_minidlna_libraries(Some(vec!["Filmy".into(), "Seriály".into()]))
.unwrap();
let some = fx.fm.minidlna_scope();
assert_eq!(some.movies.len(), 1);
assert_eq!(some.mirror_libs, vec!["Seriály"]);
assert!(some.mixed_series.is_empty());
fs::create_dir_all(fx.pool().join("Seriály/Real")).unwrap();
let (stale, valid) =
fx.fm
.minidlna_prune_targets(&some.mirror_libs, &all.mixed_series, "Seriály");
assert_eq!(stale, vec!["Domácí"]);
assert!(valid.contains("Real") && valid.contains("Show (2010)"));
let (stale2, valid2) = fx.fm.minidlna_prune_targets(&[], &[], "Seriály");
assert_eq!(stale2, vec!["Domácí"]);
assert!(valid2.is_empty());
}
#[test]
fn test_refresh_tags_from_nfo() {
let mut fx = Fixture::new();
let opts = ImportOptions {
year: Some("1999".into()),
tmdb_id: Some("603".into()),
..Default::default()
};
let p = fx
.fm
.import_movie(&fx.source("raw.mkv"), "Matrix", opts)
.unwrap()
.unwrap();
fx.fm
.import_movie(&fx.source("other.mkv"), "Other", ImportOptions::default())
.unwrap();
fs::write(p.with_extension("nfo"), "<movie><year>1999</year><genre>Akční</genre><genre>Sci-Fi</genre><country>USA</country><rating>8.7</rating></movie>").unwrap();
let files: Vec<PathBuf> = fx.fm.filelist.iter().map(|f| f.file_path.clone()).collect();
let r = fx.fm.refresh_tags_from_nfo(&files).unwrap();
assert_eq!((r.updated, r.skipped), (1, 1));
let f = fx.fm.file(&p).unwrap();
for t in ["Žánr/Akční", "Žánr/Sci-Fi", "Rok/1999", "Země původu/USA"] {
assert!(f.tag_paths().contains(t), "{t}");
}
assert_eq!(f.meta.as_ref().unwrap()["rating"], 87);
}
#[test]
fn test_rename_all_to_canonical_reports() {
let mut fx = Fixture::new();
let a = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap()
.unwrap();
let b = fx
.fm
.import_movie(&fx.source("b.mkv"), "B", ImportOptions::default())
.unwrap()
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&a), "Rok/2000")
.unwrap();
let r = fx
.fm
.rename_all_to_canonical(&[a, b], &mut |_, _| {}, &|| false);
assert_eq!(
r.renamed,
vec![("A.mkv".to_string(), "A (2000).mkv".to_string())]
);
assert_eq!(r.skipped_no_year, vec!["B.mkv"]);
}
#[test]
fn test_subtitle_attach_and_remove() {
let mut fx = Fixture::new();
let p = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap()
.unwrap();
let sub = fx.source("x.srt");
let target = fx
.fm
.attach_subtitle(&p, &sub, Some("cs"), None, false)
.unwrap();
assert_eq!(fx.fm.list_subtitles(&p).len(), 1);
fx.fm.remove_subtitle(&target).unwrap();
assert!(fx.fm.list_subtitles(&p).is_empty());
}
}
+998
View File
@@ -0,0 +1,998 @@
//! Movie versions and parts (Jellyfin "multiple versions" and "stacking").
//!
//! A movie with one video file stays flat (`<Library>/Title (rok) [tmdbid].ext`).
//! The moment a second file is added it is promoted to its own folder, where
//! every file starts with the folder's exact name:
//!
//! ```text
//! <Library>/Title (rok) [tmdbid]/
//! Title (rok) [tmdbid].ext (default version, bare name)
//! Title (rok) [tmdbid] - 3D HSBS.ext (another version)
//! ```
//!
//! or, for a film split across files, `Title (rok) [tmdbid]-part1.ext`, `-part2`…
//! A folder holds either versions or parts, never both.
use std::fs;
use std::path::{Path, PathBuf};
use super::{FileManager, LibraryKind, is_media_file};
use crate::core::error::{CoreError, CoreResult};
use crate::core::file::{File, now_iso};
use crate::core::movie::{
DEFAULT_VERSION_LABEL, normalize_3d_label, normalize_part_label, part_label,
};
use crate::core::naming::{is_canonical_pool_stem, parse_3d_format};
use crate::core::paths;
use crate::core::series::looks_like_series_folder;
use crate::core::subtitles;
const PARTS_AND_VERSIONS_MIX: &str = "Film může mít buď díly (přehrají se za sebou), nebo verze (alternativy jako 2D/3D, 4K) — ne obojí v jedné složce. Uprav popisek, nebo nejdřív odeber soubory toho druhého druhu.";
/// How a film's files are laid out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GroupMode {
/// One file in the library root.
Flat,
/// A folder of alternatives.
Versions,
/// A folder of pieces played back to back.
Parts,
}
/// Filename of one version in a movie folder: the default version is bare
/// `<stem><ext>`, a part `<stem>-part<N><ext>`, anything else
/// `<stem> - <label><ext>`.
pub fn version_filename(stem: &str, label: &str, ext: &str) -> String {
let label = label.trim();
if label.is_empty() || label == DEFAULT_VERSION_LABEL {
return format!("{stem}{ext}");
}
match normalize_part_label(label) {
Some(part) => format!("{stem}-part{part}{ext}"),
None => format!("{stem} - {label}{ext}"),
}
}
/// True when `labels` contain both part labels and version labels.
fn mixes_parts_and_versions<'a>(labels: impl IntoIterator<Item = &'a str>) -> bool {
let kinds: std::collections::HashSet<bool> = labels
.into_iter()
.map(|l| normalize_part_label(l).is_some())
.collect();
kinds.len() > 1
}
/// The version label a pooled path implies.
pub fn label_of(path: &Path) -> String {
File::bare(path).identity_from_path().label
}
fn label_or_default(path: &Path) -> String {
let l = label_of(path);
if l.is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
l
}
}
impl FileManager {
/// True when `path` is one version inside its own per-movie folder (in a
/// movie or mixed library; in a mixed one the folder must not be a series).
pub fn is_movie_group_member(&self, path: &Path) -> bool {
let (Some(pool), Some(lib)) = (self.pool_dir(), self.library_of(path)) else {
return false;
};
if !self.libraries_accepting_movies().contains(&lib) {
return false;
}
let Some(parent) = path.parent() else {
return false;
};
if parent == pool.join(&lib) || !is_canonical_pool_stem(&paths::name(parent)) {
return false;
}
!(self.library_kind(&lib) == Some(LibraryKind::Mixed) && looks_like_series_folder(parent))
}
/// Every version sharing `path`'s movie folder, name-sorted (`[path]` when flat).
pub fn list_movie_versions(&self, path: &Path) -> Vec<PathBuf> {
if !self.is_movie_group_member(path) {
return vec![path.to_path_buf()];
}
let folder = path.parent().unwrap_or(Path::new("."));
paths::entries(folder)
.into_iter()
.filter(|p| p.is_file() && is_media_file(p))
.collect()
}
/// The version shown for the movie in the table: plain file first, then the
/// "Výchozí" one, then alphabetically.
pub fn primary_version(&self, versions: &[PathBuf]) -> PathBuf {
versions
.iter()
.min_by_key(|p| {
let label = label_of(p);
let order = if label.is_empty() {
0
} else if label == DEFAULT_VERSION_LABEL {
1
} else {
2
};
(order, paths::name(p).to_lowercase())
})
.cloned()
.unwrap_or_default()
}
/// `filelist` collapsed to one row per movie (a folder contributes only its
/// primary version).
pub fn visible_movies(&self) -> Vec<PathBuf> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for f in &self.filelist {
if !self.is_movie_group_member(&f.file_path) {
out.push(f.file_path.clone());
continue;
}
let folder = f
.file_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
if seen.insert(folder) {
out.push(self.primary_version(&self.list_movie_versions(&f.file_path)));
}
}
out
}
/// Add `source` as another playable version of `path`'s movie, promoting a
/// flat movie to its own folder on the first extra version. An empty
/// `label` becomes "Výchozí"; an unset `existing_label` is derived from the
/// existing file (its 3D tag, else "Výchozí", or `part1` when adding parts).
pub fn add_movie_version(
&mut self,
path: &Path,
source: &Path,
label: &str,
move_source: bool,
existing_label: Option<&str>,
) -> CoreResult<PathBuf> {
let label = if label.trim().is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
label.trim().to_string()
};
let pool = self.require_pool()?;
let (folder, stem) = if self.is_movie_group_member(path) {
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let existing: Vec<String> = self
.list_movie_versions(path)
.iter()
.map(|p| label_or_default(p))
.collect();
if mixes_parts_and_versions(existing.iter().map(String::as_str).chain([label.as_str()]))
{
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let stem = paths::name(&folder);
(folder, stem)
} else {
let file = self.file(path).cloned().unwrap_or_else(|| File::bare(path));
let stem = self.canonical_name_for(&file, false).ok_or_else(|| {
CoreError::invalid("Film nemá platný název/rok — nejde založit složku verzí.")
})?;
let existing_label = match existing_label {
Some(l) => l.to_string(),
None if normalize_part_label(&label).is_some() => "part1".into(),
None => parse_3d_format(&paths::stem(path))
.map_or_else(|| DEFAULT_VERSION_LABEL.to_string(), |f| format!("3D {f}")),
};
if mixes_parts_and_versions([existing_label.as_str(), label.as_str()]) {
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let lib = self
.library_of(path)
.unwrap_or_else(|| self.movie_libraries()[0].clone());
let folder = pool.join(lib).join(&stem);
if folder.exists() {
return Err(CoreError::Exists(format!(
"Složka „{stem}“ už v poolu existuje."
)));
}
fs::create_dir_all(&folder)?;
let existing_target = folder.join(version_filename(
&stem,
&existing_label,
&paths::suffix(path),
));
paths::move_file(path, &existing_target)?;
subtitles::rename_subtitles_with(path, &existing_target)?;
self.with_file(path, |f, tm, index| {
f.relocate(&existing_target, Some(index))?;
f.save_location(tm, index)
})?;
(folder, stem)
};
let target = folder.join(version_filename(&stem, &label, &paths::suffix(source)));
if target.exists() {
return Err(CoreError::Exists(format!("Verze „{label}“ už existuje.")));
}
paths::transfer(source, &target, move_source)?;
let Self {
tagmanager,
index,
filelist,
..
} = self;
let index = index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
let mut version = File::load(&target, tagmanager, Some(index))?;
version.added = Some(now_iso());
version.save_metadata(index)?;
filelist.push(version);
Ok(target)
}
/// Add `source` as the next part of `path`'s film (a flat film becomes
/// `part1` and `source` `part2`).
pub fn add_movie_part(
&mut self,
path: &Path,
source: &Path,
move_source: bool,
) -> CoreResult<PathBuf> {
if !self.is_movie_group_member(path) {
return self.add_movie_version(path, source, &part_label(2), move_source, None);
}
let numbers: Vec<Option<u32>> = self
.list_movie_versions(path)
.iter()
.map(|p| normalize_part_label(&label_of(p)))
.collect();
if numbers.iter().any(Option::is_none) {
return Err(CoreError::invalid(
"Film už má verze (2D/3D, kvalita…) — díly s verzemi zatím nejde kombinovat. Odeber nejdřív verze, nebo přidej soubor jako verzi.",
));
}
let next = numbers.into_iter().flatten().max().unwrap_or(0) + 1;
self.add_movie_version(path, source, &part_label(next), move_source, None)
}
pub fn movie_group_mode(&self, path: &Path) -> GroupMode {
if !self.is_movie_group_member(path) {
return GroupMode::Flat;
}
let labels: Vec<String> = self
.list_movie_versions(path)
.iter()
.map(|p| label_of(p))
.collect();
if !labels.is_empty() && labels.iter().all(|l| normalize_part_label(l).is_some()) {
GroupMode::Parts
} else {
GroupMode::Versions
}
}
/// The film's files in display order — parts by number, versions with the
/// default first, then by popisek.
pub fn ordered_media(&self, path: &Path) -> Vec<PathBuf> {
let mut versions = self.list_movie_versions(path);
versions.sort_by_key(|p| {
let label = label_of(p);
match normalize_part_label(&label) {
Some(n) => (0, n, String::new()),
None if label.is_empty() || label == DEFAULT_VERSION_LABEL => (0, 0, String::new()),
None => (1, 0, label.to_lowercase()),
}
});
versions
}
/// Give every file of the folder a new popisek at once (through temporary
/// names, so swapping two labels never overwrites a file).
fn relabel_movie_group(
&mut self,
path: &Path,
assignments: &[(PathBuf, String)],
) -> CoreResult<()> {
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let current: std::collections::HashSet<PathBuf> =
self.list_movie_versions(path).into_iter().collect();
let assigned: std::collections::HashSet<PathBuf> =
assignments.iter().map(|(p, _)| p.clone()).collect();
if assigned != current || assignments.len() != current.len() {
return Err(CoreError::invalid(
"Přeřazení musí zahrnout každý soubor složky právě jednou.",
));
}
let labels: Vec<String> = assignments
.iter()
.map(|(_, l)| {
if l.trim().is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
l.trim().to_string()
}
})
.collect();
if mixes_parts_and_versions(labels.iter().map(String::as_str)) {
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let stem = paths::name(&folder);
let targets: Vec<PathBuf> = assignments
.iter()
.zip(&labels)
.map(|((p, _), l)| folder.join(version_filename(&stem, l, &paths::suffix(p))))
.collect();
let unique: std::collections::HashSet<String> = targets
.iter()
.map(|t| paths::name(t).to_lowercase())
.collect();
if unique.len() != targets.len() {
return Err(CoreError::invalid(
"Dva soubory by dostaly stejný název — popisky se musí lišit.",
));
}
let mut temps = Vec::new();
for (i, (p, _)) in assignments.iter().enumerate() {
let temp = folder.join(format!("{stem} - .curator-tmp-{i}{}", paths::suffix(p)));
self.relocate_file(p, &temp, true)?;
temps.push(temp);
}
for (temp, target) in temps.iter().zip(&targets) {
self.relocate_file(temp, target, true)?;
self.with_file(target, |f, tm, index| f.save_location(tm, index))?;
}
Ok(())
}
/// Move one part earlier (`delta < 0`) or later; parts are renumbered.
pub fn move_movie_part(&mut self, path: &Path, delta: i32) -> CoreResult<()> {
if self.movie_group_mode(path) != GroupMode::Parts {
return Err(CoreError::invalid("Film nemá díly, které by šlo přeřadit."));
}
let mut order = self.ordered_media(path);
let Some(i) = order.iter().position(|p| p == path) else {
return Ok(());
};
let j = i as i64 + i64::from(delta);
if j < 0 || j >= order.len() as i64 {
return Ok(());
}
order.swap(i, j as usize);
let assignments: Vec<(PathBuf, String)> = order
.into_iter()
.enumerate()
.map(|(n, p)| (p, part_label(n as u32 + 1)))
.collect();
self.relabel_movie_group(path, &assignments)
}
/// Switch a folder between versions and parts. To versions, the first part
/// becomes the default and the others take `labels` in order.
pub fn set_movie_group_mode(
&mut self,
path: &Path,
mode: GroupMode,
labels: &[String],
) -> CoreResult<()> {
if mode == GroupMode::Flat {
return Err(CoreError::invalid("Neznámý režim „flat“."));
}
let current = self.movie_group_mode(path);
if current == GroupMode::Flat {
return Err(CoreError::invalid(
"Film má jen jeden soubor — režim složky zatím nemá co měnit.",
));
}
if current == mode {
return Ok(());
}
let order = self.ordered_media(path);
if mode == GroupMode::Parts {
let a: Vec<(PathBuf, String)> = order
.into_iter()
.enumerate()
.map(|(n, p)| (p, part_label(n as u32 + 1)))
.collect();
return self.relabel_movie_group(path, &a);
}
let clean: Vec<String> = labels.iter().map(|l| l.trim().to_string()).collect();
if clean.len() != order.len() - 1 || clean.iter().any(String::is_empty) {
return Err(CoreError::invalid(format!(
"Každý další soubor potřebuje popisek verze ({}).",
order.len() - 1
)));
}
if clean
.iter()
.any(|l| l.to_lowercase() == DEFAULT_VERSION_LABEL.to_lowercase())
{
return Err(CoreError::invalid(format!(
"„{DEFAULT_VERSION_LABEL}“ je vyhrazený pro první soubor."
)));
}
let unique: std::collections::HashSet<String> =
clean.iter().map(|l| l.to_lowercase()).collect();
if unique.len() != clean.len() {
return Err(CoreError::invalid("Popisky verzí se musí lišit."));
}
let mut a = vec![(order[0].clone(), String::new())];
a.extend(order[1..].iter().cloned().zip(clean));
self.relabel_movie_group(path, &a)
}
/// Rename one version's label within its folder (empty → "Výchozí").
pub fn rename_movie_version(&mut self, path: &Path, new_label: &str) -> CoreResult<PathBuf> {
if !self.is_movie_group_member(path) {
return Err(CoreError::invalid(
"Film nemá žádné další verze — popisek se uplatní až od druhé verze.",
));
}
let new_label = if new_label.trim().is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
new_label.trim().to_string()
};
let others: Vec<String> = self
.list_movie_versions(path)
.iter()
.filter(|p| p.as_path() != path)
.map(|p| label_or_default(p))
.collect();
if mixes_parts_and_versions(
others
.iter()
.map(String::as_str)
.chain([new_label.as_str()]),
) {
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let target = folder.join(version_filename(
&paths::name(&folder),
&new_label,
&paths::suffix(path),
));
if target == path {
return Ok(target);
}
if target.exists() {
return Err(CoreError::Exists(format!(
"Verze „{new_label}“ už existuje."
)));
}
self.relocate_file(path, &target, true)?;
self.with_file(&target, |f, tm, index| f.save_location(tm, index))?;
Ok(target)
}
/// Rename grouped default versions still called `<folder> - Výchozí.ext` to
/// the bare `<folder>.ext`. Returns the `(old, new)` names.
pub fn normalize_default_version_names(&mut self) -> CoreResult<Vec<(String, String)>> {
let mut renamed = Vec::new();
let paths_now: Vec<PathBuf> = self.filelist.iter().map(|f| f.file_path.clone()).collect();
for path in paths_now {
if !self.is_movie_group_member(&path) {
continue;
}
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let fname = paths::name(&folder);
if paths::stem(&path) != format!("{fname} - {DEFAULT_VERSION_LABEL}") {
continue;
}
let target = folder.join(format!("{fname}{}", paths::suffix(&path)));
if target.exists() {
continue;
}
self.relocate_file(&path, &target, true)?;
self.with_file(&target, |f, tm, index| f.save_location(tm, index))?;
renamed.push((paths::name(&path), paths::name(&target)));
}
Ok(renamed)
}
/// Remove one version (+ subtitles). When one version is left the movie is
/// demoted back to a flat file (keeping a 3D tag if the survivor was 3D).
pub fn remove_movie_version(&mut self, path: &Path) -> CoreResult<()> {
if !self.is_movie_group_member(path) {
return Err(CoreError::invalid(
"Film nemá žádné další verze k odebrání.",
));
}
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
self.with_file(path, |f, _, index| f.delete_metadata(index))?;
for sub in subtitles::find_subtitles(path) {
fs::remove_file(&sub.path)?;
}
fs::remove_file(path)?;
self.filelist.retain(|f| f.file_path != path);
let remaining: Vec<PathBuf> = paths::entries(&folder)
.into_iter()
.filter(|p| p.is_file() && is_media_file(p))
.collect();
if remaining.len() == 1 {
let sole = &remaining[0];
let fname = paths::name(&folder);
let prefix = format!("{fname} - ");
let sole_stem = paths::stem(sole);
let label = sole_stem.strip_prefix(&prefix).unwrap_or("");
let new_stem = match normalize_3d_label(label) {
Some(fmt) => format!("{fname}.3D.{fmt}"),
None => fname.clone(),
};
let new_path = folder
.parent()
.unwrap_or(Path::new("."))
.join(format!("{new_stem}{}", paths::suffix(sole)));
self.relocate_file(sole, &new_path, true)?;
self.with_file(&new_path, |f, tm, index| f.save_location(tm, index))?;
let _ = fs::remove_dir_all(&folder);
}
Ok(())
}
/// Delete a whole movie — every version, subtitle and record.
pub fn remove_movie(&mut self, path: &Path) -> CoreResult<()> {
let versions = self.list_movie_versions(path);
let grouped = self.is_movie_group_member(path);
for v in &versions {
self.with_file(v, |f, _, index| f.delete_metadata(index))?;
for sub in subtitles::find_subtitles(v) {
let _ = fs::remove_file(&sub.path);
}
let _ = fs::remove_file(v);
self.filelist.retain(|f| &f.file_path != v);
}
if grouped && let Some(folder) = path.parent() {
let _ = fs::remove_dir_all(folder);
}
Ok(())
}
/// Fold the duplicate movie `other` into `keep` as one more version. `keep`'s
/// metadata wins; `other` only contributes missing tags and a ČSFD link.
pub fn merge_as_version(
&mut self,
keep: &Path,
other: &Path,
label: &str,
keep_label: Option<&str>,
) -> CoreResult<PathBuf> {
if keep == other {
return Err(CoreError::invalid("Nelze sloučit film sám se sebou."));
}
if self.is_movie_group_member(other) {
return Err(CoreError::invalid(
"Slučovaný film už má více verzí — slouč opačně, nebo verze nejdřív odeber.",
));
}
if self.is_movie_group_member(keep) && keep.parent() == other.parent() {
return Err(CoreError::invalid(
"Oba soubory už jsou verzemi téhož filmu.",
));
}
let other_file = self
.file(other)
.cloned()
.unwrap_or_else(|| File::bare(other));
self.with_file(keep, |k, _, index| {
for tag in &other_file.tags {
if !k.tags.contains(tag) {
k.tags.push(tag.clone());
if other_file.auto_tag_paths.contains(&tag.full_path()) {
k.auto_tag_paths.insert(tag.full_path());
}
}
}
if k.csfd_url.is_none() && other_file.csfd_url.is_some() {
k.csfd_url = other_file.csfd_url.clone();
}
k.save_metadata(index)
})?;
self.with_file(other, |f, _, index| f.delete_metadata(index))?;
let new_version = match self.add_movie_version(keep, other, label, true, keep_label) {
Ok(p) => p,
Err(e) => {
let _ = self.with_file(other, |f, _, index| f.save_metadata(index));
return Err(e);
}
};
subtitles::rename_subtitles_with(other, &new_version)?;
self.filelist.retain(|f| f.file_path != other);
Ok(new_version)
}
}
#[cfg(test)]
mod tests {
use super::super::movies::ImportOptions;
use super::super::test_support::Fixture;
use super::*;
use crate::core::file_manager::Library;
const STEM: &str = "Hobit (2013) [tmdbid-49051]";
fn hobbit(fx: &mut Fixture) -> PathBuf {
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
..Default::default()
};
fx.fm
.import_movie(&fx.source("h1.mkv"), "Hobit", opts)
.unwrap()
.unwrap()
}
fn folder(fx: &Fixture) -> PathBuf {
fx.pool().join("Filmy").join(STEM)
}
#[test]
fn test_version_filename_variants() {
assert_eq!(version_filename("S", "", ".mkv"), "S.mkv");
assert_eq!(
version_filename("S", DEFAULT_VERSION_LABEL, ".mkv"),
"S.mkv"
);
assert_eq!(version_filename("S", "CD2", ".mkv"), "S-part2.mkv");
assert_eq!(version_filename("S", "3D HSBS", ".mkv"), "S - 3D HSBS.mkv");
}
#[test]
fn test_add_version_promotes_flat_movie_with_subtitles() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
assert!(!fx.fm.is_movie_group_member(&movie));
fs::write(fx.pool().join(format!("Filmy/{STEM}.cs.srt")), "s").unwrap();
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "3D HSBS", false, None)
.unwrap();
assert_eq!(v2, folder(&fx).join(format!("{STEM} - 3D HSBS.mkv")));
let promoted = folder(&fx).join(format!("{STEM}.mkv"));
assert!(promoted.exists());
assert!(folder(&fx).join(format!("{STEM}.cs.srt")).exists());
assert!(fx.fm.is_movie_group_member(&promoted));
assert_eq!(fx.fm.list_movie_versions(&promoted).len(), 2);
assert_eq!(fx.fm.visible_movies(), vec![promoted.clone()]);
assert!(matches!(
fx.fm
.add_movie_version(&promoted, &fx.source("h3.mkv"), "3D HSBS", false, None),
Err(CoreError::Exists(_))
));
// empty label → Výchozí → bare name, already taken
assert!(
fx.fm
.add_movie_version(&promoted, &fx.source("h4.mkv"), "", false, None)
.is_err()
);
}
#[test]
fn test_import_existing_film_adds_version_or_refuses() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "3D HSBS", false, None)
.unwrap();
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
on_conflict: super::super::OnConflict::Version,
version_label: Some("4K".into()),
..Default::default()
};
let p = fx
.fm
.import_movie(&fx.source("h5.mkv"), "Hobit", opts.clone())
.unwrap()
.unwrap();
assert_eq!(p, folder(&fx).join(format!("{STEM} - 4K.mkv")));
let plain = ImportOptions {
version_label: None,
..opts.clone()
};
assert!(matches!(
fx.fm.import_movie(&fx.source("h8.mkv"), "Hobit", plain),
Err(CoreError::Exists(_))
));
let ftab = ImportOptions {
version_label: None,
format_3d: Some("FTAB".into()),
..opts
};
let p3 = fx
.fm
.import_movie(&fx.source("h6.mkv"), "Hobit", ftab)
.unwrap()
.unwrap();
assert_eq!(paths::name(&p3), format!("{STEM} - 3D FTAB.mkv"));
let root_files: Vec<PathBuf> = paths::entries(&fx.pool().join("Filmy"))
.into_iter()
.filter(|p| p.is_file())
.collect();
assert!(root_files.is_empty());
}
#[test]
fn test_remove_version_demotes_and_keeps_3d_and_metadata() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.assign_tag(std::slice::from_ref(&movie), "Žánr/Fantasy")
.unwrap();
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "3D HSBS", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
fx.fm.remove_movie_version(&primary).unwrap();
let flat = fx.pool().join(format!("Filmy/{STEM}.3D.HSBS.mkv"));
assert!(flat.exists());
assert!(!folder(&fx).exists());
assert!(!v2.exists());
let f = fx.fm.file(&flat).unwrap();
assert!(f.tag_paths().contains("Žánr/Fantasy"));
assert!(fx.fm.remove_movie_version(&flat).is_err());
}
#[test]
fn test_grouped_movie_refuses_rename_and_move_but_allows_metadata() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
assert!(fx.fm.rename_movie(&primary, "Jiný").is_err());
assert!(fx.fm.move_to_library(&primary, "Filmy").is_err());
assert_eq!(fx.fm.rename_to_canonical(&primary).unwrap(), None);
let edits = super::super::movies::MovieEdits {
title: "Hobit".into(),
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
date: Some("2026-02-02".into()),
..Default::default()
};
fx.fm.apply_movie_edits(&primary, edits).unwrap();
let bad = super::super::movies::MovieEdits {
title: "Jiný".into(),
year: Some("2013".into()),
..Default::default()
};
assert!(fx.fm.apply_movie_edits(&primary, bad).is_err());
}
#[test]
fn test_rename_movie_version_cases() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&v2), "Žánr/Fantasy")
.unwrap();
let renamed = fx.fm.rename_movie_version(&v2, "Režisérská verze").unwrap();
assert_eq!(
paths::name(&renamed),
format!("{STEM} - Režisérská verze.mkv")
);
assert!(
fx.fm
.file(&renamed)
.unwrap()
.tag_paths()
.contains("Žánr/Fantasy")
);
assert_eq!(
fx.fm
.rename_movie_version(&renamed, "Režisérská verze")
.unwrap(),
renamed
);
assert!(fx.fm.rename_movie_version(&renamed, "").is_err()); // collides with Výchozí
assert!(fx.fm.rename_movie_version(&renamed, "part2").is_err()); // mixes
let flat = fx
.fm
.import_movie(
&fx.source("o.mkv"),
"Other",
ImportOptions {
year: Some("2000".into()),
..Default::default()
},
)
.unwrap()
.unwrap();
assert!(fx.fm.rename_movie_version(&flat, "x").is_err());
}
#[test]
fn test_parts_add_order_move_and_mode_switch() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
let p2 = fx
.fm
.add_movie_part(&movie, &fx.source("h2.mkv"), false)
.unwrap();
let p1 = folder(&fx).join(format!("{STEM}-part1.mkv"));
assert!(p1.exists());
assert_eq!(paths::name(&p2), format!("{STEM}-part2.mkv"));
let p3 = fx
.fm
.add_movie_part(&p1, &fx.source("h3.mkv"), false)
.unwrap();
assert_eq!(paths::name(&p3), format!("{STEM}-part3.mkv"));
assert_eq!(fx.fm.movie_group_mode(&p1), GroupMode::Parts);
assert!(
fx.fm
.add_movie_version(&p1, &fx.source("x.mkv"), "4K", false, None)
.is_err()
);
fs::write(folder(&fx).join(format!("{STEM}-part1.cs.srt")), "one").unwrap();
fx.fm.move_movie_part(&p1, 1).unwrap();
// the old part1 content is now part2, with its subtitle
assert_eq!(
fs::read_to_string(folder(&fx).join(format!("{STEM}-part2.mkv"))).unwrap(),
"h1.mkv"
);
assert_eq!(
fs::read_to_string(folder(&fx).join(format!("{STEM}-part2.cs.srt"))).unwrap(),
"one"
);
fx.fm
.move_movie_part(&folder(&fx).join(format!("{STEM}-part1.mkv")), -1)
.unwrap(); // beyond the end: no-op
let any = folder(&fx).join(format!("{STEM}-part1.mkv"));
assert!(
fx.fm
.set_movie_group_mode(&any, GroupMode::Versions, &["a".into()])
.is_err()
);
assert!(
fx.fm
.set_movie_group_mode(&any, GroupMode::Versions, &["a".into(), "A".into()])
.is_err()
);
fx.fm
.set_movie_group_mode(&any, GroupMode::Versions, &["4K".into(), "3D HSBS".into()])
.unwrap();
let first = folder(&fx).join(format!("{STEM}.mkv"));
assert_eq!(fx.fm.movie_group_mode(&first), GroupMode::Versions);
fx.fm
.set_movie_group_mode(&first, GroupMode::Parts, &[])
.unwrap();
assert_eq!(
fx.fm
.movie_group_mode(&folder(&fx).join(format!("{STEM}-part1.mkv"))),
GroupMode::Parts
);
}
#[test]
fn test_removing_down_to_one_part_demotes_to_flat() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
let p2 = fx
.fm
.add_movie_part(&movie, &fx.source("h2.mkv"), false)
.unwrap();
fx.fm.remove_movie_version(&p2).unwrap();
assert!(fx.pool().join(format!("Filmy/{STEM}.mkv")).exists());
}
#[test]
fn test_remove_movie_deletes_every_version() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
fx.fm.remove_movie(&primary).unwrap();
assert!(!folder(&fx).exists());
assert!(fx.fm.filelist.is_empty());
assert!(fx.fm.index.as_ref().unwrap().records.is_empty());
}
#[test]
fn test_merge_as_version_carries_tags_csfd_and_subtitles() {
let mut fx = Fixture::new();
let keep = hobbit(&mut fx);
let other = fx
.fm
.import_movie(
&fx.source("dup.mkv"),
"Hobit kopie",
ImportOptions::default(),
)
.unwrap()
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&other), "Žánr/Fantasy")
.unwrap();
fx.fm
.with_file(&other, |f, _, index| {
f.csfd_url = Some("u".into());
f.save_metadata(index)
})
.unwrap();
fs::write(fx.pool().join("Filmy/Hobit kopie.cs.srt"), "s").unwrap();
let v = fx.fm.merge_as_version(&keep, &other, "4K", None).unwrap();
assert_eq!(v, folder(&fx).join(format!("{STEM} - 4K.mkv")));
assert!(folder(&fx).join(format!("{STEM} - 4K.cs.srt")).exists());
let primary = folder(&fx).join(format!("{STEM}.mkv"));
let f = fx.fm.file(&primary).unwrap();
assert!(f.tag_paths().contains("Žánr/Fantasy"));
assert_eq!(f.csfd_url.as_deref(), Some("u"));
assert_eq!(fx.fm.index.as_ref().unwrap().records.len(), 1);
assert!(
fx.fm
.merge_as_version(&primary, &primary, "x", None)
.is_err()
);
}
#[test]
fn test_mixed_library_version_folder_vs_series() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![Library::new("Mix", LibraryKind::Mixed)])
.unwrap();
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
library: Some("Mix".into()),
..Default::default()
};
let movie = fx
.fm
.import_movie(&fx.source("h1.mkv"), "Hobit", opts)
.unwrap()
.unwrap();
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
assert!(fx.fm.is_movie_group_member(&v2));
let ep = fx
.pool()
.join("Mix/Show (2010) [tmdbid-1]/Season 01/S01E01 - Show.mkv");
fs::create_dir_all(ep.parent().unwrap()).unwrap();
fs::write(&ep, "e").unwrap();
assert!(!fx.fm.is_movie_group_member(&ep));
fx.fm.load_pool_movies().unwrap();
assert_eq!(fx.fm.filelist.len(), 2);
}
#[test]
fn test_normalize_default_version_names() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
let old = folder(&fx).join(format!("{STEM} - Výchozí.mkv"));
fx.fm.relocate_file(&primary, &old, true).unwrap();
let renamed = fx.fm.normalize_default_version_names().unwrap();
assert_eq!(renamed, vec![(paths::name(&old), paths::name(&primary))]);
assert!(primary.exists());
}
}
-704
View File
@@ -1,704 +0,0 @@
"""
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)
"""
import os
import random
from collections import defaultdict
from pathlib import Path
from typing import List, Tuple, Optional, Dict, Set
from .file import File
from .naming import is_valid_year, parse_pool_stem
class _SafeDict(dict):
"""dict for str.format_map that leaves unknown fields as an empty string."""
def __missing__(self, key): # noqa: ANN001
return ""
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}``.
"""
def __init__(self, output_dir: Path):
"""
Initialize HardlinkManager.
Args:
output_dir: Base directory where the tag-based structure will be created
"""
self.output_dir = Path(output_dir)
self.created_links: List[Path] = []
self.errors: List[Tuple[Path, str]] = []
def _resolve_roots(
self,
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.
"""
if category_roots is not None:
return dict(category_roots)
if categories is not None:
return {cat: cat for cat in categories}
return None
def _clean_title(self, file_obj: File) -> str:
"""The movie's clean title (no trailing ``(YYYY)``), path-safe."""
ctx = file_obj.name_context()
title = str(ctx.get("title") or file_obj.file_path.stem or "").strip()
parsed = parse_pool_stem(title)
if parsed is not None:
title = parsed[0]
title = title.replace("/", "-").replace("\\", "-").strip()
return title or file_obj.file_path.stem
def _default_link_name(self, file_obj: File) -> str:
"""Filmotéka hardlink name: the clean title + extension (no year)."""
return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}"
def _year_link_name(self, file_obj: File) -> Optional[str]:
"""``Title (YYYY).ext`` used to disambiguate a name collision, or None."""
year = str(file_obj.name_context().get("year") or "").strip()
if not is_valid_year(year):
return None
return f"{self._clean_title(file_obj)} ({year}){file_obj.file_path.suffix}"
def _render_template(self, file_obj: File, template: str) -> str:
"""Render a per-category filename template; fall back to the clean name."""
try:
rendered = template.format_map(_SafeDict(file_obj.name_context()))
except (ValueError, KeyError, IndexError, AttributeError):
return self._default_link_name(file_obj)
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
return rendered or self._default_link_name(file_obj)
def _plan_links(
self,
files: List[File],
roots: Optional[Dict[str, str]],
transforms: Optional[Dict[str, str]],
templates: Optional[Dict[str, str]],
) -> List[Tuple[File, Path]]:
"""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).
"""
# (file_obj, target_dir, name, is_templated), one per file+folder
entries: List[List] = []
seen_fd: set[tuple[str, str]] = set()
for file_obj in files:
for tag in file_obj.tags:
target_dir = self._target_dir(tag, roots, transforms)
if target_dir is None:
continue
key = (str(file_obj.file_path), str(target_dir))
if key in seen_fd: # one link per movie per folder
continue
seen_fd.add(key)
template = templates.get(tag.category) if templates else None
if template:
entries.append(
[file_obj, target_dir, self._render_template(file_obj, template), True])
else:
entries.append(
[file_obj, target_dir, self._default_link_name(file_obj), False])
# Disambiguate default-named collisions (different movies) by their year.
groups: dict[tuple[Path, str], set[str]] = defaultdict(set)
for file_obj, target_dir, name, templated in entries:
if not templated:
groups[(target_dir, name)].add(str(file_obj.file_path))
for entry in entries:
file_obj, target_dir, name, templated = entry
if not templated and len(groups[(target_dir, name)]) > 1:
year_name = self._year_link_name(file_obj)
if year_name:
entry[2] = year_name
# Final pass: guarantee unique paths (stable numeric suffix on any tie).
by_path: dict[tuple[Path, str], List[List]] = defaultdict(list)
for entry in entries:
by_path[(entry[1], entry[2])].append(entry)
plan: List[Tuple[File, Path]] = []
for (target_dir, name), group in by_path.items():
if len(group) == 1:
plan.append((group[0][0], target_dir / name))
continue
ordered = sorted(group, key=lambda e: str(e[0].file_path))
stem, suffix = Path(name).stem, Path(name).suffix
for i, entry in enumerate(ordered):
fname = name if i == 0 else f"{stem}_{i + 1}{suffix}"
plan.append((entry[0], target_dir / fname))
return plan
def _folder_value(self, 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.
"""
if transforms and tag.category in transforms:
from .transforms import apply_transform
return apply_transform(tag.name, transforms[tag.category])
return tag.name
def _target_dir(
self, tag, roots: Optional[Dict[str, str]],
transforms: Optional[Dict[str, str]] = None,
) -> Optional[Path]:
"""Output directory for a tag, or None if its category is excluded."""
if roots is None:
folder = tag.category
elif tag.category in roots:
folder = roots[tag.category]
else:
return None
base = self.output_dir / folder if folder else self.output_dir
return base / self._folder_value(tag, transforms)
def _managed_top_dirs(
self, files: List[File], roots: Optional[Dict[str, str]],
transforms: Optional[Dict[str, str]] = None,
reserved_subfolders: Optional[Set[str]] = None,
) -> Optional[Set[str]]:
"""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.
"""
if roots is None:
return None
grouping_roots = {folder for folder in roots.values() if folder}
has_root_level = any(not folder for folder in roots.values())
tops: Set[str] = set(grouping_roots)
# genre folders currently present on the movies
for cat, folder in roots.items():
if not folder:
for file_obj in files:
for tag in file_obj.tags:
if tag.category == cat:
tops.add(self._folder_value(tag, transforms))
# plus any other root-level folder (catches genres that lost their last
# movie), minus grouping roots and reserved (mirrors / Tipy dne / …)
if has_root_level and self.output_dir.exists():
reserved = set(reserved_subfolders or set())
for entry in self.output_dir.iterdir():
if (entry.is_dir() and entry.name not in grouping_roots
and entry.name not in reserved):
tops.add(entry.name)
return tops
def create_structure_for_files(
self,
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)
"""
self.created_links = []
self.errors = []
roots = self._resolve_roots(categories, category_roots)
success_count = 0
fail_count = 0
plan = self._plan_links(
files, roots, category_transforms, category_filename_templates)
for file_obj, target_file in plan:
try:
if not dry_run:
target_file.parent.mkdir(parents=True, exist_ok=True)
if target_file.exists():
# Already the right link → nothing to do (not re-counted).
if self._is_same_file(file_obj.file_path, target_file):
continue
# A wrong occupant sits on the planned path; the plan is
# authoritative, so replace it.
target_file.unlink()
os.link(file_obj.file_path, target_file)
self.created_links.append(target_file)
success_count += 1
except OSError as e:
self.errors.append((file_obj.file_path, str(e)))
fail_count += 1
return success_count, fail_count
def mirror_as_is(
self,
source_dir: Path,
subfolder: str | None = None,
dry_run: bool = False
) -> Tuple[int, int]:
"""Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.
Recreates the exact directory hierarchy of ``source_dir`` under
``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)
"""
source_dir = Path(source_dir)
if not source_dir.is_dir():
return 0, 0
base = self.output_dir / subfolder if subfolder else self.output_dir
success_count = 0
fail_count = 0
for src_file in source_dir.rglob("*"):
if not src_file.is_file():
continue
if src_file.name.endswith((".!tag", ".!ftag", ".!gtag", ".!index")):
continue
target_file = base / src_file.relative_to(source_dir)
try:
if not dry_run:
target_file.parent.mkdir(parents=True, exist_ok=True)
if target_file.exists():
if self._is_same_file(src_file, target_file):
success_count += 1
continue
target_file.unlink()
os.link(src_file, target_file)
self.created_links.append(target_file)
success_count += 1
except OSError as e:
self.errors.append((src_file, str(e)))
fail_count += 1
return success_count, fail_count
def _fill_special_folder(
self, chosen: List[File], subfolder: str, dry_run: bool
) -> Tuple[int, int]:
"""Empty ``subfolder`` and hardlink ``chosen`` files into it."""
base = self.output_dir / subfolder
if not dry_run and base.exists():
for child in base.iterdir():
if child.is_file() or child.is_symlink():
try:
child.unlink()
except OSError:
pass
created = 0
fail = 0
for file_obj in chosen:
target = base / self._default_link_name(file_obj)
try:
if not dry_run:
base.mkdir(parents=True, exist_ok=True)
if target.exists():
if self._is_same_file(file_obj.file_path, target):
created += 1
continue
# Collision with another movie: prefer the year-qualified
# name, else a numeric suffix.
year_name = self._year_link_name(file_obj)
if year_name and not (base / year_name).exists():
target = base / year_name
else:
target = self._get_unique_name(target)
os.link(file_obj.file_path, target)
self.created_links.append(target)
created += 1
except OSError as e:
self.errors.append((file_obj.file_path, str(e)))
fail += 1
return created, fail
def generate_recently_added(
self,
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).
"""
chosen = sorted(files, key=lambda f: f.added_timestamp(), reverse=True)[:count]
return self._fill_special_folder(chosen, subfolder, dry_run)
def generate_random_tips(
self,
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)
"""
chosen = random.sample(files, min(count, len(files)))
return self._fill_special_folder(chosen, subfolder, dry_run)
def _is_same_file(self, path1: Path, path2: Path) -> bool:
"""Check if two paths point to the same file (same inode)."""
try:
return path1.stat().st_ino == path2.stat().st_ino
except OSError:
return False
def _get_unique_name(self, path: Path) -> Path:
"""Get a unique filename by adding a numeric suffix."""
stem = path.stem
suffix = path.suffix
parent = path.parent
counter = 1
while True:
new_name = f"{stem}_{counter}{suffix}"
new_path = parent / new_name
if not new_path.exists():
return new_path
counter += 1
def remove_created_links(self) -> int:
"""
Remove all hardlinks created by the last operation.
Returns:
Number of links removed
"""
removed = 0
for link_path in self.created_links:
try:
if link_path.exists() and link_path.is_file():
link_path.unlink()
removed += 1
# Try to remove empty parent directories
self._remove_empty_parents(link_path.parent)
except OSError:
pass
self.created_links = []
return removed
def _remove_empty_parents(self, path: Path) -> None:
"""Remove empty parent directories up to output_dir."""
try:
while path != self.output_dir and path.is_dir():
if any(path.iterdir()):
break # Directory not empty
path.rmdir()
path = path.parent
except OSError:
pass
def get_preview(
self,
files: List[File],
categories: Optional[List[str]] = None,
category_roots: Optional[Dict[str, str]] = None,
category_transforms: Optional[Dict[str, str]] = None,
category_filename_templates: Optional[Dict[str, str]] = None,
) -> List[Tuple[Path, Path]]:
"""
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)
"""
roots = self._resolve_roots(categories, category_roots)
plan = self._plan_links(
files, roots, category_transforms, category_filename_templates)
return [(file_obj.file_path, target_file) for file_obj, target_file in plan]
def find_obsolete_links(
self,
files: List[File],
categories: Optional[List[str]] = None,
category_roots: Optional[Dict[str, str]] = None,
category_transforms: Optional[Dict[str, str]] = None,
category_filename_templates: Optional[Dict[str, str]] = None,
reserved_subfolders: Optional[Set[str]] = None,
) -> List[Tuple[Path, Path]]:
"""
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
"""
obsolete: List[Tuple[Path, Path]] = []
if not self.output_dir.exists():
return obsolete
roots = self._resolve_roots(categories, category_roots)
# Build a map of source file inodes to File objects
inode_to_file: dict[int, File] = {}
for file_obj in files:
try:
inode = file_obj.file_path.stat().st_ino
inode_to_file[inode] = file_obj
except OSError:
continue
# Every link the tag tree *should* contain, mapped to the inode(s) it may
# point at — taken from the very same plan that creates the links, so the
# two never disagree on a name. Matching by inode too — not just the path
# — is what catches an **orphan that squats the expected name**: a link
# whose name is right but which points to an old inode (movie
# re-imported/replaced) is still obsolete, while the correctly created
# link is *not* wrongly swept.
expected: dict[Path, set[int]] = {}
for file_obj, path in self._plan_links(
files, roots, category_transforms, category_filename_templates
):
try:
expected.setdefault(path, set()).add(file_obj.file_path.stat().st_ino)
except OSError:
continue
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors).
# Inside them, any file that isn't an expected link is obsolete — whether
# a tag was removed, the movie was replaced (orphan inode), or the folder
# itself is a leftover from a renamed layout.
top_dirs = self._managed_top_dirs(
files, roots, category_transforms, reserved_subfolders)
for top in self.output_dir.iterdir():
if not top.is_dir():
continue
if top_dirs is not None and top.name not in top_dirs:
continue
for link_file in top.rglob("*"):
if not link_file.is_file():
continue
try:
link_inode = link_file.stat().st_ino
except OSError:
continue
# Valid only when both the path *and* the inode match a current
# movie; a name-only match (orphan squatting the name) is stale.
if link_inode in expected.get(link_file, ()):
continue
source = inode_to_file.get(link_inode)
obsolete.append(
(link_file, source.file_path if source else link_file)
)
return obsolete
def remove_obsolete_links(
self,
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)
"""
obsolete = self.find_obsolete_links(
files, categories, category_roots, category_transforms,
category_filename_templates, reserved_subfolders)
removed_paths = []
if dry_run:
return len(obsolete), [link for link, _ in obsolete]
for link_path, _ in obsolete:
try:
link_path.unlink()
removed_paths.append(link_path)
# Try to remove empty parent directories
self._remove_empty_parents(link_path.parent)
except OSError:
pass
return len(removed_paths), removed_paths
def sync_structure(
self,
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)
"""
# First find how many obsolete links there are
obsolete_count = len(self.find_obsolete_links(
files, categories, category_roots, category_transforms,
category_filename_templates, reserved_subfolders))
# Remove obsolete links
removed, removed_paths = self.remove_obsolete_links(
files, categories, dry_run, category_roots, category_transforms,
category_filename_templates, reserved_subfolders
)
remove_failed = obsolete_count - removed if not dry_run else 0
# Then create new links
created, create_failed = self.create_structure_for_files(
files, categories, dry_run, category_roots, category_transforms,
category_filename_templates
)
return created, create_failed, removed, remove_failed
def create_hardlink_structure(
files: List[File],
output_dir: Path,
categories: Optional[List[str]] = None
) -> Tuple[int, int, List[Tuple[Path, str]]]:
"""
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)
"""
manager = HardlinkManager(output_dir)
success, fail = manager.create_structure_for_files(files, categories)
return success, fail, manager.errors
+926
View File
@@ -0,0 +1,926 @@
//! Builds the miniDLNA / Filmotéka tree: a directory structure derived from the
//! movies' tags, made only of **hardlinks** into the pool (no data duplicated).
//!
//! ```text
//! output/
//! ├── Komedie/film.mkv (category at the root, e.g. genres)
//! └── - Dle roku/1988/film.mkv (category under a grouping folder)
//! ```
//!
//! The layout is driven by a *category → root folder* map: each tag lands at
//! `output/<root>/<tag value>/<file>`; an empty root puts the tag's folders
//! directly at the output root.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use file_id::FileId;
use indexmap::IndexMap;
use rand::seq::IndexedRandom;
use super::file::File;
use super::naming::{is_valid_year, parse_pool_stem};
use super::paths;
use super::transforms::apply_transform;
/// Curator metadata files that never belong in the output.
const METADATA_SUFFIXES: &[&str] = &[".!tag", ".!ftag", ".!gtag", ".!index"];
/// Which categories get folders, where, and how they are named.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TreeSpec {
/// Category → root folder. `None` = every category, folder == category name.
pub roots: Option<IndexMap<String, String>>,
/// Category → transform name applied to the folder name (e.g. rating bands).
pub transforms: HashMap<String, String>,
/// Category → hardlink-name template used inside that category's folders.
pub templates: HashMap<String, String>,
}
impl TreeSpec {
/// Only `categories`, each in a folder of its own name.
pub fn categories(categories: &[&str]) -> Self {
Self {
roots: Some(
categories
.iter()
.map(|c| (c.to_string(), c.to_string()))
.collect(),
),
..Default::default()
}
}
/// An explicit category → root map.
pub fn with_roots(roots: IndexMap<String, String>) -> Self {
Self {
roots: Some(roots),
..Default::default()
}
}
}
pub fn file_id(path: &Path) -> Option<FileId> {
file_id::get_file_id(path).ok()
}
/// True when both paths are the same file on disk (same inode).
pub fn is_same_file(a: &Path, b: &Path) -> bool {
match (file_id(a), file_id(b)) {
(Some(x), Some(y)) => x == y,
_ => false,
}
}
/// Render a Python `str.format_map` style template: `{field}` (an optional
/// `:spec` / `!conv` is ignored), `{{` / `}}` escapes, unknown fields → "".
/// `None` when the braces are malformed.
pub fn render_template(template: &str, ctx: &BTreeMap<String, String>) -> Option<String> {
let mut out = String::new();
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' if chars.peek() == Some(&'{') => {
chars.next();
out.push('{');
}
'}' if chars.peek() == Some(&'}') => {
chars.next();
out.push('}');
}
'}' => return None,
'{' => {
let mut field = String::new();
loop {
match chars.next()? {
'}' => break,
'{' => return None,
ch => field.push(ch),
}
}
let name = field.split([':', '!']).next().unwrap_or("").trim();
out.push_str(ctx.get(name).map(String::as_str).unwrap_or(""));
}
_ => out.push(c),
}
}
Some(out)
}
struct Entry<'a> {
file: &'a File,
dir: PathBuf,
name: String,
templated: bool,
}
pub struct HardlinkManager {
pub output_dir: PathBuf,
pub created_links: Vec<PathBuf>,
pub errors: Vec<(PathBuf, String)>,
}
impl HardlinkManager {
pub fn new(output_dir: &Path) -> Self {
Self {
output_dir: output_dir.to_path_buf(),
created_links: Vec::new(),
errors: Vec::new(),
}
}
/// The movie's clean title (no trailing `(YYYY)`), path-safe.
fn clean_title(&self, file: &File) -> String {
let ctx = file.name_context();
let stem = paths::stem(&file.file_path);
let mut title = ctx
.get("title")
.cloned()
.filter(|t| !t.is_empty())
.unwrap_or_else(|| stem.clone())
.trim()
.to_string();
if let Some((t, _)) = parse_pool_stem(&title) {
title = t;
}
let title = title.replace(['/', '\\'], "-").trim().to_string();
if title.is_empty() { stem } else { title }
}
/// Default hardlink name: the clean title + extension (no year).
fn default_link_name(&self, file: &File) -> String {
format!(
"{}{}",
self.clean_title(file),
paths::suffix(&file.file_path)
)
}
/// `Title (YYYY).ext`, used to disambiguate a name collision.
fn year_link_name(&self, file: &File) -> Option<String> {
let year = file.name_context().get("year").cloned().unwrap_or_default();
let year = year.trim();
is_valid_year(year).then(|| {
format!(
"{} ({year}){}",
self.clean_title(file),
paths::suffix(&file.file_path)
)
})
}
fn render(&self, file: &File, template: &str) -> String {
match render_template(template, &file.name_context()) {
Some(r) => {
let r = r.replace(['/', '\\'], "-").trim().to_string();
if r.is_empty() {
self.default_link_name(file)
} else {
r
}
}
None => self.default_link_name(file),
}
}
fn folder_value(&self, category: &str, value: &str, spec: &TreeSpec) -> String {
match spec.transforms.get(category) {
Some(t) => apply_transform(value, Some(t)),
None => value.to_string(),
}
}
/// Output directory for a tag, or `None` if its category is excluded.
fn target_dir(&self, category: &str, value: &str, spec: &TreeSpec) -> Option<PathBuf> {
let folder = match &spec.roots {
None => category.to_string(),
Some(roots) => roots.get(category)?.clone(),
};
let base = if folder.is_empty() {
self.output_dir.clone()
} else {
self.output_dir.join(folder)
};
Some(base.join(self.folder_value(category, value, spec)))
}
/// Resolve the tag tree to a collision-free `(file, link path)` plan.
///
/// Default names are the clean title; when two *different* movies collide
/// in one folder they fall back to `Title (YYYY).ext`; a residual clash gets
/// a stable numeric suffix. Link creation and obsolete detection both use
/// this one plan, so they can never disagree on a name.
fn plan_links<'a>(&self, files: &'a [File], spec: &TreeSpec) -> Vec<(&'a File, PathBuf)> {
let mut entries: Vec<Entry<'a>> = Vec::new();
let mut seen: HashSet<(PathBuf, PathBuf)> = HashSet::new();
for file in files {
for tag in &file.tags {
let Some(dir) = self.target_dir(&tag.category, &tag.name, spec) else {
continue;
};
if !seen.insert((file.file_path.clone(), dir.clone())) {
continue; // one link per movie per folder
}
match spec.templates.get(&tag.category).filter(|t| !t.is_empty()) {
Some(t) => entries.push(Entry {
file,
dir,
name: self.render(file, t),
templated: true,
}),
None => entries.push(Entry {
file,
dir,
name: self.default_link_name(file),
templated: false,
}),
}
}
}
let mut groups: HashMap<(PathBuf, String), HashSet<PathBuf>> = HashMap::new();
for e in entries.iter().filter(|e| !e.templated) {
groups
.entry((e.dir.clone(), e.name.clone()))
.or_default()
.insert(e.file.file_path.clone());
}
for e in entries.iter_mut().filter(|e| !e.templated) {
if groups
.get(&(e.dir.clone(), e.name.clone()))
.is_some_and(|g| g.len() > 1)
&& let Some(year_name) = self.year_link_name(e.file)
{
e.name = year_name;
}
}
let mut by_path: IndexMap<(PathBuf, String), Vec<&Entry<'a>>> = IndexMap::new();
for e in &entries {
by_path
.entry((e.dir.clone(), e.name.clone()))
.or_default()
.push(e);
}
let mut plan = Vec::new();
for ((dir, name), mut group) in by_path {
if group.len() == 1 {
plan.push((group[0].file, dir.join(&name)));
continue;
}
group.sort_by(|a, b| a.file.file_path.cmp(&b.file.file_path));
let p = Path::new(&name);
let (stem, suffix) = (paths::stem(p), paths::suffix(p));
for (i, e) in group.into_iter().enumerate() {
let fname = if i == 0 {
name.clone()
} else {
format!("{stem}_{}{suffix}", i + 1)
};
plan.push((e.file, dir.join(fname)));
}
}
plan
}
/// Top-level output folders owned by the tag tree (`None` = all).
///
/// A root-level category (genres) owns its folders at the root — and so a
/// genre whose last movie dropped the tag still gets cleaned, every other
/// root-level folder counts as owned too, except grouping roots and
/// `reserved` (mirrors, "Tipy dne", …).
fn managed_top_dirs(
&self,
files: &[File],
spec: &TreeSpec,
reserved: &HashSet<String>,
) -> Option<HashSet<String>> {
let roots = spec.roots.as_ref()?;
let grouping: HashSet<String> = roots.values().filter(|f| !f.is_empty()).cloned().collect();
let has_root_level = roots.values().any(String::is_empty);
let mut tops = grouping.clone();
for (cat, folder) in roots {
if folder.is_empty() {
for file in files {
for tag in file.tags.iter().filter(|t| &t.category == cat) {
tops.insert(self.folder_value(&tag.category, &tag.name, spec));
}
}
}
}
if has_root_level && self.output_dir.exists() {
for entry in paths::subdirs(&self.output_dir) {
let name = paths::name(&entry);
if !grouping.contains(&name) && !reserved.contains(&name) {
tops.insert(name);
}
}
}
Some(tops)
}
/// Create the hardlink tree for `files`. Returns `(created, failed)`.
pub fn create_structure_for_files(
&mut self,
files: &[File],
spec: &TreeSpec,
dry_run: bool,
) -> (usize, usize) {
self.created_links.clear();
self.errors.clear();
let (mut ok, mut fail) = (0, 0);
for (file, target) in self.plan_links(files, spec) {
let result: io::Result<bool> = (|| {
if !dry_run {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
if target.exists() {
if is_same_file(&file.file_path, &target) {
return Ok(false); // already the right link
}
fs::remove_file(&target)?;
}
fs::hard_link(&file.file_path, &target)?;
}
Ok(true)
})();
match result {
Ok(true) => {
self.created_links.push(target);
ok += 1;
}
Ok(false) => {}
Err(e) => {
self.errors.push((file.file_path.clone(), e.to_string()));
fail += 1;
}
}
}
(ok, fail)
}
/// Mirror a "copy-as-is" folder 1:1 into `output/subfolder` as hardlinks.
pub fn mirror_as_is(
&mut self,
source_dir: &Path,
subfolder: Option<&str>,
dry_run: bool,
) -> (usize, usize) {
if !source_dir.is_dir() {
return (0, 0);
}
let base = match subfolder {
Some(s) if !s.is_empty() => self.output_dir.join(s),
_ => self.output_dir.clone(),
};
let (mut ok, mut fail) = (0, 0);
for src in paths::walk_files(source_dir) {
let name = paths::name(&src);
if METADATA_SUFFIXES.iter().any(|s| name.ends_with(s)) {
continue;
}
let Ok(rel) = src.strip_prefix(source_dir) else {
continue;
};
let target = base.join(rel);
let result: io::Result<bool> = (|| {
if !dry_run {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
if target.exists() {
if is_same_file(&src, &target) {
return Ok(false);
}
fs::remove_file(&target)?;
}
fs::hard_link(&src, &target)?;
}
Ok(true)
})();
match result {
Ok(created) => {
if created {
self.created_links.push(target);
}
ok += 1;
}
Err(e) => {
self.errors.push((src, e.to_string()));
fail += 1;
}
}
}
(ok, fail)
}
/// Remove `output/name` entirely (file or folder). `name` may contain "/".
pub fn remove_mirror(&self, name: &str) -> bool {
let target = paths::join_posix(&self.output_dir, name);
if !target.exists() {
return false;
}
let res = if target.is_dir() {
fs::remove_dir_all(&target)
} else {
fs::remove_file(&target)
};
res.is_ok()
}
/// Empty `subfolder` and hardlink `chosen` into it.
fn fill_special_folder(
&mut self,
chosen: &[&File],
subfolder: &str,
dry_run: bool,
) -> (usize, usize) {
let base = self.output_dir.join(subfolder);
if !dry_run && base.exists() {
for child in paths::entries(&base) {
if child.is_file() || child.is_symlink() {
let _ = fs::remove_file(&child);
}
}
}
let (mut ok, mut fail) = (0, 0);
for file in chosen {
let mut target = base.join(self.default_link_name(file));
let result: io::Result<bool> = (|| {
if !dry_run {
fs::create_dir_all(&base)?;
if target.exists() {
if is_same_file(&file.file_path, &target) {
return Ok(false);
}
target = match self.year_link_name(file) {
Some(y) if !base.join(&y).exists() => base.join(y),
_ => unique_name(&target),
};
}
fs::hard_link(&file.file_path, &target)?;
}
Ok(true)
})();
match result {
Ok(created) => {
if created {
self.created_links.push(target);
}
ok += 1;
}
Err(e) => {
self.errors.push((file.file_path.clone(), e.to_string()));
fail += 1;
}
}
}
(ok, fail)
}
/// (Re)fill `subfolder` with the `count` most recently added movies.
pub fn generate_recently_added(
&mut self,
files: &[File],
count: usize,
subfolder: &str,
dry_run: bool,
) -> (usize, usize) {
let mut sorted: Vec<&File> = files.iter().collect();
sorted.sort_by(|a, b| b.added_timestamp().total_cmp(&a.added_timestamp()));
sorted.truncate(count);
self.fill_special_folder(&sorted, subfolder, dry_run)
}
/// (Re)fill `subfolder` with `count` random movies.
pub fn generate_random_tips(
&mut self,
files: &[File],
count: usize,
subfolder: &str,
dry_run: bool,
) -> (usize, usize) {
let mut rng = rand::rng();
let chosen: Vec<&File> = files.sample(&mut rng, count.min(files.len())).collect();
self.fill_special_folder(&chosen, subfolder, dry_run)
}
/// Remove every link the last operation created (and emptied parents).
pub fn remove_created_links(&mut self) -> usize {
let mut removed = 0;
for link in std::mem::take(&mut self.created_links) {
if link.is_file() && fs::remove_file(&link).is_ok() {
removed += 1;
if let Some(parent) = link.parent() {
self.remove_empty_parents(parent);
}
}
}
removed
}
fn remove_empty_parents(&self, start: &Path) {
let mut path = start.to_path_buf();
while path != self.output_dir && path.is_dir() {
if !paths::is_empty_dir(&path) || fs::remove_dir(&path).is_err() {
break;
}
match path.parent() {
Some(p) => path = p.to_path_buf(),
None => break,
}
}
}
/// `(source, target)` pairs the tree would contain.
pub fn preview(&self, files: &[File], spec: &TreeSpec) -> Vec<(PathBuf, PathBuf)> {
self.plan_links(files, spec)
.into_iter()
.map(|(f, p)| (f.file_path.clone(), p))
.collect()
}
/// Links in the managed part of the output that no longer match the plan —
/// a removed tag, a replaced movie (orphan inode squatting the right name),
/// or a leftover folder. Returns `(link, source)` pairs.
pub fn find_obsolete_links(
&self,
files: &[File],
spec: &TreeSpec,
reserved: &HashSet<String>,
) -> Vec<(PathBuf, PathBuf)> {
if !self.output_dir.exists() {
return Vec::new();
}
let id_to_file: HashMap<FileId, &File> = files
.iter()
.filter_map(|f| file_id(&f.file_path).map(|id| (id, f)))
.collect();
let mut expected: HashMap<PathBuf, HashSet<FileId>> = HashMap::new();
for (file, path) in self.plan_links(files, spec) {
if let Some(id) = file_id(&file.file_path) {
expected.entry(path).or_default().insert(id);
}
}
let tops = self.managed_top_dirs(files, spec, reserved);
let mut obsolete = Vec::new();
for top in paths::subdirs(&self.output_dir) {
if tops
.as_ref()
.is_some_and(|t| !t.contains(&paths::name(&top)))
{
continue;
}
for link in paths::walk_files(&top) {
let Some(id) = file_id(&link) else { continue };
if expected.get(&link).is_some_and(|ids| ids.contains(&id)) {
continue;
}
let source = id_to_file
.get(&id)
.map_or_else(|| link.clone(), |f| f.file_path.clone());
obsolete.push((link, source));
}
}
obsolete
}
/// Remove obsolete links. Returns `(removed, removed paths)`.
pub fn remove_obsolete_links(
&self,
files: &[File],
spec: &TreeSpec,
reserved: &HashSet<String>,
dry_run: bool,
) -> (usize, Vec<PathBuf>) {
let obsolete = self.find_obsolete_links(files, spec, reserved);
if dry_run {
return (
obsolete.len(),
obsolete.into_iter().map(|(l, _)| l).collect(),
);
}
let mut removed = Vec::new();
for (link, _) in obsolete {
if fs::remove_file(&link).is_ok() {
if let Some(parent) = link.parent() {
self.remove_empty_parents(parent);
}
removed.push(link);
}
}
(removed.len(), removed)
}
/// Remove obsolete links, then create missing ones.
/// Returns `(created, create_failed, removed, remove_failed)`.
pub fn sync_structure(
&mut self,
files: &[File],
spec: &TreeSpec,
reserved: &HashSet<String>,
dry_run: bool,
) -> (usize, usize, usize, usize) {
let obsolete_count = self.find_obsolete_links(files, spec, reserved).len();
let (removed, _) = self.remove_obsolete_links(files, spec, reserved, dry_run);
let remove_failed = if dry_run { 0 } else { obsolete_count - removed };
let (created, failed) = self.create_structure_for_files(files, spec, dry_run);
(created, failed, removed, remove_failed)
}
}
/// `stem_N.ext` with the first free N.
pub fn unique_name(path: &Path) -> PathBuf {
let (stem, suffix) = (paths::stem(path), paths::suffix(path));
let parent = path.parent().unwrap_or(Path::new("."));
(1..)
.map(|n| parent.join(format!("{stem}_{n}{suffix}")))
.find(|p| !p.exists())
.unwrap_or_else(|| path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::tag::Tag;
struct Env {
_dir: tempfile::TempDir,
src: PathBuf,
out: PathBuf,
}
fn env() -> Env {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("pool");
let out = dir.path().join("out");
fs::create_dir_all(&src).unwrap();
Env {
_dir: dir,
src,
out,
}
}
fn movie(dir: &Path, name: &str, tags: &[(&str, &str)]) -> File {
let p = dir.join(name);
fs::write(&p, name).unwrap();
let mut f = File::bare(&p);
f.tags = tags.iter().map(|(c, n)| Tag::new(*c, *n)).collect();
f
}
fn roots(pairs: &[(&str, &str)]) -> TreeSpec {
TreeSpec::with_roots(
pairs
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect(),
)
}
#[test]
fn test_render_template() {
let ctx: BTreeMap<String, String> =
[("title", "Matrix"), ("year", "1999"), ("ext", ".mkv")]
.into_iter()
.map(|(a, b)| (a.into(), b.into()))
.collect();
assert_eq!(
render_template("{year} - {title}{ext}", &ctx).as_deref(),
Some("1999 - Matrix.mkv")
);
assert_eq!(
render_template("{missing}{title}", &ctx).as_deref(),
Some("Matrix")
);
assert_eq!(
render_template("{{x}} {title:>10}", &ctx).as_deref(),
Some("{x} Matrix")
);
assert_eq!(render_template("{title", &ctx), None);
assert_eq!(render_template("title}", &ctx), None);
}
#[test]
fn test_create_structure_basic_and_same_inode() {
let e = env();
let files = vec![movie(
&e.src,
"Film.mkv",
&[("Žánr", "Komedie"), ("Žánr", "Akční"), ("Rok", "1988")],
)];
let mut m = HardlinkManager::new(&e.out);
let (ok, fail) = m.create_structure_for_files(&files, &TreeSpec::default(), false);
assert_eq!((ok, fail), (3, 0));
let link = e.out.join("Žánr/Komedie/Film.mkv");
assert!(is_same_file(&link, &files[0].file_path));
assert!(e.out.join("Rok/1988/Film.mkv").exists());
// re-run creates nothing new
assert_eq!(
m.create_structure_for_files(&files, &TreeSpec::default(), false),
(0, 0)
);
}
#[test]
fn test_category_filter_and_roots() {
let e = env();
let files = vec![movie(
&e.src,
"Film.mkv",
&[("Žánr", "Komedie"), ("Rok", "1988")],
)];
let mut m = HardlinkManager::new(&e.out);
m.create_structure_for_files(
&files,
&roots(&[("Žánr", ""), ("Rok", "- Dle roku")]),
false,
);
assert!(e.out.join("Komedie/Film.mkv").exists());
assert!(e.out.join("- Dle roku/1988/Film.mkv").exists());
let e2 = env();
let mut m2 = HardlinkManager::new(&e2.out);
let files2 = vec![movie(
&e2.src,
"Film.mkv",
&[("Žánr", "Komedie"), ("Rok", "1988")],
)];
m2.create_structure_for_files(&files2, &TreeSpec::categories(&["Rok"]), false);
assert!(!e2.out.join("Žánr").exists());
assert!(e2.out.join("Rok/1988/Film.mkv").exists());
}
#[test]
fn test_link_name_strips_year_and_disambiguates() {
let e = env();
let mut a = movie(
&e.src,
"Matrix (1999) [tmdbid-603].mkv",
&[("Žánr", "Akční")],
);
a.title = Some("Matrix".into());
let mut b = movie(&e.src, "Matrix (2021).mkv", &[("Žánr", "Akční")]);
b.title = Some("Matrix".into());
let c = movie(&e.src, "Jiný (2000).mkv", &[("Žánr", "Akční")]);
let mut m = HardlinkManager::new(&e.out);
m.create_structure_for_files(&[a, b, c], &roots(&[("Žánr", "")]), false);
assert!(e.out.join("Akční/Matrix (1999).mkv").exists());
assert!(e.out.join("Akční/Matrix (2021).mkv").exists());
assert!(e.out.join("Akční/Jiný.mkv").exists());
}
#[test]
fn test_transform_and_template() {
let e = env();
let mut f = movie(
&e.src,
"Matrix (1999).mkv",
&[("Hodnocení", "87"), ("Rok", "1999")],
);
f.title = Some("Matrix".into());
let mut spec = roots(&[("Hodnocení", "- Dle hodnocení"), ("Rok", "- Dle roku")]);
spec.transforms
.insert("Hodnocení".into(), "decade_band".into());
spec.templates
.insert("Rok".into(), "{year} - {title}{ext}".into());
let files = vec![f];
let mut m = HardlinkManager::new(&e.out);
m.create_structure_for_files(&files, &spec, false);
assert!(e.out.join("- Dle hodnocení/80–89 %/Matrix.mkv").exists());
assert!(e.out.join("- Dle roku/1999/1999 - Matrix.mkv").exists());
assert!(
m.find_obsolete_links(&files, &spec, &HashSet::new())
.is_empty()
);
}
#[test]
fn test_sync_removes_dropped_tag_and_empty_root_genre() {
let e = env();
let mut files = vec![movie(
&e.src,
"Film.mkv",
&[("Žánr", "Komedie"), ("Žánr", "Drama")],
)];
let spec = roots(&[("Žánr", "")]);
let mut m = HardlinkManager::new(&e.out);
m.sync_structure(&files, &spec, &HashSet::new(), false);
assert!(e.out.join("Drama/Film.mkv").exists());
files[0].tags.retain(|t| t.name != "Drama");
let (created, failed, removed, rfail) =
m.sync_structure(&files, &spec, &HashSet::new(), false);
assert_eq!((created, failed, removed, rfail), (0, 0, 1, 0));
assert!(!e.out.join("Drama").exists());
assert!(e.out.join("Komedie/Film.mkv").exists());
}
#[test]
fn test_sync_leaves_reserved_mirror_untouched() {
let e = env();
let files = vec![movie(&e.src, "Film.mkv", &[("Žánr", "Komedie")])];
fs::create_dir_all(e.out.join("Seriály/X")).unwrap();
fs::write(e.out.join("Seriály/X/ep.mkv"), "x").unwrap();
let reserved: HashSet<String> = ["Seriály".to_string()].into_iter().collect();
let mut m = HardlinkManager::new(&e.out);
m.sync_structure(&files, &roots(&[("Žánr", "")]), &reserved, false);
assert!(e.out.join("Seriály/X/ep.mkv").exists());
}
#[test]
fn test_sync_replaces_orphan_squatting_expected_name() {
let e = env();
let files = vec![movie(&e.src, "Film.mkv", &[("Žánr", "Komedie")])];
fs::create_dir_all(e.out.join("Komedie")).unwrap();
fs::write(e.out.join("Komedie/Film.mkv"), "old copy").unwrap();
let mut m = HardlinkManager::new(&e.out);
let spec = roots(&[("Žánr", "")]);
m.sync_structure(&files, &spec, &HashSet::new(), false);
assert!(is_same_file(
&e.out.join("Komedie/Film.mkv"),
&files[0].file_path
));
}
#[test]
fn test_dry_run_creates_nothing_and_preview_lists() {
let e = env();
let files = vec![movie(&e.src, "Film.mkv", &[("Rok", "1988")])];
let mut m = HardlinkManager::new(&e.out);
assert_eq!(
m.create_structure_for_files(&files, &TreeSpec::default(), true),
(1, 0)
);
assert!(!e.out.exists());
assert_eq!(
m.preview(&files, &TreeSpec::default()),
vec![(files[0].file_path.clone(), e.out.join("Rok/1988/Film.mkv"))]
);
}
#[test]
fn test_remove_created_links_cleans_dirs() {
let e = env();
let files = vec![movie(&e.src, "Film.mkv", &[("Rok", "1988")])];
let mut m = HardlinkManager::new(&e.out);
m.create_structure_for_files(&files, &TreeSpec::default(), false);
assert_eq!(m.remove_created_links(), 1);
assert!(!e.out.join("Rok").exists());
}
#[test]
fn test_random_tips_and_recent() {
let e = env();
let mut files: Vec<File> = (0..5)
.map(|i| movie(&e.src, &format!("F{i}.mkv"), &[]))
.collect();
for (i, f) in files.iter_mut().enumerate() {
f.added = Some(format!("2026-01-0{}T00:00:00", i + 1));
}
let mut m = HardlinkManager::new(&e.out);
assert_eq!(m.generate_random_tips(&files, 3, "Tipy dne", false), (3, 0));
assert_eq!(paths::entries(&e.out.join("Tipy dne")).len(), 3);
assert_eq!(
m.generate_random_tips(&files, 10, "Tipy dne", false),
(5, 0)
);
m.generate_recently_added(&files, 2, "- Nově přidané", false);
let names: Vec<String> = paths::entries(&e.out.join("- Nově přidané"))
.iter()
.map(|p| paths::name(p))
.collect();
assert_eq!(names, vec!["F3.mkv", "F4.mkv"]);
}
#[test]
fn test_mirror_as_is_and_remove_mirror() {
let e = env();
let ser = e.src.join("Seriály/Show/Season 01");
fs::create_dir_all(&ser).unwrap();
fs::write(ser.join("S01E01.mkv"), "x").unwrap();
fs::write(e.src.join("Seriály/.Curator.!index"), "{}").unwrap();
let mut m = HardlinkManager::new(&e.out);
assert_eq!(
m.mirror_as_is(&e.src.join("Seriály"), Some("Seriály"), false),
(1, 0)
);
assert!(e.out.join("Seriály/Show/Season 01/S01E01.mkv").exists());
assert!(!e.out.join("Seriály/.Curator.!index").exists());
assert_eq!(m.mirror_as_is(&e.src.join("nope"), None, false), (0, 0));
assert!(m.remove_mirror("Seriály/Show"));
assert!(e.out.join("Seriály").exists());
assert!(!m.remove_mirror("Seriály/Show"));
}
#[test]
fn test_unique_name() {
let e = env();
let p = e.src.join("a.mkv");
fs::write(&p, "").unwrap();
fs::write(e.src.join("a_1.mkv"), "").unwrap();
assert_eq!(unique_name(&p), e.src.join("a_2.mkv"));
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Shared blocking HTTP client for the remote services.
use std::sync::LazyLock;
use std::time::Duration;
use reqwest::blocking::Client;
/// A browser-like user agent (ČSFD rejects obvious bots).
pub const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
static CLIENT: LazyLock<Client> = LazyLock::new(|| {
Client::builder()
.user_agent(USER_AGENT)
.cookie_store(true)
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new())
});
/// The process-wide client (connection pooling, the OS certificate store).
pub fn client() -> &'static Client {
&CLIENT
}
/// A fresh client with its own cookie jar (ČSFD's anti-bot cookie is per session).
pub fn new_session() -> Client {
Client::builder()
.user_agent(USER_AGENT)
.cookie_store(true)
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new())
}
/// An environment variable trimmed, `None` when unset or blank.
pub fn env_nonempty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
-114
View File
@@ -1,114 +0,0 @@
"""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.
"""
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
FFMPEG = "ffmpeg"
FFPROBE = "ffprobe"
# Cap a single stderr excerpt so a chatty decoder can't flood the report.
_MAX_ERROR_LEN = 300
@dataclass
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``.
"""
path: Path
ok: bool
error: str = ""
checked: bool = True
def ffmpeg_available(deep: bool = True) -> bool:
"""Is the required FFmpeg tool (ffmpeg for deep, ffprobe for quick) on PATH?"""
return shutil.which(FFMPEG if deep else FFPROBE) is not None
def 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 :class:`IntegrityResult`. ``checked=False`` when the check could not
be run at all (tool or file missing) — distinct from a real failure.
"""
path = Path(path)
if not path.is_file():
return IntegrityResult(path, ok=False, error="Soubor neexistuje", checked=False)
tool = FFMPEG if deep else FFPROBE
if shutil.which(tool) is None:
return IntegrityResult(
path, ok=False, error=f"{tool} není k dispozici", checked=False
)
if deep:
cmd = [FFMPEG, "-v", "error", "-xerror", "-i", str(path), "-f", "null", "-"]
else:
cmd = [
FFPROBE, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path),
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, errors="replace", timeout=timeout
)
except subprocess.TimeoutExpired:
return IntegrityResult(path, ok=False, error="Kontrola vypršela (timeout)")
except OSError as exc:
return IntegrityResult(path, ok=False, error=str(exc), checked=False)
stderr = (result.stderr or "").strip()
if deep:
ok = result.returncode == 0 and not stderr
else:
# Quick probe: a healthy file returns rc 0 and a "video" codec_type row.
ok = result.returncode == 0 and "video" in (result.stdout or "")
if not ok and not stderr:
stderr = "Žádný video stream / nečitelný kontejner"
if ok:
return IntegrityResult(path, ok=True)
return IntegrityResult(
path, ok=False, error=_first_error_line(stderr) or "Neznámá chyba dekódování"
)
def _first_error_line(stderr: str) -> str:
"""First non-empty stderr line, truncated — a compact reason for the report."""
for line in stderr.splitlines():
line = line.strip()
if line:
return line[:_MAX_ERROR_LEN]
return ""
+240
View File
@@ -0,0 +1,240 @@
//! Video integrity checking via FFmpeg.
//!
//! - **deep** (default): a full `ffmpeg` decode to the null muxer. Catches
//! mid-file data corruption, but reads and decodes the whole file.
//! - **quick**: an `ffprobe` container/stream probe. Fast but shallow.
//!
//! [`ffmpeg_available`] reports whether the tools are on the PATH so callers can
//! degrade gracefully.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
pub const FFMPEG: &str = "ffmpeg";
pub const FFPROBE: &str = "ffprobe";
/// Cap a single stderr excerpt so a chatty decoder can't flood the report.
const MAX_ERROR_LEN: usize = 300;
/// Outcome of checking one file. `checked == false` means the check couldn't
/// run (missing tool / file), which is *not* the same as "corrupted".
#[derive(Debug, Clone, PartialEq)]
pub struct IntegrityResult {
pub path: PathBuf,
pub ok: bool,
pub error: String,
pub checked: bool,
}
impl IntegrityResult {
fn ok(path: &Path) -> Self {
Self {
path: path.into(),
ok: true,
error: String::new(),
checked: true,
}
}
fn failed(path: &Path, error: impl Into<String>, checked: bool) -> Self {
Self {
path: path.into(),
ok: false,
error: error.into(),
checked,
}
}
}
/// Locate an executable on the PATH.
pub fn which(tool: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
let exe_names: Vec<String> = if cfg!(windows) {
vec![format!("{tool}.exe"), tool.to_string()]
} else {
vec![tool.to_string()]
};
std::env::split_paths(&path_var)
.flat_map(|dir| exe_names.iter().map(move |n| dir.join(n)))
.find(|p| p.is_file())
}
/// Is the required tool (ffmpeg for deep, ffprobe for quick) on PATH?
pub fn ffmpeg_available(deep: bool) -> bool {
which(if deep { FFMPEG } else { FFPROBE }).is_some()
}
/// Output of a finished process; `None` stdout/stderr on timeout.
pub(crate) struct ProcOutput {
pub status_ok: bool,
pub stdout: String,
pub stderr: String,
}
/// Run `cmd` capturing stdout/stderr, killing it after `timeout`.
pub(crate) fn run_with_timeout(
mut cmd: Command,
timeout: Option<Duration>,
) -> std::io::Result<Option<ProcOutput>> {
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut out_pipe = child.stdout.take();
let mut err_pipe = child.stderr.take();
let out_t = thread::spawn(move || {
let mut buf = Vec::new();
if let Some(p) = out_pipe.as_mut() {
let _ = p.read_to_end(&mut buf);
}
buf
});
let err_t = thread::spawn(move || {
let mut buf = Vec::new();
if let Some(p) = err_pipe.as_mut() {
let _ = p.read_to_end(&mut buf);
}
buf
});
let start = Instant::now();
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if timeout.is_some_and(|t| start.elapsed() >= t) {
let _ = child.kill();
let _ = child.wait();
return Ok(None);
}
thread::sleep(Duration::from_millis(50));
};
let stdout = String::from_utf8_lossy(&out_t.join().unwrap_or_default()).into_owned();
let stderr = String::from_utf8_lossy(&err_t.join().unwrap_or_default()).into_owned();
Ok(Some(ProcOutput {
status_ok: status.success(),
stdout,
stderr,
}))
}
/// Check a single video file for corruption.
pub fn check_video_integrity(
path: &Path,
deep: bool,
timeout: Option<Duration>,
) -> IntegrityResult {
if !path.is_file() {
return IntegrityResult::failed(path, "Soubor neexistuje", false);
}
let tool = if deep { FFMPEG } else { FFPROBE };
let Some(exe) = which(tool) else {
return IntegrityResult::failed(path, format!("{tool} není k dispozici"), false);
};
let mut cmd = Command::new(exe);
if deep {
cmd.args(["-v", "error", "-xerror", "-i"])
.arg(path)
.args(["-f", "null", "-"]);
} else {
cmd.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_type",
"-of",
"csv=p=0",
])
.arg(path);
}
let out = match run_with_timeout(cmd, timeout) {
Ok(Some(out)) => out,
Ok(None) => return IntegrityResult::failed(path, "Kontrola vypršela (timeout)", true),
Err(e) => return IntegrityResult::failed(path, e.to_string(), false),
};
let mut stderr = out.stderr.trim().to_string();
let ok = if deep {
out.status_ok && stderr.is_empty()
} else {
let ok = out.status_ok && out.stdout.contains("video");
if !ok && stderr.is_empty() {
stderr = "Žádný video stream / nečitelný kontejner".into();
}
ok
};
if ok {
return IntegrityResult::ok(path);
}
let reason = first_error_line(&stderr);
IntegrityResult::failed(
path,
if reason.is_empty() {
"Neznámá chyba dekódování".into()
} else {
reason
},
true,
)
}
/// First non-empty stderr line, truncated.
fn first_error_line(stderr: &str) -> String {
stderr
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(|l| l.chars().take(MAX_ERROR_LEN).collect())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_missing_file_is_not_checked() {
let r = check_video_integrity(Path::new("/nonexistent/x.mkv"), true, None);
assert!(!r.ok);
assert!(!r.checked);
assert_eq!(r.error, "Soubor neexistuje");
}
#[test]
fn test_first_error_line_truncates() {
assert_eq!(first_error_line("\n\n boom \nsecond"), "boom");
assert_eq!(first_error_line(&"x".repeat(500)).len(), MAX_ERROR_LEN);
assert_eq!(first_error_line(""), "");
}
#[test]
fn test_garbage_file_fails_when_tool_present() {
if !ffmpeg_available(false) {
return;
}
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("bad.mkv");
std::fs::write(&f, "not a video").unwrap();
let r = check_video_integrity(&f, false, Some(Duration::from_secs(30)));
assert!(!r.ok);
assert!(r.checked);
}
#[test]
fn test_run_with_timeout_kills_slow_process() {
if which("sleep").is_none() {
return;
}
let mut cmd = Command::new("sleep");
cmd.arg("5");
assert!(
run_with_timeout(cmd, Some(Duration::from_millis(100)))
.unwrap()
.is_none()
);
}
}
-105
View File
@@ -1,105 +0,0 @@
"""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.
"""
import os
from dataclasses import dataclass
from typing import Optional
import requests
try: # the values can also come from the real environment
from dotenv import load_dotenv
load_dotenv()
except Exception: # noqa: BLE001
pass
_TIMEOUT = 30
@dataclass
class JellyfinMovie:
"""A movie as Jellyfin currently sees it (for verification)."""
jellyfin_id: str
title: str
year: Optional[int]
tmdb_id: Optional[int]
path: Optional[str]
class JellyfinError(RuntimeError):
"""A Jellyfin request could not be completed (no config, network, status)."""
def base_url() -> Optional[str]:
"""The configured Jellyfin base URL (no trailing slash), or None."""
url = os.getenv("JELLYFIN_URL")
return url.strip().rstrip("/") if url and url.strip() else None
def api_key() -> Optional[str]:
"""The configured Jellyfin API key, or None."""
key = os.getenv("JELLYFIN_API_KEY")
return key.strip() if key and key.strip() else None
def api_available() -> bool:
"""True when both the Jellyfin URL and API key are configured."""
return bool(base_url() and api_key())
def _request(method: str, path: str, params: Optional[dict] = None,
timeout: Optional[int] = None) -> requests.Response:
url, key = base_url(), api_key()
if not (url and key):
raise JellyfinError("JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).")
try:
resp = requests.request(
method, f"{url}{path}", headers={"X-Emby-Token": key},
params=params, timeout=timeout or _TIMEOUT)
except requests.RequestException as exc:
raise JellyfinError(f"Jellyfin nedostupný: {exc}") from exc
if resp.status_code in (401, 403):
raise JellyfinError("Neplatný JELLYFIN_API_KEY.")
if not resp.ok:
raise JellyfinError(f"Jellyfin odpověděl HTTP {resp.status_code}.")
return resp
def server_version(timeout: Optional[int] = None) -> str:
"""Return the Jellyfin server version (a quick connectivity check)."""
data = _request("GET", "/System/Info", timeout=timeout).json()
return str(data.get("Version") or "?")
def scan_all() -> None:
"""Trigger a scan of all Jellyfin libraries (picks up on-disk changes)."""
_request("POST", "/Library/Refresh")
def fetch_movies() -> list[JellyfinMovie]:
"""Read every movie Jellyfin currently has, with its TMDb id and path."""
data = _request("GET", "/Items", params={
"IncludeItemTypes": "Movie",
"Recursive": "true",
"Fields": "ProviderIds,Path,ProductionYear",
"EnableTotalRecordCount": "false",
}).json()
movies: list[JellyfinMovie] = []
for item in data.get("Items", []):
provider = item.get("ProviderIds") or {}
raw_tmdb = provider.get("Tmdb") or provider.get("tmdb")
tmdb_id = int(raw_tmdb) if raw_tmdb and str(raw_tmdb).isdigit() else None
movies.append(JellyfinMovie(
jellyfin_id=str(item.get("Id")),
title=item.get("Name") or "",
year=item.get("ProductionYear"),
tmdb_id=tmdb_id,
path=item.get("Path"),
))
return movies
+165
View File
@@ -0,0 +1,165 @@
//! Jellyfin server API client — thin, hybrid integration.
//!
//! The NFO + artwork on disk stay authoritative; this client only does what the
//! disk can't: **trigger a library scan** and **read back what Jellyfin
//! currently sees**. Connection details come from `.env` — `JELLYFIN_URL` and
//! `JELLYFIN_API_KEY`.
use std::time::Duration;
use reqwest::Method;
use serde_json::Value;
use super::error::CoreError;
use super::http::{client, env_nonempty};
const TIMEOUT: Duration = Duration::from_secs(30);
/// A movie as Jellyfin currently sees it.
#[derive(Debug, Clone, PartialEq)]
pub struct JellyfinMovie {
pub jellyfin_id: String,
pub title: String,
pub year: Option<i64>,
pub tmdb_id: Option<i64>,
pub path: Option<String>,
}
fn err(msg: impl Into<String>) -> CoreError {
CoreError::Remote(msg.into())
}
/// The configured base URL (no trailing slash).
pub fn base_url() -> Option<String> {
env_nonempty("JELLYFIN_URL").map(|u| u.trim_end_matches('/').to_string())
}
pub fn api_key() -> Option<String> {
env_nonempty("JELLYFIN_API_KEY")
}
/// True when both the URL and the API key are configured.
pub fn api_available() -> bool {
base_url().is_some() && api_key().is_some()
}
fn request(
method: Method,
path: &str,
params: &[(&str, &str)],
timeout: Option<Duration>,
) -> Result<Value, CoreError> {
let (Some(url), Some(key)) = (base_url(), api_key()) else {
return Err(err(
"JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).",
));
};
let resp = client()
.request(method, format!("{url}{path}"))
.header("X-Emby-Token", key)
.query(params)
.timeout(timeout.unwrap_or(TIMEOUT))
.send()
.map_err(|e| err(format!("Jellyfin nedostupný: {e}")))?;
let status = resp.status();
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(err("Neplatný JELLYFIN_API_KEY."));
}
if !status.is_success() {
return Err(err(format!("Jellyfin odpověděl HTTP {}.", status.as_u16())));
}
let text = resp
.text()
.map_err(|e| err(format!("Jellyfin nedostupný: {e}")))?;
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
}
/// The server version (a quick connectivity check).
pub fn server_version(timeout: Option<Duration>) -> Result<String, CoreError> {
let data = request(Method::GET, "/System/Info", &[], timeout)?;
Ok(data
.get("Version")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("?")
.to_string())
}
/// Trigger a scan of all Jellyfin libraries.
pub fn scan_all() -> Result<(), CoreError> {
request(Method::POST, "/Library/Refresh", &[], None).map(|_| ())
}
/// Parse one `/Items` entry.
pub fn parse_movie(item: &Value) -> JellyfinMovie {
let provider = item.get("ProviderIds");
let raw_tmdb = provider.and_then(|p| p.get("Tmdb").or_else(|| p.get("tmdb")));
let tmdb_id = raw_tmdb.and_then(|v| match v {
Value::String(s) if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) => {
s.parse().ok()
}
Value::Number(n) => n.as_i64(),
_ => None,
});
JellyfinMovie {
jellyfin_id: item
.get("Id")
.map(|v| v.as_str().map_or_else(|| v.to_string(), String::from))
.unwrap_or_else(|| "None".into()),
title: item
.get("Name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
year: item.get("ProductionYear").and_then(Value::as_i64),
tmdb_id,
path: item.get("Path").and_then(Value::as_str).map(String::from),
}
}
/// Every movie Jellyfin currently has, with its TMDb id and path.
pub fn fetch_movies() -> Result<Vec<JellyfinMovie>, CoreError> {
let data = request(
Method::GET,
"/Items",
&[
("IncludeItemTypes", "Movie"),
("Recursive", "true"),
("Fields", "ProviderIds,Path,ProductionYear"),
("EnableTotalRecordCount", "false"),
],
None,
)?;
Ok(data
.get("Items")
.and_then(Value::as_array)
.map(|a| a.iter().map(parse_movie).collect())
.unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_movie() {
let m = parse_movie(
&json!({"Id": "abc", "Name": "Matrix", "ProductionYear": 1999,
"ProviderIds": {"Tmdb": "603"}, "Path": "/pool/Filmy/Matrix.mkv"}),
);
assert_eq!(
m,
JellyfinMovie {
jellyfin_id: "abc".into(),
title: "Matrix".into(),
year: Some(1999),
tmdb_id: Some(603),
path: Some("/pool/Filmy/Matrix.mkv".into())
}
);
let bare = parse_movie(&json!({"Id": "x", "ProviderIds": {"Tmdb": "tt1"}}));
assert_eq!(bare.tmdb_id, None);
assert_eq!(bare.title, "");
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Readers for Curator's *pre-index* metadata layouts.
//!
//! Before the unified pool index, Tagger / early Curator stored a movie's
//! metadata in a per-file sidecar `.{filename}.!tag` (JSON) next to the video.
//! When importing a file 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. Read-only recovery for the import dialog.
use std::fs;
use std::path::Path;
use serde_json::Value;
use super::paths;
use super::pool_index::INDEX_FILENAME;
pub const SIDECAR_SUFFIX: &str = ".!tag";
const MAX_INDEX_WALK_UP: usize = 4;
/// What an old sidecar / index record can still tell us about a movie.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LegacyMeta {
pub csfd_url: Option<String>,
pub title: Option<String>,
pub year: Option<String>,
/// "sidecar" | "index".
pub source: String,
}
fn nonempty_str(v: Option<&Value>) -> Option<String> {
v.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from)
}
/// Pull the useful fields out of a stored movie record (any schema version).
fn record_to_meta(record: &Value, source: &str) -> Option<LegacyMeta> {
let rec = record.as_object()?;
let csfd = nonempty_str(rec.get("csfd_url")).or_else(|| nonempty_str(rec.get("csfd_link")));
let title = nonempty_str(rec.get("title"));
let from = |key: &str| {
rec.get(key)
.and_then(Value::as_object)
.and_then(|o| o.get("year"))
.cloned()
};
let raw_year = from("meta")
.filter(super::pool_index::truthy)
.or_else(|| from("csfd_cache"))
.filter(|v| !v.is_null());
let year = raw_year
.map(|v| {
v.as_str()
.map_or_else(|| v.to_string(), String::from)
.trim()
.to_string()
})
.filter(|y| !y.is_empty() && y.chars().all(|c| c.is_ascii_digit()));
if csfd.is_none() && title.is_none() && year.is_none() {
return None;
}
Some(LegacyMeta {
csfd_url: csfd,
title,
year,
source: source.into(),
})
}
fn read_json(path: &Path) -> Option<Value> {
serde_json::from_str(&fs::read_to_string(path).ok()?).ok()
}
fn from_sidecar(video_path: &Path) -> Option<LegacyMeta> {
let sidecar = video_path
.parent()?
.join(format!(".{}{SIDECAR_SUFFIX}", paths::name(video_path)));
if !sidecar.is_file() {
return None;
}
record_to_meta(&read_json(&sidecar)?, "sidecar")
}
/// The record for `video_path` in an index's `movies` map — exact pool-relative
/// key first, then a unique basename match.
fn lookup_in_index<'a>(
movies: &'a serde_json::Map<String, Value>,
video_path: &Path,
index_dir: &Path,
) -> Option<&'a Value> {
if video_path.starts_with(index_dir)
&& let Some(rec) = movies.get(&paths::posix_relative(video_path, index_dir))
{
return Some(rec);
}
let name = paths::name(video_path);
let matches: Vec<&Value> = movies
.iter()
.filter(|(k, _)| k.rsplit('/').next() == Some(name.as_str()))
.map(|(_, v)| v)
.collect();
(matches.len() == 1).then(|| matches[0])
}
fn from_legacy_index(video_path: &Path) -> Option<LegacyMeta> {
let mut directory = video_path.parent()?.to_path_buf();
for _ in 0..=MAX_INDEX_WALK_UP {
let index_path = directory.join(INDEX_FILENAME);
if index_path.is_file() {
let data = read_json(&index_path)?;
let obj = data.as_object()?;
let movies = match obj.get("movies") {
Some(m) => m.as_object()?,
None => obj,
};
return record_to_meta(lookup_in_index(movies, video_path, &directory)?, "index");
}
match directory.parent() {
Some(parent) if parent != directory => directory = parent.to_path_buf(),
_ => break,
}
}
None
}
/// Recover ČSFD link / title / year from a pre-index layout: a `.{name}.!tag`
/// sidecar next to the video, then a `.Curator.!index` up to 4 levels up.
pub fn read_legacy_meta(video_path: &Path) -> Option<LegacyMeta> {
from_sidecar(video_path).or_else(|| from_legacy_index(video_path))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn video(dir: &Path, rel: &str) -> std::path::PathBuf {
let p = dir.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, "x").unwrap();
p
}
#[test]
fn test_reads_tagger_era_sidecar() {
let dir = tempfile::tempdir().unwrap();
let v = video(dir.path(), "Matrix.mkv");
fs::write(dir.path().join(".Matrix.mkv.!tag"),
json!({"csfd_link": "https://www.csfd.cz/film/9499/", "title": "Matrix", "csfd_cache": {"year": 1999}}).to_string()).unwrap();
let meta = read_legacy_meta(&v).unwrap();
assert_eq!(
meta.csfd_url.as_deref(),
Some("https://www.csfd.cz/film/9499/")
);
assert_eq!(meta.year.as_deref(), Some("1999"));
assert_eq!(meta.source, "sidecar");
}
#[test]
fn test_reads_old_index_by_key_and_by_basename() {
let dir = tempfile::tempdir().unwrap();
let v = video(dir.path(), "Filmy/sub/Matrix.mkv");
fs::write(dir.path().join(INDEX_FILENAME),
json!({"movies": {"Filmy/sub/Matrix.mkv": {"csfd_url": "u1"}, "Old/Other.mkv": {"title": "Other"}}}).to_string()).unwrap();
assert_eq!(
read_legacy_meta(&v).unwrap().csfd_url.as_deref(),
Some("u1")
);
let moved = video(dir.path(), "Elsewhere/Other.mkv");
assert_eq!(
read_legacy_meta(&moved).unwrap().title.as_deref(),
Some("Other")
);
}
#[test]
fn test_sidecar_wins_over_index() {
let dir = tempfile::tempdir().unwrap();
let v = video(dir.path(), "Matrix.mkv");
fs::write(
dir.path().join(".Matrix.mkv.!tag"),
json!({"csfd_url": "side"}).to_string(),
)
.unwrap();
fs::write(
dir.path().join(INDEX_FILENAME),
json!({"movies": {"Matrix.mkv": {"csfd_url": "idx"}}}).to_string(),
)
.unwrap();
assert_eq!(
read_legacy_meta(&v).unwrap().csfd_url.as_deref(),
Some("side")
);
}
#[test]
fn test_none_cases() {
let dir = tempfile::tempdir().unwrap();
let v = video(dir.path(), "Matrix.mkv");
assert!(read_legacy_meta(&v).is_none());
fs::write(dir.path().join(".Matrix.mkv.!tag"), "{broken").unwrap();
assert!(read_legacy_meta(&v).is_none());
fs::write(
dir.path().join(".Matrix.mkv.!tag"),
json!({"tags": []}).to_string(),
)
.unwrap();
assert!(read_legacy_meta(&v).is_none());
}
}
-20
View File
@@ -1,20 +0,0 @@
from typing import List
from .file import File
class ListManager:
def __init__(self):
# 'name' nebo 'date'
self.sort_mode = "name"
def set_sort(self, mode: str):
if mode in ("name", "date"):
self.sort_mode = mode
def sort_files(self, files: List[File]) -> List[File]:
if self.sort_mode == "name":
return sorted(files, key=lambda f: f.filename.lower())
else:
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
def date_key(f):
return (f.date is None, f.date or "")
return sorted(files, key=date_key)
-43
View File
@@ -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}")
-62
View File
@@ -1,62 +0,0 @@
"""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.
"""
import re
import unicodedata
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class MatchProposal:
"""One movie's proposed TMDb match."""
file: object # core.file.File
current_name: str
title: str
year: Optional[str]
status: str # "auto" | "ambiguous" | "no_match" | "error"
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
error: str = ""
def normalize(text: str) -> str:
"""Fold a title for comparison: strip diacritics/punctuation, lowercase."""
text = unicodedata.normalize("NFD", text or "")
text = "".join(c for c in text if not unicodedata.combining(c))
text = re.sub(r"[^0-9a-zA-Z ]+", " ", text.lower())
return re.sub(r"\s+", " ", text).strip()
def _year_close(a: Optional[str], b: Optional[str]) -> bool:
if not (a and b and str(a).isdigit() and str(b).isdigit()):
return False
return abs(int(a) - int(b)) <= 1
def classify(title: str, year: Optional[str], candidates: list) -> tuple[str, object]:
"""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.
"""
if not candidates:
return "no_match", None
nt = normalize(title)
exact_year = [
c for c in candidates
if normalize(c.title) == nt and _year_close(c.year, year)
]
if len(exact_year) == 1:
return "auto", exact_year[0]
return "ambiguous", candidates[0]
+134
View File
@@ -0,0 +1,134 @@
//! 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 against TMDb by title + year and proposes an id. The
//! classification is deliberately conservative: only an exact
//! (diacritics-insensitive) title match with a year within ±1 and a single
//! qualifying candidate is `auto`; everything else is for the user to confirm.
use std::sync::LazyLock;
use regex::Regex;
use super::movie::fold_diacritics;
use super::tmdb::TmdbResult;
static NON_ALNUM_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[^0-9a-zA-Z ]+").expect("valid regex"));
static SPACES_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").expect("valid regex"));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchStatus {
Auto,
Ambiguous,
NoMatch,
Error,
}
impl MatchStatus {
/// Czech label shown in the migration dialog.
pub fn label(self) -> &'static str {
match self {
Self::Auto => "jisté",
Self::Ambiguous => "nejednoznačné",
Self::NoMatch => "bez shody",
Self::Error => "chyba",
}
}
}
/// One movie's proposed TMDb match.
#[derive(Debug, Clone, PartialEq)]
pub struct MatchProposal {
/// Identity key of the movie in the pool index.
pub movie_key: String,
pub current_name: String,
pub title: String,
pub year: Option<String>,
pub status: MatchStatus,
/// Preselected candidate (index into `candidates`).
pub chosen: Option<usize>,
pub candidates: Vec<TmdbResult>,
pub error: String,
/// e.g. "přes ČSFD: The Shawshank Redemption (1994)".
pub note: String,
}
/// Fold a title for comparison: strip diacritics/punctuation, lowercase.
pub fn normalize(text: &str) -> String {
let folded = fold_diacritics(text).to_lowercase();
let cleaned = NON_ALNUM_RE.replace_all(&folded, " ");
SPACES_RE.replace_all(&cleaned, " ").trim().to_string()
}
fn year_close(a: Option<&str>, b: Option<&str>) -> bool {
match (
a.and_then(|s| s.parse::<i64>().ok()),
b.and_then(|s| s.parse::<i64>().ok()),
) {
(Some(x), Some(y)) => (x - y).abs() <= 1,
_ => false,
}
}
/// `(status, chosen index)` for a title/year against TMDb candidates.
pub fn classify(
title: &str,
year: Option<&str>,
candidates: &[TmdbResult],
) -> (MatchStatus, Option<usize>) {
if candidates.is_empty() {
return (MatchStatus::NoMatch, None);
}
let nt = normalize(title);
let exact: Vec<usize> = candidates
.iter()
.enumerate()
.filter(|(_, c)| normalize(&c.title) == nt && year_close(c.year.as_deref(), year))
.map(|(i, _)| i)
.collect();
if exact.len() == 1 {
(MatchStatus::Auto, Some(exact[0]))
} else {
(MatchStatus::Ambiguous, Some(0))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::tmdb::Kind;
fn cand(id: i64, title: &str, year: &str) -> TmdbResult {
TmdbResult {
tmdb_id: id,
title: title.into(),
year: Some(year.into()),
kind: Kind::Movie,
}
}
#[test]
fn test_normalize_folds() {
assert_eq!(normalize("Pelíšky! (Remaster)"), "pelisky remaster");
}
#[test]
fn test_classify_cases() {
assert_eq!(classify("X", None, &[]), (MatchStatus::NoMatch, None));
let c = [cand(1, "Pelíšky", "1999"), cand(2, "Pelisky 2", "2001")];
assert_eq!(
classify("Pelisky", Some("2000"), &c),
(MatchStatus::Auto, Some(0))
);
assert_eq!(
classify("Pelisky", Some("2005"), &c),
(MatchStatus::Ambiguous, Some(0))
);
let dup = [cand(1, "Matrix", "1999"), cand(2, "Matrix", "1999")];
assert_eq!(
classify("Matrix", Some("1999"), &dup).0,
MatchStatus::Ambiguous
);
}
}
+24
View File
@@ -0,0 +1,24 @@
//! Domain model and every operation on the pool. Never depends on the UI.
pub mod config;
pub mod csfd;
pub mod error;
pub mod file;
pub mod file_manager;
pub mod hardlink_manager;
pub mod http;
pub mod integrity;
pub mod jellyfin;
pub mod legacy_meta;
pub mod migrate;
pub mod movie;
pub mod naming;
pub mod nfo;
pub mod paths;
pub mod pool_index;
pub mod series;
pub mod subtitles;
pub mod tag;
pub mod tag_manager;
pub mod tmdb;
pub mod transforms;
+552
View File
@@ -0,0 +1,552 @@
//! The movie object — metadata first, files second.
//!
//! A movie is identified by `(title, year, tmdb_id)` and owns **its files**. 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 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):
//!
//! ```text
//! 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
//! ```
//!
//! Subtitles are not media entries — they ride along with their video by
//! filename stem (see `subtitles`).
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use serde_json::{Map, Value, json};
use unicode_normalization::UnicodeNormalization;
use unicode_normalization::char::is_combining_mark;
use super::naming::{canonical_stem, is_valid_year};
use super::paths;
use super::tag::Tag;
use super::tag_manager::TagManager;
/// The only media role so far — artwork stays Jellyfin's job.
pub const ROLE_VIDEO: &str = "video";
/// A grouped movie needs a label on every file; this is what an unlabelled one gets.
pub const DEFAULT_VERSION_LABEL: &str = "Výchozí";
static LABEL_3D_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(?:3d[\s._-]*)?(hsbs|fsbs|htab|ftab|mvc)$").expect("valid regex")
});
static LABEL_PART_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{1,2})$").expect("valid regex")
});
static NON_SLUG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[^0-9a-z]+").expect("valid regex"));
/// The 3D format a version label denotes (upper-case), or `None`.
///
/// `"3D HSBS"` typed in the version dialog and `"hsbs"` picked from a dropdown
/// mean the same thing.
pub fn normalize_3d_label(label: &str) -> Option<String> {
LABEL_3D_RE
.captures(label.trim())
.map(|c| c[1].to_uppercase())
}
/// The part number a version label denotes (`part2`, `CD2`, `pt 2`), or `None`.
///
/// A *part* is one piece of a film split across several files, unlike a
/// *version*, which is an alternative.
pub fn normalize_part_label(label: &str) -> Option<u32> {
LABEL_PART_RE
.captures(label.trim())
.and_then(|c| c[1].parse().ok())
}
/// Canonical version label of part `number` — `"part<N>"`.
pub fn part_label(number: u32) -> String {
format!("part{number}")
}
/// Filename for one media file of a movie whose canonical stem is `stem`.
///
/// - no label → `stem.ext`
/// - 3D label → `stem.3D.<FORMAT>.ext`
/// - anything else → `stem - label.ext`
///
/// `grouped` only decides whether an empty label is allowed: inside a movie
/// folder every file gets one ([`DEFAULT_VERSION_LABEL`]).
pub fn media_filename(stem: &str, label: &str, ext: &str, grouped: bool) -> String {
let mut label = label.trim();
if grouped && label.is_empty() {
label = DEFAULT_VERSION_LABEL;
}
if label.is_empty() {
return format!("{stem}{ext}");
}
if let Some(fmt) = normalize_3d_label(label) {
return format!("{stem}.3D.{fmt}{ext}");
}
format!("{stem} - {label}{ext}")
}
/// Fold diacritics: "Pelíšky" → "Pelisky".
pub fn fold_diacritics(text: &str) -> String {
text.nfd().filter(|c| !is_combining_mark(*c)).collect()
}
/// 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.
pub fn identity_key(tmdb_id: Option<&str>, title: &str, year: Option<&str>) -> String {
let id_s = tmdb_id.unwrap_or("").trim();
if !id_s.is_empty()
&& id_s.chars().all(|c| c.is_ascii_digit())
&& let Ok(id) = id_s.parse::<u64>()
{
return format!("tmdb-{id}");
}
let raw = format!("{} {}", title.trim(), year.unwrap_or("").trim())
.trim()
.to_lowercase();
let folded = fold_diacritics(&raw);
let slug = NON_SLUG_RE
.replace_all(&folded, "-")
.trim_matches('-')
.to_string();
format!("name-{}", if slug.is_empty() { "bez-nazvu" } else { &slug })
}
/// [`identity_key`] for an integer id.
pub fn identity_key_for(tmdb_id: Option<i64>, title: &str, year: Option<&str>) -> String {
let id = tmdb_id.map(|i| i.to_string());
identity_key(id.as_deref(), title, year)
}
/// One file belonging to a movie (today always a playable video).
#[derive(Debug, Clone, PartialEq)]
pub struct MediaFile {
pub path: PathBuf,
pub label: String,
pub role: String,
}
impl MediaFile {
pub fn new(path: impl Into<PathBuf>, label: impl Into<String>) -> Self {
Self {
path: path.into(),
label: label.into(),
role: ROLE_VIDEO.into(),
}
}
/// The 3D format this version's label denotes, if any.
pub fn format_3d(&self) -> Option<String> {
normalize_3d_label(&self.label)
}
/// Serialise for the pool index (path stored pool-relative, POSIX).
pub fn to_record(&self, pool_dir: &Path) -> Value {
json!({
"role": self.role,
"label": self.label,
"file": paths::posix_relative(&self.path, pool_dir),
})
}
pub fn from_record(data: &Value, pool_dir: &Path) -> Self {
let role = str_field(data, "role").unwrap_or_else(|| ROLE_VIDEO.into());
Self {
path: paths::join_posix(pool_dir, &str_field(data, "file").unwrap_or_default()),
label: str_field(data, "label").unwrap_or_default(),
role,
}
}
}
/// A non-empty string field of a JSON object (numbers are stringified).
pub(crate) fn str_field(data: &Value, key: &str) -> Option<String> {
match data.get(key)? {
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(true) => Some("True".into()),
_ => None,
}
}
/// An integer field that may be stored as a number or a digit string.
pub(crate) fn int_field(data: &Value, key: &str) -> Option<i64> {
match data.get(key)? {
Value::Number(n) => n.as_i64(),
Value::String(s) if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) => {
s.parse().ok()
}
_ => None,
}
}
/// A film in the pool: its identity, its metadata, and its media files.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Movie {
pub key: String,
pub title: String,
pub year: Option<String>,
pub tmdb_id: Option<i64>,
pub library: String,
pub media: Vec<MediaFile>,
pub tags: Vec<Tag>,
pub auto_tag_paths: BTreeSet<String>,
pub csfd_url: Option<String>,
pub date: Option<String>,
pub added: Option<String>,
pub attributes: BTreeMap<String, String>,
pub meta: Option<Map<String, Value>>,
}
impl Movie {
/// Playable versions, in a stable order (by label, case-insensitively).
pub fn videos(&self) -> Vec<&MediaFile> {
let mut v: Vec<&MediaFile> = self.media.iter().filter(|m| m.role == ROLE_VIDEO).collect();
v.sort_by_key(|m| m.label.to_lowercase());
v
}
/// True when the movie needs its own folder (more than one version).
pub fn is_grouped(&self) -> bool {
self.videos().len() > 1
}
/// Canonical `Title (rok) [tmdbid-N]` — the folder / filename base. Falls back
/// to the bare title when no four-digit year is known.
pub fn stem(&self) -> String {
let mut title = self.title.replace(['/', '\\'], "-").trim().to_string();
if title.is_empty() {
title = self.key.clone();
}
match self.year.as_deref() {
Some(y) if is_valid_year(y) => {
canonical_stem(&title, y, self.tmdb_id, None).unwrap_or(title)
}
_ => title,
}
}
/// The version that represents the movie (first by [`Movie::videos`]).
pub fn primary(&self) -> Option<&MediaFile> {
self.videos().into_iter().next()
}
/// `(index into media, where it belongs)` for every video, from the record alone.
///
/// One video sits directly in the library, two or more move into
/// `<Library>/<stem>/`. Callers move whatever doesn't already match.
pub fn desired_layout(&self, pool_dir: &Path) -> Vec<(usize, PathBuf)> {
let base = pool_dir.join(&self.library);
let grouped = self.is_grouped();
let stem = self.stem();
let folder = if grouped { base.join(&stem) } else { base };
let mut order: Vec<usize> = (0..self.media.len())
.filter(|&i| self.media[i].role == ROLE_VIDEO)
.collect();
order.sort_by_key(|&i| self.media[i].label.to_lowercase());
order
.into_iter()
.map(|i| {
let m = &self.media[i];
let ext = paths::suffix(&m.path);
(
i,
folder.join(media_filename(&stem, &m.label, &ext, grouped)),
)
})
.collect()
}
pub fn tag_paths(&self) -> Vec<String> {
self.tags.iter().map(Tag::full_path).collect()
}
pub fn has_tag(&self, tag: &Tag) -> bool {
self.tags.contains(tag)
}
pub fn to_record(&self, pool_dir: &Path) -> Value {
let mut data = json!({
"title": self.title,
"year": self.year,
"tmdb_id": self.tmdb_id,
"library": self.library,
"media": self.media.iter().map(|m| m.to_record(pool_dir)).collect::<Vec<_>>(),
"tags": self.tag_paths(),
"auto_tags": self.auto_tag_paths.iter().collect::<Vec<_>>(),
"csfd_url": self.csfd_url,
"date": self.date,
"added": self.added,
"attributes": self.attributes,
});
if let (Some(meta), Some(obj)) = (&self.meta, data.as_object_mut()) {
obj.insert("meta".into(), Value::Object(meta.clone()));
}
data
}
/// Rebuild a movie from its index record. Tags are registered with
/// `tagmanager` when one is given.
pub fn from_record(
key: &str,
data: &Value,
pool_dir: &Path,
tagmanager: Option<&mut TagManager>,
) -> Self {
let media = data
.get("media")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.map(|m| MediaFile::from_record(m, pool_dir))
.collect()
})
.unwrap_or_default();
let auto_tag_paths = data
.get("auto_tags")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let attributes = data
.get("attributes")
.and_then(Value::as_object)
.map(|o| {
o.iter()
.map(|(k, v)| {
(
k.clone(),
v.as_str().map_or_else(|| v.to_string(), String::from),
)
})
.collect()
})
.unwrap_or_default();
let meta = data
.get("meta")
.and_then(Value::as_object)
.filter(|m| !m.is_empty())
.cloned();
let mut movie = Self {
key: key.into(),
title: str_field(data, "title").unwrap_or_default(),
year: str_field(data, "year"),
tmdb_id: int_field(data, "tmdb_id"),
library: str_field(data, "library").unwrap_or_default(),
media,
tags: Vec::new(),
auto_tag_paths,
csfd_url: str_field(data, "csfd_url"),
date: str_field(data, "date"),
added: str_field(data, "added"),
attributes,
meta,
};
if let Some(tm) = tagmanager {
for full in data
.get("tags")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(tag) = full.as_str().and_then(Tag::from_full_path) {
movie.tags.push(tm.add_tag(&tag.category, &tag.name));
}
}
}
movie
}
}
#[cfg(test)]
mod tests {
use super::*;
fn movie() -> Movie {
Movie {
key: "tmdb-19995".into(),
title: "Avatar".into(),
year: Some("2009".into()),
tmdb_id: Some(19995),
library: "Filmy".into(),
..Default::default()
}
}
#[test]
fn test_media_filename_variants() {
assert_eq!(
media_filename("Avatar (2009)", "", ".mkv", false),
"Avatar (2009).mkv"
);
assert_eq!(
media_filename("Avatar (2009)", "3D HSBS", ".mkv", false),
"Avatar (2009).3D.HSBS.mkv"
);
assert_eq!(
media_filename("Avatar (2009)", "4K", ".mkv", true),
"Avatar (2009) - 4K.mkv"
);
assert_eq!(
media_filename("Avatar (2009)", "", ".mkv", true),
format!("Avatar (2009) - {DEFAULT_VERSION_LABEL}.mkv")
);
}
#[test]
fn test_identity_key_variants() {
assert_eq!(
identity_key(Some("603"), "Matrix", Some("1999")),
"tmdb-603"
);
assert_eq!(
identity_key(None, "Matrix", Some("1999")),
"name-matrix-1999"
);
assert_eq!(
identity_key(None, "Pelíšky", Some("1999")),
"name-pelisky-1999"
);
assert_eq!(identity_key(None, "Nějaký film", None), "name-nejaky-film");
assert_eq!(identity_key(None, "", None), "name-bez-nazvu");
}
#[test]
fn test_normalize_labels() {
assert_eq!(normalize_3d_label("3D HSBS").as_deref(), Some("HSBS"));
assert_eq!(normalize_3d_label("hsbs").as_deref(), Some("HSBS"));
assert_eq!(normalize_3d_label("4K"), None);
for (label, expected) in [
("part1", Some(1)),
("Part 2", Some(2)),
("pt3", Some(3)),
("CD1", Some(1)),
("disc-2", Some(2)),
("dvd_4", Some(4)),
("disk12", Some(12)),
("4K", None),
("", None),
("partition", None),
] {
assert_eq!(normalize_part_label(label), expected, "{label}");
}
}
#[test]
fn test_stem_canonical_and_fallback() {
assert_eq!(movie().stem(), "Avatar (2009) [tmdbid-19995]");
let m = Movie {
year: None,
tmdb_id: None,
..movie()
};
assert_eq!(m.stem(), "Avatar");
}
#[test]
fn test_grouping_and_primary() {
let mut m = movie();
m.media = vec![MediaFile::new("/x.mkv", "")];
assert!(!m.is_grouped());
m.media = vec![
MediaFile::new("/a.mkv", "3D HSBS"),
MediaFile::new("/b.mkv", ""),
];
assert!(m.is_grouped());
assert_eq!(m.primary().unwrap().label, "");
}
#[test]
fn test_desired_layout_flat_and_grouped() {
let pool = Path::new("/pool");
let mut m = movie();
m.media = vec![MediaFile::new("/pool/kdekoli.mkv", "3D HSBS")];
let layout = m.desired_layout(pool);
assert_eq!(
layout[0].1,
pool.join("Filmy/Avatar (2009) [tmdbid-19995].3D.HSBS.mkv")
);
m.media = vec![
MediaFile::new("/a.mkv", ""),
MediaFile::new("/b.mkv", "3D HSBS"),
];
let targets: BTreeMap<String, PathBuf> = m
.desired_layout(pool)
.into_iter()
.map(|(i, p)| (m.media[i].label.clone(), p))
.collect();
let folder = pool.join("Filmy/Avatar (2009) [tmdbid-19995]");
assert_eq!(
targets[""],
folder.join(format!(
"Avatar (2009) [tmdbid-19995] - {DEFAULT_VERSION_LABEL}.mkv"
))
);
assert_eq!(
targets["3D HSBS"],
folder.join("Avatar (2009) [tmdbid-19995].3D.HSBS.mkv")
);
}
#[test]
fn test_layout_follows_library_move() {
let mut m = Movie {
library: "Kids".into(),
..movie()
};
m.media = vec![MediaFile::new("/a.mkv", "")];
assert_eq!(
m.desired_layout(Path::new("/p"))[0].1.parent().unwrap(),
Path::new("/p/Kids")
);
}
#[test]
fn test_record_roundtrip() {
let pool = Path::new("/pool");
let mut m = movie();
m.media = vec![MediaFile::new("/pool/Filmy/a.mkv", "3D HSBS")];
m.csfd_url = Some("https://www.csfd.cz/film/1/".into());
m.date = Some("2026-01-01".into());
m.attributes.insert("collection_sort".into(), "01".into());
let mut meta = Map::new();
meta.insert("year".into(), Value::from("2009"));
m.meta = Some(meta);
let record = m.to_record(pool);
assert_eq!(
record["media"],
json!([{"role": "video", "label": "3D HSBS", "file": "Filmy/a.mkv"}])
);
let back = Movie::from_record("tmdb-19995", &record, pool, None);
assert_eq!(back, m);
assert_eq!(back.media[0].format_3d().as_deref(), Some("HSBS"));
}
#[test]
fn test_from_record_registers_tags_and_reads_python_types() {
let record = json!({"title": "Avatar", "year": 2009, "tmdb_id": "19995",
"media": [], "tags": ["Žánr/Sci-Fi"]});
let mut tm = TagManager::new();
let back = Movie::from_record("k", &record, Path::new("/p"), Some(&mut tm));
assert_eq!(back.tag_paths(), vec!["Žánr/Sci-Fi"]);
assert_eq!(back.year.as_deref(), Some("2009"));
assert_eq!(back.tmdb_id, Some(19995));
assert_eq!(tm.all_tags(), vec!["Žánr/Sci-Fi"]);
}
}
-82
View File
@@ -1,82 +0,0 @@
"""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. These
helpers build, validate and parse the stem (the filename without its extension).
"""
import re
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]``. The
# title is non-greedy so a title that itself contains parentheses keeps them:
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019.
_POOL_STEM_RE = re.compile(
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?$")
def is_valid_year(year: object) -> bool:
"""True when ``year`` is (or stringifies to) exactly four digits."""
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str:
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` if given).
Raises:
ValueError: the title is empty, the year is not four digits, or a given
``tmdb_id`` is not a positive integer.
"""
title = (title or "").strip()
year_s = str(year).strip()
if not title:
raise ValueError("Název filmu nesmí být prázdný.")
if not re.fullmatch(r"\d{4}", year_s):
raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.")
stem = f"{title} ({year_s})"
if tmdb_id is not None and str(tmdb_id).strip() != "":
id_s = str(tmdb_id).strip()
if not id_s.isdigit():
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
stem += f" [tmdbid-{id_s}]"
return stem
def 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 :func:`parse_tmdb_id`
to read it), so the returned title is always the clean title.
"""
match = _POOL_STEM_RE.match((stem or "").strip())
if not match:
return None
return match.group("title").strip(), int(match.group("year"))
def parse_tmdb_id(stem: str) -> int | None:
"""Read the ``[tmdbid-N]`` from a canonical stem, or ``None`` if absent."""
match = _POOL_STEM_RE.match((stem or "").strip())
if not match or not match.group("id"):
return None
return int(match.group("id"))
def is_canonical_pool_stem(stem: str) -> bool:
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
return _POOL_STEM_RE.match((stem or "").strip()) is not None
def 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``.
"""
paren = re.search(r"\((19\d{2}|20\d{2})\)", name)
if paren:
return paren.group(1)
loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name)
return loose[-1] if loose else None
+314
View File
@@ -0,0 +1,314 @@
//! 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).
//!
//! A 3D movie additionally carries a `.3D.<FORMAT>` tag right before the
//! extension (Jellyfin's own convention): `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.
use std::sync::LazyLock;
use regex::Regex;
use super::error::{CoreError, CoreResult};
/// Jellyfin's recognised 3D format tags. hsbs/fsbs = half/full side-by-side,
/// htab/ftab = half/full top-and-bottom, mvc = Multiview Video Coding.
pub const THREE_D_FORMATS: [&str; 5] = ["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)" → "Kurz (special)".
static POOL_STEM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?(?:\.3D\.(?P<format3d>hsbs|fsbs|htab|ftab|mvc))?$",
)
.expect("valid regex")
});
static YEAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d{4}$").expect("valid regex"));
static PAREN_YEAR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\((19\d{2}|20\d{2})\)").expect("valid regex"));
static DIGIT_RUN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+").expect("valid regex"));
static THREE_D_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)3d[\s._-]*(hsbs|fsbs|htab|ftab|mvc)").expect("valid regex"));
/// True when `year` is exactly four digits (surrounding whitespace ignored).
pub fn is_valid_year(year: &str) -> bool {
YEAR_RE.is_match(year.trim())
}
/// Build the canonical pool stem `Title (YYYY)` (+ ` [tmdbid-N]` / `.3D.<FORMAT>`).
///
/// Empty `tmdb_id` / `format_3d` count as absent.
pub fn canonical_pool_stem(
title: &str,
year: &str,
tmdb_id: Option<&str>,
format_3d: Option<&str>,
) -> CoreResult<String> {
let title = title.trim();
let year_s = year.trim();
if title.is_empty() {
return Err(CoreError::invalid("Název filmu nesmí být prázdný."));
}
if !YEAR_RE.is_match(year_s) {
return Err(CoreError::invalid(format!(
"Rok musí být čtyřmístné číslo, ne „{year}“."
)));
}
let mut stem = format!("{title} ({year_s})");
if let Some(id) = tmdb_id.map(str::trim).filter(|s| !s.is_empty()) {
if !id.chars().all(|c| c.is_ascii_digit()) {
return Err(CoreError::invalid(format!(
"TMDb ID musí být číslo, ne „{id}“."
)));
}
stem.push_str(&format!(" [tmdbid-{id}]"));
}
if let Some(fmt) = format_3d.map(str::trim).filter(|s| !s.is_empty()) {
let upper = fmt.to_uppercase();
if !THREE_D_FORMATS.contains(&upper.as_str()) {
return Err(CoreError::invalid(format!(
"3D formát musí být jedno z {}, ne „{fmt}“.",
THREE_D_FORMATS.join(", ")
)));
}
stem.push_str(&format!(".3D.{upper}"));
}
Ok(stem)
}
/// Convenience for the common integer-id case.
pub fn canonical_stem(
title: &str,
year: &str,
tmdb_id: Option<i64>,
format_3d: Option<&str>,
) -> CoreResult<String> {
let id = tmdb_id.map(|i| i.to_string());
canonical_pool_stem(title, year, id.as_deref(), format_3d)
}
/// Split a canonical stem into `(title, year)`; `None` if it isn't one.
///
/// A trailing `[tmdbid-N]` / `.3D.X` is tolerated and dropped.
pub fn parse_pool_stem(stem: &str) -> Option<(String, i32)> {
let caps = POOL_STEM_RE.captures(stem.trim())?;
let year = caps["year"].parse().ok()?;
Some((caps["title"].trim().to_string(), year))
}
/// Read the `[tmdbid-N]` from a canonical stem.
pub fn parse_tmdb_id(stem: &str) -> Option<i64> {
POOL_STEM_RE
.captures(stem.trim())?
.name("id")?
.as_str()
.parse()
.ok()
}
/// Read the `.3D.<FORMAT>` tag from a canonical stem (upper-cased).
pub fn parse_3d_format(stem: &str) -> Option<String> {
Some(
POOL_STEM_RE
.captures(stem.trim())?
.name("format3d")?
.as_str()
.to_uppercase(),
)
}
/// True when `stem` matches the `Title (YYYY)` convention.
pub fn is_canonical_pool_stem(stem: &str) -> bool {
POOL_STEM_RE.is_match(stem.trim())
}
/// Best-effort four-digit year guessed from a raw (release) filename.
///
/// Prefers a parenthesised year and otherwise falls back to the last standalone
/// 19xx/20xx run of exactly four digits. Only used to pre-fill the import dialog.
pub fn year_from_filename(name: &str) -> Option<String> {
if let Some(c) = PAREN_YEAR_RE.captures(name) {
return Some(c[1].to_string());
}
DIGIT_RUN_RE
.find_iter(name)
.map(|m| m.as_str())
.filter(|s| s.len() == 4 && (s.starts_with("19") || s.starts_with("20")))
.last()
.map(str::to_string)
}
fn is_alnum_ascii(c: Option<char>) -> bool {
c.is_some_and(|c| c.is_ascii_alphanumeric())
}
/// Best-effort 3D format guessed from a raw filename, e.g.
/// `Avatar.2009.3D.HSBS.BluRay.mkv` → `"HSBS"`. The `3D…` token must not be
/// glued to a neighbouring letter or digit on either side.
pub fn three_d_format_from_filename(name: &str) -> Option<String> {
let mut start = 0;
while let Some(caps) = THREE_D_RE.captures_at(name, start) {
let whole = caps.get(0)?;
let before = name[..whole.start()].chars().next_back();
let after = name[whole.end()..].chars().next();
if !is_alnum_ascii(before) && !is_alnum_ascii(after) {
return Some(caps[1].to_uppercase());
}
// advance by one character and retry
start = whole.start() + name[whole.start()..].chars().next()?.len_utf8();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_valid_year_cases() {
for (year, ok) in [
("1968", true),
("968", false),
("19688", false),
("abcd", false),
("", false),
(" 1999 ", true),
] {
assert_eq!(is_valid_year(year), ok, "{year}");
}
}
#[test]
fn test_canonical_pool_stem_builds_title_year() {
assert_eq!(
canonical_pool_stem("Matrix", "1999", None, None).unwrap(),
"Matrix (1999)"
);
assert_eq!(
canonical_pool_stem(" Matrix ", "1999", None, None).unwrap(),
"Matrix (1999)"
);
}
#[test]
fn test_canonical_pool_stem_rejects_bad_input() {
assert!(canonical_pool_stem("Matrix", "99", None, None).is_err());
assert!(canonical_pool_stem(" ", "1999", None, None).is_err());
assert!(canonical_pool_stem("Matrix", "1999", Some("tt0133093"), None).is_err());
assert!(canonical_pool_stem("Avatar", "2009", None, Some("bogus")).is_err());
}
#[test]
fn test_parse_pool_stem_roundtrip() {
assert_eq!(
parse_pool_stem("Matrix (1999)"),
Some(("Matrix".into(), 1999))
);
assert_eq!(
parse_pool_stem("Já, robot (2004)"),
Some(("Já, robot".into(), 2004))
);
assert_eq!(
parse_pool_stem("Kurz (special) (2019)"),
Some(("Kurz (special)".into(), 2019))
);
assert_eq!(
parse_pool_stem("Matrix (1999) [tmdbid-603]"),
Some(("Matrix".into(), 1999))
);
}
#[test]
fn test_parse_pool_stem_non_canonical() {
assert_eq!(parse_pool_stem("Matrix"), None);
assert_eq!(parse_pool_stem("Matrix (99)"), None);
assert_eq!(parse_pool_stem("Matrix 1999"), None);
}
#[test]
fn test_canonical_pool_stem_with_tmdb_id() {
assert_eq!(
canonical_pool_stem("Matrix", "1999", Some("603"), None).unwrap(),
"Matrix (1999) [tmdbid-603]"
);
assert_eq!(
canonical_pool_stem("Matrix", "1999", Some(""), None).unwrap(),
"Matrix (1999)"
);
}
#[test]
fn test_parse_tmdb_id_cases() {
assert_eq!(parse_tmdb_id("Matrix (1999) [tmdbid-603]"), Some(603));
assert_eq!(parse_tmdb_id("Matrix (1999)"), None);
assert_eq!(parse_tmdb_id("Matrix"), None);
assert!(is_canonical_pool_stem("Matrix (1999) [tmdbid-603]"));
assert!(!is_canonical_pool_stem("Matrix"));
}
#[test]
fn test_canonical_pool_stem_with_3d_format() {
assert_eq!(
canonical_pool_stem("Avatar", "2009", Some("19995"), Some("hsbs")).unwrap(),
"Avatar (2009) [tmdbid-19995].3D.HSBS"
);
assert_eq!(
canonical_pool_stem("Avatar", "2009", None, Some("fsbs")).unwrap(),
"Avatar (2009).3D.FSBS"
);
assert_eq!(
canonical_pool_stem("Avatar", "2009", None, Some("")).unwrap(),
"Avatar (2009)"
);
}
#[test]
fn test_parse_3d_format_roundtrip() {
for fmt in THREE_D_FORMATS {
let stem = canonical_pool_stem("Avatar", "2009", Some("19995"), Some(fmt)).unwrap();
assert_eq!(parse_3d_format(&stem).as_deref(), Some(fmt));
assert_eq!(parse_pool_stem(&stem), Some(("Avatar".into(), 2009)));
assert_eq!(parse_tmdb_id(&stem), Some(19995));
}
assert_eq!(
parse_3d_format("Avatar (2009).3D.hsbs").as_deref(),
Some("HSBS")
);
assert_eq!(parse_3d_format("Avatar (2009)"), None);
}
#[test]
fn test_year_from_filename_cases() {
for (name, year) in [
("Matrix.1999.1080p.BluRay.mkv", Some("1999")),
("Šíleně smutná princezna (1968).mkv", Some("1968")),
("Movie 2001 2010 remux.mkv", Some("2010")),
("No Year Here.mkv", None),
("Film 12345.mkv", None),
] {
assert_eq!(year_from_filename(name).as_deref(), year, "{name}");
}
}
#[test]
fn test_three_d_format_from_filename_cases() {
for (name, fmt) in [
("Avatar.2009.3D.HSBS.BluRay.mkv", Some("HSBS")),
("Avatar.2009.3d.hsbs.mkv", Some("HSBS")),
("Avatar (2009) 3D_FTAB 1080p.mkv", Some("FTAB")),
("Avatar (2009) - 3D-MVC.mkv", Some("MVC")),
("Avatar (2009).mkv", None),
("Premium3D.HSBS.mkv", None),
("Avatar.2009.HSBS.mkv", None),
] {
assert_eq!(three_d_format_from_filename(name).as_deref(), fmt, "{name}");
}
}
}
-101
View File
@@ -1,101 +0,0 @@
"""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).
"""
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class NfoData:
"""The bits of a movie ``.nfo`` Curator uses."""
title: Optional[str] = None
year: Optional[str] = None # four-digit string
genres: list[str] = field(default_factory=list)
countries: list[str] = field(default_factory=list)
rating: Optional[int] = None # 0–100 (NFO 0–10 scaled up), or None
tmdb_id: Optional[int] = None
def field_values(self, name: str) -> list[str]:
"""Values for a tag-schema source field (genres/countries/year/rating)."""
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 []
def find_nfo(video_path: Path) -> Optional[Path]:
"""Locate the ``.nfo`` for a movie: sibling ``<stem>.nfo`` or ``movie.nfo``."""
video_path = Path(video_path)
sibling = video_path.with_suffix(".nfo")
if sibling.is_file():
return sibling
movie_nfo = video_path.parent / "movie.nfo"
if movie_nfo.is_file():
return movie_nfo
return None
def _texts(root: ET.Element, tag: str) -> list[str]:
return [e.text.strip() for e in root.findall(tag) if e.text and e.text.strip()]
def _parse_rating(root: ET.Element) -> Optional[int]:
"""Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands)."""
raw = root.findtext("rating")
if raw is None:
value = root.find("ratings/rating/value")
raw = value.text if value is not None else None
if not raw:
return None
try:
return round(float(raw.strip().replace(",", ".")) * 10)
except ValueError:
return None
def _parse_tmdb_id(root: ET.Element) -> Optional[int]:
for uid in root.findall("uniqueid"):
if uid.get("type") == "tmdb" and uid.text and uid.text.strip().isdigit():
return int(uid.text.strip())
raw = root.findtext("tmdbid")
return int(raw.strip()) if raw and raw.strip().isdigit() else None
def read_nfo(video_path: Path) -> Optional[NfoData]:
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
path = find_nfo(video_path)
if path is None:
return None
try:
root = ET.parse(path).getroot()
except (ET.ParseError, OSError):
return None
year = root.findtext("year")
year = year.strip() if year and year.strip().isdigit() else None
title = root.findtext("title")
return NfoData(
title=title.strip() if title and title.strip() else None,
year=year,
genres=_texts(root, "genre"),
countries=_texts(root, "country"),
rating=_parse_rating(root),
tmdb_id=_parse_tmdb_id(root),
)
+311
View File
@@ -0,0 +1,311 @@
//! NFO reader — the movie metadata Jellyfin writes next to each film.
//!
//! Curator only **reads** it — to drive the miniDLNA genre tree and to fill the
//! year/rating used by folder grouping. For a flat pool file the sidecar is
//! `<stem>.nfo`; a per-movie folder may instead hold `movie.nfo`. Missing or
//! unparsable NFO → `None`.
use std::fs;
use std::path::{Path, PathBuf};
use roxmltree::{Document, Node, ParsingOptions};
/// The bits of a movie `.nfo` Curator uses.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NfoData {
pub title: Option<String>,
/// Digit string (normally four digits).
pub year: Option<String>,
pub genres: Vec<String>,
pub countries: Vec<String>,
/// 0–100 (the NFO's 0–10 scaled up).
pub rating: Option<i64>,
pub tmdb_id: Option<i64>,
/// ČSFD film URL, if the NFO carries a ČSFD id.
pub csfd_url: Option<String>,
}
impl NfoData {
/// Values for a tag-schema source field (genres/countries/year/rating).
pub fn field_values(&self, name: &str) -> Vec<String> {
match name {
"genres" => self.genres.clone(),
"countries" => self.countries.clone(),
"year" => self.year.iter().cloned().collect(),
"rating" => self.rating.iter().map(|r| r.to_string()).collect(),
_ => Vec::new(),
}
}
}
/// Locate the `.nfo` for a movie: sibling `<stem>.nfo`, else `movie.nfo`.
pub fn find_nfo(video_path: &Path) -> Option<PathBuf> {
let sibling = video_path.with_extension("nfo");
if sibling.is_file() {
return Some(sibling);
}
let movie_nfo = video_path.parent()?.join("movie.nfo");
movie_nfo.is_file().then_some(movie_nfo)
}
fn children<'a, 'i>(node: Node<'a, 'i>, tag: &'a str) -> impl Iterator<Item = Node<'a, 'i>> + 'a {
node.children()
.filter(move |n| n.is_element() && n.tag_name().name() == tag)
}
fn child<'a, 'i>(node: Node<'a, 'i>, tag: &'a str) -> Option<Node<'a, 'i>> {
children(node, tag).next()
}
/// Text of the first direct child `tag` (`Some("")` for an empty element).
fn child_text(node: Node<'_, '_>, tag: &str) -> Option<String> {
child(node, tag).map(|n| n.text().unwrap_or("").to_string())
}
fn texts(node: Node<'_, '_>, tag: &str) -> Vec<String> {
children(node, tag)
.filter_map(|n| {
n.text()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(String::from)
})
.collect()
}
fn is_digits(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
}
/// Read a 0–10 rating and scale it to 0–100 (banker's rounding, like Python).
fn parse_rating(root: Node<'_, '_>) -> Option<i64> {
let raw = match child_text(root, "rating") {
Some(r) => r,
None => child(root, "ratings")
.and_then(|r| child(r, "rating"))
.and_then(|r| child(r, "value"))
.map(|v| v.text().unwrap_or("").to_string())?,
};
if raw.is_empty() {
return None;
}
let value: f64 = raw.trim().replace(',', ".").parse().ok()?;
Some((value * 10.0).round_ties_even() as i64)
}
fn parse_tmdb_id(root: Node<'_, '_>) -> Option<i64> {
for uid in children(root, "uniqueid") {
let text = uid.text().unwrap_or("").trim();
if uid.attribute("type") == Some("tmdb") && is_digits(text) {
return text.parse().ok();
}
}
child_text(root, "tmdbid")
.map(|t| t.trim().to_string())
.filter(|t| is_digits(t))?
.parse()
.ok()
}
/// A ČSFD film URL from `<uniqueid type="csfd">` or a legacy `<csfdid>` /
/// `<csfd>` element. A bare id expands to `https://www.csfd.cz/film/<id>/`.
fn parse_csfd_url(root: Node<'_, '_>) -> Option<String> {
let mut candidates: Vec<String> = children(root, "uniqueid")
.filter(|u| u.attribute("type") == Some("csfd"))
.filter_map(|u| {
u.text()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(String::from)
})
.collect();
for tag in ["csfdid", "csfd"] {
if let Some(raw) = child_text(root, tag)
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
{
candidates.push(raw);
}
}
candidates.into_iter().find_map(|value| {
if is_digits(&value) {
Some(format!("https://www.csfd.cz/film/{value}/"))
} else if value.contains("csfd.cz") {
Some(if value.starts_with("http") {
value
} else {
format!("https://{}", value.trim_start_matches('/'))
})
} else {
None
}
})
}
/// Parse NFO XML text.
pub fn parse_nfo(text: &str) -> Option<NfoData> {
let opts = ParsingOptions {
allow_dtd: true,
..ParsingOptions::default()
};
let doc = Document::parse_with_options(text, opts).ok()?;
let root = doc.root_element();
let year = child_text(root, "year")
.map(|y| y.trim().to_string())
.filter(|y| is_digits(y));
let title = child_text(root, "title")
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty());
Some(NfoData {
title,
year,
genres: texts(root, "genre"),
countries: texts(root, "country"),
rating: parse_rating(root),
tmdb_id: parse_tmdb_id(root),
csfd_url: parse_csfd_url(root),
})
}
/// Read the movie's `.nfo`, or `None` if absent/broken.
pub fn read_nfo(video_path: &Path) -> Option<NfoData> {
let path = find_nfo(video_path)?;
let bytes = fs::read(path).ok()?;
parse_nfo(&String::from_utf8_lossy(&bytes))
}
#[cfg(test)]
mod tests {
use super::*;
const MOVIE_NFO: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<movie>
<title>Matrix</title>
<year>1999</year>
<genre>Akční</genre>
<genre>Sci-Fi</genre>
<country>USA</country>
<rating>8.7</rating>
<uniqueid type="tmdb">603</uniqueid>
</movie>
"#;
fn video_with_nfo(dir: &Path, nfo: &str) -> PathBuf {
let v = dir.join("Matrix (1999) [tmdbid-603].mkv");
fs::write(&v, "x").unwrap();
fs::write(v.with_extension("nfo"), nfo).unwrap();
v
}
#[test]
fn test_read_nfo_sibling() {
let dir = tempfile::tempdir().unwrap();
let nfo = read_nfo(&video_with_nfo(dir.path(), MOVIE_NFO)).unwrap();
assert_eq!(
nfo,
NfoData {
title: Some("Matrix".into()),
year: Some("1999".into()),
genres: vec!["Akční".into(), "Sci-Fi".into()],
countries: vec!["USA".into()],
rating: Some(87),
tmdb_id: Some(603),
csfd_url: None,
}
);
}
#[test]
fn test_rating_variants() {
assert_eq!(
parse_nfo("<movie><rating>7.3</rating></movie>")
.unwrap()
.rating,
Some(73)
);
assert_eq!(
parse_nfo("<movie><rating>7,3</rating></movie>")
.unwrap()
.rating,
Some(73)
);
let block =
"<movie><ratings><rating name='tmdb'><value>9.1</value></rating></ratings></movie>";
assert_eq!(parse_nfo(block).unwrap().rating, Some(91));
}
#[test]
fn test_ids_and_csfd() {
assert_eq!(
parse_nfo("<movie><tmdbid>603</tmdbid></movie>")
.unwrap()
.tmdb_id,
Some(603)
);
assert_eq!(
parse_nfo(r#"<movie><uniqueid type="csfd">9499</uniqueid></movie>"#)
.unwrap()
.csfd_url
.as_deref(),
Some("https://www.csfd.cz/film/9499/")
);
assert_eq!(
parse_nfo(r#"<movie><uniqueid type="csfd">https://www.csfd.cz/film/9499-matrix/</uniqueid></movie>"#)
.unwrap().csfd_url.as_deref(),
Some("https://www.csfd.cz/film/9499-matrix/")
);
assert_eq!(
parse_nfo("<movie><csfdid>12345</csfdid></movie>")
.unwrap()
.csfd_url
.as_deref(),
Some("https://www.csfd.cz/film/12345/")
);
assert_eq!(
parse_nfo("<movie><title>Matrix</title></movie>")
.unwrap()
.csfd_url,
None
);
}
#[test]
fn test_find_prefers_sibling_over_movie_nfo() {
let dir = tempfile::tempdir().unwrap();
let video = dir.path().join("Matrix (1999).mkv");
fs::write(&video, "x").unwrap();
fs::write(dir.path().join("movie.nfo"), MOVIE_NFO).unwrap();
assert_eq!(find_nfo(&video).unwrap().file_name().unwrap(), "movie.nfo");
fs::write(video.with_extension("nfo"), MOVIE_NFO).unwrap();
assert_eq!(
find_nfo(&video).unwrap().file_name().unwrap(),
"Matrix (1999).nfo"
);
}
#[test]
fn test_missing_or_broken_nfo_is_none() {
let dir = tempfile::tempdir().unwrap();
let v = dir.path().join("x.mkv");
fs::write(&v, "x").unwrap();
assert!(read_nfo(&v).is_none());
fs::write(v.with_extension("nfo"), "<movie><genre>unclosed").unwrap();
assert!(read_nfo(&v).is_none());
}
#[test]
fn test_field_values() {
let nfo = NfoData {
year: Some("1999".into()),
genres: vec!["A".into(), "B".into()],
countries: vec!["USA".into()],
rating: Some(87),
..Default::default()
};
assert_eq!(nfo.field_values("genres"), vec!["A", "B"]);
assert_eq!(nfo.field_values("countries"), vec!["USA"]);
assert_eq!(nfo.field_values("year"), vec!["1999"]);
assert_eq!(nfo.field_values("rating"), vec!["87"]);
assert!(nfo.field_values("directors").is_empty());
}
}
+171
View File
@@ -0,0 +1,171 @@
//! Small filesystem helpers mirroring the `pathlib` behaviour the pool relies on.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// The final extension including its dot (`".mkv"`), or `""`.
pub fn suffix(path: &Path) -> String {
path.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default()
}
/// Lower-cased [`suffix`].
pub fn suffix_lower(path: &Path) -> String {
suffix(path).to_lowercase()
}
/// The file name without its final extension.
pub fn stem(path: &Path) -> String {
path.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// The last path component as a string.
pub fn name(path: &Path) -> String {
path.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// `path` relative to `base` with `/` separators, or the whole path (POSIX
/// style) when it isn't under `base`. This is how the pool index stores paths.
pub fn posix_relative(path: &Path, base: &Path) -> String {
let rel = path.strip_prefix(base).unwrap_or(path);
rel.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
.replace("//", "/")
}
/// Join a POSIX-style relative path (as stored in the index) onto `base`.
pub fn join_posix(base: &Path, rel: &str) -> PathBuf {
let mut out = base.to_path_buf();
for part in rel.split('/').filter(|p| !p.is_empty()) {
out.push(part);
}
out
}
/// Every regular file under `dir`, recursively, sorted by path. Symlinked
/// directories are not followed. A missing directory yields an empty list.
pub fn walk_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
walk_into(dir, &mut out);
out.sort();
out
}
fn walk_into(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
let path = entry.path();
if ft.is_dir() {
walk_into(&path, out);
} else if path.is_file() {
out.push(path);
}
}
}
/// Immediate subdirectories of `dir`, sorted.
pub fn subdirs(dir: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = fs::read_dir(dir)
.map(|it| {
it.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect()
})
.unwrap_or_default();
out.sort();
out
}
/// Immediate entries (files and folders) of `dir`, sorted.
pub fn entries(dir: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = fs::read_dir(dir)
.map(|it| it.flatten().map(|e| e.path()).collect())
.unwrap_or_default();
out.sort();
out
}
/// Move a file, falling back to copy + delete across filesystems.
pub fn move_file(src: &Path, dst: &Path) -> io::Result<()> {
match fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(_) => {
fs::copy(src, dst)?;
fs::remove_file(src)
}
}
}
/// Copy a file keeping its modification time (like Python's `shutil.copy2`).
pub fn copy_preserving(src: &Path, dst: &Path) -> io::Result<()> {
fs::copy(src, dst)?;
if let Ok(mtime) = fs::metadata(src).and_then(|m| m.modified())
&& let Ok(f) = fs::OpenOptions::new().write(true).open(dst)
{
let _ = f.set_modified(mtime);
}
Ok(())
}
/// Copy or move `src` to `dst`.
pub fn transfer(src: &Path, dst: &Path, move_source: bool) -> io::Result<()> {
if move_source {
move_file(src, dst)
} else {
copy_preserving(src, dst)
}
}
/// True when `dir` exists and holds nothing.
pub fn is_empty_dir(dir: &Path) -> bool {
fs::read_dir(dir)
.map(|mut it| it.next().is_none())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_suffix_and_stem() {
let p = Path::new("/a/Film (2009).3D.HSBS.mkv");
assert_eq!(suffix(p), ".mkv");
assert_eq!(stem(p), "Film (2009).3D.HSBS");
assert_eq!(suffix(Path::new("/a/.hidden")), "");
}
#[test]
fn test_posix_relative_and_join_roundtrip() {
let base = Path::new("/pool");
let p = Path::new("/pool/Filmy/a b/x.mkv");
let rel = posix_relative(p, base);
assert_eq!(rel, "Filmy/a b/x.mkv");
assert_eq!(join_posix(base, &rel), p);
}
#[test]
fn test_walk_files_recurses_and_sorts() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("b/c")).unwrap();
fs::write(dir.path().join("b/c/2.txt"), "").unwrap();
fs::write(dir.path().join("a.txt"), "").unwrap();
let files = walk_files(dir.path());
assert_eq!(
files,
vec![dir.path().join("a.txt"), dir.path().join("b/c/2.txt")]
);
}
}
-89
View File
@@ -1,89 +0,0 @@
"""
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::
{"settings": {...}, "movies": {"Filmy/Matrix.mkv": {...}, ...}}
Records are keyed by the file's path relative to the pool root (POSIX form),
e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines.
"""
import json
from pathlib import Path
INDEX_FILENAME = ".Curator.!index"
class PoolIndex:
"""Single-file JSON metadata store for all files in a pool."""
def __init__(self, pool_dir: Path) -> None:
self.pool_dir = Path(pool_dir)
self.index_path = self.pool_dir / INDEX_FILENAME
self.records: dict[str, dict] = {}
self.settings: dict = {}
self.load()
def _key(self, file_path: Path) -> str:
"""Pool-relative POSIX key for a file path."""
path = Path(file_path)
try:
return path.relative_to(self.pool_dir).as_posix()
except ValueError:
return path.as_posix()
def load(self) -> None:
"""Load the index from disk (missing/corrupt index = empty)."""
if not self.index_path.exists():
self.records = {}
self.settings = {}
return
try:
with open(self.index_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.records = data.get("movies", {})
self.settings = data.get("settings", {})
except (json.JSONDecodeError, OSError):
self.records = {}
self.settings = {}
def save(self) -> None:
"""Persist the index to disk."""
self.pool_dir.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f:
json.dump(
{"settings": self.settings, "movies": self.records},
f, indent=2, ensure_ascii=False,
)
def get_setting(self, key: str, default=None):
"""Return a library-level setting stored in the index."""
return self.settings.get(key, default)
def set_setting(self, key: str, value) -> None:
"""Upsert a library-level setting and persist the index."""
self.settings[key] = value
self.save()
def get(self, file_path: Path) -> dict | None:
"""Return the record for a file, or None if it is not indexed."""
return self.records.get(self._key(file_path))
def set(self, file_path: Path, data: dict) -> None:
"""Upsert a record and persist the index."""
self.records[self._key(file_path)] = data
self.save()
def delete(self, file_path: Path) -> None:
"""Remove a record (if present) and persist the index."""
if self.records.pop(self._key(file_path), None) is not None:
self.save()
+388
View File
@@ -0,0 +1,388 @@
//! Unified metadata index for the Curator pool.
//!
//! The whole pool keeps a single JSON index at `<pool>/.Curator.!index`. Curator
//! owns the pool, so files never move behind its back and a central index is safe.
//! The index also carries **library-level settings** (`tag_schema`, `libraries`,
//! …) under `settings`, so everything describing a pool travels with it.
//!
//! On-disk shape (**v2**):
//!
//! ```text
//! {"version": 2,
//! "settings": {...},
//! "movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
//! ```
//!
//! Records are keyed by the movie's identity and own their files. A **v1** index
//! (keyed by one file's pool-relative path) is migrated in place on load, after
//! backing the old file up. Migration only rewrites metadata.
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
use tracing::warn;
use super::error::CoreResult;
use super::movie::{ROLE_VIDEO, identity_key_for};
use super::naming::{is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id};
use super::paths;
pub const INDEX_FILENAME: &str = ".Curator.!index";
pub const INDEX_VERSION: i64 = 2;
/// Single-file JSON metadata store for a pool.
#[derive(Debug, Clone)]
pub struct PoolIndex {
pub pool_dir: PathBuf,
pub index_path: PathBuf,
pub records: Map<String, Value>,
pub settings: Map<String, Value>,
}
impl PoolIndex {
/// Open (and, if needed, migrate) the index of `pool_dir`.
pub fn open(pool_dir: &Path) -> Self {
let mut idx = Self {
pool_dir: pool_dir.to_path_buf(),
index_path: pool_dir.join(INDEX_FILENAME),
records: Map::new(),
settings: Map::new(),
};
idx.load();
idx
}
/// Pool-relative POSIX key for a file path.
pub fn rel_key(&self, file_path: &Path) -> String {
paths::posix_relative(file_path, &self.pool_dir)
}
/// Load from disk (missing/corrupt index = empty).
pub fn load(&mut self) {
self.records.clear();
self.settings.clear();
let Ok(text) = fs::read_to_string(&self.index_path) else {
return;
};
let data: Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => {
warn!("pool index {} is corrupt: {e}", self.index_path.display());
return;
}
};
self.records = data
.get("movies")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
self.settings = data
.get("settings")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let version = data.get("version").and_then(Value::as_i64).unwrap_or(1);
if version < INDEX_VERSION {
self.migrate_v1_to_v2();
}
}
/// Re-key path-keyed records into identity-keyed movie records, merging the
/// several v1 records that describe one multi-version movie.
fn migrate_v1_to_v2(&mut self) {
let mut migrated: Map<String, Value> = Map::new();
for (rel_path, record) in &self.records {
let Some(rec) = record.as_object() else {
continue;
};
if rec.contains_key("media") {
continue; // already v2-shaped
}
let parts: Vec<&str> = rel_path.split('/').filter(|p| !p.is_empty()).collect();
let library = if parts.len() > 1 { parts[0] } else { "" };
let path = Path::new(rel_path.as_str());
let file_stem = paths::stem(path);
let parent = if parts.len() > 1 {
parts[parts.len() - 2]
} else {
""
};
let grouped = parts.len() > 2 && is_canonical_pool_stem(parent);
let identity_stem = if grouped {
parent.to_string()
} else {
file_stem.clone()
};
let parsed = parse_pool_stem(&identity_stem);
let title = rec
.get("title")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from)
.unwrap_or_else(|| parsed.as_ref().map_or(file_stem.clone(), |p| p.0.clone()));
let year = parsed.as_ref().map(|p| p.1.to_string());
let tmdb_id = parse_tmdb_id(&identity_stem);
let label = if grouped {
match file_stem.strip_prefix(&format!("{parent} - ")) {
Some(l) => l.to_string(),
None => file_stem
.get(parent.len()..)
.unwrap_or("")
.trim_start_matches(['.', ' ', '-'])
.to_string(),
}
} else {
String::new()
};
let key = identity_key_for(tmdb_id, &title, year.as_deref());
let entry = json!({"role": ROLE_VIDEO, "label": label, "file": rel_path});
if let Some(existing) = migrated.get_mut(&key) {
if let Some(media) = existing.get_mut("media").and_then(Value::as_array_mut) {
media.push(entry);
}
continue;
}
let get = |k: &str| rec.get(k).cloned().unwrap_or(Value::Null);
let nonempty = |k: &str| rec.get(k).filter(|v| truthy(v)).cloned();
let mut new = json!({
"title": title,
"year": year,
"tmdb_id": tmdb_id,
"library": library,
"media": [entry],
"tags": rec.get("tags").cloned().unwrap_or_else(|| json!([])),
"auto_tags": rec.get("auto_tags").or_else(|| rec.get("csfd_tags")).cloned().unwrap_or_else(|| json!([])),
"csfd_url": nonempty("csfd_url").or_else(|| nonempty("csfd_link")).unwrap_or(Value::Null),
"date": get("date"),
"added": get("added"),
"attributes": rec.get("attributes").cloned().unwrap_or_else(|| json!({})),
});
if let (Some(meta), Some(obj)) = (nonempty("meta"), new.as_object_mut()) {
obj.insert("meta".into(), meta);
}
migrated.insert(key, new);
}
let backup = self
.index_path
.with_file_name(format!("{INDEX_FILENAME}.v1.bak"));
if let Err(e) = fs::copy(&self.index_path, &backup) {
warn!("could not back up the v1 index: {e}");
}
self.records = migrated;
if let Err(e) = self.save() {
warn!("could not save the migrated index: {e}");
}
}
/// Persist the index to disk.
pub fn save(&self) -> CoreResult<()> {
fs::create_dir_all(&self.pool_dir)?;
let data = json!({
"version": INDEX_VERSION,
"settings": self.settings,
"movies": self.records,
});
fs::write(&self.index_path, serde_json::to_string_pretty(&data)?)?;
Ok(())
}
pub fn get_setting(&self, key: &str) -> Option<&Value> {
self.settings.get(key)
}
/// Upsert a library-level setting and persist the index.
pub fn set_setting(&mut self, key: &str, value: Value) -> CoreResult<()> {
self.settings.insert(key.into(), value);
self.save()
}
pub fn get_movie(&self, key: &str) -> Option<&Value> {
self.records.get(key)
}
/// Upsert a movie record and persist the index.
pub fn set_movie(&mut self, key: &str, data: Value) -> CoreResult<()> {
self.records.insert(key.into(), data);
self.save()
}
/// Remove a movie record (if present) and persist the index.
pub fn delete_movie(&mut self, key: &str) -> CoreResult<()> {
if self.records.shift_remove(key).is_some() {
self.save()?;
}
Ok(())
}
/// Move a record to a new identity key (e.g. its TMDb id was set/fixed).
pub fn rekey_movie(&mut self, old_key: &str, new_key: &str) -> CoreResult<()> {
if old_key == new_key {
return Ok(());
}
if let Some(rec) = self.records.shift_remove(old_key) {
self.records.insert(new_key.into(), rec);
self.save()?;
}
Ok(())
}
/// `(key, record)` of the movie owning `file_path`.
pub fn find_movie_by_media(&self, file_path: &Path) -> Option<(String, &Value)> {
let rel = self.rel_key(file_path);
self.records.iter().find_map(|(key, record)| {
let owns = record
.get("media")
.and_then(Value::as_array)
.is_some_and(|media| {
media
.iter()
.any(|m| m.get("file").and_then(Value::as_str) == Some(rel.as_str()))
});
owns.then(|| (key.clone(), record))
})
}
}
/// Python-style truthiness of a JSON value.
pub(crate) fn truthy(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_index_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let idx = PoolIndex::open(dir.path());
assert!(idx.records.is_empty());
assert!(!idx.index_path.exists());
}
#[test]
fn test_set_persists_and_reloads() {
let dir = tempfile::tempdir().unwrap();
let mut idx = PoolIndex::open(dir.path());
idx.set_movie("tmdb-603", json!({"title": "Matrix", "media": []}))
.unwrap();
let raw: Value =
serde_json::from_str(&fs::read_to_string(&idx.index_path).unwrap()).unwrap();
assert_eq!(raw["version"], 2);
let again = PoolIndex::open(dir.path());
assert_eq!(again.get_movie("tmdb-603").unwrap()["title"], "Matrix");
}
#[test]
fn test_delete_and_rekey() {
let dir = tempfile::tempdir().unwrap();
let mut idx = PoolIndex::open(dir.path());
idx.set_movie("name-matrix-1999", json!({"title": "Matrix", "media": []}))
.unwrap();
idx.rekey_movie("name-matrix-1999", "tmdb-603").unwrap();
assert!(idx.get_movie("name-matrix-1999").is_none());
assert!(idx.get_movie("tmdb-603").is_some());
idx.delete_movie("tmdb-603").unwrap();
assert!(PoolIndex::open(dir.path()).records.is_empty());
}
#[test]
fn test_find_movie_by_media() {
let dir = tempfile::tempdir().unwrap();
let mut idx = PoolIndex::open(dir.path());
idx.set_movie(
"tmdb-603",
json!({"media": [{"role": "video", "label": "", "file": "Filmy/Matrix (1999).mkv"}]}),
)
.unwrap();
let found = idx.find_movie_by_media(&dir.path().join("Filmy/Matrix (1999).mkv"));
assert_eq!(found.map(|f| f.0), Some("tmdb-603".to_string()));
assert!(
idx.find_movie_by_media(&dir.path().join("Filmy/Other.mkv"))
.is_none()
);
}
#[test]
fn test_corrupt_index_loads_empty() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(INDEX_FILENAME), "{broken").unwrap();
assert!(PoolIndex::open(dir.path()).records.is_empty());
}
fn write_v1(dir: &Path, movies: Value) {
let data = json!({"settings": {"tag_schema": []}, "movies": movies});
fs::write(
dir.join(INDEX_FILENAME),
serde_json::to_string(&data).unwrap(),
)
.unwrap();
}
#[test]
fn test_v1_flat_movie_becomes_identity_keyed() {
let dir = tempfile::tempdir().unwrap();
write_v1(
dir.path(),
json!({"Filmy/Matrix (1999) [tmdbid-603].mkv": {
"title": "Matrix", "tags": ["Žánr/Akční"], "csfd_link": "https://csfd/1", "meta": {"year": "1999"}}}),
);
let idx = PoolIndex::open(dir.path());
let rec = idx.get_movie("tmdb-603").unwrap();
assert_eq!(rec["year"], "1999");
assert_eq!(rec["library"], "Filmy");
assert_eq!(rec["csfd_url"], "https://csfd/1");
assert_eq!(
rec["media"][0]["file"],
"Filmy/Matrix (1999) [tmdbid-603].mkv"
);
assert_eq!(rec["meta"]["year"], "1999");
assert!(dir.path().join(format!("{INDEX_FILENAME}.v1.bak")).exists());
let raw: Value =
serde_json::from_str(&fs::read_to_string(&idx.index_path).unwrap()).unwrap();
assert_eq!(raw["version"], 2);
assert_eq!(raw["settings"], json!({"tag_schema": []}));
}
#[test]
fn test_v1_grouped_versions_merge() {
let dir = tempfile::tempdir().unwrap();
let folder = "Filmy/Avatar (2009) [tmdbid-19995]";
write_v1(
dir.path(),
json!({
format!("{folder}/Avatar (2009) [tmdbid-19995] - 4K.mkv"): {"title": "Avatar"},
format!("{folder}/Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"): {"title": "Avatar"},
}),
);
let idx = PoolIndex::open(dir.path());
let labels: Vec<&str> = idx.get_movie("tmdb-19995").unwrap()["media"]
.as_array()
.unwrap()
.iter()
.map(|m| m["label"].as_str().unwrap())
.collect();
assert_eq!(labels, vec!["4K", "3D.HSBS"]);
}
#[test]
fn test_v1_without_tmdbid_gets_slug_key() {
let dir = tempfile::tempdir().unwrap();
write_v1(dir.path(), json!({"Filmy/Pelíšky (1999).mkv": {}}));
assert!(
PoolIndex::open(dir.path())
.get_movie("name-pelisky-1999")
.is_some()
);
}
}
-157
View File
@@ -1,157 +0,0 @@
"""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.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from .naming import canonical_pool_stem, parse_pool_stem, parse_tmdb_id
# 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}))")
# Files next to an episode that are not video (NFO / artwork / subtitles).
_SIDECARS = {
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn",
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt",
}
@dataclass
class Episode:
path: Path
season: Optional[int]
episode: Optional[int]
@property
def code(self) -> str:
if self.season is None or self.episode is None:
return "?"
return f"S{self.season:02d}E{self.episode:02d}"
@dataclass
class Series:
folder: Path
title: str
year: Optional[str]
tmdb_id: Optional[int]
library: str
episodes: list[Episode] = field(default_factory=list)
def seasons(self) -> dict[int, list[Episode]]:
"""Episodes grouped by season number (``-1`` = unrecognised)."""
grouped: dict[int, list[Episode]] = {}
for ep in self.episodes:
grouped.setdefault(ep.season if ep.season is not None else -1, []).append(ep)
for eps in grouped.values():
eps.sort(key=lambda e: (e.episode if e.episode is not None else 1_000_000))
return dict(sorted(grouped.items()))
def parse_episode(name: str) -> Optional[tuple[int, int]]:
"""Extract ``(season, episode)`` from a filename, or ``None``."""
match = _EPISODE_RE.search(name)
if not match:
return None
if match.group(1) is not None:
return int(match.group(1)), int(match.group(2))
return int(match.group(3)), int(match.group(4))
def season_folder_name(season: int) -> str:
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
return "Specials" if season == 0 else f"Season {season:02d}"
def episode_body(stem: str) -> str:
"""The descriptive part of an episode filename, minus a leading ``SxxEyy`` code.
Keeps the original (release) filename so it survives into the canonical name,
while stripping an already-applied ``SxxEyy - `` prefix so repeated renames
don't double it. Returns ``""`` when the stem was only the code.
"""
s = _EPISODE_RE.sub("", stem, count=1) # drop the SxxEyy/NxNN token
s = re.sub(r"([\s._-])[\s._-]+", r"\1", s) # collapse separator runs
return s.strip(" ._-")
def episode_filename(season: int, episode: int, orig_stem: str, ext: str) -> str:
"""Canonical episode filename ``SxxEyy - {original name}.ext``.
Keeps the original filename after the code; when the original was only the
code (nothing descriptive left) it collapses to ``SxxEyy.ext``.
"""
code = f"S{season:02d}E{episode:02d}"
body = episode_body(orig_stem)
return f"{code} - {body}{ext}" if body else f"{code}{ext}"
def 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.
"""
title = (title or "").strip()
if not title:
raise ValueError("Název seriálu nesmí být prázdný.")
year_s = str(year).strip() if year is not None else ""
if re.fullmatch(r"\d{4}", year_s):
return canonical_pool_stem(title, year_s, tmdb_id)
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
def is_canonical_episode(path: Path) -> bool:
"""True when ``path`` already follows ``SxxEyy - name`` in the right season."""
parsed = parse_episode(path.name)
if parsed is None:
return False
season, episode = parsed
expected = episode_filename(season, episode, path.stem, path.suffix)
if path.name != expected:
return False
return path.parent.name == season_folder_name(season)
def scan_series(lib_dir: Path, library: str) -> list[Series]:
"""Scan one series library into a list of :class:`Series`.
Each immediate subfolder of ``lib_dir`` is a series; its name yields the
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
parsed from the filename). Non-video sidecars are skipped.
"""
lib_dir = Path(lib_dir)
if not lib_dir.is_dir():
return []
series_list: list[Series] = []
for folder in sorted(p for p in lib_dir.iterdir() if p.is_dir()):
parsed = parse_pool_stem(folder.name)
if parsed is not None:
title, year = parsed[0], str(parsed[1])
else:
title, year = folder.name, None
tmdb_id = parse_tmdb_id(folder.name)
episodes: list[Episode] = []
for f in sorted(folder.rglob("*")):
if not f.is_file() or f.suffix.lower() in _SIDECARS:
continue
code = parse_episode(f.name)
episodes.append(Episode(
f, code[0] if code else None, code[1] if code else None))
series_list.append(Series(folder, title, year, tmdb_id, library, episodes))
return series_list
+497
View File
@@ -0,0 +1,497 @@
//! Series naming + scanning for the Jellyfin TV layout.
//!
//! ```text
//! Seriály/Series (year) [tmdbid-N]/
//! tvshow.nfo (Jellyfin writes it)
//! Season 01/
//! S01E01 - Series.ext
//! Specials/ (season 0)
//! ```
//!
//! Episodes are matched by `SxxEyy`. These helpers parse the episode number out
//! of a raw filename, build the canonical names, and scan a series library into
//! a `Series → Season → Episode` tree.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::{Captures, Regex};
use super::error::{CoreError, CoreResult};
use super::naming::{canonical_stem, is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id};
use super::paths;
// SxxEyy / s1e2 / 1x02 — the episode marker in a filename. First match wins.
static EPISODE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))")
.expect("valid regex")
});
// Jellyfin's "stacking" tokens for one episode split across several files. The
// token must end the stem and must not be glued to a preceding letter/digit
// (checked in code — the regex crate has no look-behind).
static PART_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(?:cd|dvd|part|pt|disc|disk)[\s._-]?(\d{1,2})$").expect("valid regex")
});
// The compact code a user types by hand ("S01E05P2") may also use a bare "P";
// only ever matched against the tail *after* the recognised SxxEyy.
static PART_RE_LOOSE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^[\s._-]?(?:cd|dvd|part|pt|disc|disk|p)[\s._-]?(\d{1,2})$")
.expect("valid regex")
});
static SEASON_DIR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^Season \d{1,3}$").expect("valid regex"));
static FS_UNSAFE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"[<>:"/\\|?*\x00-\x1f]"#).expect("valid regex"));
static WHITESPACE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").expect("valid regex"));
static YEAR4_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d{4}$").expect("valid regex"));
/// Files next to an episode that are not video (NFO / artwork / subtitles).
pub const SIDECARS: &[&str] = &[
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn", ".srt", ".ass", ".ssa",
".sub", ".idx", ".vtt",
];
pub fn is_sidecar(path: &Path) -> bool {
SIDECARS.contains(&paths::suffix_lower(path).as_str())
}
#[derive(Debug, Clone, PartialEq)]
pub struct Episode {
pub path: PathBuf,
pub season: Option<u32>,
pub episode: Option<u32>,
/// Which physical part of the episode this file is — `None` for a whole episode.
pub part: Option<u32>,
}
impl Episode {
/// `S01E02`, `S01E02.pt2`, or `?` when unrecognised.
pub fn code(&self) -> String {
match (self.season, self.episode) {
(Some(s), Some(e)) => {
let base = format!("S{s:02}E{e:02}");
match self.part {
Some(p) if p > 0 => format!("{base}.pt{p}"),
_ => base,
}
}
_ => "?".into(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Series {
pub folder: PathBuf,
pub title: String,
pub year: Option<String>,
pub tmdb_id: Option<i64>,
pub library: String,
pub episodes: Vec<Episode>,
}
impl Series {
/// Episodes grouped by season number (`-1` = unrecognised), each season
/// sorted by episode number.
pub fn seasons(&self) -> BTreeMap<i64, Vec<Episode>> {
let mut grouped: BTreeMap<i64, Vec<Episode>> = BTreeMap::new();
for ep in &self.episodes {
grouped
.entry(ep.season.map_or(-1, i64::from))
.or_default()
.push(ep.clone());
}
for eps in grouped.values_mut() {
eps.sort_by_key(|e| e.episode.unwrap_or(1_000_000));
}
grouped
}
}
fn episode_numbers(caps: &Captures<'_>) -> Option<(u32, u32)> {
if let (Some(s), Some(e)) = (caps.get(1), caps.get(2)) {
return Some((s.as_str().parse().ok()?, e.as_str().parse().ok()?));
}
Some((
caps.get(3)?.as_str().parse().ok()?,
caps.get(4)?.as_str().parse().ok()?,
))
}
/// Extract `(season, episode)` from a filename.
pub fn parse_episode(name: &str) -> Option<(u32, u32)> {
episode_numbers(&EPISODE_RE.captures(name)?)
}
/// Part number implied by a stacking suffix right before the extension
/// (`Show S01E02.pt2.mkv`, `Show S01E02-part-2.mkv`, …).
pub fn parse_episode_part(name: &str) -> Option<u32> {
let stem = paths::stem(Path::new(name));
let mut start = 0;
while let Some(caps) = PART_RE.captures_at(&stem, start) {
let whole = caps.get(0)?;
let before = stem[..whole.start()].chars().next_back();
if !before.is_some_and(|c| c.is_ascii_alphanumeric()) {
return caps[1].parse().ok();
}
start = whole.start() + stem[whole.start()..].chars().next()?.len_utf8();
}
None
}
/// Parse a compact user-typed code: `SxxEyy`, optionally followed by a part
/// marker (`S01E02P3`, `S01E02 pt3`, `S01E02.part.3`, `S01E02 cd2`).
pub fn parse_episode_code(text: &str) -> Option<(u32, u32, Option<u32>)> {
let text = text.trim();
let caps = EPISODE_RE.captures(text)?;
let (season, episode) = episode_numbers(&caps)?;
let end = caps.get(0)?.end();
let part = PART_RE_LOOSE
.captures(&text[end..])
.and_then(|c| c[1].parse().ok());
Some((season, episode, part))
}
/// Every video file under `folder` (recursively), sidecars excluded, sorted.
pub fn collect_episode_files(folder: &Path) -> Vec<PathBuf> {
if !folder.is_dir() {
return Vec::new();
}
paths::walk_files(folder)
.into_iter()
.filter(|f| !is_sidecar(f))
.collect()
}
/// Folder name for a season — `Specials` for 0, else `Season NN`.
pub fn season_folder_name(season: u32) -> String {
if season == 0 {
"Specials".into()
} else {
format!("Season {season:02}")
}
}
/// True for `Specials` or `Season <n>` (any padding).
pub fn is_season_folder(name: &str) -> bool {
name == "Specials" || SEASON_DIR_RE.is_match(name)
}
/// True when `folder` holds at least one season subfolder — the shape Curator
/// always gives a series; a movie's version folder never has one.
pub fn looks_like_series_folder(folder: &Path) -> bool {
folder.is_dir()
&& paths::subdirs(folder)
.iter()
.any(|p| is_season_folder(&paths::name(p)))
}
/// A title made safe to sit in a filename (spaces collapsed, edge dots trimmed).
pub fn sanitize_title(title: &str) -> String {
let cleaned = FS_UNSAFE_RE.replace_all(title, "");
WHITESPACE_RE
.replace_all(&cleaned, " ")
.trim_matches(|c| c == ' ' || c == '.')
.to_string()
}
/// Canonical episode filename `SxxEyy - {series title}[.ptN].ext`.
///
/// The release string is deliberately dropped — identity is the code, and
/// Jellyfin fetches the episode title from TMDb.
pub fn episode_filename(
season: u32,
episode: u32,
series_title: &str,
ext: &str,
part: Option<u32>,
) -> String {
let code = format!("S{season:02}E{episode:02}");
let body = sanitize_title(series_title);
let mut name = if body.is_empty() {
code
} else {
format!("{code} - {body}")
};
if let Some(p) = part.filter(|p| *p > 0) {
name.push_str(&format!(".pt{p}"));
}
format!("{name}{ext}")
}
/// Series folder name: `Title (year) [tmdbid-N]` (year/id optional).
pub fn series_folder_stem(
title: &str,
year: Option<&str>,
tmdb_id: Option<i64>,
) -> CoreResult<String> {
let title = title.trim();
if title.is_empty() {
return Err(CoreError::invalid("Název seriálu nesmí být prázdný."));
}
let year_s = year.unwrap_or("").trim();
if YEAR4_RE.is_match(year_s) {
return canonical_stem(title, year_s, tmdb_id, None);
}
Ok(match tmdb_id {
Some(id) if id >= 0 => format!("{title} [tmdbid-{id}]"),
_ => title.to_string(),
})
}
/// True when `path` is already `SxxEyy - {series title}.ext` in its season folder.
pub fn is_canonical_episode(path: &Path, series_title: &str) -> bool {
let name = paths::name(path);
let Some((season, episode)) = parse_episode(&name) else {
return false;
};
let part = parse_episode_part(&name);
let expected = episode_filename(season, episode, series_title, &paths::suffix(path), part);
name == expected
&& path.parent().map(paths::name).as_deref() == Some(season_folder_name(season).as_str())
}
/// Scan one series library into a list of [`Series`].
///
/// Each immediate subfolder is a series; video files anywhere below it are
/// episodes. With `skip_movie_folders` (a `mixed` library) a canonical-shaped
/// folder without any season folder is a movie's version folder and is skipped.
pub fn scan_series(lib_dir: &Path, library: &str, skip_movie_folders: bool) -> Vec<Series> {
if !lib_dir.is_dir() {
return Vec::new();
}
let mut out = Vec::new();
for folder in paths::subdirs(lib_dir) {
let folder_name = paths::name(&folder);
if skip_movie_folders
&& is_canonical_pool_stem(&folder_name)
&& !looks_like_series_folder(&folder)
{
continue;
}
let (title, year) = match parse_pool_stem(&folder_name) {
Some((t, y)) => (t, Some(y.to_string())),
None => (folder_name.clone(), None),
};
let episodes = paths::walk_files(&folder)
.into_iter()
.filter(|f| !is_sidecar(f))
.map(|f| {
let name = paths::name(&f);
let code = parse_episode(&name);
Episode {
season: code.map(|c| c.0),
episode: code.map(|c| c.1),
part: parse_episode_part(&name),
path: f,
}
})
.collect();
out.push(Series {
tmdb_id: parse_tmdb_id(&folder_name),
folder,
title,
year,
library: library.into(),
episodes,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_parse_episode_cases() {
for (name, expected) in [
("Breaking Bad S01E02.mkv", Some((1, 2))),
("bb.s1e2.mkv", Some((1, 2))),
("Show 01x02 x264.mkv", Some((1, 2))),
("Series.S01E02E03.mkv", Some((1, 2))),
("Series S12E145.mkv", Some((12, 145))),
("No code here.mkv", None),
] {
assert_eq!(parse_episode(name), expected, "{name}");
}
}
#[test]
fn test_names() {
assert_eq!(season_folder_name(0), "Specials");
assert_eq!(season_folder_name(12), "Season 12");
assert_eq!(
episode_filename(1, 2, "Most!", ".mkv", None),
"S01E02 - Most!.mkv"
);
assert_eq!(episode_filename(3, 4, "", ".mkv", None), "S03E04.mkv");
assert_eq!(episode_filename(3, 4, "/", ".mkv", None), "S03E04.mkv");
assert_eq!(
episode_filename(1, 2, "Most!", ".mkv", Some(3)),
"S01E02 - Most!.pt3.mkv"
);
assert_eq!(
episode_filename(1, 2, "", ".mkv", Some(2)),
"S01E02.pt2.mkv"
);
}
#[test]
fn test_sanitize_title_cases() {
assert_eq!(sanitize_title("Most!"), "Most!");
assert_eq!(sanitize_title("A: B / C D"), "A B C D");
assert_eq!(sanitize_title(" trailing. "), "trailing");
}
#[test]
fn test_is_season_folder_cases() {
assert!(is_season_folder("Season 01"));
assert!(is_season_folder("Season 1"));
assert!(is_season_folder("Specials"));
assert!(!is_season_folder("Extras"));
assert!(!is_season_folder("Season"));
}
#[test]
fn test_series_folder_stem_cases() {
assert_eq!(
series_folder_stem("Breaking Bad", Some("2008"), Some(1396)).unwrap(),
"Breaking Bad (2008) [tmdbid-1396]"
);
assert_eq!(
series_folder_stem("Neznámý", None, Some(42)).unwrap(),
"Neznámý [tmdbid-42]"
);
assert_eq!(
series_folder_stem("Jen název", None, None).unwrap(),
"Jen název"
);
assert!(series_folder_stem(" ", None, None).is_err());
}
#[test]
fn test_parse_episode_part_cases() {
for (name, expected) in [
("Show S01E02.pt2.mkv", Some(2)),
("Show S01E01-part-1.mkv", Some(1)),
("Show S01E01 part 1.mkv", Some(1)),
("Show S01E01_cd1.mkv", Some(1)),
("Show S01E01.disc2.mkv", Some(2)),
("Show S01E01.mkv", None),
("Show Abcd2.mkv", None),
("Show Part of Something.mkv", None),
] {
assert_eq!(parse_episode_part(name), expected, "{name}");
}
}
#[test]
fn test_parse_episode_code_cases() {
for (text, expected) in [
("S01E02P3", Some((1, 2, Some(3)))),
("s1e2p3", Some((1, 2, Some(3)))),
("S01E02 pt3", Some((1, 2, Some(3)))),
("S01E02.part.3", Some((1, 2, Some(3)))),
("S01E02 cd2", Some((1, 2, Some(2)))),
("S01E02", Some((1, 2, None))),
("1x02", Some((1, 2, None))),
("no code here", None),
] {
assert_eq!(parse_episode_code(text), expected, "{text}");
}
}
#[test]
fn test_episode_code_property() {
let ep = |s, e, p| Episode {
path: PathBuf::new(),
season: s,
episode: e,
part: p,
};
assert_eq!(ep(Some(1), Some(2), Some(3)).code(), "S01E02.pt3");
assert_eq!(ep(Some(1), Some(2), None).code(), "S01E02");
assert_eq!(ep(None, None, None).code(), "?");
}
#[test]
fn test_is_canonical_episode_cases() {
let dir = tempfile::tempdir().unwrap();
let season = dir.path().join("Season 01");
assert!(is_canonical_episode(
&season.join("S01E02 - Breaking Bad.mkv"),
"Breaking Bad"
));
assert!(!is_canonical_episode(
&dir.path().join("S01E02 - Breaking Bad.mkv"),
"Breaking Bad"
));
assert!(!is_canonical_episode(
&season.join("S01E02 - Pilot.mkv"),
"Breaking Bad"
));
assert!(is_canonical_episode(
&season.join("S01E02 - Show.pt2.mkv"),
"Show"
));
}
#[test]
fn test_scan_series_reads_tree() {
let dir = tempfile::tempdir().unwrap();
let s1 = dir
.path()
.join("Breaking Bad (2008) [tmdbid-1396]/Season 01");
fs::create_dir_all(&s1).unwrap();
fs::write(s1.join("S01E01 - Breaking Bad.mkv"), "").unwrap();
fs::write(s1.join("S01E02 - Breaking Bad.mkv"), "").unwrap();
fs::write(s1.join("S01E02 - Breaking Bad.cs.srt"), "").unwrap();
let found = scan_series(dir.path(), "Seriály", false);
assert_eq!(found.len(), 1);
let ser = &found[0];
assert_eq!(
(ser.title.as_str(), ser.year.as_deref(), ser.tmdb_id),
("Breaking Bad", Some("2008"), Some(1396))
);
assert_eq!(ser.episodes.len(), 2);
assert_eq!(ser.seasons().keys().copied().collect::<Vec<_>>(), vec![1]);
}
#[test]
fn test_scan_series_skips_movie_folders_in_mixed() {
let dir = tempfile::tempdir().unwrap();
let movie = dir.path().join("Avatar (2009) [tmdbid-19995]");
fs::create_dir_all(&movie).unwrap();
fs::write(movie.join("Avatar (2009) [tmdbid-19995] - 4K.mkv"), "").unwrap();
assert!(scan_series(dir.path(), "Mix", true).is_empty());
assert_eq!(scan_series(dir.path(), "Mix", false).len(), 1);
}
#[test]
fn test_collect_episode_files_walks_subfolders() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("Breaking.Bad.COMPLETE");
for f in [
"Season 01/BB S01E01.mkv",
"Season 01/BB S01E02.mkv",
"Season 01/BB S01E02.srt",
"Season 02/BB S02E01.avi",
"readme.nfo",
] {
fs::create_dir_all(root.join(f).parent().unwrap()).unwrap();
fs::write(root.join(f), "x").unwrap();
}
let names: Vec<String> = collect_episode_files(&root)
.iter()
.map(|p| paths::name(p))
.collect();
assert_eq!(
names,
vec!["BB S01E01.mkv", "BB S01E02.mkv", "BB S02E01.avi"]
);
assert!(collect_episode_files(&dir.path().join("nope")).is_empty());
}
}
+282
View File
@@ -0,0 +1,282 @@
//! External subtitle sidecars — attach, discover, keep in sync on rename.
//!
//! 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:
//!
//! ```text
//! 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 — nothing is cached in the pool index.
use std::fs;
use std::path::{Path, PathBuf};
use super::error::{CoreError, CoreResult};
use super::paths;
pub const SUBTITLE_EXTENSIONS: &[&str] = &[".srt", ".ass", ".ssa", ".sub", ".vtt"];
const FLAGS: &[&str] = &["forced", "sdh", "default"];
/// One subtitle sidecar found next to a video.
#[derive(Debug, Clone, PartialEq)]
pub struct SubtitleFile {
pub path: PathBuf,
/// ISO 639-1/2 code, e.g. "cs".
pub language: Option<String>,
/// "forced" | "sdh" | "default".
pub flag: Option<String>,
}
impl SubtitleFile {
/// Human-readable `jazyk (flag)`, e.g. `cs (forced)`.
pub fn label(&self) -> String {
let text = self.language.as_deref().unwrap_or("?");
match &self.flag {
Some(f) => format!("{text} ({f})"),
None => text.to_string(),
}
}
}
pub fn is_subtitle(path: &Path) -> bool {
SUBTITLE_EXTENSIONS.contains(&paths::suffix_lower(path).as_str())
}
/// Canonical subtitle filename: `{video_stem}[.language][.flag]{ext}`.
pub fn subtitle_filename(
video_stem: &str,
ext: &str,
language: Option<&str>,
flag: Option<&str>,
) -> String {
let mut parts = vec![video_stem.to_string()];
if let Some(l) = language.filter(|l| !l.is_empty()) {
parts.push(l.trim().to_lowercase());
}
if let Some(f) = flag.filter(|f| !f.is_empty()) {
parts.push(f.trim().to_lowercase());
}
parts.join(".") + &ext.to_lowercase()
}
fn is_language_code(token: &str) -> bool {
(2..=3).contains(&token.len()) && token.chars().all(|c| c.is_ascii_lowercase())
}
/// `(language, flag)` if `filename` is a subtitle sidecar of `video_stem`.
fn parse_subtitle_name(
video_stem: &str,
filename: &str,
) -> Option<(Option<String>, Option<String>)> {
let candidate = Path::new(filename);
if !is_subtitle(candidate) {
return None;
}
let stem = paths::stem(candidate);
if stem == video_stem {
return Some((None, None));
}
let rest = stem.strip_prefix(&format!("{video_stem}."))?;
let mut language = None;
let mut flag = None;
for token in rest.split('.').filter(|t| !t.is_empty()) {
let low = token.to_lowercase();
if FLAGS.contains(&low.as_str()) {
flag = Some(low);
} else if language.is_none() && is_language_code(&low) {
language = Some(low);
}
}
Some((language, flag))
}
/// Every external subtitle sidecar sitting next to `video_path`, sorted.
pub fn find_subtitles(video_path: &Path) -> Vec<SubtitleFile> {
let Some(folder) = video_path.parent().filter(|f| f.is_dir()) else {
return Vec::new();
};
let video_stem = paths::stem(video_path);
paths::entries(folder)
.into_iter()
.filter(|f| f.is_file())
.filter_map(|f| {
let (language, flag) = parse_subtitle_name(&video_stem, &paths::name(&f))?;
Some(SubtitleFile {
path: f,
language,
flag,
})
})
.collect()
}
/// Copy (or move) `source` next to `video_path`, named to match it. An existing
/// subtitle at that exact name is overwritten.
pub fn attach_subtitle(
video_path: &Path,
source: &Path,
language: Option<&str>,
flag: Option<&str>,
move_source: bool,
) -> CoreResult<PathBuf> {
let ext = paths::suffix_lower(source);
if !SUBTITLE_EXTENSIONS.contains(&ext.as_str()) {
return Err(CoreError::invalid(format!(
"Nepodporovaný formát titulků: {}",
paths::suffix(source)
)));
}
let folder = video_path.parent().unwrap_or(Path::new("."));
let target = folder.join(subtitle_filename(
&paths::stem(video_path),
&ext,
language,
flag,
));
if target.exists() {
fs::remove_file(&target)?;
}
if move_source {
paths::move_file(source, &target)?;
} else {
paths::copy_preserving(source, &target)?;
}
Ok(target)
}
/// Move every subtitle sidecar of `old_video` to match `new_video`, so an
/// attached subtitle never silently detaches. Returns the new paths.
pub fn rename_subtitles_with(old_video: &Path, new_video: &Path) -> CoreResult<Vec<PathBuf>> {
if old_video == new_video {
return Ok(Vec::new());
}
let new_folder = new_video.parent().unwrap_or(Path::new("."));
let new_stem = paths::stem(new_video);
let mut moved = Vec::new();
for sub in find_subtitles(old_video) {
let target = new_folder.join(subtitle_filename(
&new_stem,
&paths::suffix(&sub.path),
sub.language.as_deref(),
sub.flag.as_deref(),
));
if sub.path == target {
continue;
}
fs::create_dir_all(new_folder)?;
if target.exists() {
fs::remove_file(&target)?;
}
paths::move_file(&sub.path, &target)?;
moved.push(target);
}
Ok(moved)
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(p: &Path) {
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, "x").unwrap();
}
#[test]
fn test_subtitle_filename_variants() {
assert_eq!(
subtitle_filename("Film (2009)", ".srt", None, None),
"Film (2009).srt"
);
assert_eq!(
subtitle_filename("Film (2009)", ".srt", Some("cs"), None),
"Film (2009).cs.srt"
);
assert_eq!(
subtitle_filename("Film (2009)", ".SRT", Some("CS"), Some("Forced")),
"Film (2009).cs.forced.srt"
);
}
#[test]
fn test_find_subtitles_bare_language_and_flagged() {
let dir = tempfile::tempdir().unwrap();
let video = dir.path().join("Film (2009).mkv");
touch(&video);
touch(&dir.path().join("Film (2009).srt"));
touch(&dir.path().join("Film (2009).cs.srt"));
touch(&dir.path().join("Film (2009).en.forced.ass"));
touch(&dir.path().join("Film (2009) 2.cs.srt"));
touch(&dir.path().join("Jiný film.cs.srt"));
let found: Vec<(Option<String>, Option<String>)> = find_subtitles(&video)
.into_iter()
.map(|s| (s.language, s.flag))
.collect();
assert_eq!(
found,
vec![
(Some("cs".into()), None),
(Some("en".into()), Some("forced".into())),
(None, None),
]
);
assert!(find_subtitles(&dir.path().join("nope/x.mkv")).is_empty());
}
#[test]
fn test_label() {
let s = SubtitleFile {
path: PathBuf::new(),
language: Some("cs".into()),
flag: Some("forced".into()),
};
assert_eq!(s.label(), "cs (forced)");
let s = SubtitleFile {
path: PathBuf::new(),
language: None,
flag: None,
};
assert_eq!(s.label(), "?");
}
#[test]
fn test_attach_copies_moves_and_overwrites() {
let dir = tempfile::tempdir().unwrap();
let video = dir.path().join("movie/Film (2009).mkv");
touch(&video);
let src = dir.path().join("download/titulky.SRT");
touch(&src);
let target = attach_subtitle(&video, &src, Some("cs"), None, false).unwrap();
assert_eq!(target, dir.path().join("movie/Film (2009).cs.srt"));
assert!(src.exists());
fs::write(&src, "new").unwrap();
attach_subtitle(&video, &src, Some("cs"), None, true).unwrap();
assert!(!src.exists());
assert_eq!(fs::read_to_string(&target).unwrap(), "new");
let bad = dir.path().join("x.txt");
touch(&bad);
assert!(attach_subtitle(&video, &bad, None, None, false).is_err());
}
#[test]
fn test_rename_subtitles_with_moves_across_folders() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("Filmy/Old (2009).mkv");
touch(&old);
touch(&dir.path().join("Filmy/Old (2009).cs.forced.srt"));
let new = dir.path().join("Filmy/New (2009)/New (2009) - 4K.mkv");
let moved = rename_subtitles_with(&old, &new).unwrap();
assert_eq!(
moved,
vec![
dir.path()
.join("Filmy/New (2009)/New (2009) - 4K.cs.forced.srt")
]
);
assert!(moved[0].exists());
assert!(rename_subtitles_with(&old, &old).unwrap().is_empty());
}
}
-22
View File
@@ -1,22 +0,0 @@
class Tag:
def __init__(self, category: str, name: str):
self.category = category
self.name = name
@property
def full_path(self):
return f"{self.category}/{self.name}"
def __str__(self):
return self.full_path
def __repr__(self):
return f"Tag({self.full_path})"
def __eq__(self, other):
if isinstance(other, Tag):
return self.category == other.category and self.name == other.name
return False
def __hash__(self):
return hash((self.category, self.name))
+57
View File
@@ -0,0 +1,57 @@
//! A tag: a `Category/Name` pair attached to a movie.
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Tag {
pub category: String,
pub name: String,
}
impl Tag {
pub fn new(category: impl Into<String>, name: impl Into<String>) -> Self {
Self {
category: category.into(),
name: name.into(),
}
}
/// `Category/Name` — the form tags are stored and filtered by.
pub fn full_path(&self) -> String {
format!("{}/{}", self.category, self.name)
}
/// Parse a `Category/Name` path (split on the first `/`).
pub fn from_full_path(path: &str) -> Option<Self> {
let (category, name) = path.split_once('/')?;
Some(Self::new(category, name))
}
}
impl fmt::Display for Tag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.category, self.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_full_path_joins_category_and_name() {
assert_eq!(Tag::new("Žánr", "Drama").full_path(), "Žánr/Drama");
}
#[test]
fn test_from_full_path_splits_on_first_slash() {
assert_eq!(Tag::from_full_path("A/B/C"), Some(Tag::new("A", "B/C")));
assert_eq!(Tag::from_full_path("nocategory"), None);
}
#[test]
fn test_equality_compares_both_parts() {
assert_eq!(Tag::new("A", "x"), Tag::new("A", "x"));
assert_ne!(Tag::new("A", "x"), Tag::new("B", "x"));
}
}
-67
View File
@@ -1,67 +0,0 @@
from .tag import Tag
# Default tags that are always available (order in list = display order).
# The legacy Tagger presets (Hodnocení / Barva) were removed for Curator; the
# pool is driven by ČSFD-derived tags (Žánr / Rok / Země původu). Add entries here to
# reintroduce always-available predefined tags.
DEFAULT_TAGS: dict[str, list[str]] = {}
# Tag sort order for default categories (preserves display order)
DEFAULT_TAG_ORDER: dict[str, dict[str, int]] = {
category: {name: i for i, name in enumerate(names)}
for category, names in DEFAULT_TAGS.items()
}
class TagManager:
def __init__(self):
self.tags_by_category = {} # {category: set(Tag)}
self._init_default_tags()
def _init_default_tags(self):
"""Initialize default tags (ratings and colors)"""
for category, tags in DEFAULT_TAGS.items():
for tag_name in tags:
self.add_tag(category, tag_name)
def add_category(self, category: str):
if category not in self.tags_by_category:
self.tags_by_category[category] = set()
def remove_category(self, category: str):
if category in self.tags_by_category:
del self.tags_by_category[category]
def add_tag(self, category: str, name: str) -> Tag:
self.add_category(category)
tag = Tag(category, name)
self.tags_by_category[category].add(tag)
return tag
def remove_tag(self, category: str, name: str):
if category in self.tags_by_category:
tag = Tag(category, name)
self.tags_by_category[category].discard(tag)
if not self.tags_by_category[category]:
self.remove_category(category)
def get_all_tags(self):
"""Vrací list všech tagů full_path"""
return [tag.full_path for tags in self.tags_by_category.values() for tag in tags]
def get_categories(self):
return list(self.tags_by_category.keys())
def get_tags_in_category(self, category: str) -> list[Tag]:
"""Get tags in category, sorted by predefined order for default categories"""
tags = list(self.tags_by_category.get(category, []))
# Use predefined order for default categories
if category in DEFAULT_TAG_ORDER:
order = DEFAULT_TAG_ORDER[category]
tags.sort(key=lambda t: order.get(t.name, 999))
else:
# Sort alphabetically for custom categories
tags.sort(key=lambda t: t.name)
return tags
+130
View File
@@ -0,0 +1,130 @@
//! The registry of all tags currently known, grouped by category.
use std::collections::BTreeSet;
use super::tag::Tag;
/// Categories keep their insertion order (the sidebar shows them that way);
/// tags inside a category are sorted by name.
#[derive(Debug, Default, Clone)]
pub struct TagManager {
categories: Vec<(String, BTreeSet<Tag>)>,
}
impl TagManager {
pub fn new() -> Self {
Self::default()
}
fn position(&self, category: &str) -> Option<usize> {
self.categories.iter().position(|(c, _)| c == category)
}
pub fn add_category(&mut self, category: &str) {
if self.position(category).is_none() {
self.categories
.push((category.to_string(), BTreeSet::new()));
}
}
pub fn remove_category(&mut self, category: &str) {
if let Some(i) = self.position(category) {
self.categories.remove(i);
}
}
pub fn add_tag(&mut self, category: &str, name: &str) -> Tag {
self.add_category(category);
let tag = Tag::new(category, name);
if let Some(i) = self.position(category) {
self.categories[i].1.insert(tag.clone());
}
tag
}
/// Remove a tag; a category left empty disappears with it.
pub fn remove_tag(&mut self, category: &str, name: &str) {
if let Some(i) = self.position(category) {
self.categories[i].1.remove(&Tag::new(category, name));
if self.categories[i].1.is_empty() {
self.categories.remove(i);
}
}
}
pub fn clear(&mut self) {
self.categories.clear();
}
pub fn all_tags(&self) -> Vec<String> {
self.categories
.iter()
.flat_map(|(_, tags)| tags.iter().map(Tag::full_path))
.collect()
}
pub fn categories(&self) -> Vec<String> {
self.categories.iter().map(|(c, _)| c.clone()).collect()
}
/// Tags of one category, sorted by name.
pub fn tags_in_category(&self, category: &str) -> Vec<Tag> {
let mut tags: Vec<Tag> = self
.position(category)
.map(|i| self.categories[i].1.iter().cloned().collect())
.unwrap_or_default();
tags.sort_by(|a, b| a.name.cmp(&b.name));
tags
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_tag_creates_category() {
let mut tm = TagManager::new();
tm.add_tag("Žánr", "Drama");
assert_eq!(tm.categories(), vec!["Žánr"]);
assert_eq!(tm.all_tags(), vec!["Žánr/Drama"]);
}
#[test]
fn test_add_tag_is_idempotent() {
let mut tm = TagManager::new();
tm.add_tag("Rok", "1999");
tm.add_tag("Rok", "1999");
assert_eq!(tm.tags_in_category("Rok").len(), 1);
}
#[test]
fn test_remove_last_tag_drops_category() {
let mut tm = TagManager::new();
tm.add_tag("Rok", "1999");
tm.remove_tag("Rok", "1999");
assert!(tm.categories().is_empty());
}
#[test]
fn test_tags_in_category_sorted_by_name() {
let mut tm = TagManager::new();
tm.add_tag("Žánr", "Komedie");
tm.add_tag("Žánr", "Akční");
let names: Vec<String> = tm
.tags_in_category("Žánr")
.into_iter()
.map(|t| t.name)
.collect();
assert_eq!(names, vec!["Akční", "Komedie"]);
}
#[test]
fn test_categories_keep_insertion_order() {
let mut tm = TagManager::new();
tm.add_tag("Žánr", "Drama");
tm.add_tag("Rok", "1999");
tm.add_tag("Země původu", "Česko");
assert_eq!(tm.categories(), vec!["Žánr", "Rok", "Země původu"]);
}
}
-137
View File
@@ -1,137 +0,0 @@
"""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: :func:`api_available`
reports readiness and the lookups raise :class:`TmdbError` with a clear message.
"""
import os
from dataclasses import dataclass
from typing import Optional
import requests
try: # optional — the key can also come from the real environment
from dotenv import load_dotenv
load_dotenv()
except Exception: # noqa: BLE001 — dotenv is a convenience, never required
pass
_BASE = "https://api.themoviedb.org/3"
_LANG = os.getenv("TMDB_LANGUAGE", "cs-CZ")
_TIMEOUT = 15
@dataclass
class TmdbResult:
"""A resolved TMDb entry — just enough to name the pool file."""
tmdb_id: int
title: str
year: Optional[str] # four-digit string, or None when TMDb has no date
kind: str # "movie" | "tv"
class TmdbError(RuntimeError):
"""A TMDb request could not be completed (no key, network, bad status)."""
def api_key() -> Optional[str]:
"""The configured TMDb API key, or None."""
key = os.getenv("TMDB_API_KEY")
return key.strip() if key and key.strip() else None
def api_available() -> bool:
"""True when a TMDb API key is configured."""
return api_key() is not None
def _get(path: str, params: dict) -> dict:
key = api_key()
if not key:
raise TmdbError("TMDB_API_KEY není nastaven (.env).")
query = {"api_key": key, "language": _LANG, **params}
try:
resp = requests.get(f"{_BASE}{path}", params=query, timeout=_TIMEOUT)
except requests.RequestException as exc:
raise TmdbError(f"Síťová chyba TMDb: {exc}") from exc
if resp.status_code == 401:
raise TmdbError("Neplatný TMDB_API_KEY.")
if resp.status_code == 404:
raise TmdbError("TMDb ID nenalezeno.")
if not resp.ok:
raise TmdbError(f"TMDb odpověděl HTTP {resp.status_code}.")
return resp.json()
def _year_of(date_str: Optional[str]) -> Optional[str]:
if date_str and len(date_str) >= 4 and date_str[:4].isdigit():
return date_str[:4]
return None
def lookup_movie(tmdb_id: int) -> Optional[TmdbResult]:
"""Resolve a movie by its TMDb id → title + year, or None if it has no title."""
data = _get(f"/movie/{int(tmdb_id)}", {})
title = data.get("title") or data.get("original_title")
if not title:
return None
return TmdbResult(int(data["id"]), title, _year_of(data.get("release_date")), "movie")
def lookup_tv(tmdb_id: int) -> Optional[TmdbResult]:
"""Resolve a series by its TMDb id → name + first-air year, or None."""
data = _get(f"/tv/{int(tmdb_id)}", {})
title = data.get("name") or data.get("original_name")
if not title:
return None
return TmdbResult(int(data["id"]), title, _year_of(data.get("first_air_date")), "tv")
def lookup(tmdb_id: int, kind: str = "movie") -> Optional[TmdbResult]:
"""Resolve a TMDb id as a movie (default) or a series (``kind="tv"``)."""
return lookup_tv(tmdb_id) if kind == "tv" else lookup_movie(tmdb_id)
def search_movie(
query: str, year: Optional[str] = None, limit: int = 5
) -> list[TmdbResult]:
"""Search movies by title (optionally narrowed by year); best matches first."""
if not query.strip():
return []
params: dict = {"query": query.strip()}
if year:
params["year"] = year
data = _get("/search/movie", params)
results: list[TmdbResult] = []
for item in (data.get("results") or [])[:limit]:
title = item.get("title") or item.get("original_title")
if title and item.get("id"):
results.append(
TmdbResult(int(item["id"]), title,
_year_of(item.get("release_date")), "movie"))
return results
def search_tv(
query: str, year: Optional[str] = None, limit: int = 5
) -> list[TmdbResult]:
"""Search series by name (optionally narrowed by first-air year)."""
if not query.strip():
return []
params: dict = {"query": query.strip()}
if year:
params["first_air_date_year"] = year
data = _get("/search/tv", params)
results: list[TmdbResult] = []
for item in (data.get("results") or [])[:limit]:
title = item.get("name") or item.get("original_name")
if title and item.get("id"):
results.append(
TmdbResult(int(item["id"]), title,
_year_of(item.get("first_air_date")), "tv"))
return results
+293
View File
@@ -0,0 +1,293 @@
//! TMDb (The Movie Database) client — Curator's identity source.
//!
//! Turns a **TMDb id** (or a title/year search) into a canonical **title +
//! year** for naming, and supplies genres/countries/rating for tags. Needs a
//! free API key in `TMDB_API_KEY` (from `.env`). Everything degrades gracefully
//! when the key or the network is missing.
use serde_json::Value;
use super::error::CoreError;
use super::http::{client, env_nonempty};
const BASE: &str = "https://api.themoviedb.org/3";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Movie,
Tv,
}
/// A resolved TMDb entry — just enough to name the pool file.
#[derive(Debug, Clone, PartialEq)]
pub struct TmdbResult {
pub tmdb_id: i64,
pub title: String,
/// Four-digit string, or `None` when TMDb has no date.
pub year: Option<String>,
pub kind: Kind,
}
/// Full movie data used to (re)generate a movie's tags.
#[derive(Debug, Clone, PartialEq)]
pub struct TmdbDetails {
pub tmdb_id: i64,
pub title: String,
pub year: Option<String>,
pub genres: Vec<String>,
pub countries: Vec<String>,
/// 0–100 (vote_average 0–10 scaled up).
pub rating: Option<i64>,
}
impl TmdbDetails {
pub fn field_values(&self, name: &str) -> Vec<String> {
match name {
"genres" => self.genres.clone(),
"countries" => self.countries.clone(),
"year" => self.year.iter().cloned().collect(),
"rating" => self.rating.iter().map(|r| r.to_string()).collect(),
_ => Vec::new(),
}
}
}
pub type TmdbResultT<T> = Result<T, CoreError>;
fn err(msg: impl Into<String>) -> CoreError {
CoreError::Remote(msg.into())
}
/// The configured TMDb API key.
pub fn api_key() -> Option<String> {
env_nonempty("TMDB_API_KEY")
}
pub fn api_available() -> bool {
api_key().is_some()
}
fn language() -> String {
env_nonempty("TMDB_LANGUAGE").unwrap_or_else(|| "cs-CZ".into())
}
fn get(path: &str, params: &[(&str, String)]) -> TmdbResultT<Value> {
let key = api_key().ok_or_else(|| err("TMDB_API_KEY není nastaven (.env)."))?;
let mut query: Vec<(&str, String)> = vec![("api_key", key), ("language", language())];
query.extend(params.iter().cloned());
let resp = client()
.get(format!("{BASE}{path}"))
.query(&query)
.timeout(std::time::Duration::from_secs(15))
.send()
.map_err(|e| err(format!("Síťová chyba TMDb: {e}")))?;
match resp.status().as_u16() {
401 => return Err(err("Neplatný TMDB_API_KEY.")),
404 => return Err(err("TMDb ID nenalezeno.")),
s if !resp.status().is_success() => return Err(err(format!("TMDb odpověděl HTTP {s}."))),
_ => {}
}
resp.json()
.map_err(|e| err(format!("Neplatná odpověď TMDb: {e}")))
}
fn year_of(date: Option<&str>) -> Option<String> {
let d = date?;
let head = d.get(..4)?;
head.chars()
.all(|c| c.is_ascii_digit())
.then(|| head.to_string())
}
fn str_of<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|k| v.get(*k).and_then(Value::as_str).filter(|s| !s.is_empty()))
}
fn id_of(v: &Value) -> Option<i64> {
v.get("id").and_then(Value::as_i64).filter(|i| *i != 0)
}
/// Parse a movie or TV payload (detail or search item) into a [`TmdbResult`].
pub fn parse_result(data: &Value, kind: Kind) -> Option<TmdbResult> {
let (title_keys, date_key): (&[&str], &str) = match kind {
Kind::Movie => (&["title", "original_title"], "release_date"),
Kind::Tv => (&["name", "original_name"], "first_air_date"),
};
Some(TmdbResult {
tmdb_id: id_of(data)?,
title: str_of(data, title_keys)?.to_string(),
year: year_of(data.get(date_key).and_then(Value::as_str)),
kind,
})
}
/// Parse a `/movie/{id}` payload into [`TmdbDetails`].
pub fn parse_details(data: &Value) -> Option<TmdbDetails> {
let names = |key: &str| -> Vec<String> {
data.get(key)
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|g| {
g.get("name")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from)
})
.collect()
})
.unwrap_or_default()
};
let rating = data
.get("vote_average")
.and_then(Value::as_f64)
.filter(|v| *v != 0.0)
.map(|v| (v * 10.0).round_ties_even() as i64);
Some(TmdbDetails {
tmdb_id: data.get("id").and_then(Value::as_i64)?,
title: str_of(data, &["title", "original_title"])
.unwrap_or("")
.to_string(),
year: year_of(data.get("release_date").and_then(Value::as_str)),
genres: names("genres"),
countries: names("production_countries"),
rating,
})
}
/// Resolve a TMDb id as a movie or a series; `Ok(None)` when it has no title.
pub fn lookup(tmdb_id: i64, kind: Kind) -> TmdbResultT<Option<TmdbResult>> {
let path = match kind {
Kind::Movie => format!("/movie/{tmdb_id}"),
Kind::Tv => format!("/tv/{tmdb_id}"),
};
Ok(parse_result(&get(&path, &[])?, kind))
}
pub fn lookup_movie(tmdb_id: i64) -> TmdbResultT<Option<TmdbResult>> {
lookup(tmdb_id, Kind::Movie)
}
/// Full movie data from TMDb (genres/countries/rating/year).
pub fn movie_details(tmdb_id: i64) -> TmdbResultT<TmdbDetails> {
parse_details(&get(&format!("/movie/{tmdb_id}"), &[])?)
.ok_or_else(|| err("Neplatná odpověď TMDb."))
}
fn search(
kind: Kind,
query: &str,
year: Option<&str>,
limit: usize,
) -> TmdbResultT<Vec<TmdbResult>> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let (path, year_param) = match kind {
Kind::Movie => ("/search/movie", "year"),
Kind::Tv => ("/search/tv", "first_air_date_year"),
};
let mut params = vec![("query", query.to_string())];
if let Some(y) = year.filter(|y| !y.is_empty()) {
params.push((year_param, y.to_string()));
}
let data = get(path, &params)?;
Ok(data
.get("results")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(limit)
.filter_map(|i| parse_result(i, kind))
.collect()
})
.unwrap_or_default())
}
/// Search movies by title (optionally narrowed by year); best matches first.
pub fn search_movie(query: &str, year: Option<&str>, limit: usize) -> TmdbResultT<Vec<TmdbResult>> {
search(Kind::Movie, query, year, limit)
}
/// Search series by name (optionally narrowed by first-air year).
pub fn search_tv(query: &str, year: Option<&str>, limit: usize) -> TmdbResultT<Vec<TmdbResult>> {
search(Kind::Tv, query, year, limit)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_movie_result() {
let r = parse_result(
&json!({"id": 603, "title": "Matrix", "release_date": "1999-03-30"}),
Kind::Movie,
)
.unwrap();
assert_eq!(
r,
TmdbResult {
tmdb_id: 603,
title: "Matrix".into(),
year: Some("1999".into()),
kind: Kind::Movie
}
);
}
#[test]
fn test_parse_falls_back_to_original_title_and_no_date() {
let r = parse_result(
&json!({"id": 1, "title": "", "original_title": "Orig", "release_date": ""}),
Kind::Movie,
)
.unwrap();
assert_eq!(r.title, "Orig");
assert_eq!(r.year, None);
assert!(parse_result(&json!({"id": 1}), Kind::Movie).is_none());
}
#[test]
fn test_parse_tv_result() {
let r = parse_result(
&json!({"id": 1396, "name": "Perníkový táta", "first_air_date": "2008-01-20"}),
Kind::Tv,
)
.unwrap();
assert_eq!(
(r.title.as_str(), r.year.as_deref()),
("Perníkový táta", Some("2008"))
);
}
#[test]
fn test_parse_details() {
let d = parse_details(&json!({
"id": 603, "title": "Matrix", "release_date": "1999-03-30", "vote_average": 8.25,
"genres": [{"name": "Akční"}, {"name": "Sci-Fi"}],
"production_countries": [{"name": "USA"}]
}))
.unwrap();
assert_eq!(d.genres, vec!["Akční", "Sci-Fi"]);
assert_eq!(d.countries, vec!["USA"]);
assert_eq!(d.rating, Some(82));
assert_eq!(d.field_values("year"), vec!["1999"]);
let none = parse_details(&json!({"id": 1, "vote_average": 0})).unwrap();
assert_eq!(none.rating, None);
}
#[test]
fn test_without_key_errors_cleanly() {
if api_available() {
return;
}
let e = lookup_movie(603).unwrap_err();
assert!(e.to_string().contains("TMDB_API_KEY"));
assert!(search_movie(" ", None, 5).unwrap().is_empty());
}
}
-41
View File
@@ -1,41 +0,0 @@
"""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``.
"""
import re
from typing import Optional
def 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.
"""
low = min((rating // 10) * 10, 90)
high = 100 if low == 90 else low + 9
return f"{low}–{high} %"
def _decade_band(value) -> str:
"""``decade_band`` transform: pull the leading integer and bucket it."""
match = re.search(r"\d+", str(value))
return rating_band(int(match.group())) if match else str(value)
# Named value transforms usable from the tag schema (config-driven, extensible).
TAG_TRANSFORMS = {
None: str,
"identity": str,
"decade_band": _decade_band,
}
def apply_transform(value: str, transform: Optional[str]) -> str:
"""Apply a named tag-schema transform to a value, returning a string."""
fn = TAG_TRANSFORMS.get(transform, str)
try:
return str(fn(value))
except (ValueError, TypeError, AttributeError):
return str(value)
+70
View File
@@ -0,0 +1,70 @@
//! 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 %").
use std::sync::LazyLock;
use regex::Regex;
static LEADING_INT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+").expect("valid regex"));
/// 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.
pub fn rating_band(rating: i64) -> String {
let low = ((rating.div_euclid(10)) * 10).min(90);
let high = if low == 90 { 100 } else { low + 9 };
format!("{low}–{high} %")
}
/// `decade_band` transform: pull the first integer and bucket it.
fn decade_band(value: &str) -> String {
LEADING_INT
.find(value)
.and_then(|m| m.as_str().parse::<i64>().ok())
.map(rating_band)
.unwrap_or_else(|| value.to_string())
}
/// Apply a named tag-schema transform; unknown names leave the value as-is.
pub fn apply_transform(value: &str, transform: Option<&str>) -> String {
match transform {
Some("decade_band") => decade_band(value),
_ => value.to_string(),
}
}
/// Names a tag schema may use, in the order the schema dialog offers them.
pub const TRANSFORM_NAMES: &[&str] = &["decade_band"];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rating_band_buckets_by_ten() {
assert_eq!(rating_band(87), "80–89 %");
assert_eq!(rating_band(0), "0–9 %");
}
#[test]
fn test_rating_band_top_bucket_includes_100() {
assert_eq!(rating_band(95), "90–100 %");
assert_eq!(rating_band(100), "90–100 %");
}
#[test]
fn test_apply_transform_decade_band() {
assert_eq!(apply_transform("87", Some("decade_band")), "80–89 %");
assert_eq!(apply_transform("n/a", Some("decade_band")), "n/a");
}
#[test]
fn test_apply_transform_identity_and_unknown() {
assert_eq!(apply_transform("Drama", None), "Drama");
assert_eq!(apply_transform("Drama", Some("identity")), "Drama");
assert_eq!(apply_transform("Drama", Some("bogus")), "Drama");
}
}
-7
View File
@@ -1,7 +0,0 @@
from pathlib import Path
def list_files(folder_path: str | Path) -> list[Path]:
folder = Path(folder_path)
if not folder.is_dir():
raise NotADirectoryError(f"{folder} není platná složka.")
return [file_path for file_path in folder.rglob("*") if file_path.is_file()]
+8
View File
@@ -0,0 +1,8 @@
//! Curator — a Jellyfin-first movie and series library manager.
//!
//! `core` holds the domain model and every operation on the pool; `ui` is the
//! egui front end that only ever calls into `core`.
pub mod constants;
pub mod core;
pub mod ui;
+21
View File
@@ -0,0 +1,21 @@
//! Curator entry point: load `.env`, set up logging, open the window.
use curator::core::file_manager::FileManager;
fn main() -> anyhow::Result<()> {
// secrets / settings (TMDB_API_KEY, JELLYFIN_*, ENV_DEBUG) from .env
dotenvy::dotenv().ok();
let default_level = if curator::constants::debug_mode() {
"curator=debug,warn"
} else {
"curator=info,warn"
};
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level)),
)
.init();
let fm = FileManager::new();
curator::ui::run(fm).map_err(|e| anyhow::anyhow!("GUI se nepodařilo spustit: {e}"))
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File
+2001
View File
File diff suppressed because it is too large Load Diff
+474
View File
@@ -0,0 +1,474 @@
//! Building blocks shared by the main window and the dialogs: message boxes,
//! small prompts, background jobs and formatting helpers.
use std::path::Path;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::thread;
use egui::{Color32, Id, RichText, Ui};
use crate::core::movie::normalize_3d_label;
use crate::core::naming::THREE_D_FORMATS;
pub const RED: Color32 = Color32::from_rgb(0xc0, 0x39, 0x2b);
pub const BLUE: Color32 = Color32::from_rgb(0x29, 0x80, 0xb9);
pub const GREEN: Color32 = Color32::from_rgb(0x27, 0xae, 0x60);
pub const GREY: Color32 = Color32::from_rgb(0x88, 0x88, 0x88);
/// Human-readable size (`1.5 GB`).
pub fn format_size(bytes: u64) -> String {
let mut size = bytes as f64;
for unit in ["B", "KB", "MB", "GB"] {
if size < 1024.0 {
return format!("{size:.1} {unit}");
}
size /= 1024.0;
}
format!("{size:.1} TB")
}
/// "verze" / "verzí" by count.
pub fn versions_word(count: usize) -> &'static str {
if (2..=4).contains(&count) {
"verze"
} else {
"verzí"
}
}
/// Bullet list of at most `limit` items with an "… a další (n)" tail.
pub fn bullet_list(items: &[String], limit: usize) -> String {
let mut s: String = items
.iter()
.take(limit)
.map(|i| format!(" • {i}\n"))
.collect();
if items.len() > limit {
s.push_str(&format!(" … a další ({})\n", items.len() - limit));
}
s.trim_end().to_string()
}
/// Open a file with the desktop's default application.
pub fn open_path(path: &Path) -> Result<(), String> {
let result = if cfg!(target_os = "windows") {
std::process::Command::new("cmd")
.args(["/C", "start", ""])
.arg(path)
.spawn()
} else if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(path).spawn()
} else {
std::process::Command::new("xdg-open").arg(path).spawn()
};
result
.map(|_| ())
.map_err(|e| format!("Nelze otevřít {}: {e}", path.display()))
}
/// Keep only ASCII digits, at most `max` of them (a `\d{0,max}` validator).
pub fn digits_only(text: &mut String, max: usize) {
let cleaned: String = text
.chars()
.filter(char::is_ascii_digit)
.take(max)
.collect();
if *text != cleaned {
*text = cleaned;
}
}
/// Add `widget` with a fixed width (inside a grid egui would otherwise shrink a
/// text field to the column's width from the previous frame).
pub fn sized(ui: &mut Ui, width: f32, widget: impl egui::Widget) -> egui::Response {
let height = ui.spacing().interact_size.y;
ui.add_sized([width, height], widget)
}
/// A single-line text field restricted to digits.
pub fn digit_edit(
ui: &mut Ui,
text: &mut String,
max: usize,
width: f32,
hint: &str,
) -> egui::Response {
let resp = sized(ui, width, egui::TextEdit::singleline(text).hint_text(hint));
digits_only(text, max);
resp
}
// ---------------------------------------------------------------------------
// Message boxes
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Info,
Warning,
}
/// An informational / warning popup, optionally with collapsible details.
#[derive(Debug, Clone)]
pub struct Message {
pub level: Level,
pub title: String,
pub text: String,
pub details: Option<String>,
}
impl Message {
pub fn info(title: impl Into<String>, text: impl Into<String>) -> Self {
Self {
level: Level::Info,
title: title.into(),
text: text.into(),
details: None,
}
}
pub fn warning(title: impl Into<String>, text: impl Into<String>) -> Self {
Self {
level: Level::Warning,
title: title.into(),
text: text.into(),
details: None,
}
}
pub fn with_details(mut self, details: impl Into<String>) -> Self {
let d = details.into();
if !d.trim().is_empty() {
self.details = Some(d);
}
self
}
/// Show it; `true` once the user closed it.
pub fn show(&self, ctx: &egui::Context, id: Id) -> bool {
let mut closed = false;
let resp = egui::Modal::new(id).show(ctx, |ui| {
ui.set_max_width(560.0);
let icon = match self.level {
Level::Info => "ℹ",
Level::Warning => "⚠",
};
ui.heading(format!("{icon} {}", self.title));
ui.add_space(6.0);
ui.label(&self.text);
if let Some(details) = &self.details {
ui.add_space(6.0);
egui::CollapsingHeader::new("Podrobnosti")
.id_salt(id.with("details"))
.show(ui, |ui| {
egui::ScrollArea::vertical()
.max_height(320.0)
.show(ui, |ui| {
ui.add(
egui::Label::new(RichText::new(details).monospace())
.selectable(true),
);
});
});
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("OK").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
closed = true;
}
});
});
closed || resp.should_close()
}
}
// ---------------------------------------------------------------------------
// Prompts (small modal questions owned by a dialog or the main window)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub enum PromptKind {
/// Yes / No.
Confirm,
/// A free text answer.
Text { value: String },
/// Pick one option (optionally typing a new one).
Choice {
options: Vec<String>,
selected: usize,
editable: bool,
typed: String,
},
/// A row of custom buttons; the answer is the button index.
Buttons(Vec<String>),
/// A version popisek: a 3D preset or free text.
VersionLabel {
preset: Option<String>,
label: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum Answer {
Cancel,
Yes,
Text(String),
Choice(usize, String),
Button(usize),
}
/// A modal question carrying a `purpose` the owner acts on when answered.
#[derive(Debug, Clone)]
pub struct Prompt<T> {
pub title: String,
pub text: String,
pub kind: PromptKind,
pub purpose: T,
}
impl<T: Clone> Prompt<T> {
pub fn confirm(title: impl Into<String>, text: impl Into<String>, purpose: T) -> Self {
Self {
title: title.into(),
text: text.into(),
kind: PromptKind::Confirm,
purpose,
}
}
pub fn text(
title: impl Into<String>,
text: impl Into<String>,
value: impl Into<String>,
purpose: T,
) -> Self {
Self {
title: title.into(),
text: text.into(),
kind: PromptKind::Text {
value: value.into(),
},
purpose,
}
}
pub fn choice(
title: impl Into<String>,
text: impl Into<String>,
options: Vec<String>,
editable: bool,
purpose: T,
) -> Self {
let typed = options.first().cloned().unwrap_or_default();
Self {
title: title.into(),
text: text.into(),
kind: PromptKind::Choice {
options,
selected: 0,
editable,
typed,
},
purpose,
}
}
pub fn buttons(
title: impl Into<String>,
text: impl Into<String>,
buttons: Vec<String>,
purpose: T,
) -> Self {
Self {
title: title.into(),
text: text.into(),
kind: PromptKind::Buttons(buttons),
purpose,
}
}
/// The version-label prompt, pre-filled from `initial` (a 3D preset or text).
pub fn version_label(intro: impl Into<String>, initial: &str, purpose: T) -> Self {
let preset = normalize_3d_label(initial);
let label = if preset.is_some() {
format!("3D {}", preset.clone().unwrap_or_default())
} else {
initial.to_string()
};
Self {
title: "Popisek verze".into(),
text: intro.into(),
kind: PromptKind::VersionLabel { preset, label },
purpose,
}
}
/// Show it; `Some((purpose, answer))` once answered.
pub fn show(&mut self, ctx: &egui::Context, id: Id) -> Option<(T, Answer)> {
let mut answer = None;
let resp = egui::Modal::new(id).show(ctx, |ui| {
ui.set_max_width(520.0);
ui.heading(&self.title);
ui.add_space(4.0);
if !self.text.is_empty() {
ui.label(&self.text);
ui.add_space(4.0);
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
match &mut self.kind {
PromptKind::Confirm => {
ui.horizontal(|ui| {
if ui.button("Ano").clicked() || enter {
answer = Some(Answer::Yes);
}
if ui.button("Ne").clicked() {
answer = Some(Answer::Cancel);
}
});
}
PromptKind::Text { value } => {
let r = crate::ui::common::sized(ui, 420.0, egui::TextEdit::singleline(value));
r.request_focus();
ok_cancel(ui, enter, &mut answer, || Answer::Text(value.trim().to_string()));
}
PromptKind::Choice { options, selected, editable, typed } => {
if *editable {
crate::ui::common::sized(ui, 300.0, egui::TextEdit::singleline(typed));
}
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (i, opt) in options.iter().enumerate() {
if ui.selectable_label(*selected == i, opt).clicked() {
*selected = i;
*typed = opt.clone();
}
}
});
let (sel, text) = (*selected, if *editable { typed.trim().to_string() } else { options.get(*selected).cloned().unwrap_or_default() });
ok_cancel(ui, enter, &mut answer, || Answer::Choice(sel, text));
}
PromptKind::Buttons(buttons) => {
ui.horizontal_wrapped(|ui| {
for (i, b) in buttons.iter().enumerate() {
if ui.button(b).clicked() {
answer = Some(Answer::Button(i));
}
}
});
}
PromptKind::VersionLabel { preset, label } => {
ui.label("Jak tuhle verzi Jellyfin rozliší od ostatních (uvidí ji v přehrávači jako volbu).");
egui::Grid::new(id.with("vl")).num_columns(2).show(ui, |ui| {
ui.label("3D formát:");
let before = preset.clone();
egui::ComboBox::from_id_salt(id.with("fmt"))
.selected_text(preset.as_ref().map_or("— vlastní popisek —".to_string(), |f| format!("3D {f}")))
.show_ui(ui, |ui| {
ui.selectable_value(preset, None, "— vlastní popisek —");
for f in THREE_D_FORMATS {
ui.selectable_value(preset, Some(f.to_string()), format!("3D {f}"));
}
});
if *preset != before {
*label = preset.as_ref().map(|f| format!("3D {f}")).unwrap_or_default();
}
ui.end_row();
ui.label("Popisek:");
ui.add_enabled_ui(preset.is_none(), |ui| {
sized(
ui,
320.0,
egui::TextEdit::singleline(label).hint_text(
"např. 4K, Director's Cut (prázdné = obyčejná 2D verze, Výchozí)",
),
)
});
ui.end_row();
});
let value = label.trim().to_string();
ok_cancel(ui, enter, &mut answer, || Answer::Text(value));
}
}
});
if answer.is_none() && resp.should_close() {
answer = Some(Answer::Cancel);
}
answer.map(|a| (self.purpose.clone(), a))
}
}
fn ok_cancel(ui: &mut Ui, enter: bool, answer: &mut Option<Answer>, ok: impl FnOnce() -> Answer) {
ui.add_space(6.0);
ui.horizontal(|ui| {
let ok_clicked = ui.button("OK").clicked() || enter;
if ui.button("Zrušit").clicked() {
*answer = Some(Answer::Cancel);
} else if ok_clicked {
*answer = Some(ok());
}
});
}
// ---------------------------------------------------------------------------
// Background jobs
// ---------------------------------------------------------------------------
/// A closure running on a worker thread; poll it every frame.
pub struct Job<T> {
rx: Receiver<T>,
}
impl<T: Send + 'static> Job<T> {
pub fn spawn(ctx: &egui::Context, f: impl FnOnce() -> T + Send + 'static) -> Self {
let (tx, rx) = channel();
let ctx = ctx.clone();
thread::spawn(move || {
let _ = tx.send(f());
ctx.request_repaint();
});
Self { rx }
}
/// The result once ready. A crashed worker yields `None` forever, so callers
/// treat a disconnected job as done via [`Job::finished`].
pub fn poll(&self) -> Option<T> {
self.rx.try_recv().ok()
}
pub fn finished(&self) -> bool {
matches!(self.rx.try_recv(), Err(TryRecvError::Disconnected))
}
}
/// A button that is disabled while `busy` and shows a spinner next to it.
pub fn busy_button(ui: &mut Ui, busy: bool, text: &str) -> bool {
let clicked = ui.add_enabled(!busy, egui::Button::new(text)).clicked();
if busy {
ui.spinner();
}
clicked
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_size() {
assert_eq!(format_size(0), "0.0 B");
assert_eq!(format_size(1536), "1.5 KB");
assert_eq!(format_size(5 * 1024 * 1024 * 1024), "5.0 GB");
}
#[test]
fn test_digits_only() {
let mut s = "19a99x9".to_string();
digits_only(&mut s, 4);
assert_eq!(s, "1999");
}
#[test]
fn test_bullet_list() {
let items: Vec<String> = (1..=3).map(|i| i.to_string()).collect();
assert_eq!(bullet_list(&items, 2), " • 1\n • 2\n … a další (1)");
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Tri-state bulk tag assignment over the selected movies.
use std::collections::HashSet;
use std::path::PathBuf;
use super::{DialogState, Env, Event, modal, ok_cancel_row};
use crate::core::file_manager::FileManager;
use crate::ui::common::{Answer, Prompt};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Checked,
Unchecked,
/// Some of the movies have it — left unchanged.
Mixed,
}
#[derive(Debug, Clone)]
enum Purpose {
Category,
Name(String),
}
pub struct AssignTagsDialog {
files: Vec<PathBuf>,
file_tags: Vec<HashSet<String>>,
/// `(category, [(name, state)])` in display order.
tree: Vec<(String, Vec<(String, State)>)>,
prompt: Option<Prompt<Purpose>>,
}
impl AssignTagsDialog {
pub fn new(fm: &FileManager, files: Vec<PathBuf>) -> Self {
let file_tags: Vec<HashSet<String>> = files
.iter()
.map(|p| {
fm.file(p)
.map(|f| f.tag_paths().into_iter().collect())
.unwrap_or_default()
})
.collect();
let mut d = Self {
files,
file_tags,
tree: Vec::new(),
prompt: None,
};
// schema categories (incl. empty ones) ∪ categories that have tags
let mut categories: Vec<String> = fm.tag_schema().into_iter().map(|e| e.category).collect();
let mut known = fm.tagmanager.categories();
known.sort();
for c in known {
if !categories.contains(&c) {
categories.push(c);
}
}
for c in categories {
d.category(&c);
for t in fm.tagmanager.tags_in_category(&c) {
d.add_tag(&c, &t.name, false);
}
}
d
}
fn category(&mut self, category: &str) -> usize {
if let Some(i) = self.tree.iter().position(|(c, _)| c == category) {
return i;
}
self.tree.push((category.to_string(), Vec::new()));
self.tree.len() - 1
}
fn add_tag(&mut self, category: &str, name: &str, force_checked: bool) {
let full = format!("{category}/{name}");
let ci = self.category(category);
if let Some(entry) = self.tree[ci].1.iter_mut().find(|(n, _)| n == name) {
if force_checked {
entry.1 = State::Checked;
}
return;
}
let have = self.file_tags.iter().filter(|s| s.contains(&full)).count();
let state = if force_checked || (have == self.file_tags.len() && have > 0) {
State::Checked
} else if have == 0 {
State::Unchecked
} else {
State::Mixed
};
self.tree[ci].1.push((name.to_string(), state));
}
fn apply(&self, env: &mut Env) {
for (cat, tags) in &self.tree {
for (name, state) in tags {
let full = format!("{cat}/{name}");
let r = match state {
State::Checked => env.fm.assign_tag(&self.files, &full),
State::Unchecked => env.fm.remove_tag(&self.files, &full),
State::Mixed => Ok(()),
};
if let Err(e) = r {
env.warn("Štítky", e.to_string());
return;
}
}
}
env.events.push(Event::Refresh);
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut ok = None;
let mut add = false;
let (_, close) = modal(ctx, "assign_tags", "Přiřadit štítky", 380.0, |ui| {
ui.label(format!("Vybráno filmů: {}", self.files.len()));
egui::ScrollArea::vertical()
.max_height(420.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (cat, tags) in &mut self.tree {
egui::CollapsingHeader::new(cat.as_str())
.default_open(true)
.show(ui, |ui| {
for (name, state) in tags.iter_mut() {
let mut on = *state == State::Checked;
let resp = ui.add(
egui::Checkbox::new(&mut on, name.as_str())
.indeterminate(*state == State::Mixed),
);
if resp.clicked() {
// checked → unchecked → (mixed is only an initial state)
*state = if *state == State::Checked {
State::Unchecked
} else {
State::Checked
};
}
}
});
}
});
add = ui.button("➕ Nový štítek…").clicked();
ok = ok_cancel_row(ui);
});
if add {
let known: Vec<String> = self.tree.iter().map(|(c, _)| c.clone()).collect();
self.prompt = Some(Prompt::choice(
"Nový štítek",
"Kategorie:",
known,
true,
Purpose::Category,
));
}
if let Some(mut p) = self.prompt.take() {
match p.show(ctx, egui::Id::unique("tags_prompt")) {
Some((Purpose::Category, Answer::Choice(_, cat))) if !cat.trim().is_empty() => {
let cat = cat.trim().to_string();
self.prompt = Some(Prompt::text(
"Nový štítek",
format!("Název štítku v kategorii „{cat}“:"),
"",
Purpose::Name(cat),
));
}
Some((Purpose::Name(cat), Answer::Text(name))) if !name.trim().is_empty() => {
self.add_tag(&cat, name.trim(), true)
}
Some(_) => {}
None => self.prompt = Some(p),
}
}
match ok {
Some(true) => {
self.apply(env);
DialogState::Closed
}
Some(false) => DialogState::Closed,
None if close && self.prompt.is_none() => DialogState::Closed,
None => DialogState::Open,
}
}
}
+231
View File
@@ -0,0 +1,231 @@
//! Nástroje → Kontrola duplicit: movies present more than once, with merge
//! (as a version of another one) and delete.
use std::path::{Path, PathBuf};
use egui::RichText;
use super::{DialogState, Env, Event, modal};
use crate::core::file_manager::{DuplicateGroup, FileManager};
use crate::core::naming::parse_3d_format;
use crate::core::paths;
use crate::ui::common::{Answer, Prompt, format_size};
#[derive(Debug, Clone)]
enum Purpose {
PickKeep(PathBuf, Vec<PathBuf>),
Label { keep: PathBuf, other: PathBuf },
Remove(PathBuf),
}
pub struct DuplicatesDialog {
groups: Vec<DuplicateGroup>,
selected: Option<(usize, usize)>,
prompt: Option<Prompt<Purpose>>,
}
fn display_name(fm: &FileManager, p: &Path) -> String {
fm.file(p)
.and_then(|f| f.title.clone())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| paths::name(p))
}
impl DuplicatesDialog {
pub fn new(fm: &FileManager) -> Self {
Self {
groups: fm.find_duplicates(),
selected: None,
prompt: None,
}
}
fn reload(&mut self, fm: &FileManager) {
self.groups = fm.find_duplicates();
self.selected = None;
}
fn start_merge(&mut self, env: &mut Env) {
let Some((gi, mi)) = self.selected else {
return;
};
let group = &self.groups[gi];
let other = group.movies[mi].clone();
let candidates: Vec<PathBuf> = group
.movies
.iter()
.filter(|m| **m != other)
.cloned()
.collect();
if candidates.len() == 1 {
self.ask_label(env.fm, candidates[0].clone(), other);
} else {
self.prompt = Some(Prompt::choice(
"Sloučit",
"Do kterého filmu přidat jako verzi?",
candidates.iter().map(|c| paths::name(c)).collect(),
false,
Purpose::PickKeep(other, candidates),
));
}
}
fn ask_label(&mut self, fm: &FileManager, keep: PathBuf, other: PathBuf) {
let fmt = parse_3d_format(&paths::stem(&other));
self.prompt = Some(Prompt::version_label(
format!(
"„{}“ → verze filmu „{}“.",
paths::name(&other),
display_name(fm, &keep)
),
&fmt.map(|f| format!("3D {f}")).unwrap_or_default(),
Purpose::Label { keep, other },
));
}
fn handle(&mut self, purpose: Purpose, answer: Answer, env: &mut Env) {
match (purpose, answer) {
(Purpose::PickKeep(other, candidates), Answer::Choice(i, _)) => {
if let Some(keep) = candidates.get(i).cloned() {
self.ask_label(env.fm, keep, other);
}
}
(Purpose::Label { keep, other }, Answer::Text(label)) => {
match env.fm.merge_as_version(&keep, &other, &label, None) {
Ok(_) => {
env.events.push(Event::Refresh);
self.reload(env.fm);
}
Err(e) => env.warn("Sloučení se nezdařilo", e.to_string()),
}
}
(Purpose::Remove(p), Answer::Yes) => {
if let Err(e) = env.fm.remove_movie(&p) {
env.warn("Chyba", e.to_string());
return;
}
env.events.push(Event::Refresh);
self.reload(env.fm);
}
_ => {}
}
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut act = None;
let fm: &FileManager = env.fm;
let (_, close) = modal(ctx, "duplicates", "Kontrola duplicit", 860.0, |ui| {
if self.groups.is_empty() {
ui.label("✅ Žádné duplicity — každý film je v poolu jen jednou.");
} else {
ui.add(egui::Label::new(format!(
"Nalezeno {} skupin možných duplicit. Vyber soubor a buď ho slouč jako verzi do druhého filmu (např. 2D + 3D), nebo smaž, pokud je to jen kopie.",
self.groups.len()
)).wrap());
}
ui.add_space(6.0);
egui::ScrollArea::vertical()
.max_height(420.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (gi, g) in self.groups.iter().enumerate() {
let first = display_name(fm, &g.movies[0]);
ui.label(
RichText::new(format!("{first} — {}", g.reasons.join(", "))).strong(),
);
egui::Grid::new(("dup", gi))
.striped(true)
.num_columns(4)
.show(ui, |ui| {
for (mi, m) in g.movies.iter().enumerate() {
let versions = fm.list_movie_versions(m);
let grouped = fm.is_movie_group_member(m);
let name = if grouped {
format!(
"{}/",
m.parent().map(paths::name).unwrap_or_default()
)
} else {
paths::name(m)
};
let size: u64 = versions
.iter()
.map(|v| std::fs::metadata(v).map(|x| x.len()).unwrap_or(0))
.sum();
let r = ui.selectable_label(
self.selected == Some((gi, mi)),
format!(" {name}"),
);
if r.clicked() {
self.selected = Some((gi, mi));
}
r.on_hover_text(
versions
.iter()
.map(|v| v.display().to_string())
.collect::<Vec<_>>()
.join("\n"),
);
ui.label(fm.library_of(m).unwrap_or_else(|| "—".into()));
ui.label(format!("{} verzí", versions.len()));
ui.label(format_size(size));
ui.end_row();
}
});
ui.add_space(4.0);
}
});
ui.separator();
ui.horizontal(|ui| {
let picked = self.selected.is_some();
if ui
.add_enabled(picked, egui::Button::new("Sloučit jako verzi do…"))
.on_hover_text("Přesune vybraný soubor do složky druhého filmu jako další verzi; metadata druhého filmu zůstanou, štítky a ČSFD odkaz se doplní.")
.clicked()
{
act = Some("merge");
}
if ui.add_enabled(picked, egui::Button::new("Smazat vybraný z poolu…")).clicked() {
act = Some("remove");
}
if ui.button("Zavřít").clicked() {
act = Some("close");
}
});
});
match act {
Some("close") => return DialogState::Closed,
Some("merge") => self.start_merge(env),
Some("remove") => {
if let Some((gi, mi)) = self.selected {
let m = self.groups[gi].movies[mi].clone();
let n = env.fm.list_movie_versions(&m).len();
let extra = if n > 1 {
format!(" (všech {n} verzí)")
} else {
String::new()
};
self.prompt = Some(Prompt::confirm(
"Smazat z poolu",
format!(
"Smazat „{}“{extra} z poolu včetně souboru, titulků a metadat?",
paths::name(&m)
),
Purpose::Remove(m),
));
}
}
_ => {}
}
if let Some(mut p) = self.prompt.take() {
match p.show(ctx, egui::Id::unique("dup_prompt")) {
Some((purpose, answer)) => self.handle(purpose, answer, env),
None => self.prompt = Some(p),
}
}
if close && self.prompt.is_none() {
return DialogState::Closed;
}
DialogState::Open
}
}
+377
View File
@@ -0,0 +1,377 @@
//! Import episode files into a series (`Series/Season NN/SxxEyy - Series.ext`).
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use super::{DialogState, Env, Event, modal, ok_cancel_row};
use crate::core::file_manager::{EpisodeCode, OnConflict};
use crate::core::naming::{parse_pool_stem, parse_tmdb_id};
use crate::core::paths;
use crate::core::series::{
collect_episode_files, parse_episode, parse_episode_code, parse_episode_part,
};
use crate::core::tmdb::{self, TmdbResult};
use crate::ui::common::{
Answer, GREEN, Job, Message, Prompt, RED, bullet_list, busy_button, digit_edit, sized,
};
use crate::ui::task::Outcome;
struct Row {
source: PathBuf,
code: String,
}
impl Row {
fn new(source: PathBuf) -> Self {
let name = paths::name(&source);
let code = match parse_episode(&name) {
Some((s, e)) => {
let mut c = format!("S{s:02}E{e:02}");
if let Some(p) = parse_episode_part(&name) {
c.push_str(&format!("P{p}"));
}
c
}
None => String::new(),
};
Self { source, code }
}
}
pub struct ImportEpisodesDialog {
title: String,
year: String,
tmdb: String,
libraries: Vec<String>,
library: String,
rows: Vec<Row>,
move_source: bool,
job: Option<Job<Result<Option<TmdbResult>, String>>>,
confirm: Option<Prompt<()>>,
}
impl ImportEpisodesDialog {
pub fn new(libraries: Vec<String>) -> Self {
let libraries = if libraries.is_empty() {
vec!["Seriály".to_string()]
} else {
libraries
};
Self {
title: String::new(),
year: String::new(),
tmdb: String::new(),
library: libraries[0].clone(),
libraries,
rows: Vec::new(),
move_source: false,
job: None,
confirm: None,
}
}
fn add_folder(&mut self, env: &mut Env) {
let Some(folder) = rfd::FileDialog::new()
.set_title("Vyber složku se seriálem")
.pick_folder()
else {
return;
};
let have: HashSet<PathBuf> = self.rows.iter().map(|r| r.source.clone()).collect();
let new: Vec<PathBuf> = collect_episode_files(&folder)
.into_iter()
.filter(|p| !have.contains(p))
.collect();
if new.is_empty() {
env.info(
"Import epizod",
"Ve složce (ani podsložkách) nejsou žádné nové video soubory.",
);
return;
}
self.rows.extend(new.into_iter().map(Row::new));
let name = paths::name(&folder);
if self.title.trim().is_empty() {
match parse_pool_stem(&name) {
Some((t, y)) => {
self.title = t;
if self.year.trim().is_empty() {
self.year = y.to_string();
}
}
None => self.title = name.clone(),
}
}
if let Some(id) = parse_tmdb_id(&name)
&& self.tmdb.trim().is_empty()
{
self.tmdb = id.to_string();
}
}
fn start_lookup(&mut self, ctx: &egui::Context, env: &mut Env) {
if !tmdb::api_available() {
env.warn("TMDb", "Není nastaven TMDB_API_KEY (.env).");
return;
}
let (title, year, id) = (
self.title.clone(),
self.year.trim().to_string(),
self.tmdb.trim().to_string(),
);
self.job = Some(Job::spawn(ctx, move || {
if !id.is_empty() {
id.parse()
.map_err(|_| "Neplatné ID".to_string())
.and_then(|n| tmdb::lookup(n, tmdb::Kind::Tv).map_err(|e| e.to_string()))
} else {
tmdb::search_tv(&title, Some(year.as_str()).filter(|y| !y.is_empty()), 1)
.map(|h| h.into_iter().next())
.map_err(|e| e.to_string())
}
}));
}
fn codes(&self) -> HashMap<PathBuf, EpisodeCode> {
self.rows
.iter()
.filter_map(|r| Some((r.source.clone(), parse_episode_code(r.code.trim())?)))
.collect()
}
fn start_import(&self, env: &mut Env) {
let sources: Vec<PathBuf> = self.rows.iter().map(|r| r.source.clone()).collect();
let codes = self.codes();
let (title, year, library, move_source) = (
self.title.trim().to_string(),
self.year.trim().to_string(),
self.library.clone(),
self.move_source,
);
let tmdb_id: Option<i64> = self.tmdb.trim().parse().ok();
env.events.push(Event::Task {
title: "Importuji epizody".into(),
work: Box::new(move |fm, progress| {
progress.set(0, sources.len(), "Kopíruji…");
let year = Some(year.as_str()).filter(|y| !y.is_empty());
match fm.import_episodes(
&sources,
&title,
year,
tmdb_id,
Some(&library),
move_source,
OnConflict::Suffix,
&codes,
) {
Ok(r) => {
let msg = format!(
"Naimportováno {} epizod. Nerozpoznáno (bez SxxEyy): {}.",
r.imported,
r.skipped_no_code.len()
);
let m = if r.errors.is_empty() {
Message::info("Import epizod", msg.clone())
} else {
Message::warning(
"Import epizod s chybami",
format!(
"{msg}\n\nChyby:\n{}",
r.errors
.iter()
.take(5)
.cloned()
.collect::<Vec<_>>()
.join("\n")
),
)
};
Outcome {
messages: vec![m],
status: Some(msg),
reload_series: true,
select: Vec::new(),
}
}
Err(e) => Outcome {
messages: vec![Message::warning("Import epizod", e.to_string())],
..Default::default()
},
}
}),
});
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
if let Some(job) = &self.job {
if let Some(res) = job.poll() {
self.job = None;
match res {
Err(e) => env.warn("TMDb", e),
Ok(None) => env.info("TMDb", "Nic nenalezeno."),
Ok(Some(hit)) => {
if self.tmdb.trim().is_empty() {
self.tmdb = hit.tmdb_id.to_string();
}
self.title = hit.title;
if let Some(y) = hit.year {
self.year = y;
}
}
}
} else if job.finished() {
self.job = None;
}
}
let busy = self.job.is_some();
let mut ok = None;
let mut remove = None;
let mut add_files = false;
let mut add_folder = false;
let mut lookup = false;
let (_, close) = modal(
ctx,
"import_episodes",
"Importovat epizody seriálu",
760.0,
|ui| {
ui.horizontal(|ui| {
ui.label("Seriál:");
sized(
ui,
260.0,
egui::TextEdit::singleline(&mut self.title).hint_text("Název seriálu"),
);
ui.label("Rok:");
digit_edit(ui, &mut self.year, 4, 48.0, "rok");
ui.label("TMDb ID:");
digit_edit(ui, &mut self.tmdb, 9, 80.0, "TV ID");
lookup = busy_button(ui, busy, "🔎 Dohledat");
});
ui.horizontal(|ui| {
ui.label("Knihovna:");
egui::ComboBox::from_id_salt("ep_library")
.selected_text(self.library.clone())
.show_ui(ui, |ui| {
for l in &self.libraries {
ui.selectable_value(&mut self.library, l.clone(), l);
}
});
});
ui.add(egui::Label::new(
"Epizody se uloží jako „SxxEyy - Název.ext“ do Season složek. Číslo sezóny/epizody se předvyplní z názvu souboru — nerozpoznané (nebo špatně určené) dopiš ve sloupci SxxEyy (např. S01E05 / 1x05). Je-li epizoda rozdělená na víc souborů, přidej part: S01E05P2. „Přidat složku…“ projde i všechny podsložky.",
).wrap());
egui::ScrollArea::vertical().max_height(320.0).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("ep_rows").striped(true).num_columns(3).show(ui, |ui| {
ui.strong("Soubor");
ui.strong("SxxEyy");
ui.label("");
ui.end_row();
for r in &mut self.rows {
ui.label(paths::name(&r.source));
let text = r.code.trim().to_string();
let (color, tip) = if text.is_empty() {
(None, "Prázdné — epizoda se přeskočí. Zadej např. S01E05.")
} else if parse_episode_code(&text).is_none() {
(Some(RED), "Nerozpoznaný formát — použij S01E05 nebo 1x05 (část epizody: S01E05P2).")
} else {
(Some(GREEN), "")
};
let mut edit = egui::TextEdit::singleline(&mut r.code).hint_text("SxxEyy");
if let Some(c) = color {
edit = edit.text_color(c);
}
let resp = sized(ui, 100.0, edit);
if !tip.is_empty() {
resp.on_hover_text(tip);
}
if ui.button("✕").clicked() {
remove = Some(r.source.clone());
}
ui.end_row();
}
});
});
ui.horizontal(|ui| {
add_files = ui.button("➕ Přidat epizody…").clicked();
add_folder = ui
.button("📁 Přidat složku…")
.on_hover_text("Vyber složku seriálu — Curator projde i všechny podsložky a přidá každý video soubor v nich.")
.clicked();
});
ui.checkbox(
&mut self.move_source,
"Přesunout soubory do poolu (jinak zkopírovat)",
);
ok = ok_cancel_row(ui);
},
);
if let Some(src) = remove {
self.rows.retain(|r| r.source != src);
}
if add_files
&& let Some(files) = rfd::FileDialog::new()
.set_title("Vyber epizody")
.pick_files()
{
let have: HashSet<PathBuf> = self.rows.iter().map(|r| r.source.clone()).collect();
self.rows.extend(
files
.into_iter()
.filter(|f| !have.contains(f))
.map(Row::new),
);
}
if add_folder {
self.add_folder(env);
}
if lookup {
self.start_lookup(ctx, env);
}
if let Some(mut c) = self.confirm.take() {
match c.show(ctx, egui::Id::unique("ep_confirm")) {
Some((_, Answer::Yes)) => {
self.start_import(env);
return DialogState::Closed;
}
Some(_) => {}
None => self.confirm = Some(c),
}
}
match ok {
Some(false) => return DialogState::Closed,
Some(true) => {
if self.rows.is_empty() {
env.warn("Seriál", "Přidej aspoň jednu epizodu (soubor nebo složku).");
} else if self.title.trim().is_empty() {
env.warn("Seriál", "Zadej název seriálu.");
} else {
let missing: Vec<String> = self
.rows
.iter()
.filter(|r| parse_episode_code(r.code.trim()).is_none())
.map(|r| paths::name(&r.source))
.collect();
if missing.is_empty() {
self.start_import(env);
return DialogState::Closed;
}
self.confirm = Some(Prompt::confirm(
"Chybí číslování",
format!(
"{} epizod nemá platné SxxEyy a při importu se přeskočí:\n{}\n\nPokračovat?",
missing.len(),
bullet_list(&missing, 10)
),
(),
));
}
}
None => {}
}
if close && self.confirm.is_none() {
return DialogState::Closed;
}
DialogState::Open
}
}
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
//! The library organizer (name + kind of each top-level pool folder) and the
//! choice of libraries feeding miniDLNA generation.
use std::collections::HashSet;
use std::fs;
use super::{DialogState, Env, Event, modal, ok_cancel_row};
use crate::core::file_manager::{FileManager, Library, LibraryKind};
use crate::ui::common::{GREY, RED};
pub struct LibraryOrganizerDialog {
rows: Vec<(String, LibraryKind)>,
selected: Option<usize>,
}
impl LibraryOrganizerDialog {
pub fn new(fm: &FileManager) -> Self {
Self {
rows: fm
.libraries()
.into_iter()
.map(|l| (l.name, l.kind))
.collect(),
selected: None,
}
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut ok = None;
let (_, close) = modal(ctx, "libraries", "Knihovny poolu", 560.0, |ui| {
ui.label("Knihovny = složky v kořeni poolu, každá je jedna knihovna v Jellyfinu.\nTyp určuje, jak Curator složku zpracuje.");
ui.add_space(6.0);
egui::Grid::new("lib_rows")
.striped(true)
.num_columns(3)
.show(ui, |ui| {
ui.strong("");
ui.strong("Název složky");
ui.strong("Typ");
ui.end_row();
for (i, (name, kind)) in self.rows.iter_mut().enumerate() {
ui.radio_value(&mut self.selected, Some(i), "");
crate::ui::common::sized(ui, 240.0, egui::TextEdit::singleline(name));
egui::ComboBox::from_id_salt(("kind", i))
.selected_text(kind.label())
.show_ui(ui, |ui| {
for k in LibraryKind::ALL {
ui.selectable_value(kind, k, k.label());
}
});
ui.end_row();
}
});
ui.horizontal(|ui| {
if ui.button("➕ Přidat knihovnu").clicked() {
self.rows.push((String::new(), LibraryKind::Movie));
}
if ui.button("➖ Odebrat").clicked()
&& let Some(i) = self.selected.take().filter(|i| *i < self.rows.len())
{
self.rows.remove(i);
}
});
ui.add(egui::Label::new(egui::RichText::new(
"Pozn.: „Mix“ zpracuje filmy (v kořeni) i seriály (v podsložkách); „Domácí videa“ se do miniDLNA zrcadlí celé. Po uložení nabídnu smazání prázdných složek, které už nejsou knihovnou (s obsahem nechám být).",
).color(GREY)).wrap());
ok = ok_cancel_row(ui);
});
match ok {
Some(true) => {
let specs: Vec<Library> = self
.rows
.iter()
.filter(|(n, _)| !n.trim().is_empty())
.map(|(n, k)| Library::new(n.trim(), *k))
.collect();
if let Err(e) = env.fm.set_libraries(specs) {
env.warn("Knihovny", e.to_string());
return DialogState::Open;
}
if let Some(pool) = env.fm.pool_dir() {
for lib in env.fm.libraries() {
let _ = fs::create_dir_all(pool.join(&lib.name));
}
}
let names: Vec<String> = env
.fm
.libraries()
.iter()
.map(|l| format!("{} ({})", l.name, l.kind.as_str()))
.collect();
env.events
.push(Event::Status(format!("Knihovny: {}", names.join(", "))));
env.events.push(Event::LibrariesChanged);
DialogState::Closed
}
Some(false) => DialogState::Closed,
None if close => DialogState::Closed,
None => DialogState::Open,
}
}
}
pub struct MinidlnaLibrariesDialog {
rows: Vec<(String, LibraryKind, bool)>,
}
impl MinidlnaLibrariesDialog {
pub fn new(fm: &FileManager) -> Self {
let chosen: Option<HashSet<String>> =
fm.minidlna_libraries().map(|v| v.into_iter().collect());
Self {
rows: fm
.libraries()
.into_iter()
.map(|l| {
let on = chosen.as_ref().is_none_or(|c| c.contains(&l.name));
(l.name, l.kind, on)
})
.collect(),
}
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut ok = None;
let (_, close) = modal(
ctx,
"minidlna_libs",
"Knihovny pro generování miniDLNA",
440.0,
|ui| {
ui.add(egui::Label::new("Které knihovny se mají promítnout do miniDLNA stromu (žánry, Tipy dne, Nově přidané, zrcadlené seriály)?").wrap());
ui.add(egui::Label::new(egui::RichText::new("⚠ Odškrtnutá knihovna: její odkazy ve stromu se při dalším generování odeberou jako zastaralé, dokud ji znovu nezaškrtneš.").color(RED)).wrap());
ui.add_space(6.0);
for (name, kind, on) in &mut self.rows {
ui.checkbox(on, format!("{name} ({})", kind.as_str()));
}
ui.horizontal(|ui| {
if ui.button("Vše").clicked() {
self.rows.iter_mut().for_each(|r| r.2 = true);
}
if ui.button("Nic").clicked() {
self.rows.iter_mut().for_each(|r| r.2 = false);
}
});
ok = ok_cancel_row(ui);
},
);
match ok {
Some(true) => {
let chosen: Vec<String> = self
.rows
.iter()
.filter(|r| r.2)
.map(|r| r.0.clone())
.collect();
let all = chosen.len() == self.rows.len();
if let Err(e) =
env.fm
.set_minidlna_libraries(if all { None } else { Some(chosen.clone()) })
{
env.warn("miniDLNA", e.to_string());
}
env.events.push(Event::Status(format!(
"miniDLNA: {}/{} knihoven vybráno pro generování.",
chosen.len(),
self.rows.len()
)));
DialogState::Closed
}
Some(false) => DialogState::Closed,
None if close => DialogState::Closed,
None => DialogState::Open,
}
}
}
+181
View File
@@ -0,0 +1,181 @@
//! Review TMDb match proposals for an existing pool and apply the ids.
use std::collections::BTreeMap;
use super::{DialogState, Env, Event, modal};
use crate::core::migrate::{MatchProposal, MatchStatus};
use crate::ui::common::{BLUE, GREEN, RED};
pub struct MigrateDialog {
proposals: Vec<MatchProposal>,
/// Chosen candidate per proposal (`None` = skip).
choice: Vec<Option<usize>>,
}
impl MigrateDialog {
pub fn new(proposals: Vec<MatchProposal>) -> Self {
let choice = proposals
.iter()
.map(|p| {
if p.status == MatchStatus::Auto && !p.candidates.is_empty() {
p.chosen
} else {
None
}
})
.collect();
Self { proposals, choice }
}
fn apply(&mut self, env: &mut Env, only_auto: bool) {
let mut done = 0;
let mut errors = Vec::new();
for (p, choice) in self.proposals.iter().zip(&self.choice) {
if only_auto && p.status != MatchStatus::Auto {
continue;
}
let Some(c) = choice.and_then(|i| p.candidates.get(i)) else {
continue;
};
let Some(path) = env.fm.path_by_filename(&p.current_name) else {
errors.push(format!("{}: soubor už v poolu není", p.current_name));
continue;
};
match env.fm.assign_tmdb_id(&path, c.tmdb_id, c.year.as_deref()) {
Ok(_) => done += 1,
Err(e) => errors.push(format!("{}: {e}", p.current_name)),
}
}
let msg = format!("Přejmenováno {done} filmů.");
if errors.is_empty() {
env.info("Migrace", msg);
} else {
env.warn(
"Migrace s chybami",
format!(
"{msg}\n\nChyby:\n{}",
errors
.iter()
.take(8)
.cloned()
.collect::<Vec<_>>()
.join("\n")
),
);
}
env.events.push(Event::Refresh);
}
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
let mut act = None;
let mut close_clicked = false;
let (_, close) = modal(
ctx,
"migrate",
"Spárovat pool s TMDb (migrace)",
860.0,
|ui| {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for p in &self.proposals {
*counts.entry(p.status.label()).or_default() += 1;
}
let summary: Vec<String> =
counts.iter().map(|(k, v)| format!("{k}: {v}")).collect();
ui.add(egui::Label::new(format!(
"Návrhy párování (zelené jisté jsou předvybrané). Zkontroluj, uprav u nejednoznačných a přejmenuj. {}",
summary.join(" · ")
)).wrap());
ui.add_space(6.0);
egui::ScrollArea::vertical()
.max_height(440.0)
.auto_shrink([false, true])
.show(ui, |ui| {
egui::Grid::new("migrate_rows")
.striped(true)
.num_columns(3)
.show(ui, |ui| {
ui.strong("Soubor");
ui.strong("Stav");
ui.strong("Kandidát z TMDb");
ui.end_row();
for (i, (p, choice)) in self
.proposals
.iter()
.zip(self.choice.iter_mut())
.enumerate()
{
let name = if p.note.is_empty() {
egui::RichText::new(&p.current_name)
} else {
egui::RichText::new(&p.current_name).color(BLUE)
};
let r = ui.label(name);
if !p.note.is_empty() {
r.on_hover_text(&p.note);
}
let color = match p.status {
MatchStatus::Auto => Some(GREEN),
MatchStatus::NoMatch | MatchStatus::Error => Some(RED),
MatchStatus::Ambiguous => None,
};
let st = egui::RichText::new(p.status.label());
ui.label(match color {
Some(c) => st.color(c),
None => st,
});
if p.candidates.is_empty() {
ui.label(if p.error.is_empty() {
"nic nenalezeno"
} else {
&p.error
});
} else {
let text = |c: &crate::core::tmdb::TmdbResult| {
format!(
"{} ({}) · tmdb {}",
c.title,
c.year.as_deref().unwrap_or("?"),
c.tmdb_id
)
};
let selected = choice
.and_then(|i| p.candidates.get(i))
.map_or("(přeskočit)".to_string(), text);
egui::ComboBox::from_id_salt(("cand", i))
.selected_text(selected)
.width(420.0)
.show_ui(ui, |ui| {
for (ci, c) in p.candidates.iter().enumerate() {
ui.selectable_value(choice, Some(ci), text(c));
}
ui.selectable_value(choice, None, "(přeskočit)");
});
}
ui.end_row();
}
});
});
ui.separator();
ui.horizontal(|ui| {
if ui.button("Přejmenovat jen jisté").clicked() {
act = Some(true);
}
if ui.button("Přejmenovat vybrané").clicked() {
act = Some(false);
}
if ui.button("Zavřít").clicked() {
close_clicked = true;
}
});
},
);
if let Some(only_auto) = act {
self.apply(env, only_auto);
return DialogState::Closed;
}
if close || close_clicked {
return DialogState::Closed;
}
DialogState::Open
}
}

Some files were not shown because too many files have changed in this diff Show More