Compare commits
23
Commits
dd9c7d9ec5
..
rust
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b6de20b67 | ||
|
|
d022e241a9 | ||
|
|
65b5b74c9f | ||
|
|
364b515e3c | ||
|
|
4daf4e960d | ||
|
|
7cc2a1c63e | ||
|
|
c69f5f9166 | ||
|
|
c399a6aed3 | ||
|
|
c0ad1c1553 | ||
|
|
79c9e34a5d | ||
|
|
39712d7283 | ||
|
|
6a2baf243f | ||
|
|
1c78ca9013 | ||
|
|
4c4180f28c | ||
|
|
97a39709e8 | ||
|
|
7ae0709f38 | ||
|
|
f0f38d4257 | ||
|
|
b5c2023212 | ||
|
|
ee99fc957d | ||
|
|
a71b209539 | ||
|
|
b3a61f9e86 | ||
|
|
86c689b9f1 | ||
|
|
22a14b1e41 |
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# --- Rust ---------------------------------------------------------------
|
||||||
|
# Cargo build output (debug/release builds, incremental caches)
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# --- Python -------------------------------------------------------------
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Tooling caches
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# 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
|
||||||
|
*.!ftag
|
||||||
|
*.!tag
|
||||||
|
*.!index
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
DESIGN_DOCUMENT.md
|
||||||
|
.claude/
|
||||||
+939
@@ -0,0 +1,939 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Document all notable changes to the project, grouped by version and release date.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Each version entry uses these sections (include only those that apply):
|
||||||
|
|
||||||
|
- **Added** — new features
|
||||||
|
- **Changed** — changes to existing functionality
|
||||||
|
- **Fixed** — bug fixes
|
||||||
|
- **Removed** — removed features
|
||||||
|
- **Dependencies** — added, updated, or removed dependencies
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Follow semantic versioning: `MAJOR.MINOR.PATCH`
|
||||||
|
- Newest version goes at the top
|
||||||
|
- 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
|
||||||
|
- **Pool naming convention `Title (YYYY).ext`** (`src/core/naming.py`). Pooled
|
||||||
|
movies now carry a four-digit year in parentheses, so two same-named films
|
||||||
|
coexist and the filename is self-describing.
|
||||||
|
- **Import requires a year**: the import dialog has a per-row **Rok** field
|
||||||
|
(pre-filled from the source filename), and *"Najít ČSFD odkazy"* now also
|
||||||
|
fills the year from the top ČSFD hit — a **suggested** year is tinted **blue**
|
||||||
|
(verify it), a name **colliding** with the pool stays **red**. The import is
|
||||||
|
blocked until every row has a four-digit year, and the file lands in the pool
|
||||||
|
as `Title (rok).ext`. Same title + different year no longer collide.
|
||||||
|
- **Rename to the convention**: *Filmy → "Přejmenovat dle ČSFD (rok)…"*
|
||||||
|
(`FileManager.rename_all_to_canonical`) renames selected movies (or the whole
|
||||||
|
pool) to `Title (rok).ext` using the year from their ČSFD metadata, with a
|
||||||
|
cancelable progress dialog; files with no known year are skipped and name
|
||||||
|
collisions reported. `File.title` keeps the clean title (without the year);
|
||||||
|
`File.name_context` recovers the year from a canonical filename when no
|
||||||
|
ČSFD/tag year exists.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Filmotéka hardlinks are named by the clean title (no year).** In the tag
|
||||||
|
tree a movie now links as `Title.ext` instead of the pooled `Title (YYYY).ext`.
|
||||||
|
When two *different* films would collide on that name in the **same folder**,
|
||||||
|
the colliding ones keep the year (`Title (YYYY).ext`) to tell them apart; a
|
||||||
|
residual clash (same title *and* year) gets a stable numeric suffix. Copy-as-is
|
||||||
|
mirrors (Seriály) are untouched — they stay 1:1. A per-category
|
||||||
|
`filename_template` (Tag schéma dialog) still overrides the default inside its
|
||||||
|
folders. Link creation and obsolete-detection now share one collision-resolved
|
||||||
|
plan (`HardlinkManager._plan_links`), so they can't disagree on a name — the
|
||||||
|
first generation after upgrading re-lays the affected links once, then it is
|
||||||
|
stable.
|
||||||
|
- `File.name_context`'s `year` field is now always a string (was an int when it
|
||||||
|
came straight from the ČSFD cache) — uniform for filename templates.
|
||||||
|
|
||||||
|
## 1.10.0 — 2026-07-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Video integrity check** (`src/core/integrity.py`, *Testy → "Kontrola
|
||||||
|
integrity videa…"*): scans movies for corrupted/unreadable video data via
|
||||||
|
FFmpeg. Two depths — **Důkladná** (full `ffmpeg` decode to `-f null`, catches
|
||||||
|
mid-file data corruption, slow) and **Rychlá** (`ffprobe` container/stream
|
||||||
|
probe, catches truncated/unreadable files, fast). Runs on the selected movies
|
||||||
|
or the whole pool, with a cancelable progress dialog, and reports the bad
|
||||||
|
files (with the first decoder error) separately from those that couldn't be
|
||||||
|
checked. `FileManager.scan_video_integrity` drives the scan;
|
||||||
|
`check_video_integrity` checks one file and degrades gracefully when FFmpeg
|
||||||
|
isn't installed.
|
||||||
|
|
||||||
|
## 1.9.0 — 2026-07-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Endless Filmotéka churn (`vytvořeno: N, odebráno: N` every run).** When a movie
|
||||||
|
was re-imported/replaced (new inode), the old hardlink kept the correct name
|
||||||
|
but pointed at the dead inode. Obsolete detection matched links **by name
|
||||||
|
only**, so it spared that orphan and instead forced the freshly created,
|
||||||
|
correct link to a `_1` suffix — which, having an unexpected name, was swept on
|
||||||
|
the next run and recreated again, forever. `HardlinkManager.find_obsolete_links`
|
||||||
|
now matches **both the path and the inode**: an orphan squatting the expected
|
||||||
|
name is removed and the base name is restored, so generation is stable after
|
||||||
|
one cleanup run.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Status bar** now shows the current **Pool** and **Filmotéka** paths as
|
||||||
|
permanent widgets (right-aligned), so they stay visible while the transient
|
||||||
|
area shows the movie count or the current selection — previously the paths
|
||||||
|
were part of the transient message and were wiped as soon as a selection
|
||||||
|
updated the status.
|
||||||
|
|
||||||
|
## 1.8.0 — 2026-07-03
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Split config responsibilities by scope.** The global config (`.Curator.!gtag`)
|
||||||
|
now carries **application data only** — window state, MRU folders, and the
|
||||||
|
`pool_dir` / `filmoteka_dir` pointers. Everything describing a particular
|
||||||
|
library — **`tag_schema`** and **`copyasis_folders`** — moved into the pool
|
||||||
|
index (`.Curator.!index`, under a new `settings` section), so it travels with
|
||||||
|
the pool. `FileManager` reads/writes those via the index (`PoolIndex.get_setting`
|
||||||
|
/ `set_setting`) and **migrates** any values left in an old global config into
|
||||||
|
the index once, on first open (`FileManager._migrate_global_settings`).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Per-folder config (`.Curator.!ftag`)** and the whole legacy folder-scan path
|
||||||
|
(`FileManager.append` / `get_folder_config` / `save_folder_config` /
|
||||||
|
`set_ignore_patterns` / `get_ignore_patterns`, `config.load_folder_config` etc.)
|
||||||
|
— inherited from Tagger and unused by the Filmotéka workflow.
|
||||||
|
- **Per-file metadata sidecars (`.{filename}.!tag`).** `File` metadata is now
|
||||||
|
stored only in the pool index; an index-less `File` is an in-memory object and
|
||||||
|
writes nothing to disk.
|
||||||
|
- **Old tkinter GUI** (`src/ui/gui.py`), orphaned since the PySide6 rewrite and
|
||||||
|
the only remaining consumer of the folder-config/sidecar code.
|
||||||
|
|
||||||
|
## 1.7.0 — 2026-07-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Nastavení → "Statistiky…"**: a library overview dialog — total movies, total
|
||||||
|
size, average ČSFD rating, with/without ČSFD link, untagged count, and a
|
||||||
|
per-category tag breakdown (counts sorted by frequency).
|
||||||
|
`FileManager.statistics()` aggregates it.
|
||||||
|
- **Testy menu → "Kontrola konzistence dat…"**: compares the pool index against
|
||||||
|
the actual files in pool/Filmy and reports **metadata without a file** (movie
|
||||||
|
deleted directly) and **files without metadata** (dropped into the pool without
|
||||||
|
importing). Read-only diagnosis (`FileManager.check_data_consistency`).
|
||||||
|
- **"- Nově přidané"** special folder in the Filmotéka: the **10** most recently
|
||||||
|
added movies as hardlinks (while **"- Tipy dne"** holds **15** random ones).
|
||||||
|
Import now records an **`added`** timestamp on each movie (`File.added`);
|
||||||
|
ordering falls back to the file's **mtime** for older items — not ctime, which
|
||||||
|
the hardlink generation itself bumps (`File.added_timestamp`,
|
||||||
|
`HardlinkManager.generate_recently_added`).
|
||||||
|
- Standalone **`scripts/tipy_dne.py`** to regenerate the "Tipy dne" folder
|
||||||
|
independently of the app (deployable on the server / cron): reads the pool
|
||||||
|
index, picks N random movies (default 15), clears the tips folder and recreates
|
||||||
|
the hardlinks. Stdlib only; paths via `--pool`/`--output` or the global config.
|
||||||
|
Ships with a systemd oneshot `tipy-dne.service` + daily `tipy-dne.timer`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Special folders are prefixed with "- "** so DLNA/TV browsers sort them ahead
|
||||||
|
of the genre folders at the output root: the grouping roots (now `- Dle roku`,
|
||||||
|
`- Dle země původu`, `- Dle hodnocení`, …) plus `- Tipy dne` and `- Nově
|
||||||
|
přidané`. Genre folders stay at the root, listed after the special ones.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Obsolete-link cleanup now also removes **orphan links** in the tag tree —
|
||||||
|
hardlinks pointing at an inode no longer in the pool (movie re-imported /
|
||||||
|
replaced) — not just links whose current pool inode moved. Within a managed
|
||||||
|
folder, any file that isn't an expected link is swept, so leftovers from a
|
||||||
|
renamed layout (e.g. an old unprefixed `Dle roku`) are cleaned up on the next
|
||||||
|
generation instead of lingering forever.
|
||||||
|
|
||||||
|
## 1.6.0 — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Filmotéka generation now also creates a **"Tipy dne"** folder with **10 random
|
||||||
|
pool movies** (hardlinks; copy-as-is folders like Seriály are excluded). The
|
||||||
|
folder is refreshed each run (`HardlinkManager.generate_random_tips`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Stale **root-level genre folders** were left in the Filmotéka output when the
|
||||||
|
last movie carrying a genre dropped that tag: obsolete-link cleanup only
|
||||||
|
scanned genre folders still present on current movies, so an emptied one was
|
||||||
|
never visited. Cleanup now also sweeps other root-level folders (excluding the
|
||||||
|
grouping roots and `reserved_subfolders` — copy-as-is mirrors, "Tipy dne") and
|
||||||
|
removes the now-empty folder. Grouping folders (Dle roku/…) were already fine.
|
||||||
|
|
||||||
|
## 1.5.0 — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Free-form per-movie attributes** (`attributes` in the index, set via *Filmy →
|
||||||
|
Nastavit atribut…* / context menu): arbitrary `key → value` pairs (e.g.
|
||||||
|
`collection_sort`) that are exposed to Filmotéka filename templates through
|
||||||
|
`File.name_context`. So a Kolekce template `"{collection_sort} - {title}{ext}"`
|
||||||
|
orders the folder as `01 - Dr. No.mkv`, `02 - …`. Empty value removes the attr.
|
||||||
|
- **Per-category filename template** in the tag schema (`filename_template`,
|
||||||
|
editable in *Nastavení → Tag schéma…*): the hardlink inside that category's
|
||||||
|
folders is named from the template (fields `title`/`year`/`rating`/`ext`/
|
||||||
|
`stem`/`filename`), e.g. a *Kolekce* with `"{year} - {title}{ext}"` yields
|
||||||
|
`Dle kolekce/James Bond/1962 - Dr. No.mkv` while every other folder (and the
|
||||||
|
pool) keeps the plain filename. `HardlinkManager` takes
|
||||||
|
`category_filename_templates`; `File.name_context()` supplies the fields.
|
||||||
|
- The "Přiřadit štítky" dialog now lists **all tag-schema categories** (including
|
||||||
|
empty user ones like *Kolekce*) and has a **"➕ Nový štítek…"** button to create
|
||||||
|
a tag value (e.g. *Kolekce/James Bond*) on the spot and assign it. Tag values
|
||||||
|
are created on first assignment — the schema editor defines categories + rules,
|
||||||
|
this dialog defines/assigns the individual values.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Tag schema `transform` now shapes only the **Filmotéka folder name**, not the
|
||||||
|
tag value. ČSFD tags keep the **exact value** (rating → `Hodnocení/90`) while
|
||||||
|
the grouping transform is applied when building folders (→ `Dle hodnocení/
|
||||||
|
90–100 %`). `csfd_field_values` no longer transforms; `HardlinkManager` takes a
|
||||||
|
`category_transforms` map (`FileManager.filmoteka_category_transforms`) and
|
||||||
|
`apply_transform`/`decade_band` run at folder-generation time.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Sidebar filter tree no longer re-expands every category on each check/uncheck:
|
||||||
|
the expanded/collapsed state is preserved across the rebuild (new categories
|
||||||
|
still default to expanded).
|
||||||
|
|
||||||
|
## 1.3.0 — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Configurable tag schema** (`tag_schema` in the global config, edited via
|
||||||
|
*Nastavení → Tag schéma…*): a single source of truth for which tag categories
|
||||||
|
exist, which ČSFD field feeds each (with an optional value transform, e.g.
|
||||||
|
`decade_band` for the rating), and how each maps into the Filmotéka tree
|
||||||
|
(`""` = output root, `"Dle …"` = grouping folder, none = filter-only, no
|
||||||
|
folders). Replaces the hard-coded `FILMOTEKA_CATEGORY_ROOTS` and the fixed
|
||||||
|
category list in `apply_csfd_tags`; `HardlinkManager` roots are now derived
|
||||||
|
from the schema (`FileManager.filmoteka_category_roots`).
|
||||||
|
- **Tag provenance (ČSFD vs user)**: each file tracks which tags came from ČSFD
|
||||||
|
(`csfd_tags` in the index). `apply_csfd_tags` now **regenerates only the ČSFD
|
||||||
|
tags** from the schema and leaves user-added tags untouched — so changing a
|
||||||
|
movie's ČSFD link refreshes its ČSFD tags without wiping your own.
|
||||||
|
|
||||||
|
## 1.2.0 — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Fork of the former **Tagger** project as **Curator**, a movie-library manager.
|
||||||
|
- **Pool** concept (single source of truth) with `Filmy` / `Seriály` folders and
|
||||||
|
a configurable **Filmotéka** output folder, stored in the global config.
|
||||||
|
- **Multi-file "Import movies" flow**: pick several videos at once and give each
|
||||||
|
its own Title + ČSFD link (one row per file, more addable in the dialog); a
|
||||||
|
copy/move toggle chooses whether sources are copied (default, non-destructive)
|
||||||
|
or moved into `pool/Filmy` as `Title.ext`. Imported movies are indexed and, if
|
||||||
|
a ČSFD link is set, enriched with tags right away.
|
||||||
|
- **Auto-find ČSFD links** in the import dialog ("🔎 Najít ČSFD odkazy"): for
|
||||||
|
every row without a link it cleans the filename into a query
|
||||||
|
(`clean_filename_to_query` strips resolution/codec/source/group, keeps the
|
||||||
|
year) and fills in the first ČSFD search hit (`find_csfd_url` →
|
||||||
|
`search_movies`, reusing one Anubis session). Existing links are never
|
||||||
|
overwritten; results are a suggestion the user can review before importing.
|
||||||
|
- Import dialog now **highlights in red any title that already exists in the
|
||||||
|
pool** (live as you type) and lets you **remove a single row** (✕ per row), so
|
||||||
|
you can drop one file without discarding the whole batch and re-picking.
|
||||||
|
- **Conflict resolution on import**: when a row is left red (name already in the
|
||||||
|
pool), import asks whether to **replace** the existing movie(s) with the new
|
||||||
|
file, **keep both** (numeric suffix), or **cancel**. `FileManager.import_movie`
|
||||||
|
gained an `on_conflict` policy (`suffix` / `replace` / `skip`); `replace`
|
||||||
|
evicts the existing same-named movie (file + index metadata) first.
|
||||||
|
- `File` now stores `title` and `csfd_link`.
|
||||||
|
- New **PySide6** GUI reframed around the Filmotéka workflow (pool setup, import,
|
||||||
|
tag filter sidebar, movie table, one-click Filmotéka generation), replacing the
|
||||||
|
tkinter GUI as the entry point.
|
||||||
|
- Seriály **copy-as-is mirror**: `pool/Seriály` is cloned 1:1 into the Filmotéka
|
||||||
|
output as hardlinks (`HardlinkManager.mirror_as_is`), generated alongside the
|
||||||
|
movie tree.
|
||||||
|
- **Unified pool metadata index** (`pool_index.py`): the whole pool's metadata
|
||||||
|
lives in a single `<pool>/.Curator.!index` JSON keyed by pool-relative path.
|
||||||
|
`File` reads/writes the index when one is injected and otherwise keeps using
|
||||||
|
per-file `.!tag` sidecars; `FileManager` uses the index for the pool.
|
||||||
|
- **Configurable copy-as-is folders**: `copyasis_folders` in the global config
|
||||||
|
(editable from the GUI) lists pool subfolders mirrored 1:1 during generation;
|
||||||
|
`Seriály` is the default. Generalizes the previously hardcoded Seriály mirror.
|
||||||
|
- Project `README.md` (overview, concepts, workflow, run/build instructions).
|
||||||
|
- **ČSFD scraping** (`csfd.py`, ported from the Tagger devel branch): fetches
|
||||||
|
movie data from a ČSFD link (JSON-LD + HTML parsing). `File.apply_csfd_tags`
|
||||||
|
assigns **Žánr / Rok / Země původu / Hodnocení** tags and caches the fetched
|
||||||
|
data (incl. directors and the first 10 actors) in the metadata. The rating is
|
||||||
|
bucketed into ten-point bands (`rating_band`, e.g. `80–89 %`, `90–100 %`).
|
||||||
|
**Directors and actors are collected but intentionally not turned into tags or
|
||||||
|
Filmotéka folders** — there would be far too many. The GUI auto-fetches on
|
||||||
|
import when a link is given and offers "Načíst tagy z ČSFD" for selected movies.
|
||||||
|
- **Rename a pooled movie** from the app ("Přejmenovat…" in the Movie menu /
|
||||||
|
context menu, F2): `FileManager.rename_movie` renames the physical file in
|
||||||
|
pool/Filmy to `<new name>.<ext>` (extension preserved), moves its metadata to
|
||||||
|
the new index key, and syncs `title`/`filename`. Refuses empty names, names
|
||||||
|
with path separators, and collisions with an existing pooled file.
|
||||||
|
- App startup injects `truststore` so HTTPS uses the OS certificate store —
|
||||||
|
ČSFD fetching works behind corporate SSL inspection (where certifi's bundle
|
||||||
|
lacks the proxy root CA).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `media_utils.add_video_resolution_tag` referenced `subprocess` without importing it.
|
||||||
|
- ČSFD parsing updated for the current site HTML: year is read from JSON-LD
|
||||||
|
`dateCreated`, and the origin line (now bullet-separated, no commas) is
|
||||||
|
tokenized so country / year / duration are extracted correctly.
|
||||||
|
- **ČSFD anti-bot wall (Anubis):** ČSFD now serves a proof-of-work challenge
|
||||||
|
page instead of the movie, so fetches returned a film with no genres/year
|
||||||
|
("načteno 0 tagů"). `csfd.py` now detects the Anubis challenge, solves the
|
||||||
|
SHA-256 proof-of-work the way the bundled worker JS does, and replays the
|
||||||
|
request through a `requests.Session` (reused across a batch so only the first
|
||||||
|
fetch pays the PoW cost). Žánr / Rok / Země původu tags load again.
|
||||||
|
- "Assign tags" dialog crashed on PySide6/Qt6 — `Qt.ItemIsTristate` was renamed
|
||||||
|
to `Qt.ItemIsAutoTristate`.
|
||||||
|
- Sidebar tag-filter checkboxes never appeared checked: every toggle triggered a
|
||||||
|
table refresh that rebuilt the tree from scratch (all unchecked), wiping the
|
||||||
|
click. The active filter is now kept in a separate model (`_active_filter`) and
|
||||||
|
restored on rebuild. The count after each tag is also now filter-aware — it
|
||||||
|
shows how many of the currently filtered movies carry that tag (i.e. how many
|
||||||
|
would remain if it were checked), instead of always the pool-wide total. The
|
||||||
|
refresh is deferred via `QTimer.singleShot` so the tree is not rebuilt inside
|
||||||
|
its own `itemChanged` signal (which deleted the item Qt was still processing
|
||||||
|
and crashed the app with SIGSEGV on a real click).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- ČSFD country tag category renamed **Země → Země původu**. Added
|
||||||
|
`scripts/migrate_tag_category.py` to rewrite the category in an existing pool
|
||||||
|
index (backs up `.Curator.!index` first); run against the live pool.
|
||||||
|
- Filmotéka tree **relaid out**: genre folders now sit **directly at the output
|
||||||
|
root** (next to the copy-as-is Seriály mirror), with year tags grouped under a
|
||||||
|
**`Dle roku`** folder and country tags under **`Dle země původu`**.
|
||||||
|
`HardlinkManager` gained a category → root-folder map (`category_roots`,
|
||||||
|
empty root = tag folders at the output root) and now restricts obsolete-link
|
||||||
|
cleanup to the tag-tree's own top-level folders, so copy-as-is mirrors are
|
||||||
|
never touched. The tree also groups the ČSFD rating under `Dle hodnocení`.
|
||||||
|
- ČSFD origin is now parsed as **multiple countries**: a co-production like
|
||||||
|
"USA / Velká Británie" becomes a separate **Země původu** tag per country
|
||||||
|
(so the film is filed under each), instead of one combined tag. `CSFDMovie`
|
||||||
|
gained `countries: list[str]` (replacing the single `country`); the csfd cache
|
||||||
|
schema bumped to v2 (legacy single-country caches are split on read).
|
||||||
|
- Movie table trimmed to **Název / Štítky / Velikost** — the Datum and ČSFD
|
||||||
|
columns were dropped (a ČSFD link is a prerequisite, so its indicator was
|
||||||
|
always the same).
|
||||||
|
- All references to "Tagger" renamed to "Curator" (code, spec, config filenames
|
||||||
|
`.Curator.!gtag` / `.Curator.!ftag`, tests).
|
||||||
|
- `requires-python` narrowed to `>=3.14,<3.15` (PySide6 compatibility).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Duplicate `src/core/constants.py` (hard-coded stale `VERSION = "v1.0.3"`). The
|
||||||
|
GUI window title imported from it, so it showed the wrong version. All imports
|
||||||
|
now use `src/constants.py` (version derived from `pyproject.toml`), which also
|
||||||
|
absorbed `APP_VIEWPORT`; the window title uses `APP_TITLE` → "Curator vX.Y.Z".
|
||||||
|
- Legacy Tagger predefined tags: the always-available **Hodnocení** (⭐ rating)
|
||||||
|
and **Barva** (color) categories in `TagManager`, and the automatic
|
||||||
|
**Stav/Nové** tag assigned to every newly imported file. `DEFAULT_TAGS` is now
|
||||||
|
empty; the pool is driven by ČSFD-derived tags (Žánr / Rok / Země původu).
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- Added `pyside6` (GUI), `requests` + `beautifulsoup4` (ČSFD scraping),
|
||||||
|
`truststore` (OS cert store for HTTPS). Declared `python-dotenv`, `pillow`,
|
||||||
|
`loguru` (already imported by the inherited code).
|
||||||
Generated
+6161
File diff suppressed because it is too large
Load Diff
+44
@@ -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"
|
||||||
@@ -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`.
|
||||||
+478
@@ -0,0 +1,478 @@
|
|||||||
|
# PROJECT.md
|
||||||
|
|
||||||
|
This file is project-specific. Only include information directly related to the concrete project — goals, current state, architecture decisions, known issues, and tasks.
|
||||||
|
|
||||||
|
## Origin
|
||||||
|
|
||||||
|
Curator is a fork of the former **Tagger** project. The tagging, filtering and
|
||||||
|
hardlink-tree parts are inherited and keep working as before. On top of that,
|
||||||
|
Curator becomes a full **movie library manager (Filmotéka)**.
|
||||||
|
|
||||||
|
## Core idea
|
||||||
|
|
||||||
|
Curator manages a personal movie library based on two folders:
|
||||||
|
|
||||||
|
- **Pool** — the managed repository of video files. This is the **single source
|
||||||
|
of truth**. Curator manages the pool itself (insert/remove file), so files are
|
||||||
|
never moved by hand. The pool has exactly two top-level folders: **Filmy**
|
||||||
|
(movies — tag-based tree) and **Seriály** (series — a "copy-as-is" folder
|
||||||
|
mirrored 1:1 into the output; see Design decisions). Every file lives here
|
||||||
|
exactly once.
|
||||||
|
- **Filmotéka (output)** — a generated, browsable directory tree made only of
|
||||||
|
**hardlinks** into the pool (the same mechanism as today's hardlink manager).
|
||||||
|
It is fully disposable: deleting the Filmotéka folder loses nothing, because
|
||||||
|
it can always be regenerated from the pool.
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. The user configures two folders: the **pool** and the **Filmotéka output**.
|
||||||
|
2. The user picks a video file via "Open file".
|
||||||
|
3. Curator opens a dialog to fill in basic info — at minimum the **title/name**
|
||||||
|
and a **ČSFD link**.
|
||||||
|
4. Curator **renames** the file and **moves** it into the managed pool, and
|
||||||
|
writes a **metadata file** describing it.
|
||||||
|
5. From the pool, Curator **generates the Filmotéka** — a complex tree of
|
||||||
|
hardlinks built from each file's tags/metadata (like the current hardlink
|
||||||
|
manager, but driven by the pool).
|
||||||
|
6. Deleting the Filmotéka has no effect on the pool; the tree is regenerated on
|
||||||
|
demand.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- **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 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
|
||||||
|
that describes a specific library — `tag_schema` and `copyasis_folders` — lives
|
||||||
|
in the pool index under a `settings` section, so it travels with the pool.
|
||||||
|
`FileManager` migrates any pre-existing global values into the index on first
|
||||||
|
open. The old per-folder `.!ftag` config and per-file `.!tag` sidecars were
|
||||||
|
removed (they were unused Tagger leftovers), and the orphaned tkinter
|
||||||
|
`src/ui/gui.py` went with them.
|
||||||
|
- **Import dialog:** **multi-file** — pick several videos at once and give each
|
||||||
|
its own **Title** + **ČSFD link** (one row per file, more can be added from the
|
||||||
|
dialog), or auto-filled with **"Najít ČSFD odkazy"** (cleans each filename into
|
||||||
|
a query and fills the first ČSFD search hit; existing links are kept). A single
|
||||||
|
**copy/move** toggle decides whether the sources are copied (default) or moved
|
||||||
|
into the pool. Each file is renamed to `Title.ext`. When a
|
||||||
|
ČSFD link is given, Curator fetches the movie and assigns Žánr / Rok / Země
|
||||||
|
původu / Hodnocení (ten-point band) tags automatically; further tags can be
|
||||||
|
added via the UI. Directors and the first 10 actors are fetched and cached too,
|
||||||
|
but **deliberately not turned into tags/folders** (there would be too many).
|
||||||
|
- **Genres / countries:** a movie can have **multiple genres** and, for a
|
||||||
|
co-production, **multiple countries of origin** (ČSFD writes them
|
||||||
|
slash-separated, e.g. "USA / Velká Británie"). Each becomes its own tag, so the
|
||||||
|
film appears under every matching genre and country branch in the Filmotéka
|
||||||
|
(multiple hardlinks).
|
||||||
|
- **Pool layout:** two top-level folders — **Filmy** and **Seriály**. Movies are
|
||||||
|
the first target; the Seriály branch follows the "copy-as-is" rule below.
|
||||||
|
- **Copy-as-is folders (Seriály):** a subfolder inside the pool can be marked as
|
||||||
|
**copy / as-is**. For such a folder Curator does **not** build a tag-based tree;
|
||||||
|
instead it **mirrors the exact directory hierarchy** from the pool into the
|
||||||
|
Filmotéka output, with the files materialized as **hardlinks** into the pool.
|
||||||
|
So `pool/Seriály/...` is cloned 1:1 into `output/Seriály/...` (same structure,
|
||||||
|
hardlinked files). This is how Seriály work.
|
||||||
|
- **File naming:** pooled movies follow the convention **`Title (YYYY).ext`** — a
|
||||||
|
clean title plus a four-digit year in parentheses (`naming.py`:
|
||||||
|
`canonical_pool_stem` / `parse_pool_stem`). The year lets two same-named films
|
||||||
|
coexist and makes the filename self-describing (`File.name_context` recovers
|
||||||
|
the year from the filename when no ČSFD/tag year is present). The GUI **import
|
||||||
|
requires a four-digit year** (per-row *Rok* field, pre-filled from the source
|
||||||
|
filename and fillable from ČSFD — a ČSFD-suggested year is tinted blue to be
|
||||||
|
verified, a name colliding with the pool is tinted red). *Filmy → "Přejmenovat
|
||||||
|
dle ČSFD (rok)…"* (`FileManager.rename_all_to_canonical`) brings older
|
||||||
|
`Title.ext` files up to the convention from their ČSFD metadata; files without
|
||||||
|
a known year are skipped. `File.title` always stays the clean title (no year).
|
||||||
|
- **Import copy vs move:** by default the original file is **copied** into the
|
||||||
|
pool (non-destructive); the import dialog also offers a **move** option that
|
||||||
|
relocates the source into the pool instead.
|
||||||
|
- **Filmotéka tree layout:** driven by a category → root-folder map
|
||||||
|
(`FILMOTEKA_CATEGORY_ROOTS`). At the output root sit the **genre folders
|
||||||
|
directly** (`output/Akční/film`, …), next to the copy-as-is mirrors
|
||||||
|
(**Seriály**), plus two grouping folders: **`Dle roku`** (`output/Dle
|
||||||
|
roku/<rok>/film`) and **`Dle země původu`** (`output/Dle země
|
||||||
|
původu/<země>/film`), plus `Dle hodnocení`. Each is a hardlink.
|
||||||
|
`HardlinkManager` supports an empty root (tag folders placed directly at the
|
||||||
|
output root) and restricts obsolete cleanup to the tag-tree's own top-level
|
||||||
|
folders so mirrors are never touched.
|
||||||
|
- **Tag schema (config-driven, not hard-coded):** the categories, their ČSFD
|
||||||
|
source field + transform, and their Filmotéka folder mapping all live in
|
||||||
|
`tag_schema` in the global config (default `config.DEFAULT_TAG_SCHEMA`, edited
|
||||||
|
via *Nastavení → Tag schéma…*). Both `apply_csfd_tags` (which fields → tags)
|
||||||
|
and the Filmotéka layout (`FileManager.filmoteka_category_roots`) read from it,
|
||||||
|
so adding a category or changing a folder rule needs no code change. A category
|
||||||
|
can be made filter-only (no folders) by setting its `filmoteka_root` to null.
|
||||||
|
The `transform` (e.g. `decade_band`) shapes only the **folder name** — tags keep
|
||||||
|
the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90–100 %`);
|
||||||
|
it is applied at Filmotéka generation via `filmoteka_category_transforms`.
|
||||||
|
- **Filmotéka link naming (clean title, year on collision):** the default
|
||||||
|
hardlink name in the tag tree is the movie's **clean title** — `Title.ext`,
|
||||||
|
*not* the pooled `Title (YYYY).ext`. When two different films would collide on
|
||||||
|
that name **within one folder**, the colliding ones keep the disambiguating
|
||||||
|
**year** (`Title (YYYY).ext`); a residual same-title-and-year clash gets a
|
||||||
|
stable numeric suffix. `HardlinkManager._plan_links` resolves the whole tag
|
||||||
|
tree to a collision-free `(file, path)` plan that **both** link creation and
|
||||||
|
obsolete-detection consume, so they can never disagree on a name (the previous
|
||||||
|
churn source). Copy-as-is mirrors (Seriály) are unaffected — they mirror 1:1.
|
||||||
|
- **Per-category filename template** (`filename_template` in a schema entry):
|
||||||
|
overrides the default naming **inside that category's folders only**, rendered
|
||||||
|
from the movie's metadata (`File.name_context`: title/year/rating/ext/stem/
|
||||||
|
filename plus any free-form attributes), e.g. a Kolekce with
|
||||||
|
`"{collection_sort} - {title}{ext}"`. Applied via
|
||||||
|
`filmoteka_category_filename_templates`.
|
||||||
|
- **Free-form per-movie attributes** (`File.attributes`, set in the GUI): arbitrary
|
||||||
|
`key → value` metadata stored in the index and merged into `name_context`, so
|
||||||
|
custom fields like `collection_sort` can drive filename templates.
|
||||||
|
- **Tag provenance (ČSFD vs user):** each file records which tags came from ČSFD
|
||||||
|
(`csfd_tags`). Re-fetching regenerates only those; user-added tags are kept, so
|
||||||
|
changing a movie's ČSFD link refreshes ČSFD tags without losing manual ones.
|
||||||
|
- **Special folders + "- " prefix:** besides the tag tree, generation produces
|
||||||
|
`- Tipy dne` (15 random) and `- Nově přidané` (10 newest by `File.added`, mtime
|
||||||
|
fallback — ctime is bumped by hardlinking). All grouping folders and these
|
||||||
|
specials are prefixed with **"- "** so
|
||||||
|
DLNA/TV browsers sort them before the genre folders at the output root. Specials
|
||||||
|
are `reserved_subfolders` so cleanup never touches them; renaming old unprefixed
|
||||||
|
folders is handled automatically (they become stale root-level folders and are
|
||||||
|
swept on the next generation).
|
||||||
|
- **Date added:** `import_movie` stamps `File.added` (ISO); older items without it
|
||||||
|
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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- Video integrity check (`integrity.py`, Testy → "Kontrola integrity videa"):
|
||||||
|
FFmpeg-based scan for corrupted/unreadable video data, deep (full decode) or
|
||||||
|
quick (ffprobe), over selected movies or the whole pool
|
||||||
|
- Pool-root and Filmotéka-output folder settings in the global config
|
||||||
|
- Filmy / Seriály top-level folder handling in the pool
|
||||||
|
- "Import movie" dialog (Title + ČSFD link), copy into pool/Filmy as Title.ext
|
||||||
|
- Rename a pooled movie from the app (`FileManager.rename_movie`): renames the
|
||||||
|
file in pool/Filmy and moves its metadata to the new index key
|
||||||
|
- Remove-from-pool (delete file + its metadata)
|
||||||
|
- Generate the Filmotéka hardlink tree from the pool (Rok / Žánr / Země původu /
|
||||||
|
Hodnocení)
|
||||||
|
- Filmotéka fully regenerable from the pool alone (delete output = no loss)
|
||||||
|
- GUI reframed around the Filmotéka and rewritten in PySide6
|
||||||
|
- Seriály "copy-as-is" mirror: pool/Seriály cloned 1:1 into the output as
|
||||||
|
hardlinks (`HardlinkManager.mirror_as_is`), wired into Filmotéka generation
|
||||||
|
- Fixed `media_utils` missing `subprocess` import
|
||||||
|
- Unified pool metadata index (`pool_index.py`): one `.Curator.!index` per pool;
|
||||||
|
`File` reads/writes it when injected, `FileManager` uses it for the pool
|
||||||
|
- Configurable copy-as-is folders (`copyasis_folders` in global config, editable
|
||||||
|
from the GUI); each is mirrored 1:1 during Filmotéka generation (Seriály default)
|
||||||
|
- README.md written (overview, concepts, workflow, run/build instructions)
|
||||||
|
- ČSFD scraping (`csfd.py`, ported from Tagger devel): `File.apply_csfd_tags`
|
||||||
|
fetches a movie and assigns Žánr / Rok / Země původu tags (cached in metadata); wired
|
||||||
|
into the GUI (auto-fetch on import with a ČSFD link, plus "Načíst tagy z ČSFD").
|
||||||
|
Parsing updated for current ČSFD HTML and verified live against Matrix
|
||||||
|
(film/9499); HTTPS uses the OS cert store via `truststore` (corporate SSL)
|
||||||
|
- ČSFD Anubis anti-bot wall handled: `csfd.py` detects the proof-of-work
|
||||||
|
challenge page, solves it (SHA-256 PoW matching the bundled worker JS) and
|
||||||
|
replays via a shared `requests.Session`, so Žánr / Rok / Země původu tags load again
|
||||||
|
(the "nalezeno 1 film, načteno 0 tagů" symptom). Verified live (Matrix 1999)
|
||||||
|
- Removed the inherited Tagger predefined tags: `DEFAULT_TAGS` is now empty
|
||||||
|
(no Hodnocení ⭐ / Barva categories) and new files no longer get an automatic
|
||||||
|
`Stav/Nové` tag. Tags now come from ČSFD (Žánr / Rok / Země původu) and manual edits.
|
||||||
|
Note: `Hodnocení` is still listed in `FILMOTEKA_CATEGORIES`, so that branch is
|
||||||
|
simply empty until something assigns a Hodnocení tag again
|
||||||
|
- Fixed template cruft: `src/constants.py` made consistent (Curator values,
|
||||||
|
`get_version`/`get_debug_mode` API) and `test_constants.py` aligned; removed
|
||||||
|
the imported `tagger/` devel dump
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Curator
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 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:
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
- **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
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt # format
|
||||||
|
cargo clippy -- -D warnings # lint
|
||||||
|
cargo test # unit tests + headless UI tests
|
||||||
|
cargo doc --open # API reference
|
||||||
|
```
|
||||||
|
|
||||||
|
`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
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Restreams the Makov webcam (YouTube live "Krmítko") into a local HLS
|
||||||
|
# playlist that never goes stale for the viewer — Jellyfin always points at
|
||||||
|
# this fixed local path, not at YouTube's own signed/expiring URL.
|
||||||
|
#
|
||||||
|
# When ffmpeg dies (the YouTube URL expired, network hiccup, stream briefly
|
||||||
|
# ended, ...) this script simply exits; systemd (Restart=always) starts it
|
||||||
|
# again, which resolves a *fresh* URL via yt-dlp. Nothing needs to be done
|
||||||
|
# by hand.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
YOUTUBE_URL="https://www.youtube.com/watch?v=F0fsV1GGZ3M"
|
||||||
|
OUT_DIR="/var/lib/jellyfin-iptv/hls"
|
||||||
|
OUT_FILE="${OUT_DIR}/makov.m3u8"
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
# This live stream only serves separate video-only/audio-only HLS renditions
|
||||||
|
# (no combined progressive format), so we resolve both and let ffmpeg mux
|
||||||
|
# them. "bestvideo+bestaudio" makes yt-dlp -g print exactly two URLs, video
|
||||||
|
# then audio; fall back to "best" in case a progressive format reappears.
|
||||||
|
mapfile -t STREAM_URLS < <(yt-dlp -g -f 'bestvideo+bestaudio/best' "$YOUTUBE_URL")
|
||||||
|
VIDEO_URL="${STREAM_URLS[0]:-}"
|
||||||
|
AUDIO_URL="${STREAM_URLS[1]:-}"
|
||||||
|
|
||||||
|
if [ -z "$VIDEO_URL" ]; then
|
||||||
|
echo "yt-dlp nevrátilo žádnou URL (stream offline, nebo se YouTube brání) — končím, systemd to za chvíli zkusí znovu." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$AUDIO_URL" ]; then
|
||||||
|
# Progressive fallback matched: single URL carries both video and audio.
|
||||||
|
exec ffmpeg -nostdin -loglevel warning \
|
||||||
|
-re -i "$VIDEO_URL" \
|
||||||
|
-c copy \
|
||||||
|
-f hls -hls_time 4 -hls_list_size 6 \
|
||||||
|
-hls_flags delete_segments+omit_endlist \
|
||||||
|
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
|
||||||
|
"$OUT_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec ffmpeg -nostdin -loglevel warning \
|
||||||
|
-re -i "$VIDEO_URL" -re -i "$AUDIO_URL" \
|
||||||
|
-map 0:v -map 1:a \
|
||||||
|
-c copy \
|
||||||
|
-f hls -hls_time 4 -hls_list_size 6 \
|
||||||
|
-hls_flags delete_segments+omit_endlist \
|
||||||
|
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
|
||||||
|
"$OUT_FILE"
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Makov webkamera → Jellyfin (Live TV)
|
||||||
|
|
||||||
|
Restreamuje YouTube live přenos ("Krmítko", https://www.youtube.com/watch?v=F0fsV1GGZ3M)
|
||||||
|
do lokálního HLS, na který se Jellyfin dívá přes stabilní adresu. Když YouTube
|
||||||
|
odkaz vyprší nebo spadne, systemd proces sám restartuje a natáhne novou URL —
|
||||||
|
divák/Jellyfin si ničeho nevšimne.
|
||||||
|
|
||||||
|
## 0) Kam skript patří
|
||||||
|
|
||||||
|
Toto **musí běžet na stejném stroji (resp. za stejnou veřejnou IP), jako
|
||||||
|
Jellyfin** — YouTube vrací streamovací URL svázanou s IP adresou, ze které
|
||||||
|
byla vyžádána (`yt-dlp -g`). Pokud poběží jinde než Jellyfin, `ffmpeg` dostane
|
||||||
|
403 a nikdy se nerozjede.
|
||||||
|
|
||||||
|
## 1) Závislosti
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y ffmpeg python3
|
||||||
|
sudo pip install -U yt-dlp # nebo pipx install yt-dlp — apt verze bývá stará
|
||||||
|
# a YouTube ji rád rozbije
|
||||||
|
```
|
||||||
|
|
||||||
|
Ověř, že `yt-dlp` a `ffmpeg` jedou z shellu bez chyby:
|
||||||
|
```bash
|
||||||
|
yt-dlp --version
|
||||||
|
ffmpeg -version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Uživatel a oprávnění
|
||||||
|
|
||||||
|
Vlastní, neprivilegovaný systémový účet — obě služby pod ním běží:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system --no-create-home --shell /usr/sbin/nologin makov-cam
|
||||||
|
sudo mkdir -p /var/lib/jellyfin-iptv/hls
|
||||||
|
sudo chown -R makov-cam:makov-cam /var/lib/jellyfin-iptv
|
||||||
|
```
|
||||||
|
|
||||||
|
Pokud je `yt-dlp` nainstalované přes **snap** (`which yt-dlp` ukazuje
|
||||||
|
`/snap/bin/yt-dlp`), potřebuje při startu zapisovatelný domovský adresář —
|
||||||
|
a to i přes `--no-create-home`, protože domov si zjišťuje přímo z
|
||||||
|
`/etc/passwd`, ne z proměnné `$HOME` (tu přenastavenou v service souboru
|
||||||
|
ignoruje). Bez toho skončí na `Permission denied: mkdir /home/makov-cam`.
|
||||||
|
Vytvoř mu ho ručně:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /home/makov-cam
|
||||||
|
sudo chown makov-cam:makov-cam /home/makov-cam
|
||||||
|
sudo chmod 750 /home/makov-cam
|
||||||
|
```
|
||||||
|
|
||||||
|
(Pokud `yt-dlp` instaluješ přes `pip`/`pipx` dle kroku 1, tenhle problém
|
||||||
|
nenastává a krok lze přeskočit.)
|
||||||
|
|
||||||
|
## 3) Nasazení souborů
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 755 makov-cam.sh /usr/local/bin/makov-cam.sh
|
||||||
|
sudo install -m 644 makov-cam.service /etc/systemd/system/
|
||||||
|
sudo install -m 644 makov-cam-http.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) Spuštění
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now makov-cam.service
|
||||||
|
sudo systemctl enable --now makov-cam-http.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Ověření:
|
||||||
|
```bash
|
||||||
|
systemctl status makov-cam.service makov-cam-http.service
|
||||||
|
journalctl -u makov-cam.service -f # živě sleduj log ffmpeg/yt-dlp
|
||||||
|
|
||||||
|
# do pár vteřin by se měl objevit playlist a segmenty:
|
||||||
|
ls -la /var/lib/jellyfin-iptv/hls/
|
||||||
|
|
||||||
|
# a mělo by jít stáhnout přes HTTP:
|
||||||
|
curl -sI http://127.0.0.1:8888/hls/makov.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
`makov-cam-http.service` je svázaná (`BindsTo=`) na `makov-cam.service` — když
|
||||||
|
restream spadne/restartuje, HTTP server padne/naskočí s ním, ať nikdy
|
||||||
|
neservíruje mrtvý/prázdný playlist. Pozor: `BindsTo=` jen zajišťuje, že HTTP
|
||||||
|
server *spadne*, když spadne hlavní služba — nezajišťuje, že se s ní i sám
|
||||||
|
*nastartuje*. Po `enable --now` obou služeb to není problém (obě se spustí),
|
||||||
|
ale pokud jen restartuješ `makov-cam.service` ručně, zkontroluj/restartuj i
|
||||||
|
`makov-cam-http.service`.
|
||||||
|
|
||||||
|
HTTP server poslouchá jen na jedné IP (`--bind`), ne na `0.0.0.0` — nemá cenu
|
||||||
|
ho vystavovat celé síti, stačí odkud se na něj bude dívat Jellyfin:
|
||||||
|
|
||||||
|
- **Jellyfin běží přímo na hostu** (ne v kontejneru): `--bind 127.0.0.1`
|
||||||
|
stačí.
|
||||||
|
- **Jellyfin běží v Docker kontejneru na tomtéž hostu** (běžný případ) —
|
||||||
|
`127.0.0.1` uvnitř kontejneru je jeho *vlastní* loopback, ne hostitelův,
|
||||||
|
takže na hostitelův HTTP server se tak nedostane. Zjisti gateway IP té
|
||||||
|
docker sítě, na které kontejner běží, a naviaž se na ni:
|
||||||
|
```bash
|
||||||
|
docker inspect <jellyfin-container> --format '{{.HostConfig.NetworkMode}}'
|
||||||
|
docker inspect <jellyfin-container> --format '{{json .NetworkSettings.Networks}}'
|
||||||
|
# z výstupu vezmi "Gateway", např. 172.19.0.1 — tam se z kontejneru dostaneš
|
||||||
|
```
|
||||||
|
a použij `--bind <ta-gateway-IP>` (viz `makov-cam-http.service`). Pokud by
|
||||||
|
Jellyfin běžel v jiné VM, změň `--bind` na IP, na které je pro ni
|
||||||
|
dosažitelný, a případně otevři firewall jen pro tenhle port a jen z její
|
||||||
|
IP.
|
||||||
|
|
||||||
|
## 5) Playlist pro Jellyfin
|
||||||
|
|
||||||
|
`WorkingDirectory` HTTP služby je `/var/lib/jellyfin-iptv` (rodič `hls/`), aby
|
||||||
|
šel přes stejný server servírovat i samotný `.m3u` playlist — to se hodí,
|
||||||
|
pokud Jellyfin (typicky v kontejneru) nevidí souborový systém hostitele a
|
||||||
|
tuner tak musí načíst playlist přes HTTP URL, ne lokální cestou.
|
||||||
|
|
||||||
|
Vytvoř `/var/lib/jellyfin-iptv/makov.m3u`:
|
||||||
|
|
||||||
|
```
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:-1 tvg-id="makov-krmitko" tvg-name="Makov - Krmítko",Makov - Krmítko
|
||||||
|
http://<BIND_IP>:8888/hls/makov.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
kde `<BIND_IP>` je stejná adresa, na kterou je navázaný `makov-cam-http.service`
|
||||||
|
(viz krok 4).
|
||||||
|
|
||||||
|
## 6) Nastavení v Jellyfinu
|
||||||
|
|
||||||
|
1. **Dashboard → Live TV → Tuner Devices → +** (Add)
|
||||||
|
2. Typ: **M3U Tuner**
|
||||||
|
3. File or URL:
|
||||||
|
- Jellyfin na hostu s přístupem k souborovému systému: lokální cesta
|
||||||
|
`/var/lib/jellyfin-iptv/makov.m3u`.
|
||||||
|
- Jellyfin v kontejneru (nevidí hostitelův FS): HTTP URL
|
||||||
|
`http://<BIND_IP>:8888/makov.m3u` (viz krok 5).
|
||||||
|
4. **Save** → Jellyfin načte kanál "Makov - Krmítko" do Live TV
|
||||||
|
5. Doporučeno: **Dashboard → Live TV → Guide Providers** — pro tenhle kanál
|
||||||
|
žádného není třeba (žádný EPG), Jellyfin ho ukáže jako kanál bez programu,
|
||||||
|
což nevadí.
|
||||||
|
6. Zkontroluj přehrávání v **Live TV** sekci hlavního rozhraní.
|
||||||
|
|
||||||
|
Protože kanál pořád ukazuje na stejnou lokální adresu, odpadá starost o
|
||||||
|
obnovování guide dat nebo expiraci URL na Jellyfin straně — o to se stará jen
|
||||||
|
`makov-cam.service` na pozadí.
|
||||||
|
|
||||||
|
## 7) Autostart po restartu serveru
|
||||||
|
|
||||||
|
`enable` v kroku 4 už zajistil start při bootu. Rychlá kontrola:
|
||||||
|
```bash
|
||||||
|
systemctl is-enabled makov-cam.service makov-cam-http.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Řešení problémů
|
||||||
|
|
||||||
|
- **`ffmpeg` pořád padá / v logu 403 od googlevideo.com** — nejčastěji IP
|
||||||
|
mismatch (viz bod 0): stroj, co spouští `yt-dlp -g`/`ffmpeg`, musí mít
|
||||||
|
stejnou veřejnou IP jako to, odkud by se stream normálně přehrával.
|
||||||
|
Zkontroluj `journalctl -u makov-cam.service -n 50`.
|
||||||
|
- **`yt-dlp: command not found` v service logu** — systemd nepoužívá tvůj
|
||||||
|
interaktivní `$PATH`; ověř `which yt-dlp` a případně uprav `ExecStart` v
|
||||||
|
`makov-cam.sh`/skriptu na plnou cestu (`/usr/local/bin/yt-dlp` apod.), nebo
|
||||||
|
nainstaluj přes `sudo pip install`, aby šel do `/usr/local/bin`.
|
||||||
|
- **Playlist v Jellyfinu jede, ale sekundy/minuty výpadku při refreshi** —
|
||||||
|
normální, `hls_list_size 6` × `hls_time 4s` drží ~24s okno; při restartu
|
||||||
|
streamu je pár vteřin díra, než naběhnou nové segmenty. Jde zkrátit/prodloužit
|
||||||
|
úpravou těch dvou hodnot ve skriptu.
|
||||||
|
- **YouTube někdy dočasně rozbije `yt-dlp` novou verzí přehrávače** — drž
|
||||||
|
`yt-dlp` aktuální (`sudo pip install -U yt-dlp`, klidně jako týdenní cron).
|
||||||
|
- **`ERROR: Requested format is not available`** — stream v tu chvíli nenabízí
|
||||||
|
žádný kombinovaný (progresivní) formát, jen oddělené video-only/audio-only
|
||||||
|
HLS řady (ověř přes `yt-dlp -F <url>`). Skript proto primárně používá
|
||||||
|
`-f 'bestvideo+bestaudio/best'` a při dvou vrácených URL pustí `ffmpeg` se
|
||||||
|
dvěma `-i` vstupy (`-map 0:v -map 1:a`); jedna URL = fallback na progresivní
|
||||||
|
formát, pokud se zase objeví.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Generate the "Tipy dne" Filmoteka folder
|
||||||
|
After=local-fs.target remote-fs.target
|
||||||
|
RequiresMountsFor=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=honza
|
||||||
|
Group=honza
|
||||||
|
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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily regeneration of the "Tipy dne" Filmoteka folder
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Kazdy den o pulnoci (mistni cas serveru = CEST/CET)
|
||||||
|
OnCalendar=*-*-* 00:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}")
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+311
@@ -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")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Curator — a Jellyfin-first movie and series library manager.
|
||||||
|
//!
|
||||||
|
//! `core` holds the domain model and every operation on the pool; `ui` is the
|
||||||
|
//! egui front end that only ever calls into `core`.
|
||||||
|
|
||||||
|
pub mod constants;
|
||||||
|
pub mod core;
|
||||||
|
pub mod ui;
|
||||||
+21
@@ -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}"))
|
||||||
|
}
|
||||||
+2001
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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())))),
|
||||||
|
)
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
//! Long-running operations that need the `FileManager` (TMDb refresh, integrity
|
||||||
|
//! scan, bulk renames, miniDLNA generation). The manager is moved into a worker
|
||||||
|
//! thread for the duration and handed back with the result, so the window keeps
|
||||||
|
//! repainting and can show progress and a cancel button.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::sync::mpsc::{Receiver, channel};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use crate::core::file_manager::FileManager;
|
||||||
|
|
||||||
|
use super::common::Message;
|
||||||
|
|
||||||
|
/// The generic result of a task started from a dialog.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Outcome {
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub reload_series: bool,
|
||||||
|
/// Movies to select in the table afterwards.
|
||||||
|
pub select: Vec<std::path::PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Work a dialog hands to the main window to run in the background.
|
||||||
|
pub type Work = Box<dyn FnOnce(&mut FileManager, &Progress) -> Outcome + Send>;
|
||||||
|
|
||||||
|
/// Progress shared between the worker and the UI.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Progress {
|
||||||
|
pub done: AtomicUsize,
|
||||||
|
pub total: AtomicUsize,
|
||||||
|
pub label: Mutex<String>,
|
||||||
|
pub cancel: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Progress {
|
||||||
|
pub fn set(&self, done: usize, total: usize, label: impl Into<String>) {
|
||||||
|
self.done.store(done, Ordering::Relaxed);
|
||||||
|
self.total.store(total, Ordering::Relaxed);
|
||||||
|
if let Ok(mut l) = self.label.lock() {
|
||||||
|
*l = label.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancelled(&self) -> bool {
|
||||||
|
self.cancel.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running task; `R` is its result.
|
||||||
|
pub struct Task<R> {
|
||||||
|
pub title: String,
|
||||||
|
pub progress: Arc<Progress>,
|
||||||
|
rx: Receiver<(FileManager, R)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Send + 'static> Task<R> {
|
||||||
|
pub fn spawn(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
title: impl Into<String>,
|
||||||
|
mut fm: FileManager,
|
||||||
|
work: impl FnOnce(&mut FileManager, &Progress) -> R + Send + 'static,
|
||||||
|
) -> Self {
|
||||||
|
let progress = Arc::new(Progress::default());
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
let p = progress.clone();
|
||||||
|
let ctx2 = ctx.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result = work(&mut fm, &p);
|
||||||
|
let _ = tx.send((fm, result));
|
||||||
|
ctx2.request_repaint();
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
title: title.into(),
|
||||||
|
progress,
|
||||||
|
rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Some` once finished (the manager comes back with the result).
|
||||||
|
pub fn poll(&self) -> Option<(FileManager, R)> {
|
||||||
|
self.rx.try_recv().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the progress modal.
|
||||||
|
pub fn show(&self, ctx: &egui::Context) {
|
||||||
|
egui::Modal::new(egui::Id::unique("task_progress")).show(ctx, |ui| {
|
||||||
|
ui.set_min_width(420.0);
|
||||||
|
ui.heading(&self.title);
|
||||||
|
let done = self.progress.done.load(Ordering::Relaxed);
|
||||||
|
let total = self.progress.total.load(Ordering::Relaxed);
|
||||||
|
let label = self
|
||||||
|
.progress
|
||||||
|
.label
|
||||||
|
.lock()
|
||||||
|
.map(|l| l.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
ui.label(label);
|
||||||
|
let frac = if total > 0 {
|
||||||
|
done as f32 / total as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
ui.add(
|
||||||
|
egui::ProgressBar::new(frac)
|
||||||
|
.show_percentage()
|
||||||
|
.animate(total == 0),
|
||||||
|
);
|
||||||
|
if self.progress.cancelled() {
|
||||||
|
ui.label("Ruším…");
|
||||||
|
} else if ui.button("Zrušit").clicked() {
|
||||||
|
self.progress.cancel.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
//! End-to-end UI tests: drive the egui window headlessly (egui_kittest) the way
|
||||||
|
//! a user would — open a dialog, click buttons — and check the pool on disk.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use curator::core::file_manager::FileManager;
|
||||||
|
use curator::ui::app::CuratorApp;
|
||||||
|
use egui_kittest::Harness;
|
||||||
|
use egui_kittest::kittest::Queryable;
|
||||||
|
|
||||||
|
struct Env {
|
||||||
|
dir: tempfile::TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Env {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
dir: tempfile::tempdir().unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool(&self) -> PathBuf {
|
||||||
|
self.dir.path().join("pool")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manager(&self) -> FileManager {
|
||||||
|
let mut fm = FileManager::with_config_path(self.dir.path().join("cfg.json"));
|
||||||
|
fm.set_pool_dir(&self.pool()).unwrap();
|
||||||
|
fm.load_pool_movies().unwrap();
|
||||||
|
fm
|
||||||
|
}
|
||||||
|
|
||||||
|
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).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn harness(fm: FileManager) -> Harness<'static, CuratorApp> {
|
||||||
|
Harness::builder()
|
||||||
|
.with_size(egui::vec2(1300.0, 800.0))
|
||||||
|
.allow_missing_glyphs()
|
||||||
|
.with_accessibility_check(false)
|
||||||
|
.build_eframe(|cc| CuratorApp::new(fm, cc.egui_ctx.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step until no background task runs (or time out).
|
||||||
|
fn wait_idle(h: &mut Harness<'static, CuratorApp>) {
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
h.step();
|
||||||
|
if !h.state().busy() {
|
||||||
|
h.run_steps(3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
start.elapsed() < Duration::from_secs(20),
|
||||||
|
"task did not finish"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exists(p: &Path) -> bool {
|
||||||
|
p.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_movie_through_the_dialog() {
|
||||||
|
let env = Env::new();
|
||||||
|
let src = env.source("Matrix.1999.1080p.mkv");
|
||||||
|
let mut h = harness(env.manager());
|
||||||
|
h.state_mut().open_import(vec![src.clone()]);
|
||||||
|
h.run_steps(3);
|
||||||
|
h.get_by_label("OK").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
wait_idle(&mut h);
|
||||||
|
let pooled = env.pool().join("Filmy/Matrix.1999.1080p (1999).mkv");
|
||||||
|
assert!(
|
||||||
|
exists(&pooled),
|
||||||
|
"pool: {:?}",
|
||||||
|
fs::read_dir(env.pool().join("Filmy"))
|
||||||
|
.unwrap()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert!(exists(&src), "copy keeps the source");
|
||||||
|
// the summary message is shown and can be dismissed
|
||||||
|
h.get_by_label("OK").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
let fm = h.state().file_manager().unwrap();
|
||||||
|
assert_eq!(fm.filelist.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_requires_a_year() {
|
||||||
|
let env = Env::new();
|
||||||
|
let src = env.source("Bez roku.mkv");
|
||||||
|
let mut h = harness(env.manager());
|
||||||
|
h.state_mut().open_import(vec![src]);
|
||||||
|
h.run_steps(3);
|
||||||
|
h.get_by_label("OK").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
// a warning appears and nothing is imported
|
||||||
|
assert!(h.query_by_label_contains("čtyřmístný rok").is_some());
|
||||||
|
assert!(
|
||||||
|
fs::read_dir(env.pool().join("Filmy"))
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_existing_film_as_a_version() {
|
||||||
|
let env = Env::new();
|
||||||
|
let mut fm = env.manager();
|
||||||
|
let opts = curator::core::file_manager::ImportOptions {
|
||||||
|
year: Some("2009".into()),
|
||||||
|
tmdb_id: Some("19995".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
fm.import_movie(&env.source("a.mkv"), "Avatar", opts)
|
||||||
|
.unwrap();
|
||||||
|
// a second copy of the same film, named so title/year/id are prefilled
|
||||||
|
let src = env.source("Avatar (2009).3D.HSBS.mkv");
|
||||||
|
let nfo = src.with_extension("nfo");
|
||||||
|
fs::write(
|
||||||
|
&nfo,
|
||||||
|
"<movie><title>Avatar</title><uniqueid type=\"tmdb\">19995</uniqueid></movie>",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut h = harness(fm);
|
||||||
|
h.state_mut().open_import(vec![src]);
|
||||||
|
h.run_steps(3);
|
||||||
|
h.get_by_label("OK").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
// conflict question → add as a version
|
||||||
|
h.get_by_label("Přidat jako verzi").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
// the popisek prompt is pre-filled from the 3D tag
|
||||||
|
h.get_by_label("OK").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
wait_idle(&mut h);
|
||||||
|
let folder = env.pool().join("Filmy/Avatar (2009) [tmdbid-19995]");
|
||||||
|
assert!(folder.join("Avatar (2009) [tmdbid-19995].mkv").exists());
|
||||||
|
assert!(
|
||||||
|
folder
|
||||||
|
.join("Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv")
|
||||||
|
.exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn imported(env: &Env, fm: &mut FileManager, name: &str, title: &str) -> PathBuf {
|
||||||
|
fm.import_movie(
|
||||||
|
&env.source(name),
|
||||||
|
title,
|
||||||
|
curator::core::file_manager::ImportOptions::default(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_movie_renames_with_year_and_id() {
|
||||||
|
let env = Env::new();
|
||||||
|
let mut fm = env.manager();
|
||||||
|
let p = imported(&env, &mut fm, "x.mkv", "Matrix");
|
||||||
|
let mut h = harness(fm);
|
||||||
|
h.state_mut().select_movies(&[p]);
|
||||||
|
h.state_mut().debug_action("edit");
|
||||||
|
h.run_steps(3);
|
||||||
|
// the editor's year and TMDb fields (hint texts identify them)
|
||||||
|
for (label, text) in [("Rok:", "1999"), ("TMDb ID:", "603")] {
|
||||||
|
h.get_by_label(label).focus();
|
||||||
|
h.run_steps(1);
|
||||||
|
h.get_by_label(label).type_text(text);
|
||||||
|
h.run_steps(1);
|
||||||
|
}
|
||||||
|
h.run_steps(2);
|
||||||
|
h.get_by_label("OK").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
assert!(
|
||||||
|
env.pool()
|
||||||
|
.join("Filmy/Matrix (1999) [tmdbid-603].mkv")
|
||||||
|
.exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_movie_after_confirmation() {
|
||||||
|
let env = Env::new();
|
||||||
|
let mut fm = env.manager();
|
||||||
|
let p = imported(&env, &mut fm, "x.mkv", "Matrix");
|
||||||
|
let mut h = harness(fm);
|
||||||
|
h.state_mut().select_movies(std::slice::from_ref(&p));
|
||||||
|
h.run_steps(2);
|
||||||
|
h.key_press(egui::Key::Delete);
|
||||||
|
h.run_steps(3);
|
||||||
|
h.get_by_label("Ano").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
assert!(!p.exists());
|
||||||
|
assert!(h.state().file_manager().unwrap().filelist.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_assign_a_new_tag_through_the_dialog() {
|
||||||
|
let env = Env::new();
|
||||||
|
let mut fm = env.manager();
|
||||||
|
let p = imported(&env, &mut fm, "x.mkv", "Matrix");
|
||||||
|
let mut h = harness(fm);
|
||||||
|
h.state_mut().select_movies(std::slice::from_ref(&p));
|
||||||
|
h.state_mut().debug_action("tags");
|
||||||
|
h.run_steps(3);
|
||||||
|
h.get_by_label("➕ Nový štítek…").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
// pick the "Žánr" category, then type the tag name
|
||||||
|
h.get_all_by_label("Žánr").last().unwrap().click();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.get_all_by_label("OK").last().unwrap().click();
|
||||||
|
h.run_steps(3);
|
||||||
|
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||||
|
.last()
|
||||||
|
.unwrap()
|
||||||
|
.type_text("Sci-Fi");
|
||||||
|
h.run_steps(2);
|
||||||
|
h.get_all_by_label("OK").last().unwrap().click();
|
||||||
|
h.run_steps(3);
|
||||||
|
h.get_by_label("OK").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
let f = h.state().file_manager().unwrap().file(&p).unwrap().clone();
|
||||||
|
assert!(f.tag_paths().contains("Žánr/Sci-Fi"), "{:?}", f.tags);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user