Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b6de20b67 | ||
|
|
d022e241a9 | ||
|
|
65b5b74c9f | ||
|
|
364b515e3c | ||
|
|
4daf4e960d | ||
|
|
7cc2a1c63e | ||
|
|
c69f5f9166 | ||
|
|
c399a6aed3 | ||
|
|
c0ad1c1553 | ||
|
|
79c9e34a5d | ||
|
|
39712d7283 | ||
|
|
6a2baf243f |
@@ -1,4 +1,8 @@
|
||||
# Python
|
||||
# --- Rust ---------------------------------------------------------------
|
||||
# Cargo build output (debug/release builds, incremental caches)
|
||||
/target/
|
||||
|
||||
# --- Python -------------------------------------------------------------
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
@@ -11,15 +15,16 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# Environment and secrets
|
||||
.env
|
||||
|
||||
# Logs (loguru file sink)
|
||||
logs/
|
||||
|
||||
# PyInstaller build artifacts (dist/ is committed — see DESIGN_DOCUMENT §13)
|
||||
build/
|
||||
|
||||
# --- Common -------------------------------------------------------------
|
||||
# Environment and secrets
|
||||
.env
|
||||
|
||||
# Curator runtime metadata (user/machine-specific, regenerated)
|
||||
.Curator.!gtag
|
||||
*.!gtag
|
||||
@@ -35,4 +40,3 @@ AGENTS.md
|
||||
CLAUDE.md
|
||||
DESIGN_DOCUMENT.md
|
||||
.claude/
|
||||
|
||||
|
||||
@@ -16,11 +16,616 @@ Each version entry uses these sections (include only those that apply):
|
||||
|
||||
- Follow semantic versioning: `MAJOR.MINOR.PATCH`
|
||||
- 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
|
||||
|
||||
## 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
|
||||
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the
|
||||
library filter offered only `movie`-kind libraries (falling back to a default
|
||||
"Filmy"), so a pool of only mixed/series/homevideo libraries had nowhere to put
|
||||
a movie. They now use `libraries_accepting_movies` (kind **movie or mixed**),
|
||||
and `load_pool_movies` scans a mixed library's **top-level** files as movies
|
||||
(subfolders are series). Movies can be imported into / moved between mixed
|
||||
libraries.
|
||||
- **Cleanup of leftover library folders.** After editing libraries, Curator
|
||||
offers to delete **empty** pool folders that are no longer a library, and lists
|
||||
non-empty ones (never auto-deleted). `FileManager.orphan_pool_folders` /
|
||||
`remove_empty_pool_folder`.
|
||||
- **Adding a movie version in a `mixed` library silently broke the movie.**
|
||||
Its version folder and a series folder are both `Title (rok) [tmdbid]/` —
|
||||
indistinguishable by name — and a `mixed` library's subfolders used to be
|
||||
read as series unconditionally (`load_pool_movies` only scanned that
|
||||
library's **top-level** files as movies). So the movie vanished from the
|
||||
Film list and reappeared, mis-parsed, as a "series" — no longer reachable
|
||||
via *Upravit film*. Told apart by **shape** now instead: a series always
|
||||
nests its episodes in a `Season NN`/`Specials` folder, a movie's version
|
||||
folder never does (`series.looks_like_series_folder`). A `mixed` library's
|
||||
canonical-shaped subfolder without one is read as a movie by
|
||||
`load_pool_movies`/`is_movie_group_member`, and skipped by `scan_series`
|
||||
(`skip_movie_folders=True` there, so a `movie`-and-`series` pair with the
|
||||
same identity can sit side by side without either one leaking into the
|
||||
other's listing) — no manual per-item type switch needed.
|
||||
|
||||
### Dependencies
|
||||
- **Poetry and pyenv are replaced by uv.** `pyproject.toml` was recreated with
|
||||
`uv init` / `uv add` (no `[tool.poetry]`, no `[build-system]`), `poetry.lock`
|
||||
is replaced by `uv.lock`, and `.venv` is built on a uv-managed Python 3.14.
|
||||
Commands are now `uv sync` / `uv add` / `uv run` (`DESIGN_DOCUMENT_PYTHON.md`
|
||||
§11). Dependency lower bounds moved up to the versions uv resolved; Poetry's
|
||||
upper bounds (`<7.0.0`, …) are gone.
|
||||
- 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
|
||||
- **Codebase cleaned up for ruff 0.17's default rules** (`uv run ruff check`
|
||||
passes). Mostly mechanical: PEP 604/585 annotations (`X | None`, `list[...]`
|
||||
instead of `Optional`/`List`), sorted imports, stale `# noqa` removed, unused
|
||||
unpacked variables prefixed with `_`, silent `try/except/pass` blocks
|
||||
rewritten as `contextlib.suppress`, dialog class-level tables in
|
||||
`src/ui/qt_app.py` annotated `ClassVar`. `scripts/tipy_dne.py` is now
|
||||
executable (it has a shebang).
|
||||
- **Three blanket `except Exception` handlers are narrowed** — anything outside
|
||||
the listed types now propagates instead of being swallowed:
|
||||
`config.load_global_config` (`OSError`/`ValueError`/`TypeError`),
|
||||
the ffprobe resolution lookup in `media_utils`
|
||||
(`OSError`/`SubprocessError`/`ValueError`), and the window-geometry restore
|
||||
in `qt_app` (`ValueError`/`AttributeError`).
|
||||
- User-facing "missing packages" hints (ČSFD) now say `uv sync` instead of
|
||||
`poetry install`.
|
||||
|
||||
## 1.12.0 — 2026-08-31
|
||||
|
||||
### Added
|
||||
- **Series support** (Jellyfin TV layout, phase 5; `src/core/series.py`, menu
|
||||
**Seriály**). Episodes are matched by `SxxEyy` (also `1x02`) and laid out as
|
||||
`Seriály/Series (rok) [tmdbid]/Season NN/Series SxxEyy.ext` (season 0 →
|
||||
`Specials`). *"Importovat epizody…"* places episode files (series identified
|
||||
via TMDb TV lookup, per-file SxxEyy parsed from the filename); *"Přehled
|
||||
seriálů…"* shows a Series → Season → Episode tree with a naming status and a
|
||||
per-series **"Přejmenovat epizody"** that renames mis-named episodes into the
|
||||
right Season folder. `FileManager` gained `load_series`, `import_episodes`,
|
||||
`rename_episodes_to_canonical`; `tmdb.lookup_tv` / `search_tv` resolve series.
|
||||
- **NFO-driven genres, ČSFD removed** (phase 4). Curator's metadata source is now
|
||||
the `.nfo` Jellyfin writes into the media folders (`src/core/nfo.py`, read-only).
|
||||
*Filmy → "Načíst žánry z NFO"* (`FileManager.refresh_tags_from_nfo`) reads
|
||||
genre/year/country/rating from each movie's NFO and (re)generates its tags
|
||||
(user-added tags kept); generating the miniDLNA tree refreshes from NFO first,
|
||||
so the genre-split tree reflects what Jellyfin knows. The **Filmotéka** menu is
|
||||
now **miniDLNA** ("Vygenerovat miniDLNA strom").
|
||||
- **Jellyfin integration** (thin/hybrid, phase 3; `src/core/jellyfin.py`, menu
|
||||
**Jellyfin**). NFO/artwork on disk stay authoritative; the client only
|
||||
triggers a scan and reads back what Jellyfin sees. **"Skenovat knihovnu"**
|
||||
(`POST /Library/Refresh`), **"Ověřit proti poolu…"** (matches pooled movies to
|
||||
Jellyfin items by TMDb id, then title+year, and reports what is out of sync —
|
||||
`FileManager.verify_against_jellyfin`), **"Zkontrolovat připojení"**. The
|
||||
status bar shows the Jellyfin state (nenastaveno / nezkontrolováno / ● verze).
|
||||
Config via `.env` — `JELLYFIN_URL`, `JELLYFIN_API_KEY`.
|
||||
- **Movie libraries as physical partitions** (Jellyfin-first, phase 2). The pool
|
||||
can now hold several top-level movie libraries (`movie_libraries` in the pool
|
||||
index — default `Filmy`; add Kids/Dokumenty/Anime via *Pool → "Knihovny
|
||||
(rozdělení)…"*). Import lands in a chosen library, and *Filmy → "Přesunout do
|
||||
knihovny…"* (context menu too) moves a movie between them. The movie table
|
||||
gained a **Knihovna** column and a **library filter** (Vše / per library) in
|
||||
the toolbar. `FileManager` gained `movie_libraries` / `set_movie_libraries`,
|
||||
`import_movie(library=…)`, `move_to_library`, and `library_of`; `load_pool_movies`
|
||||
and the consistency check now scan every movie library and skip the sidecars
|
||||
Jellyfin writes (`.nfo`, artwork, subtitles).
|
||||
- **TMDb identity source** (`src/core/tmdb.py`) — start of the Jellyfin-first
|
||||
pivot. Resolves a **TMDb id** (or a title/year search) into a canonical
|
||||
**title + year**; the rich metadata/artwork are left to Jellyfin. Needs a free
|
||||
`TMDB_API_KEY` in `.env`; degrades gracefully without it.
|
||||
- **`[tmdbid-N]` in the pool naming convention** (`naming.py`): the pooled file
|
||||
is now `Title (YYYY) [tmdbid-N].ext` when the id is known — the exact form
|
||||
Jellyfin matches on. The id is optional (plain `Title (YYYY)` still valid),
|
||||
parsed back by `parse_tmdb_id`, preserved across `rename_to_canonical`, and
|
||||
stripped from the miniDLNA hardlink names (they stay clean `Title.ext`).
|
||||
`FileManager.import_movie` takes a `tmdb_id`.
|
||||
|
||||
### Changed
|
||||
- **Import reworked around TMDb, ČSFD removed from the import flow.** The import
|
||||
dialog's "ČSFD odkaz" column is now **TMDb ID**, and **"Dohledat z TMDb"**
|
||||
fills Title/Year/id (by id, or by searching the filename) — a suggested value
|
||||
is tinted blue, a name colliding with the pool red. Import no longer fetches
|
||||
ČSFD tags. (The legacy ČSFD tag actions remain until the tag tree is replaced
|
||||
by NFO-driven miniDLNA generation.)
|
||||
|
||||
### Removed
|
||||
- **ČSFD entirely.** Deleted `src/core/csfd.py` (scraping, Anubis PoW solver) and
|
||||
its tests; dropped `File.apply_csfd_tags` / `set_csfd_link` / `get_cached_movie`,
|
||||
the `csfd_link` field and the ČSFD menu/context actions. `File.csfd_cache` →
|
||||
`File.meta` and `csfd_tag_paths` → `auto_tag_paths` (both read the old index
|
||||
keys for back-compat); the folder-name transforms moved to `src/core/transforms.py`.
|
||||
|
||||
## 1.11.0 — 2026-07-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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`.
|
||||
@@ -39,33 +39,43 @@ Curator manages a personal movie library based on two folders:
|
||||
|
||||
## Current state
|
||||
|
||||
- Inherited from Tagger: `Tag`, `TagManager`, `File` (sidecar metadata),
|
||||
`FileManager` (folder scan, filtering, ignore patterns), 3-level config,
|
||||
`HardlinkManager` (create/sync/cleanup), pytest suite.
|
||||
- Rename Tagger → Curator done across code, spec, config filenames
|
||||
(`.Curator.!gtag` / `.Curator.!ftag`) and tests.
|
||||
- **PySide6 GUI** (`src/ui/qt_app.py`) reframed around the Filmotéka workflow is
|
||||
the entry point; the old tkinter `src/ui/gui.py` is retained for reference.
|
||||
- **Pool + Filmotéka wired up:** global config holds `pool_dir` / `filmoteka_dir`;
|
||||
`FileManager` creates `Filmy`/`Seriály`, imports movies (copy → `Title.ext`),
|
||||
loads the pool, and the GUI generates the Filmotéka tree via `HardlinkManager`.
|
||||
- `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index**
|
||||
(`<pool>/.Curator.!index`, see `pool_index.py`); `File` writes there when an
|
||||
index is injected. Without an index a `File` is an in-memory object only — the
|
||||
old per-file `.!tag` sidecars and the per-folder `.!ftag` config have been
|
||||
removed.
|
||||
- **Rust rewrite (branch `rust`).** Curator is a Rust application: the core
|
||||
(`src/core/`) is a library with the same behaviour and on-disk formats as the
|
||||
Python version (pool index v2, tag schema, naming, global config keys), and
|
||||
the GUI is egui/eframe (`src/ui/`). An existing pool opens unchanged. The
|
||||
Python sources live on in the history of `devel` as the reference.
|
||||
- `FileManager` is the service layer; `File` is a view on one media entry of an
|
||||
identity-keyed movie record in `<pool>/.Curator.!index`; series are scanned
|
||||
live from disk.
|
||||
- The standalone `tipy-dne` binary replaces `scripts/tipy_dne.py` (which had
|
||||
stopped finding movies after the index moved to v2). The one-off Python
|
||||
migration scripts for old ČSFD tag formats were not ported.
|
||||
- Global config moved from `.Curator.!gtag` next to the script to the platform
|
||||
config directory (`CURATOR_CONFIG` overrides it); an old `.Curator.!gtag` in
|
||||
the working directory is picked up on first run.
|
||||
|
||||
### GUI decision
|
||||
|
||||
The GUI was **reframed around the Filmotéka** (not kept as a generic tagger) and
|
||||
**rewritten in PySide6**: Pool/Filmotéka setup, Import movie, tag-filter sidebar,
|
||||
movie table, and one-click Filmotéka generation.
|
||||
The GUI is **egui** (eframe): pure Rust, one static binary, straightforward
|
||||
Windows builds. It mirrors the PySide6 layout — menu, tag-filter sidebar, movie
|
||||
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
|
||||
|
||||
- **Metadata storage:** one **unified metadata file** for the whole pool (a
|
||||
central index), not per-file sidecars. Justified because Curator owns the pool
|
||||
and files are never moved manually, so it is not exposed to path drift.
|
||||
- **The record is the movie, files are its children** (index **v2**,
|
||||
`movie.py`). Keyed by identity (`tmdb-<id>`, else a `name-<slug>` fallback),
|
||||
each record holds the metadata *and* a `media` list of its files with a free
|
||||
`label` per version. Chosen after the v1 path-keyed shape made multi-version
|
||||
movies unworkable: metadata rode on one file, so it had to be rescued when
|
||||
that file went, and retitling a grouped movie had to be forbidden. With
|
||||
identity as the key, `Movie.desired_layout()` is a pure function of the
|
||||
record — **one video → flat, two or more → own folder** — so promote, demote
|
||||
and rename are the same operation, and an import can recognise "this tmdbid
|
||||
is already in the pool" instead of silently creating a duplicate.
|
||||
- **Config split by scope:** the global config (`.Curator.!gtag`) holds **app
|
||||
data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir`
|
||||
pointers (which must stay global: the index is found *through* them). Everything
|
||||
@@ -164,12 +174,267 @@ movie table, and one-click Filmotéka generation.
|
||||
fall back to the file's mtime for the "recently added" ordering (ctime is
|
||||
unreliable — creating the Filmotéka hardlinks bumps it).
|
||||
|
||||
## Direction — Jellyfin pivot (in progress)
|
||||
|
||||
Curator is being refocused from a ČSFD-tagging tool into a **Jellyfin-first
|
||||
library manager**. Agreed shape:
|
||||
|
||||
- **Pool = the Jellyfin library set.** Files are named `Title (rok) [tmdbid-N].ext`
|
||||
— the form Jellyfin matches on. Libraries are physical top-level pool folders,
|
||||
each with a **kind** (`libraries` setting; managed in *Pool → Knihovny…*):
|
||||
- `movie` — single-file movies, TMDb naming (done)
|
||||
- `series` — series/season/episode tree, mirrored 1:1 (done)
|
||||
- `mixed` — movies **and** series in one folder (Jellyfin "Mixed" type;
|
||||
supported-but-discouraged by Jellyfin, but low-risk here thanks to the strict
|
||||
structure, and fully reversible — see decisions). **Scanning TBD (phase 9).**
|
||||
- `homevideo` — files with no TMDb (Jellyfin "Home videos" type). **TBD (ph. 8).**
|
||||
- **Target library layout (the user's plan):** `Hlavní` (mixed, incl. kids),
|
||||
`Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda`
|
||||
(homevideo). Kids are **not** a separate folder — see decisions.
|
||||
- **Naming convention:**
|
||||
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form. A 3D
|
||||
movie carries an extra `.3D.<FORMAT>` tag before the extension (Jellyfin's
|
||||
own convention; `FORMAT` ∈ `HSBS`/`FSBS`/`HTAB`/`FTAB`/`MVC`), set via the
|
||||
edit dialog and preserved across every future rename like the tmdbid is.
|
||||
- **Multiple movie versions** (2D+3D, 4K+1080p, …): identity is
|
||||
`(title, rok, tmdbid)`, stays a **flat file** at one version. A second
|
||||
version promotes it to Jellyfin's required per-movie folder — every file
|
||||
prefixed with the exact folder name — `Title (rok) [tmdbid]/Title (rok)
|
||||
[tmdbid] - {popisek}.ext`; a 3D version's popisek doubles as its 3D tag
|
||||
(`3D HSBS`, scanned by Jellyfin the same as the flat `.3D.HSBS` form).
|
||||
Curator does the promotion/demotion itself (*Upravit film → Verze*), and a
|
||||
version's popisek is renamable too (`rename_movie_version`) — a flat movie
|
||||
always shows one placeholder row ("Výchozí") in the list even though it
|
||||
has no on-disk label yet, but renaming only applies once it's grouped. A
|
||||
popisek left empty (a plain "2D" file with nothing else to call it) is
|
||||
`DEFAULT_VERSION_LABEL` ("Výchozí"), not an error — everywhere a popisek is
|
||||
entered (`add_movie_version`, `rename_movie_version`, `merge_as_version`,
|
||||
the import dialog's version link). Promoting/demoting always carries the
|
||||
file's *own* current 3D formát over automatically when no popisek is
|
||||
given for it (`existing_label`/`keep_label` default to `None` → derived
|
||||
via `naming.parse_3d_format`) — a movie can freely gain and lose versions
|
||||
in any order (3D-only → +2D → -3D → 3D-only again, …) without a formát
|
||||
ever getting silently dropped along the way.
|
||||
The default (plain) version of a grouped movie carries **no label**:
|
||||
`Folder/Folder.ext` (`file_manager._version_filename`); "Výchozí" is the
|
||||
UI's name for it. Identity — what decides "is this film already in the
|
||||
pool" — is the folder name for a grouped movie and the stem minus any
|
||||
`.3D.<FORMAT>` for a flat one (`FileManager._identity_stem`,
|
||||
`pooled_with_stem`). Importing a film that's already there defaults to
|
||||
adding a version, never a root-level duplicate, and "keep both as separate
|
||||
films" is not offered for same-TMDb-id films (they'd share one index record).
|
||||
**Parts** (one film split across files, Jellyfin "stacking") are versions
|
||||
with a `partN` popisek, written as `Folder-partN.ext` instead of
|
||||
`Folder - popisek.ext` (`file_manager._version_filename`,
|
||||
`movie.normalize_part_label`); a film with parts always lives in its own
|
||||
folder. Parts and versions in one folder are not combined yet.
|
||||
*Upravit film* presents this as one "Média filmu" section: flat
|
||||
("Samostatný film") until a second medium arrives, then a folder whose
|
||||
mode is switched between versions and parts (`movie_group_mode`,
|
||||
`set_movie_group_mode`); parts are reordered with ↑/↓ (`move_movie_part`).
|
||||
Renames after a relabel go through `File.save_location`, never a plain
|
||||
`save_metadata`, so a stale sibling can't overwrite shared metadata.
|
||||
Custom per-movie attributes are no longer editable in the GUI; stored ones
|
||||
still feed the miniDLNA filename templates.
|
||||
The table always shows one row per movie (`visible_movies`), marked `▣` /
|
||||
`Film · N verze` when it has several; shared metadata lives on the movie
|
||||
record (index v2), so every version reads the same. Accidental duplicates
|
||||
(same tmdbid / title+year / file size as separate movies) are found by
|
||||
*Nástroje → Kontrola duplicit* and folded in via `merge_as_version`.
|
||||
**Not yet supported:** renaming/moving/retitling a grouped movie (remove
|
||||
the version or edit files directly).
|
||||
Works in `mixed` libraries too, disambiguated from a series by **shape**:
|
||||
a movie's version folder and a series folder are both
|
||||
`Title (rok) [tmdbid]/`, but a series always nests episodes in a
|
||||
`Season NN`/`Specials` folder and a movie's version folder never does
|
||||
(`series.looks_like_series_folder`) — `load_pool_movies`/
|
||||
`is_movie_group_member` read the shape and `scan_series`
|
||||
(`skip_movie_folders=True` for `mixed`) skips what it recognises as a
|
||||
movie, so both kinds can share one identity-named folder pair without
|
||||
either leaking into the other's listing. A `series`-kind library (which
|
||||
never holds movies) keeps reading every subfolder as a series regardless
|
||||
of shape, so a raw not-yet-renamed import can still have loose episode
|
||||
files at the series root before `Season NN` exists.
|
||||
Assignable at **import** time too, not just after the fact: picking
|
||||
several files for the same film in one batch (*Importovat filmy…*), a
|
||||
row's "🔗 Přiřadit jako verzi…" marks it a version of another row instead
|
||||
of importing it as its own movie — the earlier attempt at this (a 3D
|
||||
formát combo per row) was rejected because a lone row has nothing to
|
||||
attach the version *to* and just created a duplicate; picking the target
|
||||
row explicitly is what that was missing. The popisek is pre-filled from
|
||||
the filename (`naming.three_d_format_from_filename`) the same way
|
||||
`year_from_filename` pre-fills the year. Import runs the target rows
|
||||
first, then folds each linked row onto its target via
|
||||
`add_movie_version`. The reverse case — a film whose only file *is* 3D,
|
||||
with nothing to link it to — gets its own **3D formát combo per row**
|
||||
instead (`import_movie(..., format_3d=…)`, same as *Upravit film*);
|
||||
disabled the moment the row is linked as a version or gains one as a
|
||||
follower, since a grouped movie's 3D-ness lives on the version's popisek,
|
||||
not on the row that started the group.
|
||||
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {série}.ext`
|
||||
(season 0 → `Specials`). The episode filename is the code + the series title
|
||||
only — the release string is dropped (identity is the code; Jellyfin fetches
|
||||
episode metadata from TMDb via the code + `[tmdbid]`). Episode numbers are
|
||||
recognised from the filename and editable at import / in the series GUI;
|
||||
*Seriály → "Napravit názvy souborů…"* bulk-fixes the whole pool.
|
||||
- **Episodes split into physical parts** (an old recording cut into
|
||||
several files) get Jellyfin's own `.ptN` stacking suffix —
|
||||
`SxxEyy - {série}.ptN.ext` — recognised from *cd/dvd/part/pt/disc/disk* +
|
||||
a number in the source filename (`Show.S01E02.pt2.mkv`,
|
||||
`Show S01E01-part-1.mkv`, …) or typed by hand at import as `S01E02P3`
|
||||
(`src.core.series.parse_episode_code`/`parse_episode_part`). Distinct from
|
||||
a multi-episode file (`E01-E02`, one file = several episodes) — a part is
|
||||
the opposite: several files = one episode.
|
||||
- **Removing a series is scoped to the tree selection**: *Přehled seriálů →
|
||||
"Odebrat z poolu…"* deletes the series, a single season, or a single
|
||||
episode, whichever is selected (`FileManager.remove_series`/
|
||||
`remove_season`/`remove_episodes`) — series still carry no index metadata,
|
||||
so this is always just deleting files/folders from disk.
|
||||
- **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by
|
||||
`tmdbid` (yearless title search picks the wrong film, hence the earlier
|
||||
"weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when
|
||||
TMDb has no cs title); last fallback = the original filename. The filename is
|
||||
always editable in the GUI.
|
||||
- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to)
|
||||
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
|
||||
Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is
|
||||
kept **only** as a title source (not for tags/matching — it exposes no
|
||||
TMDb/IMDb id, verified): it feeds the **ČSFD → TMDb bridge** (`csfd.py`,
|
||||
`FileManager.resolve_via_csfd`) — filename → ČSFD search → ČSFD *original*
|
||||
title + year → TMDb search → tmdbid, more reliable than a raw Czech-title
|
||||
TMDb search. Used by the import dialog ("Dohledat přes ČSFD") and by migration
|
||||
for any movie with a stored `csfd_url`.
|
||||
- **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link**
|
||||
(drives the ČSFD→TMDb bridge, and stored as a pointer); filename hand-editable
|
||||
before/after import. The import row is pre-filled on open (no network) from
|
||||
metadata already next to the source file: Curator's **pre-index** layouts —
|
||||
a `.{filename}.!tag` sidecar or an old `.Curator.!index` up the tree
|
||||
(`legacy_meta.read_legacy_meta`, authoritative for the ČSFD link) — then a
|
||||
Kodi/Jellyfin `.nfo` (`nfo.read_nfo`, also reads a `csfd` `uniqueid` /
|
||||
`<csfdid>`, and supplies the TMDb id).
|
||||
- **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser,
|
||||
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
|
||||
`naming.py`), not from ČSFD tags. **Scoped per library** (*miniDLNA →
|
||||
"Knihovny pro generování…"*, `minidlna_libraries` in the pool index, default
|
||||
= all) — lets a library (e.g. Kids) be excluded from the dumb-DLNA output
|
||||
entirely; a movie library's genre-tree links are swept as obsolete the normal
|
||||
way when it's excluded. A `series` **or** `homevideo`-kind library has no
|
||||
genre tags to build a tree from (homevideo has no TMDb id either), so both
|
||||
hardcopy whole into `output/<jejich vlastní název>` (`mirror_as_is_libraries`)
|
||||
— this works today independent of phase 8 (homevideo needs no GUI
|
||||
import/browse support to be mirrored). Mirrors have **no obsolete-link
|
||||
mechanism of their own**, so excluding one is pruned explicitly
|
||||
(`FileManager.minidlna_prune_targets` + `HardlinkManager.remove_mirror`) —
|
||||
including the shared `output/Seriály` bucket a mixed-library series and a
|
||||
same-named real library can both write into, reconciled per-entry so pruning
|
||||
one never erases the other's still-valid content.
|
||||
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
|
||||
key + URL in `.env`. NFO on disk stays authoritative.
|
||||
- **Subtitles stay external, never muxed** (`subtitles.py`): a `.srt`/`.ass`/
|
||||
`.ssa`/`.sub`/`.vtt` sharing the video's stem (+ optional `.lang`/`.forced`/
|
||||
`.sdh`/`.default`) plays natively in Jellyfin — no re-encode, multiple
|
||||
languages side by side, easy to replace. Presence is read live from disk
|
||||
(like NFO), not cached in the index. The one thing Curator owns: a subtitle
|
||||
**follows its video through every rename/move** so it never silently
|
||||
detaches (every rename/move path calls `subtitles.rename_subtitles_with`).
|
||||
|
||||
### Decisions (locked)
|
||||
|
||||
- **Kids = Jellyfin parental rating, not a folder** (variant B). Kids content
|
||||
stays in the normal libraries; a child Jellyfin account gets a max-rating limit
|
||||
with **"block unrated" ON**. Ratings come from TMDb via Jellyfin. Caveat: Czech
|
||||
certifications are sparse — set the library metadata country to one with fuller
|
||||
ratings (US) and fix odd cases in Jellyfin (Identify + lock).
|
||||
- **Mixed libraries are OK and reversible.** Jellyfin discourages them (unreliable
|
||||
metadata) but our disciplined structure (series = `Season/SxxEyy`, movie =
|
||||
single file + `[tmdbid]`) gives the scanner clear signals. Because metadata is
|
||||
**NFO on disk** + **tmdbid in the name**, switching mixed↔separate later is just
|
||||
moving folders (Jellyfin re-reads NFO, no re-fetch, no loss).
|
||||
- **Home videos** (Chajda) use Jellyfin's **"Home videos"** content type — no
|
||||
online lookup, just serves the files.
|
||||
- **Always match by tmdbid, not by yearless title.** TMDb cs-CZ titles are good
|
||||
when the right film is matched (lookup by id, or search with a year); a bare
|
||||
title search trips on remakes/same-name films (verified: Home Alone → 2021
|
||||
reboot, Vejška → 2006). Import supplies the id; migration searches with a year.
|
||||
|
||||
### Phasing
|
||||
|
||||
(1) TMDb identity + tmdbid naming + import rework [done]; (2) movie libraries as
|
||||
physical partitions + import-into / move-between [done]; (3) Jellyfin API — scan +
|
||||
verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done];
|
||||
(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing
|
||||
pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs +
|
||||
library organizer (unified `libraries` model with kinds) [done]; (9) mixed-library
|
||||
scanning + generation (movies + series in one folder) [done].
|
||||
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs).
|
||||
|
||||
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
|
||||
[tmdbid].ext`); per-movie folders are a later refinement.
|
||||
|
||||
## Tasks
|
||||
|
||||
# (no open tasks — see Done)
|
||||
### Phase 8 — import without TMDb / without year (homevideo, no-TMDb docs)
|
||||
|
||||
- Relax the import dialog: allow a row with **no year and no tmdbid** (currently
|
||||
a 4-digit year is mandatory). Pool name then falls back to `Title.ext`
|
||||
(movies) / `Series/Season NN/SxxEyy.ext` (series), no `[tmdbid]`.
|
||||
- Drive this by **library kind**: `homevideo` (and no-TMDb rows in `mixed`)
|
||||
skip the TMDb requirement; `movie` keeps requiring a year (as today).
|
||||
- `homevideo` library scanning: load its files as items (flat), no TMDb naming,
|
||||
no genre tags. Consider a dedicated import path or a per-import "bez TMDb" toggle.
|
||||
**miniDLNA generation for `homevideo` needs none of this** — it hardcopies the
|
||||
whole library folder as-is (`mirror_as_is_libraries` **[done]**); this task is
|
||||
only about GUI import/browsing.
|
||||
- Tests: import with no year/id lands correctly; homevideo library loads.
|
||||
|
||||
### Phase 9 — mixed-library scanning + generation
|
||||
|
||||
- Scanning **[done]**: a `mixed` library's top-level files load as movies
|
||||
(`load_pool_movies`), its subfolders as series (`load_series` /
|
||||
`series_libraries` = kind `series` **or** `mixed`, reusing `scan_series`) —
|
||||
*except* a canonical-shaped subfolder with no `Season NN`/`Specials` inside
|
||||
it, which is a movie's version folder instead and loads as a movie too (see
|
||||
"Multiple movie versions" above). Both are kind-aware over the unified
|
||||
`libraries` list. The library filter / table already surfaces mixed
|
||||
libraries.
|
||||
- miniDLNA generation **[done]**: a mixed library's movies are genre-tagged as
|
||||
usual; series in its subfolders have no genre tags, so they're hardcopied
|
||||
straight into `output/Seriály/<název>/…` instead
|
||||
(`FileManager.minidlna_scope` → the mixed-library series list; the mirror
|
||||
loop in `generate_filmoteka`). NFO refresh needs no separate handling — it
|
||||
only ever touches movies (`self.filelist`), series never go through NFO/TMDb
|
||||
tagging, mixed or not.
|
||||
- Tests: a mixed folder with both a movie and a series loads/generates
|
||||
correctly **[done]** (`TestMinidlnaScope` in `test_file_manager.py`).
|
||||
|
||||
### Later / optional
|
||||
|
||||
- **ČSFD → TMDb title bridge (done).** A direct TMDb search on a Czech title was
|
||||
matching the wrong film too often, so a lean `csfd.py` was revived (Anubis PoW
|
||||
solver, film-page + search parsing, `original_title` extraction — the entry in
|
||||
`.film-names` whose flag matches the country of origin). `resolve_via_csfd`
|
||||
drives it from the import dialog and from migration. **Not** a return of ČSFD
|
||||
tags — genres still come from TMDb via Jellyfin/NFO. Open follow-up: batch
|
||||
"look up ČSFD links for the whole pool" as a migration pre-pass (today the
|
||||
`csfd_url` is per-movie, set at import / in the edit dialog).
|
||||
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat
|
||||
files — **partially done**: a movie only gets its own folder once it has a
|
||||
second playable version (see "Multiple movie versions" below); a single-file
|
||||
movie stays flat as before.
|
||||
- Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO.
|
||||
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
|
||||
path-aware `library_of` (currently single-segment).
|
||||
|
||||
## Done
|
||||
|
||||
- Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie
|
||||
libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD
|
||||
removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs
|
||||
- Phase 9 — mixed-library scanning + generation: a `mixed` library's top-level
|
||||
movies genre-tag as usual, series in its subfolders hardcopy into
|
||||
`output/Seriály/<název>/…` (no genre tags apply to a series)
|
||||
- Library organizer (`libraries` unified model with kinds movie/series/mixed/
|
||||
homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived
|
||||
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
|
||||
import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus
|
||||
"Přejmenovat dle ČSFD (rok)" to bring older files up to the convention
|
||||
|
||||
@@ -1,60 +1,91 @@
|
||||
# Curator
|
||||
|
||||
Curator is a desktop **movie-library manager (Filmotéka)**. You import video
|
||||
files into a managed **pool**, tag them, and Curator generates a browsable
|
||||
**Filmotéka** — a directory tree of hardlinks into the pool — without ever
|
||||
duplicating the actual video data on disk.
|
||||
Curator is a desktop app for managing a **Jellyfin-first movie and series
|
||||
library**. You import video files into a managed **pool**; Curator identifies
|
||||
them through **TMDb**, names and lays them out the way Jellyfin expects, and can
|
||||
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
|
||||
hardlink-tree machinery is inherited and extended into a full library workflow.
|
||||
Metadata (posters, plots, `.nfo` files) is Jellyfin's job; Curator owns the
|
||||
files, their names and the library structure. Written in Rust with an egui GUI.
|
||||
|
||||
## Concepts
|
||||
|
||||
- **Pool** — the managed repository of files and the **single source of truth**.
|
||||
Curator owns it: it imports and removes files itself, so nothing moves behind
|
||||
its back. The pool has two top-level folders:
|
||||
- `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).
|
||||
- **Pool** — the managed folder and the **single source of truth**. Curator
|
||||
imports, renames and removes files itself, so nothing moves behind its back.
|
||||
Each top-level folder is a **library** with a kind:
|
||||
|
||||
## 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.
|
||||
2. **Import a movie**: pick a video, enter its **Title** and a **ČSFD link**. The
|
||||
file is copied (non-destructively) into `pool/Filmy` as `Title.ext` and
|
||||
recorded in the index. If a ČSFD link is given, Curator fetches the movie from
|
||||
[ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země původu** tags
|
||||
automatically (use "Načíst tagy z ČSFD" to (re)fetch later).
|
||||
3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI.
|
||||
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
|
||||
`Seriály` are mirrored 1:1 as hardlinks.
|
||||
- **Pool index** — Curator's own metadata (TMDb id, ČSFD link, versions, tags,
|
||||
library settings) lives in one `.Curator.!index` JSON file at the pool root.
|
||||
- **miniDLNA output** — a generated tree of **hardlinks** into the pool, built
|
||||
from genres / years / countries / ratings. It is disposable: delete it and
|
||||
regenerate it at any time.
|
||||
|
||||
## Features
|
||||
|
||||
- Import movies and episodes with a **TMDb id**; Czech titles come from TMDb,
|
||||
with **ČSFD** as a fallback and as a bridge for finding the right TMDb id.
|
||||
- **Multiple versions** of one film (2D/3D, 4K, director's cut) and
|
||||
**multi-part** films and episodes, named so Jellyfin groups them.
|
||||
- Subtitle sidecars follow their video through renames.
|
||||
- Duplicate check, video integrity check (FFmpeg), library statistics,
|
||||
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
|
||||
|
||||
```bash
|
||||
poetry install
|
||||
poetry run python Curator.py
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
The GUI is built with **PySide6**.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
poetry run pytest # tests
|
||||
poetry run ruff check # lint / format
|
||||
poetry run mypy # type checking
|
||||
cargo fmt # format
|
||||
cargo clippy -- -D warnings # lint
|
||||
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
|
||||
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`).
|
||||
|
||||
@@ -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. |
|
||||
@@ -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.
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=HTTP server pro HLS složku Makov webkamery (pro Jellyfin M3U tuner)
|
||||
After=network-online.target makov-cam.service
|
||||
Wants=network-online.target
|
||||
BindsTo=makov-cam.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# WorkingDirectory je rodič hls/, aby šel přes HTTP servírovat i makov.m3u
|
||||
# (playlist pro Jellyfin), ne jen samotné HLS segmenty.
|
||||
WorkingDirectory=/var/lib/jellyfin-iptv
|
||||
# Bind na IP brány docker sítě, na které běží Jellyfin kontejner (ne na
|
||||
# 127.0.0.1 hostitele — to by z kontejneru bylo jeho vlastní loopback, ne
|
||||
# hostitelův). Konkrétní hodnota je specifická pro tenhle server, viz
|
||||
# setup.md.
|
||||
ExecStart=/usr/bin/python3 -m http.server 8888 --bind 172.19.0.1
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=makov-cam
|
||||
Group=makov-cam
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Restream Makov webkamery (Krmítko) do lokálního HLS pro Jellyfin
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/makov-cam.sh
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Necháme běžet pod samostatným účtem, ne pod root.
|
||||
User=makov-cam
|
||||
Group=makov-cam
|
||||
# Pokud je yt-dlp nainstalované přes snap, ignoruje $HOME nastavené tady —
|
||||
# zjišťuje domovský adresář přímo z /etc/passwd. Účet je --no-create-home,
|
||||
# takže je potřeba /home/makov-cam vytvořit a vlastnit ručně (viz setup.md).
|
||||
# Skript si sám zakládá /var/lib/jellyfin-iptv/hls — účet tam musí mít zápis
|
||||
# (viz setup.md, sekce "Uživatel a oprávnění").
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
# Restreams the Makov webcam (YouTube live "Krmítko") into a local HLS
|
||||
# playlist that never goes stale for the viewer — Jellyfin always points at
|
||||
# this fixed local path, not at YouTube's own signed/expiring URL.
|
||||
#
|
||||
# When ffmpeg dies (the YouTube URL expired, network hiccup, stream briefly
|
||||
# ended, ...) this script simply exits; systemd (Restart=always) starts it
|
||||
# again, which resolves a *fresh* URL via yt-dlp. Nothing needs to be done
|
||||
# by hand.
|
||||
set -euo pipefail
|
||||
|
||||
YOUTUBE_URL="https://www.youtube.com/watch?v=F0fsV1GGZ3M"
|
||||
OUT_DIR="/var/lib/jellyfin-iptv/hls"
|
||||
OUT_FILE="${OUT_DIR}/makov.m3u8"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
# This live stream only serves separate video-only/audio-only HLS renditions
|
||||
# (no combined progressive format), so we resolve both and let ffmpeg mux
|
||||
# them. "bestvideo+bestaudio" makes yt-dlp -g print exactly two URLs, video
|
||||
# then audio; fall back to "best" in case a progressive format reappears.
|
||||
mapfile -t STREAM_URLS < <(yt-dlp -g -f 'bestvideo+bestaudio/best' "$YOUTUBE_URL")
|
||||
VIDEO_URL="${STREAM_URLS[0]:-}"
|
||||
AUDIO_URL="${STREAM_URLS[1]:-}"
|
||||
|
||||
if [ -z "$VIDEO_URL" ]; then
|
||||
echo "yt-dlp nevrátilo žádnou URL (stream offline, nebo se YouTube brání) — končím, systemd to za chvíli zkusí znovu." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$AUDIO_URL" ]; then
|
||||
# Progressive fallback matched: single URL carries both video and audio.
|
||||
exec ffmpeg -nostdin -loglevel warning \
|
||||
-re -i "$VIDEO_URL" \
|
||||
-c copy \
|
||||
-f hls -hls_time 4 -hls_list_size 6 \
|
||||
-hls_flags delete_segments+omit_endlist \
|
||||
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
|
||||
"$OUT_FILE"
|
||||
fi
|
||||
|
||||
exec ffmpeg -nostdin -loglevel warning \
|
||||
-re -i "$VIDEO_URL" -re -i "$AUDIO_URL" \
|
||||
-map 0:v -map 1:a \
|
||||
-c copy \
|
||||
-f hls -hls_time 4 -hls_list_size 6 \
|
||||
-hls_flags delete_segments+omit_endlist \
|
||||
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
|
||||
"$OUT_FILE"
|
||||
@@ -0,0 +1,175 @@
|
||||
# Makov webkamera → Jellyfin (Live TV)
|
||||
|
||||
Restreamuje YouTube live přenos ("Krmítko", https://www.youtube.com/watch?v=F0fsV1GGZ3M)
|
||||
do lokálního HLS, na který se Jellyfin dívá přes stabilní adresu. Když YouTube
|
||||
odkaz vyprší nebo spadne, systemd proces sám restartuje a natáhne novou URL —
|
||||
divák/Jellyfin si ničeho nevšimne.
|
||||
|
||||
## 0) Kam skript patří
|
||||
|
||||
Toto **musí běžet na stejném stroji (resp. za stejnou veřejnou IP), jako
|
||||
Jellyfin** — YouTube vrací streamovací URL svázanou s IP adresou, ze které
|
||||
byla vyžádána (`yt-dlp -g`). Pokud poběží jinde než Jellyfin, `ffmpeg` dostane
|
||||
403 a nikdy se nerozjede.
|
||||
|
||||
## 1) Závislosti
|
||||
|
||||
```bash
|
||||
sudo apt install -y ffmpeg python3
|
||||
sudo pip install -U yt-dlp # nebo pipx install yt-dlp — apt verze bývá stará
|
||||
# a YouTube ji rád rozbije
|
||||
```
|
||||
|
||||
Ověř, že `yt-dlp` a `ffmpeg` jedou z shellu bez chyby:
|
||||
```bash
|
||||
yt-dlp --version
|
||||
ffmpeg -version
|
||||
```
|
||||
|
||||
## 2) Uživatel a oprávnění
|
||||
|
||||
Vlastní, neprivilegovaný systémový účet — obě služby pod ním běží:
|
||||
|
||||
```bash
|
||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin makov-cam
|
||||
sudo mkdir -p /var/lib/jellyfin-iptv/hls
|
||||
sudo chown -R makov-cam:makov-cam /var/lib/jellyfin-iptv
|
||||
```
|
||||
|
||||
Pokud je `yt-dlp` nainstalované přes **snap** (`which yt-dlp` ukazuje
|
||||
`/snap/bin/yt-dlp`), potřebuje při startu zapisovatelný domovský adresář —
|
||||
a to i přes `--no-create-home`, protože domov si zjišťuje přímo z
|
||||
`/etc/passwd`, ne z proměnné `$HOME` (tu přenastavenou v service souboru
|
||||
ignoruje). Bez toho skončí na `Permission denied: mkdir /home/makov-cam`.
|
||||
Vytvoř mu ho ručně:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /home/makov-cam
|
||||
sudo chown makov-cam:makov-cam /home/makov-cam
|
||||
sudo chmod 750 /home/makov-cam
|
||||
```
|
||||
|
||||
(Pokud `yt-dlp` instaluješ přes `pip`/`pipx` dle kroku 1, tenhle problém
|
||||
nenastává a krok lze přeskočit.)
|
||||
|
||||
## 3) Nasazení souborů
|
||||
|
||||
```bash
|
||||
sudo install -m 755 makov-cam.sh /usr/local/bin/makov-cam.sh
|
||||
sudo install -m 644 makov-cam.service /etc/systemd/system/
|
||||
sudo install -m 644 makov-cam-http.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
## 4) Spuštění
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now makov-cam.service
|
||||
sudo systemctl enable --now makov-cam-http.service
|
||||
```
|
||||
|
||||
Ověření:
|
||||
```bash
|
||||
systemctl status makov-cam.service makov-cam-http.service
|
||||
journalctl -u makov-cam.service -f # živě sleduj log ffmpeg/yt-dlp
|
||||
|
||||
# do pár vteřin by se měl objevit playlist a segmenty:
|
||||
ls -la /var/lib/jellyfin-iptv/hls/
|
||||
|
||||
# a mělo by jít stáhnout přes HTTP:
|
||||
curl -sI http://127.0.0.1:8888/hls/makov.m3u8
|
||||
```
|
||||
|
||||
`makov-cam-http.service` je svázaná (`BindsTo=`) na `makov-cam.service` — když
|
||||
restream spadne/restartuje, HTTP server padne/naskočí s ním, ať nikdy
|
||||
neservíruje mrtvý/prázdný playlist. Pozor: `BindsTo=` jen zajišťuje, že HTTP
|
||||
server *spadne*, když spadne hlavní služba — nezajišťuje, že se s ní i sám
|
||||
*nastartuje*. Po `enable --now` obou služeb to není problém (obě se spustí),
|
||||
ale pokud jen restartuješ `makov-cam.service` ručně, zkontroluj/restartuj i
|
||||
`makov-cam-http.service`.
|
||||
|
||||
HTTP server poslouchá jen na jedné IP (`--bind`), ne na `0.0.0.0` — nemá cenu
|
||||
ho vystavovat celé síti, stačí odkud se na něj bude dívat Jellyfin:
|
||||
|
||||
- **Jellyfin běží přímo na hostu** (ne v kontejneru): `--bind 127.0.0.1`
|
||||
stačí.
|
||||
- **Jellyfin běží v Docker kontejneru na tomtéž hostu** (běžný případ) —
|
||||
`127.0.0.1` uvnitř kontejneru je jeho *vlastní* loopback, ne hostitelův,
|
||||
takže na hostitelův HTTP server se tak nedostane. Zjisti gateway IP té
|
||||
docker sítě, na které kontejner běží, a naviaž se na ni:
|
||||
```bash
|
||||
docker inspect <jellyfin-container> --format '{{.HostConfig.NetworkMode}}'
|
||||
docker inspect <jellyfin-container> --format '{{json .NetworkSettings.Networks}}'
|
||||
# z výstupu vezmi "Gateway", např. 172.19.0.1 — tam se z kontejneru dostaneš
|
||||
```
|
||||
a použij `--bind <ta-gateway-IP>` (viz `makov-cam-http.service`). Pokud by
|
||||
Jellyfin běžel v jiné VM, změň `--bind` na IP, na které je pro ni
|
||||
dosažitelný, a případně otevři firewall jen pro tenhle port a jen z její
|
||||
IP.
|
||||
|
||||
## 5) Playlist pro Jellyfin
|
||||
|
||||
`WorkingDirectory` HTTP služby je `/var/lib/jellyfin-iptv` (rodič `hls/`), aby
|
||||
šel přes stejný server servírovat i samotný `.m3u` playlist — to se hodí,
|
||||
pokud Jellyfin (typicky v kontejneru) nevidí souborový systém hostitele a
|
||||
tuner tak musí načíst playlist přes HTTP URL, ne lokální cestou.
|
||||
|
||||
Vytvoř `/var/lib/jellyfin-iptv/makov.m3u`:
|
||||
|
||||
```
|
||||
#EXTM3U
|
||||
#EXTINF:-1 tvg-id="makov-krmitko" tvg-name="Makov - Krmítko",Makov - Krmítko
|
||||
http://<BIND_IP>:8888/hls/makov.m3u8
|
||||
```
|
||||
|
||||
kde `<BIND_IP>` je stejná adresa, na kterou je navázaný `makov-cam-http.service`
|
||||
(viz krok 4).
|
||||
|
||||
## 6) Nastavení v Jellyfinu
|
||||
|
||||
1. **Dashboard → Live TV → Tuner Devices → +** (Add)
|
||||
2. Typ: **M3U Tuner**
|
||||
3. File or URL:
|
||||
- Jellyfin na hostu s přístupem k souborovému systému: lokální cesta
|
||||
`/var/lib/jellyfin-iptv/makov.m3u`.
|
||||
- Jellyfin v kontejneru (nevidí hostitelův FS): HTTP URL
|
||||
`http://<BIND_IP>:8888/makov.m3u` (viz krok 5).
|
||||
4. **Save** → Jellyfin načte kanál "Makov - Krmítko" do Live TV
|
||||
5. Doporučeno: **Dashboard → Live TV → Guide Providers** — pro tenhle kanál
|
||||
žádného není třeba (žádný EPG), Jellyfin ho ukáže jako kanál bez programu,
|
||||
což nevadí.
|
||||
6. Zkontroluj přehrávání v **Live TV** sekci hlavního rozhraní.
|
||||
|
||||
Protože kanál pořád ukazuje na stejnou lokální adresu, odpadá starost o
|
||||
obnovování guide dat nebo expiraci URL na Jellyfin straně — o to se stará jen
|
||||
`makov-cam.service` na pozadí.
|
||||
|
||||
## 7) Autostart po restartu serveru
|
||||
|
||||
`enable` v kroku 4 už zajistil start při bootu. Rychlá kontrola:
|
||||
```bash
|
||||
systemctl is-enabled makov-cam.service makov-cam-http.service
|
||||
```
|
||||
|
||||
## Řešení problémů
|
||||
|
||||
- **`ffmpeg` pořád padá / v logu 403 od googlevideo.com** — nejčastěji IP
|
||||
mismatch (viz bod 0): stroj, co spouští `yt-dlp -g`/`ffmpeg`, musí mít
|
||||
stejnou veřejnou IP jako to, odkud by se stream normálně přehrával.
|
||||
Zkontroluj `journalctl -u makov-cam.service -n 50`.
|
||||
- **`yt-dlp: command not found` v service logu** — systemd nepoužívá tvůj
|
||||
interaktivní `$PATH`; ověř `which yt-dlp` a případně uprav `ExecStart` v
|
||||
`makov-cam.sh`/skriptu na plnou cestu (`/usr/local/bin/yt-dlp` apod.), nebo
|
||||
nainstaluj přes `sudo pip install`, aby šel do `/usr/local/bin`.
|
||||
- **Playlist v Jellyfinu jede, ale sekundy/minuty výpadku při refreshi** —
|
||||
normální, `hls_list_size 6` × `hls_time 4s` drží ~24s okno; při restartu
|
||||
streamu je pár vteřin díra, než naběhnou nové segmenty. Jde zkrátit/prodloužit
|
||||
úpravou těch dvou hodnot ve skriptu.
|
||||
- **YouTube někdy dočasně rozbije `yt-dlp` novou verzí přehrávače** — drž
|
||||
`yt-dlp` aktuální (`sudo pip install -U yt-dlp`, klidně jako týdenní cron).
|
||||
- **`ERROR: Requested format is not available`** — stream v tu chvíli nenabízí
|
||||
žádný kombinovaný (progresivní) formát, jen oddělené video-only/audio-only
|
||||
HLS řady (ověř přes `yt-dlp -F <url>`). Skript proto primárně používá
|
||||
`-f 'bestvideo+bestaudio/best'` a při dvou vrácených URL pustí `ffmpeg` se
|
||||
dvěma `-i` vstupy (`-map 0:v -map 1:a`); jedna URL = fallback na progresivní
|
||||
formát, pokud se zase objeví.
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -1,35 +0,0 @@
|
||||
[project]
|
||||
name = "curator"
|
||||
version = "1.11.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)"
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -7,6 +7,6 @@ RequiresMountsFor=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0
|
||||
Type=oneshot
|
||||
User=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
|
||||
NoNewPrivileges=true
|
||||
|
||||
@@ -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()
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Auto-generated — do not edit manually."""
|
||||
__version__ = "1.11.0"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -1,100 +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 — CSFDMovie attribute feeding it (genres / year / countries /
|
||||
# rating / directors / actors), or None for a user-only category
|
||||
# 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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,682 +0,0 @@
|
||||
"""
|
||||
CSFD.cz scraper module for fetching movie information.
|
||||
|
||||
This module provides functionality to fetch movie data from CSFD.cz (Czech-Slovak Film Database).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
HAS_DEPENDENCIES = True
|
||||
except ImportError:
|
||||
HAS_DEPENDENCIES = False
|
||||
requests = None # type: ignore
|
||||
BeautifulSoup = None # type: ignore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
CSFD_BASE_URL = "https://www.csfd.cz"
|
||||
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
|
||||
|
||||
# User agent to avoid being blocked
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept-Language": "cs,en;q=0.9",
|
||||
}
|
||||
|
||||
# Anubis is the proof-of-work anti-bot wall ČSFD now puts in front of every page.
|
||||
# A plain request gets a 200 with a JS challenge page (title "Ujišťujeme se, že
|
||||
# nejste robot!") instead of the movie, so JSON-LD/genres/year all parse empty.
|
||||
# We detect that page, solve the PoW the way the bundled worker JS does, and
|
||||
# replay the request through the same session to obtain the auth cookie.
|
||||
ANUBIS_CHALLENGE_MARKER = 'id="anubis_challenge"'
|
||||
ANUBIS_PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
|
||||
# Safety cap so a difficulty bump can never spin forever (difficulty 1 needs ~16).
|
||||
ANUBIS_MAX_NONCE = 50_000_000
|
||||
|
||||
# Keep only the top-billed cast from a movie's actor list.
|
||||
MAX_ACTORS = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSFDMovie:
|
||||
"""Represents movie data from CSFD.cz"""
|
||||
title: str
|
||||
url: str
|
||||
year: Optional[int] = None
|
||||
genres: list[str] = field(default_factory=list)
|
||||
directors: list[str] = field(default_factory=list)
|
||||
actors: list[str] = field(default_factory=list)
|
||||
rating: Optional[int] = None # Percentage 0-100
|
||||
rating_count: Optional[int] = None
|
||||
duration: Optional[int] = None # Minutes
|
||||
# A movie can be a co-production, so the origin is a list of countries
|
||||
# (ČSFD writes them slash-separated, e.g. "Japonsko / USA").
|
||||
countries: list[str] = field(default_factory=list)
|
||||
poster_url: Optional[str] = None
|
||||
plot: Optional[str] = None
|
||||
csfd_id: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize to a plain dict for storage in .!tag cache."""
|
||||
return {
|
||||
"title": self.title,
|
||||
"url": self.url,
|
||||
"year": self.year,
|
||||
"genres": self.genres,
|
||||
"directors": self.directors,
|
||||
"actors": self.actors,
|
||||
"rating": self.rating,
|
||||
"rating_count": self.rating_count,
|
||||
"duration": self.duration,
|
||||
"countries": self.countries,
|
||||
"poster_url": self.poster_url,
|
||||
"plot": self.plot,
|
||||
"csfd_id": self.csfd_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CSFDMovie":
|
||||
"""Deserialize from a plain dict (e.g. loaded from .!tag cache)."""
|
||||
countries = data.get("countries")
|
||||
if countries is None:
|
||||
# Legacy cache stored a single "country" string (possibly slash-joined)
|
||||
countries = _split_countries(data.get("country"))
|
||||
return cls(
|
||||
title=data.get("title", ""),
|
||||
url=data.get("url", ""),
|
||||
year=data.get("year"),
|
||||
genres=data.get("genres", []),
|
||||
directors=data.get("directors", []),
|
||||
actors=data.get("actors", []),
|
||||
rating=data.get("rating"),
|
||||
rating_count=data.get("rating_count"),
|
||||
duration=data.get("duration"),
|
||||
countries=countries,
|
||||
poster_url=data.get("poster_url"),
|
||||
plot=data.get("plot"),
|
||||
csfd_id=data.get("csfd_id"),
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
parts = [self.title]
|
||||
if self.year:
|
||||
parts[0] += f" ({self.year})"
|
||||
if self.rating is not None:
|
||||
parts.append(f"Hodnocení: {self.rating}%")
|
||||
if self.genres:
|
||||
parts.append(f"Žánr: {', '.join(self.genres)}")
|
||||
if self.countries:
|
||||
parts.append(f"Země původu: {', '.join(self.countries)}")
|
||||
if self.directors:
|
||||
parts.append(f"Režie: {', '.join(self.directors)}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def rating_band(rating: int) -> str:
|
||||
"""Bucket a 0–100 ČSFD 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).
|
||||
# These are applied when building Filmotéka FOLDER names — the tag itself keeps
|
||||
# the exact value (e.g. rating 90 → tag "Hodnocení/90", folder "…/90–100 %").
|
||||
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)
|
||||
|
||||
|
||||
def csfd_field_values(movie: "CSFDMovie", field: str) -> list[str]:
|
||||
"""Exact tag values for a CSFDMovie attribute (scalar or list), as strings.
|
||||
|
||||
No transform is applied here — tags carry the precise value; any grouping
|
||||
transform happens later, when the Filmotéka folder names are built.
|
||||
Unknown field / missing value yields ``[]``.
|
||||
"""
|
||||
raw = getattr(movie, field, None)
|
||||
if raw is None or raw == "":
|
||||
return []
|
||||
items = list(raw) if isinstance(raw, (list, tuple)) else [raw]
|
||||
values = [str(item).strip() for item in items if item is not None and item != ""]
|
||||
return [v for v in values if v]
|
||||
|
||||
|
||||
def _split_countries(text: Optional[str]) -> list[str]:
|
||||
"""Split a ČSFD origin country string into individual countries.
|
||||
|
||||
ČSFD writes co-productions slash-separated, e.g. ``"Japonsko / USA"`` →
|
||||
``["Japonsko", "USA"]``. ``None``/empty yields an empty list.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
return [part.strip() for part in text.split("/") if part.strip()]
|
||||
|
||||
|
||||
def _check_dependencies():
|
||||
"""Check if required dependencies are installed."""
|
||||
if not HAS_DEPENDENCIES:
|
||||
raise ImportError(
|
||||
"CSFD module requires 'requests' and 'beautifulsoup4' packages. "
|
||||
"Install them with: pip install requests beautifulsoup4"
|
||||
)
|
||||
|
||||
|
||||
def _extract_csfd_id(url: str) -> Optional[int]:
|
||||
"""Extract CSFD movie ID from URL."""
|
||||
match = re.search(r"/film/(\d+)", url)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _parse_duration(duration_str: str) -> Optional[int]:
|
||||
"""Parse ISO 8601 duration (PT97M) to minutes."""
|
||||
match = re.search(r"PT(\d+)M", duration_str)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _extract_json_blob(html: str, element_id: str):
|
||||
"""Return the parsed JSON from an Anubis ``<script id=...>`` blob, or None."""
|
||||
match = re.search(
|
||||
rf'<script id="{re.escape(element_id)}" type="application/json">(.*?)</script>',
|
||||
html,
|
||||
re.S,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return json.loads(match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]:
|
||||
"""Brute-force the Anubis proof-of-work.
|
||||
|
||||
Mirrors the bundled ``sha256-purejs`` worker: find the smallest ``nonce``
|
||||
such that ``sha256(random_data + str(nonce))`` has ``difficulty`` leading
|
||||
zero nibbles. Returns ``(hash_hex, nonce, elapsed_ms)``.
|
||||
"""
|
||||
full_zero_bytes = difficulty // 2
|
||||
needs_half_byte = difficulty % 2 != 0
|
||||
start = time.monotonic()
|
||||
for nonce in range(ANUBIS_MAX_NONCE):
|
||||
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).digest()
|
||||
if any(digest[i] != 0 for i in range(full_zero_bytes)):
|
||||
continue
|
||||
if needs_half_byte and digest[full_zero_bytes] >> 4 != 0:
|
||||
continue
|
||||
elapsed_ms = int((time.monotonic() - start) * 1000)
|
||||
return digest.hex(), nonce, elapsed_ms
|
||||
raise ValueError(
|
||||
f"Anubis PoW unsolved within {ANUBIS_MAX_NONCE} attempts (difficulty {difficulty})"
|
||||
)
|
||||
|
||||
|
||||
def _solve_anubis_challenge(session, html: str, url: str):
|
||||
"""Solve the Anubis challenge in ``html`` and return the real page response.
|
||||
|
||||
Posts the proof-of-work back to the pass-challenge endpoint through
|
||||
``session`` (which stores the resulting auth cookie) and follows the
|
||||
redirect to the originally requested page.
|
||||
"""
|
||||
payload = _extract_json_blob(html, "anubis_challenge")
|
||||
if not payload:
|
||||
raise ValueError("ČSFD anti-bot stránka bez čitelné Anubis challenge")
|
||||
|
||||
rules = payload.get("rules", {})
|
||||
challenge = payload.get("challenge", {})
|
||||
random_data = challenge.get("randomData")
|
||||
difficulty = int(rules.get("difficulty", 1))
|
||||
if not random_data:
|
||||
raise ValueError("Anubis challenge neobsahuje randomData")
|
||||
|
||||
base_prefix = _extract_json_blob(html, "anubis_base_prefix") or ""
|
||||
logger.debug(f"Solving Anubis challenge (difficulty {difficulty}) for {url}")
|
||||
hash_hex, nonce, elapsed_ms = _solve_anubis_pow(random_data, difficulty)
|
||||
logger.debug(f"Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms")
|
||||
|
||||
pass_url = urljoin(CSFD_BASE_URL, f"{base_prefix}{ANUBIS_PASS_PATH}")
|
||||
response = session.get(
|
||||
pass_url,
|
||||
params={
|
||||
"id": challenge.get("id"),
|
||||
"response": hash_hex,
|
||||
"nonce": nonce,
|
||||
"redir": url,
|
||||
"elapsedTime": elapsed_ms,
|
||||
},
|
||||
headers=HEADERS,
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
if ANUBIS_CHALLENGE_MARKER in response.text:
|
||||
raise ValueError("ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto)")
|
||||
return response
|
||||
|
||||
|
||||
def _get_page(session, url: str):
|
||||
"""GET ``url`` through ``session``, transparently clearing an Anubis wall."""
|
||||
response = session.get(url, headers=HEADERS, timeout=10)
|
||||
response.raise_for_status()
|
||||
if ANUBIS_CHALLENGE_MARKER in response.text:
|
||||
response = _solve_anubis_challenge(session, response.text, url)
|
||||
return response
|
||||
|
||||
|
||||
def fetch_movie(url: str, session=None) -> CSFDMovie:
|
||||
"""
|
||||
Fetch movie information from CSFD.cz URL.
|
||||
|
||||
Args:
|
||||
url: Full CSFD.cz movie URL (e.g., https://www.csfd.cz/film/9423-pane-vy-jste-vdova/)
|
||||
session: Optional ``requests.Session`` to reuse (keeps the Anubis auth
|
||||
cookie across calls so only the first fetch pays the PoW cost).
|
||||
|
||||
Returns:
|
||||
CSFDMovie object with extracted data
|
||||
|
||||
Raises:
|
||||
ImportError: If required dependencies are not installed
|
||||
requests.RequestException: If network request fails
|
||||
ValueError: If URL is invalid or page cannot be parsed
|
||||
"""
|
||||
_check_dependencies()
|
||||
|
||||
own_session = session is None
|
||||
if own_session:
|
||||
session = requests.Session()
|
||||
try:
|
||||
response = _get_page(session, url)
|
||||
finally:
|
||||
if own_session:
|
||||
session.close()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# Try to extract JSON-LD structured data first (most reliable)
|
||||
movie_data = _extract_json_ld(soup)
|
||||
|
||||
# Extract additional data from HTML
|
||||
movie_data["url"] = url
|
||||
movie_data["csfd_id"] = _extract_csfd_id(url)
|
||||
|
||||
# Get rating from HTML if not in JSON-LD
|
||||
if movie_data.get("rating") is None:
|
||||
movie_data["rating"] = _extract_rating(soup)
|
||||
|
||||
# Get poster URL
|
||||
if movie_data.get("poster_url") is None:
|
||||
movie_data["poster_url"] = _extract_poster(soup)
|
||||
|
||||
# Get plot summary
|
||||
if movie_data.get("plot") is None:
|
||||
movie_data["plot"] = _extract_plot(soup)
|
||||
|
||||
# Get countries and year from origin info
|
||||
origin_info = _extract_origin_info(soup)
|
||||
if origin_info:
|
||||
if not movie_data.get("countries"):
|
||||
movie_data["countries"] = origin_info.get("countries", [])
|
||||
if movie_data.get("year") is None:
|
||||
movie_data["year"] = origin_info.get("year")
|
||||
if movie_data.get("duration") is None:
|
||||
movie_data["duration"] = origin_info.get("duration")
|
||||
|
||||
# Get genres from HTML if not in JSON-LD
|
||||
if not movie_data.get("genres"):
|
||||
movie_data["genres"] = _extract_genres(soup)
|
||||
|
||||
# Keep only the leading cast (ČSFD lists them in billing order)
|
||||
movie_data["actors"] = movie_data.get("actors", [])[:MAX_ACTORS]
|
||||
|
||||
return CSFDMovie(**movie_data)
|
||||
|
||||
|
||||
def _extract_json_ld(soup: BeautifulSoup) -> dict:
|
||||
"""Extract movie data from JSON-LD structured data."""
|
||||
data = {
|
||||
"title": "",
|
||||
"year": None,
|
||||
"genres": [],
|
||||
"directors": [],
|
||||
"actors": [],
|
||||
"rating": None,
|
||||
"rating_count": None,
|
||||
"duration": None,
|
||||
"countries": [],
|
||||
"poster_url": None,
|
||||
"plot": None,
|
||||
}
|
||||
|
||||
# Find JSON-LD script
|
||||
script_tags = soup.find_all("script", type="application/ld+json")
|
||||
for script in script_tags:
|
||||
try:
|
||||
json_data = json.loads(script.string)
|
||||
|
||||
# Handle both single object and array
|
||||
if isinstance(json_data, list):
|
||||
for item in json_data:
|
||||
if item.get("@type") == "Movie":
|
||||
json_data = item
|
||||
break
|
||||
else:
|
||||
continue
|
||||
|
||||
if json_data.get("@type") != "Movie":
|
||||
continue
|
||||
|
||||
# Title
|
||||
data["title"] = json_data.get("name", "")
|
||||
|
||||
# Genres
|
||||
genre = json_data.get("genre", [])
|
||||
if isinstance(genre, str):
|
||||
data["genres"] = [genre]
|
||||
else:
|
||||
data["genres"] = list(genre)
|
||||
|
||||
# Directors
|
||||
directors = json_data.get("director", [])
|
||||
if isinstance(directors, dict):
|
||||
directors = [directors]
|
||||
data["directors"] = [d.get("name", "") for d in directors if d.get("name")]
|
||||
|
||||
# Actors
|
||||
actors = json_data.get("actor", [])
|
||||
if isinstance(actors, dict):
|
||||
actors = [actors]
|
||||
data["actors"] = [a.get("name", "") for a in actors if a.get("name")]
|
||||
|
||||
# Rating
|
||||
agg_rating = json_data.get("aggregateRating", {})
|
||||
if agg_rating:
|
||||
rating_value = agg_rating.get("ratingValue")
|
||||
if rating_value is not None:
|
||||
data["rating"] = round(float(rating_value))
|
||||
data["rating_count"] = agg_rating.get("ratingCount")
|
||||
|
||||
# Duration
|
||||
duration_str = json_data.get("duration", "")
|
||||
if duration_str:
|
||||
data["duration"] = _parse_duration(duration_str)
|
||||
|
||||
# Year (CSFD exposes it via dateCreated / datePublished)
|
||||
for date_key in ("dateCreated", "datePublished"):
|
||||
date_val = json_data.get(date_key)
|
||||
if date_val:
|
||||
year_match = re.search(r"(19\d{2}|20\d{2})", str(date_val))
|
||||
if year_match:
|
||||
data["year"] = int(year_match.group(1))
|
||||
break
|
||||
|
||||
# Poster
|
||||
image = json_data.get("image")
|
||||
if image:
|
||||
if isinstance(image, str):
|
||||
data["poster_url"] = image
|
||||
elif isinstance(image, dict):
|
||||
data["poster_url"] = image.get("url")
|
||||
|
||||
# Description
|
||||
data["plot"] = json_data.get("description")
|
||||
|
||||
break # Found movie data
|
||||
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
continue
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _extract_rating(soup: BeautifulSoup) -> Optional[int]:
|
||||
"""Extract rating percentage from HTML."""
|
||||
# Look for rating box
|
||||
rating_elem = soup.select_one(".film-rating-average")
|
||||
if rating_elem:
|
||||
text = rating_elem.get_text(strip=True)
|
||||
match = re.search(r"(\d+)%", text)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_poster(soup: BeautifulSoup) -> Optional[str]:
|
||||
"""Extract poster image URL from HTML."""
|
||||
# Look for poster image
|
||||
poster = soup.select_one(".film-poster img")
|
||||
if poster:
|
||||
src = poster.get("src") or poster.get("data-src")
|
||||
if src:
|
||||
if src.startswith("//"):
|
||||
return "https:" + src
|
||||
return src
|
||||
return None
|
||||
|
||||
|
||||
def _extract_plot(soup: BeautifulSoup) -> Optional[str]:
|
||||
"""Extract plot summary from HTML."""
|
||||
# Look for plot/description section
|
||||
plot_elem = soup.select_one(".plot-full p")
|
||||
if plot_elem:
|
||||
return plot_elem.get_text(strip=True)
|
||||
|
||||
# Alternative: shorter plot
|
||||
plot_elem = soup.select_one(".plot-preview p")
|
||||
if plot_elem:
|
||||
return plot_elem.get_text(strip=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_genres(soup: BeautifulSoup) -> list[str]:
|
||||
"""Extract genres from HTML."""
|
||||
genres = []
|
||||
genre_links = soup.select(".genres a")
|
||||
for link in genre_links:
|
||||
genre = link.get_text(strip=True)
|
||||
if genre:
|
||||
genres.append(genre)
|
||||
return genres
|
||||
|
||||
|
||||
def _extract_origin_info(soup: BeautifulSoup) -> dict:
|
||||
"""Extract countries, year, duration from the origin info line.
|
||||
|
||||
CSFD separates the values with inline bullet ``<span>`` elements (no commas),
|
||||
so ``get_text(strip=True)`` would glue them together (e.g. "USA1999136 min").
|
||||
We tokenize on those inline boundaries (and on commas, for the older format)
|
||||
before extracting each field. The country segment of a co-production is
|
||||
slash-separated (e.g. "USA / Velká Británie") and is split into a list.
|
||||
"""
|
||||
info: dict = {}
|
||||
|
||||
origin_elem = soup.select_one(".origin")
|
||||
if not origin_elem:
|
||||
return info
|
||||
|
||||
# Split on inline element boundaries, then also on commas (older format).
|
||||
raw = origin_elem.get_text(separator="|", strip=True)
|
||||
tokens = [t.strip() for part in raw.split("|") for t in part.split(",")]
|
||||
tokens = [t for t in tokens if t]
|
||||
|
||||
for token in tokens:
|
||||
if "year" not in info and re.fullmatch(r"(19\d{2}|20\d{2})", token):
|
||||
info["year"] = int(token)
|
||||
continue
|
||||
if "duration" not in info:
|
||||
duration_match = re.search(r"(\d+)\s*min", token)
|
||||
if duration_match:
|
||||
info["duration"] = int(duration_match.group(1))
|
||||
continue
|
||||
# Countries: first alphabetic token that doesn't start with a digit;
|
||||
# may list several slash-separated countries for a co-production.
|
||||
if "countries" not in info and not token[0].isdigit() and re.search(r"[^\W\d_]", token):
|
||||
info["countries"] = _split_countries(token)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def search_movies(query: str, limit: int = 10, session=None) -> list[CSFDMovie]:
|
||||
"""
|
||||
Search for movies on CSFD.cz.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum number of results to return
|
||||
session: Optional ``requests.Session`` to reuse (keeps the Anubis auth
|
||||
cookie across calls so only the first lookup pays the PoW cost).
|
||||
|
||||
Returns:
|
||||
List of CSFDMovie objects with basic info (title, url, year)
|
||||
"""
|
||||
_check_dependencies()
|
||||
|
||||
search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}"
|
||||
own_session = session is None
|
||||
if own_session:
|
||||
session = requests.Session()
|
||||
try:
|
||||
response = _get_page(session, search_url)
|
||||
finally:
|
||||
if own_session:
|
||||
session.close()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
results = []
|
||||
|
||||
# Find movie results
|
||||
movie_items = soup.select(".film-title-name, .search-result-item a[href*='/film/']")
|
||||
|
||||
for item in movie_items[:limit]:
|
||||
href = item.get("href", "")
|
||||
if "/film/" not in href:
|
||||
continue
|
||||
|
||||
title = item.get_text(strip=True)
|
||||
url = urljoin(CSFD_BASE_URL, href)
|
||||
|
||||
# Try to get year from sibling/parent
|
||||
year = None
|
||||
parent = item.find_parent(class_="article-content")
|
||||
if parent:
|
||||
year_elem = parent.select_one(".info")
|
||||
if year_elem:
|
||||
year_match = re.search(r"\((\d{4})\)", year_elem.get_text())
|
||||
if year_match:
|
||||
year = int(year_match.group(1))
|
||||
|
||||
results.append(CSFDMovie(
|
||||
title=title,
|
||||
url=url,
|
||||
year=year,
|
||||
csfd_id=_extract_csfd_id(url)
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def fetch_movie_by_id(csfd_id: int) -> CSFDMovie:
|
||||
"""
|
||||
Fetch movie by CSFD ID.
|
||||
|
||||
Args:
|
||||
csfd_id: CSFD movie ID number
|
||||
|
||||
Returns:
|
||||
CSFDMovie object with full data
|
||||
"""
|
||||
url = f"{CSFD_BASE_URL}/film/{csfd_id}/"
|
||||
return fetch_movie(url)
|
||||
|
||||
|
||||
# Release-name tokens that mark the end of the actual title in a filename.
|
||||
_RELEASE_MARKERS = {
|
||||
"bluray", "blu-ray", "brrip", "bdrip", "bdremux", "remux", "webrip", "web",
|
||||
"web-dl", "webdl", "hdtv", "dvdrip", "dvd", "dvd5", "dvd9", "hdrip", "cam",
|
||||
"ts", "tc", "x264", "x265", "h264", "h265", "hevc", "avc", "xvid", "divx",
|
||||
"aac", "ac3", "eac3", "dts", "dd5", "ddp5", "truehd", "atmos", "flac",
|
||||
"10bit", "8bit", "hdr", "hdr10", "dolby", "sdr", "proper", "repack",
|
||||
"extended", "unrated", "remastered", "imax", "multi", "dual", "complete",
|
||||
"internal", "limited", "uncut",
|
||||
}
|
||||
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
|
||||
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$|^[24]k$", re.IGNORECASE)
|
||||
|
||||
|
||||
def clean_filename_to_query(filename: str) -> str:
|
||||
"""Turn a (possibly release-named) filename into a ČSFD search query.
|
||||
|
||||
Strips the path/extension, splits on common separators and keeps the words
|
||||
before the first release marker (year, resolution, codec, source, …). The
|
||||
detected year is appended back as a disambiguator. Example::
|
||||
|
||||
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
stem = Path(filename).stem
|
||||
tokens = [t for t in re.split(r"[.\s_]+", stem) if t]
|
||||
|
||||
title_words: list[str] = []
|
||||
year: Optional[str] = None
|
||||
for token in tokens:
|
||||
bare = token.strip("()[]{}")
|
||||
if _YEAR_RE.match(bare):
|
||||
year = bare
|
||||
break
|
||||
if _RESOLUTION_RE.match(bare) or bare.lower() in _RELEASE_MARKERS:
|
||||
break
|
||||
# also stop at a release group glued with a dash (e.g. "x264-GROUP")
|
||||
title_words.append(token)
|
||||
|
||||
# If nothing survived (title started with a marker), fall back to the stem.
|
||||
title = " ".join(title_words).strip() or re.sub(r"[.\s_]+", " ", stem).strip()
|
||||
return f"{title} {year}".strip() if year else title
|
||||
|
||||
|
||||
def find_csfd_url(query: str, session=None) -> Optional[str]:
|
||||
"""Return the first ČSFD film URL matching a query, or None.
|
||||
|
||||
Thin wrapper over :func:`search_movies` that takes the top result. Pass a
|
||||
shared ``session`` to reuse the Anubis auth cookie across several lookups.
|
||||
"""
|
||||
if not query.strip():
|
||||
return None
|
||||
results = search_movies(query, limit=1, session=session)
|
||||
return results[0].url if results else None
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -1,302 +0,0 @@
|
||||
from pathlib import Path
|
||||
from .tag import Tag
|
||||
|
||||
# Bump this when the csfd_cache schema changes to force re-fetch on next open.
|
||||
# v2: country (str) → countries (list[str]) for co-productions.
|
||||
CSFD_CACHE_VERSION = 2
|
||||
|
||||
|
||||
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
|
||||
self.csfd_link: 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 CSFD data — avoids re-fetching on every open
|
||||
self.csfd_cache: dict | None = None
|
||||
# full_paths of tags that came from ČSFD (vs. user-added); only these are
|
||||
# regenerated when the ČSFD link changes — user tags are preserved.
|
||||
self.csfd_tag_paths: set[str] = set()
|
||||
# free-form per-movie attributes (e.g. "collection_sort"); usable in
|
||||
# Filmotéka 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_link = None
|
||||
self.csfd_cache = None
|
||||
self.csfd_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 came from ČSFD (provenance)
|
||||
"csfd_tags": sorted(self.csfd_tag_paths),
|
||||
# date může být None
|
||||
"date": self.date,
|
||||
"title": self.title,
|
||||
"csfd_link": self.csfd_link,
|
||||
"attributes": self.attributes,
|
||||
"added": self.added,
|
||||
}
|
||||
if self.csfd_cache is not None:
|
||||
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache}
|
||||
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_link = data.get("csfd_link", None)
|
||||
# Legacy records have no provenance → treated as all-user (empty set)
|
||||
self.csfd_tag_paths = set(data.get("csfd_tags", []))
|
||||
self.attributes = dict(data.get("attributes", {}))
|
||||
self.added = data.get("added", None)
|
||||
raw_cache = data.get("csfd_cache")
|
||||
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION:
|
||||
self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"}
|
||||
else:
|
||||
self.csfd_cache = 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 Filmotéka filename template (see config tag schema).
|
||||
|
||||
Provides title / year / rating / ext / stem / filename from the movie's
|
||||
metadata (year & rating come from the ČSFD cache, year falling back to a
|
||||
``Rok`` tag). Missing values render as an empty string.
|
||||
"""
|
||||
cache = self.csfd_cache 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 set_csfd_link(self, url: str | None) -> None:
|
||||
"""Nastaví CSFD odkaz nebo None. Invaliduje cache při změně odkazu."""
|
||||
new_url = url if url else None
|
||||
if new_url != self.csfd_link:
|
||||
self.csfd_cache = None # odkaz se změnil — stará cache je neplatná
|
||||
self.csfd_link = new_url
|
||||
self.save_metadata()
|
||||
|
||||
def get_cached_movie(self):
|
||||
"""Vrátí CSFDMovie z cache, nebo None. Nevyžaduje síť."""
|
||||
if self.csfd_cache is None:
|
||||
return None
|
||||
try:
|
||||
from .csfd import CSFDMovie
|
||||
return CSFDMovie.from_dict(self.csfd_cache)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def apply_csfd_tags(self, schema: list[dict] | None = None) -> dict:
|
||||
"""Načte data z ČSFD a přegeneruje **jen ČSFD tagy** podle tag schématu.
|
||||
|
||||
``schema`` je seznam definic kategorií (viz ``DEFAULT_TAG_SCHEMA``); pro
|
||||
každý záznam s ``csfd_field`` vytvoří tagy z příslušného pole filmu
|
||||
(s volitelným ``transform``). Tagy přidané uživatelem (mimo provenance
|
||||
ČSFD) zůstávají nedotčené — proto změna ČSFD odkazu nepřepíše vlastní tagy.
|
||||
Režie/herci se stále cachují, tag se z nich tvoří jen je-li v schématu.
|
||||
|
||||
Returns:
|
||||
dict s klíči 'success', 'movie'/'error', 'tags_added'
|
||||
"""
|
||||
if not self.csfd_link:
|
||||
return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []}
|
||||
|
||||
if schema is None:
|
||||
from .config import DEFAULT_TAG_SCHEMA
|
||||
schema = DEFAULT_TAG_SCHEMA
|
||||
|
||||
try:
|
||||
from .csfd import fetch_movie
|
||||
movie = fetch_movie(self.csfd_link)
|
||||
self.csfd_cache = movie.to_dict()
|
||||
except ImportError as e:
|
||||
return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
|
||||
|
||||
from .csfd import csfd_field_values
|
||||
|
||||
# Drop previous ČSFD-sourced tags; keep user-added ones.
|
||||
self.tags = [t for t in self.tags if t.full_path not in self.csfd_tag_paths]
|
||||
self.csfd_tag_paths = set()
|
||||
|
||||
tags_added: list[str] = []
|
||||
for entry in schema:
|
||||
field = entry.get("csfd_field")
|
||||
if not field:
|
||||
continue # user-only category
|
||||
category = entry["category"]
|
||||
for value in csfd_field_values(movie, 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.csfd_tag_paths.add(tag_obj.full_path)
|
||||
|
||||
# Use the CSFD title if we don't have one yet
|
||||
if movie.title and not self.title:
|
||||
self.title = movie.title
|
||||
|
||||
self.save_metadata()
|
||||
return {"success": True, "movie": movie, "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()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,600 +0,0 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import Counter
|
||||
import shutil
|
||||
from .file import File
|
||||
from .tag_manager import TagManager
|
||||
from .pool_index import PoolIndex
|
||||
from .utils import list_files
|
||||
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
|
||||
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem
|
||||
from typing import Callable, Iterable
|
||||
from src.core.config import (
|
||||
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
||||
)
|
||||
|
||||
# Top-level folders inside the managed pool
|
||||
POOL_MOVIES = "Filmy"
|
||||
POOL_SERIES = "Seriály"
|
||||
|
||||
# Library-level settings that live in the pool index (not the global config).
|
||||
_LIBRARY_SETTINGS_DEFAULTS = {
|
||||
"tag_schema": DEFAULT_TAG_SCHEMA,
|
||||
"copyasis_folders": [POOL_SERIES],
|
||||
}
|
||||
|
||||
# Curator metadata files that must never be treated as content. The legacy
|
||||
# per-file (.!tag) and per-folder (.!ftag) sidecars are no longer written, but
|
||||
# stray ones are still skipped so they never leak into the Filmotéka.
|
||||
METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index")
|
||||
|
||||
|
||||
class FileManager:
|
||||
def __init__(self, tagmanager: TagManager):
|
||||
self.filelist: list[File] = []
|
||||
self.tagmanager = tagmanager
|
||||
self.on_files_changed = None # callback do GUI
|
||||
self.global_config = load_global_config()
|
||||
self.index: PoolIndex | None = None # unified pool metadata + settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pool (single source of truth) and Filmotéka (generated output)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def pool_dir(self) -> Path | None:
|
||||
value = self.global_config.get("pool_dir")
|
||||
return Path(value) if value else None
|
||||
|
||||
@property
|
||||
def movies_dir(self) -> Path | None:
|
||||
pool = self.pool_dir
|
||||
return pool / POOL_MOVIES if pool else None
|
||||
|
||||
@property
|
||||
def series_dir(self) -> Path | None:
|
||||
pool = self.pool_dir
|
||||
return pool / POOL_SERIES if pool else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pool index + library-level settings (stored in the index, per pool)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _open_index(self, pool: Path) -> PoolIndex:
|
||||
"""(Re)load the pool index and migrate any legacy global settings."""
|
||||
self.index = PoolIndex(pool)
|
||||
self._migrate_global_settings()
|
||||
return self.index
|
||||
|
||||
def _ensure_index(self) -> PoolIndex | None:
|
||||
"""Return the pool index, opening it lazily when a pool is configured."""
|
||||
if self.index is None and self.pool_dir:
|
||||
self._open_index(self.pool_dir)
|
||||
return self.index
|
||||
|
||||
def _migrate_global_settings(self) -> None:
|
||||
"""Move library settings from the old global config into the index once.
|
||||
|
||||
Earlier versions stored ``tag_schema`` / ``copyasis_folders`` in the
|
||||
global ``.!gtag``. They now belong to the pool; seed the index from the
|
||||
old values (if present and not already migrated) and drop them from the
|
||||
global config so it carries app data only.
|
||||
"""
|
||||
if self.index is None:
|
||||
return
|
||||
moved = False
|
||||
for key in _LIBRARY_SETTINGS_DEFAULTS:
|
||||
if key in self.global_config:
|
||||
if key not in self.index.settings:
|
||||
self.index.set_setting(key, self.global_config[key])
|
||||
del self.global_config[key]
|
||||
moved = True
|
||||
if moved:
|
||||
save_global_config(self.global_config)
|
||||
|
||||
def _get_setting(self, key: str):
|
||||
"""Read a library setting from the index (falls back to the default)."""
|
||||
default = _LIBRARY_SETTINGS_DEFAULTS[key]
|
||||
idx = self._ensure_index()
|
||||
if idx is None:
|
||||
return default
|
||||
return idx.get_setting(key, default)
|
||||
|
||||
def _set_setting(self, key: str, value) -> None:
|
||||
"""Persist a library setting into the index (needs a configured pool)."""
|
||||
idx = self._ensure_index()
|
||||
if idx is None:
|
||||
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
|
||||
idx.set_setting(key, value)
|
||||
|
||||
@property
|
||||
def copyasis_folders(self) -> list[str]:
|
||||
"""Names of pool subfolders mirrored 1:1 (copy-as-is) into the output."""
|
||||
return self._get_setting("copyasis_folders")
|
||||
|
||||
def set_copyasis_folders(self, names: list[str]) -> None:
|
||||
"""Set the copy-as-is subfolder list and persist it."""
|
||||
cleaned = [n.strip() for n in names if n.strip()]
|
||||
self._set_setting("copyasis_folders", cleaned)
|
||||
|
||||
@property
|
||||
def tag_schema(self) -> list[dict]:
|
||||
"""Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA)."""
|
||||
return self._get_setting("tag_schema")
|
||||
|
||||
def set_tag_schema(self, schema: list[dict]) -> None:
|
||||
"""Set the tag schema and persist it."""
|
||||
self._set_setting("tag_schema", schema)
|
||||
|
||||
def filmoteka_category_roots(self) -> dict[str, str]:
|
||||
"""Category → output root-folder map derived from the tag schema.
|
||||
|
||||
Categories with ``filmoteka_root`` set to None are filterable but get no
|
||||
folders in the generated tree.
|
||||
"""
|
||||
return {
|
||||
e["category"]: e["filmoteka_root"]
|
||||
for e in self.tag_schema
|
||||
if e.get("filmoteka_root") is not None
|
||||
}
|
||||
|
||||
def filmoteka_category_transforms(self) -> dict[str, str]:
|
||||
"""Category → folder transform-name map (for grouping, e.g. rating bands).
|
||||
|
||||
Tags keep their exact value; this transform only shapes the folder name
|
||||
in the generated tree. Categories without a transform are omitted.
|
||||
"""
|
||||
return {
|
||||
e["category"]: e["transform"]
|
||||
for e in self.tag_schema
|
||||
if e.get("filmoteka_root") is not None and e.get("transform")
|
||||
}
|
||||
|
||||
def filmoteka_category_filename_templates(self) -> dict[str, str]:
|
||||
"""Category → hardlink-name template map (applied inside that category)."""
|
||||
return {
|
||||
e["category"]: e["filename_template"]
|
||||
for e in self.tag_schema
|
||||
if e.get("filmoteka_root") is not None and e.get("filename_template")
|
||||
}
|
||||
|
||||
@property
|
||||
def filmoteka_dir(self) -> Path | None:
|
||||
value = self.global_config.get("filmoteka_dir")
|
||||
return Path(value) if value else None
|
||||
|
||||
def set_pool_dir(self, pool_dir: Path) -> None:
|
||||
"""Set the managed pool root and create its top-level folders."""
|
||||
pool_dir = Path(pool_dir)
|
||||
(pool_dir / POOL_MOVIES).mkdir(parents=True, exist_ok=True)
|
||||
(pool_dir / POOL_SERIES).mkdir(parents=True, exist_ok=True)
|
||||
self.global_config["pool_dir"] = str(pool_dir)
|
||||
save_global_config(self.global_config)
|
||||
|
||||
def set_filmoteka_dir(self, filmoteka_dir: Path) -> None:
|
||||
"""Set the Filmotéka output folder (generated hardlink tree)."""
|
||||
self.global_config["filmoteka_dir"] = str(Path(filmoteka_dir))
|
||||
save_global_config(self.global_config)
|
||||
|
||||
def load_pool_movies(self) -> None:
|
||||
"""Reload the movie list from pool/Filmy using the unified pool index."""
|
||||
self.filelist = []
|
||||
movies = self.movies_dir
|
||||
pool = self.pool_dir
|
||||
if not (movies and movies.is_dir() and pool):
|
||||
return
|
||||
|
||||
self._open_index(pool)
|
||||
for each in list_files(movies):
|
||||
if each.name.endswith(METADATA_SUFFIXES):
|
||||
continue
|
||||
file_obj = File(each, self.tagmanager, index=self.index)
|
||||
self.filelist.append(file_obj)
|
||||
|
||||
def check_data_consistency(self) -> dict:
|
||||
"""Compare the pool index against the actual files in pool/Filmy.
|
||||
|
||||
Detects drift such as a movie file deleted directly (metadata left behind)
|
||||
or a file dropped into the pool without importing (no metadata).
|
||||
|
||||
Returns a dict with:
|
||||
ok — False if the pool isn't set (with 'error')
|
||||
missing — index keys whose file no longer exists on disk
|
||||
untracked — pool files (pool-relative) with no index entry
|
||||
index_count / disk_count — totals for the summary
|
||||
"""
|
||||
pool = self.pool_dir
|
||||
movies = self.movies_dir
|
||||
if not (pool and movies and movies.is_dir()):
|
||||
return {"ok": False, "error": "Pool není nastaven.",
|
||||
"missing": [], "untracked": []}
|
||||
|
||||
index = self.index or PoolIndex(pool)
|
||||
index_keys = set(index.records.keys())
|
||||
|
||||
disk_keys = set()
|
||||
for f in list_files(movies):
|
||||
if f.name.endswith(METADATA_SUFFIXES):
|
||||
continue
|
||||
disk_keys.add(f.relative_to(pool).as_posix())
|
||||
|
||||
missing = sorted(k for k in index_keys if not (pool / k).is_file())
|
||||
untracked = sorted(disk_keys - index_keys)
|
||||
return {
|
||||
"ok": True,
|
||||
"missing": missing,
|
||||
"untracked": untracked,
|
||||
"index_count": len(index_keys),
|
||||
"disk_count": len(disk_keys),
|
||||
}
|
||||
|
||||
def scan_video_integrity(
|
||||
self,
|
||||
files: list[File],
|
||||
deep: bool = True,
|
||||
on_progress: Callable[[int, int, File], None] | None = None,
|
||||
should_cancel: Callable[[], bool] | None = None,
|
||||
) -> dict:
|
||||
"""Check a list of movies for corrupted/unreadable video data.
|
||||
|
||||
Runs :func:`check_video_integrity` on each file (deep = full ffmpeg
|
||||
decode, otherwise a quick ffprobe probe). ``on_progress(done, total,
|
||||
file)`` is called after each file and ``should_cancel()`` is polled
|
||||
before each — so a GUI can show progress and stop between files.
|
||||
|
||||
Returns a dict with:
|
||||
ok — False if ffmpeg/ffprobe isn't available (with 'error')
|
||||
problems — IntegrityResult list for files that failed (bad or
|
||||
unable-to-check), in scan order
|
||||
checked — how many files were actually processed
|
||||
cancelled — True if the scan stopped early
|
||||
"""
|
||||
if not ffmpeg_available(deep):
|
||||
tool = "ffmpeg" if deep else "ffprobe"
|
||||
return {"ok": False, "error": f"{tool} není k dispozici (nainstaluj FFmpeg).",
|
||||
"problems": [], "checked": 0, "cancelled": False}
|
||||
|
||||
problems: list[IntegrityResult] = []
|
||||
total = len(files)
|
||||
checked = 0
|
||||
cancelled = False
|
||||
for i, f in enumerate(files, 1):
|
||||
if should_cancel is not None and should_cancel():
|
||||
cancelled = True
|
||||
break
|
||||
result = check_video_integrity(f.file_path, deep=deep)
|
||||
checked += 1
|
||||
if not result.ok:
|
||||
problems.append(result)
|
||||
if on_progress is not None:
|
||||
on_progress(i, total, f)
|
||||
return {"ok": True, "problems": problems, "checked": checked,
|
||||
"cancelled": cancelled}
|
||||
|
||||
def statistics(self) -> dict:
|
||||
"""Aggregate stats over the loaded pool movies for the stats view.
|
||||
|
||||
Returns a dict with totals and, per tag category, the tag counts sorted
|
||||
by frequency (descending).
|
||||
"""
|
||||
files = self.filelist
|
||||
total_size = 0
|
||||
with_csfd = 0
|
||||
untagged = 0
|
||||
ratings: list[int] = []
|
||||
categories: dict[str, Counter] = {}
|
||||
|
||||
for f in files:
|
||||
try:
|
||||
total_size += f.file_path.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
if f.csfd_link:
|
||||
with_csfd += 1
|
||||
if not f.tags:
|
||||
untagged += 1
|
||||
for tag in f.tags:
|
||||
categories.setdefault(tag.category, Counter())[tag.name] += 1
|
||||
rating = (f.csfd_cache or {}).get("rating")
|
||||
if isinstance(rating, (int, float)):
|
||||
ratings.append(rating)
|
||||
|
||||
return {
|
||||
"count": len(files),
|
||||
"total_size": total_size,
|
||||
"with_csfd": with_csfd,
|
||||
"without_csfd": len(files) - with_csfd,
|
||||
"untagged": untagged,
|
||||
"avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None,
|
||||
"categories": {
|
||||
cat: counter.most_common() for cat, counter in categories.items()
|
||||
},
|
||||
}
|
||||
|
||||
def pooled_with_stem(self, title: str) -> list[File]:
|
||||
"""Pooled movies whose filename stem matches ``title`` (case-insensitive)."""
|
||||
stem = title.strip().lower()
|
||||
return [f for f in self.filelist if Path(f.filename).stem.lower() == stem]
|
||||
|
||||
def _evict(self, file_obj: File) -> None:
|
||||
"""Delete a pooled movie: its metadata, the file, and the list entry."""
|
||||
file_obj.delete_metadata()
|
||||
if file_obj.file_path.exists():
|
||||
file_obj.file_path.unlink()
|
||||
if file_obj in self.filelist:
|
||||
self.filelist.remove(file_obj)
|
||||
|
||||
def import_movie(
|
||||
self, source: Path, title: str, csfd_link: str | None = None,
|
||||
move: bool = False, on_conflict: str = "suffix",
|
||||
year: int | str | None = None,
|
||||
) -> File | None:
|
||||
"""Bring a video file into pool/Filmy and index its metadata.
|
||||
|
||||
The pooled filename follows the naming convention ``Title (YYYY).ext``
|
||||
when a ``year`` is given (validated to four digits); without a year it
|
||||
falls back to ``Title.ext``. The GUI import always supplies a year, so a
|
||||
library built through the app is uniformly ``Title (YYYY).ext``.
|
||||
|
||||
By default the original is **copied** (non-destructive). With ``move=True``
|
||||
the source file is moved into the pool instead, leaving nothing behind.
|
||||
|
||||
``on_conflict`` decides what happens when a pooled movie of the same name
|
||||
already exists:
|
||||
|
||||
- ``"suffix"`` (default): keep both, the new file gets a ``_N`` suffix.
|
||||
- ``"replace"``: evict the existing same-named movie(s) (file + metadata)
|
||||
and import the new one under the plain name.
|
||||
- ``"skip"``: do not import; return ``None``.
|
||||
|
||||
Raises:
|
||||
ValueError: a ``year`` was given but is not a four-digit number.
|
||||
"""
|
||||
movies = self.movies_dir
|
||||
pool = self.pool_dir
|
||||
if movies is None or pool is None:
|
||||
raise RuntimeError("Pool není nastaven.")
|
||||
movies.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self.index is None:
|
||||
self._open_index(pool)
|
||||
|
||||
source = Path(source)
|
||||
safe_title = title.strip() or source.stem
|
||||
has_year = year is not None and str(year).strip() != ""
|
||||
stem = canonical_pool_stem(safe_title, year) if has_year else safe_title
|
||||
target = movies / f"{stem}{source.suffix}"
|
||||
|
||||
existing = self.pooled_with_stem(stem)
|
||||
conflict = bool(existing) or target.exists()
|
||||
|
||||
if conflict and on_conflict == "skip":
|
||||
return None
|
||||
if conflict and on_conflict == "replace":
|
||||
for f in existing:
|
||||
self._evict(f)
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
else:
|
||||
# "suffix": never clobber an existing exact filename
|
||||
counter = 1
|
||||
while target.exists():
|
||||
target = movies / f"{stem}_{counter}{source.suffix}"
|
||||
counter += 1
|
||||
|
||||
if move:
|
||||
shutil.move(str(source), str(target))
|
||||
else:
|
||||
shutil.copy2(source, target)
|
||||
|
||||
file_obj = File(target, self.tagmanager, index=self.index)
|
||||
file_obj.title = safe_title
|
||||
file_obj.csfd_link = csfd_link or None
|
||||
file_obj.added = datetime.now().isoformat(timespec="seconds")
|
||||
file_obj.save_metadata()
|
||||
|
||||
self.filelist.append(file_obj)
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
return file_obj
|
||||
|
||||
def rename_movie(self, file_obj: File, new_title: str) -> File:
|
||||
"""Rename a pooled movie's file to ``<new_title>.<ext>`` and reindex it.
|
||||
|
||||
Renames the physical file in pool/Filmy (keeping its extension), moves
|
||||
the metadata to the new key, and syncs ``title``/``filename``. The
|
||||
extension is preserved; ``new_title`` is the bare name without it.
|
||||
|
||||
Raises:
|
||||
ValueError: empty name or a name containing a path separator.
|
||||
FileExistsError: another pooled file already uses that name.
|
||||
"""
|
||||
new_title = new_title.strip()
|
||||
if not new_title:
|
||||
raise ValueError("Název nesmí být prázdný.")
|
||||
if "/" in new_title or "\\" in new_title:
|
||||
raise ValueError("Název nesmí obsahovat lomítka.")
|
||||
|
||||
old_path = file_obj.file_path
|
||||
new_path = old_path.with_name(f"{new_title}{old_path.suffix}")
|
||||
if new_path == old_path:
|
||||
return file_obj # no change
|
||||
|
||||
if new_path.exists():
|
||||
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
|
||||
|
||||
old_path.rename(new_path)
|
||||
file_obj.relocate(new_path)
|
||||
file_obj.title = new_title
|
||||
file_obj.save_metadata()
|
||||
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
return file_obj
|
||||
|
||||
def _canonical_name_for(self, file_obj: File) -> str | None:
|
||||
"""The ``Title (YYYY)`` stem this movie should have, or ``None``.
|
||||
|
||||
Uses the clean title and the year from the movie's metadata
|
||||
(``name_context``: ČSFD cache → ``Rok`` tag → the current filename). A
|
||||
``(YYYY)`` that leaked into the title is stripped so it isn't doubled.
|
||||
Returns ``None`` when no four-digit year is known.
|
||||
"""
|
||||
ctx = file_obj.name_context()
|
||||
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
|
||||
year = str(ctx.get("year") or "").strip()
|
||||
|
||||
parsed = parse_pool_stem(title)
|
||||
if parsed is not None:
|
||||
title = parsed[0]
|
||||
year = year or str(parsed[1])
|
||||
|
||||
title = title.replace("/", "-").replace("\\", "-").strip()
|
||||
if not (title and is_valid_year(year)):
|
||||
return None
|
||||
return canonical_pool_stem(title, year)
|
||||
|
||||
def rename_to_canonical(self, file_obj: File) -> File | None:
|
||||
"""Rename a pooled movie to ``Title (YYYY).ext`` from its metadata.
|
||||
|
||||
The year comes from the movie's ČSFD metadata (falling back to a ``Rok``
|
||||
tag or the existing filename). ``file_obj.title`` keeps the clean title
|
||||
(without the year). Does not fire ``on_files_changed`` — the caller
|
||||
refreshes once after a batch.
|
||||
|
||||
Returns:
|
||||
The file when renamed (or already canonical); ``None`` when no
|
||||
four-digit year is known, so no canonical name can be built.
|
||||
|
||||
Raises:
|
||||
FileExistsError: another pooled file already uses that name.
|
||||
"""
|
||||
new_stem = self._canonical_name_for(file_obj)
|
||||
if new_stem is None:
|
||||
return None
|
||||
|
||||
old_path = file_obj.file_path
|
||||
new_path = old_path.with_name(f"{new_stem}{old_path.suffix}")
|
||||
if new_path == old_path:
|
||||
return file_obj # already canonical
|
||||
if new_path.exists():
|
||||
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
|
||||
|
||||
parsed = parse_pool_stem(new_stem)
|
||||
old_path.rename(new_path)
|
||||
file_obj.relocate(new_path)
|
||||
if parsed is not None:
|
||||
file_obj.title = parsed[0]
|
||||
file_obj.save_metadata()
|
||||
return file_obj
|
||||
|
||||
def rename_all_to_canonical(
|
||||
self,
|
||||
files: list[File],
|
||||
on_progress: Callable[[int, int, File], None] | None = None,
|
||||
should_cancel: Callable[[], bool] | None = None,
|
||||
) -> dict:
|
||||
"""Rename each movie to ``Title (YYYY).ext`` from its metadata.
|
||||
|
||||
Skips files already canonical and those without a known year. Reports
|
||||
conflicts (target name taken) without aborting the rest.
|
||||
|
||||
Returns a dict with:
|
||||
renamed — (old_name, new_name) pairs actually renamed
|
||||
skipped_no_year — filenames with no four-digit year available
|
||||
conflicts — (old_name, message) where the target name was taken
|
||||
unchanged — count already in canonical form
|
||||
cancelled — True if stopped early
|
||||
"""
|
||||
renamed: list[tuple[str, str]] = []
|
||||
skipped_no_year: list[str] = []
|
||||
conflicts: list[tuple[str, str]] = []
|
||||
unchanged = 0
|
||||
cancelled = False
|
||||
total = len(files)
|
||||
for i, f in enumerate(files, 1):
|
||||
if should_cancel is not None and should_cancel():
|
||||
cancelled = True
|
||||
break
|
||||
old_name = f.filename
|
||||
try:
|
||||
result = self.rename_to_canonical(f)
|
||||
except FileExistsError as exc:
|
||||
conflicts.append((old_name, str(exc)))
|
||||
else:
|
||||
if result is None:
|
||||
skipped_no_year.append(old_name)
|
||||
elif f.filename != old_name:
|
||||
renamed.append((old_name, f.filename))
|
||||
else:
|
||||
unchanged += 1
|
||||
if on_progress is not None:
|
||||
on_progress(i, total, f)
|
||||
|
||||
if renamed and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
return {"renamed": renamed, "skipped_no_year": skipped_no_year,
|
||||
"conflicts": conflicts, "unchanged": unchanged,
|
||||
"cancelled": cancelled}
|
||||
|
||||
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
||||
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
||||
for f in files_objs:
|
||||
if isinstance(tag, str):
|
||||
if "/" in tag:
|
||||
category, name = tag.split("/", 1)
|
||||
tag_obj = self.tagmanager.add_tag(category, name)
|
||||
else:
|
||||
tag_obj = self.tagmanager.add_tag("default", tag)
|
||||
else:
|
||||
tag_obj = tag
|
||||
if tag_obj not in f.tags:
|
||||
f.tags.append(tag_obj)
|
||||
f.save_metadata()
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
def remove_tag_from_file_objects(self, files_objs: list[File], tag):
|
||||
"""Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů."""
|
||||
for f in files_objs:
|
||||
if isinstance(tag, str):
|
||||
if "/" in tag:
|
||||
category, name = tag.split("/", 1)
|
||||
from .tag import Tag as TagClass
|
||||
tag_obj = TagClass(category, name)
|
||||
else:
|
||||
from .tag import Tag as TagClass
|
||||
tag_obj = TagClass("default", tag)
|
||||
else:
|
||||
tag_obj = tag
|
||||
if tag_obj in f.tags:
|
||||
f.tags.remove(tag_obj)
|
||||
f.save_metadata()
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
def filter_files_by_tags(self, tags: Iterable):
|
||||
"""
|
||||
Vrátí jen soubory, které obsahují všechny zadané tagy.
|
||||
'tags' může být iterace Tag objektů nebo stringů 'category/name'.
|
||||
"""
|
||||
tags_list = list(tags) if tags is not None else []
|
||||
if not tags_list:
|
||||
return self.filelist
|
||||
|
||||
target_full_paths = set()
|
||||
from .tag import Tag as TagClass
|
||||
for t in tags_list:
|
||||
if isinstance(t, TagClass):
|
||||
target_full_paths.add(t.full_path)
|
||||
elif isinstance(t, str):
|
||||
target_full_paths.add(t)
|
||||
else:
|
||||
continue
|
||||
|
||||
filtered = []
|
||||
for f in self.filelist:
|
||||
file_tags = {t.full_path for t in f.tags}
|
||||
if all(tag in file_tags for tag in target_full_paths):
|
||||
filtered.append(f)
|
||||
return filtered
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 .csfd 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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 ""
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Pool file naming convention.
|
||||
|
||||
Every movie in the pool is named ``Title (YYYY).ext`` — a title followed by a
|
||||
four-digit year in parentheses. The year lets two films of the same name live
|
||||
side by side and makes the filename self-describing (the year can be recovered
|
||||
without any metadata). These helpers build, validate and parse that stem (the
|
||||
filename without its extension).
|
||||
"""
|
||||
import re
|
||||
|
||||
# A trailing four-digit year in parentheses. The title is non-greedy so a title
|
||||
# that itself contains parentheses keeps them: "Já, robot (2004)" but also
|
||||
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019.
|
||||
_POOL_STEM_RE = re.compile(r"^(?P<title>.+?) \((?P<year>\d{4})\)$")
|
||||
_FOUR_DIGITS = re.compile(r"\d{4}")
|
||||
|
||||
|
||||
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) -> str:
|
||||
"""Build the canonical pool stem ``Title (YYYY)``.
|
||||
|
||||
Raises:
|
||||
ValueError: the title is empty or the year is not four digits.
|
||||
"""
|
||||
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}“.")
|
||||
return f"{title} ({year_s})"
|
||||
|
||||
|
||||
def parse_pool_stem(stem: str) -> tuple[str, int] | None:
|
||||
"""Split a canonical stem into ``(title, year)``; ``None`` if it isn't one."""
|
||||
match = _POOL_STEM_RE.match((stem or "").strip())
|
||||
if not match:
|
||||
return None
|
||||
return match.group("title").strip(), int(match.group("year"))
|
||||
|
||||
|
||||
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
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms)?;
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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()]
|
||||
@@ -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;
|
||||
@@ -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}"))
|
||||
}
|
||||
|
Before Width: | Height: | Size: 596 B |
|
Before Width: | Height: | Size: 892 B |
|
Before Width: | Height: | Size: 618 B |
|
Before Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 710 B |
|
Before Width: | Height: | Size: 716 B |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! The modal dialogs. Each one owns its form state, talks to the
|
||||
//! `FileManager` through [`Env`] and reports back to the main window with
|
||||
//! [`Event`]s.
|
||||
|
||||
mod assign_tags;
|
||||
mod duplicates;
|
||||
mod import_episodes;
|
||||
mod import_movies;
|
||||
mod libraries;
|
||||
mod migrate;
|
||||
mod movie_edit;
|
||||
mod series;
|
||||
mod stats;
|
||||
mod tag_schema;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::file_manager::{FileManager, Stats};
|
||||
use crate::core::migrate::MatchProposal;
|
||||
use crate::core::series::Series;
|
||||
|
||||
use super::common::Message;
|
||||
|
||||
/// What a dialog can touch while it is open.
|
||||
pub struct Env<'a> {
|
||||
pub fm: &'a mut FileManager,
|
||||
pub messages: &'a mut Vec<Message>,
|
||||
pub events: &'a mut Vec<Event>,
|
||||
}
|
||||
|
||||
impl Env<'_> {
|
||||
pub fn info(&mut self, title: &str, text: impl Into<String>) {
|
||||
self.messages.push(Message::info(title, text));
|
||||
}
|
||||
|
||||
pub fn warn(&mut self, title: &str, text: impl Into<String>) {
|
||||
self.messages.push(Message::warning(title, text));
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests from a dialog to the main window.
|
||||
pub enum Event {
|
||||
Refresh,
|
||||
ReloadSeries,
|
||||
Status(String),
|
||||
/// Select these movies in the table.
|
||||
Select(Vec<PathBuf>),
|
||||
/// The library list changed (refresh + offer orphan cleanup).
|
||||
LibrariesChanged,
|
||||
/// Replace the current dialog with another one.
|
||||
OpenDialog(Box<Dialog>),
|
||||
/// Run work in the background (the dialog is closed by then).
|
||||
Task {
|
||||
title: String,
|
||||
work: crate::ui::task::Work,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DialogState {
|
||||
Open,
|
||||
Closed,
|
||||
}
|
||||
|
||||
pub enum Dialog {
|
||||
ImportMovies(Box<import_movies::ImportMoviesDialog>),
|
||||
ImportFlow(Box<import_movies::ImportFlow>),
|
||||
ImportEpisodes(Box<import_episodes::ImportEpisodesDialog>),
|
||||
SeriesOverview(Box<series::SeriesOverviewDialog>),
|
||||
SeriesEdit(Box<series::SeriesEditDialog>),
|
||||
MovieEdit(Box<movie_edit::MovieEditDialog>),
|
||||
Duplicates(Box<duplicates::DuplicatesDialog>),
|
||||
Libraries(Box<libraries::LibraryOrganizerDialog>),
|
||||
MinidlnaLibraries(Box<libraries::MinidlnaLibrariesDialog>),
|
||||
Migrate(Box<migrate::MigrateDialog>),
|
||||
AssignTags(Box<assign_tags::AssignTagsDialog>),
|
||||
TagSchema(Box<tag_schema::TagSchemaDialog>),
|
||||
Stats(Box<stats::StatsDialog>),
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
pub fn import_movies(
|
||||
files: Vec<PathBuf>,
|
||||
existing: HashSet<String>,
|
||||
libraries: Vec<String>,
|
||||
) -> Self {
|
||||
Self::ImportMovies(Box::new(import_movies::ImportMoviesDialog::new(
|
||||
files, existing, libraries,
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn import_episodes(libraries: Vec<String>) -> Self {
|
||||
Self::ImportEpisodes(Box::new(import_episodes::ImportEpisodesDialog::new(
|
||||
libraries,
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn series_overview(fm: &FileManager) -> Self {
|
||||
Self::SeriesOverview(Box::new(series::SeriesOverviewDialog::new(fm)))
|
||||
}
|
||||
|
||||
pub fn series_edit(fm: &FileManager, ser: Series) -> Self {
|
||||
Self::SeriesEdit(Box::new(series::SeriesEditDialog::new(fm, ser)))
|
||||
}
|
||||
|
||||
pub fn movie_edit(fm: &FileManager, path: &std::path::Path) -> Self {
|
||||
Self::MovieEdit(Box::new(movie_edit::MovieEditDialog::new(fm, path)))
|
||||
}
|
||||
|
||||
pub fn duplicates(fm: &FileManager) -> Self {
|
||||
Self::Duplicates(Box::new(duplicates::DuplicatesDialog::new(fm)))
|
||||
}
|
||||
|
||||
pub fn libraries(fm: &FileManager) -> Self {
|
||||
Self::Libraries(Box::new(libraries::LibraryOrganizerDialog::new(fm)))
|
||||
}
|
||||
|
||||
pub fn minidlna_libraries(fm: &FileManager) -> Self {
|
||||
Self::MinidlnaLibraries(Box::new(libraries::MinidlnaLibrariesDialog::new(fm)))
|
||||
}
|
||||
|
||||
pub fn migrate(proposals: Vec<MatchProposal>) -> Self {
|
||||
Self::Migrate(Box::new(migrate::MigrateDialog::new(proposals)))
|
||||
}
|
||||
|
||||
pub fn assign_tags(fm: &FileManager, files: Vec<PathBuf>) -> Self {
|
||||
Self::AssignTags(Box::new(assign_tags::AssignTagsDialog::new(fm, files)))
|
||||
}
|
||||
|
||||
pub fn tag_schema(fm: &FileManager) -> Self {
|
||||
Self::TagSchema(Box::new(tag_schema::TagSchemaDialog::new(fm)))
|
||||
}
|
||||
|
||||
pub fn stats(stats: Stats) -> Self {
|
||||
Self::Stats(Box::new(stats::StatsDialog::new(stats)))
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
|
||||
match self {
|
||||
Self::ImportMovies(d) => d.show(ctx, env),
|
||||
Self::ImportFlow(d) => d.show(ctx, env),
|
||||
Self::ImportEpisodes(d) => d.show(ctx, env),
|
||||
Self::SeriesOverview(d) => d.show(ctx, env),
|
||||
Self::SeriesEdit(d) => d.show(ctx, env),
|
||||
Self::MovieEdit(d) => d.show(ctx, env),
|
||||
Self::Duplicates(d) => d.show(ctx, env),
|
||||
Self::Libraries(d) => d.show(ctx, env),
|
||||
Self::MinidlnaLibraries(d) => d.show(ctx, env),
|
||||
Self::Migrate(d) => d.show(ctx, env),
|
||||
Self::AssignTags(d) => d.show(ctx, env),
|
||||
Self::TagSchema(d) => d.show(ctx, env),
|
||||
Self::Stats(d) => d.show(ctx, env),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A standard dialog frame: a modal with a heading and a scrollable body
|
||||
/// limited to most of the screen.
|
||||
pub fn modal<R>(
|
||||
ctx: &egui::Context,
|
||||
id: &str,
|
||||
title: &str,
|
||||
min_width: f32,
|
||||
body: impl FnOnce(&mut egui::Ui) -> R,
|
||||
) -> (R, bool) {
|
||||
let screen = ctx.content_rect();
|
||||
let resp = egui::Modal::new(egui::Id::unique(id)).show(ctx, |ui| {
|
||||
ui.set_width(min_width.min(screen.width() - 40.0));
|
||||
ui.set_max_height(screen.height() - 60.0);
|
||||
ui.heading(title);
|
||||
ui.add_space(6.0);
|
||||
body(ui)
|
||||
});
|
||||
let close = resp.should_close();
|
||||
(resp.inner, close)
|
||||
}
|
||||
|
||||
/// The OK / Cancel row at the bottom of a dialog: `Some(true)` = OK.
|
||||
pub fn ok_cancel_row(ui: &mut egui::Ui) -> Option<bool> {
|
||||
let mut out = None;
|
||||
ui.add_space(6.0);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("OK").clicked() {
|
||||
out = Some(true);
|
||||
}
|
||||
if ui.button("Zrušit").clicked() {
|
||||
out = Some(false);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
//! One dialog for every editable parameter of a movie: identity, date, ČSFD
|
||||
//! link, 3D formát, subtitles, and its media (versions or parts).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{DialogState, Env, Event, modal, ok_cancel_row};
|
||||
use crate::core::file_manager::{FileManager, GroupMode, MovieEdits, label_of};
|
||||
use crate::core::movie::{DEFAULT_VERSION_LABEL, normalize_part_label};
|
||||
use crate::core::naming::{THREE_D_FORMATS, parse_3d_format, parse_tmdb_id};
|
||||
use crate::core::paths;
|
||||
use crate::core::subtitles::SubtitleFile;
|
||||
use crate::core::tmdb::{self, TmdbResult};
|
||||
use crate::ui::common::{Answer, Job, Prompt, busy_button, digit_edit};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Purpose {
|
||||
SubLanguage(PathBuf),
|
||||
SubForced(PathBuf, Option<String>),
|
||||
RemoveSub(PathBuf),
|
||||
AddVersion(PathBuf),
|
||||
RenameVersion(PathBuf),
|
||||
RemoveMedia(PathBuf),
|
||||
ToParts,
|
||||
/// Collecting the popisek of extra file `n` when switching to versions.
|
||||
ToVersions(usize, Vec<String>),
|
||||
}
|
||||
|
||||
pub struct MovieEditDialog {
|
||||
path: PathBuf,
|
||||
movie_key: Option<String>,
|
||||
title: String,
|
||||
year: String,
|
||||
tmdb: String,
|
||||
libraries: Vec<String>,
|
||||
library: String,
|
||||
date: String,
|
||||
csfd: String,
|
||||
format_3d: Option<String>,
|
||||
subtitles: Vec<SubtitleFile>,
|
||||
selected_sub: Option<usize>,
|
||||
media: Vec<PathBuf>,
|
||||
selected_media: Option<usize>,
|
||||
/// The folder mode chosen in the radio (for a flat film: what the next
|
||||
/// added medium becomes).
|
||||
mode_parts: bool,
|
||||
prompt: Option<Prompt<Purpose>>,
|
||||
job: Option<Job<Result<Option<TmdbResult>, String>>>,
|
||||
}
|
||||
|
||||
impl MovieEditDialog {
|
||||
pub fn new(fm: &FileManager, path: &Path) -> Self {
|
||||
let file = fm
|
||||
.file(path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| crate::core::file::File::bare(path));
|
||||
let ctx = file.name_context();
|
||||
let mut d = Self {
|
||||
path: path.to_path_buf(),
|
||||
movie_key: file.movie_key.clone(),
|
||||
title: file
|
||||
.title
|
||||
.clone()
|
||||
.filter(|t| !t.is_empty())
|
||||
.unwrap_or_else(|| paths::stem(path)),
|
||||
year: ctx.get("year").cloned().unwrap_or_default(),
|
||||
tmdb: String::new(),
|
||||
libraries: fm.libraries_accepting_movies(),
|
||||
library: fm.library_of(path).unwrap_or_default(),
|
||||
date: file.date.clone().unwrap_or_default(),
|
||||
csfd: file.csfd_url.clone().unwrap_or_default(),
|
||||
format_3d: None,
|
||||
subtitles: Vec::new(),
|
||||
selected_sub: None,
|
||||
media: Vec::new(),
|
||||
selected_media: None,
|
||||
mode_parts: false,
|
||||
prompt: None,
|
||||
job: None,
|
||||
};
|
||||
d.refresh_identity(fm);
|
||||
d.reload(fm, None);
|
||||
d
|
||||
}
|
||||
|
||||
/// After a media change the edited file may have moved (promotion to a
|
||||
/// folder, demotion, relabel) — find it again through its movie record.
|
||||
fn resync_path(&mut self, fm: &FileManager) {
|
||||
if self.path.exists() {
|
||||
return;
|
||||
}
|
||||
let Some(key) = &self.movie_key else { return };
|
||||
let siblings: Vec<PathBuf> = fm
|
||||
.filelist
|
||||
.iter()
|
||||
.filter(|f| f.movie_key.as_ref() == Some(key))
|
||||
.map(|f| f.file_path.clone())
|
||||
.collect();
|
||||
if !siblings.is_empty() {
|
||||
self.path = fm.primary_version(&siblings);
|
||||
}
|
||||
}
|
||||
|
||||
fn grouped(&self, fm: &FileManager) -> bool {
|
||||
fm.is_movie_group_member(&self.path)
|
||||
}
|
||||
|
||||
fn identity_stem(&self, fm: &FileManager) -> String {
|
||||
if self.grouped(fm) {
|
||||
self.path.parent().map(paths::name).unwrap_or_default()
|
||||
} else {
|
||||
paths::stem(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_identity(&mut self, fm: &FileManager) {
|
||||
let stem = self.identity_stem(fm);
|
||||
self.tmdb = parse_tmdb_id(&stem)
|
||||
.map(|i| i.to_string())
|
||||
.unwrap_or_default();
|
||||
self.format_3d = if self.grouped(fm) {
|
||||
None
|
||||
} else {
|
||||
parse_3d_format(&stem)
|
||||
};
|
||||
}
|
||||
|
||||
fn reload(&mut self, fm: &FileManager, select: Option<usize>) {
|
||||
self.resync_path(fm);
|
||||
self.subtitles = fm.list_subtitles(&self.path);
|
||||
self.selected_sub = None;
|
||||
self.media = fm.ordered_media(&self.path);
|
||||
let mode = fm.movie_group_mode(&self.path);
|
||||
if mode != GroupMode::Flat {
|
||||
self.mode_parts = mode == GroupMode::Parts;
|
||||
}
|
||||
self.selected_media = select.filter(|i| *i < self.media.len());
|
||||
}
|
||||
|
||||
fn after_change(&mut self, fm: &FileManager, select: Option<usize>) {
|
||||
self.reload(fm, select);
|
||||
self.refresh_identity(fm);
|
||||
}
|
||||
|
||||
fn media_row(&self, fm: &FileManager, p: &Path) -> (String, String) {
|
||||
let grouped = fm.movie_group_mode(&self.path) != GroupMode::Flat;
|
||||
let label = if grouped { label_of(p) } else { String::new() };
|
||||
match normalize_part_label(&label) {
|
||||
Some(n) => (format!("díl {n}"), String::new()),
|
||||
None if !label.is_empty() && label != DEFAULT_VERSION_LABEL => ("verze".into(), label),
|
||||
None => (DEFAULT_VERSION_LABEL.into(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
let n: i64 = id.parse().map_err(|_| "Neplatné ID".to_string())?;
|
||||
tmdb::lookup_movie(n).map_err(|e| e.to_string())
|
||||
} else {
|
||||
tmdb::search_movie(&title, Some(year.as_str()).filter(|y| !y.is_empty()), 1)
|
||||
.map(|h| h.into_iter().next())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn on_mode_clicked(&mut self, env: &mut Env, wanted_parts: bool) {
|
||||
let current = env.fm.movie_group_mode(&self.path);
|
||||
self.mode_parts = wanted_parts;
|
||||
if current == GroupMode::Flat || (current == GroupMode::Parts) == wanted_parts {
|
||||
return;
|
||||
}
|
||||
if wanted_parts {
|
||||
let order: Vec<String> = self
|
||||
.media
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, p)| format!(" díl {}: {}", n + 1, paths::name(p)))
|
||||
.collect();
|
||||
self.prompt = Some(Prompt::confirm(
|
||||
"Přepnout na díly",
|
||||
format!(
|
||||
"Soubory se přejmenují na díly v tomto pořadí (pak jde pořadí změnit šipkami):\n{}",
|
||||
order.join("\n")
|
||||
),
|
||||
Purpose::ToParts,
|
||||
));
|
||||
} else {
|
||||
self.ask_version_label_for_switch(0, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
fn ask_version_label_for_switch(&mut self, n: usize, labels: Vec<String>) {
|
||||
let Some(p) = self.media.get(n + 1) else {
|
||||
return;
|
||||
};
|
||||
self.prompt = Some(Prompt::version_label(
|
||||
format!(
|
||||
"Popisek verze pro „{}“ (první soubor bude výchozí verze).",
|
||||
paths::name(p)
|
||||
),
|
||||
"",
|
||||
Purpose::ToVersions(n, labels),
|
||||
));
|
||||
}
|
||||
|
||||
fn handle_prompt(&mut self, purpose: Purpose, answer: Answer, env: &mut Env) {
|
||||
let fm_err = |env: &mut Env, title: &str, e: crate::core::error::CoreError| {
|
||||
env.warn(title, e.to_string())
|
||||
};
|
||||
match (purpose, answer) {
|
||||
(Purpose::SubLanguage(src), Answer::Text(lang)) => {
|
||||
let lang = lang.trim().to_lowercase();
|
||||
if !lang.is_empty()
|
||||
&& !(lang.len() >= 2
|
||||
&& lang.len() <= 3
|
||||
&& lang.chars().all(|c| c.is_ascii_lowercase()))
|
||||
{
|
||||
env.warn("Titulky", "Kód jazyka zadej jako 2–3 písmena (např. cs).");
|
||||
return;
|
||||
}
|
||||
self.prompt = Some(Prompt::confirm(
|
||||
"Titulky",
|
||||
"Jsou to vynucené (forced) titulky?",
|
||||
Purpose::SubForced(src, Some(lang).filter(|l| !l.is_empty())),
|
||||
));
|
||||
}
|
||||
(Purpose::SubForced(src, lang), answer) => {
|
||||
let flag = (answer == Answer::Yes).then_some("forced");
|
||||
if let Err(e) =
|
||||
env.fm
|
||||
.attach_subtitle(&self.path, &src, lang.as_deref(), flag, false)
|
||||
{
|
||||
env.warn("Titulky", format!("Nelze přidat titulky: {e}"));
|
||||
}
|
||||
self.reload(env.fm, self.selected_media);
|
||||
}
|
||||
(Purpose::RemoveSub(p), Answer::Yes) => {
|
||||
if let Err(e) = env.fm.remove_subtitle(&p) {
|
||||
fm_err(env, "Titulky", e);
|
||||
}
|
||||
self.reload(env.fm, self.selected_media);
|
||||
}
|
||||
(Purpose::AddVersion(src), Answer::Text(label)) => {
|
||||
match env
|
||||
.fm
|
||||
.add_movie_version(&self.path, &src, &label, false, None)
|
||||
{
|
||||
Ok(added) => {
|
||||
self.after_change(env.fm, None);
|
||||
self.selected_media = self.media.iter().position(|p| p == &added);
|
||||
}
|
||||
Err(e) => env.warn("Média", format!("Nelze přidat: {e}")),
|
||||
}
|
||||
env.events.push(Event::Refresh);
|
||||
}
|
||||
(Purpose::RenameVersion(p), Answer::Text(label)) => {
|
||||
match env.fm.rename_movie_version(&p, &label) {
|
||||
Ok(new) => {
|
||||
if self.path == p {
|
||||
self.path = new.clone();
|
||||
}
|
||||
self.after_change(env.fm, None);
|
||||
self.selected_media = self.media.iter().position(|m| m == &new);
|
||||
}
|
||||
Err(e) => env.warn("Verze", format!("Nelze přejmenovat verzi: {e}")),
|
||||
}
|
||||
env.events.push(Event::Refresh);
|
||||
}
|
||||
(Purpose::RemoveMedia(p), Answer::Yes) => {
|
||||
let sibling = self.media.iter().find(|m| **m != p).cloned();
|
||||
if let Err(e) = env.fm.remove_movie_version(&p) {
|
||||
fm_err(env, "Média", e);
|
||||
return;
|
||||
}
|
||||
if self.path == p
|
||||
&& let Some(s) = sibling
|
||||
{
|
||||
self.path = s;
|
||||
}
|
||||
self.after_change(env.fm, None);
|
||||
env.events.push(Event::Refresh);
|
||||
}
|
||||
(Purpose::ToParts, Answer::Yes) => {
|
||||
if let Err(e) = env
|
||||
.fm
|
||||
.set_movie_group_mode(&self.path, GroupMode::Parts, &[])
|
||||
{
|
||||
fm_err(env, "Režim složky", e);
|
||||
}
|
||||
self.after_change(env.fm, None);
|
||||
env.events.push(Event::Refresh);
|
||||
}
|
||||
(Purpose::ToVersions(n, mut labels), Answer::Text(label)) if !label.is_empty() => {
|
||||
labels.push(label);
|
||||
if n + 2 < self.media.len() {
|
||||
self.ask_version_label_for_switch(n + 1, labels);
|
||||
} else {
|
||||
if let Err(e) =
|
||||
env.fm
|
||||
.set_movie_group_mode(&self.path, GroupMode::Versions, &labels)
|
||||
{
|
||||
fm_err(env, "Režim složky", e);
|
||||
}
|
||||
self.after_change(env.fm, None);
|
||||
env.events.push(Event::Refresh);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// cancelled: the radio goes back to the folder's actual mode
|
||||
self.reload(env.fm, self.selected_media);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(&mut self, env: &mut Env) -> bool {
|
||||
let edits = MovieEdits {
|
||||
title: self.title.trim().to_string(),
|
||||
year: Some(self.year.trim().to_string()).filter(|y| !y.is_empty()),
|
||||
tmdb_id: Some(self.tmdb.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
library: Some(self.library.clone()).filter(|l| !l.is_empty()),
|
||||
date: Some(self.date.trim().to_string()),
|
||||
csfd_url: Some(self.csfd.trim().to_string()),
|
||||
format_3d: Some(self.format_3d.clone().unwrap_or_default()),
|
||||
..Default::default()
|
||||
};
|
||||
match env.fm.apply_movie_edits(&self.path, edits) {
|
||||
Ok(new) => {
|
||||
env.events.push(Event::Refresh);
|
||||
env.events.push(Event::Select(vec![new.clone()]));
|
||||
env.events
|
||||
.push(Event::Status(format!("Upraveno: {}", paths::name(&new))));
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
env.warn("Úprava selhala", e.to_string());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)) => {
|
||||
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 grouped = self.grouped(env.fm);
|
||||
let mode = env.fm.movie_group_mode(&self.path);
|
||||
let rows: Vec<(String, String, String)> = self
|
||||
.media
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (kind, label) = self.media_row(env.fm, p);
|
||||
(paths::name(p), kind, label)
|
||||
})
|
||||
.collect();
|
||||
let mut ok = None;
|
||||
let mut act: Option<&str> = None;
|
||||
let mut mode_click = None;
|
||||
let (_, close) = modal(ctx, "movie_edit", "Upravit film", 600.0, |ui| {
|
||||
let note = "Film má víc verzí — název/rok/TMDb ID/knihovna se zatím needitují společně (odeber verzi, nebo uprav soubory ručně).";
|
||||
egui::Grid::new("movie_form").num_columns(2).show(ui, |ui| {
|
||||
let l = ui.label("Název:");
|
||||
ui.add_enabled_ui(!grouped, |ui| crate::ui::common::sized(ui, 380.0, egui::TextEdit::singleline(&mut self.title))).inner.labelled_by(l.id).on_disabled_hover_text(note);
|
||||
ui.end_row();
|
||||
let l = ui.label("Rok:");
|
||||
ui.add_enabled_ui(!grouped, |ui| digit_edit(ui, &mut self.year, 4, 60.0, "rok").labelled_by(l.id));
|
||||
ui.end_row();
|
||||
let l = ui.label("TMDb ID:");
|
||||
ui.add_enabled_ui(!grouped, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
digit_edit(ui, &mut self.tmdb, 9, 100.0, "TMDb ID").labelled_by(l.id);
|
||||
if busy_button(ui, busy, "Dohledat z TMDb") {
|
||||
act = Some("lookup");
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Knihovna:");
|
||||
ui.add_enabled_ui(!grouped, |ui| {
|
||||
egui::ComboBox::from_id_salt("me_lib").selected_text(self.library.clone()).show_ui(ui, |ui| {
|
||||
for l in &self.libraries {
|
||||
ui.selectable_value(&mut self.library, l.clone(), l);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
let l = ui.label("Datum přidání:");
|
||||
crate::ui::common::sized(ui, 140.0, egui::TextEdit::singleline(&mut self.date).hint_text("YYYY-MM-DD")).labelled_by(l.id);
|
||||
ui.end_row();
|
||||
let l = ui.label("ČSFD odkaz:");
|
||||
crate::ui::common::sized(ui, 380.0, egui::TextEdit::singleline(&mut self.csfd).hint_text("https://www.csfd.cz/film/… (jen uloží se)")).labelled_by(l.id);
|
||||
ui.end_row();
|
||||
ui.label("3D formát:");
|
||||
ui.add_enabled_ui(!grouped, |ui| {
|
||||
egui::ComboBox::from_id_salt("me_3d")
|
||||
.selected_text(self.format_3d.clone().unwrap_or_else(|| "— žádný —".into()))
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut self.format_3d, None, "— žádný —");
|
||||
for f in THREE_D_FORMATS {
|
||||
ui.selectable_value(&mut self.format_3d, Some(f.to_string()), f);
|
||||
}
|
||||
})
|
||||
.response
|
||||
.on_hover_text(if grouped {
|
||||
"U filmu s víc verzemi patří 3D do popisku té konkrétní verze (viz „Média“ níže), ne sem."
|
||||
} else {
|
||||
"Jellyfin pozná 3D podle „.3D.<FORMÁT>“ v názvu souboru — HSBS/FSBS = side-by-side (half/full), HTAB/FTAB = top-and-bottom (half/full), MVC = nativní 3D blu-ray stream."
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.label("Titulky (externí soubor vedle videa — Jellyfin je přehraje bez muxování; sledují film i při přejmenování):");
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_height(60.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("subs")
|
||||
.max_height(100.0)
|
||||
.show(ui, |ui| {
|
||||
for (i, s) in self.subtitles.iter().enumerate() {
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.selected_sub == Some(i),
|
||||
format!("{} {}", paths::name(&s.path), s.label()),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.selected_sub = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("➕ Přidat titulky…").clicked() {
|
||||
act = Some("add_sub");
|
||||
}
|
||||
if ui.button("➖ Odebrat").clicked() {
|
||||
act = Some("remove_sub");
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.strong("Média filmu");
|
||||
let text = match mode {
|
||||
GroupMode::Flat => "Samostatný film — jeden soubor v kořeni knihovny (+ titulky). Přidáním dalšího média se film přesune do vlastní složky jako verze, nebo jako díl — podle volby níže.".to_string(),
|
||||
GroupMode::Parts => format!("Složka filmu — {} médií (díly, přehrají se za sebou).", self.media.len()),
|
||||
GroupMode::Versions => format!("Složka filmu — {} médií (verze, Jellyfin nabídne výběr).", self.media.len()),
|
||||
};
|
||||
ui.add(egui::Label::new(text).wrap());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Ve složce:");
|
||||
if ui.radio(!self.mode_parts, "Verze — alternativy k výběru").on_hover_text("Každý soubor je celý film, Jellyfin nabídne výběr mezi nimi — kinoverze, režisérská verze, 3D, 4K…").clicked() {
|
||||
mode_click = Some(false);
|
||||
}
|
||||
if ui.radio(self.mode_parts, "Díly — přehrají se za sebou").on_hover_text("Film rozdělený do víc souborů (díl 1, díl 2…) — Jellyfin je přehraje za sebou jako jeden film.").clicked() {
|
||||
mode_click = Some(true);
|
||||
}
|
||||
});
|
||||
egui::Grid::new("media_rows").striped(true).num_columns(3).show(ui, |ui| {
|
||||
ui.strong("Soubor");
|
||||
ui.strong("Typ");
|
||||
ui.strong("Popisek");
|
||||
ui.end_row();
|
||||
for (i, (name, kind, label)) in rows.iter().enumerate() {
|
||||
if ui.selectable_label(self.selected_media == Some(i), name).clicked() {
|
||||
self.selected_media = Some(i);
|
||||
}
|
||||
ui.label(kind);
|
||||
ui.label(label);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
let parts = self.mode_parts;
|
||||
let is_grouped = mode != GroupMode::Flat;
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("➕ Přidat médium…").on_hover_text("Přidá další soubor jako verzi, nebo jako díl — podle volby „Ve složce“. Samostatný film se tím přesune do vlastní složky.").clicked() {
|
||||
act = Some("add_media");
|
||||
}
|
||||
if ui.add_enabled(is_grouped && !parts, egui::Button::new("✏ Popisek / 3D…")).clicked() {
|
||||
act = Some("label");
|
||||
}
|
||||
if ui.add_enabled(is_grouped && parts, egui::Button::new("↑")).on_hover_text("Posunout díl dopředu").clicked() {
|
||||
act = Some("up");
|
||||
}
|
||||
if ui.add_enabled(is_grouped && parts, egui::Button::new("↓")).on_hover_text("Posunout díl dozadu").clicked() {
|
||||
act = Some("down");
|
||||
}
|
||||
if ui.button("➖ Odebrat").clicked() {
|
||||
act = Some("remove_media");
|
||||
}
|
||||
});
|
||||
});
|
||||
ok = ok_cancel_row(ui);
|
||||
});
|
||||
|
||||
if let Some(wanted) = mode_click {
|
||||
self.on_mode_clicked(env, wanted);
|
||||
}
|
||||
match act {
|
||||
Some("lookup") => self.start_lookup(ctx, env),
|
||||
Some("add_sub") => {
|
||||
if let Some(src) = rfd::FileDialog::new().set_title("Vyber soubor s titulky").add_filter("Titulky", &["srt", "ass", "ssa", "sub", "vtt"]).pick_file() {
|
||||
self.prompt = Some(Prompt::text("Jazyk titulků", "Kód jazyka (např. cs, en; prázdné = bez tagu):", "", Purpose::SubLanguage(src)));
|
||||
}
|
||||
}
|
||||
Some("remove_sub") => match self.selected_sub.and_then(|i| self.subtitles.get(i)) {
|
||||
Some(s) => self.prompt = Some(Prompt::confirm("Odebrat titulky", format!("Smazat soubor „{}“?", paths::name(&s.path)), Purpose::RemoveSub(s.path.clone()))),
|
||||
None => env.info("Titulky", "Vyber titulky k odebrání."),
|
||||
},
|
||||
Some("add_media") => {
|
||||
if let Some(src) = rfd::FileDialog::new().set_title("Vyber další médium filmu").pick_file() {
|
||||
if self.mode_parts {
|
||||
match env.fm.add_movie_part(&self.path, &src, false) {
|
||||
Ok(added) => {
|
||||
self.after_change(env.fm, None);
|
||||
self.selected_media = self.media.iter().position(|p| p == &added);
|
||||
}
|
||||
Err(e) => env.warn("Média", format!("Nelze přidat: {e}")),
|
||||
}
|
||||
env.events.push(Event::Refresh);
|
||||
} else {
|
||||
self.prompt = Some(Prompt::version_label("", "", Purpose::AddVersion(src)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("label") => match self.selected_media.and_then(|i| self.media.get(i)).cloned() {
|
||||
Some(p) if mode == GroupMode::Versions => {
|
||||
let (_, label) = self.media_row(env.fm, &p);
|
||||
self.prompt = Some(Prompt::version_label("", &label, Purpose::RenameVersion(p)));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => env.info("Verze", "Vyber verzi."),
|
||||
},
|
||||
Some(dir @ ("up" | "down")) => match self.selected_media.and_then(|i| self.media.get(i).map(|p| (i, p.clone()))) {
|
||||
Some((i, p)) => {
|
||||
let delta = if dir == "up" { -1 } else { 1 };
|
||||
if let Err(e) = env.fm.move_movie_part(&p, delta) {
|
||||
env.warn("Díly", e.to_string());
|
||||
}
|
||||
let target = (i as i64 + i64::from(delta)).clamp(0, self.media.len() as i64 - 1) as usize;
|
||||
self.after_change(env.fm, Some(target));
|
||||
env.events.push(Event::Refresh);
|
||||
}
|
||||
None => env.info("Díly", "Vyber díl k posunutí."),
|
||||
},
|
||||
Some("remove_media") => match self.selected_media.and_then(|i| self.media.get(i)).cloned() {
|
||||
None => env.info("Média", "Vyber médium k odebrání."),
|
||||
Some(_) if self.media.len() < 2 => env.info("Média", "Film má jen tenhle jeden soubor — celý film odebereš z hlavní tabulky (Odebrat z poolu)."),
|
||||
Some(p) => self.prompt = Some(Prompt::confirm("Odebrat médium", format!("Smazat soubor „{}“?", paths::name(&p)), Purpose::RemoveMedia(p))),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
if let Some(mut prompt) = self.prompt.take() {
|
||||
match prompt.show(ctx, egui::Id::unique("movie_edit_prompt")) {
|
||||
Some((purpose, answer)) => self.handle_prompt(purpose, answer, env),
|
||||
None => self.prompt = Some(prompt),
|
||||
}
|
||||
}
|
||||
match ok {
|
||||
Some(true) => {
|
||||
if self.accept(env) {
|
||||
return DialogState::Closed;
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
env.events.push(Event::Refresh); // versions may have changed meanwhile
|
||||
return DialogState::Closed;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if close && self.prompt.is_none() && !busy {
|
||||
env.events.push(Event::Refresh);
|
||||
return DialogState::Closed;
|
||||
}
|
||||
DialogState::Open
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
//! Series overview (Series → Season → Episode tree) and the series editor.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use egui::RichText;
|
||||
|
||||
use super::{Dialog, DialogState, Env, Event, modal, ok_cancel_row};
|
||||
use crate::core::file_manager::FileManager;
|
||||
use crate::core::paths;
|
||||
use crate::core::series::{Series, is_canonical_episode, season_folder_name};
|
||||
use crate::core::tmdb::{self, Kind, TmdbResult};
|
||||
use crate::ui::common::{Answer, Job, Prompt, RED, busy_button, digit_edit};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Sel {
|
||||
Series(usize),
|
||||
Season(usize, i64),
|
||||
Episode(usize, PathBuf),
|
||||
}
|
||||
|
||||
impl Sel {
|
||||
fn series_index(&self) -> usize {
|
||||
match self {
|
||||
Self::Series(i) | Self::Season(i, _) | Self::Episode(i, _) => *i,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SeriesOverviewDialog {
|
||||
series: Vec<Series>,
|
||||
selected: Option<Sel>,
|
||||
confirm: Option<Prompt<Sel>>,
|
||||
}
|
||||
|
||||
impl SeriesOverviewDialog {
|
||||
pub fn new(fm: &FileManager) -> Self {
|
||||
Self {
|
||||
series: fm.load_series(),
|
||||
selected: None,
|
||||
confirm: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reload(&mut self, fm: &FileManager) {
|
||||
self.series = fm.load_series();
|
||||
self.selected = None;
|
||||
}
|
||||
|
||||
fn ask_remove(&mut self, env: &mut Env) {
|
||||
let Some(sel) = self.selected.clone() else {
|
||||
env.info("Seriály", "Vyber seriál, sezónu nebo epizodu.");
|
||||
return;
|
||||
};
|
||||
let ser = &self.series[sel.series_index()];
|
||||
let question = match &sel {
|
||||
Sel::Series(_) => format!(
|
||||
"Smazat seriál „{}“ z poolu včetně všech sezón a epizod?",
|
||||
ser.title
|
||||
),
|
||||
Sel::Season(_, season) => {
|
||||
let eps = ser.seasons().get(season).map_or(0, Vec::len);
|
||||
let name = if *season >= 0 {
|
||||
season_folder_name(*season as u32)
|
||||
} else {
|
||||
"epizody bez rozpoznané sezóny".into()
|
||||
};
|
||||
format!("Smazat „{name}“ ({eps} dílů) seriálu „{}“?", ser.title)
|
||||
}
|
||||
Sel::Episode(_, path) => {
|
||||
let code = ser
|
||||
.episodes
|
||||
.iter()
|
||||
.find(|e| &e.path == path)
|
||||
.map(|e| e.code())
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"Smazat epizodu {code} „{}“ ze seriálu „{}“?",
|
||||
paths::name(path),
|
||||
ser.title
|
||||
)
|
||||
}
|
||||
};
|
||||
self.confirm = Some(Prompt::confirm("Odebrat z poolu", question, sel));
|
||||
}
|
||||
|
||||
fn remove(&mut self, sel: Sel, env: &mut Env) {
|
||||
let ser = self.series[sel.series_index()].clone();
|
||||
let result = match sel {
|
||||
Sel::Series(_) => env.fm.remove_series(&ser),
|
||||
Sel::Season(_, season) => env.fm.remove_season(&ser, season),
|
||||
Sel::Episode(_, path) => {
|
||||
let eps: Vec<_> = ser
|
||||
.episodes
|
||||
.iter()
|
||||
.filter(|e| e.path == path)
|
||||
.cloned()
|
||||
.collect();
|
||||
env.fm.remove_episodes(&eps);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
env.warn("Chyba", e.to_string());
|
||||
}
|
||||
self.reload(env.fm);
|
||||
env.events.push(Event::ReloadSeries);
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
|
||||
let mut action = None;
|
||||
let (_, close) = modal(ctx, "series_overview", "Přehled seriálů", 760.0, |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(460.0)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
for (i, ser) in self.series.iter().enumerate() {
|
||||
let mut label = ser.title.clone();
|
||||
if let Some(y) = &ser.year {
|
||||
label.push_str(&format!(" ({y})"));
|
||||
}
|
||||
if let Some(id) = ser.tmdb_id {
|
||||
label.push_str(&format!(" [tmdb-{id}]"));
|
||||
}
|
||||
let header = format!("{label} — {} epizod", ser.episodes.len());
|
||||
let id = ui.make_persistent_id(("ser", i));
|
||||
egui::collapsing_header::CollapsingState::load_with_default_open(
|
||||
ui.ctx(),
|
||||
id,
|
||||
true,
|
||||
)
|
||||
.show_header(ui, |ui| {
|
||||
let r = ui.selectable_label(
|
||||
self.selected == Some(Sel::Series(i)),
|
||||
RichText::new(header).strong(),
|
||||
);
|
||||
if r.clicked() {
|
||||
self.selected = Some(Sel::Series(i));
|
||||
}
|
||||
if r.double_clicked() {
|
||||
action = Some("edit");
|
||||
}
|
||||
})
|
||||
.body(|ui| {
|
||||
for (season, eps) in ser.seasons() {
|
||||
let sname = if season >= 0 {
|
||||
season_folder_name(season as u32)
|
||||
} else {
|
||||
"? bez sezóny".into()
|
||||
};
|
||||
let sid = ui.make_persistent_id(("season", i, season));
|
||||
egui::collapsing_header::CollapsingState::load_with_default_open(
|
||||
ui.ctx(),
|
||||
sid,
|
||||
false,
|
||||
)
|
||||
.show_header(ui, |ui| {
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.selected == Some(Sel::Season(i, season)),
|
||||
format!("{sname} — {} dílů", eps.len()),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.selected = Some(Sel::Season(i, season));
|
||||
}
|
||||
})
|
||||
.body(|ui| {
|
||||
for ep in eps {
|
||||
let ok = is_canonical_episode(&ep.path, &ser.title);
|
||||
ui.horizontal(|ui| {
|
||||
let sel = Sel::Episode(i, ep.path.clone());
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.selected.as_ref() == Some(&sel),
|
||||
format!(
|
||||
"{} {}",
|
||||
ep.code(),
|
||||
paths::name(&ep.path)
|
||||
),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.selected = Some(sel);
|
||||
}
|
||||
if ok {
|
||||
ui.label("OK");
|
||||
} else {
|
||||
ui.colored_label(RED, "přejmenovat");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if self.series.is_empty() {
|
||||
ui.label("Žádné seriály v poolu.");
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Upravit seriál…").clicked() {
|
||||
action = Some("edit");
|
||||
}
|
||||
if ui.button("Přejmenovat epizody").clicked() {
|
||||
action = Some("rename");
|
||||
}
|
||||
if ui
|
||||
.button("Odebrat z poolu…")
|
||||
.on_hover_text("Smaže to, co je ve stromu vybrané — celý seriál, jen jednu sezónu, nebo jen jednu epizodu.")
|
||||
.clicked()
|
||||
{
|
||||
action = Some("remove");
|
||||
}
|
||||
if ui.button("Zavřít").clicked() {
|
||||
action = Some("close");
|
||||
}
|
||||
});
|
||||
});
|
||||
match action {
|
||||
Some("close") => return DialogState::Closed,
|
||||
Some("edit") => match &self.selected {
|
||||
Some(sel) => {
|
||||
let ser = self.series[sel.series_index()].clone();
|
||||
let mut d = SeriesEditDialog::new(env.fm, ser);
|
||||
d.back_to_overview = true;
|
||||
env.events
|
||||
.push(Event::OpenDialog(Box::new(Dialog::SeriesEdit(Box::new(d)))));
|
||||
return DialogState::Closed;
|
||||
}
|
||||
None => env.info("Seriály", "Vyber seriál."),
|
||||
},
|
||||
Some("rename") => match &self.selected {
|
||||
Some(sel) => {
|
||||
let mut ser = self.series[sel.series_index()].clone();
|
||||
let r = env.fm.rename_episodes_to_canonical(&mut ser);
|
||||
self.reload(env.fm);
|
||||
env.events.push(Event::ReloadSeries);
|
||||
env.info(
|
||||
"Přejmenování epizod",
|
||||
format!(
|
||||
"Přejmenováno: {}, přeskočeno: {}, kolize: {}.",
|
||||
r.renamed.len(),
|
||||
r.skipped.len(),
|
||||
r.conflicts.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
None => env.info("Seriály", "Vyber seriál."),
|
||||
},
|
||||
Some("remove") => self.ask_remove(env),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(mut c) = self.confirm.take() {
|
||||
match c.show(ctx, egui::Id::unique("series_confirm")) {
|
||||
Some((sel, Answer::Yes)) => self.remove(sel, env),
|
||||
Some(_) => {}
|
||||
None => self.confirm = Some(c),
|
||||
}
|
||||
}
|
||||
if close && self.confirm.is_none() {
|
||||
return DialogState::Closed;
|
||||
}
|
||||
DialogState::Open
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SeriesEditDialog {
|
||||
series: Series,
|
||||
title: String,
|
||||
year: String,
|
||||
tmdb: String,
|
||||
/// TMDb lists this "series" as a movie: lookups use the movie endpoints and
|
||||
/// the id is not written into the series folder name.
|
||||
movie_id: bool,
|
||||
libraries: Vec<String>,
|
||||
library: String,
|
||||
/// `(season, episode)` per episode, editable.
|
||||
numbers: Vec<(u32, u32)>,
|
||||
job: Option<Job<Result<Option<TmdbResult>, String>>>,
|
||||
pub back_to_overview: bool,
|
||||
}
|
||||
|
||||
impl SeriesEditDialog {
|
||||
pub fn new(fm: &FileManager, ser: Series) -> Self {
|
||||
let numbers = ser
|
||||
.episodes
|
||||
.iter()
|
||||
.map(|e| (e.season.unwrap_or(1), e.episode.unwrap_or(1)))
|
||||
.collect();
|
||||
Self {
|
||||
title: ser.title.clone(),
|
||||
year: ser.year.clone().unwrap_or_default(),
|
||||
tmdb: ser.tmdb_id.map(|i| i.to_string()).unwrap_or_default(),
|
||||
movie_id: false,
|
||||
libraries: fm.series_libraries(),
|
||||
library: ser.library.clone(),
|
||||
numbers,
|
||||
series: ser,
|
||||
job: None,
|
||||
back_to_overview: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(&mut self, ctx: &egui::Context, env: &mut Env) {
|
||||
if !tmdb::api_available() {
|
||||
env.warn("TMDb", "Není nastaven TMDB_API_KEY (.env).");
|
||||
return;
|
||||
}
|
||||
let kind = if self.movie_id { Kind::Movie } else { Kind::Tv };
|
||||
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() {
|
||||
let n: i64 = id.parse().map_err(|_| "Neplatné ID".to_string())?;
|
||||
tmdb::lookup(n, kind).map_err(|e| e.to_string())
|
||||
} else {
|
||||
let year = Some(year.as_str()).filter(|y| !y.is_empty());
|
||||
let hits = match kind {
|
||||
Kind::Movie => tmdb::search_movie(&title, year, 1),
|
||||
Kind::Tv => tmdb::search_tv(&title, year, 1),
|
||||
};
|
||||
hits.map(|h| h.into_iter().next())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn apply(&mut self, env: &mut Env) {
|
||||
let mut ser = self.series.clone();
|
||||
for (ep, (s, e)) in ser.episodes.iter_mut().zip(&self.numbers) {
|
||||
ep.season = Some(*s);
|
||||
ep.episode = Some(*e);
|
||||
}
|
||||
env.fm.rename_episodes_to_canonical(&mut ser);
|
||||
let tmdb_id = if self.movie_id {
|
||||
None
|
||||
} else {
|
||||
self.tmdb.trim().parse().ok()
|
||||
};
|
||||
let year = Some(self.year.trim()).filter(|y| !y.is_empty());
|
||||
if let Err(e) =
|
||||
env.fm
|
||||
.apply_series_edits(&ser, self.title.trim(), year, tmdb_id, Some(&self.library))
|
||||
{
|
||||
env.warn("Úprava seriálu selhala", e.to_string());
|
||||
}
|
||||
env.events.push(Event::ReloadSeries);
|
||||
env.events.push(Event::Status(format!(
|
||||
"Seriál upraven: {}",
|
||||
self.title.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
fn finish(&self, env: &mut Env) -> DialogState {
|
||||
if self.back_to_overview {
|
||||
env.events
|
||||
.push(Event::OpenDialog(Box::new(Dialog::series_overview(env.fm))));
|
||||
}
|
||||
DialogState::Closed
|
||||
}
|
||||
|
||||
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",
|
||||
if self.movie_id {
|
||||
"Film nenalezen."
|
||||
} else {
|
||||
"Seriál nenalezen."
|
||||
},
|
||||
),
|
||||
Ok(Some(hit)) => {
|
||||
self.title = hit.title;
|
||||
if let Some(y) = hit.year {
|
||||
self.year = y;
|
||||
}
|
||||
if self.tmdb.trim().is_empty() {
|
||||
self.tmdb = hit.tmdb_id.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if job.finished() {
|
||||
self.job = None;
|
||||
}
|
||||
}
|
||||
let busy = self.job.is_some();
|
||||
let mut ok = None;
|
||||
let mut lookup = false;
|
||||
let title = format!("Upravit seriál — {}", self.series.title);
|
||||
let (_, close) = modal(ctx, "series_edit", &title, 660.0, |ui| {
|
||||
egui::Grid::new("series_form").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Název:");
|
||||
crate::ui::common::sized(ui, 380.0, egui::TextEdit::singleline(&mut self.title));
|
||||
ui.end_row();
|
||||
ui.label("Rok:");
|
||||
digit_edit(ui, &mut self.year, 4, 60.0, "rok");
|
||||
ui.end_row();
|
||||
ui.label("TMDb ID:");
|
||||
ui.horizontal(|ui| {
|
||||
digit_edit(ui, &mut self.tmdb, 9, 100.0, "TMDb ID");
|
||||
lookup = busy_button(ui, busy, "🔎 Dohledat");
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.checkbox(&mut self.movie_id, "Metadata projednou z ID filmu (ne seriálu)").on_hover_text(
|
||||
"TMDb vede tohle jako film, ne seriál. „Dohledat“ pak použije filmové ID / vyhledávání filmů. Filmové ID se do názvu složky seriálu neuloží — Jellyfin by ho hledal jako ID seriálu a spároval něco jiného; seriál pak spáruje podle názvu a roku.",
|
||||
);
|
||||
ui.end_row();
|
||||
ui.label("Knihovna:");
|
||||
egui::ComboBox::from_id_salt("series_lib").selected_text(self.library.clone()).show_ui(ui, |ui| {
|
||||
for l in &self.libraries {
|
||||
ui.selectable_value(&mut self.library, l.clone(), l);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label("Epizody — uprav sezónu/díl (uloží se jako SxxEyy):");
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(320.0)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new("series_eps")
|
||||
.striped(true)
|
||||
.num_columns(3)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("Soubor");
|
||||
ui.strong("Sezóna");
|
||||
ui.strong("Díl");
|
||||
ui.end_row();
|
||||
for (ep, (s, e)) in
|
||||
self.series.episodes.iter().zip(self.numbers.iter_mut())
|
||||
{
|
||||
ui.label(paths::name(&ep.path));
|
||||
ui.add(egui::DragValue::new(s).range(0..=99));
|
||||
ui.add(egui::DragValue::new(e).range(0..=999));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
ok = ok_cancel_row(ui);
|
||||
});
|
||||
if lookup {
|
||||
self.lookup(ctx, env);
|
||||
}
|
||||
match ok {
|
||||
Some(true) => {
|
||||
if self.title.trim().is_empty() {
|
||||
env.warn("Seriál", "Zadej název seriálu.");
|
||||
} else {
|
||||
self.apply(env);
|
||||
return self.finish(env);
|
||||
}
|
||||
}
|
||||
Some(false) => return self.finish(env),
|
||||
None => {}
|
||||
}
|
||||
if close && !busy {
|
||||
return self.finish(env);
|
||||
}
|
||||
DialogState::Open
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Read-only overview of the pool: totals + per-category tag breakdown.
|
||||
|
||||
use super::{DialogState, Env, modal};
|
||||
use crate::core::file_manager::Stats;
|
||||
use crate::ui::common::format_size;
|
||||
|
||||
pub struct StatsDialog {
|
||||
stats: Stats,
|
||||
}
|
||||
|
||||
impl StatsDialog {
|
||||
pub fn new(stats: Stats) -> Self {
|
||||
Self { stats }
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ctx: &egui::Context, _env: &mut Env) -> DialogState {
|
||||
let s = &self.stats;
|
||||
let mut closed = false;
|
||||
let (_, close) = modal(ctx, "stats", "Statistiky knihovny", 460.0, |ui| {
|
||||
egui::Grid::new("stats_summary")
|
||||
.num_columns(2)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("Filmů v poolu:");
|
||||
ui.label(s.count.to_string());
|
||||
ui.end_row();
|
||||
ui.strong("Celková velikost:");
|
||||
ui.label(format_size(s.total_size));
|
||||
ui.end_row();
|
||||
ui.strong("Průměrné hodnocení:");
|
||||
ui.label(s.avg_rating.map_or("—".to_string(), |a| format!("{a} %")));
|
||||
ui.end_row();
|
||||
ui.strong("S NFO metadaty:");
|
||||
ui.label(format!("{} · bez: {}", s.with_nfo, s.without_nfo));
|
||||
ui.end_row();
|
||||
ui.strong("Bez štítků:");
|
||||
ui.label(s.untagged.to_string());
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(420.0)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
for (cat, entries) in &s.categories {
|
||||
egui::CollapsingHeader::new(format!("{cat} ({})", entries.len())).show(
|
||||
ui,
|
||||
|ui| {
|
||||
egui::Grid::new(("stats_cat", cat))
|
||||
.striped(true)
|
||||
.num_columns(2)
|
||||
.show(ui, |ui| {
|
||||
for (name, count) in entries {
|
||||
ui.label(name);
|
||||
ui.label(count.to_string());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
closed = ui.button("Zavřít").clicked();
|
||||
});
|
||||
if closed || close {
|
||||
DialogState::Closed
|
||||
} else {
|
||||
DialogState::Open
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Editor for the tag schema: categories, their NFO source and folder rules.
|
||||
|
||||
use super::{DialogState, Env, Event, modal, ok_cancel_row};
|
||||
use crate::core::config::TagSchemaEntry;
|
||||
use crate::core::file_manager::FileManager;
|
||||
use crate::ui::common::sized;
|
||||
|
||||
const FIELD_CHOICES: &[(&str, Option<&str>)] = &[
|
||||
("(žádné — uživatelská)", None),
|
||||
("Žánry (genres)", Some("genres")),
|
||||
("Rok (year)", Some("year")),
|
||||
("Země (countries)", Some("countries")),
|
||||
("Hodnocení (rating)", Some("rating")),
|
||||
];
|
||||
const TRANSFORM_CHOICES: &[(&str, Option<&str>)] = &[
|
||||
("(žádný)", None),
|
||||
("Pásmo po 10 (decade_band)", Some("decade_band")),
|
||||
];
|
||||
|
||||
struct Row {
|
||||
category: String,
|
||||
field: Option<String>,
|
||||
transform: Option<String>,
|
||||
make_folders: bool,
|
||||
folder: String,
|
||||
template: String,
|
||||
}
|
||||
|
||||
fn label_of(choices: &[(&str, Option<&str>)], value: &Option<String>) -> String {
|
||||
choices
|
||||
.iter()
|
||||
.find(|(_, v)| v.map(String::from) == *value)
|
||||
.map_or(choices[0].0, |(l, _)| l)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub struct TagSchemaDialog {
|
||||
rows: Vec<Row>,
|
||||
selected: Option<usize>,
|
||||
}
|
||||
|
||||
impl TagSchemaDialog {
|
||||
pub fn new(fm: &FileManager) -> Self {
|
||||
let rows = fm
|
||||
.tag_schema()
|
||||
.into_iter()
|
||||
.map(|e| Row {
|
||||
make_folders: e.filmoteka_root.is_some(),
|
||||
folder: e.filmoteka_root.clone().unwrap_or_default(),
|
||||
template: e.filename_template.clone().unwrap_or_default(),
|
||||
category: e.category,
|
||||
field: e.csfd_field,
|
||||
transform: e.transform,
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
rows,
|
||||
selected: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn schema(&self) -> Vec<TagSchemaEntry> {
|
||||
self.rows
|
||||
.iter()
|
||||
.filter(|r| !r.category.trim().is_empty())
|
||||
.map(|r| TagSchemaEntry {
|
||||
category: r.category.trim().to_string(),
|
||||
csfd_field: r.field.clone(),
|
||||
transform: r.transform.clone(),
|
||||
filmoteka_root: r.make_folders.then(|| r.folder.trim().to_string()),
|
||||
filename_template: Some(r.template.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ctx: &egui::Context, env: &mut Env) -> DialogState {
|
||||
let mut ok = None;
|
||||
let (_, close) = modal(
|
||||
ctx,
|
||||
"tag_schema",
|
||||
"Tag schéma — kategorie a pravidla",
|
||||
1060.0,
|
||||
|ui| {
|
||||
ui.add(egui::Label::new(
|
||||
"Kategorie tagů, jejich zdroj z NFO a jak se z nich tvoří složky miniDLNA.\nSložka: prázdné = v kořeni výstupu, „Dle…“ = seskupit, vypnuté „Tvořit složky“ = jen filtr bez složek.\nŠablona názvu: jak pojmenovat hardlink jen v této kategorii, např. „{year} - {title}{ext}“ (pole: title/year/rating/ext/stem/filename).",
|
||||
).wrap());
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::both()
|
||||
.max_height(380.0)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new("schema_rows")
|
||||
.striped(true)
|
||||
.num_columns(7)
|
||||
.show(ui, |ui| {
|
||||
for h in [
|
||||
"",
|
||||
"Kategorie",
|
||||
"Zdroj (NFO)",
|
||||
"Transform",
|
||||
"Tvořit složky",
|
||||
"Složka",
|
||||
"Šablona názvu",
|
||||
] {
|
||||
ui.strong(h);
|
||||
}
|
||||
ui.end_row();
|
||||
for (i, r) in self.rows.iter_mut().enumerate() {
|
||||
ui.radio_value(&mut self.selected, Some(i), "");
|
||||
sized(ui, 140.0, egui::TextEdit::singleline(&mut r.category));
|
||||
egui::ComboBox::from_id_salt(("field", i))
|
||||
.selected_text(label_of(FIELD_CHOICES, &r.field))
|
||||
.show_ui(ui, |ui| {
|
||||
for (l, v) in FIELD_CHOICES {
|
||||
ui.selectable_value(
|
||||
&mut r.field,
|
||||
v.map(String::from),
|
||||
*l,
|
||||
);
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt(("transform", i))
|
||||
.selected_text(label_of(TRANSFORM_CHOICES, &r.transform))
|
||||
.show_ui(ui, |ui| {
|
||||
for (l, v) in TRANSFORM_CHOICES {
|
||||
ui.selectable_value(
|
||||
&mut r.transform,
|
||||
v.map(String::from),
|
||||
*l,
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.checkbox(&mut r.make_folders, "");
|
||||
sized(
|
||||
ui,
|
||||
160.0,
|
||||
egui::TextEdit::singleline(&mut r.folder)
|
||||
.hint_text("prázdné = kořen výstupu"),
|
||||
);
|
||||
sized(
|
||||
ui,
|
||||
180.0,
|
||||
egui::TextEdit::singleline(&mut r.template)
|
||||
.hint_text("výchozí název souboru"),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("➕ Přidat kategorii").clicked() {
|
||||
self.rows.push(Row {
|
||||
category: String::new(),
|
||||
field: None,
|
||||
transform: None,
|
||||
make_folders: true,
|
||||
folder: String::new(),
|
||||
template: String::new(),
|
||||
});
|
||||
}
|
||||
if ui.button("➖ Odebrat vybranou").clicked()
|
||||
&& let Some(i) = self.selected.take().filter(|i| *i < self.rows.len())
|
||||
{
|
||||
self.rows.remove(i);
|
||||
}
|
||||
});
|
||||
ok = ok_cancel_row(ui);
|
||||
},
|
||||
);
|
||||
match ok {
|
||||
Some(true) => {
|
||||
let schema = self.schema();
|
||||
if let Err(e) = env.fm.set_tag_schema(&schema) {
|
||||
env.warn("Tag schéma", e.to_string());
|
||||
return DialogState::Open;
|
||||
}
|
||||
env.events.push(Event::Refresh);
|
||||
env.events.push(Event::Status(format!(
|
||||
"Tag schéma uloženo ({} kategorií). Pro nové tagy spusťte „Načíst žánry z TMDb“ a přegenerujte miniDLNA.",
|
||||
schema.len()
|
||||
)));
|
||||
DialogState::Closed
|
||||
}
|
||||
Some(false) => DialogState::Closed,
|
||||
None if close => DialogState::Closed,
|
||||
None => DialogState::Open,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! egui front end. `run` opens the main window.
|
||||
|
||||
pub mod app;
|
||||
pub mod common;
|
||||
pub mod dialogs;
|
||||
pub mod task;
|
||||
|
||||
use crate::constants;
|
||||
use crate::core::file_manager::FileManager;
|
||||
|
||||
/// Open the main window and block until it is closed.
|
||||
pub fn run(fm: FileManager) -> eframe::Result {
|
||||
let size = app::initial_size(&fm);
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title(format!("{} — Jellyfin knihovna", constants::app_title()))
|
||||
.with_inner_size(size)
|
||||
.with_min_inner_size([800.0, 500.0]),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
constants::APP_NAME,
|
||||
options,
|
||||
Box::new(move |cc| Ok(Box::new(app::CuratorApp::new(fm, cc.egui_ctx.clone())))),
|
||||
)
|
||||
}
|
||||