Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4daf4e960d | ||
|
|
7cc2a1c63e | ||
|
|
c69f5f9166 | ||
|
|
c399a6aed3 | ||
|
|
c0ad1c1553 | ||
|
|
79c9e34a5d | ||
|
|
39712d7283 | ||
|
|
6a2baf243f | ||
|
|
1c78ca9013 | ||
|
|
4c4180f28c | ||
|
|
97a39709e8 | ||
|
|
7ae0709f38 | ||
|
|
f0f38d4257 | ||
|
|
b5c2023212 | ||
|
|
ee99fc957d | ||
|
|
a71b209539 | ||
|
|
b3a61f9e86 | ||
|
|
86c689b9f1 | ||
|
|
22a14b1e41 |
@@ -0,0 +1,38 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Tooling caches
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Logs (loguru file sink)
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# PyInstaller build artifacts (dist/ is committed — see DESIGN_DOCUMENT §13)
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Curator runtime metadata (user/machine-specific, regenerated)
|
||||||
|
.Curator.!gtag
|
||||||
|
*.!gtag
|
||||||
|
*.!ftag
|
||||||
|
*.!tag
|
||||||
|
*.!index
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
DESIGN_DOCUMENT.md
|
||||||
|
.claude/
|
||||||
|
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
# 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 `pyproject.toml`
|
||||||
|
- Document changes as they are made, not all at once at release time
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The standalone build is now a single file.** `Curator.spec` produces one
|
||||||
|
self-contained `dist/Curator` executable (onefile) instead of a
|
||||||
|
`dist/Curator/` folder of libraries. It unpacks to a temp folder on every
|
||||||
|
start, so launching takes a few seconds longer.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- Added `pyinstaller` to the dev group — `uv run pyinstaller Curator.spec`
|
||||||
|
now works out of the box (it was used for builds but never declared).
|
||||||
|
|
||||||
|
## 1.13.0 — 2026-10-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Custom attributes are gone from the GUI.** The attribute table in
|
||||||
|
*Upravit film* and *Filmy → "Nastavit atribut…"* (menu and context menu) are
|
||||||
|
removed. Values already stored keep working in miniDLNA filename templates
|
||||||
|
(`File.name_context`); they just can't be edited from the app anymore.
|
||||||
|
- **The pool index is keyed by the movie, not by a file path** (index **v2**,
|
||||||
|
`src/core/movie.py`). A record used to describe one *file*, so a movie's
|
||||||
|
metadata was a passenger on one particular file — which is why the "primary
|
||||||
|
version" had to carry the tags, why removing it needed a metadata-rescue
|
||||||
|
hack, and why renaming a multi-version movie had to be disabled outright.
|
||||||
|
Now the record **is** the movie (`tmdb-603`, or a diacritics-folded
|
||||||
|
`name-<title>-<year>` slug without an id) and owns its files:
|
||||||
|
```json
|
||||||
|
"tmdb-19995": {"title": "Avatar", "year": "2009", "tmdb_id": 19995,
|
||||||
|
"library": "Filmy", "tags": [...], "csfd_url": …,
|
||||||
|
"media": [{"role": "video", "label": "", "file": "…"},
|
||||||
|
{"role": "video", "label": "3D HSBS", "file": "…"}]}
|
||||||
|
```
|
||||||
|
Every version of a film therefore reads and writes **the same** metadata —
|
||||||
|
adding a version inherits it, removing any version (including the one shown
|
||||||
|
in the table) leaves it intact, and the rescue hack (plus the interim
|
||||||
|
`File.is_primary_version` flag) is gone. A newly seen file also joins an
|
||||||
|
existing record of the same identity instead of blanking its metadata.
|
||||||
|
`Movie.desired_layout` derives where the files belong from the record alone
|
||||||
|
(one video → flat in the library, two or more → its own
|
||||||
|
`Title (rok) [tmdbid]/` folder; a 3D label keeps Jellyfin's documented
|
||||||
|
`.3D.<FORMAT>` spelling in both layouts, other labels use `" - label"`) —
|
||||||
|
**not wired in yet**: `add_movie_version` / `remove_movie_version` still lay
|
||||||
|
files out themselves, and the edit dialog doesn't apply a recomputed layout
|
||||||
|
on rename. A move now updates the record's `library` as well.
|
||||||
|
`check_data_consistency` compares disk against the records' media paths.
|
||||||
|
`File` is now a *view* on one media entry of a record (same public
|
||||||
|
interface); `PoolIndex` gained `get_movie`/`set_movie`/`delete_movie`/
|
||||||
|
`rekey_movie`/`all_movies`/`find_movie_by_media`/`record_for`, and
|
||||||
|
**migrates a v1 index on first open** — metadata only, **no file is moved**
|
||||||
|
(a v1 movie already sits where v2 wants it), with the old index copied to
|
||||||
|
`.Curator.!index.v1.bak` first.
|
||||||
|
- **"Odebrat z poolu" removes the whole movie** (`FileManager.remove_movie`):
|
||||||
|
on a multi-version row it deletes every version, their subtitles and the
|
||||||
|
version folder — it used to delete only the file shown in the table and
|
||||||
|
leave the rest behind as a half-empty group. Subtitles of a flat movie are
|
||||||
|
removed too now.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Upravit film: one "Média filmu" section with a folder mode.** A flat
|
||||||
|
movie (one file in the library root, plus subtitles) shows as "Samostatný
|
||||||
|
film"; adding a second medium moves it into its own folder automatically.
|
||||||
|
The folder has two modes, chosen with a **"Verze / Díly"** switch:
|
||||||
|
**versions** — alternatives, each with its own popisek (free text such as
|
||||||
|
"kinoverze", "zkrácená verze", or a 3D preset via **"✏️ Popisek / 3D…"**);
|
||||||
|
**parts** — pieces played back to back, listed in order and reordered with
|
||||||
|
**↑ / ↓**. "➕ Přidat médium…" adds by the chosen mode (for a flat movie the
|
||||||
|
switch decides what the second file becomes). Switching an existing folder
|
||||||
|
converts it: to parts numbers the files in their current order; to versions
|
||||||
|
makes the first file the default and asks a popisek for each other one.
|
||||||
|
New `FileManager.movie_group_mode`, `ordered_media` (parts sorted
|
||||||
|
numerically, `part2` before `part10`), `move_movie_part`,
|
||||||
|
`set_movie_group_mode`; all of them relabel the folder through temporary
|
||||||
|
names, so swapping two parts never overwrites a file.
|
||||||
|
- **Movie parts (Jellyfin multi-part / stacking)** — a film that TMDb lists
|
||||||
|
as one movie but that you have as several files. *Upravit film* gets
|
||||||
|
**"➕ Přidat díl…"**: the film moves into its own folder (Jellyfin requires
|
||||||
|
it for parts, even with no other versions) as `Folder-part1.ext`,
|
||||||
|
`Folder-part2.ext`, … and Jellyfin plays them back to back as one movie —
|
||||||
|
unlike versions, which it offers as alternatives. Under the hood a part is
|
||||||
|
a version whose popisek is `partN`, so everything that handles versions
|
||||||
|
handles parts too (removing down to one demotes to flat, subtitles follow
|
||||||
|
each part, renaming works). Any Jellyfin spelling typed as a popisek
|
||||||
|
(`part2`, `CD2`, `pt 2`, `disc-2`) is written back as `-part2` and the
|
||||||
|
existing file becomes `part1` — so the import collision dialog's "Přidat
|
||||||
|
jako verzi" with popisek `CD2` works the same way. The version table shows
|
||||||
|
"díl N", the main table "Film · N díly". Parts and versions can't be
|
||||||
|
combined in one folder yet (`add_movie_part` refuses). New
|
||||||
|
`FileManager.add_movie_part`, `movie.normalize_part_label` / `part_label`.
|
||||||
|
- **Upravit seriál: TMDb lookup, optionally from a movie id.** The series
|
||||||
|
editor gets a **"🔎 Dohledat"** button (title + year from TMDb, by the typed
|
||||||
|
id or by the title) and a **"Metadata projednou z ID filmu (ne seriálu)"**
|
||||||
|
checkbox for series that TMDb lists only as a movie (a miniseries cut
|
||||||
|
together, a documentary in parts): ticked, the lookup uses the movie
|
||||||
|
endpoints instead of the TV ones. The movie id is then **not** written into
|
||||||
|
the series folder's `[tmdbid-N]` — Jellyfin's TV provider would resolve it as
|
||||||
|
a TV id and match an unrelated show — so Jellyfin matches the series by
|
||||||
|
title and year instead (`SeriesEditDialog._lookup`, `.tmdb_id`).
|
||||||
|
- **Import movies: assign a version right in the batch** (e.g. a 2D + a 3D
|
||||||
|
file of the same film picked together). A row's new **"🔗 Přiřadit jako
|
||||||
|
verzi…"** button picks which other row in the dialog it's a version of and
|
||||||
|
a popisek (pre-filled with the 3D formát detected in its filename via
|
||||||
|
`naming.three_d_format_from_filename` — e.g. `Avatar.2009.3D.HSBS.mkv` →
|
||||||
|
`3D HSBS` — free text otherwise, same `VersionLabelDialog` as *Upravit
|
||||||
|
film → Verze*); its own název/rok/TMDb ID/ČSFD fields grey out since they
|
||||||
|
come from the film it's a version of. On import the target row lands
|
||||||
|
first via `import_movie`, then every linked row is added onto it via
|
||||||
|
`FileManager.add_movie_version` — one movie with several versions instead
|
||||||
|
of two separate (duplicate) movies. Removing a row it points to un-links
|
||||||
|
it back to standalone; a row already pointed at by others can't itself
|
||||||
|
become a version (no chains).
|
||||||
|
- **Import movies: "3D formát" per row**, for the opposite case — a film
|
||||||
|
whose *only* file is entirely 3D, with no plain version to make it a
|
||||||
|
version *of*. A combo box (pre-filled from the filename, same detection as
|
||||||
|
the popisek above) tags a standalone row's import straight to
|
||||||
|
`Title (rok) [tmdbid].3D.<FORMÁT>.ext`, exactly like the 3D formát dropdown
|
||||||
|
in *Upravit film* — no more importing plain, then editing it in just to add
|
||||||
|
the tag. Disabled (and reset) the moment a row is linked as a version or
|
||||||
|
gains one as a follower, since a grouped movie's 3D-ness belongs on the
|
||||||
|
specific version's popisek instead, never on the group as a whole.
|
||||||
|
- **Duplicate check** (*Nástroje → Kontrola duplicit…*,
|
||||||
|
`FileManager.find_duplicates`). Lists movies that are in the pool more than
|
||||||
|
once — a version folder counts as one movie, never as a duplicate of itself.
|
||||||
|
Flagged when two movies share the **same TMDb id** (e.g. `X.mkv` and
|
||||||
|
`X.3D.HSBS.mkv` lying flat side by side, or one film in two libraries), the
|
||||||
|
**same title + year** (one copy paired with TMDb, the other not), or a
|
||||||
|
**byte-identical file size** (a plain copy); overlapping hits form one group
|
||||||
|
with every reason listed. Per file the dialog offers **"Sloučit jako verzi
|
||||||
|
do…"** (`FileManager.merge_as_version`: moves the file into the other movie's
|
||||||
|
version folder — promoting it from flat when needed, a 3D file pre-labelled
|
||||||
|
`3D <FORMÁT>` — carries its subtitles along, keeps the target's metadata and
|
||||||
|
only adds missing tags / ČSFD link, and drops the duplicate's own record) or
|
||||||
|
**"Smazat vybraný z poolu…"**.
|
||||||
|
- **Multi-version movies are marked in the main table**: `▣` before the bold
|
||||||
|
name, `Film · N verze` in the Typ column, the size summed over all versions
|
||||||
|
and a tooltip listing every file; the detail panel shows the folder and the
|
||||||
|
version count.
|
||||||
|
- **Episodes split into physical parts** — an old recording cut into several
|
||||||
|
files, as opposed to `E01-E02` (several *episodes* in one file) —
|
||||||
|
get Jellyfin's own `.ptN` stacking suffix: `SxxEyy - {série}.ptN.ext`.
|
||||||
|
Recognised from *cd/dvd/part/pt/disc/disk* + a number already in the source
|
||||||
|
filename (`Show.S01E02.pt2.mkv`, `Show S01E01-part-1.mkv`, …), or typed by
|
||||||
|
hand in the import dialog's SxxEyy column as `S01E02P3` (new
|
||||||
|
`series.parse_episode_part`/`parse_episode_code`); carried through
|
||||||
|
scanning, import, "Napravit názvy souborů…" and the series tree.
|
||||||
|
- **Přehled seriálů → "Odebrat z poolu…" now targets whatever is selected in
|
||||||
|
the tree**, not always the whole series: pick a single episode to delete
|
||||||
|
just that file (+ its subtitles), a season to delete the whole `Season NN`/
|
||||||
|
`Specials` folder, or the series itself as before. New
|
||||||
|
`FileManager.remove_episodes`/`remove_season`; a season folder left
|
||||||
|
completely empty by the removal is cleaned up, one still holding something
|
||||||
|
else is left alone.
|
||||||
|
- **Multiple versions of one movie** (Jellyfin's own "multiple versions" —
|
||||||
|
2D+3D, 4K+1080p, Director's Cut, …). A movie is identified by its
|
||||||
|
`(title, year, tmdbid)` and stays a **flat file** while it has exactly one
|
||||||
|
playable version (today's layout, unchanged). Adding a second version
|
||||||
|
**promotes it automatically** into its own folder — Jellyfin's required
|
||||||
|
shape, every file prefixed with the exact folder name:
|
||||||
|
```
|
||||||
|
Title (rok) [tmdbid]/
|
||||||
|
Title (rok) [tmdbid] - Výchozí.ext
|
||||||
|
Title (rok) [tmdbid] - 3D HSBS.ext
|
||||||
|
```
|
||||||
|
*Upravit film* gets a **„Verze"** list — **"➕ Přidat verzi…"** (pick a file,
|
||||||
|
then a small `VersionLabelDialog`: a **3D formát** preset — its label becomes
|
||||||
|
exactly `3D <FORMÁT>`, the same tokens Jellyfin scans for in a flat
|
||||||
|
`.3D.<FORMÁT>` filename — or free text for a non-3D version like `4K` /
|
||||||
|
`Director's Cut`), **"✏️ Přejmenovat verzi…"** (`FileManager.rename_movie_version`
|
||||||
|
— same dialog, pre-filled with the current label; renames the file + its
|
||||||
|
subtitles in place, shared metadata untouched) and **"➖ Odebrat verzi"**
|
||||||
|
(removing down to one version **demotes back to flat** automatically). A
|
||||||
|
flat movie always shows one row too — labelled **„Výchozí"**, a placeholder
|
||||||
|
since it has no on-disk label yet; renaming is disabled until a second
|
||||||
|
version exists (there's nothing to rename it *from* until then). The table
|
||||||
|
still shows **one row per movie** — a group's other versions live only in
|
||||||
|
that list, not as
|
||||||
|
separate rows (`FileManager.visible_movies`; the row shown is the plain /
|
||||||
|
`Výchozí` version, `primary_version`). All versions share **one metadata
|
||||||
|
record** (see *Changed → index v2*), so a new version inherits the title/
|
||||||
|
tags/ČSFD/atributy and removing any version — even the one shown in the table
|
||||||
|
— leaves them intact. Renaming/moving/retitling a grouped movie isn't
|
||||||
|
supported yet (those fields are disabled in the edit dialog; remove the
|
||||||
|
version or edit the files directly). `FileManager.is_movie_group_member` /
|
||||||
|
`list_movie_versions` / `primary_version` / `add_movie_version` /
|
||||||
|
`remove_movie_version`. Works in `mixed` libraries too (see *Fixed* below
|
||||||
|
for how a version folder there is told apart from a series).
|
||||||
|
- **3D movie tagging** (`naming.py`, Jellyfin's own convention —
|
||||||
|
https://jellyfin.org/docs/general/server/media/movies/). *Upravit film* gets
|
||||||
|
a **„3D formát"** dropdown (žádný / HSBS / FSBS / HTAB / FTAB / MVC),
|
||||||
|
pre-filled from the current filename; picking one renames the pool file to
|
||||||
|
`Title (rok) [tmdbid].3D.<FORMÁT>.ext`. The tag is preserved across every
|
||||||
|
future rename (editing other fields, "Přejmenovat dle metadat") the same way
|
||||||
|
`[tmdbid-N]` already is — `canonical_pool_stem` / `parse_3d_format` /
|
||||||
|
`THREE_D_FORMATS`; `apply_movie_edits(format_3d=…)` (omitted = keep, `""` =
|
||||||
|
clear). This is for a movie that's **only ever going to have one file** —
|
||||||
|
deliberately **not** exposed in the import dialog: importing a second file
|
||||||
|
for a movie already in the pool that way would create a plain, ungrouped
|
||||||
|
duplicate instead of a proper version (see "Multiple movie versions" above,
|
||||||
|
which is the right tool for 2D+3D of the same film). `import_movie` still
|
||||||
|
takes `format_3d=…` as a building block, just with no UI wired to it.
|
||||||
|
- **miniDLNA hardcopies `homevideo` libraries too.** A `homevideo` library
|
||||||
|
(no TMDb id, arbitrary internal structure) has no genre tags to build a tree
|
||||||
|
from — same reasoning as series — so it's now mirrored whole into
|
||||||
|
`output/<název knihovny>` alongside the series copy-as-is mirrors
|
||||||
|
(`FileManager.mirror_as_is_libraries` = `series` + `homevideo`; the
|
||||||
|
series-only `copyasis_folders` used by the library organizer is unchanged).
|
||||||
|
Respects the per-library miniDLNA selection.
|
||||||
|
- **miniDLNA covers series inside `mixed` libraries.** A `mixed` library's
|
||||||
|
top-level files are movies (genre-tagged as usual); any **series in its
|
||||||
|
subfolders** now get hardcopied into `output/Seriály/<vlastní název>/…` —
|
||||||
|
no genre tags apply to a series, so it bypasses the tag tree entirely, same
|
||||||
|
mechanism as a dedicated series library's copy-as-is mirror but scoped to one
|
||||||
|
series' own folder. Respects the per-library miniDLNA selection below.
|
||||||
|
`FileManager.minidlna_scope` now returns a third element, the in-scope
|
||||||
|
mixed-library `series.Series` list.
|
||||||
|
- **miniDLNA: choose which libraries generate.** *miniDLNA → "Knihovny pro
|
||||||
|
generování…"* — a checklist of the pool's libraries (persisted in the pool
|
||||||
|
index, default = all, matching the previous behaviour). "Vygenerovat miniDLNA
|
||||||
|
strom" then only includes movies from the checked libraries in the genre tree
|
||||||
|
/ Tipy dne / Nově přidané, and only mirrors checked series/homevideo
|
||||||
|
libraries — the dialog warns up front that unchecking one removes its output.
|
||||||
|
A movie library's genre-tree links are swept as obsolete the normal way; a
|
||||||
|
mirrored (series/homevideo) library or a mixed-library series has **no**
|
||||||
|
obsolete-link mechanism of its own, so deselecting one is handled explicitly:
|
||||||
|
`FileManager.minidlna_prune_targets` works out what fell out of scope (the
|
||||||
|
whole-library output folder, or — since a mixed-library series and a
|
||||||
|
same-named real library can share one output bucket like `Seriály` — just the
|
||||||
|
stale entries in it) and `HardlinkManager.remove_mirror` deletes it.
|
||||||
|
`FileManager.minidlna_libraries` / `set_minidlna_libraries` / `minidlna_scope`.
|
||||||
|
- **External subtitle management for movies** (`src/core/subtitles.py`). Jellyfin
|
||||||
|
plays an external `.srt`/`.ass`/`.ssa`/`.sub`/`.vtt` natively when it shares the
|
||||||
|
video's filename stem (`Title (rok) [tmdbid].cs.srt`, optionally `.forced`/
|
||||||
|
`.sdh`/`.default`) — no muxing needed. *Upravit film* lists a movie's attached
|
||||||
|
subtitles and can **"➕ Přidat titulky…"** (pick a file, tag its language,
|
||||||
|
mark forced; copied in and named to match) or remove one. Subtitles now
|
||||||
|
**follow the movie through every rename/move** (`rename_movie`,
|
||||||
|
`apply_movie_edits`, `move_to_library`, `rename_to_canonical`, and
|
||||||
|
`rename_episodes_to_canonical` for series) instead of silently detaching —
|
||||||
|
`subtitles.rename_subtitles_with`. `FileManager.list_subtitles` /
|
||||||
|
`attach_subtitle` / `remove_subtitle`. Presence is read live from disk (like
|
||||||
|
NFO), nothing is cached in the pool index.
|
||||||
|
- **Movies and series in one list.** The main table lists both as rows with a
|
||||||
|
**Typ** column (Film / Seriál); size is the file size for a movie and the sum of
|
||||||
|
the episodes for a series. Double-click (or the context menu) opens the right
|
||||||
|
editor by type — a movie → the movie parameters dialog, a series → the series
|
||||||
|
editor with the per-episode SxxEyy tree. The right detail panel handles both.
|
||||||
|
Library filter + search cover both; a tag filter narrows movies (series carry
|
||||||
|
no tags).
|
||||||
|
- **Detail panel on the right** of the table — the selected item's filename (or
|
||||||
|
series folder), Czech title, library and size (`_update_details`).
|
||||||
|
- **Consolidated edit dialogs.** *Filmy → "Upravit film…"* (double-click) opens
|
||||||
|
one dialog for every Curator-owned movie parameter — title, year, TMDb ID (with
|
||||||
|
"Dohledat z TMDb"), library, date, attributes and a ČSFD link — applied at once
|
||||||
|
(rename `Title (rok) [tmdbid].ext`, move between libraries, set metadata) via
|
||||||
|
`FileManager.apply_movie_edits`. A series opens an editor (in *Přehled seriálů*
|
||||||
|
or from the list): edit its title/year/TMDb ID/library **and each episode's
|
||||||
|
season/episode number**, renaming the folder and laying episodes out canonically
|
||||||
|
(`FileManager.apply_series_edits`).
|
||||||
|
- **Library organizer** (*Pool → "Knihovny…"*). One dialog manages the pool's
|
||||||
|
top-level libraries as ``{name, kind}`` — kinds **movie / series / mixed /
|
||||||
|
homevideo** — stored in a unified ``libraries`` setting in the pool index
|
||||||
|
(`FileManager.libraries` / `set_libraries`; legacy `movie_libraries` /
|
||||||
|
`copyasis_folders` are derived from it and synthesised for old pools). Setting
|
||||||
|
the pool creates a folder for every library. Replaces the separate "Knihovny
|
||||||
|
(rozdělení)" and "Copy-as-is složky" dialogs. **mixed** and **homevideo** can be
|
||||||
|
defined now; homevideo scanning lands in a later phase.
|
||||||
|
- **Series import into `mixed` libraries.** `series_libraries` (import target +
|
||||||
|
`load_series` scan) covers kind `series` **and** `mixed`, so episodes can be
|
||||||
|
imported into a mixed library and show up in the series overview/editor.
|
||||||
|
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at
|
||||||
|
import (a column in the movie import dialog) and editable in *Upravit film*.
|
||||||
|
- **ČSFD → TMDb title bridge** (`src/core/csfd.py`, lean revival of the removed
|
||||||
|
scraper). A direct TMDb search on a Czech distribution title is unreliable
|
||||||
|
(remakes, localized names); ČSFD indexes Czech release names well **and** lists
|
||||||
|
each film's *original* title, so the flow is: filename → ČSFD search → ČSFD
|
||||||
|
film page → original title + year → TMDb search → tmdbid.
|
||||||
|
- Import dialog: a second button **"🎬 Dohledat přes ČSFD"** next to "Dohledat
|
||||||
|
z TMDb". Per row it uses the filled ČSFD odkaz, else searches ČSFD by the
|
||||||
|
cleaned filename; fills ČSFD odkaz / TMDb ID / Název / Rok (blue = suggested).
|
||||||
|
- Migration: a movie carrying a `csfd_url` is resolved through ČSFD first, so
|
||||||
|
`match_pool_to_tmdb` searches on the original title + year (the proposal is
|
||||||
|
tinted blue with a *"přes ČSFD: …"* tooltip). `FileManager.resolve_via_csfd`.
|
||||||
|
- Search is scoped to the *Filmy* section and rejects two-segment episode
|
||||||
|
hrefs (a bare-title search otherwise returned a series episode); a yearful
|
||||||
|
query that misses is retried without the year. The original title is only
|
||||||
|
taken from a `.film-names` entry whose flag matches the country of origin —
|
||||||
|
a Czech film (which lists only foreign alt-titles there) keeps its `<h1>`
|
||||||
|
title instead of a random Slovenian/English one.
|
||||||
|
- Scope: a title→tmdbid bridge only — **no ČSFD tags/genres return** (those
|
||||||
|
stay TMDb-via-Jellyfin/NFO). ČSFD's Anubis proof-of-work wall is solved
|
||||||
|
(SHA-256, stdlib) and replayed over a shared session so a batch pays it once.
|
||||||
|
- **Import rows pre-filled from metadata next to the source file.** When a picked
|
||||||
|
video sits next to metadata from an earlier Curator/Tagger setup, the import
|
||||||
|
row is seeded on open (no network) — **ČSFD odkaz**, Název, Rok (blue =
|
||||||
|
suggested, verify):
|
||||||
|
- `src/core/legacy_meta.py` — reads Curator's **pre-index** layouts: a per-file
|
||||||
|
`.{filename}.!tag` JSON sidecar (Tagger / early Curator; key `csfd_link`),
|
||||||
|
else a `.Curator.!index` found walking up the tree (matched by relative key,
|
||||||
|
then by unique basename). These win for the ČSFD link.
|
||||||
|
- `nfo.read_nfo` also parses a ČSFD id/URL from `<uniqueid type="csfd">` (bare
|
||||||
|
id or full URL) / legacy `<csfdid>` / `<csfd>` (`nfo.NfoData.csfd_url`), and
|
||||||
|
fills what the sidecar/index don't carry (notably the TMDb id).
|
||||||
|
- `ImportMoviesDialog._prefill_row`.
|
||||||
|
- **Import: "Vynechat řádky bez TMDb ID".** A checkbox in the import dialog that
|
||||||
|
drops every row without a TMDb ID on OK — they are skipped (and no longer need
|
||||||
|
a year). Rows that would be skipped are shown struck-through/italic as the box
|
||||||
|
is ticked; the import summary reports how many were skipped
|
||||||
|
(`ImportMoviesDialog.skipped_no_tmdb`).
|
||||||
|
- **Episode import: "📁 Přidat složku…".** The episode-import dialog can now take
|
||||||
|
a whole series folder and scans it **recursively** (all subfolders — `Season
|
||||||
|
01`, `Season 02`, …), adding every video file it finds (sidecars skipped);
|
||||||
|
season/episode still parsed per file. A canonical folder name
|
||||||
|
(`Title (rok) [tmdbid-N]`) also pre-fills the series title / year / TMDb ID.
|
||||||
|
The dialog now opens empty (add files or a folder from inside it).
|
||||||
|
`series.collect_episode_files`, `ImportEpisodesDialog._add_folder`.
|
||||||
|
- **Episode import: editable SxxEyy per row.** The `SxxEyy` column is now an
|
||||||
|
editable field, pre-filled from the filename and colour-coded (green valid /
|
||||||
|
red unparsable / plain empty). Fill in an unrecognised episode (or fix a
|
||||||
|
mis-parsed one) right in the import dialog — `S01E05` / `1x05` both work; OK
|
||||||
|
warns before importing if any row still has no valid code.
|
||||||
|
`ImportEpisodesDialog.episode_codes` feeds a new `codes=` override on
|
||||||
|
`FileManager.import_episodes`.
|
||||||
|
- **Remove a series from the pool.** *Seriály → "Odebrat z poolu…"*, the table
|
||||||
|
context menu (series-only selection, one or more), and a button in *Přehled
|
||||||
|
seriálů* delete a whole series folder — every season and episode — after a
|
||||||
|
confirm. `FileManager.remove_series` (refuses a folder outside the pool).
|
||||||
|
- **Bulk-fix series episode filenames.** *Seriály → "Napravit názvy souborů…"*
|
||||||
|
goes over every series in the pool and renames each episode to
|
||||||
|
**`SxxEyy - {název seriálu}.ext`** in the right `Season NN` / `Specials`
|
||||||
|
folder, then removes the season folders emptied by the moves. Episodes with no
|
||||||
|
recognisable `SxxEyy` are left in place and listed (fix them in the series
|
||||||
|
editor). `FileManager.rename_all_series_to_canonical`.
|
||||||
|
- **Pool migration to TMDb** (`src/core/migrate.py`, *Nástroje → "Spárovat pool
|
||||||
|
s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet) against TMDb by
|
||||||
|
title + year and proposes an id, filling a missing year from TMDb. Conservative
|
||||||
|
classification — **jisté** (exact diacritics-insensitive title + matching year,
|
||||||
|
unique), **nejednoznačné** (pick from candidates), **bez shody**. A review
|
||||||
|
dialog applies the confident ones or the ones you pick → renames to
|
||||||
|
`Title (rok) [tmdbid].ext`. Already-migrated files are skipped (re-runnable).
|
||||||
|
`FileManager.match_pool_to_tmdb` / `assign_tmdb_id`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Adding/removing a movie version could silently drop its 3D formát.**
|
||||||
|
Promoting a flat, 3D-only movie to a group (adding a second version)
|
||||||
|
always renamed the pre-existing file to `" - Výchozí"`, discarding
|
||||||
|
whatever `.3D.<FORMAT>` tag it had — same the other way round: demoting
|
||||||
|
back to flat when the sole survivor's own popisek *was* a 3D formát always
|
||||||
|
produced a plain filename. Both now read the file's own current formát
|
||||||
|
(`naming.parse_3d_format` / `movie.normalize_3d_label`) and carry it over
|
||||||
|
automatically, so a movie can freely gain and lose versions — 3D-only,
|
||||||
|
2D-only, several formats at once — without ever losing a formát that
|
||||||
|
wasn't touched in that particular step. Affects `add_movie_version` (its
|
||||||
|
`existing_label` param, and `merge_as_version`'s `keep_label` which now
|
||||||
|
derives from it too) and `remove_movie_version`.
|
||||||
|
- **A version's popisek can be left empty** — for a plain "2D" file with
|
||||||
|
nothing else to call it — instead of being rejected; it becomes
|
||||||
|
`movie.DEFAULT_VERSION_LABEL` ("Výchozí") the same way the pre-existing
|
||||||
|
file already did on promotion. Applies to `add_movie_version`,
|
||||||
|
`rename_movie_version`, `merge_as_version`, and everywhere they're driven
|
||||||
|
from the GUI (*Upravit film → Verze*, *Kontrola duplicit*, *Importovat
|
||||||
|
filmy…*'s "Přiřadit jako verzi…") — none of them require typing "Výchozí"
|
||||||
|
by hand anymore. `VersionLabelDialog`'s popisek field says so directly.
|
||||||
|
- **Renaming or demoting a version could wipe the film's tags.** Every version
|
||||||
|
holds its own in-memory copy of the shared metadata; one loaded before a tag
|
||||||
|
was added wrote its stale copy back when it was renamed, relabelled or left
|
||||||
|
as the sole survivor. Those paths now use the new `File.save_location`, which
|
||||||
|
re-reads the movie record first so only the file's own path and popisek
|
||||||
|
change.
|
||||||
|
- **A movie's version couldn't be removed when the edit dialog was open on
|
||||||
|
it.** *Upravit film* opens on the movie's primary version, and removing that
|
||||||
|
very file was blocked ("Nelze odebrat verzi, kterou právě upravuješ…") — so
|
||||||
|
in a folder whose first file alphabetically was the one to go, it couldn't go
|
||||||
|
at all. It can now: the dialog carries on with a sibling (all versions share
|
||||||
|
one movie record) and *Upravit film* applies its edits to that file.
|
||||||
|
- **Parts and versions could be mixed in one folder**, e.g. `-part1`, `-part2`
|
||||||
|
and a version labelled `díl 1` — and the version table showed both the part
|
||||||
|
and the version as "díl 1". `add_movie_version` and `rename_movie_version`
|
||||||
|
now refuse a folder holding both kinds, and the version table has separate
|
||||||
|
**Typ** (díl N / verze / Výchozí) and **Popisek** (the exact label) columns.
|
||||||
|
- **The default version of a grouped movie is named bare.** In a folder of
|
||||||
|
two or more versions the plain one is now `Folder/Folder.ext`, not
|
||||||
|
`Folder/Folder - Výchozí.ext`: "Výchozí" is only the table's name for it.
|
||||||
|
Other versions stay `Folder - <popisek>.ext`. The version table and index
|
||||||
|
label read it as the default. Pool files already named `- Výchozí` are
|
||||||
|
renamed with **Nástroje → "Sjednotit názvy výchozích verzí…"**
|
||||||
|
(`FileManager.normalize_default_version_names`; subtitles and metadata follow,
|
||||||
|
a folder whose bare name is already taken is left alone).
|
||||||
|
- **Importing a film already in the pool added a root-level duplicate instead
|
||||||
|
of a version.** `pooled_with_stem` compared only the filename stem, so the
|
||||||
|
files of a grouped movie (`Folder - 3D HSBS.mkv`) never matched the film's
|
||||||
|
identity and the import wrote `Title (rok) [tmdbid].mkv` beside the folder.
|
||||||
|
Matching is now by movie identity (flat file, any version of a group, or a
|
||||||
|
3D-tagged copy). When the batch import finds such a film it asks **"Přidat
|
||||||
|
jako verzi" / "Nahradit celý film novým" / "Přeskočit"** (no "keep both as
|
||||||
|
separate films" — same TMDb id means one movie record, so that's just a
|
||||||
|
duplicate) and, for "Přidat jako verzi", asks for each file's popisek
|
||||||
|
(pre-filled from its 3D formát). `import_movie(on_conflict="version",
|
||||||
|
version_label=…)` does the adding; `"replace"` removes a grouped movie as a
|
||||||
|
whole rather than one of its files, and "suffix" no longer lands beside a
|
||||||
|
folder of the same identity.
|
||||||
|
- Repair for an already-created root duplicate: **Nástroje → Kontrola duplicit**
|
||||||
|
flags it (same TMDb id) and **"Sloučit jako verzi do…"** folds it into the
|
||||||
|
group.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Tags come from TMDb, not NFO.** *Filmy → "Načíst žánry z TMDb"*
|
||||||
|
(`FileManager.refresh_tags_from_tmdb`, `tmdb.movie_details`) fetches
|
||||||
|
genres/year/country/rating by the movie's tmdbid and regenerates its tags;
|
||||||
|
generating the miniDLNA tree refreshes from TMDb first. Genres come localized
|
||||||
|
(cs-CZ); production countries stay TMDb's English names. Removes the wait for
|
||||||
|
Jellyfin to write NFO before the genre tree is populated.
|
||||||
|
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**
|
||||||
|
(genre tags come from TMDb; manual tag editing isn't needed in the Jellyfin flow).
|
||||||
|
- **Episode filename is `SxxEyy - {série}.ext`** inside `Series (rok) [tmdbid]/
|
||||||
|
Season NN/`. The release string is deliberately dropped — identity is the
|
||||||
|
`SxxEyy` code and Jellyfin fetches the episode title/metadata from TMDb
|
||||||
|
(matched by the code + the folder's `[tmdbid-N]`); an empty/unsafe title
|
||||||
|
collapses to `SxxEyy.ext`. `episode_filename` takes the series title,
|
||||||
|
`is_canonical_episode(path, series_title)`, and import / rename / edit all
|
||||||
|
follow (`series.sanitize_title`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the
|
||||||
|
library filter offered only `movie`-kind libraries (falling back to a default
|
||||||
|
"Filmy"), so a pool of only mixed/series/homevideo libraries had nowhere to put
|
||||||
|
a movie. They now use `libraries_accepting_movies` (kind **movie or mixed**),
|
||||||
|
and `load_pool_movies` scans a mixed library's **top-level** files as movies
|
||||||
|
(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.
|
||||||
|
- **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).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Imports
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
from src.ui.qt_app import run
|
||||||
|
|
||||||
|
|
||||||
|
def _use_system_certificates() -> None:
|
||||||
|
"""Make HTTPS (ČSFD fetching) use the OS certificate store.
|
||||||
|
|
||||||
|
Required behind corporate SSL inspection, where the proxy's root CA is in the
|
||||||
|
Windows trust store but not in certifi's bundle. Best-effort; ignored if the
|
||||||
|
optional `truststore` package is unavailable.
|
||||||
|
"""
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
import truststore
|
||||||
|
|
||||||
|
truststore.inject_into_ssl()
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.tagmanager = TagManager()
|
||||||
|
self.filehandler = FileManager(self.tagmanager)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
_use_system_certificates()
|
||||||
|
state = State()
|
||||||
|
run(state.filehandler, state.tagmanager)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['Curator.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='Curator',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Python Development Guidelines
|
||||||
|
|
||||||
|
**Document Version:** v12
|
||||||
|
|
||||||
|
> **Note on Versioning:**
|
||||||
|
> - This document version is independent — reused across projects
|
||||||
|
> - **Project version** source of truth: `pyproject.toml`
|
||||||
|
> - Version propagates: `pyproject.toml` → `constants.py` → code
|
||||||
|
> - `CHANGELOG.md` uses project version from `pyproject.toml`
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- **README.md** — Project overview, tool descriptions, build instructions
|
||||||
|
- **AGENTS.md** — Rules for AI assistants
|
||||||
|
- **PROJECT.md** — Project goals and current state
|
||||||
|
- **CHANGELOG.md** — Version history
|
||||||
|
|
||||||
|
### Documentation Organization
|
||||||
|
|
||||||
|
All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root.
|
||||||
|
|
||||||
|
The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_PYTHON.md`, `PROJECT.md`, `CHANGELOG.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Code Style
|
||||||
|
|
||||||
|
- **PEP8** with 120-character lines (Ruff)
|
||||||
|
- **4 spaces** indentation
|
||||||
|
- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants
|
||||||
|
- **Type hints** required on all functions
|
||||||
|
- **Import order**: stdlib → third-party → local
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. SOLID Principles
|
||||||
|
|
||||||
|
- **SRP** — One class = one responsibility
|
||||||
|
- **OCP** — Open for extension, closed for modification
|
||||||
|
- **LSP** — Subclasses substitutable for parents
|
||||||
|
- **ISP** — Small interfaces over large ones
|
||||||
|
- **DIP** — Depend on abstractions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dependency Injection
|
||||||
|
|
||||||
|
Pass dependencies via constructor. Never instantiate dependencies inside a class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Protocols Over Inheritance
|
||||||
|
|
||||||
|
Prefer `typing.Protocol` and composition over class inheritance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data Classes
|
||||||
|
|
||||||
|
Use `@dataclass` for internal data structures, `pydantic.BaseModel` for data that requires validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Logging and Console Output
|
||||||
|
|
||||||
|
### Logging — loguru
|
||||||
|
|
||||||
|
Use **loguru** for all internal logging. Never log secrets, passwords, tokens, or API keys.
|
||||||
|
|
||||||
|
#### Log sinks
|
||||||
|
|
||||||
|
| Sink | Level | Format |
|
||||||
|
|------|-------|--------|
|
||||||
|
| File `logs/{AppName}_{time}.log` | `DEBUG` | full (timestamp + level + message) |
|
||||||
|
| stdout | `INFO` | full |
|
||||||
|
|
||||||
|
File sink retains **max 10 log files** (`retention=10`). No rotation by size — each run creates a new file via `{time}` in the filename.
|
||||||
|
|
||||||
|
The `DEBUG` sink is only active when `constants.DEFAULT_DEBUG` is `True` (controlled by `ENV_DEBUG=true` in `.env`).
|
||||||
|
|
||||||
|
Additional sinks (e.g. GUI log panels) may be added per project as needed.
|
||||||
|
|
||||||
|
#### Log levels
|
||||||
|
|
||||||
|
| Level | When to use |
|
||||||
|
|-------|-------------|
|
||||||
|
| `DEBUG` | Per-item detail: individual file reads/writes, cache hits, per-row operations |
|
||||||
|
| `INFO` | User-visible milestones: file loaded, process started/finished, file saved |
|
||||||
|
| `WARNING` | Recoverable issues: fallback used, unexpected but non-fatal state |
|
||||||
|
| `ERROR` | Failures the user must know about: missing file, failed save, missing config — operation cannot continue |
|
||||||
|
|
||||||
|
### Console output — print()
|
||||||
|
|
||||||
|
`print()` is **allowed** for direct user-facing console communication: input prompts, progress lines, and result summaries. This applies to console tools that interact with the user through stdin/stdout.
|
||||||
|
|
||||||
|
`print()` is **not** a substitute for loguru. It must not be used for debugging or internal event tracking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Environment and Secrets
|
||||||
|
|
||||||
|
- Secrets in `.env` file; controlled by `ENV_DEBUG=true/false`
|
||||||
|
- Load via `python-dotenv` and `os.getenv()`
|
||||||
|
- Never commit `.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Error Handling
|
||||||
|
|
||||||
|
Define specific exception types. Use a fail-fast approach — surface errors early rather than silently continuing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Testing
|
||||||
|
|
||||||
|
- **pytest** only — no `unittest`, no `TestCase` classes, no `self.assert*`
|
||||||
|
- Arrange-Act-Assert pattern
|
||||||
|
- Test naming: `test_<action>_<context>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Tooling
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| **Ruff** | Formatting and linting |
|
||||||
|
| **mypy** | Static type checking |
|
||||||
|
| **pytest** | Testing |
|
||||||
|
|
||||||
|
Run before every commit:
|
||||||
|
```bash
|
||||||
|
uv run ruff check
|
||||||
|
uv run mypy
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. uv
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync # Install all dependencies
|
||||||
|
uv add <pkg> # Add runtime dependency
|
||||||
|
uv add --dev <pkg> # Add dev dependency
|
||||||
|
uv remove <pkg> # Remove dependency
|
||||||
|
uv run <cmd> # Run command in virtualenv
|
||||||
|
uv python install <version> # Install a uv-managed Python
|
||||||
|
```
|
||||||
|
|
||||||
|
Never edit `pyproject.toml` directly to add or remove dependencies.
|
||||||
|
|
||||||
|
The interpreter is a uv-managed Python — no pyenv, no system Python.
|
||||||
|
|
||||||
|
### uv.lock
|
||||||
|
|
||||||
|
`uv.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
project/
|
||||||
|
├── entry_point.py # Entry point(s) — named by purpose or tool name
|
||||||
|
├── src/ # All application modules
|
||||||
|
├── tests/ # Tests
|
||||||
|
├── docs/ # Detailed documentation
|
||||||
|
├── .venv/ # Virtual environment (managed by uv)
|
||||||
|
└── pyproject.toml # Project config and dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
A project may have multiple entry points (e.g. `cli.py`, `gui.py`, or per-tool scripts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Distribution and Deployment
|
||||||
|
|
||||||
|
When a project is distributed as a standalone executable (no Python required on target machine):
|
||||||
|
|
||||||
|
- Use **PyInstaller** to compile each entry point into a single `.exe`
|
||||||
|
- Each tool has its own `.spec` file in the project root
|
||||||
|
- **Console tools use `console=True`** in the `.spec` — they rely on `input()` and `print()` for user interaction. GUI applications turn the console off with `ENV_BUILD_CONSOLE=false`; that flag is independent of `ENV_DEBUG`
|
||||||
|
- Compiled executables are stored in `dist/` and **committed to the repository** — the repository serves as the distribution channel for internal teams
|
||||||
|
- `.gitignore` must **not** exclude `dist/` in projects that use this deployment model
|
||||||
|
|
||||||
|
### prebuild.py
|
||||||
|
|
||||||
|
`prebuild.py` runs before PyInstaller. It verifies that the active interpreter is the project `.venv`, prints the resolved version, and rewrites the `console=` line in the `.spec` to match `.env`.
|
||||||
|
|
||||||
|
| Variable | Default | Effect |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| `ENV_BUILD_CONSOLE` | `true` | Value written into `console=` in the `.spec` |
|
||||||
|
| `ENV_BUILD_SPEC` | `<project folder>.spec` | Which `.spec` file to update |
|
||||||
|
|
||||||
|
`prebuild.py` touches one `.spec` per run — with several entry points, set `ENV_BUILD_SPEC` for each build.
|
||||||
|
|
||||||
|
Build commands:
|
||||||
|
```bash
|
||||||
|
uv run python prebuild.py
|
||||||
|
uv run pyinstaller ToolName.spec
|
||||||
|
```
|
||||||
|
|
||||||
|
> This section applies only to projects that produce standalone executables. Skip for libraries or web services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Versioning
|
||||||
|
|
||||||
|
- Follow **semantic versioning**: `MAJOR.MINOR.PATCH`
|
||||||
|
- Version is defined in `pyproject.toml` under `[project]`
|
||||||
|
- Always ask before bumping the version — never increment automatically
|
||||||
|
- Update `CHANGELOG.md` before bumping the version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Documentation and Task Management
|
||||||
|
|
||||||
|
- Keep `PROJECT.md` and `CHANGELOG.md` up to date when making changes
|
||||||
|
- Document architectural changes in this file or in `docs/`
|
||||||
|
|
||||||
|
### Task notation
|
||||||
|
|
||||||
|
Tasks are written as single-line comments directly in code, using the **Todo Tree** tags defined in `AGENTS.md` (`TODO`, `FIXME`, `BUG`, `HACK`, `NOTE`). `PROJECT.md` carries only cross-cutting tasks that have no single place in the code.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# TODO: extract this into a separate loader
|
||||||
|
# FIXME: crashes on an empty file
|
||||||
|
# BUG: rounding is off by one cent on negative amounts
|
||||||
|
# HACK: temporary workaround until the API adds paging
|
||||||
|
# NOTE: order matters here, the parser is stateful
|
||||||
|
```
|
||||||
|
|
||||||
|
No other task format is used — **no checkboxes, no numbered lists in documentation**.
|
||||||
|
|
||||||
|
If a tag already exists at a specific location in code, do not repeat it in `PROJECT.md`.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
- Inherited from Tagger: `Tag`, `TagManager`, `File` (sidecar metadata),
|
||||||
|
`FileManager` (folder scan, filtering, ignore patterns), 3-level config,
|
||||||
|
`HardlinkManager` (create/sync/cleanup), pytest suite.
|
||||||
|
- Rename Tagger → Curator done across code, spec, config filenames
|
||||||
|
(`.Curator.!gtag` / `.Curator.!ftag`) and tests.
|
||||||
|
- **PySide6 GUI** (`src/ui/qt_app.py`) reframed around the Filmotéka workflow is
|
||||||
|
the entry point; the old tkinter `src/ui/gui.py` is retained for reference.
|
||||||
|
- **Pool + Filmotéka wired up:** global config holds `pool_dir` / `filmoteka_dir`;
|
||||||
|
`FileManager` creates `Filmy`/`Seriály`, imports movies (copy → `Title.ext`),
|
||||||
|
loads the pool, and the GUI generates the Filmotéka tree via `HardlinkManager`.
|
||||||
|
- `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index**
|
||||||
|
(`<pool>/.Curator.!index`, see `pool_index.py`); `File` writes there when an
|
||||||
|
index is injected. Without an index a `File` is an in-memory object only — the
|
||||||
|
old per-file `.!tag` sidecars and the per-folder `.!ftag` config have been
|
||||||
|
removed.
|
||||||
|
|
||||||
|
### GUI decision
|
||||||
|
|
||||||
|
The GUI was **reframed around the Filmotéka** (not kept as a generic tagger) and
|
||||||
|
**rewritten in PySide6**: Pool/Filmotéka setup, Import movie, tag-filter sidebar,
|
||||||
|
movie table, and one-click Filmotéka generation.
|
||||||
|
|
||||||
|
## 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,60 @@
|
|||||||
|
# Curator
|
||||||
|
|
||||||
|
Curator is a desktop **movie-library manager (Filmotéka)**. You import video
|
||||||
|
files into a managed **pool**, tag them, and Curator generates a browsable
|
||||||
|
**Filmotéka** — a directory tree of hardlinks into the pool — without ever
|
||||||
|
duplicating the actual video data on disk.
|
||||||
|
|
||||||
|
Curator is a fork of the former *Tagger* project; the tagging, filtering and
|
||||||
|
hardlink-tree machinery is inherited and extended into a full library workflow.
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- **Pool** — the managed repository of files and the **single source of truth**.
|
||||||
|
Curator owns it: it imports and removes files itself, so nothing moves behind
|
||||||
|
its back. The pool has two top-level folders:
|
||||||
|
- `Filmy` — movies, organized by tags into the generated tree.
|
||||||
|
- `Seriály` — series (and any other **copy-as-is** folder), mirrored verbatim.
|
||||||
|
- **Filmotéka (output)** — a generated tree of **hardlinks** into the pool. It is
|
||||||
|
fully disposable: delete it and nothing is lost, because it is regenerated from
|
||||||
|
the pool on demand.
|
||||||
|
- **Pool index** — all pool metadata lives in a single `.Curator.!index` JSON
|
||||||
|
file at the pool root (title, ČSFD link, tags, date per file).
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Configure the **pool** and the **Filmotéka output** folder.
|
||||||
|
2. **Import a movie**: pick a video, enter its **Title** and a **ČSFD link**. The
|
||||||
|
file is copied (non-destructively) into `pool/Filmy` as `Title.ext` and
|
||||||
|
recorded in the index. If a ČSFD link is given, Curator fetches the movie from
|
||||||
|
[ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země původu** tags
|
||||||
|
automatically (use "Načíst tagy z ČSFD" to (re)fetch later).
|
||||||
|
3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI.
|
||||||
|
4. **Generate the Filmotéka**: movies become a `Category/Tag/film` hardlink tree
|
||||||
|
(from the `Rok` / `Žánr` / `Hodnocení` categories); copy-as-is folders such as
|
||||||
|
`Seriály` are mirrored 1:1 as hardlinks.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run python Curator.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The GUI is built with **PySide6**.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest # tests
|
||||||
|
uv run ruff check # lint / format
|
||||||
|
uv run mypy # type checking
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building a standalone executable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pyinstaller Curator.spec
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting `dist/Curator` is the distribution artifact.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Curator documentation
|
||||||
|
|
||||||
|
Curator is a PySide6 desktop application that manages a Jellyfin-first movie and
|
||||||
|
series pool: it imports files, identifies them through TMDb, groups multiple
|
||||||
|
versions of a film, keeps series in Jellyfin's layout and generates a miniDLNA /
|
||||||
|
Filmotéka tree.
|
||||||
|
|
||||||
|
- [Architecture](architecture.md) — layers, on-disk layout, the pool index and the
|
||||||
|
rules about versions, series and subtitles. Read this first.
|
||||||
|
- [Module reference](#module-reference) — one document per source module, with a
|
||||||
|
description of the module and of every class and function.
|
||||||
|
|
||||||
|
## Module reference
|
||||||
|
|
||||||
|
| Source | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| [`Curator.py`](modules/entry-point.md) | Application entry point. |
|
||||||
|
| [`src/_version.py`](modules/_version.md) | Auto-generated — do not edit manually. |
|
||||||
|
| [`src/constants.py`](modules/constants.md) | Application-level constants for Curator (build/runtime metadata). |
|
||||||
|
| [`src/core/config.py`](modules/core.config.md) | Configuration for Curator. |
|
||||||
|
| [`src/core/csfd.py`](modules/core.csfd.md) | ČSFD.cz scraper — a *title bridge* to TMDb. |
|
||||||
|
| [`src/core/file.py`](modules/core.file.md) | The `File` class: one playable movie file as the UI sees it. |
|
||||||
|
| [`src/core/file_manager.py`](modules/core.file_manager.md) | `FileManager`: the application's service layer. |
|
||||||
|
| [`src/core/hardlink_manager.py`](modules/core.hardlink_manager.md) | Hardlink Manager for Curator |
|
||||||
|
| [`src/core/integrity.py`](modules/core.integrity.md) | Video integrity checking via FFmpeg. |
|
||||||
|
| [`src/core/jellyfin.py`](modules/core.jellyfin.md) | Jellyfin server API client — thin, hybrid integration. |
|
||||||
|
| [`src/core/legacy_meta.py`](modules/core.legacy_meta.md) | Readers for Curator's *pre-index* metadata layouts. |
|
||||||
|
| [`src/core/list_manager.py`](modules/core.list_manager.md) | Sorting of file lists by name or by date added. |
|
||||||
|
| [`src/core/media_utils.py`](modules/core.media_utils.md) | Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/<height>p` tag. |
|
||||||
|
| [`src/core/migrate.py`](modules/core.migrate.md) | One-off migration of an existing pool to the Jellyfin naming convention. |
|
||||||
|
| [`src/core/movie.py`](modules/core.movie.md) | The movie object — metadata first, files second. |
|
||||||
|
| [`src/core/naming.py`](modules/core.naming.md) | Pool file naming convention. |
|
||||||
|
| [`src/core/nfo.py`](modules/core.nfo.md) | NFO reader — Curator's metadata source after ČSFD. |
|
||||||
|
| [`src/core/pool_index.py`](modules/core.pool_index.md) | Unified metadata index for the Curator pool. |
|
||||||
|
| [`src/core/series.py`](modules/core.series.md) | Series naming + scanning for the Jellyfin TV layout. |
|
||||||
|
| [`src/core/subtitles.py`](modules/core.subtitles.md) | External subtitle sidecars — attach, discover, keep in sync on rename. |
|
||||||
|
| [`src/core/tag.py`](modules/core.tag.md) | The `Tag` value object: a `(category, name)` pair identified by its `full_path` (`Category/Name`). |
|
||||||
|
| [`src/core/tag_manager.py`](modules/core.tag_manager.md) | `TagManager`: the registry of all tag categories and their tags. |
|
||||||
|
| [`src/core/tmdb.py`](modules/core.tmdb.md) | TMDb (The Movie Database) client — Curator's identity source. |
|
||||||
|
| [`src/core/transforms.py`](modules/core.transforms.md) | Tag-schema value transforms for miniDLNA folder names. |
|
||||||
|
| [`src/core/utils.py`](modules/core.utils.md) | Small filesystem helpers shared across the core modules. |
|
||||||
|
| [`src/ui/qt_app.py`](modules/ui.qt_app.md) | PySide6 GUI for Curator — reframed around the Filmotéka (movie-library) workflow. |
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This document describes how Curator is put together: the layers, what owns what
|
||||||
|
on disk, and the handful of rules the rest of the code relies on. The per-module
|
||||||
|
API reference is in [`modules/`](README.md).
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
Curator.py entry point — certificates, State, run()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
src/ui/qt_app.py PySide6 GUI: QtApp (main window) + dialogs
|
||||||
|
│ never touches the filesystem directly
|
||||||
|
▼
|
||||||
|
src/core/file_manager.py FileManager — the service layer
|
||||||
|
│ owns: filelist, pool index, library config, global config
|
||||||
|
│
|
||||||
|
├── domain model file.py (File), movie.py (Movie, MediaFile),
|
||||||
|
│ series.py (Series, Episode), tag.py, tag_manager.py
|
||||||
|
├── persistence pool_index.py (.Curator.!index), config.py
|
||||||
|
│ legacy_meta.py (reads pre-index layouts)
|
||||||
|
├── naming naming.py (movie stems, 3D), series.py (SxxEyy),
|
||||||
|
│ subtitles.py (sidecar names), movie.identity_key
|
||||||
|
├── external services tmdb.py, csfd.py, jellyfin.py, nfo.py (read-only)
|
||||||
|
└── output and checks hardlink_manager.py (miniDLNA / Filmotéka),
|
||||||
|
transforms.py, integrity.py (ffmpeg), migrate.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies only point downwards. The GUI calls `FileManager` methods and
|
||||||
|
receives plain objects back; the core modules never import Qt.
|
||||||
|
|
||||||
|
### Entry point
|
||||||
|
|
||||||
|
`Curator.py` builds a `State` (one `TagManager` and one `FileManager`) and hands
|
||||||
|
both to `src.ui.qt_app.run`. Before that it asks the OS certificate store for
|
||||||
|
HTTPS, because ČSFD and TMDb are reached from behind corporate proxies.
|
||||||
|
|
||||||
|
### GUI
|
||||||
|
|
||||||
|
`QtApp` is the main window: the menu bar, a tag sidebar (an AND filter over the
|
||||||
|
movie list), the movie and series table, a detail panel and the status bar. Each
|
||||||
|
menu action is a method on `QtApp`; most of them open a dialog and then call one
|
||||||
|
`FileManager` method. The dialogs are separate classes in the same module —
|
||||||
|
import, edit, duplicate check, series overview, library organiser and so on.
|
||||||
|
|
||||||
|
### Service layer
|
||||||
|
|
||||||
|
`FileManager` is the only object that changes the pool. Everything the user can
|
||||||
|
do maps onto one of its public methods, for example `import_movie`,
|
||||||
|
`add_movie_version`, `remove_movie_version`, `merge_as_version`,
|
||||||
|
`apply_movie_edits`, `import_episodes`, the miniDLNA scope methods (`minidlna_scope`,
|
||||||
|
`minidlna_prune_targets`) and `find_duplicates`. Methods that change files also keep the pool index and the
|
||||||
|
in-memory `filelist` in step.
|
||||||
|
|
||||||
|
## What lives where on disk
|
||||||
|
|
||||||
|
The pool is one folder. Each top-level folder inside it is a **library**, and each
|
||||||
|
library has a **kind** that decides how Curator treats it:
|
||||||
|
|
||||||
|
| Kind | Holds | Layout |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `movie` | single-file movies and grouped movies | `Title (rok) [tmdbid].ext` or `Title (rok) [tmdbid]/…` |
|
||||||
|
| `series` | series trees | `Series (rok) [tmdbid]/Season NN/SxxEyy - Series.ext` |
|
||||||
|
| `mixed` | movies and series in one folder | movies flat or in a movie folder, series folders with `Season NN` inside |
|
||||||
|
| `homevideo` | files without TMDb ids | mirrored as-is |
|
||||||
|
|
||||||
|
The default pool has `Filmy` (movie) and `Seriály` (series). Library
|
||||||
|
configuration lives in the pool index, not in the global config.
|
||||||
|
|
||||||
|
Two more files sit in the pool root:
|
||||||
|
|
||||||
|
- `.Curator.!index` — the pool index (JSON, version 2). See below.
|
||||||
|
- nothing else: there are no per-file sidecars. Curator's own metadata is in the
|
||||||
|
index; Jellyfin writes its NFO and artwork next to the media.
|
||||||
|
|
||||||
|
Outside the pool, the Filmotéka / miniDLNA output is a tree of hardlinks made by
|
||||||
|
`hardlink_manager`. It never holds metadata of its own.
|
||||||
|
|
||||||
|
## The pool index
|
||||||
|
|
||||||
|
`pool_index.py` keeps one JSON document per pool:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"settings": { "libraries": [...], "tag_schema": [...], "minidlna_libraries": null },
|
||||||
|
"movies": {
|
||||||
|
"tmdb-19995": {
|
||||||
|
"title": "Avatar", "year": "2009", "tmdb_id": 19995,
|
||||||
|
"library": "Filmy",
|
||||||
|
"tags": ["Žánr/Sci-Fi"], "csfd_url": null, "attributes": {}, "date": null,
|
||||||
|
"media": [
|
||||||
|
{"role": "video", "label": "", "file": "Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995].mkv"},
|
||||||
|
{"role": "video", "label": "3D HSBS", "file": "Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key of a movie record is its **identity**:
|
||||||
|
|
||||||
|
- `tmdb-<id>` when the TMDb id is known;
|
||||||
|
- otherwise `name-<slug>` built from the title and year, with diacritics folded
|
||||||
|
(`identity_key` in `movie.py`).
|
||||||
|
|
||||||
|
Everything about a film — title, tags, ČSFD link, attributes, date added — is on
|
||||||
|
that one record. Its `media` list holds every playable file of the film. A version
|
||||||
|
therefore never carries metadata of its own, and removing one version never loses
|
||||||
|
the metadata of the others.
|
||||||
|
|
||||||
|
Version 1 of the index was keyed by file path. `PoolIndex.load` migrates it on
|
||||||
|
first open: metadata only, no file is moved, and the old file is kept as
|
||||||
|
`.Curator.!index.v1.bak`.
|
||||||
|
|
||||||
|
## Movies and versions
|
||||||
|
|
||||||
|
A film starts as one flat file. Adding a second file for the same film turns it
|
||||||
|
into a **grouped movie**: a folder named after the film, with every file in it
|
||||||
|
named after that folder.
|
||||||
|
|
||||||
|
```
|
||||||
|
Filmy/
|
||||||
|
Avatar (2009) [tmdbid-19995]/
|
||||||
|
Avatar (2009) [tmdbid-19995].mkv ← default version, no label
|
||||||
|
Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv ← version with popisek "3D HSBS"
|
||||||
|
Avatar (2009) [tmdbid-19995] - 4K.mkv
|
||||||
|
Avatar (2009) [tmdbid-19995].cs.srt ← subtitles follow their video
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules that the code depends on:
|
||||||
|
|
||||||
|
- **Default version.** The plain version has no label. In the UI it is shown as
|
||||||
|
"Výchozí". A flat movie that has been promoted keeps its 3D formát, if it had one,
|
||||||
|
as its label; otherwise it becomes the bare default.
|
||||||
|
- **3D formát.** A version whose popisek is `3D <FORMÁT>` is 3D; a flat movie
|
||||||
|
carries the same information as `.3D.<FORMÁT>` in its name. Promotion and
|
||||||
|
demotion carry the formát across, so a movie that ends up with only a 3D version
|
||||||
|
is flat again with its tag intact.
|
||||||
|
- **Demotion.** When one version is left, the movie goes back to a flat file named
|
||||||
|
after the folder (plus the 3D tag of the survivor, if any) and the folder is removed.
|
||||||
|
- **Identity.** A file belongs to the film whose identity matches its folder name
|
||||||
|
(grouped) or its own stem minus any `.3D.<FORMAT>` tag (flat). Duplicate checking,
|
||||||
|
import collision handling and index lookups all use this rule.
|
||||||
|
- **Mixed libraries.** A movie's folder and a series folder have the same outer
|
||||||
|
name. They are told apart by content: a series always nests episodes in a
|
||||||
|
`Season NN` or `Specials` subfolder, a movie folder never does
|
||||||
|
(`series.looks_like_series_folder`).
|
||||||
|
|
||||||
|
## Series
|
||||||
|
|
||||||
|
A series is a folder with `Season NN` (and `Specials` for season 0) subfolders.
|
||||||
|
Episodes are named `SxxEyy - Series.ext`; an episode split across several physical
|
||||||
|
files gets Jellyfin's stacking suffix `.ptN`. Series carry no index record: they are
|
||||||
|
scanned from disk each time (`series.scan_series`), so deleting an episode file is
|
||||||
|
the whole of removing it.
|
||||||
|
|
||||||
|
## Subtitles
|
||||||
|
|
||||||
|
Subtitles are sidecar files that share the video's stem, with an optional language
|
||||||
|
and flag: `Name.cs.srt`, `Name.cs.forced.srt`. They are not index entries. Every
|
||||||
|
operation that renames or moves a video calls `subtitles.rename_subtitles_with`,
|
||||||
|
so the sidecars keep their match.
|
||||||
|
|
||||||
|
## Output and external services
|
||||||
|
|
||||||
|
- **miniDLNA / Filmotéka** — `FileManager.minidlna_scope` decides which libraries
|
||||||
|
take part; `HardlinkManager` builds the genre tree and mirrors the copy-as-is
|
||||||
|
libraries. Libraries left out of scope have their old links swept on the next run.
|
||||||
|
- **TMDb** (`tmdb.py`) — the identity source. Turns a TMDb id or a title/year search
|
||||||
|
into a canonical title and year, and supplies genres and years for tags.
|
||||||
|
- **ČSFD** (`csfd.py`) — a bridge for Czech titles: filename → ČSFD film → original
|
||||||
|
title and year → TMDb.
|
||||||
|
- **Jellyfin** (`jellyfin.py`) — library scan trigger and a read-back of what
|
||||||
|
Jellyfin sees, used to verify the pool.
|
||||||
|
- **NFO** (`nfo.py`) — read-only metadata written by Jellyfin, used to fill fields
|
||||||
|
of files imported without them.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Two stores, by responsibility (see `config.py`):
|
||||||
|
|
||||||
|
- the **global config** (`.Curator.!gtag`, next to `Curator.py`) holds application
|
||||||
|
state only — window geometry, recent folders and the pool and output paths;
|
||||||
|
- the **pool index** holds everything that describes a pool — libraries, tag schema,
|
||||||
|
miniDLNA selection and all movie records.
|
||||||
|
|
||||||
|
Secrets come from `.env`: `TMDB_API_KEY`, `JELLYFIN_URL`, `JELLYFIN_API_KEY`,
|
||||||
|
and an optional debug flag read by `constants.py`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests live in `tests/` and run with pytest. The core modules are tested directly
|
||||||
|
against temporary pools; the Qt dialogs are exercised headless
|
||||||
|
(`QT_QPA_PLATFORM=offscreen`) through their methods.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# `src/_version.py`
|
||||||
|
|
||||||
|
Auto-generated — do not edit manually.
|
||||||
|
|
||||||
|
Source: [`src/_version.py`](../../src/_version.py)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# `src/constants.py`
|
||||||
|
|
||||||
|
Application-level constants for Curator (build/runtime metadata).
|
||||||
|
|
||||||
|
This module derives the app version and debug flag. The version is loaded from
|
||||||
|
`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for
|
||||||
|
frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`).
|
||||||
|
|
||||||
|
It is the single source of truth for app metadata used across the GUI and build
|
||||||
|
tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`).
|
||||||
|
|
||||||
|
Source: [`src/constants.py`](../../src/constants.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `get_version`
|
||||||
|
|
||||||
|
*function · line 26*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_version() -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the project version (pyproject → _version.py → unknown).
|
||||||
|
|
||||||
|
### `get_debug_mode`
|
||||||
|
|
||||||
|
*function · line 55*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_debug_mode() -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Return True when ENV_DEBUG is set to a truthy value in the environment.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# `src/core/config.py`
|
||||||
|
|
||||||
|
Configuration for Curator.
|
||||||
|
|
||||||
|
Two stores, by responsibility:
|
||||||
|
|
||||||
|
1. Global config (`.Curator.!gtag` next to Curator.py) — **application data
|
||||||
|
only**: window state, MRU folders, and the pointers to where the pool and the
|
||||||
|
Filmotéka output live (`pool_dir` / `filmoteka_dir`). Those pointers must
|
||||||
|
stay here because the pool index is found *through* them.
|
||||||
|
2. Pool index (`<pool>/.Curator.!index`, see `pool_index.py`) — everything
|
||||||
|
describing a particular library: per-movie metadata **and** library-level
|
||||||
|
settings (`tag_schema`, `copyasis_folders`). It travels with the pool.
|
||||||
|
|
||||||
|
Source: [`src/core/config.py`](../../src/core/config.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `load_global_config`
|
||||||
|
|
||||||
|
*function · line 68*
|
||||||
|
|
||||||
|
```python
|
||||||
|
load_global_config() -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Load global application config
|
||||||
|
|
||||||
|
### `save_global_config`
|
||||||
|
|
||||||
|
*function · line 84*
|
||||||
|
|
||||||
|
```python
|
||||||
|
save_global_config(cfg: dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
Save global application config
|
||||||
|
|
||||||
|
### `load_config`
|
||||||
|
|
||||||
|
*function · line 94*
|
||||||
|
|
||||||
|
```python
|
||||||
|
load_config()
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy function - returns global config
|
||||||
|
|
||||||
|
### `save_config`
|
||||||
|
|
||||||
|
*function · line 99*
|
||||||
|
|
||||||
|
```python
|
||||||
|
save_config(cfg: dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy function - saves global config
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# `src/core/csfd.py`
|
||||||
|
|
||||||
|
ČSFD.cz scraper — a *title bridge* to TMDb.
|
||||||
|
|
||||||
|
Curator identifies movies by **TMDb id**. A direct TMDb search on a Czech
|
||||||
|
distribution title is unreliable (remakes, localized names, "weird" hits).
|
||||||
|
ČSFD, on the other hand, indexes Czech release names well **and** lists a
|
||||||
|
film's *original* title. So the bridge is::
|
||||||
|
|
||||||
|
filename → ČSFD search → ČSFD film page → original title + year
|
||||||
|
→ TMDb search → tmdbid
|
||||||
|
|
||||||
|
This module only scrapes ČSFD; the TMDb half lives in
|
||||||
|
`src.core.file_manager` (`FileManager.resolve_via_csfd`). Nothing
|
||||||
|
here feeds tags/genres — those still come from TMDb via Jellyfin/NFO.
|
||||||
|
|
||||||
|
ČSFD serves an **Anubis** proof-of-work wall in front of every page: a plain
|
||||||
|
request gets a 200 with a JS challenge instead of the movie. We detect it,
|
||||||
|
solve the SHA-256 PoW the way the bundled worker JS does, and replay the
|
||||||
|
request through a shared `requests.Session` (so a batch pays the PoW
|
||||||
|
cost only once).
|
||||||
|
|
||||||
|
Source: [`src/core/csfd.py`](../../src/core/csfd.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `CsfdError`
|
||||||
|
|
||||||
|
*class · line 62*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CsfdError(RuntimeError):
|
||||||
|
```
|
||||||
|
|
||||||
|
A ČSFD lookup could not be completed (deps missing, network, anti-bot).
|
||||||
|
|
||||||
|
### `CsfdFilm`
|
||||||
|
|
||||||
|
*class · line 67*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CsfdFilm:
|
||||||
|
```
|
||||||
|
|
||||||
|
The slice of a ČSFD film page Curator needs to reach a TMDb id.
|
||||||
|
|
||||||
|
#### `CsfdFilm.tmdb_query`
|
||||||
|
|
||||||
|
*property · line 77*
|
||||||
|
|
||||||
|
```python
|
||||||
|
tmdb_query -> tuple[str, Optional[str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(title, year)` best suited for a TMDb search — original title first.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `dependencies_available`
|
||||||
|
|
||||||
|
*function · line 82*
|
||||||
|
|
||||||
|
```python
|
||||||
|
dependencies_available() -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when `requests` + `beautifulsoup4` are importable.
|
||||||
|
|
||||||
|
### `_check_dependencies`
|
||||||
|
|
||||||
|
*function · line 87*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_check_dependencies() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Raise `CsfdError` when the optional `requests` / `beautifulsoup4` packages are missing (they are needed only for ČSFD scraping).
|
||||||
|
|
||||||
|
### `_extract_csfd_id`
|
||||||
|
|
||||||
|
*function · line 95*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_extract_csfd_id(url: str) -> Optional[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pull the numeric film id out of a ČSFD URL, or `None`.
|
||||||
|
|
||||||
|
### `_extract_json_blob`
|
||||||
|
|
||||||
|
*function · line 105*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_extract_json_blob(html: str, element_id: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the parsed JSON from an Anubis `<script id=...>` blob, or None.
|
||||||
|
|
||||||
|
### `_solve_anubis_pow`
|
||||||
|
|
||||||
|
*function · line 120*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Brute-force the Anubis proof-of-work.
|
||||||
|
|
||||||
|
Mirrors the bundled `sha256-purejs` worker: find the smallest `nonce`
|
||||||
|
such that `sha256(random_data + str(nonce))` has `difficulty` leading
|
||||||
|
zero nibbles. Returns `(hash_hex, nonce, elapsed_ms)`.
|
||||||
|
|
||||||
|
### `_solve_anubis_challenge`
|
||||||
|
|
||||||
|
*function · line 143*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_solve_anubis_challenge(session, html: str, url: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Solve the Anubis challenge in `html` and return the real page response.
|
||||||
|
|
||||||
|
### `_get_page`
|
||||||
|
|
||||||
|
*function · line 180*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_get_page(session, url: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
GET `url` through `session`, transparently clearing an Anubis wall.
|
||||||
|
|
||||||
|
### `_parse_json_ld`
|
||||||
|
|
||||||
|
*function · line 193*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_json_ld(soup) -> tuple[str, Optional[str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(name, year)` from the film page's JSON-LD (name is the Czech title).
|
||||||
|
|
||||||
|
### `_parse_origin`
|
||||||
|
|
||||||
|
*function · line 215*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_origin(soup) -> tuple[Optional[str], Optional[str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(origin_country, year)` from the `.origin` line.
|
||||||
|
|
||||||
|
ČSFD separates values with inline bullet `<span>` elements (no commas),
|
||||||
|
e.g. `USA • 1999 • 136 min`. For a co-production only the first country
|
||||||
|
is returned — enough to match it against a name's flag.
|
||||||
|
|
||||||
|
### `_parse_original_title`
|
||||||
|
|
||||||
|
*function · line 245*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_original_title(soup, origin_country: Optional[str]) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The film's original title from `.film-names` — or `None` to use the CZ title.
|
||||||
|
|
||||||
|
Each `<li>` carries a country/language flag (`img.flag` `title`) and a
|
||||||
|
title. The original title is the entry whose flag matches the **country of
|
||||||
|
origin** (USA → "The Shawshank Redemption"). ČSFD does **not** repeat the
|
||||||
|
Czech title here when it equals the `<h1>`, so a Czech film has only
|
||||||
|
foreign/festival alt-titles in this list (`[angličtina] Cosy Dens`,
|
||||||
|
`[Slovinsko] Samotarji`) — picking one of those would be wrong, so we
|
||||||
|
return `None` and let the caller fall back to the Czech `<h1>` title.
|
||||||
|
|
||||||
|
### `fetch_film`
|
||||||
|
|
||||||
|
*function · line 276*
|
||||||
|
|
||||||
|
```python
|
||||||
|
fetch_film(url: str, session=None) -> CsfdFilm
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetch a ČSFD film page → `CsfdFilm` (Czech + original title, year).
|
||||||
|
|
||||||
|
Pass a shared `requests.Session` to reuse the Anubis auth cookie across a
|
||||||
|
batch. Raises `CsfdError` on a network / anti-bot / parse failure.
|
||||||
|
|
||||||
|
### `_films_section`
|
||||||
|
|
||||||
|
*function · line 311*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_films_section(soup)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<section>` holding the *Filmy* results, or the whole document.
|
||||||
|
|
||||||
|
ČSFD's search page groups hits into `Filmy` / `Seriály` / `Tvůrci` /
|
||||||
|
`Uživatelé` sections; `a.film-title-name` matches in all of them, so
|
||||||
|
without scoping a bare-title search happily returns a series episode.
|
||||||
|
|
||||||
|
### `_is_film_href`
|
||||||
|
|
||||||
|
*function · line 325*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_is_film_href(href: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True for a film URL `/film/<id>-slug/` — a series episode has two segments
|
||||||
|
(`/film/<series>/<episode>/`) and is rejected.
|
||||||
|
|
||||||
|
### `search_films`
|
||||||
|
|
||||||
|
*function · line 332*
|
||||||
|
|
||||||
|
```python
|
||||||
|
search_films(query: str, limit: int=5, session=None) -> list[CsfdFilm]
|
||||||
|
```
|
||||||
|
|
||||||
|
Search ČSFD for `query`; film hits only (no series/episodes), best first.
|
||||||
|
|
||||||
|
### `find_csfd_url`
|
||||||
|
|
||||||
|
*function · line 365*
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_csfd_url(query: str, session=None) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the first ČSFD film URL matching `query`, or `None`.
|
||||||
|
|
||||||
|
ČSFD's full-text search treats a trailing year as a search term (not a
|
||||||
|
filter) and can miss on `"Title 2006"` while `"Title"` hits — so a
|
||||||
|
yearful query with no result is retried without the year.
|
||||||
|
|
||||||
|
### `clean_filename_to_query`
|
||||||
|
|
||||||
|
*function · line 397*
|
||||||
|
|
||||||
|
```python
|
||||||
|
clean_filename_to_query(filename: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Turn a (possibly release-named) filename into a ČSFD search query.
|
||||||
|
|
||||||
|
Strips the path/extension, splits on common separators and keeps the words
|
||||||
|
before the first release marker (year, resolution, codec, source, …). The
|
||||||
|
detected year is appended back as a disambiguator::
|
||||||
|
|
||||||
|
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# `src/core/file.py`
|
||||||
|
|
||||||
|
The `File` class: one playable movie file as the UI sees it. A `File` is a *view* onto one media entry of a movie record in the pool index — metadata (title, tags, ČSFD link, attributes, date) lives on the shared record, so every version of a film reads the same values. See `docs/architecture.md`.
|
||||||
|
|
||||||
|
Source: [`src/core/file.py`](../../src/core/file.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `File`
|
||||||
|
|
||||||
|
*class · line 5*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class File:
|
||||||
|
```
|
||||||
|
|
||||||
|
One movie file in the pool, as the UI sees it. Holds the path, the list of tags and the metadata accessors; metadata itself is stored on the movie record in the pool index and the `File` reads it through it.
|
||||||
|
|
||||||
|
#### `File.__init__`
|
||||||
|
|
||||||
|
*method · line 6*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__(file_path: Path, tagmanager=None, index=None) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a file object for `file_path`. Metadata is loaded from the pool index when `index` is given; without an index the object is in-memory only.
|
||||||
|
|
||||||
|
#### `File._identity_from_path`
|
||||||
|
|
||||||
|
*method · line 41*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_identity_from_path() -> tuple[str, str, str | None, int | None, str]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(key, title, year, tmdb_id, label)` implied by this file's location.
|
||||||
|
|
||||||
|
A file inside its own `Title (rok) [tmdbid]` folder is one version of
|
||||||
|
that movie — identity comes from the **folder**, and the rest of its own
|
||||||
|
stem is the version label. A flat file carries both in its own name.
|
||||||
|
|
||||||
|
#### `File.get_metadata`
|
||||||
|
|
||||||
|
*method · line 67*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_metadata() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Load this file's metadata from the movie record that owns it.
|
||||||
|
|
||||||
|
Since the pool index became identity-keyed (see `src.core.movie`),
|
||||||
|
a File is a *view* on one media entry of a movie record — every version
|
||||||
|
of the same film therefore reads the same title/tags/ČSFD/… A file the
|
||||||
|
index doesn't know yet joins the movie of the same identity when there
|
||||||
|
is one (that's how a newly added version inherits the shared metadata
|
||||||
|
instead of blanking it), otherwise it starts a new record.
|
||||||
|
|
||||||
|
#### `File._init_new_metadata`
|
||||||
|
|
||||||
|
*method · line 95*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_init_new_metadata() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset all metadata fields to their defaults for a file the index doesn't know yet.
|
||||||
|
|
||||||
|
#### `File._build_record`
|
||||||
|
|
||||||
|
*method · line 107*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_build_record() -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Serialise this file's metadata into the dictionary stored in the pool index (tags as full paths, auto-derived tags, date, title, ČSFD link, …).
|
||||||
|
|
||||||
|
#### `File._apply_record`
|
||||||
|
|
||||||
|
*method · line 126*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_apply_record(data: dict) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Load metadata from an index record dictionary into this object's fields (the inverse of `_build_record`). Missing keys fall back to defaults.
|
||||||
|
|
||||||
|
#### `File.save_metadata`
|
||||||
|
|
||||||
|
*method · line 152*
|
||||||
|
|
||||||
|
```python
|
||||||
|
save_metadata()
|
||||||
|
```
|
||||||
|
|
||||||
|
Persist metadata into this file's movie record (no-op without index).
|
||||||
|
|
||||||
|
Writes the shared fields onto the movie record and makes sure the
|
||||||
|
record lists this file among its media. A file the index has never seen
|
||||||
|
starts a new record under its own identity key.
|
||||||
|
|
||||||
|
#### `File._library_name`
|
||||||
|
|
||||||
|
*method · line 182*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_library_name() -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Top-level pool folder this file sits in (its library), or `""`.
|
||||||
|
|
||||||
|
#### `File.added_timestamp`
|
||||||
|
|
||||||
|
*method · line 190*
|
||||||
|
|
||||||
|
```python
|
||||||
|
added_timestamp() -> float
|
||||||
|
```
|
||||||
|
|
||||||
|
Epoch seconds for 'date added' — the stored `added` or the file mtime.
|
||||||
|
|
||||||
|
Used to order the "recently added" folder (newer = larger). The fallback
|
||||||
|
is **mtime**, not ctime: creating the Filmotéka hardlinks bumps the source
|
||||||
|
inode's ctime, which would make ctime-based ordering meaningless, whereas
|
||||||
|
mtime (preserved from the source on import) stays stable.
|
||||||
|
|
||||||
|
#### `File.name_context`
|
||||||
|
|
||||||
|
*method · line 209*
|
||||||
|
|
||||||
|
```python
|
||||||
|
name_context() -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields for a miniDLNA filename template (see config tag schema).
|
||||||
|
|
||||||
|
Provides title / year / rating / ext / stem / filename from the movie's
|
||||||
|
metadata (year & rating come from the NFO cache, year falling back to a
|
||||||
|
`Rok` tag, then the canonical filename). Missing values render empty.
|
||||||
|
|
||||||
|
#### `File.set_attribute`
|
||||||
|
|
||||||
|
*method · line 242*
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_attribute(key: str, value: str | None) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it.
|
||||||
|
|
||||||
|
#### `File.delete_metadata`
|
||||||
|
|
||||||
|
*method · line 253*
|
||||||
|
|
||||||
|
```python
|
||||||
|
delete_metadata() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Drop this file from its movie record; the last one takes the record.
|
||||||
|
|
||||||
|
Removing one version of a multi-version movie leaves the movie (and its
|
||||||
|
metadata) in place — only when its last media entry goes does the whole
|
||||||
|
record go with it.
|
||||||
|
|
||||||
|
#### `File.relocate`
|
||||||
|
|
||||||
|
*method · line 273*
|
||||||
|
|
||||||
|
```python
|
||||||
|
relocate(new_path: Path) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Point this File at a new path, moving its media entry along.
|
||||||
|
|
||||||
|
The physical file must already have been moved/renamed by the caller;
|
||||||
|
this only repoints the movie record's media entry (and the File), so a
|
||||||
|
rename never detaches a file from its movie.
|
||||||
|
|
||||||
|
#### `File.set_date`
|
||||||
|
|
||||||
|
*method · line 294*
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_date(date_str: str | None)
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the date the movie was added (`YYYY-MM-DD`) or clear it with `None` / an empty string, then save the metadata.
|
||||||
|
|
||||||
|
#### `File.apply_nfo`
|
||||||
|
|
||||||
|
*method · line 303*
|
||||||
|
|
||||||
|
```python
|
||||||
|
apply_nfo(nfo, schema: list[dict] | None=None) -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Regenerate the auto (NFO-derived) tags from the movie's NFO.
|
||||||
|
|
||||||
|
Reads genres/year/countries/rating from `nfo` (`nfo.NfoData`) per the
|
||||||
|
tag schema's source fields and (re)creates those tags; user-added tags are
|
||||||
|
kept. Also caches year/rating (for folder grouping) and fills the title
|
||||||
|
when missing. `nfo` of `None` (no NFO yet) is a no-op.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'success' and 'tags_added'.
|
||||||
|
|
||||||
|
#### `File.add_tag`
|
||||||
|
|
||||||
|
*method · line 347*
|
||||||
|
|
||||||
|
```python
|
||||||
|
add_tag(tag)
|
||||||
|
```
|
||||||
|
|
||||||
|
Attach a tag to this file. Accepts a `Tag` or a `category/name` string; a string without a category goes to the `default` category. Saves on change.
|
||||||
|
|
||||||
|
#### `File.remove_tag`
|
||||||
|
|
||||||
|
*method · line 364*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_tag(tag)
|
||||||
|
```
|
||||||
|
|
||||||
|
Detach a tag, given as a `Tag` or a `category/name` string. Saves on change.
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
# `src/core/hardlink_manager.py`
|
||||||
|
|
||||||
|
Hardlink Manager for Curator
|
||||||
|
|
||||||
|
Creates directory structure based on file tags and creates hardlinks
|
||||||
|
to organize files without duplicating them on disk.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:
|
||||||
|
|
||||||
|
output/
|
||||||
|
├── žánr/
|
||||||
|
│ ├── Komedie/
|
||||||
|
│ │ └── film.mkv (hardlink)
|
||||||
|
│ └── Akční/
|
||||||
|
│ └── film.mkv (hardlink)
|
||||||
|
└── rok/
|
||||||
|
└── 1988/
|
||||||
|
└── film.mkv (hardlink)
|
||||||
|
|
||||||
|
Source: [`src/core/hardlink_manager.py`](../../src/core/hardlink_manager.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `_SafeDict`
|
||||||
|
|
||||||
|
*class · line 30*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class _SafeDict(dict):
|
||||||
|
```
|
||||||
|
|
||||||
|
dict for str.format_map that leaves unknown fields as an empty string.
|
||||||
|
|
||||||
|
### `HardlinkManager`
|
||||||
|
|
||||||
|
*class · line 37*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class HardlinkManager:
|
||||||
|
```
|
||||||
|
|
||||||
|
Manager for creating hardlink-based directory structures from tagged files.
|
||||||
|
|
||||||
|
The output layout is driven by a *category → root folder* mapping
|
||||||
|
(`category_roots`). Each tag is placed at
|
||||||
|
`output/<root>/<tag_name>/<file>`; an empty root means the tag's own
|
||||||
|
folders sit directly at the output root (e.g. genre folders next to the
|
||||||
|
"Dle roku" / "Dle země původu" folders). The legacy `categories` list
|
||||||
|
(folder == category name) is still accepted and treated as the identity
|
||||||
|
mapping `{cat: cat}`.
|
||||||
|
|
||||||
|
#### `HardlinkManager.__init__`
|
||||||
|
|
||||||
|
*method · line 49*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__(output_dir: Path)
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialize HardlinkManager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Base directory where the tag-based structure will be created
|
||||||
|
|
||||||
|
#### `HardlinkManager._resolve_roots`
|
||||||
|
|
||||||
|
*method · line 60*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_resolve_roots(categories: Optional[List[str]], category_roots: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Normalize the two filter styles into a category → root-folder map.
|
||||||
|
|
||||||
|
`None` means "all categories", folder == category name.
|
||||||
|
|
||||||
|
#### `HardlinkManager._clean_title`
|
||||||
|
|
||||||
|
*method · line 75*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_clean_title(file_obj: File) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
The movie's clean title (no trailing `(YYYY)`), path-safe.
|
||||||
|
|
||||||
|
#### `HardlinkManager._default_link_name`
|
||||||
|
|
||||||
|
*method · line 85*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_default_link_name(file_obj: File) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Filmotéka hardlink name: the clean title + extension (no year).
|
||||||
|
|
||||||
|
#### `HardlinkManager._year_link_name`
|
||||||
|
|
||||||
|
*method · line 89*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_year_link_name(file_obj: File) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
`Title (YYYY).ext` used to disambiguate a name collision, or None.
|
||||||
|
|
||||||
|
#### `HardlinkManager._render_template`
|
||||||
|
|
||||||
|
*method · line 96*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_render_template(file_obj: File, template: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Render a per-category filename template; fall back to the clean name.
|
||||||
|
|
||||||
|
#### `HardlinkManager._plan_links`
|
||||||
|
|
||||||
|
*method · line 105*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_plan_links(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]], templates: Optional[Dict[str, str]]) -> List[Tuple[File, Path]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve the tag tree to a collision-free `(file, link_path)` plan.
|
||||||
|
|
||||||
|
The default hardlink name is the movie's **clean title** (no year). When
|
||||||
|
two *different* movies would land on the same name in the same folder,
|
||||||
|
the colliding ones fall back to the year-qualified `Title (YYYY).ext` to
|
||||||
|
tell them apart; a residual clash (same title *and* year) gets a stable
|
||||||
|
numeric suffix so every path is unique. A per-category
|
||||||
|
`filename_template` overrides the default inside that category's folders.
|
||||||
|
|
||||||
|
Both link creation and obsolete detection consume this one plan, so they
|
||||||
|
can never disagree on a name (which is what caused hardlink churn before).
|
||||||
|
|
||||||
|
#### `HardlinkManager._folder_value`
|
||||||
|
|
||||||
|
*method · line 172*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_folder_value(tag, transforms: Optional[Dict[str, str]]) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Folder name for a tag — its value run through the category transform.
|
||||||
|
|
||||||
|
The tag keeps its exact value; the grouping (e.g. rating → ten-point
|
||||||
|
band) only happens here, when naming the folder.
|
||||||
|
|
||||||
|
#### `HardlinkManager._target_dir`
|
||||||
|
|
||||||
|
*method · line 183*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_target_dir(tag, roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None) -> Optional[Path]
|
||||||
|
```
|
||||||
|
|
||||||
|
Output directory for a tag, or None if its category is excluded.
|
||||||
|
|
||||||
|
#### `HardlinkManager._managed_top_dirs`
|
||||||
|
|
||||||
|
*method · line 197*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_managed_top_dirs(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Optional[Set[str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Top-level output folders owned by the tag tree (None = all of them).
|
||||||
|
|
||||||
|
A category with a non-empty root owns that root folder. A category placed
|
||||||
|
at the output root (empty root, e.g. genres) owns its own folders at the
|
||||||
|
root — and, so a genre whose last movie dropped the tag still gets its
|
||||||
|
now-stale folder cleaned, *every* root-level folder is treated as owned
|
||||||
|
except the grouping roots and any `reserved_subfolders` (copy-as-is
|
||||||
|
mirrors, "Tipy dne", …). The obsolete check still only removes links to
|
||||||
|
current pool files, so reserved/foreign folders are doubly protected.
|
||||||
|
|
||||||
|
#### `HardlinkManager.create_structure_for_files`
|
||||||
|
|
||||||
|
*method · line 236*
|
||||||
|
|
||||||
|
```python
|
||||||
|
create_structure_for_files(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None) -> Tuple[int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Create hardlink structure for given files based on their tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects to process
|
||||||
|
categories: Optional list of categories to include (None = all)
|
||||||
|
dry_run: If True, only simulate without creating actual links
|
||||||
|
category_roots: Optional category → root-folder map (see class doc);
|
||||||
|
overrides `categories` when given.
|
||||||
|
category_transforms: Optional category → transform-name map applied to
|
||||||
|
the tag value when naming its folder (e.g. rating → decade band).
|
||||||
|
category_filename_templates: Optional category → hardlink-name template
|
||||||
|
applied only inside that category's folders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (successful_links, failed_links)
|
||||||
|
|
||||||
|
#### `HardlinkManager.mirror_as_is`
|
||||||
|
|
||||||
|
*method · line 294*
|
||||||
|
|
||||||
|
```python
|
||||||
|
mirror_as_is(source_dir: Path, subfolder: str | None=None, dry_run: bool=False) -> Tuple[int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.
|
||||||
|
|
||||||
|
Recreates the exact directory hierarchy of `source_dir` under
|
||||||
|
`output_dir/subfolder` (or directly under `output_dir` when
|
||||||
|
`subfolder` is None) and hardlinks every file. Curator metadata files
|
||||||
|
(`.!tag` / `.!ftag` / `.!gtag` / `.!index`) are skipped.
|
||||||
|
|
||||||
|
Used for Seriály: the pool structure is the source of truth and is cloned
|
||||||
|
verbatim instead of being rebuilt from tags.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (successful_links, failed_links)
|
||||||
|
|
||||||
|
#### `HardlinkManager.remove_mirror`
|
||||||
|
|
||||||
|
*method · line 345*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_mirror(name: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove `output_dir/name` entirely (file or folder tree).
|
||||||
|
|
||||||
|
Used to drop a whole mirrored library (or one entry of the shared
|
||||||
|
mixed-library-series bucket) when it falls out of the miniDLNA
|
||||||
|
generation's scope. Mirrors are pure hardlinks, so deleting the folder
|
||||||
|
loses nothing — it's fully regenerable from the pool. `name` may
|
||||||
|
contain "/" to address one entry inside a bucket folder.
|
||||||
|
|
||||||
|
Returns whether anything was actually removed.
|
||||||
|
|
||||||
|
#### `HardlinkManager._fill_special_folder`
|
||||||
|
|
||||||
|
*method · line 365*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_fill_special_folder(chosen: List[File], subfolder: str, dry_run: bool) -> Tuple[int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty `subfolder` and hardlink `chosen` files into it.
|
||||||
|
|
||||||
|
#### `HardlinkManager.generate_recently_added`
|
||||||
|
|
||||||
|
*method · line 405*
|
||||||
|
|
||||||
|
```python
|
||||||
|
generate_recently_added(files: List[File], count: int=10, subfolder: str='- Nově přidané', dry_run: bool=False) -> Tuple[int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
(Re)fill `subfolder` with the `count` most recently added movies.
|
||||||
|
|
||||||
|
Ordering is by `File.added_timestamp()` (import time, ctime fallback),
|
||||||
|
newest first. Returns (created_links, failed_links).
|
||||||
|
|
||||||
|
#### `HardlinkManager.generate_random_tips`
|
||||||
|
|
||||||
|
*method · line 420*
|
||||||
|
|
||||||
|
```python
|
||||||
|
generate_random_tips(files: List[File], count: int=10, subfolder: str='Tipy dne', dry_run: bool=False) -> Tuple[int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
(Re)fill `subfolder` with `count` random files as hardlinks.
|
||||||
|
|
||||||
|
The folder is emptied first so each run picks a fresh random selection.
|
||||||
|
`files` should be the pool movies only (copy-as-is mirrors excluded).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (created_links, failed_links)
|
||||||
|
|
||||||
|
#### `HardlinkManager._is_same_file`
|
||||||
|
|
||||||
|
*method · line 438*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_is_same_file(path1: Path, path2: Path) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Check if two paths point to the same file (same inode).
|
||||||
|
|
||||||
|
#### `HardlinkManager._get_unique_name`
|
||||||
|
|
||||||
|
*method · line 445*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_get_unique_name(path: Path) -> Path
|
||||||
|
```
|
||||||
|
|
||||||
|
Get a unique filename by adding a numeric suffix.
|
||||||
|
|
||||||
|
#### `HardlinkManager.remove_created_links`
|
||||||
|
|
||||||
|
*method · line 459*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_created_links() -> int
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove all hardlinks created by the last operation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of links removed
|
||||||
|
|
||||||
|
#### `HardlinkManager._remove_empty_parents`
|
||||||
|
|
||||||
|
*method · line 481*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_remove_empty_parents(path: Path) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove empty parent directories up to output_dir.
|
||||||
|
|
||||||
|
#### `HardlinkManager.get_preview`
|
||||||
|
|
||||||
|
*method · line 492*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_preview(files: List[File], categories: Optional[List[str]]=None, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None) -> List[Tuple[Path, Path]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Get a preview of what links would be created.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects
|
||||||
|
categories: Optional list of categories to include
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
`categories` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (source_path, target_path)
|
||||||
|
|
||||||
|
#### `HardlinkManager.find_obsolete_links`
|
||||||
|
|
||||||
|
*method · line 519*
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_obsolete_links(files: List[File], categories: Optional[List[str]]=None, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> List[Tuple[Path, Path]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Find hardlinks in the output directory that no longer match file tags.
|
||||||
|
|
||||||
|
Scans the managed parts of the output directory for hardlinks that point
|
||||||
|
to source files but whose path no longer matches the file's current tags.
|
||||||
|
Only the tag-tree's own top-level folders are scanned, so copy-as-is
|
||||||
|
mirrors (e.g. Seriály) are left untouched.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects (source files)
|
||||||
|
categories: Optional list of categories to check (None = all)
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
`categories` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (link_path, source_path) for obsolete links
|
||||||
|
|
||||||
|
#### `HardlinkManager.remove_obsolete_links`
|
||||||
|
|
||||||
|
*method · line 608*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_obsolete_links(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, List[Path]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove hardlinks that no longer match file tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects
|
||||||
|
categories: Optional list of categories to check
|
||||||
|
dry_run: If True, only return what would be removed
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
`categories` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
reserved_subfolders: Output top-level folders to never touch (mirrors,
|
||||||
|
"Tipy dne", …).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (removed_count, list_of_removed_paths)
|
||||||
|
|
||||||
|
#### `HardlinkManager.sync_structure`
|
||||||
|
|
||||||
|
*method · line 655*
|
||||||
|
|
||||||
|
```python
|
||||||
|
sync_structure(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, int, int, int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Synchronize hardlink structure with current file tags.
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Remove hardlinks for removed tags
|
||||||
|
2. Create new hardlinks for new tags
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects
|
||||||
|
categories: Optional list of categories to sync
|
||||||
|
dry_run: If True, only simulate
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
`categories` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
reserved_subfolders: Output top-level folders to never touch (mirrors,
|
||||||
|
"Tipy dne", …).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (created, create_failed, removed, remove_failed)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `create_hardlink_structure`
|
||||||
|
|
||||||
|
*function · line 707*
|
||||||
|
|
||||||
|
```python
|
||||||
|
create_hardlink_structure(files: List[File], output_dir: Path, categories: Optional[List[str]]=None) -> Tuple[int, int, List[Tuple[Path, str]]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Convenience function to create hardlink structure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects to process
|
||||||
|
output_dir: Base directory for output
|
||||||
|
categories: Optional list of categories to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (successful_count, failed_count, errors_list)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# `src/core/integrity.py`
|
||||||
|
|
||||||
|
Video integrity checking via FFmpeg.
|
||||||
|
|
||||||
|
Detects corrupted or unreadable video files. Two depths:
|
||||||
|
|
||||||
|
- **deep** (default): a full `ffmpeg` decode to the null muxer
|
||||||
|
(`-f null`). It actually decodes every frame, so it catches mid-file data
|
||||||
|
corruption (broken GOPs, truncated streams, bad packets). Thorough but slow —
|
||||||
|
it reads and decodes the whole file.
|
||||||
|
- **quick**: an `ffprobe` container/stream probe. Fast but shallow — it catches
|
||||||
|
an unreadable container, a truncated header or a missing video stream, but not
|
||||||
|
corruption buried inside otherwise-parseable data.
|
||||||
|
|
||||||
|
Both tools ship with FFmpeg; `ffmpeg_available` reports whether they are on the
|
||||||
|
PATH so callers can degrade gracefully.
|
||||||
|
|
||||||
|
Source: [`src/core/integrity.py`](../../src/core/integrity.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `IntegrityResult`
|
||||||
|
|
||||||
|
*class · line 30*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class IntegrityResult:
|
||||||
|
```
|
||||||
|
|
||||||
|
Outcome of checking one file.
|
||||||
|
|
||||||
|
`ok` — the file decoded/probed cleanly.
|
||||||
|
`checked` — the check actually ran; False means it couldn't (missing tool,
|
||||||
|
missing file), which is *not* the same as "corrupted".
|
||||||
|
`error` — a short reason when not `ok`.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `ffmpeg_available`
|
||||||
|
|
||||||
|
*function · line 45*
|
||||||
|
|
||||||
|
```python
|
||||||
|
ffmpeg_available(deep: bool=True) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Is the required FFmpeg tool (ffmpeg for deep, ffprobe for quick) on PATH?
|
||||||
|
|
||||||
|
### `check_video_integrity`
|
||||||
|
|
||||||
|
*function · line 50*
|
||||||
|
|
||||||
|
```python
|
||||||
|
check_video_integrity(path: Path, deep: bool=True, timeout: Optional[int]=None) -> IntegrityResult
|
||||||
|
```
|
||||||
|
|
||||||
|
Check a single video file for corruption.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: the video file to check.
|
||||||
|
deep: True → full ffmpeg decode (catches data corruption, slow);
|
||||||
|
False → ffprobe probe (catches unreadable/truncated files, fast).
|
||||||
|
timeout: per-file timeout in seconds (None = no limit).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An `IntegrityResult`. `checked=False` when the check could not
|
||||||
|
be run at all (tool or file missing) — distinct from a real failure.
|
||||||
|
|
||||||
|
### `_first_error_line`
|
||||||
|
|
||||||
|
*function · line 108*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_first_error_line(stderr: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
First non-empty stderr line, truncated — a compact reason for the report.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# `src/core/jellyfin.py`
|
||||||
|
|
||||||
|
Jellyfin server API client — thin, hybrid integration.
|
||||||
|
|
||||||
|
The NFO + artwork on disk stay authoritative (Jellyfin writes and reads them);
|
||||||
|
this client only does what the disk can't: **trigger a library scan** and **read
|
||||||
|
back what Jellyfin currently sees** (to verify the pool against it). Connection
|
||||||
|
details come from `.env` — `JELLYFIN_URL` and `JELLYFIN_API_KEY` (created in
|
||||||
|
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
|
||||||
|
|
||||||
|
Source: [`src/core/jellyfin.py`](../../src/core/jellyfin.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `JellyfinMovie`
|
||||||
|
|
||||||
|
*class · line 25*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class JellyfinMovie:
|
||||||
|
```
|
||||||
|
|
||||||
|
A movie as Jellyfin currently sees it (for verification).
|
||||||
|
|
||||||
|
### `JellyfinError`
|
||||||
|
|
||||||
|
*class · line 35*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class JellyfinError(RuntimeError):
|
||||||
|
```
|
||||||
|
|
||||||
|
A Jellyfin request could not be completed (no config, network, status).
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `base_url`
|
||||||
|
|
||||||
|
*function · line 39*
|
||||||
|
|
||||||
|
```python
|
||||||
|
base_url() -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The configured Jellyfin base URL (no trailing slash), or None.
|
||||||
|
|
||||||
|
### `api_key`
|
||||||
|
|
||||||
|
*function · line 45*
|
||||||
|
|
||||||
|
```python
|
||||||
|
api_key() -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The configured Jellyfin API key, or None.
|
||||||
|
|
||||||
|
### `api_available`
|
||||||
|
|
||||||
|
*function · line 51*
|
||||||
|
|
||||||
|
```python
|
||||||
|
api_available() -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when both the Jellyfin URL and API key are configured.
|
||||||
|
|
||||||
|
### `_request`
|
||||||
|
|
||||||
|
*function · line 56*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_request(method: str, path: str, params: Optional[dict]=None, timeout: Optional[int]=None) -> requests.Response
|
||||||
|
```
|
||||||
|
|
||||||
|
Send one authenticated request to the Jellyfin server (`X-Emby-Token` header). Raises `JellyfinError` when the URL/key are missing or the server can't be reached.
|
||||||
|
|
||||||
|
### `server_version`
|
||||||
|
|
||||||
|
*function · line 74*
|
||||||
|
|
||||||
|
```python
|
||||||
|
server_version(timeout: Optional[int]=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the Jellyfin server version (a quick connectivity check).
|
||||||
|
|
||||||
|
### `scan_all`
|
||||||
|
|
||||||
|
*function · line 80*
|
||||||
|
|
||||||
|
```python
|
||||||
|
scan_all() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Trigger a scan of all Jellyfin libraries (picks up on-disk changes).
|
||||||
|
|
||||||
|
### `fetch_movies`
|
||||||
|
|
||||||
|
*function · line 85*
|
||||||
|
|
||||||
|
```python
|
||||||
|
fetch_movies() -> list[JellyfinMovie]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read every movie Jellyfin currently has, with its TMDb id and path.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# `src/core/legacy_meta.py`
|
||||||
|
|
||||||
|
Readers for Curator's *pre-index* metadata layouts.
|
||||||
|
|
||||||
|
Before the unified pool index (`.Curator.!index`), Tagger / early Curator stored
|
||||||
|
a movie's metadata in a **per-file sidecar** `.{filename}.!tag` (JSON) sitting
|
||||||
|
next to the video. When importing a file that came from such a pool we can
|
||||||
|
recover its **ČSFD link** (and title / year) from that sidecar — or from an old
|
||||||
|
`.Curator.!index` found by walking up from the file.
|
||||||
|
|
||||||
|
This is read-only recovery for the import dialog; nothing here writes.
|
||||||
|
|
||||||
|
Source: [`src/core/legacy_meta.py`](../../src/core/legacy_meta.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `LegacyMeta`
|
||||||
|
|
||||||
|
*class · line 25*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LegacyMeta:
|
||||||
|
```
|
||||||
|
|
||||||
|
What an old sidecar / index record can still tell us about a movie.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `_record_to_meta`
|
||||||
|
|
||||||
|
*function · line 34*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_record_to_meta(record: object, source: str) -> Optional[LegacyMeta]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pull the useful fields out of a stored movie record (any schema version).
|
||||||
|
|
||||||
|
### `_from_sidecar`
|
||||||
|
|
||||||
|
*function · line 51*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_from_sidecar(video_path: Path) -> Optional[LegacyMeta]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the metadata from the per-file `.{name}.!tag` JSON sidecar next to a video, or return `None` when there is no readable sidecar.
|
||||||
|
|
||||||
|
### `_lookup_in_index`
|
||||||
|
|
||||||
|
*function · line 62*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> Optional[dict]
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the record for `video_path` in an index's `movies` map.
|
||||||
|
|
||||||
|
Tries the exact pool-relative key first, then falls back to a unique
|
||||||
|
basename match (the file may have moved since the index was written).
|
||||||
|
|
||||||
|
### `_from_legacy_index`
|
||||||
|
|
||||||
|
*function · line 79*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_from_legacy_index(video_path: Path) -> Optional[LegacyMeta]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the metadata from a `.Curator.!index` file found by walking up at most `_MAX_INDEX_WALK_UP` directories from the video. Returns `None` if nothing matches.
|
||||||
|
|
||||||
|
### `read_legacy_meta`
|
||||||
|
|
||||||
|
*function · line 99*
|
||||||
|
|
||||||
|
```python
|
||||||
|
read_legacy_meta(video_path: Path) -> Optional[LegacyMeta]
|
||||||
|
```
|
||||||
|
|
||||||
|
Recover ČSFD link / title / year from a pre-index layout, or `None`.
|
||||||
|
|
||||||
|
Order: a per-file `.{name}.!tag` sidecar next to the video, then a
|
||||||
|
`.Curator.!index` found walking up (max 4 levels) from the file.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# `src/core/list_manager.py`
|
||||||
|
|
||||||
|
Sorting of file lists by name or by date added. Small helper used by the file list views.
|
||||||
|
|
||||||
|
Source: [`src/core/list_manager.py`](../../src/core/list_manager.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `ListManager`
|
||||||
|
|
||||||
|
*class · line 4*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ListManager:
|
||||||
|
```
|
||||||
|
|
||||||
|
Holds the current sort mode (`name` or `date`) and sorts file lists accordingly.
|
||||||
|
|
||||||
|
#### `ListManager.__init__`
|
||||||
|
|
||||||
|
*method · line 5*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__()
|
||||||
|
```
|
||||||
|
|
||||||
|
Start sorting by name.
|
||||||
|
|
||||||
|
#### `ListManager.set_sort`
|
||||||
|
|
||||||
|
*method · line 9*
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_sort(mode: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Switch the sort mode to `name` or `date`; other values are ignored.
|
||||||
|
|
||||||
|
#### `ListManager.sort_files`
|
||||||
|
|
||||||
|
*method · line 13*
|
||||||
|
|
||||||
|
```python
|
||||||
|
sort_files(files: List[File]) -> List[File]
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the files sorted by name (case-insensitive) or by date. Files without a date go last in date mode.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# `src/core/media_utils.py`
|
||||||
|
|
||||||
|
Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/<height>p` tag.
|
||||||
|
|
||||||
|
Source: [`src/core/media_utils.py`](../../src/core/media_utils.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `load_icon`
|
||||||
|
|
||||||
|
*function · line 14*
|
||||||
|
|
||||||
|
```python
|
||||||
|
load_icon(path) -> ImageTk.PhotoImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Load an image file and scale it to 16×16 px as a Qt-compatible icon.
|
||||||
|
|
||||||
|
### `add_video_resolution_tag`
|
||||||
|
|
||||||
|
*function · line 19*
|
||||||
|
|
||||||
|
```python
|
||||||
|
add_video_resolution_tag(file_obj: File, tagmanager: TagManager)
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the vertical resolution of a video with `ffprobe` and tag the file with `Rozlišení/<height>p`. Needs FFmpeg on the PATH.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# `src/core/migrate.py`
|
||||||
|
|
||||||
|
One-off migration of an existing pool to the Jellyfin naming convention.
|
||||||
|
|
||||||
|
Older pools carry files like `Title.ext` / `Title (rok).ext` with **no TMDb
|
||||||
|
id**. This matches each such movie against TMDb by title + year and proposes a
|
||||||
|
`tmdbid` (and the missing year, which TMDb supplies), so the file can be renamed
|
||||||
|
to `Title (rok) [tmdbid].ext` and Jellyfin can match it deterministically.
|
||||||
|
|
||||||
|
The classification is deliberately **conservative**: only an exact
|
||||||
|
(diacritics-insensitive) title match with a matching year and a single candidate
|
||||||
|
is `auto`; everything else is `ambiguous` for the user to confirm.
|
||||||
|
|
||||||
|
Source: [`src/core/migrate.py`](../../src/core/migrate.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `MatchProposal`
|
||||||
|
|
||||||
|
*class · line 19*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MatchProposal:
|
||||||
|
```
|
||||||
|
|
||||||
|
One movie's proposed TMDb match.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `normalize`
|
||||||
|
|
||||||
|
*function · line 33*
|
||||||
|
|
||||||
|
```python
|
||||||
|
normalize(text: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Fold a title for comparison: strip diacritics/punctuation, lowercase.
|
||||||
|
|
||||||
|
### `_year_close`
|
||||||
|
|
||||||
|
*function · line 41*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_year_close(a: Optional[str], b: Optional[str]) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when two year strings are both numeric and differ by at most one year (release years often disagree by one).
|
||||||
|
|
||||||
|
### `classify`
|
||||||
|
|
||||||
|
*function · line 47*
|
||||||
|
|
||||||
|
```python
|
||||||
|
classify(title: str, year: Optional[str], candidates: list) -> tuple[str, object]
|
||||||
|
```
|
||||||
|
|
||||||
|
Return `(status, chosen)` for a title/year against TMDb candidates.
|
||||||
|
|
||||||
|
`auto` requires an exact normalized-title match, a year within ±1, and that
|
||||||
|
exactly one candidate qualifies. Otherwise `ambiguous` (top hit preselected)
|
||||||
|
or `no_match` when there are no candidates.
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# `src/core/movie.py`
|
||||||
|
|
||||||
|
The movie object — metadata first, files second.
|
||||||
|
|
||||||
|
A movie is identified by `(title, year, tmdb_id)` and owns **its files**, not
|
||||||
|
the other way round. That inversion is the point: the pool index used to key a
|
||||||
|
record by a file path, so metadata was a passenger on one particular file — and
|
||||||
|
everything about multi-version movies got awkward (which file "holds" the tags,
|
||||||
|
why renaming a grouped movie had to be blocked, why an import could silently
|
||||||
|
create a duplicate instead of a version).
|
||||||
|
|
||||||
|
Here the record is the movie; each playable file is a `MediaFile` under
|
||||||
|
it, told apart by a free-text `label` (`3D HSBS`, `4K`, `Director's
|
||||||
|
Cut`, …). Where those files *live* is then a pure function of the record —
|
||||||
|
`Movie.desired_layout` — so promoting to a folder, demoting back, or
|
||||||
|
renaming the whole thing are all the same operation: recompute and move what
|
||||||
|
doesn't match.
|
||||||
|
|
||||||
|
Layout (Jellyfin's own conventions — https://jellyfin.org/docs/general/server/media/movies/)::
|
||||||
|
|
||||||
|
one video <Library>/Title (rok) [tmdbid-N].ext
|
||||||
|
two or more <Library>/Title (rok) [tmdbid-N]/
|
||||||
|
Title (rok) [tmdbid-N] - Výchozí.ext
|
||||||
|
Title (rok) [tmdbid-N].3D.HSBS.ext
|
||||||
|
|
||||||
|
Every file in a movie folder must start with the folder's exact name; both
|
||||||
|
`" - label"` and `".3D.FMT"` are valid separators, so a 3D file keeps the
|
||||||
|
documented `.3D.<FORMAT>` spelling whether it sits flat or grouped.
|
||||||
|
Subtitles are not media entries — they ride along with their video by filename
|
||||||
|
stem (see `src.core.subtitles`).
|
||||||
|
|
||||||
|
Source: [`src/core/movie.py`](../../src/core/movie.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `MediaFile`
|
||||||
|
|
||||||
|
*class · line 102*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MediaFile:
|
||||||
|
```
|
||||||
|
|
||||||
|
One file belonging to a movie (today always a playable video).
|
||||||
|
|
||||||
|
#### `MediaFile.format_3d`
|
||||||
|
|
||||||
|
*property · line 110*
|
||||||
|
|
||||||
|
```python
|
||||||
|
format_3d -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The 3D format this version's label denotes, if any.
|
||||||
|
|
||||||
|
#### `MediaFile.to_record`
|
||||||
|
|
||||||
|
*method · line 114*
|
||||||
|
|
||||||
|
```python
|
||||||
|
to_record(pool_dir: Path) -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Serialise for the pool index (path stored pool-relative, POSIX).
|
||||||
|
|
||||||
|
#### `MediaFile.from_record`
|
||||||
|
|
||||||
|
*class method · line 123*
|
||||||
|
|
||||||
|
```python
|
||||||
|
from_record(data: dict, pool_dir: Path) -> 'MediaFile'
|
||||||
|
```
|
||||||
|
|
||||||
|
Build a `MediaFile` from one entry of a movie record's `media` list, resolving its relative path against the pool directory.
|
||||||
|
|
||||||
|
### `Movie`
|
||||||
|
|
||||||
|
*class · line 132*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Movie:
|
||||||
|
```
|
||||||
|
|
||||||
|
A film in the pool: its identity, its metadata, and its media files.
|
||||||
|
|
||||||
|
#### `Movie.videos`
|
||||||
|
|
||||||
|
*property · line 156*
|
||||||
|
|
||||||
|
```python
|
||||||
|
videos -> list[MediaFile]
|
||||||
|
```
|
||||||
|
|
||||||
|
Playable versions, in a stable order (labelled ones by label).
|
||||||
|
|
||||||
|
#### `Movie.is_grouped`
|
||||||
|
|
||||||
|
*property · line 163*
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_grouped -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when the movie needs its own folder (more than one version).
|
||||||
|
|
||||||
|
#### `Movie.stem`
|
||||||
|
|
||||||
|
*property · line 168*
|
||||||
|
|
||||||
|
```python
|
||||||
|
stem -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical `Title (rok) [tmdbid-N]` — the folder / filename base.
|
||||||
|
|
||||||
|
Falls back to the bare title when no four-digit year is known (a movie
|
||||||
|
can be imported without one).
|
||||||
|
|
||||||
|
#### `Movie.primary`
|
||||||
|
|
||||||
|
*property · line 182*
|
||||||
|
|
||||||
|
```python
|
||||||
|
primary -> Optional[MediaFile]
|
||||||
|
```
|
||||||
|
|
||||||
|
The version that represents the movie (first by `videos`).
|
||||||
|
|
||||||
|
#### `Movie.desired_layout`
|
||||||
|
|
||||||
|
*method · line 187*
|
||||||
|
|
||||||
|
```python
|
||||||
|
desired_layout(pool_dir: Path) -> list[tuple[MediaFile, Path]]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(media, where it belongs)` for every file, from the record alone.
|
||||||
|
|
||||||
|
The whole flat-vs-folder rule lives here: one video sits directly in
|
||||||
|
the library, two or more move into `<Library>/<stem>/`. Callers move
|
||||||
|
whatever doesn't already match — which makes promotion, demotion and
|
||||||
|
renaming the same operation.
|
||||||
|
|
||||||
|
#### `Movie.to_record`
|
||||||
|
|
||||||
|
*method · line 208*
|
||||||
|
|
||||||
|
```python
|
||||||
|
to_record(pool_dir: Path) -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
Serialise the movie into the dictionary stored in the pool index, with media paths relative to the pool directory.
|
||||||
|
|
||||||
|
#### `Movie.from_record`
|
||||||
|
|
||||||
|
*class method · line 227*
|
||||||
|
|
||||||
|
```python
|
||||||
|
from_record(key: str, data: dict, pool_dir: Path, tagmanager=None) -> 'Movie'
|
||||||
|
```
|
||||||
|
|
||||||
|
Build a `Movie` from an index record stored under `key`. Tags are resolved through `tagmanager` when one is given.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `normalize_3d_label`
|
||||||
|
|
||||||
|
*function · line 53*
|
||||||
|
|
||||||
|
```python
|
||||||
|
normalize_3d_label(label: object) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The 3D format a version label denotes (upper-case), or `None`.
|
||||||
|
|
||||||
|
Lets a plain label double as the 3D marker — `"3D HSBS"` typed in the
|
||||||
|
version dialog and `"hsbs"` picked from a dropdown mean the same thing.
|
||||||
|
|
||||||
|
### `media_filename`
|
||||||
|
|
||||||
|
*function · line 63*
|
||||||
|
|
||||||
|
```python
|
||||||
|
media_filename(stem: str, label: str, ext: str, *, grouped: bool) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Filename for one media file of a movie whose canonical stem is `stem`.
|
||||||
|
|
||||||
|
- no label → `stem.ext` (a lone, plain video)
|
||||||
|
- 3D label → `stem.3D.<FORMAT>.ext` (Jellyfin's documented 3D form)
|
||||||
|
- anything else → `stem - label.ext`
|
||||||
|
|
||||||
|
`grouped` only decides whether an empty label is allowed: inside a movie
|
||||||
|
folder every file gets one, so it falls back to `DEFAULT_VERSION_LABEL`.
|
||||||
|
|
||||||
|
### `identity_key`
|
||||||
|
|
||||||
|
*function · line 84*
|
||||||
|
|
||||||
|
```python
|
||||||
|
identity_key(tmdb_id: object=None, title: str='', year: object=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
The pool-index key for a movie — `tmdb-<id>`, else a title/year slug.
|
||||||
|
|
||||||
|
Keying by identity (rather than by file path) is what lets a second file of
|
||||||
|
the same film find its movie instead of becoming a separate entry.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# `src/core/naming.py`
|
||||||
|
|
||||||
|
Pool file naming convention.
|
||||||
|
|
||||||
|
Every movie in the pool is named `Title (YYYY) [tmdbid-N].ext` — a title, a
|
||||||
|
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
|
||||||
|
is exactly the form Jellyfin matches on. The `[tmdbid-N]` part is optional: a
|
||||||
|
plain `Title (YYYY)` is still valid (Jellyfin then matches by name). The year
|
||||||
|
lets two same-named films coexist and makes the filename self-describing.
|
||||||
|
|
||||||
|
A 3D movie additionally carries a `.3D.<FORMAT>` tag right before the
|
||||||
|
extension (Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/):
|
||||||
|
`Title (YYYY) [tmdbid-N].3D.HSBS.ext`. `FORMAT` is one of
|
||||||
|
`THREE_D_FORMATS`; Jellyfin reads it case-insensitively but Curator always
|
||||||
|
writes it upper-case for a single canonical form.
|
||||||
|
|
||||||
|
These helpers build, validate and parse the stem (the filename without its
|
||||||
|
extension).
|
||||||
|
|
||||||
|
Source: [`src/core/naming.py`](../../src/core/naming.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `is_valid_year`
|
||||||
|
|
||||||
|
*function · line 35*
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_valid_year(year: object) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when `year` is (or stringifies to) exactly four digits.
|
||||||
|
|
||||||
|
### `canonical_pool_stem`
|
||||||
|
|
||||||
|
*function · line 40*
|
||||||
|
|
||||||
|
```python
|
||||||
|
canonical_pool_stem(title: str, year: object, tmdb_id: object=None, format_3d: object=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the canonical pool stem `Title (YYYY)` (+ ` [tmdbid-N]` /
|
||||||
|
`.3D.<FORMAT>` if given).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: the title is empty, the year is not four digits, a given
|
||||||
|
`tmdb_id` is not a positive integer, or `format_3d` isn't one
|
||||||
|
of `THREE_D_FORMATS`.
|
||||||
|
|
||||||
|
### `parse_pool_stem`
|
||||||
|
|
||||||
|
*function · line 73*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_pool_stem(stem: str) -> tuple[str, int] | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Split a canonical stem into `(title, year)`; `None` if it isn't one.
|
||||||
|
|
||||||
|
A trailing `[tmdbid-N]` is tolerated and dropped (use `parse_tmdb_id`
|
||||||
|
to read it), so the returned title is always the clean title.
|
||||||
|
|
||||||
|
### `parse_tmdb_id`
|
||||||
|
|
||||||
|
*function · line 85*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_tmdb_id(stem: str) -> int | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the `[tmdbid-N]` from a canonical stem, or `None` if absent.
|
||||||
|
|
||||||
|
### `parse_3d_format`
|
||||||
|
|
||||||
|
*function · line 93*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_3d_format(stem: str) -> str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the `.3D.<FORMAT>` tag from a canonical stem (upper-cased), or
|
||||||
|
`None` if absent.
|
||||||
|
|
||||||
|
### `is_canonical_pool_stem`
|
||||||
|
|
||||||
|
*function · line 102*
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_canonical_pool_stem(stem: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when `stem` matches the `Title (YYYY)` convention.
|
||||||
|
|
||||||
|
### `year_from_filename`
|
||||||
|
|
||||||
|
*function · line 107*
|
||||||
|
|
||||||
|
```python
|
||||||
|
year_from_filename(name: str) -> str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Best-effort four-digit year guessed from a raw (release) filename.
|
||||||
|
|
||||||
|
Prefers a parenthesised year (`… (1999) …`) and otherwise falls back to the
|
||||||
|
last standalone 19xx/20xx run. Used only to pre-fill the import dialog — the
|
||||||
|
user confirms it. Returns the year as a string, or `None`.
|
||||||
|
|
||||||
|
### `three_d_format_from_filename`
|
||||||
|
|
||||||
|
*function · line 129*
|
||||||
|
|
||||||
|
```python
|
||||||
|
three_d_format_from_filename(name: str) -> str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Best-effort 3D format guessed from a raw (release) filename, e.g.
|
||||||
|
`Avatar.2009.3D.HSBS.BluRay.mkv` → `"HSBS"`. Unlike `parse_3d_format`
|
||||||
|
this doesn't require the rest of the name to be a canonical pool stem — used
|
||||||
|
to pre-fill a version label while importing. Returns `None` if absent.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# `src/core/nfo.py`
|
||||||
|
|
||||||
|
NFO reader — Curator's metadata source after ČSFD.
|
||||||
|
|
||||||
|
Jellyfin (configured to save metadata into the media folders) writes a Kodi-style
|
||||||
|
`.nfo` next to each movie plus artwork. Curator only **reads** it — to drive the
|
||||||
|
miniDLNA genre tree and to fill the year/rating used by folder grouping. It never
|
||||||
|
writes NFO (Jellyfin owns that).
|
||||||
|
|
||||||
|
For a flat pool file `Title (rok) [tmdbid].mkv` the sidecar is
|
||||||
|
`Title (rok) [tmdbid].nfo`; a per-movie folder may instead hold `movie.nfo`.
|
||||||
|
Both are found. Missing or unparsable NFO → `None` (the movie simply has no
|
||||||
|
genres until Jellyfin has scanned it).
|
||||||
|
|
||||||
|
Source: [`src/core/nfo.py`](../../src/core/nfo.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `NfoData`
|
||||||
|
|
||||||
|
*class · line 20*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class NfoData:
|
||||||
|
```
|
||||||
|
|
||||||
|
The bits of a movie `.nfo` Curator uses.
|
||||||
|
|
||||||
|
#### `NfoData.field_values`
|
||||||
|
|
||||||
|
*method · line 31*
|
||||||
|
|
||||||
|
```python
|
||||||
|
field_values(name: str) -> list[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
Values for a tag-schema source field (genres/countries/year/rating).
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `find_nfo`
|
||||||
|
|
||||||
|
*function · line 44*
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_nfo(video_path: Path) -> Optional[Path]
|
||||||
|
```
|
||||||
|
|
||||||
|
Locate the `.nfo` for a movie: sibling `<stem>.nfo` or `movie.nfo`.
|
||||||
|
|
||||||
|
### `_texts`
|
||||||
|
|
||||||
|
*function · line 56*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_texts(root: ET.Element, tag: str) -> list[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
All non-empty stripped texts of the child elements called `tag`.
|
||||||
|
|
||||||
|
### `_parse_rating`
|
||||||
|
|
||||||
|
*function · line 60*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_rating(root: ET.Element) -> Optional[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands).
|
||||||
|
|
||||||
|
### `_parse_tmdb_id`
|
||||||
|
|
||||||
|
*function · line 74*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_tmdb_id(root: ET.Element) -> Optional[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
The TMDb id from a Kodi NFO: a `<uniqueid type="tmdb">` element, or a legacy `<tmdbid>` element. `None` if neither holds a number.
|
||||||
|
|
||||||
|
### `_parse_csfd_url`
|
||||||
|
|
||||||
|
*function · line 82*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_csfd_url(root: ET.Element) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
A ČSFD film URL from the NFO, if any.
|
||||||
|
|
||||||
|
Czech Kodi scrapers record ČSFD as `<uniqueid type="csfd">` (a bare id or a
|
||||||
|
full URL) or a legacy `<csfdid>` / `<csfd>` element. A bare id is expanded
|
||||||
|
to `https://www.csfd.cz/film/<id>/`.
|
||||||
|
|
||||||
|
### `read_nfo`
|
||||||
|
|
||||||
|
*function · line 105*
|
||||||
|
|
||||||
|
```python
|
||||||
|
read_nfo(video_path: Path) -> Optional[NfoData]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the movie's `.nfo` into `NfoData`, or None if absent/broken.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# `src/core/pool_index.py`
|
||||||
|
|
||||||
|
Unified metadata index for the Curator pool.
|
||||||
|
|
||||||
|
Instead of one sidecar file per movie, the whole pool keeps a single JSON index
|
||||||
|
at `<pool>/.Curator.!index`. Curator owns the pool (it inserts/removes files
|
||||||
|
itself), so files never move behind its back and a central index is safe.
|
||||||
|
|
||||||
|
The index also carries **library-level settings** (`tag_schema`,
|
||||||
|
`copyasis_folders`) under a `settings` section, so everything describing a
|
||||||
|
particular pool travels with the pool. The global config keeps only app data
|
||||||
|
(window state, MRU) and the pointers to where the pool/output live.
|
||||||
|
|
||||||
|
On-disk shape (**v2**)::
|
||||||
|
|
||||||
|
{"version": 2,
|
||||||
|
"settings": {...},
|
||||||
|
"movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
|
||||||
|
|
||||||
|
Records are keyed by the movie's **identity** (`tmdb-<id>`, or a title/year
|
||||||
|
slug when there's no id) and own their files — see `src.core.movie`. The
|
||||||
|
original **v1** shape keyed a record by one file's pool-relative path, which
|
||||||
|
made metadata a passenger on that file; `PoolIndex.load` migrates such an
|
||||||
|
index in place (backing the old file up first). Migration only rewrites
|
||||||
|
metadata — no file on disk is moved, because a one-video movie is already in
|
||||||
|
its v2 position.
|
||||||
|
|
||||||
|
Source: [`src/core/pool_index.py`](../../src/core/pool_index.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `PoolIndex`
|
||||||
|
|
||||||
|
*class · line 35*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PoolIndex:
|
||||||
|
```
|
||||||
|
|
||||||
|
Single-file JSON metadata store for all files in a pool.
|
||||||
|
|
||||||
|
#### `PoolIndex.__init__`
|
||||||
|
|
||||||
|
*method · line 38*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__(pool_dir: Path) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the index of the pool at `pool_dir` (loads it, migrating an older version if needed).
|
||||||
|
|
||||||
|
#### `PoolIndex._key`
|
||||||
|
|
||||||
|
*method · line 45*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_key(file_path: Path) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Pool-relative POSIX key for a file path.
|
||||||
|
|
||||||
|
#### `PoolIndex.load`
|
||||||
|
|
||||||
|
*method · line 53*
|
||||||
|
|
||||||
|
```python
|
||||||
|
load() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Load the index from disk (missing/corrupt index = empty).
|
||||||
|
|
||||||
|
A v1 (path-keyed) index is migrated to v2 in place — the old file is
|
||||||
|
copied to `.Curator.!index.v1.bak` first.
|
||||||
|
|
||||||
|
#### `PoolIndex._migrate_v1_to_v2`
|
||||||
|
|
||||||
|
*method · line 76*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_migrate_v1_to_v2() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-key path-keyed records into identity-keyed movie records.
|
||||||
|
|
||||||
|
Each v1 record described one file; several of them can describe one
|
||||||
|
movie (the interim multi-version layout put them in a shared folder),
|
||||||
|
so they are merged by identity. Metadata only — nothing is moved on
|
||||||
|
disk, since a v1 movie's files already sit where v2 wants them.
|
||||||
|
|
||||||
|
#### `PoolIndex.save`
|
||||||
|
|
||||||
|
*method · line 139*
|
||||||
|
|
||||||
|
```python
|
||||||
|
save() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Persist the index to disk.
|
||||||
|
|
||||||
|
#### `PoolIndex.get_setting`
|
||||||
|
|
||||||
|
*method · line 150*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_setting(key: str, default=None)
|
||||||
|
```
|
||||||
|
|
||||||
|
Return a library-level setting stored in the index.
|
||||||
|
|
||||||
|
#### `PoolIndex.set_setting`
|
||||||
|
|
||||||
|
*method · line 154*
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_setting(key: str, value) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Upsert a library-level setting and persist the index.
|
||||||
|
|
||||||
|
#### `PoolIndex.get_movie`
|
||||||
|
|
||||||
|
*method · line 163*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_movie(key: str) -> dict | None
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the movie record under `key`, or None.
|
||||||
|
|
||||||
|
#### `PoolIndex.set_movie`
|
||||||
|
|
||||||
|
*method · line 167*
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_movie(key: str, data: dict) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Upsert a movie record and persist the index.
|
||||||
|
|
||||||
|
#### `PoolIndex.delete_movie`
|
||||||
|
|
||||||
|
*method · line 172*
|
||||||
|
|
||||||
|
```python
|
||||||
|
delete_movie(key: str) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove a movie record (if present) and persist the index.
|
||||||
|
|
||||||
|
#### `PoolIndex.rekey_movie`
|
||||||
|
|
||||||
|
*method · line 177*
|
||||||
|
|
||||||
|
```python
|
||||||
|
rekey_movie(old_key: str, new_key: str) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Move a record to a new identity key (e.g. its TMDb id was set/fixed).
|
||||||
|
|
||||||
|
#### `PoolIndex.all_movies`
|
||||||
|
|
||||||
|
*method · line 184*
|
||||||
|
|
||||||
|
```python
|
||||||
|
all_movies() -> dict[str, dict]
|
||||||
|
```
|
||||||
|
|
||||||
|
Every movie record, keyed by identity.
|
||||||
|
|
||||||
|
#### `PoolIndex.record_for`
|
||||||
|
|
||||||
|
*method · line 188*
|
||||||
|
|
||||||
|
```python
|
||||||
|
record_for(file_path: Path) -> dict | None
|
||||||
|
```
|
||||||
|
|
||||||
|
The movie record that owns `file_path`, or None if untracked.
|
||||||
|
|
||||||
|
#### `PoolIndex.find_movie_by_media`
|
||||||
|
|
||||||
|
*method · line 193*
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_movie_by_media(file_path: Path) -> tuple[str, dict] | None
|
||||||
|
```
|
||||||
|
|
||||||
|
`(key, record)` of the movie owning `file_path`, or None.
|
||||||
|
|
||||||
|
The reverse lookup a file needs now that records are keyed by identity
|
||||||
|
rather than by path.
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# `src/core/series.py`
|
||||||
|
|
||||||
|
Series naming + scanning for the Jellyfin TV layout.
|
||||||
|
|
||||||
|
Jellyfin wants series laid out as::
|
||||||
|
|
||||||
|
Seriály/Series (year) [tmdbid-N]/
|
||||||
|
tvshow.nfo (Jellyfin writes it)
|
||||||
|
Season 01/
|
||||||
|
Series (year) S01E01.ext
|
||||||
|
Specials/ (season 0)
|
||||||
|
|
||||||
|
Episodes are matched by `SxxEyy`. These helpers parse the episode number out of
|
||||||
|
a raw filename, build the canonical episode/season/series names, and scan a
|
||||||
|
series library into a `Series → Season → Episode` tree with a naming status.
|
||||||
|
|
||||||
|
Source: [`src/core/series.py`](../../src/core/series.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `Episode`
|
||||||
|
|
||||||
|
*class · line 49*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Episode:
|
||||||
|
```
|
||||||
|
|
||||||
|
One episode file found in a series folder: its path, its season and episode numbers (`None` when not recognised) and its part number when the episode is split across several files.
|
||||||
|
|
||||||
|
#### `Episode.code`
|
||||||
|
|
||||||
|
*property · line 58*
|
||||||
|
|
||||||
|
```python
|
||||||
|
code -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Human-readable episode code, e.g. `S01E02`, with `.ptN` appended for a part (`S01E02.pt3`). `?` when the season or episode is unknown.
|
||||||
|
|
||||||
|
### `Series`
|
||||||
|
|
||||||
|
*class · line 66*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Series:
|
||||||
|
```
|
||||||
|
|
||||||
|
One series folder with its title, year, TMDb id, library and the episodes found below it.
|
||||||
|
|
||||||
|
#### `Series.seasons`
|
||||||
|
|
||||||
|
*method · line 74*
|
||||||
|
|
||||||
|
```python
|
||||||
|
seasons() -> dict[int, list[Episode]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Episodes grouped by season number (`-1` = unrecognised).
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `parse_episode`
|
||||||
|
|
||||||
|
*function · line 84*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_episode(name: str) -> Optional[tuple[int, int]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract `(season, episode)` from a filename, or `None`.
|
||||||
|
|
||||||
|
### `parse_episode_part`
|
||||||
|
|
||||||
|
*function · line 94*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_episode_part(name: str) -> Optional[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
Part number implied by a Jellyfin stacking suffix in a filename, or `None`.
|
||||||
|
|
||||||
|
Looks for *cd*, *dvd*, *part*, *pt*, *disc* or *disk* + a number sitting
|
||||||
|
right before the extension (`Show S01E02.pt2.mkv`,
|
||||||
|
`Show S01E02-part-2.mkv`, …) — the same tokens Jellyfin itself scans for.
|
||||||
|
|
||||||
|
### `parse_episode_code`
|
||||||
|
|
||||||
|
*function · line 105*
|
||||||
|
|
||||||
|
```python
|
||||||
|
parse_episode_code(text: str) -> Optional[tuple[int, int, Optional[int]]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse a compact user-typed code: `SxxEyy`, optionally + a part.
|
||||||
|
|
||||||
|
Accepts everything `parse_episode` does, plus a trailing part marker
|
||||||
|
right after it — `S01E02P3`, `S01E02 pt3`, `S01E02.part.3`,
|
||||||
|
`S01E02 cd2` — for a file that is only *one part* of that episode.
|
||||||
|
Returns `(season, episode, part)` with `part` `None` when absent, or
|
||||||
|
`None` when no `SxxEyy` is found at all.
|
||||||
|
|
||||||
|
### `collect_episode_files`
|
||||||
|
|
||||||
|
*function · line 126*
|
||||||
|
|
||||||
|
```python
|
||||||
|
collect_episode_files(folder: Path) -> list[Path]
|
||||||
|
```
|
||||||
|
|
||||||
|
Every video file under `folder` (recursively), sidecars excluded, sorted.
|
||||||
|
|
||||||
|
Lets the episode-import dialog take a whole `Series/Season NN/` tree (or any
|
||||||
|
nested layout) in one pick instead of file-by-file. Season/episode numbers
|
||||||
|
are still read per file from its name by `parse_episode`.
|
||||||
|
|
||||||
|
### `season_folder_name`
|
||||||
|
|
||||||
|
*function · line 142*
|
||||||
|
|
||||||
|
```python
|
||||||
|
season_folder_name(season: int) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Folder name for a season — `Specials` for 0, else `Season NN`.
|
||||||
|
|
||||||
|
### `is_season_folder`
|
||||||
|
|
||||||
|
*function · line 147*
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_season_folder(name: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True for a season folder name — `Specials` or `Season <n>` (any padding).
|
||||||
|
|
||||||
|
### `looks_like_series_folder`
|
||||||
|
|
||||||
|
*function · line 152*
|
||||||
|
|
||||||
|
```python
|
||||||
|
looks_like_series_folder(folder: Path) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when `folder` holds at least one `Season NN`/`Specials` subfolder.
|
||||||
|
|
||||||
|
That's the shape Curator always gives a series (episodes always land in a
|
||||||
|
season folder, never loose at the series root) — a movie's version folder
|
||||||
|
never has one, its files sit flat inside it. This is how a `mixed`
|
||||||
|
library tells the two apart even though both use the identical outer
|
||||||
|
`Title (rok) [tmdbid]/` name: `FileManager.is_movie_group_member`
|
||||||
|
and `scan_series` both check it before deciding which one a
|
||||||
|
canonical-shaped subfolder is.
|
||||||
|
|
||||||
|
### `sanitize_title`
|
||||||
|
|
||||||
|
*function · line 173*
|
||||||
|
|
||||||
|
```python
|
||||||
|
sanitize_title(title: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
A series/episode title made safe to sit in a filename (spaces collapsed).
|
||||||
|
|
||||||
|
### `episode_filename`
|
||||||
|
|
||||||
|
*function · line 179*
|
||||||
|
|
||||||
|
```python
|
||||||
|
episode_filename(season: int, episode: int, series_title: str, ext: str, *, part: Optional[int]=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical episode filename `SxxEyy - {series title}.ext`.
|
||||||
|
|
||||||
|
The release string is deliberately **not** kept — identity is the `SxxEyy`
|
||||||
|
code, and Jellyfin fetches the episode title/metadata from TMDb (matched by
|
||||||
|
the code + the series folder's `[tmdbid-N]`). An empty title collapses to
|
||||||
|
`SxxEyy.ext`. `part` marks one physical part of that episode — an old
|
||||||
|
recording split across several files — with Jellyfin's own `.ptN`
|
||||||
|
stacking suffix right before the extension.
|
||||||
|
|
||||||
|
### `series_folder_stem`
|
||||||
|
|
||||||
|
*function · line 200*
|
||||||
|
|
||||||
|
```python
|
||||||
|
series_folder_stem(title: str, year: object=None, tmdb_id: object=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Series folder name: `Title (year) [tmdbid-N]` (year/id optional).
|
||||||
|
|
||||||
|
Reuses the movie convention when a year is present; otherwise falls back to
|
||||||
|
`Title [tmdbid-N]` or just `Title` so a year-less series still works.
|
||||||
|
|
||||||
|
### `is_canonical_episode`
|
||||||
|
|
||||||
|
*function · line 216*
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_canonical_episode(path: Path, series_title: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when `path` is already `SxxEyy - {series title}.ext` in its season.
|
||||||
|
|
||||||
|
### `scan_series`
|
||||||
|
|
||||||
|
*function · line 229*
|
||||||
|
|
||||||
|
```python
|
||||||
|
scan_series(lib_dir: Path, library: str, *, skip_movie_folders: bool=False) -> list[Series]
|
||||||
|
```
|
||||||
|
|
||||||
|
Scan one series library into a list of `Series`.
|
||||||
|
|
||||||
|
Each immediate subfolder of `lib_dir` is a series; its name yields the
|
||||||
|
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
|
||||||
|
parsed from the filename). Non-video sidecars are skipped.
|
||||||
|
|
||||||
|
`skip_movie_folders=True` (a `mixed` library, where a movie's version
|
||||||
|
folder and a series folder both use the identical
|
||||||
|
`Title (rok) [tmdbid]/` shape) additionally skips a canonical-shaped
|
||||||
|
folder with no `Season NN`/`Specials` inside it — that's a movie's
|
||||||
|
version folder instead, left for `FileManager.load_pool_movies` to
|
||||||
|
read. Left `False` (the default, a plain `series`-kind library, which
|
||||||
|
never holds movies) every subfolder is read as a series regardless of
|
||||||
|
shape — a raw, not-yet-renamed import may have loose episode files
|
||||||
|
sitting at the series root, with no season folder yet.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# `src/core/subtitles.py`
|
||||||
|
|
||||||
|
External subtitle sidecars — attach, discover, keep in sync on rename.
|
||||||
|
|
||||||
|
Curator does not mux/embed subtitles. Jellyfin plays an **external** subtitle
|
||||||
|
file natively as long as it shares the video's filename stem, with an optional
|
||||||
|
language code and flag suffix::
|
||||||
|
|
||||||
|
Title (rok) [tmdbid-N].mkv
|
||||||
|
Title (rok) [tmdbid-N].cs.srt # language
|
||||||
|
Title (rok) [tmdbid-N].cs.forced.srt # + a flag (forced / sdh / default)
|
||||||
|
|
||||||
|
Subtitle presence is a **filesystem fact**, like NFO — nothing is cached in the
|
||||||
|
pool index. What Curator does own: copying a picked file into place with the
|
||||||
|
right name (`attach_subtitle`), and moving a subtitle along whenever its
|
||||||
|
video gets renamed so it never silently detaches (`rename_subtitles_with`).
|
||||||
|
|
||||||
|
Source: [`src/core/subtitles.py`](../../src/core/subtitles.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `SubtitleFile`
|
||||||
|
|
||||||
|
*class · line 30*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SubtitleFile:
|
||||||
|
```
|
||||||
|
|
||||||
|
One subtitle sidecar found next to a video.
|
||||||
|
|
||||||
|
#### `SubtitleFile.label`
|
||||||
|
|
||||||
|
*property · line 38*
|
||||||
|
|
||||||
|
```python
|
||||||
|
label -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Human-readable `jazyk (flag)` for display, e.g. `cs (forced)`.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `subtitle_filename`
|
||||||
|
|
||||||
|
*function · line 44*
|
||||||
|
|
||||||
|
```python
|
||||||
|
subtitle_filename(video_stem: str, ext: str, language: Optional[str]=None, flag: Optional[str]=None) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical subtitle filename: `{video_stem}[.language][.flag]{ext}`.
|
||||||
|
|
||||||
|
### `_parse_subtitle_name`
|
||||||
|
|
||||||
|
*function · line 57*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_parse_subtitle_name(video_stem: str, filename: str) -> Optional[tuple[Optional[str], Optional[str]]]
|
||||||
|
```
|
||||||
|
|
||||||
|
`(language, flag)` if `filename` is a subtitle sidecar of `video_stem`.
|
||||||
|
|
||||||
|
### `find_subtitles`
|
||||||
|
|
||||||
|
*function · line 83*
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_subtitles(video_path: Path) -> list[SubtitleFile]
|
||||||
|
```
|
||||||
|
|
||||||
|
Every external subtitle sidecar sitting next to `video_path`, sorted.
|
||||||
|
|
||||||
|
### `attach_subtitle`
|
||||||
|
|
||||||
|
*function · line 101*
|
||||||
|
|
||||||
|
```python
|
||||||
|
attach_subtitle(video_path: Path, source: Path, *, language: Optional[str]=None, flag: Optional[str]=None, move: bool=False) -> Path
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy (or move) `source` next to `video_path`, named to match it.
|
||||||
|
|
||||||
|
An existing subtitle already at that exact name (e.g. replacing the Czech
|
||||||
|
track) is overwritten; nothing else next to the video is touched.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: `source`'s extension isn't a recognised subtitle format.
|
||||||
|
|
||||||
|
### `rename_subtitles_with`
|
||||||
|
|
||||||
|
*function · line 128*
|
||||||
|
|
||||||
|
```python
|
||||||
|
rename_subtitles_with(old_video_path: Path, new_video_path: Path) -> list[Path]
|
||||||
|
```
|
||||||
|
|
||||||
|
Move every subtitle sidecar of `old_video_path` to match `new_video_path`.
|
||||||
|
|
||||||
|
Call this wherever a video gets renamed/moved so an attached subtitle never
|
||||||
|
silently detaches (its stem would stop matching and Jellyfin would drop it).
|
||||||
|
No-op when the video's path didn't actually change. Returns the new paths.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# `src/core/tag.py`
|
||||||
|
|
||||||
|
The `Tag` value object: a `(category, name)` pair identified by its `full_path` (`Category/Name`). Hashable and comparable, so tags can live in sets and be compared across the tag manager and the files.
|
||||||
|
|
||||||
|
Source: [`src/core/tag.py`](../../src/core/tag.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `Tag`
|
||||||
|
|
||||||
|
*class · line 1*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Tag:
|
||||||
|
```
|
||||||
|
|
||||||
|
A tag: a category and a name. Two tags are equal when both parts match.
|
||||||
|
|
||||||
|
#### `Tag.__init__`
|
||||||
|
|
||||||
|
*method · line 2*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__(category: str, name: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a tag in `category` with the given `name`.
|
||||||
|
|
||||||
|
#### `Tag.full_path`
|
||||||
|
|
||||||
|
*property · line 7*
|
||||||
|
|
||||||
|
```python
|
||||||
|
full_path
|
||||||
|
```
|
||||||
|
|
||||||
|
The tag as `Category/Name`, the form used in the index and in strings.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# `src/core/tag_manager.py`
|
||||||
|
|
||||||
|
`TagManager`: the registry of all tag categories and their tags. Initialised with the default categories; the GUI tag tree and the AND-filter read from it.
|
||||||
|
|
||||||
|
Source: [`src/core/tag_manager.py`](../../src/core/tag_manager.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `TagManager`
|
||||||
|
|
||||||
|
*class · line 16*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TagManager:
|
||||||
|
```
|
||||||
|
|
||||||
|
Registry of tag categories (`tags_by_category`). Adding a tag also creates its category; removing the last tag of a category removes the category.
|
||||||
|
|
||||||
|
#### `TagManager.__init__`
|
||||||
|
|
||||||
|
*method · line 17*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__()
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the registry and fill it with the default tags.
|
||||||
|
|
||||||
|
#### `TagManager._init_default_tags`
|
||||||
|
|
||||||
|
*method · line 21*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_init_default_tags()
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialize default tags (ratings and colors)
|
||||||
|
|
||||||
|
#### `TagManager.add_category`
|
||||||
|
|
||||||
|
*method · line 27*
|
||||||
|
|
||||||
|
```python
|
||||||
|
add_category(category: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Create an empty category if it does not exist yet.
|
||||||
|
|
||||||
|
#### `TagManager.remove_category`
|
||||||
|
|
||||||
|
*method · line 31*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_category(category: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete a category and all its tags, if it exists.
|
||||||
|
|
||||||
|
#### `TagManager.add_tag`
|
||||||
|
|
||||||
|
*method · line 35*
|
||||||
|
|
||||||
|
```python
|
||||||
|
add_tag(category: str, name: str) -> Tag
|
||||||
|
```
|
||||||
|
|
||||||
|
Register a tag in `category` (the category is created when missing) and return it.
|
||||||
|
|
||||||
|
#### `TagManager.remove_tag`
|
||||||
|
|
||||||
|
*method · line 41*
|
||||||
|
|
||||||
|
```python
|
||||||
|
remove_tag(category: str, name: str)
|
||||||
|
```
|
||||||
|
|
||||||
|
Unregister one tag. When it was the category's last tag, the category is removed too.
|
||||||
|
|
||||||
|
#### `TagManager.get_all_tags`
|
||||||
|
|
||||||
|
*method · line 48*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_all_tags()
|
||||||
|
```
|
||||||
|
|
||||||
|
Every registered tag as a `Category/Name` string.
|
||||||
|
|
||||||
|
#### `TagManager.get_categories`
|
||||||
|
|
||||||
|
*method · line 52*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_categories()
|
||||||
|
```
|
||||||
|
|
||||||
|
The names of all registered categories.
|
||||||
|
|
||||||
|
#### `TagManager.get_tags_in_category`
|
||||||
|
|
||||||
|
*method · line 55*
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_tags_in_category(category: str) -> list[Tag]
|
||||||
|
```
|
||||||
|
|
||||||
|
Get tags in category, sorted by predefined order for default categories
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# `src/core/tmdb.py`
|
||||||
|
|
||||||
|
TMDb (The Movie Database) client — Curator's identity source.
|
||||||
|
|
||||||
|
Replaces ČSFD. Its only job is to turn a **TMDb id** (or a title/year search)
|
||||||
|
into a canonical **title + year** for naming the pool file. The rich metadata
|
||||||
|
and artwork are fetched by **Jellyfin itself** — Curator does not need them here.
|
||||||
|
|
||||||
|
Needs a free API key in `TMDB_API_KEY` (loaded from `.env`). Everything
|
||||||
|
degrades gracefully when the key or the network is missing: `api_available`
|
||||||
|
reports readiness and the lookups raise `TmdbError` with a clear message.
|
||||||
|
|
||||||
|
Source: [`src/core/tmdb.py`](../../src/core/tmdb.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `TmdbResult`
|
||||||
|
|
||||||
|
*class · line 29*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TmdbResult:
|
||||||
|
```
|
||||||
|
|
||||||
|
A resolved TMDb entry — just enough to name the pool file.
|
||||||
|
|
||||||
|
### `TmdbDetails`
|
||||||
|
|
||||||
|
*class · line 39*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TmdbDetails:
|
||||||
|
```
|
||||||
|
|
||||||
|
Full TMDb data used to (re)generate a movie's tags (genres etc.).
|
||||||
|
|
||||||
|
Exposes `field_values` so it plugs into the tag schema the same way the
|
||||||
|
NFO reader does (fields: genres / countries / year / rating).
|
||||||
|
|
||||||
|
#### `TmdbDetails.field_values`
|
||||||
|
|
||||||
|
*method · line 53*
|
||||||
|
|
||||||
|
```python
|
||||||
|
field_values(name: str) -> list[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The values of one tag source field (`genres`, `countries`, `year`, `rating`, …) as a list of strings, ready to become tags. Unknown field names return an empty list.
|
||||||
|
|
||||||
|
### `TmdbError`
|
||||||
|
|
||||||
|
*class · line 65*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TmdbError(RuntimeError):
|
||||||
|
```
|
||||||
|
|
||||||
|
A TMDb request could not be completed (no key, network, bad status).
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `api_key`
|
||||||
|
|
||||||
|
*function · line 69*
|
||||||
|
|
||||||
|
```python
|
||||||
|
api_key() -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The configured TMDb API key, or None.
|
||||||
|
|
||||||
|
### `api_available`
|
||||||
|
|
||||||
|
*function · line 75*
|
||||||
|
|
||||||
|
```python
|
||||||
|
api_available() -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
True when a TMDb API key is configured.
|
||||||
|
|
||||||
|
### `_get`
|
||||||
|
|
||||||
|
*function · line 80*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_get(path: str, params: dict) -> dict
|
||||||
|
```
|
||||||
|
|
||||||
|
GET a TMDb API path with the configured key and language. Raises `TmdbError` on a missing key, a network failure or an error response.
|
||||||
|
|
||||||
|
### `_year_of`
|
||||||
|
|
||||||
|
*function · line 98*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_year_of(date_str: Optional[str]) -> Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
The four-digit year at the start of a TMDb date (`YYYY-MM-DD`), or `None`.
|
||||||
|
|
||||||
|
### `lookup_movie`
|
||||||
|
|
||||||
|
*function · line 104*
|
||||||
|
|
||||||
|
```python
|
||||||
|
lookup_movie(tmdb_id: int) -> Optional[TmdbResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve a movie by its TMDb id → title + year, or None if it has no title.
|
||||||
|
|
||||||
|
### `movie_details`
|
||||||
|
|
||||||
|
*function · line 113*
|
||||||
|
|
||||||
|
```python
|
||||||
|
movie_details(tmdb_id: int) -> TmdbDetails
|
||||||
|
```
|
||||||
|
|
||||||
|
Full movie data from TMDb (genres/countries/rating/year), localized to cs.
|
||||||
|
|
||||||
|
### `lookup_tv`
|
||||||
|
|
||||||
|
*function · line 127*
|
||||||
|
|
||||||
|
```python
|
||||||
|
lookup_tv(tmdb_id: int) -> Optional[TmdbResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve a series by its TMDb id → name + first-air year, or None.
|
||||||
|
|
||||||
|
### `lookup`
|
||||||
|
|
||||||
|
*function · line 136*
|
||||||
|
|
||||||
|
```python
|
||||||
|
lookup(tmdb_id: int, kind: str='movie') -> Optional[TmdbResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve a TMDb id as a movie (default) or a series (`kind="tv"`).
|
||||||
|
|
||||||
|
### `search_movie`
|
||||||
|
|
||||||
|
*function · line 141*
|
||||||
|
|
||||||
|
```python
|
||||||
|
search_movie(query: str, year: Optional[str]=None, limit: int=5) -> list[TmdbResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
Search movies by title (optionally narrowed by year); best matches first.
|
||||||
|
|
||||||
|
### `search_tv`
|
||||||
|
|
||||||
|
*function · line 161*
|
||||||
|
|
||||||
|
```python
|
||||||
|
search_tv(query: str, year: Optional[str]=None, limit: int=5) -> list[TmdbResult]
|
||||||
|
```
|
||||||
|
|
||||||
|
Search series by name (optionally narrowed by first-air year).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# `src/core/transforms.py`
|
||||||
|
|
||||||
|
Tag-schema value transforms for miniDLNA folder names.
|
||||||
|
|
||||||
|
Applied when building a folder name from a tag value — the tag keeps its exact
|
||||||
|
value (e.g. rating 87 → tag "Hodnocení/87"), while the folder groups it
|
||||||
|
(→ "…/80–89 %"). Config-driven and extensible via `TAG_TRANSFORMS`.
|
||||||
|
|
||||||
|
Source: [`src/core/transforms.py`](../../src/core/transforms.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `rating_band`
|
||||||
|
|
||||||
|
*function · line 11*
|
||||||
|
|
||||||
|
```python
|
||||||
|
rating_band(rating: int) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Bucket a 0–100 rating into a ten-point band label (e.g. "80–89 %").
|
||||||
|
|
||||||
|
The top bucket spans 90–100 % so a perfect 100 still lands in a band.
|
||||||
|
|
||||||
|
### `_decade_band`
|
||||||
|
|
||||||
|
*function · line 21*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_decade_band(value) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
`decade_band` transform: pull the leading integer and bucket it.
|
||||||
|
|
||||||
|
### `apply_transform`
|
||||||
|
|
||||||
|
*function · line 35*
|
||||||
|
|
||||||
|
```python
|
||||||
|
apply_transform(value: str, transform: Optional[str]) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply a named tag-schema transform to a value, returning a string.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# `src/core/utils.py`
|
||||||
|
|
||||||
|
Small filesystem helpers shared across the core modules.
|
||||||
|
|
||||||
|
Source: [`src/core/utils.py`](../../src/core/utils.py)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `list_files`
|
||||||
|
|
||||||
|
*function · line 3*
|
||||||
|
|
||||||
|
```python
|
||||||
|
list_files(folder_path: str | Path) -> list[Path]
|
||||||
|
```
|
||||||
|
|
||||||
|
All regular files below `folder_path`, searched recursively. Raises `NotADirectoryError` when the path is not a folder.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# `Curator.py`
|
||||||
|
|
||||||
|
Application entry point. Creates the shared `FileManager` and `TagManager`, applies the OS certificate store for HTTPS, and starts the Qt main window.
|
||||||
|
|
||||||
|
Source: [`Curator.py`](../../Curator.py)
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### `State`
|
||||||
|
|
||||||
|
*class · line 21*
|
||||||
|
|
||||||
|
```python
|
||||||
|
class State:
|
||||||
|
```
|
||||||
|
|
||||||
|
Holds the application's shared services: one `TagManager` and one `FileManager` built on top of it. Created once in `main()`.
|
||||||
|
|
||||||
|
#### `State.__init__`
|
||||||
|
|
||||||
|
*method · line 22*
|
||||||
|
|
||||||
|
```python
|
||||||
|
__init__() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the tag manager and the file manager that uses it.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `_use_system_certificates`
|
||||||
|
|
||||||
|
*function · line 7*
|
||||||
|
|
||||||
|
```python
|
||||||
|
_use_system_certificates() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Make HTTPS (ČSFD fetching) use the OS certificate store.
|
||||||
|
|
||||||
|
Required behind corporate SSL inspection, where the proxy's root CA is in the
|
||||||
|
Windows trust store but not in certifi's bundle. Best-effort; ignored if the
|
||||||
|
optional `truststore` package is unavailable.
|
||||||
|
|
||||||
|
### `main`
|
||||||
|
|
||||||
|
*function · line 27*
|
||||||
|
|
||||||
|
```python
|
||||||
|
main() -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Program entry point: installs the system certificate store, builds the `State` and runs the Qt application on its file manager and tag manager.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=HTTP server pro HLS složku Makov webkamery (pro Jellyfin M3U tuner)
|
||||||
|
After=network-online.target makov-cam.service
|
||||||
|
Wants=network-online.target
|
||||||
|
BindsTo=makov-cam.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# WorkingDirectory je rodič hls/, aby šel přes HTTP servírovat i makov.m3u
|
||||||
|
# (playlist pro Jellyfin), ne jen samotné HLS segmenty.
|
||||||
|
WorkingDirectory=/var/lib/jellyfin-iptv
|
||||||
|
# Bind na IP brány docker sítě, na které běží Jellyfin kontejner (ne na
|
||||||
|
# 127.0.0.1 hostitele — to by z kontejneru bylo jeho vlastní loopback, ne
|
||||||
|
# hostitelův). Konkrétní hodnota je specifická pro tenhle server, viz
|
||||||
|
# setup.md.
|
||||||
|
ExecStart=/usr/bin/python3 -m http.server 8888 --bind 172.19.0.1
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=makov-cam
|
||||||
|
Group=makov-cam
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Restream Makov webkamery (Krmítko) do lokálního HLS pro Jellyfin
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/makov-cam.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
# Necháme běžet pod samostatným účtem, ne pod root.
|
||||||
|
User=makov-cam
|
||||||
|
Group=makov-cam
|
||||||
|
# Pokud je yt-dlp nainstalované přes snap, ignoruje $HOME nastavené tady —
|
||||||
|
# zjišťuje domovský adresář přímo z /etc/passwd. Účet je --no-create-home,
|
||||||
|
# takže je potřeba /home/makov-cam vytvořit a vlastnit ručně (viz setup.md).
|
||||||
|
# Skript si sám zakládá /var/lib/jellyfin-iptv/hls — účet tam musí mít zápis
|
||||||
|
# (viz setup.md, sekce "Uživatel a oprávnění").
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Restreams the Makov webcam (YouTube live "Krmítko") into a local HLS
|
||||||
|
# playlist that never goes stale for the viewer — Jellyfin always points at
|
||||||
|
# this fixed local path, not at YouTube's own signed/expiring URL.
|
||||||
|
#
|
||||||
|
# When ffmpeg dies (the YouTube URL expired, network hiccup, stream briefly
|
||||||
|
# ended, ...) this script simply exits; systemd (Restart=always) starts it
|
||||||
|
# again, which resolves a *fresh* URL via yt-dlp. Nothing needs to be done
|
||||||
|
# by hand.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
YOUTUBE_URL="https://www.youtube.com/watch?v=F0fsV1GGZ3M"
|
||||||
|
OUT_DIR="/var/lib/jellyfin-iptv/hls"
|
||||||
|
OUT_FILE="${OUT_DIR}/makov.m3u8"
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
# This live stream only serves separate video-only/audio-only HLS renditions
|
||||||
|
# (no combined progressive format), so we resolve both and let ffmpeg mux
|
||||||
|
# them. "bestvideo+bestaudio" makes yt-dlp -g print exactly two URLs, video
|
||||||
|
# then audio; fall back to "best" in case a progressive format reappears.
|
||||||
|
mapfile -t STREAM_URLS < <(yt-dlp -g -f 'bestvideo+bestaudio/best' "$YOUTUBE_URL")
|
||||||
|
VIDEO_URL="${STREAM_URLS[0]:-}"
|
||||||
|
AUDIO_URL="${STREAM_URLS[1]:-}"
|
||||||
|
|
||||||
|
if [ -z "$VIDEO_URL" ]; then
|
||||||
|
echo "yt-dlp nevrátilo žádnou URL (stream offline, nebo se YouTube brání) — končím, systemd to za chvíli zkusí znovu." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$AUDIO_URL" ]; then
|
||||||
|
# Progressive fallback matched: single URL carries both video and audio.
|
||||||
|
exec ffmpeg -nostdin -loglevel warning \
|
||||||
|
-re -i "$VIDEO_URL" \
|
||||||
|
-c copy \
|
||||||
|
-f hls -hls_time 4 -hls_list_size 6 \
|
||||||
|
-hls_flags delete_segments+omit_endlist \
|
||||||
|
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
|
||||||
|
"$OUT_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec ffmpeg -nostdin -loglevel warning \
|
||||||
|
-re -i "$VIDEO_URL" -re -i "$AUDIO_URL" \
|
||||||
|
-map 0:v -map 1:a \
|
||||||
|
-c copy \
|
||||||
|
-f hls -hls_time 4 -hls_list_size 6 \
|
||||||
|
-hls_flags delete_segments+omit_endlist \
|
||||||
|
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
|
||||||
|
"$OUT_FILE"
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Makov webkamera → Jellyfin (Live TV)
|
||||||
|
|
||||||
|
Restreamuje YouTube live přenos ("Krmítko", https://www.youtube.com/watch?v=F0fsV1GGZ3M)
|
||||||
|
do lokálního HLS, na který se Jellyfin dívá přes stabilní adresu. Když YouTube
|
||||||
|
odkaz vyprší nebo spadne, systemd proces sám restartuje a natáhne novou URL —
|
||||||
|
divák/Jellyfin si ničeho nevšimne.
|
||||||
|
|
||||||
|
## 0) Kam skript patří
|
||||||
|
|
||||||
|
Toto **musí běžet na stejném stroji (resp. za stejnou veřejnou IP), jako
|
||||||
|
Jellyfin** — YouTube vrací streamovací URL svázanou s IP adresou, ze které
|
||||||
|
byla vyžádána (`yt-dlp -g`). Pokud poběží jinde než Jellyfin, `ffmpeg` dostane
|
||||||
|
403 a nikdy se nerozjede.
|
||||||
|
|
||||||
|
## 1) Závislosti
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y ffmpeg python3
|
||||||
|
sudo pip install -U yt-dlp # nebo pipx install yt-dlp — apt verze bývá stará
|
||||||
|
# a YouTube ji rád rozbije
|
||||||
|
```
|
||||||
|
|
||||||
|
Ověř, že `yt-dlp` a `ffmpeg` jedou z shellu bez chyby:
|
||||||
|
```bash
|
||||||
|
yt-dlp --version
|
||||||
|
ffmpeg -version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Uživatel a oprávnění
|
||||||
|
|
||||||
|
Vlastní, neprivilegovaný systémový účet — obě služby pod ním běží:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system --no-create-home --shell /usr/sbin/nologin makov-cam
|
||||||
|
sudo mkdir -p /var/lib/jellyfin-iptv/hls
|
||||||
|
sudo chown -R makov-cam:makov-cam /var/lib/jellyfin-iptv
|
||||||
|
```
|
||||||
|
|
||||||
|
Pokud je `yt-dlp` nainstalované přes **snap** (`which yt-dlp` ukazuje
|
||||||
|
`/snap/bin/yt-dlp`), potřebuje při startu zapisovatelný domovský adresář —
|
||||||
|
a to i přes `--no-create-home`, protože domov si zjišťuje přímo z
|
||||||
|
`/etc/passwd`, ne z proměnné `$HOME` (tu přenastavenou v service souboru
|
||||||
|
ignoruje). Bez toho skončí na `Permission denied: mkdir /home/makov-cam`.
|
||||||
|
Vytvoř mu ho ručně:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /home/makov-cam
|
||||||
|
sudo chown makov-cam:makov-cam /home/makov-cam
|
||||||
|
sudo chmod 750 /home/makov-cam
|
||||||
|
```
|
||||||
|
|
||||||
|
(Pokud `yt-dlp` instaluješ přes `pip`/`pipx` dle kroku 1, tenhle problém
|
||||||
|
nenastává a krok lze přeskočit.)
|
||||||
|
|
||||||
|
## 3) Nasazení souborů
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 755 makov-cam.sh /usr/local/bin/makov-cam.sh
|
||||||
|
sudo install -m 644 makov-cam.service /etc/systemd/system/
|
||||||
|
sudo install -m 644 makov-cam-http.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) Spuštění
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now makov-cam.service
|
||||||
|
sudo systemctl enable --now makov-cam-http.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Ověření:
|
||||||
|
```bash
|
||||||
|
systemctl status makov-cam.service makov-cam-http.service
|
||||||
|
journalctl -u makov-cam.service -f # živě sleduj log ffmpeg/yt-dlp
|
||||||
|
|
||||||
|
# do pár vteřin by se měl objevit playlist a segmenty:
|
||||||
|
ls -la /var/lib/jellyfin-iptv/hls/
|
||||||
|
|
||||||
|
# a mělo by jít stáhnout přes HTTP:
|
||||||
|
curl -sI http://127.0.0.1:8888/hls/makov.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
`makov-cam-http.service` je svázaná (`BindsTo=`) na `makov-cam.service` — když
|
||||||
|
restream spadne/restartuje, HTTP server padne/naskočí s ním, ať nikdy
|
||||||
|
neservíruje mrtvý/prázdný playlist. Pozor: `BindsTo=` jen zajišťuje, že HTTP
|
||||||
|
server *spadne*, když spadne hlavní služba — nezajišťuje, že se s ní i sám
|
||||||
|
*nastartuje*. Po `enable --now` obou služeb to není problém (obě se spustí),
|
||||||
|
ale pokud jen restartuješ `makov-cam.service` ručně, zkontroluj/restartuj i
|
||||||
|
`makov-cam-http.service`.
|
||||||
|
|
||||||
|
HTTP server poslouchá jen na jedné IP (`--bind`), ne na `0.0.0.0` — nemá cenu
|
||||||
|
ho vystavovat celé síti, stačí odkud se na něj bude dívat Jellyfin:
|
||||||
|
|
||||||
|
- **Jellyfin běží přímo na hostu** (ne v kontejneru): `--bind 127.0.0.1`
|
||||||
|
stačí.
|
||||||
|
- **Jellyfin běží v Docker kontejneru na tomtéž hostu** (běžný případ) —
|
||||||
|
`127.0.0.1` uvnitř kontejneru je jeho *vlastní* loopback, ne hostitelův,
|
||||||
|
takže na hostitelův HTTP server se tak nedostane. Zjisti gateway IP té
|
||||||
|
docker sítě, na které kontejner běží, a naviaž se na ni:
|
||||||
|
```bash
|
||||||
|
docker inspect <jellyfin-container> --format '{{.HostConfig.NetworkMode}}'
|
||||||
|
docker inspect <jellyfin-container> --format '{{json .NetworkSettings.Networks}}'
|
||||||
|
# z výstupu vezmi "Gateway", např. 172.19.0.1 — tam se z kontejneru dostaneš
|
||||||
|
```
|
||||||
|
a použij `--bind <ta-gateway-IP>` (viz `makov-cam-http.service`). Pokud by
|
||||||
|
Jellyfin běžel v jiné VM, změň `--bind` na IP, na které je pro ni
|
||||||
|
dosažitelný, a případně otevři firewall jen pro tenhle port a jen z její
|
||||||
|
IP.
|
||||||
|
|
||||||
|
## 5) Playlist pro Jellyfin
|
||||||
|
|
||||||
|
`WorkingDirectory` HTTP služby je `/var/lib/jellyfin-iptv` (rodič `hls/`), aby
|
||||||
|
šel přes stejný server servírovat i samotný `.m3u` playlist — to se hodí,
|
||||||
|
pokud Jellyfin (typicky v kontejneru) nevidí souborový systém hostitele a
|
||||||
|
tuner tak musí načíst playlist přes HTTP URL, ne lokální cestou.
|
||||||
|
|
||||||
|
Vytvoř `/var/lib/jellyfin-iptv/makov.m3u`:
|
||||||
|
|
||||||
|
```
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:-1 tvg-id="makov-krmitko" tvg-name="Makov - Krmítko",Makov - Krmítko
|
||||||
|
http://<BIND_IP>:8888/hls/makov.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
kde `<BIND_IP>` je stejná adresa, na kterou je navázaný `makov-cam-http.service`
|
||||||
|
(viz krok 4).
|
||||||
|
|
||||||
|
## 6) Nastavení v Jellyfinu
|
||||||
|
|
||||||
|
1. **Dashboard → Live TV → Tuner Devices → +** (Add)
|
||||||
|
2. Typ: **M3U Tuner**
|
||||||
|
3. File or URL:
|
||||||
|
- Jellyfin na hostu s přístupem k souborovému systému: lokální cesta
|
||||||
|
`/var/lib/jellyfin-iptv/makov.m3u`.
|
||||||
|
- Jellyfin v kontejneru (nevidí hostitelův FS): HTTP URL
|
||||||
|
`http://<BIND_IP>:8888/makov.m3u` (viz krok 5).
|
||||||
|
4. **Save** → Jellyfin načte kanál "Makov - Krmítko" do Live TV
|
||||||
|
5. Doporučeno: **Dashboard → Live TV → Guide Providers** — pro tenhle kanál
|
||||||
|
žádného není třeba (žádný EPG), Jellyfin ho ukáže jako kanál bez programu,
|
||||||
|
což nevadí.
|
||||||
|
6. Zkontroluj přehrávání v **Live TV** sekci hlavního rozhraní.
|
||||||
|
|
||||||
|
Protože kanál pořád ukazuje na stejnou lokální adresu, odpadá starost o
|
||||||
|
obnovování guide dat nebo expiraci URL na Jellyfin straně — o to se stará jen
|
||||||
|
`makov-cam.service` na pozadí.
|
||||||
|
|
||||||
|
## 7) Autostart po restartu serveru
|
||||||
|
|
||||||
|
`enable` v kroku 4 už zajistil start při bootu. Rychlá kontrola:
|
||||||
|
```bash
|
||||||
|
systemctl is-enabled makov-cam.service makov-cam-http.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Řešení problémů
|
||||||
|
|
||||||
|
- **`ffmpeg` pořád padá / v logu 403 od googlevideo.com** — nejčastěji IP
|
||||||
|
mismatch (viz bod 0): stroj, co spouští `yt-dlp -g`/`ffmpeg`, musí mít
|
||||||
|
stejnou veřejnou IP jako to, odkud by se stream normálně přehrával.
|
||||||
|
Zkontroluj `journalctl -u makov-cam.service -n 50`.
|
||||||
|
- **`yt-dlp: command not found` v service logu** — systemd nepoužívá tvůj
|
||||||
|
interaktivní `$PATH`; ověř `which yt-dlp` a případně uprav `ExecStart` v
|
||||||
|
`makov-cam.sh`/skriptu na plnou cestu (`/usr/local/bin/yt-dlp` apod.), nebo
|
||||||
|
nainstaluj přes `sudo pip install`, aby šel do `/usr/local/bin`.
|
||||||
|
- **Playlist v Jellyfinu jede, ale sekundy/minuty výpadku při refreshi** —
|
||||||
|
normální, `hls_list_size 6` × `hls_time 4s` drží ~24s okno; při restartu
|
||||||
|
streamu je pár vteřin díra, než naběhnou nové segmenty. Jde zkrátit/prodloužit
|
||||||
|
úpravou těch dvou hodnot ve skriptu.
|
||||||
|
- **YouTube někdy dočasně rozbije `yt-dlp` novou verzí přehrávače** — drž
|
||||||
|
`yt-dlp` aktuální (`sudo pip install -U yt-dlp`, klidně jako týdenní cron).
|
||||||
|
- **`ERROR: Requested format is not available`** — stream v tu chvíli nenabízí
|
||||||
|
žádný kombinovaný (progresivní) formát, jen oddělené video-only/audio-only
|
||||||
|
HLS řady (ověř přes `yt-dlp -F <url>`). Skript proto primárně používá
|
||||||
|
`-f 'bestvideo+bestaudio/best'` a při dvou vrácených URL pustí `ffmpeg` se
|
||||||
|
dvěma `-i` vstupy (`-map 0:v -map 1:a`); jedna URL = fallback na progresivní
|
||||||
|
formát, pokud se zase objeví.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from src.constants import VERSION
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("PREBUILD CONFIGURATION")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Check if running in virtual environment
|
||||||
|
project_root = Path(__file__).parent
|
||||||
|
expected_venv_path = project_root / ".venv"
|
||||||
|
current_executable = Path(sys.executable)
|
||||||
|
|
||||||
|
print(f"\nPython executable: {sys.executable}")
|
||||||
|
|
||||||
|
is_correct_venv = False
|
||||||
|
try:
|
||||||
|
current_executable.relative_to(expected_venv_path)
|
||||||
|
is_correct_venv = True
|
||||||
|
except ValueError:
|
||||||
|
is_correct_venv = False
|
||||||
|
|
||||||
|
if is_correct_venv:
|
||||||
|
print("✓ Correct environment selected for building")
|
||||||
|
else:
|
||||||
|
print("✗ Wrong environment selected")
|
||||||
|
print(f" Expected: {expected_venv_path}")
|
||||||
|
print(f" Current: {current_executable.parent.parent}")
|
||||||
|
|
||||||
|
print(f"✓ Version: {VERSION}")
|
||||||
|
|
||||||
|
env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true"
|
||||||
|
console_mode = env_debug
|
||||||
|
default_spec = Path(__file__).parent.name + ".spec"
|
||||||
|
spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec)
|
||||||
|
|
||||||
|
print(f"\n{'-' * 50}")
|
||||||
|
print("BUILD SETTINGS")
|
||||||
|
print(f"{'-' * 50}")
|
||||||
|
print(f"ENV_DEBUG: {env_debug}")
|
||||||
|
print(f"Console mode: {console_mode}")
|
||||||
|
print(f"Spec file: {spec_filename}")
|
||||||
|
|
||||||
|
spec_path = Path(__file__).parent / spec_filename
|
||||||
|
if spec_path.exists():
|
||||||
|
with open(spec_path, "r", encoding="utf-8") as f:
|
||||||
|
spec_content = f.read()
|
||||||
|
|
||||||
|
if f"console={not console_mode}" in spec_content:
|
||||||
|
new_spec_content = spec_content.replace(
|
||||||
|
f"console={not console_mode}", f"console={console_mode}"
|
||||||
|
)
|
||||||
|
with open(spec_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_spec_content)
|
||||||
|
print(f"✓ Updated {spec_filename}: console={console_mode}")
|
||||||
|
else:
|
||||||
|
print(f"✓ {spec_filename} already configured: console={console_mode}")
|
||||||
|
else:
|
||||||
|
print(f"✗ {spec_filename} not found!")
|
||||||
|
|
||||||
|
print(f"{'-' * 50}\n")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[project]
|
||||||
|
name = "curator"
|
||||||
|
version = "1.13.0"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = [
|
||||||
|
"beautifulsoup4>=4.15.0",
|
||||||
|
"loguru>=0.7.3",
|
||||||
|
"pillow>=12.3.0",
|
||||||
|
"pyside6>=6.12.0",
|
||||||
|
"python-dotenv>=1.2.4",
|
||||||
|
"requests>=2.34.2",
|
||||||
|
"truststore>=0.10.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy>=2.4.0",
|
||||||
|
"pyinstaller>=6.22.3",
|
||||||
|
"pytest>=9.1.1",
|
||||||
|
"ruff>=0.17.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""One-off migration: rename a tag category inside a pool's metadata index.
|
||||||
|
|
||||||
|
Tags are stored in ``<pool>/.Curator.!index`` as ``"Category/Name"`` strings.
|
||||||
|
This rewrites every tag whose category matches ``--old`` to use ``--new``,
|
||||||
|
leaving the tag name untouched. A timestamped backup of the index is written
|
||||||
|
before saving.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python scripts/migrate_tag_category.py <pool_dir> \
|
||||||
|
--old "Země" --new "Země původu"
|
||||||
|
|
||||||
|
If ``<pool_dir>`` is omitted, the pool from the global config is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.core.config import load_global_config
|
||||||
|
from src.core.pool_index import INDEX_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int]:
|
||||||
|
"""Return (rewritten tags, number of tags changed) for one record."""
|
||||||
|
prefix = f"{old}/"
|
||||||
|
changed = 0
|
||||||
|
result: list[str] = []
|
||||||
|
for tag in tags:
|
||||||
|
if isinstance(tag, str) and tag.startswith(prefix):
|
||||||
|
result.append(f"{new}/{tag[len(prefix) :]}")
|
||||||
|
changed += 1
|
||||||
|
else:
|
||||||
|
result.append(tag)
|
||||||
|
return result, changed
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(index_path: Path, old: str, new: str) -> int:
|
||||||
|
"""Rewrite the category in place and return the number of tags changed."""
|
||||||
|
with open(index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
movies: dict[str, dict] = data.get("movies", {})
|
||||||
|
total_changed = 0
|
||||||
|
affected_records = 0
|
||||||
|
for key, record in movies.items():
|
||||||
|
tags = record.get("tags", [])
|
||||||
|
new_tags, changed = _rename_in_tags(tags, old, new)
|
||||||
|
if changed:
|
||||||
|
record["tags"] = new_tags
|
||||||
|
total_changed += changed
|
||||||
|
affected_records += 1
|
||||||
|
logger.debug(f"{key}: {changed} tag(s) renamed")
|
||||||
|
|
||||||
|
if total_changed == 0:
|
||||||
|
logger.info(f"No '{old}/…' tags found — nothing to migrate")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
backup = index_path.with_suffix(
|
||||||
|
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
)
|
||||||
|
shutil.copy2(index_path, backup)
|
||||||
|
logger.info(f"Backup written: {backup}")
|
||||||
|
|
||||||
|
with open(index_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Migrated '{old}' → '{new}': {total_changed} tag(s) "
|
||||||
|
f"across {affected_records} record(s)"
|
||||||
|
)
|
||||||
|
return total_changed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"pool_dir",
|
||||||
|
nargs="?",
|
||||||
|
help="Pool root (default: pool_dir from the global config)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--old", default="Země", help="Category to rename from")
|
||||||
|
parser.add_argument("--new", default="Země původu", help="Category to rename to")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
|
||||||
|
if not pool_dir:
|
||||||
|
parser.error("No pool_dir given and none configured in the global config")
|
||||||
|
|
||||||
|
index_path = Path(pool_dir) / INDEX_FILENAME
|
||||||
|
if not index_path.exists():
|
||||||
|
parser.error(f"No index found at {index_path}")
|
||||||
|
|
||||||
|
migrate(index_path, args.old, args.new)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""One-off migration: split combined country tags in a pool's metadata index.
|
||||||
|
|
||||||
|
Before multi-country support, a co-production fetched from ČSFD was stored as a
|
||||||
|
single ``"Země původu/USA / Velká Británie"`` tag. This rewrites each such tag
|
||||||
|
into one tag per country (``"Země původu/USA"`` + ``"Země původu/Velká
|
||||||
|
Británie"``), de-duplicating within each record. A timestamped backup of the
|
||||||
|
index is written before saving.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python scripts/split_country_tags.py [<pool_dir>] [--category "Země původu"]
|
||||||
|
|
||||||
|
If ``<pool_dir>`` is omitted, the pool from the global config is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.core.config import load_global_config
|
||||||
|
from src.core.pool_index import INDEX_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
|
||||||
|
"""Return (rewritten tags, number of combined tags split) for one record.
|
||||||
|
|
||||||
|
Order is preserved; duplicates produced by the split are dropped.
|
||||||
|
"""
|
||||||
|
prefix = f"{category}/"
|
||||||
|
result: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
split_count = 0
|
||||||
|
|
||||||
|
def _add(tag: str) -> None:
|
||||||
|
if tag not in seen:
|
||||||
|
seen.add(tag)
|
||||||
|
result.append(tag)
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
if (
|
||||||
|
isinstance(tag, str)
|
||||||
|
and tag.startswith(prefix)
|
||||||
|
and "/" in tag[len(prefix) :]
|
||||||
|
):
|
||||||
|
value = tag[len(prefix) :]
|
||||||
|
countries = [c.strip() for c in value.split("/") if c.strip()]
|
||||||
|
for country in countries:
|
||||||
|
_add(f"{prefix}{country}")
|
||||||
|
split_count += 1
|
||||||
|
else:
|
||||||
|
_add(tag)
|
||||||
|
return result, split_count
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(index_path: Path, category: str) -> int:
|
||||||
|
"""Split combined ``category`` tags in place; return number of tags split."""
|
||||||
|
with open(index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
movies: dict[str, dict] = data.get("movies", {})
|
||||||
|
total_split = 0
|
||||||
|
affected = 0
|
||||||
|
for key, record in movies.items():
|
||||||
|
tags = record.get("tags", [])
|
||||||
|
new_tags, split_count = _split_record_tags(tags, category)
|
||||||
|
if split_count:
|
||||||
|
record["tags"] = new_tags
|
||||||
|
total_split += split_count
|
||||||
|
affected += 1
|
||||||
|
logger.debug(f"{key}: {split_count} combined tag(s) split")
|
||||||
|
|
||||||
|
if total_split == 0:
|
||||||
|
logger.info(f"No combined '{category}/…' tags found — nothing to migrate")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
backup = index_path.with_suffix(
|
||||||
|
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
)
|
||||||
|
shutil.copy2(index_path, backup)
|
||||||
|
logger.info(f"Backup written: {backup}")
|
||||||
|
|
||||||
|
with open(index_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Split {total_split} combined '{category}' tag(s) across {affected} record(s)"
|
||||||
|
)
|
||||||
|
return total_split
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"pool_dir",
|
||||||
|
nargs="?",
|
||||||
|
help="Pool root (default: pool_dir from the global config)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--category", default="Země původu", help="Tag category to split"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
|
||||||
|
if not pool_dir:
|
||||||
|
parser.error("No pool_dir given and none configured in the global config")
|
||||||
|
|
||||||
|
index_path = Path(pool_dir) / INDEX_FILENAME
|
||||||
|
if not index_path.exists():
|
||||||
|
parser.error(f"No index found at {index_path}")
|
||||||
|
|
||||||
|
migrate(index_path, args.category)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""One-off migration: drop the old decade-band rating tags from a pool index.
|
||||||
|
|
||||||
|
Earlier the ČSFD rating was stored bucketed (e.g. ``Hodnocení/90–100 %``). Now
|
||||||
|
the tag carries the exact value (``Hodnocení/90``) and the band is only a folder.
|
||||||
|
This removes the legacy band tags (``Hodnocení/<x>–<y> %``) so re-fetching from
|
||||||
|
ČSFD leaves only the exact ratings. Exact rating tags are kept. A timestamped
|
||||||
|
backup of the index is written first.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python scripts/strip_rating_bands.py [<pool_dir>] [--category "Hodnocení"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.core.config import load_global_config
|
||||||
|
from src.core.pool_index import INDEX_FILENAME
|
||||||
|
|
||||||
|
# A band tag looks like "Hodnocení/90–100 %" — value has a dash range and a "%".
|
||||||
|
_BAND_RE = re.compile(r"\d+\s*[–-]\s*\d+\s*%")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_bands(tags: list[str], category: str) -> tuple[list[str], int]:
|
||||||
|
"""Return (kept tags, removed count), dropping ``category`` band tags."""
|
||||||
|
prefix = f"{category}/"
|
||||||
|
kept = [
|
||||||
|
t
|
||||||
|
for t in tags
|
||||||
|
if not (isinstance(t, str) and t.startswith(prefix) and _BAND_RE.search(t))
|
||||||
|
]
|
||||||
|
return kept, len(tags) - len(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(index_path: Path, category: str) -> int:
|
||||||
|
"""Remove band rating tags in place; return number of tags removed."""
|
||||||
|
with open(index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
movies: dict[str, dict] = data.get("movies", {})
|
||||||
|
total_removed = 0
|
||||||
|
affected = 0
|
||||||
|
for key, record in movies.items():
|
||||||
|
tags = record.get("tags", [])
|
||||||
|
kept, removed = _strip_bands(tags, category)
|
||||||
|
if removed:
|
||||||
|
record["tags"] = kept
|
||||||
|
# also drop them from the ČSFD provenance set, if present
|
||||||
|
if isinstance(record.get("csfd_tags"), list):
|
||||||
|
record["csfd_tags"] = [
|
||||||
|
t
|
||||||
|
for t in record["csfd_tags"]
|
||||||
|
if not (t.startswith(f"{category}/") and _BAND_RE.search(t))
|
||||||
|
]
|
||||||
|
total_removed += removed
|
||||||
|
affected += 1
|
||||||
|
logger.debug(f"{key}: removed {removed} band tag(s)")
|
||||||
|
|
||||||
|
if total_removed == 0:
|
||||||
|
logger.info(f"No '{category}/…–… %' band tags found — nothing to migrate")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
backup = index_path.with_suffix(
|
||||||
|
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
)
|
||||||
|
shutil.copy2(index_path, backup)
|
||||||
|
logger.info(f"Backup written: {backup}")
|
||||||
|
|
||||||
|
with open(index_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Removed {total_removed} band '{category}' tag(s) across {affected} record(s)"
|
||||||
|
)
|
||||||
|
return total_removed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"pool_dir",
|
||||||
|
nargs="?",
|
||||||
|
help="Pool root (default: pool_dir from the global config)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--category", default="Hodnocení", help="Rating category")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
|
||||||
|
if not pool_dir:
|
||||||
|
parser.error("No pool_dir given and none configured in the global config")
|
||||||
|
|
||||||
|
index_path = Path(pool_dir) / INDEX_FILENAME
|
||||||
|
if not index_path.exists():
|
||||||
|
parser.error(f"No index found at {index_path}")
|
||||||
|
|
||||||
|
migrate(index_path, args.category)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""One-off migration: drop all tags of given categories from a pool's index.
|
||||||
|
|
||||||
|
Used to remove tag categories that turned out to be a bad idea (e.g. Režie /
|
||||||
|
Herec produced far too many folders). Cached ČSFD data is left intact — only the
|
||||||
|
``tags`` lists are pruned. A timestamped backup of the index is written first.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python scripts/strip_tag_categories.py [<pool_dir>] \
|
||||||
|
--categories "Režie" "Herec"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.core.config import load_global_config
|
||||||
|
from src.core.pool_index import INDEX_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
def _strip(tags: list[str], prefixes: tuple[str, ...]) -> tuple[list[str], int]:
|
||||||
|
"""Return (kept tags, number removed) dropping tags under any prefix."""
|
||||||
|
kept = [t for t in tags if not (isinstance(t, str) and t.startswith(prefixes))]
|
||||||
|
return kept, len(tags) - len(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(index_path: Path, categories: list[str]) -> int:
|
||||||
|
"""Remove all tags of ``categories`` in place; return number of tags removed."""
|
||||||
|
prefixes = tuple(f"{c}/" for c in categories)
|
||||||
|
|
||||||
|
with open(index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
movies: dict[str, dict] = data.get("movies", {})
|
||||||
|
total_removed = 0
|
||||||
|
affected = 0
|
||||||
|
for key, record in movies.items():
|
||||||
|
tags = record.get("tags", [])
|
||||||
|
kept, removed = _strip(tags, prefixes)
|
||||||
|
if removed:
|
||||||
|
record["tags"] = kept
|
||||||
|
total_removed += removed
|
||||||
|
affected += 1
|
||||||
|
logger.debug(f"{key}: removed {removed} tag(s)")
|
||||||
|
|
||||||
|
if total_removed == 0:
|
||||||
|
logger.info(f"No tags in {categories} found — nothing to migrate")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
backup = index_path.with_suffix(
|
||||||
|
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
)
|
||||||
|
shutil.copy2(index_path, backup)
|
||||||
|
logger.info(f"Backup written: {backup}")
|
||||||
|
|
||||||
|
with open(index_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Removed {total_removed} tag(s) of {categories} across {affected} record(s)"
|
||||||
|
)
|
||||||
|
return total_removed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"pool_dir",
|
||||||
|
nargs="?",
|
||||||
|
help="Pool root (default: pool_dir from the global config)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--categories",
|
||||||
|
nargs="+",
|
||||||
|
default=["Režie", "Herec"],
|
||||||
|
help="Tag categories to strip",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
|
||||||
|
if not pool_dir:
|
||||||
|
parser.error("No pool_dir given and none configured in the global config")
|
||||||
|
|
||||||
|
index_path = Path(pool_dir) / INDEX_FILENAME
|
||||||
|
if not index_path.exists():
|
||||||
|
parser.error(f"No index found at {index_path}")
|
||||||
|
|
||||||
|
migrate(index_path, args.categories)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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=/usr/bin/python3 /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka/Tools/tipy_dne.py --pool /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka --output /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmy --count 15
|
||||||
|
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,143 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Standalone "Tipy dne" generator — deployable next to a Filmotéka (e.g. cron).
|
||||||
|
|
||||||
|
On each run it reads the pool index (``<pool>/.Curator.!index``), picks N random
|
||||||
|
pool movies, clears the tips folder in the output and recreates it as hardlinks.
|
||||||
|
It does not import Curator — only the Python standard library — so it can live on
|
||||||
|
the server with the Filmotéka, independent of the app.
|
||||||
|
|
||||||
|
Paths come from CLI args, or fall back to the global config (``.Curator.!gtag``,
|
||||||
|
keys ``pool_dir`` / ``filmoteka_dir``) when ``--config`` is given or one is found.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python scripts/tipy_dne.py --pool /mnt/srv/Filmoteka --output /mnt/srv/Filmy/Filmoteka_v2
|
||||||
|
python scripts/tipy_dne.py --count 15 # uses the repo's global config
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
INDEX_FILENAME = ".Curator.!index"
|
||||||
|
GLOBAL_CONFIG_FILENAME = ".Curator.!gtag"
|
||||||
|
DEFAULT_SUBFOLDER = "- Tipy dne" # "- " prefix sorts it first in DLNA/TV browsers
|
||||||
|
DEFAULT_COUNT = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config_paths(config_path: Path) -> tuple[Path | None, Path | None]:
|
||||||
|
"""Read (pool_dir, filmoteka_dir) from a global config file, if present."""
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
except OSError, json.JSONDecodeError:
|
||||||
|
return None, None
|
||||||
|
pool = cfg.get("pool_dir")
|
||||||
|
out = cfg.get("filmoteka_dir")
|
||||||
|
return (Path(pool) if pool else None, Path(out) if out else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_movies(pool_dir: Path) -> list[Path]:
|
||||||
|
"""Existing pool movie files listed in the index (copy-as-is folders aren't
|
||||||
|
indexed, so this is the pool/Filmy set only)."""
|
||||||
|
index_path = pool_dir / INDEX_FILENAME
|
||||||
|
with open(index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
movies: list[Path] = []
|
||||||
|
for rel in data.get("movies", {}):
|
||||||
|
path = pool_dir / rel
|
||||||
|
if path.is_file():
|
||||||
|
movies.append(path)
|
||||||
|
return movies
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_folder(folder: Path) -> int:
|
||||||
|
"""Remove the files in ``folder`` (the old tips). Returns how many removed."""
|
||||||
|
removed = 0
|
||||||
|
if folder.exists():
|
||||||
|
for child in folder.iterdir():
|
||||||
|
if child.is_file() or child.is_symlink():
|
||||||
|
try:
|
||||||
|
child.unlink()
|
||||||
|
removed += 1
|
||||||
|
except OSError as exc:
|
||||||
|
print(f" ! nelze smazat {child}: {exc}", file=sys.stderr)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def generate_tips(pool_dir: Path, output_dir: Path, count: int, subfolder: str) -> int:
|
||||||
|
"""Refresh the tips folder with ``count`` random pool movies as hardlinks."""
|
||||||
|
movies = _pool_movies(pool_dir)
|
||||||
|
if not movies:
|
||||||
|
print("Index neobsahuje žádné filmy.", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
tips_dir = output_dir / subfolder
|
||||||
|
cleared = _clear_folder(tips_dir)
|
||||||
|
tips_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
chosen = random.sample(movies, min(count, len(movies)))
|
||||||
|
created = 0
|
||||||
|
for src in chosen:
|
||||||
|
target = tips_dir / src.name
|
||||||
|
try:
|
||||||
|
if target.exists():
|
||||||
|
target.unlink()
|
||||||
|
os.link(src, target)
|
||||||
|
created += 1
|
||||||
|
print(f" + {src.name}")
|
||||||
|
except OSError as exc:
|
||||||
|
print(f" ! {src.name}: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(f"\nTipy dne: smazáno {cleared}, vytvořeno {created} (z {len(movies)} filmů)")
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Vygeneruje složku Tipy dne.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--pool", type=Path, help="Pool root (obsahuje .Curator.!index)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", type=Path, help="Výstupní složka Filmotéky")
|
||||||
|
parser.add_argument(
|
||||||
|
"--count",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_COUNT,
|
||||||
|
help=f"Počet náhodných titulů (výchozí {DEFAULT_COUNT})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--subfolder",
|
||||||
|
default=DEFAULT_SUBFOLDER,
|
||||||
|
help=f'Název složky tipů (výchozí "{DEFAULT_SUBFOLDER}")',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
type=Path,
|
||||||
|
help="Cesta ke global configu (.Curator.!gtag) pro doplnění cest",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pool, output = args.pool, args.output
|
||||||
|
if pool is None or output is None:
|
||||||
|
config_path = args.config or (
|
||||||
|
Path(__file__).resolve().parent.parent / GLOBAL_CONFIG_FILENAME
|
||||||
|
)
|
||||||
|
cfg_pool, cfg_out = _load_config_paths(config_path)
|
||||||
|
pool = pool or cfg_pool
|
||||||
|
output = output or cfg_out
|
||||||
|
|
||||||
|
if pool is None or output is None:
|
||||||
|
parser.error("Chybí --pool a/nebo --output (a nenalezeny v global configu).")
|
||||||
|
if not (pool / INDEX_FILENAME).is_file():
|
||||||
|
parser.error(f"Index nenalezen: {pool / INDEX_FILENAME}")
|
||||||
|
|
||||||
|
created = generate_tips(pool, output, args.count, args.subfolder)
|
||||||
|
sys.exit(0 if created else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Auto-generated — do not edit manually."""
|
||||||
|
__version__ = "1.13.0"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Application-level constants for Curator (build/runtime metadata).
|
||||||
|
|
||||||
|
This module derives the app version and debug flag. The version is loaded from
|
||||||
|
`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for
|
||||||
|
frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`).
|
||||||
|
|
||||||
|
It is the single source of truth for app metadata used across the GUI and build
|
||||||
|
tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
_PYPROJECT_PATH = Path(__file__).parent.parent / "pyproject.toml"
|
||||||
|
_VERSION_FILE = Path(__file__).parent / "_version.py"
|
||||||
|
|
||||||
|
APP_NAME: str = "Curator"
|
||||||
|
|
||||||
|
|
||||||
|
def get_version() -> str:
|
||||||
|
"""Return the project version (pyproject → _version.py → unknown)."""
|
||||||
|
# 1. pyproject.toml (preferred)
|
||||||
|
try:
|
||||||
|
with open(_PYPROJECT_PATH, "rb") as f:
|
||||||
|
version = tomllib.load(f)["project"]["version"]
|
||||||
|
# Cache a fallback for frozen/PyInstaller builds (best-effort).
|
||||||
|
try:
|
||||||
|
_VERSION_FILE.write_text(
|
||||||
|
f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return version
|
||||||
|
except FileNotFoundError, KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. generated _version.py
|
||||||
|
try:
|
||||||
|
from src._version import __version__ # type: ignore[import]
|
||||||
|
|
||||||
|
return __version__
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. last resort
|
||||||
|
return "0.0.0-unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def get_debug_mode() -> bool:
|
||||||
|
"""Return True when ENV_DEBUG is set to a truthy value in the environment."""
|
||||||
|
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
APP_VERSION: str = get_version()
|
||||||
|
ENV_DEBUG: bool = get_debug_mode()
|
||||||
|
APP_TITLE: str = f"{APP_NAME} v{APP_VERSION}" + ("-DEV" if ENV_DEBUG else "")
|
||||||
|
|
||||||
|
# Backwards-compatible alias used by prebuild.py
|
||||||
|
VERSION: str = APP_VERSION
|
||||||
|
|
||||||
|
# Default main-window geometry (used when no saved geometry exists)
|
||||||
|
APP_VIEWPORT: str = "1000x700"
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Configuration for Curator.
|
||||||
|
|
||||||
|
Two stores, by responsibility:
|
||||||
|
|
||||||
|
1. Global config (``.Curator.!gtag`` next to Curator.py) — **application data
|
||||||
|
only**: window state, MRU folders, and the pointers to where the pool and the
|
||||||
|
Filmotéka output live (``pool_dir`` / ``filmoteka_dir``). Those pointers must
|
||||||
|
stay here because the pool index is found *through* them.
|
||||||
|
2. Pool index (``<pool>/.Curator.!index``, see ``pool_index.py``) — everything
|
||||||
|
describing a particular library: per-movie metadata **and** library-level
|
||||||
|
settings (``tag_schema``, ``copyasis_folders``). It travels with the pool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Global config file (next to the main script)
|
||||||
|
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TAG SCHEMA - default library settings (stored per-pool in the index)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Tag schema: the single source of truth for which tag categories exist, how
|
||||||
|
# they are derived from ČSFD, and how they map to the Filmotéka folder tree.
|
||||||
|
# Each entry:
|
||||||
|
# category — tag category name (e.g. "Žánr")
|
||||||
|
# csfd_field — NFO source field feeding it (genres / year / countries /
|
||||||
|
# rating), or None for a user-only category. (Key name kept for
|
||||||
|
# index compatibility; the data now comes from the NFO.)
|
||||||
|
# transform — named value transform: None (str) or "decade_band" (rating)
|
||||||
|
# filmoteka_root — folder under the output: "" = at the root (e.g. genres),
|
||||||
|
# "Dle X" = grouping folder, None = filterable but no folders
|
||||||
|
# filename_template — optional per-category hardlink name used only inside this
|
||||||
|
# category's folders (e.g. "{year} - {title}{ext}"); fields:
|
||||||
|
# title / year / rating / ext / stem / filename. Absent/None =
|
||||||
|
# keep the pool filename. Pool files are never renamed by this.
|
||||||
|
# Grouping folders are prefixed with "- " so DLNA/TV browsers sort the special
|
||||||
|
# folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root.
|
||||||
|
# This lives in the code as the default; the effective schema is stored per-pool
|
||||||
|
# in the index (``PoolIndex.settings["tag_schema"]``).
|
||||||
|
DEFAULT_TAG_SCHEMA = [
|
||||||
|
{
|
||||||
|
"category": "Žánr",
|
||||||
|
"csfd_field": "genres",
|
||||||
|
"transform": None,
|
||||||
|
"filmoteka_root": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Rok",
|
||||||
|
"csfd_field": "year",
|
||||||
|
"transform": None,
|
||||||
|
"filmoteka_root": "- Dle roku",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Země původu",
|
||||||
|
"csfd_field": "countries",
|
||||||
|
"transform": None,
|
||||||
|
"filmoteka_root": "- Dle země původu",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Hodnocení",
|
||||||
|
"csfd_field": "rating",
|
||||||
|
"transform": "decade_band",
|
||||||
|
"filmoteka_root": "- Dle hodnocení",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GLOBAL CONFIG - Application settings only
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
DEFAULT_GLOBAL_CONFIG = {
|
||||||
|
"window_geometry": "1200x800",
|
||||||
|
"window_maximized": False,
|
||||||
|
"last_folder": None,
|
||||||
|
"sidebar_width": 250,
|
||||||
|
"recent_folders": [],
|
||||||
|
"pool_dir": None, # managed pool root (single source of truth)
|
||||||
|
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_global_config() -> dict:
|
||||||
|
"""Load global application config"""
|
||||||
|
if GLOBAL_CONFIG_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
# Merge with defaults for any missing keys
|
||||||
|
for key, value in DEFAULT_GLOBAL_CONFIG.items():
|
||||||
|
if key not in config:
|
||||||
|
config[key] = value
|
||||||
|
return config
|
||||||
|
except OSError, ValueError, TypeError:
|
||||||
|
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||||
|
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def save_global_config(cfg: dict):
|
||||||
|
"""Save global application config"""
|
||||||
|
with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BACKWARDS COMPATIBILITY
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""Legacy function - returns global config"""
|
||||||
|
return load_global_config()
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict):
|
||||||
|
"""Legacy function - saves global config"""
|
||||||
|
save_global_config(cfg)
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"""ČSFD.cz scraper — a *title bridge* to TMDb.
|
||||||
|
|
||||||
|
Curator identifies movies by **TMDb id**. A direct TMDb search on a Czech
|
||||||
|
distribution title is unreliable (remakes, localized names, "weird" hits).
|
||||||
|
ČSFD, on the other hand, indexes Czech release names well **and** lists a
|
||||||
|
film's *original* title. So the bridge is::
|
||||||
|
|
||||||
|
filename → ČSFD search → ČSFD film page → original title + year
|
||||||
|
→ TMDb search → tmdbid
|
||||||
|
|
||||||
|
This module only scrapes ČSFD; the TMDb half lives in
|
||||||
|
:mod:`src.core.file_manager` (:meth:`FileManager.resolve_via_csfd`). Nothing
|
||||||
|
here feeds tags/genres — those still come from TMDb via Jellyfin/NFO.
|
||||||
|
|
||||||
|
ČSFD serves an **Anubis** proof-of-work wall in front of every page: a plain
|
||||||
|
request gets a 200 with a JS challenge instead of the movie. We detect it,
|
||||||
|
solve the SHA-256 PoW the way the bundled worker JS does, and replay the
|
||||||
|
request through a shared :class:`requests.Session` (so a batch pays the PoW
|
||||||
|
cost only once).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode, urljoin
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
_HAS_DEPS = True
|
||||||
|
except ImportError: # pragma: no cover - exercised only without the extras
|
||||||
|
_HAS_DEPS = False
|
||||||
|
requests = None # type: ignore
|
||||||
|
BeautifulSoup = None # type: ignore
|
||||||
|
|
||||||
|
CSFD_BASE_URL = "https://www.csfd.cz"
|
||||||
|
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept-Language": "cs,en;q=0.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Anubis anti-bot wall: a plain GET returns a 200 challenge page, not the movie.
|
||||||
|
ANUBIS_CHALLENGE_MARKER = 'id="anubis_challenge"'
|
||||||
|
ANUBIS_PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
|
||||||
|
# Safety cap so a difficulty bump can never spin forever (difficulty 1 needs ~16).
|
||||||
|
ANUBIS_MAX_NONCE = 50_000_000
|
||||||
|
|
||||||
|
|
||||||
|
class CsfdError(RuntimeError):
|
||||||
|
"""A ČSFD lookup could not be completed (deps missing, network, anti-bot)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CsfdFilm:
|
||||||
|
"""The slice of a ČSFD film page Curator needs to reach a TMDb id."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
csfd_id: int | None = None
|
||||||
|
czech_title: str = ""
|
||||||
|
original_title: str = ""
|
||||||
|
year: str | None = None # four-digit string, or None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tmdb_query(self) -> tuple[str, str | None]:
|
||||||
|
"""``(title, year)`` best suited for a TMDb search — original title first."""
|
||||||
|
return (self.original_title or self.czech_title, self.year)
|
||||||
|
|
||||||
|
|
||||||
|
def dependencies_available() -> bool:
|
||||||
|
"""True when ``requests`` + ``beautifulsoup4`` are importable."""
|
||||||
|
return _HAS_DEPS
|
||||||
|
|
||||||
|
|
||||||
|
def _check_dependencies() -> None:
|
||||||
|
if not _HAS_DEPS:
|
||||||
|
raise CsfdError(
|
||||||
|
"ČSFD vyžaduje balíčky 'requests' a 'beautifulsoup4' (uv sync je doplní)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_csfd_id(url: str) -> int | None:
|
||||||
|
"""Pull the numeric film id out of a ČSFD URL, or ``None``."""
|
||||||
|
match = re.search(r"/film/(\d+)", url)
|
||||||
|
return int(match.group(1)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Anubis proof-of-work (ported verbatim from the pre-Jellyfin csfd.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_blob(html: str, element_id: str):
|
||||||
|
"""Return the parsed JSON from an Anubis ``<script id=...>`` blob, or None."""
|
||||||
|
match = re.search(
|
||||||
|
rf'<script id="{re.escape(element_id)}" type="application/json">(.*?)</script>',
|
||||||
|
html,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]:
|
||||||
|
"""Brute-force the Anubis proof-of-work.
|
||||||
|
|
||||||
|
Mirrors the bundled ``sha256-purejs`` worker: find the smallest ``nonce``
|
||||||
|
such that ``sha256(random_data + str(nonce))`` has ``difficulty`` leading
|
||||||
|
zero nibbles. Returns ``(hash_hex, nonce, elapsed_ms)``.
|
||||||
|
"""
|
||||||
|
full_zero_bytes = difficulty // 2
|
||||||
|
needs_half_byte = difficulty % 2 != 0
|
||||||
|
start = time.monotonic()
|
||||||
|
for nonce in range(ANUBIS_MAX_NONCE):
|
||||||
|
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).digest()
|
||||||
|
if any(digest[i] != 0 for i in range(full_zero_bytes)):
|
||||||
|
continue
|
||||||
|
if needs_half_byte and digest[full_zero_bytes] >> 4 != 0:
|
||||||
|
continue
|
||||||
|
elapsed_ms = int((time.monotonic() - start) * 1000)
|
||||||
|
return digest.hex(), nonce, elapsed_ms
|
||||||
|
raise CsfdError(
|
||||||
|
f"Anubis PoW nevyřešen do {ANUBIS_MAX_NONCE} pokusů (obtížnost {difficulty})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_anubis_challenge(session, html: str, url: str):
|
||||||
|
"""Solve the Anubis challenge in ``html`` and return the real page response."""
|
||||||
|
payload = _extract_json_blob(html, "anubis_challenge")
|
||||||
|
if not payload:
|
||||||
|
raise CsfdError("ČSFD anti-bot stránka bez čitelné Anubis challenge.")
|
||||||
|
|
||||||
|
rules = payload.get("rules", {})
|
||||||
|
challenge = payload.get("challenge", {})
|
||||||
|
random_data = challenge.get("randomData")
|
||||||
|
difficulty = int(rules.get("difficulty", 1))
|
||||||
|
if not random_data:
|
||||||
|
raise CsfdError("Anubis challenge neobsahuje randomData.")
|
||||||
|
|
||||||
|
base_prefix = _extract_json_blob(html, "anubis_base_prefix") or ""
|
||||||
|
logger.debug(f"Solving Anubis challenge (difficulty {difficulty}) for {url}")
|
||||||
|
hash_hex, nonce, elapsed_ms = _solve_anubis_pow(random_data, difficulty)
|
||||||
|
logger.debug(f"Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms")
|
||||||
|
|
||||||
|
pass_url = urljoin(CSFD_BASE_URL, f"{base_prefix}{ANUBIS_PASS_PATH}")
|
||||||
|
response = session.get(
|
||||||
|
pass_url,
|
||||||
|
params={
|
||||||
|
"id": challenge.get("id"),
|
||||||
|
"response": hash_hex,
|
||||||
|
"nonce": nonce,
|
||||||
|
"redir": url,
|
||||||
|
"elapsedTime": elapsed_ms,
|
||||||
|
},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
if ANUBIS_CHALLENGE_MARKER in response.text:
|
||||||
|
raise CsfdError("ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto).")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _get_page(session, url: str):
|
||||||
|
"""GET ``url`` through ``session``, transparently clearing an Anubis wall."""
|
||||||
|
response = session.get(url, headers=HEADERS, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
if ANUBIS_CHALLENGE_MARKER in response.text:
|
||||||
|
response = _solve_anubis_challenge(session, response.text, url)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Page parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_ld(soup) -> tuple[str, str | None]:
|
||||||
|
"""``(name, year)`` from the film page's JSON-LD (name is the Czech title)."""
|
||||||
|
for script in soup.find_all("script", type="application/ld+json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(script.string or "")
|
||||||
|
except json.JSONDecodeError, TypeError:
|
||||||
|
continue
|
||||||
|
if isinstance(data, list):
|
||||||
|
data = next((d for d in data if d.get("@type") == "Movie"), None)
|
||||||
|
if not isinstance(data, dict) or data.get("@type") != "Movie":
|
||||||
|
continue
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
year: str | None = None
|
||||||
|
for key in ("dateCreated", "datePublished"):
|
||||||
|
match = re.search(r"(19|20)\d{2}", str(data.get(key) or ""))
|
||||||
|
if match:
|
||||||
|
year = match.group(0)
|
||||||
|
break
|
||||||
|
return name, year
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_origin(soup) -> tuple[str | None, str | None]:
|
||||||
|
"""``(origin_country, year)`` from the ``.origin`` line.
|
||||||
|
|
||||||
|
ČSFD separates values with inline bullet ``<span>`` elements (no commas),
|
||||||
|
e.g. ``USA • 1999 • 136 min``. For a co-production only the first country
|
||||||
|
is returned — enough to match it against a name's flag.
|
||||||
|
"""
|
||||||
|
elem = soup.select_one(".origin")
|
||||||
|
if not elem:
|
||||||
|
return None, None
|
||||||
|
raw = elem.get_text(separator="|", strip=True)
|
||||||
|
tokens = [t.strip() for part in raw.split("|") for t in part.split(",")]
|
||||||
|
country: str | None = None
|
||||||
|
year: str | None = None
|
||||||
|
for token in tokens:
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
if year is None and re.fullmatch(r"(19|20)\d{2}", token):
|
||||||
|
year = token
|
||||||
|
continue
|
||||||
|
if country is None and not token[0].isdigit() and re.search(r"[^\W\d_]", token):
|
||||||
|
country = token.split("/")[0].strip()
|
||||||
|
return country, year
|
||||||
|
|
||||||
|
|
||||||
|
# ČSFD flags older Czech films "Československo" while ``.origin`` may read "Česko".
|
||||||
|
_CZ_ORIGIN_ALIASES = {"česko", "československo"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_original_title(soup, origin_country: str | None) -> str | None:
|
||||||
|
"""The film's original title from ``.film-names`` — or ``None`` to use the CZ title.
|
||||||
|
|
||||||
|
Each ``<li>`` carries a country/language flag (``img.flag`` ``title``) and a
|
||||||
|
title. The original title is the entry whose flag matches the **country of
|
||||||
|
origin** (USA → "The Shawshank Redemption"). ČSFD does **not** repeat the
|
||||||
|
Czech title here when it equals the ``<h1>``, so a Czech film has only
|
||||||
|
foreign/festival alt-titles in this list (``[angličtina] Cosy Dens``,
|
||||||
|
``[Slovinsko] Samotarji``) — picking one of those would be wrong, so we
|
||||||
|
return ``None`` and let the caller fall back to the Czech ``<h1>`` title.
|
||||||
|
"""
|
||||||
|
names: list[tuple[str, str]] = []
|
||||||
|
for li in soup.select(".film-names li"):
|
||||||
|
flag = li.select_one("img.flag")
|
||||||
|
country = str(flag.get("title") or "").strip() if flag else ""
|
||||||
|
for junk in li.select("span, img"):
|
||||||
|
junk.extract()
|
||||||
|
text = " ".join(li.get_text(" ").split()).strip(" ·")
|
||||||
|
if text:
|
||||||
|
names.append((country, text))
|
||||||
|
if not names or not origin_country:
|
||||||
|
return None
|
||||||
|
wanted = origin_country.casefold()
|
||||||
|
for country, text in names:
|
||||||
|
here = country.casefold()
|
||||||
|
if here == wanted or (
|
||||||
|
wanted in _CZ_ORIGIN_ALIASES and here in _CZ_ORIGIN_ALIASES
|
||||||
|
):
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_film(url: str, session=None) -> CsfdFilm:
|
||||||
|
"""Fetch a ČSFD film page → :class:`CsfdFilm` (Czech + original title, year).
|
||||||
|
|
||||||
|
Pass a shared ``requests.Session`` to reuse the Anubis auth cookie across a
|
||||||
|
batch. Raises :class:`CsfdError` on a network / anti-bot / parse failure.
|
||||||
|
"""
|
||||||
|
_check_dependencies()
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = requests.Session()
|
||||||
|
try:
|
||||||
|
html = _get_page(session, url).text
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise CsfdError(f"Nelze načíst ČSFD stránku: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
czech_title, year = _parse_json_ld(soup)
|
||||||
|
origin_country, origin_year = _parse_origin(soup)
|
||||||
|
year = year or origin_year
|
||||||
|
if not czech_title:
|
||||||
|
header = soup.select_one(".film-header-name h1")
|
||||||
|
czech_title = header.get_text(strip=True) if header else ""
|
||||||
|
original_title = _parse_original_title(soup, origin_country) or czech_title
|
||||||
|
return CsfdFilm(
|
||||||
|
url=url,
|
||||||
|
csfd_id=_extract_csfd_id(url),
|
||||||
|
czech_title=czech_title,
|
||||||
|
original_title=original_title,
|
||||||
|
year=str(year) if year else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _films_section(soup):
|
||||||
|
"""The ``<section>`` holding the *Filmy* results, or the whole document.
|
||||||
|
|
||||||
|
ČSFD's search page groups hits into ``Filmy`` / ``Seriály`` / ``Tvůrci`` /
|
||||||
|
``Uživatelé`` sections; ``a.film-title-name`` matches in all of them, so
|
||||||
|
without scoping a bare-title search happily returns a series episode.
|
||||||
|
"""
|
||||||
|
for section in soup.find_all("section"):
|
||||||
|
header = section.select_one(".box-header h2") or section.find("h2")
|
||||||
|
if header and header.get_text(strip=True).casefold() == "filmy":
|
||||||
|
return section
|
||||||
|
return soup
|
||||||
|
|
||||||
|
|
||||||
|
def _is_film_href(href: str) -> bool:
|
||||||
|
"""True for a film URL ``/film/<id>-slug/`` — a series episode has two segments
|
||||||
|
(``/film/<series>/<episode>/``) and is rejected."""
|
||||||
|
parts = [p for p in href.split("/") if p and p != "prehled"]
|
||||||
|
return len(parts) == 2 and parts[0] == "film"
|
||||||
|
|
||||||
|
|
||||||
|
def search_films(query: str, limit: int = 5, session=None) -> list[CsfdFilm]:
|
||||||
|
"""Search ČSFD for ``query``; film hits only (no series/episodes), best first."""
|
||||||
|
_check_dependencies()
|
||||||
|
if not query.strip():
|
||||||
|
return []
|
||||||
|
search_url = f"{CSFD_SEARCH_URL}?{urlencode({'q': query.strip()})}"
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = requests.Session()
|
||||||
|
try:
|
||||||
|
html = _get_page(session, search_url).text
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise CsfdError(f"ČSFD hledání selhalo: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
films: list[CsfdFilm] = []
|
||||||
|
for anchor in _films_section(soup).select("a.film-title-name"):
|
||||||
|
href = str(anchor.get("href") or "")
|
||||||
|
if not _is_film_href(href):
|
||||||
|
continue
|
||||||
|
films.append(
|
||||||
|
CsfdFilm(
|
||||||
|
url=urljoin(CSFD_BASE_URL, href),
|
||||||
|
csfd_id=_extract_csfd_id(href),
|
||||||
|
czech_title=anchor.get_text(strip=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(films) >= limit:
|
||||||
|
break
|
||||||
|
return films
|
||||||
|
|
||||||
|
|
||||||
|
def find_csfd_url(query: str, session=None) -> str | None:
|
||||||
|
"""Return the first ČSFD film URL matching ``query``, or ``None``.
|
||||||
|
|
||||||
|
ČSFD's full-text search treats a trailing year as a search term (not a
|
||||||
|
filter) and can miss on ``"Title 2006"`` while ``"Title"`` hits — so a
|
||||||
|
yearful query with no result is retried without the year.
|
||||||
|
"""
|
||||||
|
if not query or not query.strip():
|
||||||
|
return None
|
||||||
|
query = query.strip()
|
||||||
|
films = search_films(query, limit=1, session=session)
|
||||||
|
if not films:
|
||||||
|
without_year = re.sub(r"\s+(?:19|20)\d{2}$", "", query)
|
||||||
|
if without_year != query:
|
||||||
|
films = search_films(without_year, limit=1, session=session)
|
||||||
|
return films[0].url if films else None
|
||||||
|
|
||||||
|
|
||||||
|
# Release-name tokens that mark the end of the actual title in a filename.
|
||||||
|
_RELEASE_MARKERS = {
|
||||||
|
"bluray",
|
||||||
|
"blu-ray",
|
||||||
|
"brrip",
|
||||||
|
"bdrip",
|
||||||
|
"bdremux",
|
||||||
|
"remux",
|
||||||
|
"webrip",
|
||||||
|
"web",
|
||||||
|
"web-dl",
|
||||||
|
"webdl",
|
||||||
|
"hdtv",
|
||||||
|
"dvdrip",
|
||||||
|
"dvd",
|
||||||
|
"dvd5",
|
||||||
|
"dvd9",
|
||||||
|
"hdrip",
|
||||||
|
"cam",
|
||||||
|
"ts",
|
||||||
|
"tc",
|
||||||
|
"x264",
|
||||||
|
"x265",
|
||||||
|
"h264",
|
||||||
|
"h265",
|
||||||
|
"hevc",
|
||||||
|
"avc",
|
||||||
|
"xvid",
|
||||||
|
"divx",
|
||||||
|
"aac",
|
||||||
|
"ac3",
|
||||||
|
"eac3",
|
||||||
|
"dts",
|
||||||
|
"dd5",
|
||||||
|
"ddp5",
|
||||||
|
"truehd",
|
||||||
|
"atmos",
|
||||||
|
"flac",
|
||||||
|
"10bit",
|
||||||
|
"8bit",
|
||||||
|
"hdr",
|
||||||
|
"hdr10",
|
||||||
|
"dolby",
|
||||||
|
"sdr",
|
||||||
|
"proper",
|
||||||
|
"repack",
|
||||||
|
"extended",
|
||||||
|
"unrated",
|
||||||
|
"remastered",
|
||||||
|
"imax",
|
||||||
|
"multi",
|
||||||
|
"dual",
|
||||||
|
"complete",
|
||||||
|
"internal",
|
||||||
|
"limited",
|
||||||
|
"uncut",
|
||||||
|
}
|
||||||
|
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
|
||||||
|
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$|^[24]k$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_filename_to_query(filename: str) -> str:
|
||||||
|
"""Turn a (possibly release-named) filename into a ČSFD search query.
|
||||||
|
|
||||||
|
Strips the path/extension, splits on common separators and keeps the words
|
||||||
|
before the first release marker (year, resolution, codec, source, …). The
|
||||||
|
detected year is appended back as a disambiguator::
|
||||||
|
|
||||||
|
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
|
||||||
|
"""
|
||||||
|
stem = Path(filename).stem
|
||||||
|
tokens = [t for t in re.split(r"[.\s_]+", stem) if t]
|
||||||
|
|
||||||
|
title_words: list[str] = []
|
||||||
|
year: str | None = None
|
||||||
|
for token in tokens:
|
||||||
|
bare = token.strip("()[]{}")
|
||||||
|
if _YEAR_RE.match(bare):
|
||||||
|
year = bare
|
||||||
|
break
|
||||||
|
if _RESOLUTION_RE.match(bare) or bare.lower() in _RELEASE_MARKERS:
|
||||||
|
break
|
||||||
|
title_words.append(token)
|
||||||
|
|
||||||
|
title = " ".join(title_words).strip() or re.sub(r"[.\s_]+", " ", stem).strip()
|
||||||
|
return f"{title} {year}".strip() if year else title
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .tag import Tag
|
||||||
|
|
||||||
|
|
||||||
|
class File:
|
||||||
|
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
|
||||||
|
self.file_path = file_path
|
||||||
|
self.filename = file_path.name
|
||||||
|
# Unified pool index; when set, metadata is persisted there (see
|
||||||
|
# PoolIndex). Without an index a File is an in-memory object only — no
|
||||||
|
# metadata is written to disk (there are no per-file sidecars anymore).
|
||||||
|
self.index = index
|
||||||
|
self.new = True
|
||||||
|
self.ignored = False
|
||||||
|
self.tags: list[Tag] = []
|
||||||
|
self.tagmanager = tagmanager
|
||||||
|
# new: optional date string "YYYY-MM-DD" (assigned manually)
|
||||||
|
self.date: str | None = None
|
||||||
|
# movie-library fields
|
||||||
|
self.title: str | None = None
|
||||||
|
# Optional ČSFD film URL — a second database pointer kept alongside the
|
||||||
|
# TMDb id (which lives in the filename). Stored only, not fetched.
|
||||||
|
self.csfd_url: str | None = None
|
||||||
|
# ISO timestamp of when the movie was added to the pool (set on import);
|
||||||
|
# None for older/unknown → falls back to the file's ctime.
|
||||||
|
self.added: str | None = None
|
||||||
|
# Cached metadata used for folder grouping (year, rating) — filled from
|
||||||
|
# the movie's NFO (which Jellyfin writes).
|
||||||
|
self.meta: dict | None = None
|
||||||
|
# full_paths of tags derived automatically from the NFO (vs. user-added);
|
||||||
|
# only these are regenerated on refresh — user tags are preserved.
|
||||||
|
self.auto_tag_paths: set[str] = set()
|
||||||
|
# free-form per-movie attributes (e.g. "collection_sort"); usable in
|
||||||
|
# miniDLNA filename templates via name_context.
|
||||||
|
self.attributes: dict[str, str] = {}
|
||||||
|
# Identity key of the movie record this file belongs to (set on load /
|
||||||
|
# first save); ``None`` only for an index-less, in-memory File.
|
||||||
|
self._movie_key: str | None = None
|
||||||
|
self.get_metadata()
|
||||||
|
|
||||||
|
def _identity_from_path(self) -> tuple[str, str, str | None, int | None, str]:
|
||||||
|
"""``(key, title, year, tmdb_id, label)`` implied by this file's location.
|
||||||
|
|
||||||
|
A file inside its own ``Title (rok) [tmdbid]`` folder is one version of
|
||||||
|
that movie — identity comes from the **folder**, and the rest of its own
|
||||||
|
stem is the version label. A flat file carries both in its own name.
|
||||||
|
"""
|
||||||
|
from .movie import identity_key
|
||||||
|
from .naming import (
|
||||||
|
is_canonical_pool_stem,
|
||||||
|
parse_3d_format,
|
||||||
|
parse_pool_stem,
|
||||||
|
parse_tmdb_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
parent = self.file_path.parent.name
|
||||||
|
grouped = is_canonical_pool_stem(parent)
|
||||||
|
identity_stem = parent if grouped else self.file_path.stem
|
||||||
|
parsed = parse_pool_stem(identity_stem)
|
||||||
|
year = str(parsed[1]) if parsed else None
|
||||||
|
tmdb_id = parse_tmdb_id(identity_stem)
|
||||||
|
title = self.title or (parsed[0] if parsed else self.file_path.stem)
|
||||||
|
|
||||||
|
label = ""
|
||||||
|
part = (
|
||||||
|
re.fullmatch(
|
||||||
|
rf"{re.escape(parent)}[\s._-]*(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{{1,2}})",
|
||||||
|
self.file_path.stem,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if grouped
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if part:
|
||||||
|
# one part of a film split across files: "<folder>-part2"
|
||||||
|
label = f"part{int(part.group(1))}"
|
||||||
|
elif grouped and self.file_path.stem.startswith(f"{parent} - "):
|
||||||
|
label = self.file_path.stem[len(parent) + 3 :]
|
||||||
|
elif parse_3d_format(self.file_path.stem):
|
||||||
|
label = parse_3d_format(self.file_path.stem) or ""
|
||||||
|
return identity_key(tmdb_id, title, year), title, year, tmdb_id, label
|
||||||
|
|
||||||
|
def get_metadata(self) -> None:
|
||||||
|
"""Load this file's metadata from the movie record that owns it.
|
||||||
|
|
||||||
|
Since the pool index became identity-keyed (see :mod:`src.core.movie`),
|
||||||
|
a File is a *view* on one media entry of a movie record — every version
|
||||||
|
of the same film therefore reads the same title/tags/ČSFD/… A file the
|
||||||
|
index doesn't know yet joins the movie of the same identity when there
|
||||||
|
is one (that's how a newly added version inherits the shared metadata
|
||||||
|
instead of blanking it), otherwise it starts a new record.
|
||||||
|
"""
|
||||||
|
if self.index is None:
|
||||||
|
self._init_new_metadata()
|
||||||
|
return
|
||||||
|
|
||||||
|
found = self.index.find_movie_by_media(self.file_path)
|
||||||
|
if found is not None:
|
||||||
|
self._movie_key, record = found
|
||||||
|
self._apply_record(record)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._init_new_metadata()
|
||||||
|
key, *_ = self._identity_from_path()
|
||||||
|
existing = self.index.get_movie(key)
|
||||||
|
if existing is not None:
|
||||||
|
self._movie_key = key
|
||||||
|
self._apply_record(existing)
|
||||||
|
self.save_metadata()
|
||||||
|
|
||||||
|
def _init_new_metadata(self) -> None:
|
||||||
|
self.new = True
|
||||||
|
self.ignored = False
|
||||||
|
self.tags = []
|
||||||
|
self.date = None
|
||||||
|
self.title = None
|
||||||
|
self.csfd_url = None
|
||||||
|
self.meta = None
|
||||||
|
self.auto_tag_paths = set()
|
||||||
|
self.attributes = {}
|
||||||
|
self.added = None
|
||||||
|
|
||||||
|
def _build_record(self) -> dict:
|
||||||
|
data = {
|
||||||
|
"new": self.new,
|
||||||
|
"ignored": self.ignored,
|
||||||
|
# ukládáme full_path tagů
|
||||||
|
"tags": [
|
||||||
|
tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags
|
||||||
|
],
|
||||||
|
# which of the tags were derived automatically (from the NFO)
|
||||||
|
"auto_tags": sorted(self.auto_tag_paths),
|
||||||
|
# date může být None
|
||||||
|
"date": self.date,
|
||||||
|
"title": self.title,
|
||||||
|
"csfd_url": self.csfd_url,
|
||||||
|
"attributes": self.attributes,
|
||||||
|
"added": self.added,
|
||||||
|
}
|
||||||
|
if self.meta is not None:
|
||||||
|
data["meta"] = dict(self.meta)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _apply_record(self, data: dict) -> None:
|
||||||
|
self.new = data.get("new", True)
|
||||||
|
self.ignored = data.get("ignored", False)
|
||||||
|
self.tags = []
|
||||||
|
self.date = data.get("date", None)
|
||||||
|
self.title = data.get("title", None)
|
||||||
|
self.csfd_url = data.get("csfd_url", None)
|
||||||
|
# provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored
|
||||||
|
self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", [])))
|
||||||
|
self.attributes = dict(data.get("attributes", {}))
|
||||||
|
self.added = data.get("added", None)
|
||||||
|
raw_meta = data.get("meta")
|
||||||
|
if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
|
||||||
|
old = data.get("csfd_cache") or {}
|
||||||
|
raw_meta = (
|
||||||
|
{"year": old.get("year"), "rating": old.get("rating")} if old else None
|
||||||
|
)
|
||||||
|
self.meta = dict(raw_meta) if raw_meta else None
|
||||||
|
|
||||||
|
if not self.tagmanager:
|
||||||
|
return
|
||||||
|
|
||||||
|
for tag_str in data.get("tags", []):
|
||||||
|
if "/" in tag_str:
|
||||||
|
category, name = tag_str.split("/", 1)
|
||||||
|
tag = self.tagmanager.add_tag(category, name)
|
||||||
|
self.tags.append(tag)
|
||||||
|
|
||||||
|
def save_metadata(self):
|
||||||
|
"""Persist metadata into this file's movie record (no-op without index).
|
||||||
|
|
||||||
|
Writes the shared fields onto the movie record and makes sure the
|
||||||
|
record lists this file among its media. A file the index has never seen
|
||||||
|
starts a new record under its own identity key.
|
||||||
|
"""
|
||||||
|
if self.index is None:
|
||||||
|
return
|
||||||
|
from .movie import ROLE_VIDEO
|
||||||
|
|
||||||
|
derived_key, title, year, tmdb_id, label = self._identity_from_path()
|
||||||
|
key = self._movie_key or derived_key
|
||||||
|
record = self.index.get_movie(key) or {}
|
||||||
|
record.update(self._build_record())
|
||||||
|
# The file's actual location is the truth — a move must update this.
|
||||||
|
record["library"] = self._library_name()
|
||||||
|
record["title"] = title
|
||||||
|
record["year"] = year
|
||||||
|
record["tmdb_id"] = tmdb_id
|
||||||
|
|
||||||
|
# Keep this file listed exactly once, with the label its name implies.
|
||||||
|
rel = self.index._key(self.file_path)
|
||||||
|
media = [m for m in record.get("media", []) if m.get("file") != rel]
|
||||||
|
media.append({"role": ROLE_VIDEO, "label": label, "file": rel})
|
||||||
|
record["media"] = media
|
||||||
|
|
||||||
|
self.index.set_movie(key, record)
|
||||||
|
self._movie_key = key
|
||||||
|
|
||||||
|
def _library_name(self) -> str:
|
||||||
|
"""Top-level pool folder this file sits in (its library), or ``""``."""
|
||||||
|
if self.index is None:
|
||||||
|
return ""
|
||||||
|
rel = self.index._key(self.file_path)
|
||||||
|
parts = Path(rel).parts
|
||||||
|
return parts[0] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
def added_timestamp(self) -> float:
|
||||||
|
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
|
||||||
|
|
||||||
|
Used to order the "recently added" folder (newer = larger). The fallback
|
||||||
|
is **mtime**, not ctime: creating the Filmotéka hardlinks bumps the source
|
||||||
|
inode's ctime, which would make ctime-based ordering meaningless, whereas
|
||||||
|
mtime (preserved from the source on import) stays stable.
|
||||||
|
"""
|
||||||
|
if self.added:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
return datetime.fromisoformat(self.added).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return self.file_path.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def name_context(self) -> dict:
|
||||||
|
"""Fields for a miniDLNA filename template (see config tag schema).
|
||||||
|
|
||||||
|
Provides title / year / rating / ext / stem / filename from the movie's
|
||||||
|
metadata (year & rating come from the NFO cache, year falling back to a
|
||||||
|
``Rok`` tag, then the canonical filename). Missing values render empty.
|
||||||
|
"""
|
||||||
|
cache = self.meta or {}
|
||||||
|
year = cache.get("year")
|
||||||
|
if year is None:
|
||||||
|
for t in self.tags:
|
||||||
|
if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit():
|
||||||
|
year = t.name
|
||||||
|
break
|
||||||
|
if year is None:
|
||||||
|
# Last resort: recover it from a canonical "Title (YYYY)" filename.
|
||||||
|
from .naming import parse_pool_stem
|
||||||
|
|
||||||
|
parsed = parse_pool_stem(self.file_path.stem)
|
||||||
|
if parsed is not None:
|
||||||
|
year = parsed[1]
|
||||||
|
rating = cache.get("rating")
|
||||||
|
# user attributes first; core fields take precedence over same-named keys
|
||||||
|
context = dict(self.attributes)
|
||||||
|
context.update(
|
||||||
|
{
|
||||||
|
"title": self.title or self.file_path.stem,
|
||||||
|
"year": "" if year is None else str(year),
|
||||||
|
"rating": "" if rating is None else rating,
|
||||||
|
"ext": self.file_path.suffix,
|
||||||
|
"stem": self.file_path.stem,
|
||||||
|
"filename": self.filename,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def set_attribute(self, key: str, value: str | None) -> None:
|
||||||
|
"""Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it."""
|
||||||
|
key = key.strip()
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
if value:
|
||||||
|
self.attributes[key] = value
|
||||||
|
else:
|
||||||
|
self.attributes.pop(key, None)
|
||||||
|
self.save_metadata()
|
||||||
|
|
||||||
|
def save_location(self) -> None:
|
||||||
|
"""Persist a rename/move of this file without touching shared metadata.
|
||||||
|
|
||||||
|
Every version of a film holds its own in-memory copy of the movie
|
||||||
|
record's shared fields (title, tags, ČSFD, …). A sibling loaded before
|
||||||
|
someone changed those fields would write its stale copy back on a plain
|
||||||
|
:meth:`save_metadata`. Re-reading the record first means only this
|
||||||
|
file's own media entry — path and popisek — actually changes.
|
||||||
|
"""
|
||||||
|
if self.index is not None and self.index.find_movie_by_media(self.file_path):
|
||||||
|
self.get_metadata()
|
||||||
|
self.save_metadata()
|
||||||
|
|
||||||
|
def delete_metadata(self) -> None:
|
||||||
|
"""Drop this file from its movie record; the last one takes the record.
|
||||||
|
|
||||||
|
Removing one version of a multi-version movie leaves the movie (and its
|
||||||
|
metadata) in place — only when its last media entry goes does the whole
|
||||||
|
record go with it.
|
||||||
|
"""
|
||||||
|
if self.index is None:
|
||||||
|
return
|
||||||
|
found = self.index.find_movie_by_media(self.file_path)
|
||||||
|
if found is None:
|
||||||
|
return
|
||||||
|
key, record = found
|
||||||
|
rel = self.index._key(self.file_path)
|
||||||
|
record["media"] = [m for m in record.get("media", []) if m.get("file") != rel]
|
||||||
|
if record["media"]:
|
||||||
|
self.index.set_movie(key, record)
|
||||||
|
else:
|
||||||
|
self.index.delete_movie(key)
|
||||||
|
|
||||||
|
def relocate(self, new_path: Path) -> None:
|
||||||
|
"""Point this File at a new path, moving its media entry along.
|
||||||
|
|
||||||
|
The physical file must already have been moved/renamed by the caller;
|
||||||
|
this only repoints the movie record's media entry (and the File), so a
|
||||||
|
rename never detaches a file from its movie.
|
||||||
|
"""
|
||||||
|
if self.index is not None:
|
||||||
|
found = self.index.find_movie_by_media(self.file_path)
|
||||||
|
if found is not None:
|
||||||
|
key, record = found
|
||||||
|
old_rel = self.index._key(self.file_path)
|
||||||
|
new_rel = self.index._key(Path(new_path))
|
||||||
|
for media in record.get("media", []):
|
||||||
|
if media.get("file") == old_rel:
|
||||||
|
media["file"] = new_rel
|
||||||
|
self.index.set_movie(key, record)
|
||||||
|
self._movie_key = key
|
||||||
|
self.file_path = Path(new_path)
|
||||||
|
self.filename = self.file_path.name
|
||||||
|
|
||||||
|
def set_date(self, date_str: str | None):
|
||||||
|
"""Nastaví datum (např. '2025-09-25') nebo None pro smazání."""
|
||||||
|
if date_str is None or date_str == "":
|
||||||
|
self.date = None
|
||||||
|
else:
|
||||||
|
# neprvádíme složitou validaci zde; očekáváme 'YYYY-MM-DD'
|
||||||
|
self.date = date_str
|
||||||
|
self.save_metadata()
|
||||||
|
|
||||||
|
def apply_nfo(self, nfo, schema: list[dict] | None = None) -> dict:
|
||||||
|
"""Regenerate the auto (NFO-derived) tags from the movie's NFO.
|
||||||
|
|
||||||
|
Reads genres/year/countries/rating from ``nfo`` (``nfo.NfoData``) per the
|
||||||
|
tag schema's source fields and (re)creates those tags; user-added tags are
|
||||||
|
kept. Also caches year/rating (for folder grouping) and fills the title
|
||||||
|
when missing. ``nfo`` of ``None`` (no NFO yet) is a no-op.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'success' and 'tags_added'.
|
||||||
|
"""
|
||||||
|
if nfo is None:
|
||||||
|
return {"success": False, "error": "NFO nenalezeno", "tags_added": []}
|
||||||
|
|
||||||
|
if schema is None:
|
||||||
|
from .config import DEFAULT_TAG_SCHEMA
|
||||||
|
|
||||||
|
schema = DEFAULT_TAG_SCHEMA
|
||||||
|
|
||||||
|
self.meta = {"year": nfo.year, "rating": nfo.rating}
|
||||||
|
|
||||||
|
# Drop previous auto (NFO) tags; keep user-added ones.
|
||||||
|
self.tags = [t for t in self.tags if t.full_path not in self.auto_tag_paths]
|
||||||
|
self.auto_tag_paths = set()
|
||||||
|
|
||||||
|
tags_added: list[str] = []
|
||||||
|
for entry in schema:
|
||||||
|
field = entry.get("csfd_field") # schema key = source field name
|
||||||
|
if not field:
|
||||||
|
continue # user-only category
|
||||||
|
category = entry["category"]
|
||||||
|
for value in nfo.field_values(field):
|
||||||
|
tag_obj = (
|
||||||
|
self.tagmanager.add_tag(category, value)
|
||||||
|
if self.tagmanager
|
||||||
|
else Tag(category, value)
|
||||||
|
)
|
||||||
|
if tag_obj not in self.tags:
|
||||||
|
self.tags.append(tag_obj)
|
||||||
|
tags_added.append(tag_obj.full_path)
|
||||||
|
self.auto_tag_paths.add(tag_obj.full_path)
|
||||||
|
|
||||||
|
if nfo.title and not self.title:
|
||||||
|
self.title = nfo.title
|
||||||
|
|
||||||
|
self.save_metadata()
|
||||||
|
return {"success": True, "tags_added": tags_added}
|
||||||
|
|
||||||
|
def add_tag(self, tag):
|
||||||
|
# tag může být Tag nebo string
|
||||||
|
from .tag import Tag as TagClass
|
||||||
|
|
||||||
|
if isinstance(tag, str):
|
||||||
|
if "/" in tag and self.tagmanager:
|
||||||
|
category, name = tag.split("/", 1)
|
||||||
|
tag_obj = self.tagmanager.add_tag(category, name)
|
||||||
|
else:
|
||||||
|
tag_obj = TagClass("default", tag)
|
||||||
|
elif isinstance(tag, TagClass):
|
||||||
|
tag_obj = tag
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
if tag_obj not in self.tags:
|
||||||
|
self.tags.append(tag_obj)
|
||||||
|
self.save_metadata()
|
||||||
|
|
||||||
|
def remove_tag(self, tag):
|
||||||
|
# tag může být Tag nebo string (full_path)
|
||||||
|
if isinstance(tag, str):
|
||||||
|
if "/" in tag:
|
||||||
|
category, name = tag.split("/", 1)
|
||||||
|
tag_obj = Tag(category, name)
|
||||||
|
else:
|
||||||
|
tag_obj = Tag("default", tag)
|
||||||
|
elif isinstance(tag, Tag):
|
||||||
|
tag_obj = tag
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
if tag_obj in self.tags:
|
||||||
|
self.tags.remove(tag_obj)
|
||||||
|
self.save_metadata()
|
||||||
@@ -0,0 +1,758 @@
|
|||||||
|
"""
|
||||||
|
Hardlink Manager for Curator
|
||||||
|
|
||||||
|
Creates directory structure based on file tags and creates hardlinks
|
||||||
|
to organize files without duplicating them on disk.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:
|
||||||
|
|
||||||
|
output/
|
||||||
|
├── žánr/
|
||||||
|
│ ├── Komedie/
|
||||||
|
│ │ └── film.mkv (hardlink)
|
||||||
|
│ └── Akční/
|
||||||
|
│ └── film.mkv (hardlink)
|
||||||
|
└── rok/
|
||||||
|
└── 1988/
|
||||||
|
└── film.mkv (hardlink)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import shutil
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .file import File
|
||||||
|
from .naming import is_valid_year, parse_pool_stem
|
||||||
|
|
||||||
|
|
||||||
|
class _SafeDict(dict):
|
||||||
|
"""dict for str.format_map that leaves unknown fields as an empty string."""
|
||||||
|
|
||||||
|
def __missing__(self, key):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class HardlinkManager:
|
||||||
|
"""Manager for creating hardlink-based directory structures from tagged files.
|
||||||
|
|
||||||
|
The output layout is driven by a *category → root folder* mapping
|
||||||
|
(``category_roots``). Each tag is placed at
|
||||||
|
``output/<root>/<tag_name>/<file>``; an empty root means the tag's own
|
||||||
|
folders sit directly at the output root (e.g. genre folders next to the
|
||||||
|
"Dle roku" / "Dle země původu" folders). The legacy ``categories`` list
|
||||||
|
(folder == category name) is still accepted and treated as the identity
|
||||||
|
mapping ``{cat: cat}``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, output_dir: Path):
|
||||||
|
"""
|
||||||
|
Initialize HardlinkManager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Base directory where the tag-based structure will be created
|
||||||
|
"""
|
||||||
|
self.output_dir = Path(output_dir)
|
||||||
|
self.created_links: list[Path] = []
|
||||||
|
self.errors: list[tuple[Path, str]] = []
|
||||||
|
|
||||||
|
def _resolve_roots(
|
||||||
|
self,
|
||||||
|
categories: list[str] | None,
|
||||||
|
category_roots: dict[str, str] | None,
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
"""Normalize the two filter styles into a category → root-folder map.
|
||||||
|
|
||||||
|
``None`` means "all categories", folder == category name.
|
||||||
|
"""
|
||||||
|
if category_roots is not None:
|
||||||
|
return dict(category_roots)
|
||||||
|
if categories is not None:
|
||||||
|
return {cat: cat for cat in categories}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _clean_title(self, file_obj: File) -> str:
|
||||||
|
"""The movie's clean title (no trailing ``(YYYY)``), path-safe."""
|
||||||
|
ctx = file_obj.name_context()
|
||||||
|
title = str(ctx.get("title") or file_obj.file_path.stem or "").strip()
|
||||||
|
parsed = parse_pool_stem(title)
|
||||||
|
if parsed is not None:
|
||||||
|
title = parsed[0]
|
||||||
|
title = title.replace("/", "-").replace("\\", "-").strip()
|
||||||
|
return title or file_obj.file_path.stem
|
||||||
|
|
||||||
|
def _default_link_name(self, file_obj: File) -> str:
|
||||||
|
"""Filmotéka hardlink name: the clean title + extension (no year)."""
|
||||||
|
return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}"
|
||||||
|
|
||||||
|
def _year_link_name(self, file_obj: File) -> str | None:
|
||||||
|
"""``Title (YYYY).ext`` used to disambiguate a name collision, or None."""
|
||||||
|
year = str(file_obj.name_context().get("year") or "").strip()
|
||||||
|
if not is_valid_year(year):
|
||||||
|
return None
|
||||||
|
return f"{self._clean_title(file_obj)} ({year}){file_obj.file_path.suffix}"
|
||||||
|
|
||||||
|
def _render_template(self, file_obj: File, template: str) -> str:
|
||||||
|
"""Render a per-category filename template; fall back to the clean name."""
|
||||||
|
try:
|
||||||
|
rendered = template.format_map(_SafeDict(file_obj.name_context()))
|
||||||
|
except ValueError, KeyError, IndexError, AttributeError:
|
||||||
|
return self._default_link_name(file_obj)
|
||||||
|
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
|
||||||
|
return rendered or self._default_link_name(file_obj)
|
||||||
|
|
||||||
|
def _plan_links(
|
||||||
|
self,
|
||||||
|
files: list[File],
|
||||||
|
roots: dict[str, str] | None,
|
||||||
|
transforms: dict[str, str] | None,
|
||||||
|
templates: dict[str, str] | None,
|
||||||
|
) -> list[tuple[File, Path]]:
|
||||||
|
"""Resolve the tag tree to a collision-free ``(file, link_path)`` plan.
|
||||||
|
|
||||||
|
The default hardlink name is the movie's **clean title** (no year). When
|
||||||
|
two *different* movies would land on the same name in the same folder,
|
||||||
|
the colliding ones fall back to the year-qualified ``Title (YYYY).ext`` to
|
||||||
|
tell them apart; a residual clash (same title *and* year) gets a stable
|
||||||
|
numeric suffix so every path is unique. A per-category
|
||||||
|
``filename_template`` overrides the default inside that category's folders.
|
||||||
|
|
||||||
|
Both link creation and obsolete detection consume this one plan, so they
|
||||||
|
can never disagree on a name (which is what caused hardlink churn before).
|
||||||
|
"""
|
||||||
|
# (file_obj, target_dir, name, is_templated), one per file+folder
|
||||||
|
entries: list[list] = []
|
||||||
|
seen_fd: set[tuple[str, str]] = set()
|
||||||
|
for file_obj in files:
|
||||||
|
for tag in file_obj.tags:
|
||||||
|
target_dir = self._target_dir(tag, roots, transforms)
|
||||||
|
if target_dir is None:
|
||||||
|
continue
|
||||||
|
key = (str(file_obj.file_path), str(target_dir))
|
||||||
|
if key in seen_fd: # one link per movie per folder
|
||||||
|
continue
|
||||||
|
seen_fd.add(key)
|
||||||
|
template = templates.get(tag.category) if templates else None
|
||||||
|
if template:
|
||||||
|
entries.append(
|
||||||
|
[
|
||||||
|
file_obj,
|
||||||
|
target_dir,
|
||||||
|
self._render_template(file_obj, template),
|
||||||
|
True,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
entries.append(
|
||||||
|
[file_obj, target_dir, self._default_link_name(file_obj), False]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Disambiguate default-named collisions (different movies) by their year.
|
||||||
|
groups: dict[tuple[Path, str], set[str]] = defaultdict(set)
|
||||||
|
for file_obj, target_dir, name, templated in entries:
|
||||||
|
if not templated:
|
||||||
|
groups[(target_dir, name)].add(str(file_obj.file_path))
|
||||||
|
for entry in entries:
|
||||||
|
file_obj, target_dir, name, templated = entry
|
||||||
|
if not templated and len(groups[(target_dir, name)]) > 1:
|
||||||
|
year_name = self._year_link_name(file_obj)
|
||||||
|
if year_name:
|
||||||
|
entry[2] = year_name
|
||||||
|
|
||||||
|
# Final pass: guarantee unique paths (stable numeric suffix on any tie).
|
||||||
|
by_path: dict[tuple[Path, str], list[list]] = defaultdict(list)
|
||||||
|
for entry in entries:
|
||||||
|
by_path[(entry[1], entry[2])].append(entry)
|
||||||
|
plan: list[tuple[File, Path]] = []
|
||||||
|
for (target_dir, name), group in by_path.items():
|
||||||
|
if len(group) == 1:
|
||||||
|
plan.append((group[0][0], target_dir / name))
|
||||||
|
continue
|
||||||
|
ordered = sorted(group, key=lambda e: str(e[0].file_path))
|
||||||
|
stem, suffix = Path(name).stem, Path(name).suffix
|
||||||
|
for i, entry in enumerate(ordered):
|
||||||
|
fname = name if i == 0 else f"{stem}_{i + 1}{suffix}"
|
||||||
|
plan.append((entry[0], target_dir / fname))
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def _folder_value(self, tag, transforms: dict[str, str] | None) -> str:
|
||||||
|
"""Folder name for a tag — its value run through the category transform.
|
||||||
|
|
||||||
|
The tag keeps its exact value; the grouping (e.g. rating → ten-point
|
||||||
|
band) only happens here, when naming the folder.
|
||||||
|
"""
|
||||||
|
if transforms and tag.category in transforms:
|
||||||
|
from .transforms import apply_transform
|
||||||
|
|
||||||
|
return apply_transform(tag.name, transforms[tag.category])
|
||||||
|
return tag.name
|
||||||
|
|
||||||
|
def _target_dir(
|
||||||
|
self,
|
||||||
|
tag,
|
||||||
|
roots: dict[str, str] | None,
|
||||||
|
transforms: dict[str, str] | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Output directory for a tag, or None if its category is excluded."""
|
||||||
|
if roots is None:
|
||||||
|
folder = tag.category
|
||||||
|
elif tag.category in roots:
|
||||||
|
folder = roots[tag.category]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
base = self.output_dir / folder if folder else self.output_dir
|
||||||
|
return base / self._folder_value(tag, transforms)
|
||||||
|
|
||||||
|
def _managed_top_dirs(
|
||||||
|
self,
|
||||||
|
files: list[File],
|
||||||
|
roots: dict[str, str] | None,
|
||||||
|
transforms: dict[str, str] | None = None,
|
||||||
|
reserved_subfolders: set[str] | None = None,
|
||||||
|
) -> set[str] | None:
|
||||||
|
"""Top-level output folders owned by the tag tree (None = all of them).
|
||||||
|
|
||||||
|
A category with a non-empty root owns that root folder. A category placed
|
||||||
|
at the output root (empty root, e.g. genres) owns its own folders at the
|
||||||
|
root — and, so a genre whose last movie dropped the tag still gets its
|
||||||
|
now-stale folder cleaned, *every* root-level folder is treated as owned
|
||||||
|
except the grouping roots and any ``reserved_subfolders`` (copy-as-is
|
||||||
|
mirrors, "Tipy dne", …). The obsolete check still only removes links to
|
||||||
|
current pool files, so reserved/foreign folders are doubly protected.
|
||||||
|
"""
|
||||||
|
if roots is None:
|
||||||
|
return None
|
||||||
|
grouping_roots = {folder for folder in roots.values() if folder}
|
||||||
|
has_root_level = any(not folder for folder in roots.values())
|
||||||
|
tops: set[str] = set(grouping_roots)
|
||||||
|
|
||||||
|
# genre folders currently present on the movies
|
||||||
|
for cat, folder in roots.items():
|
||||||
|
if not folder:
|
||||||
|
for file_obj in files:
|
||||||
|
for tag in file_obj.tags:
|
||||||
|
if tag.category == cat:
|
||||||
|
tops.add(self._folder_value(tag, transforms))
|
||||||
|
|
||||||
|
# plus any other root-level folder (catches genres that lost their last
|
||||||
|
# movie), minus grouping roots and reserved (mirrors / Tipy dne / …)
|
||||||
|
if has_root_level and self.output_dir.exists():
|
||||||
|
reserved = set(reserved_subfolders or set())
|
||||||
|
for entry in self.output_dir.iterdir():
|
||||||
|
if (
|
||||||
|
entry.is_dir()
|
||||||
|
and entry.name not in grouping_roots
|
||||||
|
and entry.name not in reserved
|
||||||
|
):
|
||||||
|
tops.add(entry.name)
|
||||||
|
return tops
|
||||||
|
|
||||||
|
def create_structure_for_files(
|
||||||
|
self,
|
||||||
|
files: list[File],
|
||||||
|
categories: list[str] | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
category_roots: dict[str, str] | None = None,
|
||||||
|
category_transforms: dict[str, str] | None = None,
|
||||||
|
category_filename_templates: dict[str, str] | None = None,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Create hardlink structure for given files based on their tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects to process
|
||||||
|
categories: Optional list of categories to include (None = all)
|
||||||
|
dry_run: If True, only simulate without creating actual links
|
||||||
|
category_roots: Optional category → root-folder map (see class doc);
|
||||||
|
overrides ``categories`` when given.
|
||||||
|
category_transforms: Optional category → transform-name map applied to
|
||||||
|
the tag value when naming its folder (e.g. rating → decade band).
|
||||||
|
category_filename_templates: Optional category → hardlink-name template
|
||||||
|
applied only inside that category's folders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (successful_links, failed_links)
|
||||||
|
"""
|
||||||
|
self.created_links = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
roots = self._resolve_roots(categories, category_roots)
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
plan = self._plan_links(
|
||||||
|
files, roots, category_transforms, category_filename_templates
|
||||||
|
)
|
||||||
|
for file_obj, target_file in plan:
|
||||||
|
try:
|
||||||
|
if not dry_run:
|
||||||
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target_file.exists():
|
||||||
|
# Already the right link → nothing to do (not re-counted).
|
||||||
|
if self._is_same_file(file_obj.file_path, target_file):
|
||||||
|
continue
|
||||||
|
# A wrong occupant sits on the planned path; the plan is
|
||||||
|
# authoritative, so replace it.
|
||||||
|
target_file.unlink()
|
||||||
|
|
||||||
|
os.link(file_obj.file_path, target_file)
|
||||||
|
|
||||||
|
self.created_links.append(target_file)
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
self.errors.append((file_obj.file_path, str(e)))
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
return success_count, fail_count
|
||||||
|
|
||||||
|
def mirror_as_is(
|
||||||
|
self, source_dir: Path, subfolder: str | None = None, dry_run: bool = False
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.
|
||||||
|
|
||||||
|
Recreates the exact directory hierarchy of ``source_dir`` under
|
||||||
|
``output_dir/subfolder`` (or directly under ``output_dir`` when
|
||||||
|
``subfolder`` is None) and hardlinks every file. Curator metadata files
|
||||||
|
(``.!tag`` / ``.!ftag`` / ``.!gtag`` / ``.!index``) are skipped.
|
||||||
|
|
||||||
|
Used for Seriály: the pool structure is the source of truth and is cloned
|
||||||
|
verbatim instead of being rebuilt from tags.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (successful_links, failed_links)
|
||||||
|
"""
|
||||||
|
source_dir = Path(source_dir)
|
||||||
|
if not source_dir.is_dir():
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
base = self.output_dir / subfolder if subfolder else self.output_dir
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
for src_file in source_dir.rglob("*"):
|
||||||
|
if not src_file.is_file():
|
||||||
|
continue
|
||||||
|
if src_file.name.endswith((".!tag", ".!ftag", ".!gtag", ".!index")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_file = base / src_file.relative_to(source_dir)
|
||||||
|
try:
|
||||||
|
if not dry_run:
|
||||||
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target_file.exists():
|
||||||
|
if self._is_same_file(src_file, target_file):
|
||||||
|
success_count += 1
|
||||||
|
continue
|
||||||
|
target_file.unlink()
|
||||||
|
os.link(src_file, target_file)
|
||||||
|
self.created_links.append(target_file)
|
||||||
|
success_count += 1
|
||||||
|
except OSError as e:
|
||||||
|
self.errors.append((src_file, str(e)))
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
return success_count, fail_count
|
||||||
|
|
||||||
|
def remove_mirror(self, name: str) -> bool:
|
||||||
|
"""Remove ``output_dir/name`` entirely (file or folder tree).
|
||||||
|
|
||||||
|
Used to drop a whole mirrored library (or one entry of the shared
|
||||||
|
mixed-library-series bucket) when it falls out of the miniDLNA
|
||||||
|
generation's scope. Mirrors are pure hardlinks, so deleting the folder
|
||||||
|
loses nothing — it's fully regenerable from the pool. ``name`` may
|
||||||
|
contain "/" to address one entry inside a bucket folder.
|
||||||
|
|
||||||
|
Returns whether anything was actually removed.
|
||||||
|
"""
|
||||||
|
target = self.output_dir / name
|
||||||
|
if not target.exists():
|
||||||
|
return False
|
||||||
|
if target.is_dir():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
else:
|
||||||
|
target.unlink()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _fill_special_folder(
|
||||||
|
self, chosen: list[File], subfolder: str, dry_run: bool
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Empty ``subfolder`` and hardlink ``chosen`` files into it."""
|
||||||
|
base = self.output_dir / subfolder
|
||||||
|
|
||||||
|
if not dry_run and base.exists():
|
||||||
|
for child in base.iterdir():
|
||||||
|
if child.is_file() or child.is_symlink():
|
||||||
|
try:
|
||||||
|
child.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
fail = 0
|
||||||
|
for file_obj in chosen:
|
||||||
|
target = base / self._default_link_name(file_obj)
|
||||||
|
try:
|
||||||
|
if not dry_run:
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.exists():
|
||||||
|
if self._is_same_file(file_obj.file_path, target):
|
||||||
|
created += 1
|
||||||
|
continue
|
||||||
|
# Collision with another movie: prefer the year-qualified
|
||||||
|
# name, else a numeric suffix.
|
||||||
|
year_name = self._year_link_name(file_obj)
|
||||||
|
if year_name and not (base / year_name).exists():
|
||||||
|
target = base / year_name
|
||||||
|
else:
|
||||||
|
target = self._get_unique_name(target)
|
||||||
|
os.link(file_obj.file_path, target)
|
||||||
|
self.created_links.append(target)
|
||||||
|
created += 1
|
||||||
|
except OSError as e:
|
||||||
|
self.errors.append((file_obj.file_path, str(e)))
|
||||||
|
fail += 1
|
||||||
|
return created, fail
|
||||||
|
|
||||||
|
def generate_recently_added(
|
||||||
|
self,
|
||||||
|
files: list[File],
|
||||||
|
count: int = 10,
|
||||||
|
subfolder: str = "- Nově přidané",
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""(Re)fill ``subfolder`` with the ``count`` most recently added movies.
|
||||||
|
|
||||||
|
Ordering is by ``File.added_timestamp()`` (import time, ctime fallback),
|
||||||
|
newest first. Returns (created_links, failed_links).
|
||||||
|
"""
|
||||||
|
chosen = sorted(files, key=lambda f: f.added_timestamp(), reverse=True)[:count]
|
||||||
|
return self._fill_special_folder(chosen, subfolder, dry_run)
|
||||||
|
|
||||||
|
def generate_random_tips(
|
||||||
|
self,
|
||||||
|
files: list[File],
|
||||||
|
count: int = 10,
|
||||||
|
subfolder: str = "Tipy dne",
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""(Re)fill ``subfolder`` with ``count`` random files as hardlinks.
|
||||||
|
|
||||||
|
The folder is emptied first so each run picks a fresh random selection.
|
||||||
|
``files`` should be the pool movies only (copy-as-is mirrors excluded).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (created_links, failed_links)
|
||||||
|
"""
|
||||||
|
chosen = random.sample(files, min(count, len(files)))
|
||||||
|
return self._fill_special_folder(chosen, subfolder, dry_run)
|
||||||
|
|
||||||
|
def _is_same_file(self, path1: Path, path2: Path) -> bool:
|
||||||
|
"""Check if two paths point to the same file (same inode)."""
|
||||||
|
try:
|
||||||
|
return path1.stat().st_ino == path2.stat().st_ino
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_unique_name(self, path: Path) -> Path:
|
||||||
|
"""Get a unique filename by adding a numeric suffix."""
|
||||||
|
stem = path.stem
|
||||||
|
suffix = path.suffix
|
||||||
|
parent = path.parent
|
||||||
|
counter = 1
|
||||||
|
|
||||||
|
while True:
|
||||||
|
new_name = f"{stem}_{counter}{suffix}"
|
||||||
|
new_path = parent / new_name
|
||||||
|
if not new_path.exists():
|
||||||
|
return new_path
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
def remove_created_links(self) -> int:
|
||||||
|
"""
|
||||||
|
Remove all hardlinks created by the last operation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of links removed
|
||||||
|
"""
|
||||||
|
removed = 0
|
||||||
|
for link_path in self.created_links:
|
||||||
|
try:
|
||||||
|
if link_path.exists() and link_path.is_file():
|
||||||
|
link_path.unlink()
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
# Try to remove empty parent directories
|
||||||
|
self._remove_empty_parents(link_path.parent)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.created_links = []
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def _remove_empty_parents(self, path: Path) -> None:
|
||||||
|
"""Remove empty parent directories up to output_dir."""
|
||||||
|
try:
|
||||||
|
while path != self.output_dir and path.is_dir():
|
||||||
|
if any(path.iterdir()):
|
||||||
|
break # Directory not empty
|
||||||
|
path.rmdir()
|
||||||
|
path = path.parent
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_preview(
|
||||||
|
self,
|
||||||
|
files: list[File],
|
||||||
|
categories: list[str] | None = None,
|
||||||
|
category_roots: dict[str, str] | None = None,
|
||||||
|
category_transforms: dict[str, str] | None = None,
|
||||||
|
category_filename_templates: dict[str, str] | None = None,
|
||||||
|
) -> list[tuple[Path, Path]]:
|
||||||
|
"""
|
||||||
|
Get a preview of what links would be created.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects
|
||||||
|
categories: Optional list of categories to include
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (source_path, target_path)
|
||||||
|
"""
|
||||||
|
roots = self._resolve_roots(categories, category_roots)
|
||||||
|
plan = self._plan_links(
|
||||||
|
files, roots, category_transforms, category_filename_templates
|
||||||
|
)
|
||||||
|
return [(file_obj.file_path, target_file) for file_obj, target_file in plan]
|
||||||
|
|
||||||
|
def find_obsolete_links(
|
||||||
|
self,
|
||||||
|
files: list[File],
|
||||||
|
categories: list[str] | None = None,
|
||||||
|
category_roots: dict[str, str] | None = None,
|
||||||
|
category_transforms: dict[str, str] | None = None,
|
||||||
|
category_filename_templates: dict[str, str] | None = None,
|
||||||
|
reserved_subfolders: set[str] | None = None,
|
||||||
|
) -> list[tuple[Path, Path]]:
|
||||||
|
"""
|
||||||
|
Find hardlinks in the output directory that no longer match file tags.
|
||||||
|
|
||||||
|
Scans the managed parts of the output directory for hardlinks that point
|
||||||
|
to source files but whose path no longer matches the file's current tags.
|
||||||
|
Only the tag-tree's own top-level folders are scanned, so copy-as-is
|
||||||
|
mirrors (e.g. Seriály) are left untouched.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects (source files)
|
||||||
|
categories: Optional list of categories to check (None = all)
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (link_path, source_path) for obsolete links
|
||||||
|
"""
|
||||||
|
obsolete: list[tuple[Path, Path]] = []
|
||||||
|
|
||||||
|
if not self.output_dir.exists():
|
||||||
|
return obsolete
|
||||||
|
|
||||||
|
roots = self._resolve_roots(categories, category_roots)
|
||||||
|
|
||||||
|
# Build a map of source file inodes to File objects
|
||||||
|
inode_to_file: dict[int, File] = {}
|
||||||
|
for file_obj in files:
|
||||||
|
try:
|
||||||
|
inode = file_obj.file_path.stat().st_ino
|
||||||
|
inode_to_file[inode] = file_obj
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Every link the tag tree *should* contain, mapped to the inode(s) it may
|
||||||
|
# point at — taken from the very same plan that creates the links, so the
|
||||||
|
# two never disagree on a name. Matching by inode too — not just the path
|
||||||
|
# — is what catches an **orphan that squats the expected name**: a link
|
||||||
|
# whose name is right but which points to an old inode (movie
|
||||||
|
# re-imported/replaced) is still obsolete, while the correctly created
|
||||||
|
# link is *not* wrongly swept.
|
||||||
|
expected: dict[Path, set[int]] = {}
|
||||||
|
for file_obj, path in self._plan_links(
|
||||||
|
files, roots, category_transforms, category_filename_templates
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
expected.setdefault(path, set()).add(file_obj.file_path.stat().st_ino)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors).
|
||||||
|
# Inside them, any file that isn't an expected link is obsolete — whether
|
||||||
|
# a tag was removed, the movie was replaced (orphan inode), or the folder
|
||||||
|
# itself is a leftover from a renamed layout.
|
||||||
|
top_dirs = self._managed_top_dirs(
|
||||||
|
files, roots, category_transforms, reserved_subfolders
|
||||||
|
)
|
||||||
|
for top in self.output_dir.iterdir():
|
||||||
|
if not top.is_dir():
|
||||||
|
continue
|
||||||
|
if top_dirs is not None and top.name not in top_dirs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for link_file in top.rglob("*"):
|
||||||
|
if not link_file.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
link_inode = link_file.stat().st_ino
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
# Valid only when both the path *and* the inode match a current
|
||||||
|
# movie; a name-only match (orphan squatting the name) is stale.
|
||||||
|
if link_inode in expected.get(link_file, ()):
|
||||||
|
continue
|
||||||
|
source = inode_to_file.get(link_inode)
|
||||||
|
obsolete.append((link_file, source.file_path if source else link_file))
|
||||||
|
|
||||||
|
return obsolete
|
||||||
|
|
||||||
|
def remove_obsolete_links(
|
||||||
|
self,
|
||||||
|
files: list[File],
|
||||||
|
categories: list[str] | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
category_roots: dict[str, str] | None = None,
|
||||||
|
category_transforms: dict[str, str] | None = None,
|
||||||
|
category_filename_templates: dict[str, str] | None = None,
|
||||||
|
reserved_subfolders: set[str] | None = None,
|
||||||
|
) -> tuple[int, list[Path]]:
|
||||||
|
"""
|
||||||
|
Remove hardlinks that no longer match file tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects
|
||||||
|
categories: Optional list of categories to check
|
||||||
|
dry_run: If True, only return what would be removed
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
reserved_subfolders: Output top-level folders to never touch (mirrors,
|
||||||
|
"Tipy dne", …).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (removed_count, list_of_removed_paths)
|
||||||
|
"""
|
||||||
|
obsolete = self.find_obsolete_links(
|
||||||
|
files,
|
||||||
|
categories,
|
||||||
|
category_roots,
|
||||||
|
category_transforms,
|
||||||
|
category_filename_templates,
|
||||||
|
reserved_subfolders,
|
||||||
|
)
|
||||||
|
removed_paths = []
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return len(obsolete), [link for link, _ in obsolete]
|
||||||
|
|
||||||
|
for link_path, _ in obsolete:
|
||||||
|
try:
|
||||||
|
link_path.unlink()
|
||||||
|
removed_paths.append(link_path)
|
||||||
|
|
||||||
|
# Try to remove empty parent directories
|
||||||
|
self._remove_empty_parents(link_path.parent)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return len(removed_paths), removed_paths
|
||||||
|
|
||||||
|
def sync_structure(
|
||||||
|
self,
|
||||||
|
files: list[File],
|
||||||
|
categories: list[str] | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
category_roots: dict[str, str] | None = None,
|
||||||
|
category_transforms: dict[str, str] | None = None,
|
||||||
|
category_filename_templates: dict[str, str] | None = None,
|
||||||
|
reserved_subfolders: set[str] | None = None,
|
||||||
|
) -> tuple[int, int, int, int]:
|
||||||
|
"""
|
||||||
|
Synchronize hardlink structure with current file tags.
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Remove hardlinks for removed tags
|
||||||
|
2. Create new hardlinks for new tags
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects
|
||||||
|
categories: Optional list of categories to sync
|
||||||
|
dry_run: If True, only simulate
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
reserved_subfolders: Output top-level folders to never touch (mirrors,
|
||||||
|
"Tipy dne", …).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (created, create_failed, removed, remove_failed)
|
||||||
|
"""
|
||||||
|
# First find how many obsolete links there are
|
||||||
|
obsolete_count = len(
|
||||||
|
self.find_obsolete_links(
|
||||||
|
files,
|
||||||
|
categories,
|
||||||
|
category_roots,
|
||||||
|
category_transforms,
|
||||||
|
category_filename_templates,
|
||||||
|
reserved_subfolders,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove obsolete links
|
||||||
|
removed, _removed_paths = self.remove_obsolete_links(
|
||||||
|
files,
|
||||||
|
categories,
|
||||||
|
dry_run,
|
||||||
|
category_roots,
|
||||||
|
category_transforms,
|
||||||
|
category_filename_templates,
|
||||||
|
reserved_subfolders,
|
||||||
|
)
|
||||||
|
remove_failed = obsolete_count - removed if not dry_run else 0
|
||||||
|
|
||||||
|
# Then create new links
|
||||||
|
created, create_failed = self.create_structure_for_files(
|
||||||
|
files,
|
||||||
|
categories,
|
||||||
|
dry_run,
|
||||||
|
category_roots,
|
||||||
|
category_transforms,
|
||||||
|
category_filename_templates,
|
||||||
|
)
|
||||||
|
|
||||||
|
return created, create_failed, removed, remove_failed
|
||||||
|
|
||||||
|
|
||||||
|
def create_hardlink_structure(
|
||||||
|
files: list[File], output_dir: Path, categories: list[str] | None = None
|
||||||
|
) -> tuple[int, int, list[tuple[Path, str]]]:
|
||||||
|
"""
|
||||||
|
Convenience function to create hardlink structure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects to process
|
||||||
|
output_dir: Base directory for output
|
||||||
|
categories: Optional list of categories to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (successful_count, failed_count, errors_list)
|
||||||
|
"""
|
||||||
|
manager = HardlinkManager(output_dir)
|
||||||
|
success, fail = manager.create_structure_for_files(files, categories)
|
||||||
|
return success, fail, manager.errors
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Video integrity checking via FFmpeg.
|
||||||
|
|
||||||
|
Detects corrupted or unreadable video files. Two depths:
|
||||||
|
|
||||||
|
- **deep** (default): a full ``ffmpeg`` decode to the null muxer
|
||||||
|
(``-f null``). It actually decodes every frame, so it catches mid-file data
|
||||||
|
corruption (broken GOPs, truncated streams, bad packets). Thorough but slow —
|
||||||
|
it reads and decodes the whole file.
|
||||||
|
- **quick**: an ``ffprobe`` container/stream probe. Fast but shallow — it catches
|
||||||
|
an unreadable container, a truncated header or a missing video stream, but not
|
||||||
|
corruption buried inside otherwise-parseable data.
|
||||||
|
|
||||||
|
Both tools ship with FFmpeg; ``ffmpeg_available`` reports whether they are on the
|
||||||
|
PATH so callers can degrade gracefully.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FFMPEG = "ffmpeg"
|
||||||
|
FFPROBE = "ffprobe"
|
||||||
|
|
||||||
|
# Cap a single stderr excerpt so a chatty decoder can't flood the report.
|
||||||
|
_MAX_ERROR_LEN = 300
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IntegrityResult:
|
||||||
|
"""Outcome of checking one file.
|
||||||
|
|
||||||
|
``ok`` — the file decoded/probed cleanly.
|
||||||
|
``checked`` — the check actually ran; False means it couldn't (missing tool,
|
||||||
|
missing file), which is *not* the same as "corrupted".
|
||||||
|
``error`` — a short reason when not ``ok``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
ok: bool
|
||||||
|
error: str = ""
|
||||||
|
checked: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpeg_available(deep: bool = True) -> bool:
|
||||||
|
"""Is the required FFmpeg tool (ffmpeg for deep, ffprobe for quick) on PATH?"""
|
||||||
|
return shutil.which(FFMPEG if deep else FFPROBE) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def check_video_integrity(
|
||||||
|
path: Path, deep: bool = True, timeout: int | None = None
|
||||||
|
) -> IntegrityResult:
|
||||||
|
"""Check a single video file for corruption.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: the video file to check.
|
||||||
|
deep: True → full ffmpeg decode (catches data corruption, slow);
|
||||||
|
False → ffprobe probe (catches unreadable/truncated files, fast).
|
||||||
|
timeout: per-file timeout in seconds (None = no limit).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An :class:`IntegrityResult`. ``checked=False`` when the check could not
|
||||||
|
be run at all (tool or file missing) — distinct from a real failure.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.is_file():
|
||||||
|
return IntegrityResult(path, ok=False, error="Soubor neexistuje", checked=False)
|
||||||
|
|
||||||
|
tool = FFMPEG if deep else FFPROBE
|
||||||
|
if shutil.which(tool) is None:
|
||||||
|
return IntegrityResult(
|
||||||
|
path, ok=False, error=f"{tool} není k dispozici", checked=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if deep:
|
||||||
|
cmd = [FFMPEG, "-v", "error", "-xerror", "-i", str(path), "-f", "null", "-"]
|
||||||
|
else:
|
||||||
|
cmd = [
|
||||||
|
FFPROBE,
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=codec_type",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0",
|
||||||
|
str(path),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
errors="replace",
|
||||||
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return IntegrityResult(path, ok=False, error="Kontrola vypršela (timeout)")
|
||||||
|
except OSError as exc:
|
||||||
|
return IntegrityResult(path, ok=False, error=str(exc), checked=False)
|
||||||
|
|
||||||
|
stderr = (result.stderr or "").strip()
|
||||||
|
if deep:
|
||||||
|
ok = result.returncode == 0 and not stderr
|
||||||
|
else:
|
||||||
|
# Quick probe: a healthy file returns rc 0 and a "video" codec_type row.
|
||||||
|
ok = result.returncode == 0 and "video" in (result.stdout or "")
|
||||||
|
if not ok and not stderr:
|
||||||
|
stderr = "Žádný video stream / nečitelný kontejner"
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
return IntegrityResult(path, ok=True)
|
||||||
|
return IntegrityResult(
|
||||||
|
path, ok=False, error=_first_error_line(stderr) or "Neznámá chyba dekódování"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_error_line(stderr: str) -> str:
|
||||||
|
"""First non-empty stderr line, truncated — a compact reason for the report."""
|
||||||
|
for line in stderr.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
return line[:_MAX_ERROR_LEN]
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Jellyfin server API client — thin, hybrid integration.
|
||||||
|
|
||||||
|
The NFO + artwork on disk stay authoritative (Jellyfin writes and reads them);
|
||||||
|
this client only does what the disk can't: **trigger a library scan** and **read
|
||||||
|
back what Jellyfin currently sees** (to verify the pool against it). Connection
|
||||||
|
details come from ``.env`` — ``JELLYFIN_URL`` and ``JELLYFIN_API_KEY`` (created in
|
||||||
|
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
with contextlib.suppress(
|
||||||
|
Exception
|
||||||
|
): # the values can also come from the real environment
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JellyfinMovie:
|
||||||
|
"""A movie as Jellyfin currently sees it (for verification)."""
|
||||||
|
|
||||||
|
jellyfin_id: str
|
||||||
|
title: str
|
||||||
|
year: int | None
|
||||||
|
tmdb_id: int | None
|
||||||
|
path: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinError(RuntimeError):
|
||||||
|
"""A Jellyfin request could not be completed (no config, network, status)."""
|
||||||
|
|
||||||
|
|
||||||
|
def base_url() -> str | None:
|
||||||
|
"""The configured Jellyfin base URL (no trailing slash), or None."""
|
||||||
|
url = os.getenv("JELLYFIN_URL")
|
||||||
|
return url.strip().rstrip("/") if url and url.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def api_key() -> str | None:
|
||||||
|
"""The configured Jellyfin API key, or None."""
|
||||||
|
key = os.getenv("JELLYFIN_API_KEY")
|
||||||
|
return key.strip() if key and key.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def api_available() -> bool:
|
||||||
|
"""True when both the Jellyfin URL and API key are configured."""
|
||||||
|
return bool(base_url() and api_key())
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
method: str, path: str, params: dict | None = None, timeout: int | None = None
|
||||||
|
) -> requests.Response:
|
||||||
|
url, key = base_url(), api_key()
|
||||||
|
if not (url and key):
|
||||||
|
raise JellyfinError("JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).")
|
||||||
|
try:
|
||||||
|
resp = requests.request(
|
||||||
|
method,
|
||||||
|
f"{url}{path}",
|
||||||
|
headers={"X-Emby-Token": key},
|
||||||
|
params=params,
|
||||||
|
timeout=timeout or _TIMEOUT,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise JellyfinError(f"Jellyfin nedostupný: {exc}") from exc
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
raise JellyfinError("Neplatný JELLYFIN_API_KEY.")
|
||||||
|
if not resp.ok:
|
||||||
|
raise JellyfinError(f"Jellyfin odpověděl HTTP {resp.status_code}.")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def server_version(timeout: int | None = None) -> str:
|
||||||
|
"""Return the Jellyfin server version (a quick connectivity check)."""
|
||||||
|
data = _request("GET", "/System/Info", timeout=timeout).json()
|
||||||
|
return str(data.get("Version") or "?")
|
||||||
|
|
||||||
|
|
||||||
|
def scan_all() -> None:
|
||||||
|
"""Trigger a scan of all Jellyfin libraries (picks up on-disk changes)."""
|
||||||
|
_request("POST", "/Library/Refresh")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_movies() -> list[JellyfinMovie]:
|
||||||
|
"""Read every movie Jellyfin currently has, with its TMDb id and path."""
|
||||||
|
data = _request(
|
||||||
|
"GET",
|
||||||
|
"/Items",
|
||||||
|
params={
|
||||||
|
"IncludeItemTypes": "Movie",
|
||||||
|
"Recursive": "true",
|
||||||
|
"Fields": "ProviderIds,Path,ProductionYear",
|
||||||
|
"EnableTotalRecordCount": "false",
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
movies: list[JellyfinMovie] = []
|
||||||
|
for item in data.get("Items", []):
|
||||||
|
provider = item.get("ProviderIds") or {}
|
||||||
|
raw_tmdb = provider.get("Tmdb") or provider.get("tmdb")
|
||||||
|
tmdb_id = int(raw_tmdb) if raw_tmdb and str(raw_tmdb).isdigit() else None
|
||||||
|
movies.append(
|
||||||
|
JellyfinMovie(
|
||||||
|
jellyfin_id=str(item.get("Id")),
|
||||||
|
title=item.get("Name") or "",
|
||||||
|
year=item.get("ProductionYear"),
|
||||||
|
tmdb_id=tmdb_id,
|
||||||
|
path=item.get("Path"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return movies
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Readers for Curator's *pre-index* metadata layouts.
|
||||||
|
|
||||||
|
Before the unified pool index (`.Curator.!index`), Tagger / early Curator stored
|
||||||
|
a movie's metadata in a **per-file sidecar** ``.{filename}.!tag`` (JSON) sitting
|
||||||
|
next to the video. When importing a file that came from such a pool we can
|
||||||
|
recover its **ČSFD link** (and title / year) from that sidecar — or from an old
|
||||||
|
``.Curator.!index`` found by walking up from the file.
|
||||||
|
|
||||||
|
This is read-only recovery for the import dialog; nothing here writes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
from .pool_index import INDEX_FILENAME
|
||||||
|
|
||||||
|
SIDECAR_SUFFIX = ".!tag"
|
||||||
|
_MAX_INDEX_WALK_UP = 4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LegacyMeta:
|
||||||
|
"""What an old sidecar / index record can still tell us about a movie."""
|
||||||
|
|
||||||
|
csfd_url: str | None = None
|
||||||
|
title: str | None = None
|
||||||
|
year: str | None = None # four-digit string
|
||||||
|
source: str = "" # "sidecar" | "index"
|
||||||
|
|
||||||
|
|
||||||
|
def _record_to_meta(record: object, source: str) -> LegacyMeta | None:
|
||||||
|
"""Pull the useful fields out of a stored movie record (any schema version)."""
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return None
|
||||||
|
csfd = record.get("csfd_url") or record.get("csfd_link") # new | Tagger-era key
|
||||||
|
title = record.get("title") or None
|
||||||
|
meta_raw = record.get("meta")
|
||||||
|
meta = meta_raw if isinstance(meta_raw, dict) else {}
|
||||||
|
cache_raw = record.get("csfd_cache")
|
||||||
|
cache = cache_raw if isinstance(cache_raw, dict) else {}
|
||||||
|
raw_year = meta.get("year") or cache.get("year")
|
||||||
|
year = (
|
||||||
|
str(raw_year).strip()
|
||||||
|
if raw_year is not None and str(raw_year).strip().isdigit()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not (csfd or title or year):
|
||||||
|
return None
|
||||||
|
return LegacyMeta(csfd_url=csfd or None, title=title, year=year, source=source)
|
||||||
|
|
||||||
|
|
||||||
|
def _from_sidecar(video_path: Path) -> LegacyMeta | None:
|
||||||
|
sidecar = video_path.parent / f".{video_path.name}{SIDECAR_SUFFIX}"
|
||||||
|
if not sidecar.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
record = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError, OSError, ValueError:
|
||||||
|
return None
|
||||||
|
return _record_to_meta(record, "sidecar")
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> dict | None:
|
||||||
|
"""Find the record for ``video_path`` in an index's ``movies`` map.
|
||||||
|
|
||||||
|
Tries the exact pool-relative key first, then falls back to a unique
|
||||||
|
basename match (the file may have moved since the index was written).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rel = video_path.relative_to(index_dir).as_posix()
|
||||||
|
if rel in movies:
|
||||||
|
return movies[rel]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
name = video_path.name
|
||||||
|
matches = [v for k, v in movies.items() if PurePosixPath(k).name == name]
|
||||||
|
return matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _from_legacy_index(video_path: Path) -> LegacyMeta | None:
|
||||||
|
directory = video_path.parent
|
||||||
|
for _ in range(_MAX_INDEX_WALK_UP + 1):
|
||||||
|
index_path = directory / INDEX_FILENAME
|
||||||
|
if index_path.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError, OSError, ValueError:
|
||||||
|
return None
|
||||||
|
movies = data.get("movies", data) if isinstance(data, dict) else None
|
||||||
|
if not isinstance(movies, dict):
|
||||||
|
return None
|
||||||
|
record = _lookup_in_index(movies, video_path, directory)
|
||||||
|
return _record_to_meta(record, "index") if record is not None else None
|
||||||
|
if directory.parent == directory:
|
||||||
|
break
|
||||||
|
directory = directory.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_legacy_meta(video_path: Path) -> LegacyMeta | None:
|
||||||
|
"""Recover ČSFD link / title / year from a pre-index layout, or ``None``.
|
||||||
|
|
||||||
|
Order: a per-file ``.{name}.!tag`` sidecar next to the video, then a
|
||||||
|
``.Curator.!index`` found walking up (max 4 levels) from the file.
|
||||||
|
"""
|
||||||
|
video_path = Path(video_path)
|
||||||
|
return _from_sidecar(video_path) or _from_legacy_index(video_path)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from .file import File
|
||||||
|
|
||||||
|
|
||||||
|
class ListManager:
|
||||||
|
def __init__(self):
|
||||||
|
# 'name' nebo 'date'
|
||||||
|
self.sort_mode = "name"
|
||||||
|
|
||||||
|
def set_sort(self, mode: str):
|
||||||
|
if mode in ("name", "date"):
|
||||||
|
self.sort_mode = mode
|
||||||
|
|
||||||
|
def sort_files(self, files: list[File]) -> list[File]:
|
||||||
|
if self.sort_mode == "name":
|
||||||
|
return sorted(files, key=lambda f: f.filename.lower())
|
||||||
|
else:
|
||||||
|
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
|
||||||
|
def date_key(f):
|
||||||
|
return (f.date is None, f.date or "")
|
||||||
|
|
||||||
|
return sorted(files, key=date_key)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Module header
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .file import File
|
||||||
|
from .tag_manager import TagManager
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit("This module is not intended to be executed as the main program.")
|
||||||
|
|
||||||
|
# Imports
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
|
|
||||||
|
# Functions
|
||||||
|
def load_icon(path) -> ImageTk.PhotoImage:
|
||||||
|
img = Image.open(path)
|
||||||
|
img = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||||
|
return ImageTk.PhotoImage(img)
|
||||||
|
|
||||||
|
|
||||||
|
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
|
||||||
|
"""
|
||||||
|
Zjistí vertikální rozlišení videa a přiřadí tag Rozlišení/{výška}p.
|
||||||
|
Vyžaduje ffprobe (FFmpeg).
|
||||||
|
"""
|
||||||
|
path = str(file_obj.file_path)
|
||||||
|
try:
|
||||||
|
# ffprobe vrátí width a height ve formátu JSON
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=width,height",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0:s=x",
|
||||||
|
path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
res = result.stdout.strip() # např. "1920x1080"
|
||||||
|
if "x" not in res:
|
||||||
|
return
|
||||||
|
_width, height = map(int, res.split("x"))
|
||||||
|
tag_name = f"Rozlišení/{height}p"
|
||||||
|
tag_obj = tagmanager.add_tag("Rozlišení", f"{height}p")
|
||||||
|
file_obj.add_tag(tag_obj)
|
||||||
|
print(f"Přiřazen tag {tag_name} k {file_obj.filename}")
|
||||||
|
except (OSError, subprocess.SubprocessError, ValueError) as e:
|
||||||
|
print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""One-off migration of an existing pool to the Jellyfin naming convention.
|
||||||
|
|
||||||
|
Older pools carry files like ``Title.ext`` / ``Title (rok).ext`` with **no TMDb
|
||||||
|
id**. This matches each such movie against TMDb by title + year and proposes a
|
||||||
|
``tmdbid`` (and the missing year, which TMDb supplies), so the file can be renamed
|
||||||
|
to ``Title (rok) [tmdbid].ext`` and Jellyfin can match it deterministically.
|
||||||
|
|
||||||
|
The classification is deliberately **conservative**: only an exact
|
||||||
|
(diacritics-insensitive) title match with a matching year and a single candidate
|
||||||
|
is ``auto``; everything else is ``ambiguous`` for the user to confirm.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchProposal:
|
||||||
|
"""One movie's proposed TMDb match."""
|
||||||
|
|
||||||
|
file: object # core.file.File
|
||||||
|
current_name: str
|
||||||
|
title: str
|
||||||
|
year: str | None
|
||||||
|
status: str # "auto" | "ambiguous" | "no_match" | "error"
|
||||||
|
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
|
||||||
|
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
|
||||||
|
error: str = ""
|
||||||
|
note: str = "" # e.g. "přes ČSFD: The Shawshank Redemption (1994)"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text: str) -> str:
|
||||||
|
"""Fold a title for comparison: strip diacritics/punctuation, lowercase."""
|
||||||
|
text = unicodedata.normalize("NFD", text or "")
|
||||||
|
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||||
|
text = re.sub(r"[^0-9a-zA-Z ]+", " ", text.lower())
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _year_close(a: str | None, b: str | None) -> bool:
|
||||||
|
if not (a and b and str(a).isdigit() and str(b).isdigit()):
|
||||||
|
return False
|
||||||
|
return abs(int(a) - int(b)) <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def classify(title: str, year: str | None, candidates: list) -> tuple[str, object]:
|
||||||
|
"""Return ``(status, chosen)`` for a title/year against TMDb candidates.
|
||||||
|
|
||||||
|
``auto`` requires an exact normalized-title match, a year within ±1, and that
|
||||||
|
exactly one candidate qualifies. Otherwise ``ambiguous`` (top hit preselected)
|
||||||
|
or ``no_match`` when there are no candidates.
|
||||||
|
"""
|
||||||
|
if not candidates:
|
||||||
|
return "no_match", None
|
||||||
|
nt = normalize(title)
|
||||||
|
exact_year = [
|
||||||
|
c for c in candidates if normalize(c.title) == nt and _year_close(c.year, year)
|
||||||
|
]
|
||||||
|
if len(exact_year) == 1:
|
||||||
|
return "auto", exact_year[0]
|
||||||
|
return "ambiguous", candidates[0]
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""The movie object — metadata first, files second.
|
||||||
|
|
||||||
|
A movie is identified by ``(title, year, tmdb_id)`` and owns **its files**, not
|
||||||
|
the other way round. That inversion is the point: the pool index used to key a
|
||||||
|
record by a file path, so metadata was a passenger on one particular file — and
|
||||||
|
everything about multi-version movies got awkward (which file "holds" the tags,
|
||||||
|
why renaming a grouped movie had to be blocked, why an import could silently
|
||||||
|
create a duplicate instead of a version).
|
||||||
|
|
||||||
|
Here the record is the movie; each playable file is a :class:`MediaFile` under
|
||||||
|
it, told apart by a free-text ``label`` (``3D HSBS``, ``4K``, ``Director's
|
||||||
|
Cut``, …). Where those files *live* is then a pure function of the record —
|
||||||
|
:meth:`Movie.desired_layout` — so promoting to a folder, demoting back, or
|
||||||
|
renaming the whole thing are all the same operation: recompute and move what
|
||||||
|
doesn't match.
|
||||||
|
|
||||||
|
Layout (Jellyfin's own conventions — https://jellyfin.org/docs/general/server/media/movies/)::
|
||||||
|
|
||||||
|
one video <Library>/Title (rok) [tmdbid-N].ext
|
||||||
|
two or more <Library>/Title (rok) [tmdbid-N]/
|
||||||
|
Title (rok) [tmdbid-N] - Výchozí.ext
|
||||||
|
Title (rok) [tmdbid-N].3D.HSBS.ext
|
||||||
|
|
||||||
|
Every file in a movie folder must start with the folder's exact name; both
|
||||||
|
``" - label"`` and ``".3D.FMT"`` are valid separators, so a 3D file keeps the
|
||||||
|
documented ``.3D.<FORMAT>`` spelling whether it sits flat or grouped.
|
||||||
|
Subtitles are not media entries — they ride along with their video by filename
|
||||||
|
stem (see :mod:`src.core.subtitles`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .naming import THREE_D_FORMATS, canonical_pool_stem, is_valid_year
|
||||||
|
|
||||||
|
# Roles a media file can have. Only playable video for now — artwork stays
|
||||||
|
# Jellyfin's job (it writes poster/fanart into the folder itself).
|
||||||
|
ROLE_VIDEO = "video"
|
||||||
|
|
||||||
|
# A grouped movie needs a label on every file (all of Jellyfin's documented
|
||||||
|
# examples label every version); this is what an unlabelled one gets.
|
||||||
|
DEFAULT_VERSION_LABEL = "Výchozí"
|
||||||
|
|
||||||
|
# "HSBS" / "hsbs" / "3D HSBS" / "3d.hsbs" / "3D_HSBS" → "HSBS"
|
||||||
|
_LABEL_3D_RE = re.compile(
|
||||||
|
rf"^(?:3d[\s._-]*)?({'|'.join(THREE_D_FORMATS)})$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_3d_label(label: object) -> str | None:
|
||||||
|
"""The 3D format a version label denotes (upper-case), or ``None``.
|
||||||
|
|
||||||
|
Lets a plain label double as the 3D marker — ``"3D HSBS"`` typed in the
|
||||||
|
version dialog and ``"hsbs"`` picked from a dropdown mean the same thing.
|
||||||
|
"""
|
||||||
|
match = _LABEL_3D_RE.match(str(label or "").strip())
|
||||||
|
return match.group(1).upper() if match else None
|
||||||
|
|
||||||
|
|
||||||
|
# Jellyfin's multi-part ("stacking") keywords + a number: "part1", "CD 2", "pt-3".
|
||||||
|
_LABEL_PART_RE = re.compile(
|
||||||
|
r"^(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{1,2})$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_part_label(label: object) -> int | None:
|
||||||
|
"""The part number a version label denotes, or ``None``.
|
||||||
|
|
||||||
|
A *part* is one piece of a film split across several files (played back
|
||||||
|
to back as one movie), unlike a *version*, which is an alternative. Any of
|
||||||
|
Jellyfin's stacking spellings counts — ``part2``, ``CD2``, ``pt 2`` — and
|
||||||
|
Curator always writes it back as ``-part<N>`` (see :func:`part_label`).
|
||||||
|
"""
|
||||||
|
match = _LABEL_PART_RE.match(str(label or "").strip())
|
||||||
|
return int(match.group(1)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def part_label(number: int) -> str:
|
||||||
|
"""Canonical version label of part ``number`` — ``"part<N>"``."""
|
||||||
|
return f"part{int(number)}"
|
||||||
|
|
||||||
|
|
||||||
|
def media_filename(stem: str, label: str, ext: str, *, grouped: bool) -> str:
|
||||||
|
"""Filename for one media file of a movie whose canonical stem is ``stem``.
|
||||||
|
|
||||||
|
- no label → ``stem.ext`` (a lone, plain video)
|
||||||
|
- 3D label → ``stem.3D.<FORMAT>.ext`` (Jellyfin's documented 3D form)
|
||||||
|
- anything else → ``stem - label.ext``
|
||||||
|
|
||||||
|
``grouped`` only decides whether an empty label is allowed: inside a movie
|
||||||
|
folder every file gets one, so it falls back to :data:`DEFAULT_VERSION_LABEL`.
|
||||||
|
"""
|
||||||
|
label = (label or "").strip()
|
||||||
|
if grouped and not label:
|
||||||
|
label = DEFAULT_VERSION_LABEL
|
||||||
|
if not label:
|
||||||
|
return f"{stem}{ext}"
|
||||||
|
fmt = normalize_3d_label(label)
|
||||||
|
if fmt:
|
||||||
|
return f"{stem}.3D.{fmt}{ext}"
|
||||||
|
return f"{stem} - {label}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def identity_key(tmdb_id: object = None, title: str = "", year: object = None) -> str:
|
||||||
|
"""The pool-index key for a movie — ``tmdb-<id>``, else a title/year slug.
|
||||||
|
|
||||||
|
Keying by identity (rather than by file path) is what lets a second file of
|
||||||
|
the same film find its movie instead of becoming a separate entry.
|
||||||
|
"""
|
||||||
|
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
|
||||||
|
if id_s.isdigit():
|
||||||
|
return f"tmdb-{int(id_s)}"
|
||||||
|
raw = f"{(title or '').strip()} {str(year or '').strip()}".strip().lower()
|
||||||
|
# Fold diacritics so "Pelíšky" and "Pelisky" can't become two movies.
|
||||||
|
raw = "".join(
|
||||||
|
c for c in unicodedata.normalize("NFD", raw) if not unicodedata.combining(c)
|
||||||
|
)
|
||||||
|
slug = re.sub(r"[^0-9a-z]+", "-", raw).strip("-")
|
||||||
|
return f"name-{slug or 'bez-nazvu'}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaFile:
|
||||||
|
"""One file belonging to a movie (today always a playable video)."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
label: str = ""
|
||||||
|
role: str = ROLE_VIDEO
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_3d(self) -> str | None:
|
||||||
|
"""The 3D format this version's label denotes, if any."""
|
||||||
|
return normalize_3d_label(self.label)
|
||||||
|
|
||||||
|
def to_record(self, pool_dir: Path) -> dict:
|
||||||
|
"""Serialise for the pool index (path stored pool-relative, POSIX)."""
|
||||||
|
try:
|
||||||
|
rel = Path(self.path).relative_to(pool_dir).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
rel = Path(self.path).as_posix()
|
||||||
|
return {"role": self.role, "label": self.label, "file": rel}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_record(cls, data: dict, pool_dir: Path) -> MediaFile:
|
||||||
|
return cls(
|
||||||
|
path=pool_dir / str(data.get("file", "")),
|
||||||
|
label=str(data.get("label", "") or ""),
|
||||||
|
role=str(data.get("role") or ROLE_VIDEO),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Movie:
|
||||||
|
"""A film in the pool: its identity, its metadata, and its media files."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
title: str = ""
|
||||||
|
year: str | None = None
|
||||||
|
tmdb_id: int | None = None
|
||||||
|
library: str = ""
|
||||||
|
media: list[MediaFile] = field(default_factory=list)
|
||||||
|
|
||||||
|
# --- metadata that used to live on the "primary" File ---
|
||||||
|
tags: list = field(default_factory=list)
|
||||||
|
auto_tag_paths: set[str] = field(default_factory=set)
|
||||||
|
csfd_url: str | None = None
|
||||||
|
date: str | None = None
|
||||||
|
added: str | None = None
|
||||||
|
attributes: dict[str, str] = field(default_factory=dict)
|
||||||
|
meta: dict | None = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Identity / shape
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def videos(self) -> list[MediaFile]:
|
||||||
|
"""Playable versions, in a stable order (labelled ones by label)."""
|
||||||
|
return sorted(
|
||||||
|
(m for m in self.media if m.role == ROLE_VIDEO),
|
||||||
|
key=lambda m: m.label.casefold(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_grouped(self) -> bool:
|
||||||
|
"""True when the movie needs its own folder (more than one version)."""
|
||||||
|
return len(self.videos) > 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stem(self) -> str:
|
||||||
|
"""Canonical ``Title (rok) [tmdbid-N]`` — the folder / filename base.
|
||||||
|
|
||||||
|
Falls back to the bare title when no four-digit year is known (a movie
|
||||||
|
can be imported without one).
|
||||||
|
"""
|
||||||
|
title = (self.title or "").replace("/", "-").replace("\\", "-").strip()
|
||||||
|
if not title:
|
||||||
|
title = self.key
|
||||||
|
if is_valid_year(self.year or ""):
|
||||||
|
return canonical_pool_stem(title, self.year, self.tmdb_id)
|
||||||
|
return title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary(self) -> MediaFile | None:
|
||||||
|
"""The version that represents the movie (first by :attr:`videos`)."""
|
||||||
|
videos = self.videos
|
||||||
|
return videos[0] if videos else None
|
||||||
|
|
||||||
|
def desired_layout(self, pool_dir: Path) -> list[tuple[MediaFile, Path]]:
|
||||||
|
"""``(media, where it belongs)`` for every file, from the record alone.
|
||||||
|
|
||||||
|
The whole flat-vs-folder rule lives here: one video sits directly in
|
||||||
|
the library, two or more move into ``<Library>/<stem>/``. Callers move
|
||||||
|
whatever doesn't already match — which makes promotion, demotion and
|
||||||
|
renaming the same operation.
|
||||||
|
"""
|
||||||
|
base = Path(pool_dir) / self.library
|
||||||
|
grouped = self.is_grouped
|
||||||
|
folder = base / self.stem if grouped else base
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
m,
|
||||||
|
folder
|
||||||
|
/ media_filename(
|
||||||
|
self.stem, m.label, Path(m.path).suffix, grouped=grouped
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for m in self.videos
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Index serialisation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def to_record(self, pool_dir: Path) -> dict:
|
||||||
|
data = {
|
||||||
|
"title": self.title,
|
||||||
|
"year": self.year,
|
||||||
|
"tmdb_id": self.tmdb_id,
|
||||||
|
"library": self.library,
|
||||||
|
"media": [m.to_record(pool_dir) for m in self.media],
|
||||||
|
"tags": [getattr(t, "full_path", t) for t in self.tags],
|
||||||
|
"auto_tags": sorted(self.auto_tag_paths),
|
||||||
|
"csfd_url": self.csfd_url,
|
||||||
|
"date": self.date,
|
||||||
|
"added": self.added,
|
||||||
|
"attributes": dict(self.attributes),
|
||||||
|
}
|
||||||
|
if self.meta is not None:
|
||||||
|
data["meta"] = dict(self.meta)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_record(
|
||||||
|
cls, key: str, data: dict, pool_dir: Path, tagmanager=None
|
||||||
|
) -> Movie:
|
||||||
|
movie = cls(
|
||||||
|
key=key,
|
||||||
|
title=str(data.get("title") or ""),
|
||||||
|
year=(str(data["year"]) if data.get("year") else None),
|
||||||
|
tmdb_id=(
|
||||||
|
int(data["tmdb_id"])
|
||||||
|
if str(data.get("tmdb_id") or "").isdigit()
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
library=str(data.get("library") or ""),
|
||||||
|
media=[
|
||||||
|
MediaFile.from_record(m, pool_dir) for m in (data.get("media") or [])
|
||||||
|
],
|
||||||
|
auto_tag_paths=set(data.get("auto_tags") or []),
|
||||||
|
csfd_url=data.get("csfd_url") or None,
|
||||||
|
date=data.get("date") or None,
|
||||||
|
added=data.get("added") or None,
|
||||||
|
attributes=dict(data.get("attributes") or {}),
|
||||||
|
meta=(dict(data["meta"]) if data.get("meta") else None),
|
||||||
|
)
|
||||||
|
if tagmanager is not None:
|
||||||
|
for full_path in data.get("tags") or []:
|
||||||
|
if "/" in str(full_path):
|
||||||
|
category, name = str(full_path).split("/", 1)
|
||||||
|
movie.tags.append(tagmanager.add_tag(category, name))
|
||||||
|
return movie
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Pool file naming convention.
|
||||||
|
|
||||||
|
Every movie in the pool is named ``Title (YYYY) [tmdbid-N].ext`` — a title, a
|
||||||
|
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
|
||||||
|
is exactly the form Jellyfin matches on. The ``[tmdbid-N]`` part is optional: a
|
||||||
|
plain ``Title (YYYY)`` is still valid (Jellyfin then matches by name). The year
|
||||||
|
lets two same-named films coexist and makes the filename self-describing.
|
||||||
|
|
||||||
|
A 3D movie additionally carries a ``.3D.<FORMAT>`` tag right before the
|
||||||
|
extension (Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/):
|
||||||
|
``Title (YYYY) [tmdbid-N].3D.HSBS.ext``. ``FORMAT`` is one of
|
||||||
|
:data:`THREE_D_FORMATS`; Jellyfin reads it case-insensitively but Curator always
|
||||||
|
writes it upper-case for a single canonical form.
|
||||||
|
|
||||||
|
These helpers build, validate and parse the stem (the filename without its
|
||||||
|
extension).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Jellyfin's recognised 3D format tags (case-insensitive on read; Curator
|
||||||
|
# always writes upper-case). hsbs/fsbs = half/full side-by-side, htab/ftab =
|
||||||
|
# half/full top-and-bottom, mvc = Multiview Video Coding.
|
||||||
|
THREE_D_FORMATS = ("HSBS", "FSBS", "HTAB", "FTAB", "MVC")
|
||||||
|
|
||||||
|
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]`` and
|
||||||
|
# an optional ``.3D.<FORMAT>`` tag. The title is non-greedy so a title that
|
||||||
|
# itself contains parentheses keeps them: "Kurz (special) (2019)" → title
|
||||||
|
# "Kurz (special)", year 2019.
|
||||||
|
_POOL_STEM_RE = re.compile(
|
||||||
|
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?"
|
||||||
|
r"(?:\.3D\.(?P<format3d>hsbs|fsbs|htab|ftab|mvc))?$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_year(year: object) -> bool:
|
||||||
|
"""True when ``year`` is (or stringifies to) exactly four digits."""
|
||||||
|
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_pool_stem(
|
||||||
|
title: str,
|
||||||
|
year: object,
|
||||||
|
tmdb_id: object = None,
|
||||||
|
format_3d: object = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` /
|
||||||
|
``.3D.<FORMAT>`` if given).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: the title is empty, the year is not four digits, a given
|
||||||
|
``tmdb_id`` is not a positive integer, or ``format_3d`` isn't one
|
||||||
|
of :data:`THREE_D_FORMATS`.
|
||||||
|
"""
|
||||||
|
title = (title or "").strip()
|
||||||
|
year_s = str(year).strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Název filmu nesmí být prázdný.")
|
||||||
|
if not re.fullmatch(r"\d{4}", year_s):
|
||||||
|
raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.")
|
||||||
|
stem = f"{title} ({year_s})"
|
||||||
|
if tmdb_id is not None and str(tmdb_id).strip() != "":
|
||||||
|
id_s = str(tmdb_id).strip()
|
||||||
|
if not id_s.isdigit():
|
||||||
|
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
|
||||||
|
stem += f" [tmdbid-{id_s}]"
|
||||||
|
if format_3d is not None and str(format_3d).strip() != "":
|
||||||
|
fmt = str(format_3d).strip().upper()
|
||||||
|
if fmt not in THREE_D_FORMATS:
|
||||||
|
raise ValueError(
|
||||||
|
f"3D formát musí být jedno z {', '.join(THREE_D_FORMATS)}, "
|
||||||
|
f"ne „{format_3d}“."
|
||||||
|
)
|
||||||
|
stem += f".3D.{fmt}"
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pool_stem(stem: str) -> tuple[str, int] | None:
|
||||||
|
"""Split a canonical stem into ``(title, year)``; ``None`` if it isn't one.
|
||||||
|
|
||||||
|
A trailing ``[tmdbid-N]`` is tolerated and dropped (use :func:`parse_tmdb_id`
|
||||||
|
to read it), so the returned title is always the clean title.
|
||||||
|
"""
|
||||||
|
match = _POOL_STEM_RE.match((stem or "").strip())
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group("title").strip(), int(match.group("year"))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tmdb_id(stem: str) -> int | None:
|
||||||
|
"""Read the ``[tmdbid-N]`` from a canonical stem, or ``None`` if absent."""
|
||||||
|
match = _POOL_STEM_RE.match((stem or "").strip())
|
||||||
|
if not match or not match.group("id"):
|
||||||
|
return None
|
||||||
|
return int(match.group("id"))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_3d_format(stem: str) -> str | None:
|
||||||
|
"""Read the ``.3D.<FORMAT>`` tag from a canonical stem (upper-cased), or
|
||||||
|
``None`` if absent."""
|
||||||
|
match = _POOL_STEM_RE.match((stem or "").strip())
|
||||||
|
if not match or not match.group("format3d"):
|
||||||
|
return None
|
||||||
|
return match.group("format3d").upper()
|
||||||
|
|
||||||
|
|
||||||
|
def is_canonical_pool_stem(stem: str) -> bool:
|
||||||
|
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
|
||||||
|
return _POOL_STEM_RE.match((stem or "").strip()) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def year_from_filename(name: str) -> str | None:
|
||||||
|
"""Best-effort four-digit year guessed from a raw (release) filename.
|
||||||
|
|
||||||
|
Prefers a parenthesised year (``… (1999) …``) and otherwise falls back to the
|
||||||
|
last standalone 19xx/20xx run. Used only to pre-fill the import dialog — the
|
||||||
|
user confirms it. Returns the year as a string, or ``None``.
|
||||||
|
"""
|
||||||
|
paren = re.search(r"\((19\d{2}|20\d{2})\)", name)
|
||||||
|
if paren:
|
||||||
|
return paren.group(1)
|
||||||
|
loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name)
|
||||||
|
return loose[-1] if loose else None
|
||||||
|
|
||||||
|
|
||||||
|
# "3D" + one of THREE_D_FORMATS, anywhere in a raw filename, bordered by a
|
||||||
|
# non-alphanumeric character (or the string's edge) on each side so it can't
|
||||||
|
# match inside an unrelated word.
|
||||||
|
_THREE_D_ANYWHERE_RE = re.compile(
|
||||||
|
rf"(?<![A-Za-z0-9])3d[\s._-]*({'|'.join(THREE_D_FORMATS)})(?![A-Za-z0-9])",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def three_d_format_from_filename(name: str) -> str | None:
|
||||||
|
"""Best-effort 3D format guessed from a raw (release) filename, e.g.
|
||||||
|
``Avatar.2009.3D.HSBS.BluRay.mkv`` → ``"HSBS"``. Unlike :func:`parse_3d_format`
|
||||||
|
this doesn't require the rest of the name to be a canonical pool stem — used
|
||||||
|
to pre-fill a version label while importing. Returns ``None`` if absent.
|
||||||
|
"""
|
||||||
|
match = _THREE_D_ANYWHERE_RE.search(name or "")
|
||||||
|
return match.group(1).upper() if match else None
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""NFO reader — Curator's metadata source after ČSFD.
|
||||||
|
|
||||||
|
Jellyfin (configured to save metadata into the media folders) writes a Kodi-style
|
||||||
|
``.nfo`` next to each movie plus artwork. Curator only **reads** it — to drive the
|
||||||
|
miniDLNA genre tree and to fill the year/rating used by folder grouping. It never
|
||||||
|
writes NFO (Jellyfin owns that).
|
||||||
|
|
||||||
|
For a flat pool file ``Title (rok) [tmdbid].mkv`` the sidecar is
|
||||||
|
``Title (rok) [tmdbid].nfo``; a per-movie folder may instead hold ``movie.nfo``.
|
||||||
|
Both are found. Missing or unparsable NFO → ``None`` (the movie simply has no
|
||||||
|
genres until Jellyfin has scanned it).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NfoData:
|
||||||
|
"""The bits of a movie ``.nfo`` Curator uses."""
|
||||||
|
|
||||||
|
title: str | None = None
|
||||||
|
year: str | None = None # four-digit string
|
||||||
|
genres: list[str] = field(default_factory=list)
|
||||||
|
countries: list[str] = field(default_factory=list)
|
||||||
|
rating: int | None = None # 0–100 (NFO 0–10 scaled up), or None
|
||||||
|
tmdb_id: int | None = None
|
||||||
|
csfd_url: str | None = None # ČSFD film URL, if the NFO carries a csfd id
|
||||||
|
|
||||||
|
def field_values(self, name: str) -> list[str]:
|
||||||
|
"""Values for a tag-schema source field (genres/countries/year/rating)."""
|
||||||
|
if name == "genres":
|
||||||
|
return list(self.genres)
|
||||||
|
if name == "countries":
|
||||||
|
return list(self.countries)
|
||||||
|
if name == "year":
|
||||||
|
return [self.year] if self.year else []
|
||||||
|
if name == "rating":
|
||||||
|
return [str(self.rating)] if self.rating is not None else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def find_nfo(video_path: Path) -> Path | None:
|
||||||
|
"""Locate the ``.nfo`` for a movie: sibling ``<stem>.nfo`` or ``movie.nfo``."""
|
||||||
|
video_path = Path(video_path)
|
||||||
|
sibling = video_path.with_suffix(".nfo")
|
||||||
|
if sibling.is_file():
|
||||||
|
return sibling
|
||||||
|
movie_nfo = video_path.parent / "movie.nfo"
|
||||||
|
if movie_nfo.is_file():
|
||||||
|
return movie_nfo
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _texts(root: ET.Element, tag: str) -> list[str]:
|
||||||
|
return [e.text.strip() for e in root.findall(tag) if e.text and e.text.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rating(root: ET.Element) -> int | None:
|
||||||
|
"""Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands)."""
|
||||||
|
raw = root.findtext("rating")
|
||||||
|
if raw is None:
|
||||||
|
value = root.find("ratings/rating/value")
|
||||||
|
raw = value.text if value is not None else None
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(float(raw.strip().replace(",", ".")) * 10)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tmdb_id(root: ET.Element) -> int | None:
|
||||||
|
for uid in root.findall("uniqueid"):
|
||||||
|
if uid.get("type") == "tmdb" and uid.text and uid.text.strip().isdigit():
|
||||||
|
return int(uid.text.strip())
|
||||||
|
raw = root.findtext("tmdbid")
|
||||||
|
return int(raw.strip()) if raw and raw.strip().isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csfd_url(root: ET.Element) -> str | None:
|
||||||
|
"""A ČSFD film URL from the NFO, if any.
|
||||||
|
|
||||||
|
Czech Kodi scrapers record ČSFD as ``<uniqueid type="csfd">`` (a bare id or a
|
||||||
|
full URL) or a legacy ``<csfdid>`` / ``<csfd>`` element. A bare id is expanded
|
||||||
|
to ``https://www.csfd.cz/film/<id>/``.
|
||||||
|
"""
|
||||||
|
candidates: list[str] = []
|
||||||
|
for uid in root.findall("uniqueid"):
|
||||||
|
if uid.get("type") == "csfd" and uid.text and uid.text.strip():
|
||||||
|
candidates.append(uid.text.strip())
|
||||||
|
for tag in ("csfdid", "csfd"):
|
||||||
|
raw = root.findtext(tag)
|
||||||
|
if raw and raw.strip():
|
||||||
|
candidates.append(raw.strip())
|
||||||
|
for value in candidates:
|
||||||
|
if value.isdigit():
|
||||||
|
return f"https://www.csfd.cz/film/{value}/"
|
||||||
|
if "csfd.cz" in value:
|
||||||
|
return value if value.startswith("http") else f"https://{value.lstrip('/')}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_nfo(video_path: Path) -> NfoData | None:
|
||||||
|
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
|
||||||
|
path = find_nfo(video_path)
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
except ET.ParseError, OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
year = root.findtext("year")
|
||||||
|
year = year.strip() if year and year.strip().isdigit() else None
|
||||||
|
title = root.findtext("title")
|
||||||
|
return NfoData(
|
||||||
|
title=title.strip() if title and title.strip() else None,
|
||||||
|
year=year,
|
||||||
|
genres=_texts(root, "genre"),
|
||||||
|
countries=_texts(root, "country"),
|
||||||
|
rating=_parse_rating(root),
|
||||||
|
tmdb_id=_parse_tmdb_id(root),
|
||||||
|
csfd_url=_parse_csfd_url(root),
|
||||||
|
)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
Unified metadata index for the Curator pool.
|
||||||
|
|
||||||
|
Instead of one sidecar file per movie, the whole pool keeps a single JSON index
|
||||||
|
at ``<pool>/.Curator.!index``. Curator owns the pool (it inserts/removes files
|
||||||
|
itself), so files never move behind its back and a central index is safe.
|
||||||
|
|
||||||
|
The index also carries **library-level settings** (``tag_schema``,
|
||||||
|
``copyasis_folders``) under a ``settings`` section, so everything describing a
|
||||||
|
particular pool travels with the pool. The global config keeps only app data
|
||||||
|
(window state, MRU) and the pointers to where the pool/output live.
|
||||||
|
|
||||||
|
On-disk shape (**v2**)::
|
||||||
|
|
||||||
|
{"version": 2,
|
||||||
|
"settings": {...},
|
||||||
|
"movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
|
||||||
|
|
||||||
|
Records are keyed by the movie's **identity** (``tmdb-<id>``, or a title/year
|
||||||
|
slug when there's no id) and own their files — see :mod:`src.core.movie`. The
|
||||||
|
original **v1** shape keyed a record by one file's pool-relative path, which
|
||||||
|
made metadata a passenger on that file; :meth:`PoolIndex.load` migrates such an
|
||||||
|
index in place (backing the old file up first). Migration only rewrites
|
||||||
|
metadata — no file on disk is moved, because a one-video movie is already in
|
||||||
|
its v2 position.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
INDEX_FILENAME = ".Curator.!index"
|
||||||
|
INDEX_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
|
class PoolIndex:
|
||||||
|
"""Single-file JSON metadata store for all files in a pool."""
|
||||||
|
|
||||||
|
def __init__(self, pool_dir: Path) -> None:
|
||||||
|
self.pool_dir = Path(pool_dir)
|
||||||
|
self.index_path = self.pool_dir / INDEX_FILENAME
|
||||||
|
self.records: dict[str, dict] = {}
|
||||||
|
self.settings: dict = {}
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def _key(self, file_path: Path) -> str:
|
||||||
|
"""Pool-relative POSIX key for a file path."""
|
||||||
|
path = Path(file_path)
|
||||||
|
try:
|
||||||
|
return path.relative_to(self.pool_dir).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return path.as_posix()
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
"""Load the index from disk (missing/corrupt index = empty).
|
||||||
|
|
||||||
|
A v1 (path-keyed) index is migrated to v2 in place — the old file is
|
||||||
|
copied to ``.Curator.!index.v1.bak`` first.
|
||||||
|
"""
|
||||||
|
if not self.index_path.exists():
|
||||||
|
self.records = {}
|
||||||
|
self.settings = {}
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(self.index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.records = data.get("movies", {})
|
||||||
|
self.settings = data.get("settings", {})
|
||||||
|
except json.JSONDecodeError, OSError:
|
||||||
|
self.records = {}
|
||||||
|
self.settings = {}
|
||||||
|
return
|
||||||
|
|
||||||
|
if int(data.get("version", 1)) < INDEX_VERSION:
|
||||||
|
self._migrate_v1_to_v2()
|
||||||
|
|
||||||
|
def _migrate_v1_to_v2(self) -> None:
|
||||||
|
"""Re-key path-keyed records into identity-keyed movie records.
|
||||||
|
|
||||||
|
Each v1 record described one file; several of them can describe one
|
||||||
|
movie (the interim multi-version layout put them in a shared folder),
|
||||||
|
so they are merged by identity. Metadata only — nothing is moved on
|
||||||
|
disk, since a v1 movie's files already sit where v2 wants them.
|
||||||
|
"""
|
||||||
|
from .movie import ROLE_VIDEO, identity_key
|
||||||
|
from .naming import is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id
|
||||||
|
|
||||||
|
migrated: dict[str, dict] = {}
|
||||||
|
for rel_path, record in self.records.items():
|
||||||
|
if not isinstance(record, dict) or "media" in record:
|
||||||
|
continue # already v2-shaped, leave it alone
|
||||||
|
path = Path(rel_path)
|
||||||
|
parts = path.parts
|
||||||
|
library = parts[0] if len(parts) > 1 else ""
|
||||||
|
# A file inside its own "Title (rok) [tmdbid]" folder is one
|
||||||
|
# version of that movie; its own stem carries the " - label".
|
||||||
|
parent = path.parent.name
|
||||||
|
grouped = len(parts) > 2 and is_canonical_pool_stem(parent)
|
||||||
|
identity_stem = parent if grouped else path.stem
|
||||||
|
|
||||||
|
parsed = parse_pool_stem(identity_stem)
|
||||||
|
title = record.get("title") or (parsed[0] if parsed else path.stem)
|
||||||
|
year = str(parsed[1]) if parsed else None
|
||||||
|
tmdb_id = parse_tmdb_id(identity_stem)
|
||||||
|
label = ""
|
||||||
|
if grouped and path.stem.startswith(f"{parent} - "):
|
||||||
|
label = path.stem[len(parent) + 3 :]
|
||||||
|
elif grouped:
|
||||||
|
label = path.stem[len(parent) :].lstrip(". -") or ""
|
||||||
|
|
||||||
|
key = identity_key(tmdb_id, title, year)
|
||||||
|
entry = {"role": ROLE_VIDEO, "label": label, "file": rel_path}
|
||||||
|
if key in migrated:
|
||||||
|
migrated[key]["media"].append(entry)
|
||||||
|
continue
|
||||||
|
migrated[key] = {
|
||||||
|
"title": title,
|
||||||
|
"year": year,
|
||||||
|
"tmdb_id": tmdb_id,
|
||||||
|
"library": library,
|
||||||
|
"media": [entry],
|
||||||
|
"tags": record.get("tags", []),
|
||||||
|
"auto_tags": record.get("auto_tags", record.get("csfd_tags", [])),
|
||||||
|
"csfd_url": record.get("csfd_url") or record.get("csfd_link"),
|
||||||
|
"date": record.get("date"),
|
||||||
|
"added": record.get("added"),
|
||||||
|
"attributes": record.get("attributes", {}),
|
||||||
|
}
|
||||||
|
if record.get("meta"):
|
||||||
|
migrated[key]["meta"] = record["meta"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copy2(
|
||||||
|
self.index_path,
|
||||||
|
self.index_path.with_name(self.index_path.name + ".v1.bak"),
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.records = migrated
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Persist the index to disk."""
|
||||||
|
self.pool_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self.index_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"version": INDEX_VERSION,
|
||||||
|
"settings": self.settings,
|
||||||
|
"movies": self.records,
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_setting(self, key: str, default=None):
|
||||||
|
"""Return a library-level setting stored in the index."""
|
||||||
|
return self.settings.get(key, default)
|
||||||
|
|
||||||
|
def set_setting(self, key: str, value) -> None:
|
||||||
|
"""Upsert a library-level setting and persist the index."""
|
||||||
|
self.settings[key] = value
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Movie records (v2 — keyed by identity, own their media files)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_movie(self, key: str) -> dict | None:
|
||||||
|
"""Return the movie record under ``key``, or None."""
|
||||||
|
return self.records.get(key)
|
||||||
|
|
||||||
|
def set_movie(self, key: str, data: dict) -> None:
|
||||||
|
"""Upsert a movie record and persist the index."""
|
||||||
|
self.records[key] = data
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def delete_movie(self, key: str) -> None:
|
||||||
|
"""Remove a movie record (if present) and persist the index."""
|
||||||
|
if self.records.pop(key, None) is not None:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def rekey_movie(self, old_key: str, new_key: str) -> None:
|
||||||
|
"""Move a record to a new identity key (e.g. its TMDb id was set/fixed)."""
|
||||||
|
if old_key == new_key or old_key not in self.records:
|
||||||
|
return
|
||||||
|
self.records[new_key] = self.records.pop(old_key)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def all_movies(self) -> dict[str, dict]:
|
||||||
|
"""Every movie record, keyed by identity."""
|
||||||
|
return dict(self.records)
|
||||||
|
|
||||||
|
def record_for(self, file_path: Path) -> dict | None:
|
||||||
|
"""The movie record that owns ``file_path``, or None if untracked."""
|
||||||
|
found = self.find_movie_by_media(file_path)
|
||||||
|
return found[1] if found is not None else None
|
||||||
|
|
||||||
|
def find_movie_by_media(self, file_path: Path) -> tuple[str, dict] | None:
|
||||||
|
"""``(key, record)`` of the movie owning ``file_path``, or None.
|
||||||
|
|
||||||
|
The reverse lookup a file needs now that records are keyed by identity
|
||||||
|
rather than by path.
|
||||||
|
"""
|
||||||
|
rel = self._key(file_path)
|
||||||
|
for key, record in self.records.items():
|
||||||
|
for media in record.get("media", []):
|
||||||
|
if media.get("file") == rel:
|
||||||
|
return key, record
|
||||||
|
return None
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Series naming + scanning for the Jellyfin TV layout.
|
||||||
|
|
||||||
|
Jellyfin wants series laid out as::
|
||||||
|
|
||||||
|
Seriály/Series (year) [tmdbid-N]/
|
||||||
|
tvshow.nfo (Jellyfin writes it)
|
||||||
|
Season 01/
|
||||||
|
Series (year) S01E01.ext
|
||||||
|
Specials/ (season 0)
|
||||||
|
|
||||||
|
Episodes are matched by ``SxxEyy``. These helpers parse the episode number out of
|
||||||
|
a raw filename, build the canonical episode/season/series names, and scan a
|
||||||
|
series library into a ``Series → Season → Episode`` tree with a naming status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .naming import (
|
||||||
|
canonical_pool_stem,
|
||||||
|
is_canonical_pool_stem,
|
||||||
|
parse_pool_stem,
|
||||||
|
parse_tmdb_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# SxxEyy / s1e2 / 1x02 — the episode marker in a filename. First match wins.
|
||||||
|
_EPISODE_RE = re.compile(
|
||||||
|
r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Jellyfin's own "stacking" tokens for one episode split across several
|
||||||
|
# physical files (an old rip cut into CDs, a two-tape recording, …) — as
|
||||||
|
# opposed to E01-E02, which is *two different* episodes in one file.
|
||||||
|
_PART_TYPES = r"cd|dvd|part|pt|disc|disk"
|
||||||
|
# Scanning a real filename: the token must sit at its own boundary right
|
||||||
|
# before the end of the stem, so a title that happens to contain "cd" or
|
||||||
|
# "part" as part of a word is never misread as a stacking suffix.
|
||||||
|
_PART_RE = re.compile(
|
||||||
|
rf"(?<![A-Za-z0-9])(?:{_PART_TYPES})[\s._-]?(\d{{1,2}})$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
# The compact SxxEyy field a user types by hand (e.g. "S01E05P2") may also use
|
||||||
|
# a bare "P" and no separator at all — safe only because it is matched against
|
||||||
|
# just the tail *after* the recognised SxxEyy, never against a whole filename.
|
||||||
|
_PART_RE_LOOSE = re.compile(
|
||||||
|
rf"[\s._-]?(?:{_PART_TYPES}|p)[\s._-]?(\d{{1,2}})$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Files next to an episode that are not video (NFO / artwork / subtitles).
|
||||||
|
_SIDECARS = {
|
||||||
|
".nfo",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".png",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".gif",
|
||||||
|
".tbn",
|
||||||
|
".srt",
|
||||||
|
".ass",
|
||||||
|
".ssa",
|
||||||
|
".sub",
|
||||||
|
".idx",
|
||||||
|
".vtt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Episode:
|
||||||
|
path: Path
|
||||||
|
season: int | None
|
||||||
|
episode: int | None
|
||||||
|
# Which physical part of the episode this file is (Jellyfin stacking,
|
||||||
|
# e.g. an old recording split into two files) — None for a whole episode.
|
||||||
|
part: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def code(self) -> str:
|
||||||
|
if self.season is None or self.episode is None:
|
||||||
|
return "?"
|
||||||
|
base = f"S{self.season:02d}E{self.episode:02d}"
|
||||||
|
return f"{base}.pt{self.part}" if self.part else base
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Series:
|
||||||
|
folder: Path
|
||||||
|
title: str
|
||||||
|
year: str | None
|
||||||
|
tmdb_id: int | None
|
||||||
|
library: str
|
||||||
|
episodes: list[Episode] = field(default_factory=list)
|
||||||
|
|
||||||
|
def seasons(self) -> dict[int, list[Episode]]:
|
||||||
|
"""Episodes grouped by season number (``-1`` = unrecognised)."""
|
||||||
|
grouped: dict[int, list[Episode]] = {}
|
||||||
|
for ep in self.episodes:
|
||||||
|
grouped.setdefault(ep.season if ep.season is not None else -1, []).append(
|
||||||
|
ep
|
||||||
|
)
|
||||||
|
for eps in grouped.values():
|
||||||
|
eps.sort(key=lambda e: e.episode if e.episode is not None else 1_000_000)
|
||||||
|
return dict(sorted(grouped.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_episode(name: str) -> tuple[int, int] | None:
|
||||||
|
"""Extract ``(season, episode)`` from a filename, or ``None``."""
|
||||||
|
match = _EPISODE_RE.search(name)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
if match.group(1) is not None:
|
||||||
|
return int(match.group(1)), int(match.group(2))
|
||||||
|
return int(match.group(3)), int(match.group(4))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_episode_part(name: str) -> int | None:
|
||||||
|
"""Part number implied by a Jellyfin stacking suffix in a filename, or ``None``.
|
||||||
|
|
||||||
|
Looks for *cd*, *dvd*, *part*, *pt*, *disc* or *disk* + a number sitting
|
||||||
|
right before the extension (``Show S01E02.pt2.mkv``,
|
||||||
|
``Show S01E02-part-2.mkv``, …) — the same tokens Jellyfin itself scans for.
|
||||||
|
"""
|
||||||
|
match = _PART_RE.search(Path(name).stem)
|
||||||
|
return int(match.group(1)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_episode_code(text: str) -> tuple[int, int, int | None] | None:
|
||||||
|
"""Parse a compact user-typed code: ``SxxEyy``, optionally + a part.
|
||||||
|
|
||||||
|
Accepts everything :func:`parse_episode` does, plus a trailing part marker
|
||||||
|
right after it — ``S01E02P3``, ``S01E02 pt3``, ``S01E02.part.3``,
|
||||||
|
``S01E02 cd2`` — for a file that is only *one part* of that episode.
|
||||||
|
Returns ``(season, episode, part)`` with ``part`` ``None`` when absent, or
|
||||||
|
``None`` when no ``SxxEyy`` is found at all.
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
match = _EPISODE_RE.search(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
if match.group(1) is not None:
|
||||||
|
season, episode = int(match.group(1)), int(match.group(2))
|
||||||
|
else:
|
||||||
|
season, episode = int(match.group(3)), int(match.group(4))
|
||||||
|
part_match = _PART_RE_LOOSE.match(text[match.end() :])
|
||||||
|
return season, episode, int(part_match.group(1)) if part_match else None
|
||||||
|
|
||||||
|
|
||||||
|
def collect_episode_files(folder: Path) -> list[Path]:
|
||||||
|
"""Every video file under ``folder`` (recursively), sidecars excluded, sorted.
|
||||||
|
|
||||||
|
Lets the episode-import dialog take a whole ``Series/Season NN/`` tree (or any
|
||||||
|
nested layout) in one pick instead of file-by-file. Season/episode numbers
|
||||||
|
are still read per file from its name by :func:`parse_episode`.
|
||||||
|
"""
|
||||||
|
folder = Path(folder)
|
||||||
|
if not folder.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
f
|
||||||
|
for f in folder.rglob("*")
|
||||||
|
if f.is_file() and f.suffix.lower() not in _SIDECARS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def season_folder_name(season: int) -> str:
|
||||||
|
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
|
||||||
|
return "Specials" if season == 0 else f"Season {season:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_season_folder(name: str) -> bool:
|
||||||
|
"""True for a season folder name — ``Specials`` or ``Season <n>`` (any padding)."""
|
||||||
|
return name == "Specials" or re.fullmatch(r"Season \d{1,3}", name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_series_folder(folder: Path) -> bool:
|
||||||
|
"""True when ``folder`` holds at least one ``Season NN``/``Specials`` subfolder.
|
||||||
|
|
||||||
|
That's the shape Curator always gives a series (episodes always land in a
|
||||||
|
season folder, never loose at the series root) — a movie's version folder
|
||||||
|
never has one, its files sit flat inside it. This is how a ``mixed``
|
||||||
|
library tells the two apart even though both use the identical outer
|
||||||
|
``Title (rok) [tmdbid]/`` name: :meth:`FileManager.is_movie_group_member`
|
||||||
|
and :func:`scan_series` both check it before deciding which one a
|
||||||
|
canonical-shaped subfolder is.
|
||||||
|
"""
|
||||||
|
folder = Path(folder)
|
||||||
|
if not folder.is_dir():
|
||||||
|
return False
|
||||||
|
return any(p.is_dir() and is_season_folder(p.name) for p in folder.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
# Characters not allowed in a filename on the common filesystems.
|
||||||
|
_FS_UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_title(title: str) -> str:
|
||||||
|
"""A series/episode title made safe to sit in a filename (spaces collapsed)."""
|
||||||
|
cleaned = _FS_UNSAFE.sub("", title or "")
|
||||||
|
return re.sub(r"\s+", " ", cleaned).strip(" .")
|
||||||
|
|
||||||
|
|
||||||
|
def episode_filename(
|
||||||
|
season: int,
|
||||||
|
episode: int,
|
||||||
|
series_title: str,
|
||||||
|
ext: str,
|
||||||
|
*,
|
||||||
|
part: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Canonical episode filename ``SxxEyy - {series title}.ext``.
|
||||||
|
|
||||||
|
The release string is deliberately **not** kept — identity is the ``SxxEyy``
|
||||||
|
code, and Jellyfin fetches the episode title/metadata from TMDb (matched by
|
||||||
|
the code + the series folder's ``[tmdbid-N]``). An empty title collapses to
|
||||||
|
``SxxEyy.ext``. ``part`` marks one physical part of that episode — an old
|
||||||
|
recording split across several files — with Jellyfin's own ``.ptN``
|
||||||
|
stacking suffix right before the extension.
|
||||||
|
"""
|
||||||
|
code = f"S{season:02d}E{episode:02d}"
|
||||||
|
body = sanitize_title(series_title)
|
||||||
|
name = f"{code} - {body}" if body else code
|
||||||
|
if part:
|
||||||
|
name += f".pt{part}"
|
||||||
|
return f"{name}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def series_folder_stem(title: str, year: object = None, tmdb_id: object = None) -> str:
|
||||||
|
"""Series folder name: ``Title (year) [tmdbid-N]`` (year/id optional).
|
||||||
|
|
||||||
|
Reuses the movie convention when a year is present; otherwise falls back to
|
||||||
|
``Title [tmdbid-N]`` or just ``Title`` so a year-less series still works.
|
||||||
|
"""
|
||||||
|
title = (title or "").strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Název seriálu nesmí být prázdný.")
|
||||||
|
year_s = str(year).strip() if year is not None else ""
|
||||||
|
if re.fullmatch(r"\d{4}", year_s):
|
||||||
|
return canonical_pool_stem(title, year_s, tmdb_id)
|
||||||
|
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
|
||||||
|
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
|
||||||
|
|
||||||
|
|
||||||
|
def is_canonical_episode(path: Path, series_title: str) -> bool:
|
||||||
|
"""True when ``path`` is already ``SxxEyy - {series title}.ext`` in its season."""
|
||||||
|
parsed = parse_episode(path.name)
|
||||||
|
if parsed is None:
|
||||||
|
return False
|
||||||
|
season, episode = parsed
|
||||||
|
part = parse_episode_part(path.name)
|
||||||
|
expected = episode_filename(season, episode, series_title, path.suffix, part=part)
|
||||||
|
if path.name != expected:
|
||||||
|
return False
|
||||||
|
return path.parent.name == season_folder_name(season)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_series(
|
||||||
|
lib_dir: Path,
|
||||||
|
library: str,
|
||||||
|
*,
|
||||||
|
skip_movie_folders: bool = False,
|
||||||
|
) -> list[Series]:
|
||||||
|
"""Scan one series library into a list of :class:`Series`.
|
||||||
|
|
||||||
|
Each immediate subfolder of ``lib_dir`` is a series; its name yields the
|
||||||
|
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
|
||||||
|
parsed from the filename). Non-video sidecars are skipped.
|
||||||
|
|
||||||
|
``skip_movie_folders=True`` (a ``mixed`` library, where a movie's version
|
||||||
|
folder and a series folder both use the identical
|
||||||
|
``Title (rok) [tmdbid]/`` shape) additionally skips a canonical-shaped
|
||||||
|
folder with no ``Season NN``/``Specials`` inside it — that's a movie's
|
||||||
|
version folder instead, left for :meth:`FileManager.load_pool_movies` to
|
||||||
|
read. Left ``False`` (the default, a plain ``series``-kind library, which
|
||||||
|
never holds movies) every subfolder is read as a series regardless of
|
||||||
|
shape — a raw, not-yet-renamed import may have loose episode files
|
||||||
|
sitting at the series root, with no season folder yet.
|
||||||
|
"""
|
||||||
|
lib_dir = Path(lib_dir)
|
||||||
|
if not lib_dir.is_dir():
|
||||||
|
return []
|
||||||
|
series_list: list[Series] = []
|
||||||
|
for folder in sorted(p for p in lib_dir.iterdir() if p.is_dir()):
|
||||||
|
if (
|
||||||
|
skip_movie_folders
|
||||||
|
and is_canonical_pool_stem(folder.name)
|
||||||
|
and not looks_like_series_folder(folder)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
parsed = parse_pool_stem(folder.name)
|
||||||
|
if parsed is not None:
|
||||||
|
title, year = parsed[0], str(parsed[1])
|
||||||
|
else:
|
||||||
|
title, year = folder.name, None
|
||||||
|
tmdb_id = parse_tmdb_id(folder.name)
|
||||||
|
|
||||||
|
episodes: list[Episode] = []
|
||||||
|
for f in sorted(folder.rglob("*")):
|
||||||
|
if not f.is_file() or f.suffix.lower() in _SIDECARS:
|
||||||
|
continue
|
||||||
|
code = parse_episode(f.name)
|
||||||
|
episodes.append(
|
||||||
|
Episode(
|
||||||
|
f,
|
||||||
|
code[0] if code else None,
|
||||||
|
code[1] if code else None,
|
||||||
|
parse_episode_part(f.name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
series_list.append(Series(folder, title, year, tmdb_id, library, episodes))
|
||||||
|
return series_list
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""External subtitle sidecars — attach, discover, keep in sync on rename.
|
||||||
|
|
||||||
|
Curator does not mux/embed subtitles. Jellyfin plays an **external** subtitle
|
||||||
|
file natively as long as it shares the video's filename stem, with an optional
|
||||||
|
language code and flag suffix::
|
||||||
|
|
||||||
|
Title (rok) [tmdbid-N].mkv
|
||||||
|
Title (rok) [tmdbid-N].cs.srt # language
|
||||||
|
Title (rok) [tmdbid-N].cs.forced.srt # + a flag (forced / sdh / default)
|
||||||
|
|
||||||
|
Subtitle presence is a **filesystem fact**, like NFO — nothing is cached in the
|
||||||
|
pool index. What Curator does own: copying a picked file into place with the
|
||||||
|
right name (:func:`attach_subtitle`), and moving a subtitle along whenever its
|
||||||
|
video gets renamed so it never silently detaches (:func:`rename_subtitles_with`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".sub", ".vtt"}
|
||||||
|
_FLAGS = {"forced", "sdh", "default"}
|
||||||
|
_LANG_RE = re.compile(r"[a-z]{2,3}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SubtitleFile:
|
||||||
|
"""One subtitle sidecar found next to a video."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
language: str | None = None # ISO 639-1/2 code, e.g. "cs"
|
||||||
|
flag: str | None = None # "forced" | "sdh" | "default"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
"""Human-readable ``jazyk (flag)`` for display, e.g. ``cs (forced)``."""
|
||||||
|
text = self.language or "?"
|
||||||
|
return f"{text} ({self.flag})" if self.flag else text
|
||||||
|
|
||||||
|
|
||||||
|
def subtitle_filename(
|
||||||
|
video_stem: str,
|
||||||
|
ext: str,
|
||||||
|
language: str | None = None,
|
||||||
|
flag: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Canonical subtitle filename: ``{video_stem}[.language][.flag]{ext}``."""
|
||||||
|
parts = [video_stem]
|
||||||
|
if language:
|
||||||
|
parts.append(language.strip().lower())
|
||||||
|
if flag:
|
||||||
|
parts.append(flag.strip().lower())
|
||||||
|
return ".".join(parts) + ext.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_subtitle_name(
|
||||||
|
video_stem: str, filename: str
|
||||||
|
) -> tuple[str | None, str | None] | None:
|
||||||
|
"""``(language, flag)`` if ``filename`` is a subtitle sidecar of ``video_stem``."""
|
||||||
|
candidate = Path(filename)
|
||||||
|
if candidate.suffix.lower() not in SUBTITLE_EXTENSIONS:
|
||||||
|
return None
|
||||||
|
stem = candidate.stem
|
||||||
|
if stem == video_stem:
|
||||||
|
return None, None
|
||||||
|
if not stem.startswith(f"{video_stem}."):
|
||||||
|
return None
|
||||||
|
tokens = [t for t in stem[len(video_stem) + 1 :].split(".") if t]
|
||||||
|
if not tokens:
|
||||||
|
return None, None
|
||||||
|
language: str | None = None
|
||||||
|
flag: str | None = None
|
||||||
|
for token in tokens:
|
||||||
|
low = token.lower()
|
||||||
|
if low in _FLAGS:
|
||||||
|
flag = low
|
||||||
|
elif language is None and _LANG_RE.fullmatch(low):
|
||||||
|
language = low
|
||||||
|
return language, flag
|
||||||
|
|
||||||
|
|
||||||
|
def find_subtitles(video_path: Path) -> list[SubtitleFile]:
|
||||||
|
"""Every external subtitle sidecar sitting next to ``video_path``, sorted."""
|
||||||
|
video_path = Path(video_path)
|
||||||
|
folder = video_path.parent
|
||||||
|
if not folder.is_dir():
|
||||||
|
return []
|
||||||
|
found: list[SubtitleFile] = []
|
||||||
|
for f in sorted(folder.iterdir()):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
parsed = _parse_subtitle_name(video_path.stem, f.name)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
language, flag = parsed
|
||||||
|
found.append(SubtitleFile(f, language, flag))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def attach_subtitle(
|
||||||
|
video_path: Path,
|
||||||
|
source: Path,
|
||||||
|
*,
|
||||||
|
language: str | None = None,
|
||||||
|
flag: str | None = None,
|
||||||
|
move: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""Copy (or move) ``source`` next to ``video_path``, named to match it.
|
||||||
|
|
||||||
|
An existing subtitle already at that exact name (e.g. replacing the Czech
|
||||||
|
track) is overwritten; nothing else next to the video is touched.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: ``source``'s extension isn't a recognised subtitle format.
|
||||||
|
"""
|
||||||
|
video_path = Path(video_path)
|
||||||
|
source = Path(source)
|
||||||
|
ext = source.suffix.lower()
|
||||||
|
if ext not in SUBTITLE_EXTENSIONS:
|
||||||
|
raise ValueError(f"Nepodporovaný formát titulků: {source.suffix}")
|
||||||
|
target = video_path.parent / subtitle_filename(video_path.stem, ext, language, flag)
|
||||||
|
if target.exists():
|
||||||
|
target.unlink()
|
||||||
|
if move:
|
||||||
|
shutil.move(str(source), str(target))
|
||||||
|
else:
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def rename_subtitles_with(old_video_path: Path, new_video_path: Path) -> list[Path]:
|
||||||
|
"""Move every subtitle sidecar of ``old_video_path`` to match ``new_video_path``.
|
||||||
|
|
||||||
|
Call this wherever a video gets renamed/moved so an attached subtitle never
|
||||||
|
silently detaches (its stem would stop matching and Jellyfin would drop it).
|
||||||
|
No-op when the video's path didn't actually change. Returns the new paths.
|
||||||
|
"""
|
||||||
|
old_video_path = Path(old_video_path)
|
||||||
|
new_video_path = Path(new_video_path)
|
||||||
|
if old_video_path == new_video_path:
|
||||||
|
return []
|
||||||
|
moved: list[Path] = []
|
||||||
|
for sub in find_subtitles(old_video_path):
|
||||||
|
target = new_video_path.parent / subtitle_filename(
|
||||||
|
new_video_path.stem, sub.path.suffix, sub.language, sub.flag
|
||||||
|
)
|
||||||
|
if sub.path == target:
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.exists():
|
||||||
|
target.unlink()
|
||||||
|
sub.path.rename(target)
|
||||||
|
moved.append(target)
|
||||||
|
return moved
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
class Tag:
|
||||||
|
def __init__(self, category: str, name: str):
|
||||||
|
self.category = category
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_path(self):
|
||||||
|
return f"{self.category}/{self.name}"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.full_path
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"Tag({self.full_path})"
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if isinstance(other, Tag):
|
||||||
|
return self.category == other.category and self.name == other.name
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash((self.category, self.name))
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from .tag import Tag
|
||||||
|
|
||||||
|
# Default tags that are always available (order in list = display order).
|
||||||
|
# The legacy Tagger presets (Hodnocení / Barva) were removed for Curator; the
|
||||||
|
# pool is driven by ČSFD-derived tags (Žánr / Rok / Země původu). Add entries here to
|
||||||
|
# reintroduce always-available predefined tags.
|
||||||
|
DEFAULT_TAGS: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
# Tag sort order for default categories (preserves display order)
|
||||||
|
DEFAULT_TAG_ORDER: dict[str, dict[str, int]] = {
|
||||||
|
category: {name: i for i, name in enumerate(names)}
|
||||||
|
for category, names in DEFAULT_TAGS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TagManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.tags_by_category = {} # {category: set(Tag)}
|
||||||
|
self._init_default_tags()
|
||||||
|
|
||||||
|
def _init_default_tags(self):
|
||||||
|
"""Initialize default tags (ratings and colors)"""
|
||||||
|
for category, tags in DEFAULT_TAGS.items():
|
||||||
|
for tag_name in tags:
|
||||||
|
self.add_tag(category, tag_name)
|
||||||
|
|
||||||
|
def add_category(self, category: str):
|
||||||
|
if category not in self.tags_by_category:
|
||||||
|
self.tags_by_category[category] = set()
|
||||||
|
|
||||||
|
def remove_category(self, category: str):
|
||||||
|
if category in self.tags_by_category:
|
||||||
|
del self.tags_by_category[category]
|
||||||
|
|
||||||
|
def add_tag(self, category: str, name: str) -> Tag:
|
||||||
|
self.add_category(category)
|
||||||
|
tag = Tag(category, name)
|
||||||
|
self.tags_by_category[category].add(tag)
|
||||||
|
return tag
|
||||||
|
|
||||||
|
def remove_tag(self, category: str, name: str):
|
||||||
|
if category in self.tags_by_category:
|
||||||
|
tag = Tag(category, name)
|
||||||
|
self.tags_by_category[category].discard(tag)
|
||||||
|
if not self.tags_by_category[category]:
|
||||||
|
self.remove_category(category)
|
||||||
|
|
||||||
|
def get_all_tags(self):
|
||||||
|
"""Vrací list všech tagů full_path"""
|
||||||
|
return [
|
||||||
|
tag.full_path for tags in self.tags_by_category.values() for tag in tags
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_categories(self):
|
||||||
|
return list(self.tags_by_category.keys())
|
||||||
|
|
||||||
|
def get_tags_in_category(self, category: str) -> list[Tag]:
|
||||||
|
"""Get tags in category, sorted by predefined order for default categories"""
|
||||||
|
tags = list(self.tags_by_category.get(category, []))
|
||||||
|
|
||||||
|
# Use predefined order for default categories
|
||||||
|
if category in DEFAULT_TAG_ORDER:
|
||||||
|
order = DEFAULT_TAG_ORDER[category]
|
||||||
|
tags.sort(key=lambda t: order.get(t.name, 999))
|
||||||
|
else:
|
||||||
|
# Sort alphabetically for custom categories
|
||||||
|
tags.sort(key=lambda t: t.name)
|
||||||
|
|
||||||
|
return tags
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""TMDb (The Movie Database) client — Curator's identity source.
|
||||||
|
|
||||||
|
Replaces ČSFD. Its only job is to turn a **TMDb id** (or a title/year search)
|
||||||
|
into a canonical **title + year** for naming the pool file. The rich metadata
|
||||||
|
and artwork are fetched by **Jellyfin itself** — Curator does not need them here.
|
||||||
|
|
||||||
|
Needs a free API key in ``TMDB_API_KEY`` (loaded from ``.env``). Everything
|
||||||
|
degrades gracefully when the key or the network is missing: :func:`api_available`
|
||||||
|
reports readiness and the lookups raise :class:`TmdbError` with a clear message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# optional — the key can also come from the real environment; dotenv is a
|
||||||
|
# convenience, never required
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
_BASE = "https://api.themoviedb.org/3"
|
||||||
|
_LANG = os.getenv("TMDB_LANGUAGE", "cs-CZ")
|
||||||
|
_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TmdbResult:
|
||||||
|
"""A resolved TMDb entry — just enough to name the pool file."""
|
||||||
|
|
||||||
|
tmdb_id: int
|
||||||
|
title: str
|
||||||
|
year: str | None # four-digit string, or None when TMDb has no date
|
||||||
|
kind: str # "movie" | "tv"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TmdbDetails:
|
||||||
|
"""Full TMDb data used to (re)generate a movie's tags (genres etc.).
|
||||||
|
|
||||||
|
Exposes :meth:`field_values` so it plugs into the tag schema the same way the
|
||||||
|
NFO reader does (fields: genres / countries / year / rating).
|
||||||
|
"""
|
||||||
|
|
||||||
|
tmdb_id: int
|
||||||
|
title: str
|
||||||
|
year: str | None
|
||||||
|
genres: list[str]
|
||||||
|
countries: list[str]
|
||||||
|
rating: int | None # 0–100 (TMDb vote_average 0–10 scaled up)
|
||||||
|
|
||||||
|
def field_values(self, name: str) -> list[str]:
|
||||||
|
if name == "genres":
|
||||||
|
return list(self.genres)
|
||||||
|
if name == "countries":
|
||||||
|
return list(self.countries)
|
||||||
|
if name == "year":
|
||||||
|
return [self.year] if self.year else []
|
||||||
|
if name == "rating":
|
||||||
|
return [str(self.rating)] if self.rating is not None else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class TmdbError(RuntimeError):
|
||||||
|
"""A TMDb request could not be completed (no key, network, bad status)."""
|
||||||
|
|
||||||
|
|
||||||
|
def api_key() -> str | None:
|
||||||
|
"""The configured TMDb API key, or None."""
|
||||||
|
key = os.getenv("TMDB_API_KEY")
|
||||||
|
return key.strip() if key and key.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def api_available() -> bool:
|
||||||
|
"""True when a TMDb API key is configured."""
|
||||||
|
return api_key() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path: str, params: dict) -> dict:
|
||||||
|
key = api_key()
|
||||||
|
if not key:
|
||||||
|
raise TmdbError("TMDB_API_KEY není nastaven (.env).")
|
||||||
|
query = {"api_key": key, "language": _LANG, **params}
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{_BASE}{path}", params=query, timeout=_TIMEOUT)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise TmdbError(f"Síťová chyba TMDb: {exc}") from exc
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise TmdbError("Neplatný TMDB_API_KEY.")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise TmdbError("TMDb ID nenalezeno.")
|
||||||
|
if not resp.ok:
|
||||||
|
raise TmdbError(f"TMDb odpověděl HTTP {resp.status_code}.")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _year_of(date_str: str | None) -> str | None:
|
||||||
|
if date_str and len(date_str) >= 4 and date_str[:4].isdigit():
|
||||||
|
return date_str[:4]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_movie(tmdb_id: int) -> TmdbResult | None:
|
||||||
|
"""Resolve a movie by its TMDb id → title + year, or None if it has no title."""
|
||||||
|
data = _get(f"/movie/{int(tmdb_id)}", {})
|
||||||
|
title = data.get("title") or data.get("original_title")
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
return TmdbResult(
|
||||||
|
int(data["id"]), title, _year_of(data.get("release_date")), "movie"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def movie_details(tmdb_id: int) -> TmdbDetails:
|
||||||
|
"""Full movie data from TMDb (genres/countries/rating/year), localized to cs."""
|
||||||
|
data = _get(f"/movie/{int(tmdb_id)}", {})
|
||||||
|
title = data.get("title") or data.get("original_title") or ""
|
||||||
|
genres = [g["name"] for g in (data.get("genres") or []) if g.get("name")]
|
||||||
|
countries = [
|
||||||
|
c["name"] for c in (data.get("production_countries") or []) if c.get("name")
|
||||||
|
]
|
||||||
|
vote = data.get("vote_average")
|
||||||
|
rating = round(float(vote) * 10) if vote else None
|
||||||
|
return TmdbDetails(
|
||||||
|
tmdb_id=int(data["id"]),
|
||||||
|
title=title,
|
||||||
|
year=_year_of(data.get("release_date")),
|
||||||
|
genres=genres,
|
||||||
|
countries=countries,
|
||||||
|
rating=rating,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_tv(tmdb_id: int) -> TmdbResult | None:
|
||||||
|
"""Resolve a series by its TMDb id → name + first-air year, or None."""
|
||||||
|
data = _get(f"/tv/{int(tmdb_id)}", {})
|
||||||
|
title = data.get("name") or data.get("original_name")
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
return TmdbResult(
|
||||||
|
int(data["id"]), title, _year_of(data.get("first_air_date")), "tv"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(tmdb_id: int, kind: str = "movie") -> TmdbResult | None:
|
||||||
|
"""Resolve a TMDb id as a movie (default) or a series (``kind="tv"``)."""
|
||||||
|
return lookup_tv(tmdb_id) if kind == "tv" else lookup_movie(tmdb_id)
|
||||||
|
|
||||||
|
|
||||||
|
def search_movie(
|
||||||
|
query: str, year: str | None = None, limit: int = 5
|
||||||
|
) -> list[TmdbResult]:
|
||||||
|
"""Search movies by title (optionally narrowed by year); best matches first."""
|
||||||
|
if not query.strip():
|
||||||
|
return []
|
||||||
|
params: dict = {"query": query.strip()}
|
||||||
|
if year:
|
||||||
|
params["year"] = year
|
||||||
|
data = _get("/search/movie", params)
|
||||||
|
results: list[TmdbResult] = []
|
||||||
|
for item in (data.get("results") or [])[:limit]:
|
||||||
|
title = item.get("title") or item.get("original_title")
|
||||||
|
if title and item.get("id"):
|
||||||
|
results.append(
|
||||||
|
TmdbResult(
|
||||||
|
int(item["id"]), title, _year_of(item.get("release_date")), "movie"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def search_tv(query: str, year: str | None = None, limit: int = 5) -> list[TmdbResult]:
|
||||||
|
"""Search series by name (optionally narrowed by first-air year)."""
|
||||||
|
if not query.strip():
|
||||||
|
return []
|
||||||
|
params: dict = {"query": query.strip()}
|
||||||
|
if year:
|
||||||
|
params["first_air_date_year"] = year
|
||||||
|
data = _get("/search/tv", params)
|
||||||
|
results: list[TmdbResult] = []
|
||||||
|
for item in (data.get("results") or [])[:limit]:
|
||||||
|
title = item.get("name") or item.get("original_name")
|
||||||
|
if title and item.get("id"):
|
||||||
|
results.append(
|
||||||
|
TmdbResult(
|
||||||
|
int(item["id"]), title, _year_of(item.get("first_air_date")), "tv"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tag-schema value transforms for miniDLNA folder names.
|
||||||
|
|
||||||
|
Applied when building a folder name from a tag value — the tag keeps its exact
|
||||||
|
value (e.g. rating 87 → tag "Hodnocení/87"), while the folder groups it
|
||||||
|
(→ "…/80–89 %"). Config-driven and extensible via ``TAG_TRANSFORMS``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def rating_band(rating: int) -> str:
|
||||||
|
"""Bucket a 0–100 rating into a ten-point band label (e.g. "80–89 %").
|
||||||
|
|
||||||
|
The top bucket spans 90–100 % so a perfect 100 still lands in a band.
|
||||||
|
"""
|
||||||
|
low = min((rating // 10) * 10, 90)
|
||||||
|
high = 100 if low == 90 else low + 9
|
||||||
|
return f"{low}–{high} %"
|
||||||
|
|
||||||
|
|
||||||
|
def _decade_band(value) -> str:
|
||||||
|
"""``decade_band`` transform: pull the leading integer and bucket it."""
|
||||||
|
match = re.search(r"\d+", str(value))
|
||||||
|
return rating_band(int(match.group())) if match else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
# Named value transforms usable from the tag schema (config-driven, extensible).
|
||||||
|
TAG_TRANSFORMS = {
|
||||||
|
None: str,
|
||||||
|
"identity": str,
|
||||||
|
"decade_band": _decade_band,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_transform(value: str, transform: str | None) -> str:
|
||||||
|
"""Apply a named tag-schema transform to a value, returning a string."""
|
||||||
|
fn = TAG_TRANSFORMS.get(transform, str)
|
||||||
|
try:
|
||||||
|
return str(fn(value))
|
||||||
|
except ValueError, TypeError, AttributeError:
|
||||||
|
return str(value)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def list_files(folder_path: str | Path) -> list[Path]:
|
||||||
|
folder = Path(folder_path)
|
||||||
|
if not folder.is_dir():
|
||||||
|
raise NotADirectoryError(f"{folder} není platná složka.")
|
||||||
|
return [file_path for file_path in folder.rglob("*") if file_path.is_file()]
|
||||||
|
After Width: | Height: | Size: 596 B |
|
After Width: | Height: | Size: 892 B |
|
After Width: | Height: | Size: 618 B |
|
After Width: | Height: | Size: 961 B |
|
After Width: | Height: | Size: 710 B |
|
After Width: | Height: | Size: 716 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
# Tests package
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Konfigurace pytest - sdílené fixtures a nastavení pro všechny testy
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def session_temp_dir():
|
||||||
|
"""Session-wide dočasný adresář"""
|
||||||
|
temp_dir = Path(tempfile.mkdtemp())
|
||||||
|
yield temp_dir
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def cleanup_config_files():
|
||||||
|
"""Automaticky vyčistí config.json soubory po každém testu"""
|
||||||
|
yield
|
||||||
|
# Cleanup po testu
|
||||||
|
config_file = Path("config.json")
|
||||||
|
if config_file.exists():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
config_file.unlink()
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.config import (
|
||||||
|
DEFAULT_GLOBAL_CONFIG,
|
||||||
|
load_config, # Legacy functions
|
||||||
|
load_global_config,
|
||||||
|
save_config,
|
||||||
|
save_global_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalConfig:
|
||||||
|
"""Testy pro globální config"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_global_config(self, tmp_path, monkeypatch):
|
||||||
|
"""Fixture pro dočasný globální config soubor"""
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
import src.core.config as config_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
def test_default_global_config_structure(self):
|
||||||
|
"""Test struktury defaultní globální konfigurace (jen data aplikace)"""
|
||||||
|
assert "window_geometry" in DEFAULT_GLOBAL_CONFIG
|
||||||
|
assert "window_maximized" in DEFAULT_GLOBAL_CONFIG
|
||||||
|
assert "last_folder" in DEFAULT_GLOBAL_CONFIG
|
||||||
|
assert "sidebar_width" in DEFAULT_GLOBAL_CONFIG
|
||||||
|
assert "recent_folders" in DEFAULT_GLOBAL_CONFIG
|
||||||
|
assert "pool_dir" in DEFAULT_GLOBAL_CONFIG
|
||||||
|
assert "filmoteka_dir" in DEFAULT_GLOBAL_CONFIG
|
||||||
|
assert DEFAULT_GLOBAL_CONFIG["window_geometry"] == "1200x800"
|
||||||
|
assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False
|
||||||
|
assert DEFAULT_GLOBAL_CONFIG["last_folder"] is None
|
||||||
|
|
||||||
|
def test_global_config_holds_no_library_settings(self):
|
||||||
|
"""Library settings (tag_schema / copyasis_folders) žijí v indexu, ne tady."""
|
||||||
|
assert "tag_schema" not in DEFAULT_GLOBAL_CONFIG
|
||||||
|
assert "copyasis_folders" not in DEFAULT_GLOBAL_CONFIG
|
||||||
|
|
||||||
|
def test_load_global_config_nonexistent_file(self, temp_global_config):
|
||||||
|
"""Test načtení globální konfigurace když soubor neexistuje"""
|
||||||
|
config = load_global_config()
|
||||||
|
assert config == DEFAULT_GLOBAL_CONFIG
|
||||||
|
|
||||||
|
def test_save_global_config(self, temp_global_config):
|
||||||
|
"""Test uložení globální konfigurace"""
|
||||||
|
test_config = {
|
||||||
|
"window_geometry": "800x600",
|
||||||
|
"window_maximized": True,
|
||||||
|
"last_folder": "/home/user/documents",
|
||||||
|
"sidebar_width": 300,
|
||||||
|
"recent_folders": ["/path1", "/path2"],
|
||||||
|
}
|
||||||
|
|
||||||
|
save_global_config(test_config)
|
||||||
|
|
||||||
|
assert temp_global_config.exists()
|
||||||
|
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||||
|
saved_data = json.load(f)
|
||||||
|
assert saved_data == test_config
|
||||||
|
|
||||||
|
def test_load_global_config_existing_file(self, temp_global_config):
|
||||||
|
"""Test načtení existující globální konfigurace"""
|
||||||
|
test_config = {
|
||||||
|
"window_geometry": "1920x1080",
|
||||||
|
"window_maximized": False,
|
||||||
|
"last_folder": "/test/path",
|
||||||
|
"sidebar_width": 250,
|
||||||
|
"recent_folders": [],
|
||||||
|
"pool_dir": None,
|
||||||
|
"filmoteka_dir": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_global_config(test_config)
|
||||||
|
loaded_config = load_global_config()
|
||||||
|
|
||||||
|
assert loaded_config == test_config
|
||||||
|
|
||||||
|
def test_load_global_config_merges_defaults(self, temp_global_config):
|
||||||
|
"""Test že chybějící klíče jsou doplněny z defaultů"""
|
||||||
|
partial_config = {"window_geometry": "800x600"}
|
||||||
|
|
||||||
|
with open(temp_global_config, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(partial_config, f)
|
||||||
|
|
||||||
|
loaded = load_global_config()
|
||||||
|
assert loaded["window_geometry"] == "800x600"
|
||||||
|
assert loaded["window_maximized"] == DEFAULT_GLOBAL_CONFIG["window_maximized"]
|
||||||
|
assert loaded["sidebar_width"] == DEFAULT_GLOBAL_CONFIG["sidebar_width"]
|
||||||
|
|
||||||
|
def test_global_config_corrupted_file(self, temp_global_config):
|
||||||
|
"""Test načtení poškozeného global config souboru"""
|
||||||
|
with open(temp_global_config, "w") as f:
|
||||||
|
f.write("{ invalid json }")
|
||||||
|
|
||||||
|
config = load_global_config()
|
||||||
|
assert config == DEFAULT_GLOBAL_CONFIG
|
||||||
|
|
||||||
|
def test_global_config_utf8_encoding(self, temp_global_config):
|
||||||
|
"""Test UTF-8 encoding s českými znaky"""
|
||||||
|
test_config = {
|
||||||
|
**DEFAULT_GLOBAL_CONFIG,
|
||||||
|
"last_folder": "/cesta/s/českými/znaky",
|
||||||
|
"recent_folders": ["/složka/čeština"],
|
||||||
|
}
|
||||||
|
|
||||||
|
save_global_config(test_config)
|
||||||
|
loaded_config = load_global_config()
|
||||||
|
|
||||||
|
assert loaded_config["last_folder"] == "/cesta/s/českými/znaky"
|
||||||
|
assert loaded_config["recent_folders"] == ["/složka/čeština"]
|
||||||
|
|
||||||
|
def test_global_config_returns_new_dict(self, temp_global_config):
|
||||||
|
"""Test že load_global_config vrací nový dictionary"""
|
||||||
|
config1 = load_global_config()
|
||||||
|
config2 = load_global_config()
|
||||||
|
|
||||||
|
assert config1 is not config2
|
||||||
|
assert config1 == config2
|
||||||
|
|
||||||
|
def test_global_config_recent_folders(self, temp_global_config):
|
||||||
|
"""Test ukládání recent_folders"""
|
||||||
|
folders = ["/path/one", "/path/two", "/path/three"]
|
||||||
|
test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders}
|
||||||
|
|
||||||
|
save_global_config(test_config)
|
||||||
|
loaded = load_global_config()
|
||||||
|
|
||||||
|
assert loaded["recent_folders"] == folders
|
||||||
|
assert len(loaded["recent_folders"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestLegacyFunctions:
|
||||||
|
"""Testy pro zpětnou kompatibilitu"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_global_config(self, tmp_path, monkeypatch):
|
||||||
|
"""Fixture pro dočasný globální config soubor"""
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
import src.core.config as config_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
def test_load_config_legacy(self, temp_global_config):
|
||||||
|
"""Test že load_config funguje jako alias pro load_global_config"""
|
||||||
|
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/test"}
|
||||||
|
|
||||||
|
save_global_config(test_config)
|
||||||
|
loaded = load_config()
|
||||||
|
|
||||||
|
assert loaded["last_folder"] == "/test"
|
||||||
|
|
||||||
|
def test_save_config_legacy(self, temp_global_config):
|
||||||
|
"""Test že save_config funguje jako alias pro save_global_config"""
|
||||||
|
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/legacy"}
|
||||||
|
|
||||||
|
save_config(test_config)
|
||||||
|
loaded = load_global_config()
|
||||||
|
|
||||||
|
assert loaded["last_folder"] == "/legacy"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigEdgeCases:
|
||||||
|
"""Testy pro edge cases"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_global_config(self, tmp_path, monkeypatch):
|
||||||
|
"""Fixture pro dočasný globální config soubor"""
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
import src.core.config as config_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
def test_config_path_with_spaces(self, temp_global_config):
|
||||||
|
"""Test s cestou obsahující mezery"""
|
||||||
|
test_config = {
|
||||||
|
**DEFAULT_GLOBAL_CONFIG,
|
||||||
|
"last_folder": "/path/with spaces/in name",
|
||||||
|
}
|
||||||
|
|
||||||
|
save_global_config(test_config)
|
||||||
|
loaded = load_global_config()
|
||||||
|
|
||||||
|
assert loaded["last_folder"] == "/path/with spaces/in name"
|
||||||
|
|
||||||
|
def test_config_long_path(self, temp_global_config):
|
||||||
|
"""Test s dlouhou cestou"""
|
||||||
|
long_path = "/very/long/path/" + "subdir/" * 50 + "final"
|
||||||
|
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": long_path}
|
||||||
|
|
||||||
|
save_global_config(test_config)
|
||||||
|
loaded = load_global_config()
|
||||||
|
|
||||||
|
assert loaded["last_folder"] == long_path
|
||||||
|
|
||||||
|
def test_config_many_recent_folders(self, temp_global_config):
|
||||||
|
"""Test s velkým počtem recent folders"""
|
||||||
|
folders = [f"/path/folder{i}" for i in range(100)]
|
||||||
|
test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders}
|
||||||
|
|
||||||
|
save_global_config(test_config)
|
||||||
|
loaded = load_global_config()
|
||||||
|
|
||||||
|
assert len(loaded["recent_folders"]) == 100
|
||||||
|
|
||||||
|
def test_config_json_formatting(self, temp_global_config):
|
||||||
|
"""Test že config je uložen ve správném JSON formátu s indentací"""
|
||||||
|
test_config = {**DEFAULT_GLOBAL_CONFIG}
|
||||||
|
|
||||||
|
save_global_config(test_config)
|
||||||
|
|
||||||
|
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Mělo by být naformátováno s indentací
|
||||||
|
assert " " in content
|
||||||
|
|
||||||
|
def test_config_ensure_ascii_false(self, temp_global_config):
|
||||||
|
"""Test že ensure_ascii=False funguje správně"""
|
||||||
|
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/cesta/čeština"}
|
||||||
|
|
||||||
|
save_global_config(test_config)
|
||||||
|
|
||||||
|
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
assert "čeština" in content
|
||||||
|
assert "\\u" not in content # Nemělo by být escapováno
|
||||||
|
|
||||||
|
def test_config_overwrite(self, temp_global_config):
|
||||||
|
"""Test přepsání existující konfigurace"""
|
||||||
|
config1 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path1"}
|
||||||
|
config2 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path2"}
|
||||||
|
|
||||||
|
save_global_config(config1)
|
||||||
|
save_global_config(config2)
|
||||||
|
|
||||||
|
loaded = load_global_config()
|
||||||
|
assert loaded["last_folder"] == "/path2"
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Tests for constants module."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.constants import (
|
||||||
|
APP_NAME,
|
||||||
|
APP_TITLE,
|
||||||
|
APP_VERSION,
|
||||||
|
ENV_DEBUG,
|
||||||
|
get_debug_mode,
|
||||||
|
get_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_version()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_returns_string() -> None:
|
||||||
|
"""get_version() should return a string."""
|
||||||
|
assert isinstance(get_version(), str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_semver_format() -> None:
|
||||||
|
"""get_version() should return a semver-like string X.Y.Z."""
|
||||||
|
version = get_version()
|
||||||
|
assert re.match(r"^\d+\.\d+\.\d+", version), f"Not semver: {version!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None:
|
||||||
|
"""get_version() returns '0.0.0-unknown' when pyproject.toml and _version.py are both missing."""
|
||||||
|
missing = tmp_path / "nonexistent.toml"
|
||||||
|
with patch("src.constants._PYPROJECT_PATH", missing):
|
||||||
|
result = get_version()
|
||||||
|
# Either fallback _version.py exists (from previous run) or returns unknown
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_unknown_fallback(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""get_version() returns '0.0.0-unknown' when all sources are unavailable."""
|
||||||
|
missing = tmp_path / "nonexistent.toml"
|
||||||
|
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
|
||||||
|
|
||||||
|
# Patch _version import to also fail
|
||||||
|
with (
|
||||||
|
patch("src.constants.Path.write_text", side_effect=OSError),
|
||||||
|
patch.dict("sys.modules", {"src._version": None}),
|
||||||
|
):
|
||||||
|
result = get_version()
|
||||||
|
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_debug_mode()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_debug_mode_returns_bool() -> None:
|
||||||
|
"""get_debug_mode() should always return a bool."""
|
||||||
|
assert isinstance(get_debug_mode(), bool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_debug_mode_true(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""get_debug_mode() returns True when ENV_DEBUG=true."""
|
||||||
|
monkeypatch.setenv("ENV_DEBUG", "true")
|
||||||
|
assert get_debug_mode() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_debug_mode_true_variants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""get_debug_mode() accepts '1' and 'yes' as truthy values."""
|
||||||
|
for value in ("1", "yes", "YES", "True", "TRUE"):
|
||||||
|
monkeypatch.setenv("ENV_DEBUG", value)
|
||||||
|
assert get_debug_mode() is True, f"Expected True for ENV_DEBUG={value!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_debug_mode_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""get_debug_mode() returns False when ENV_DEBUG=false."""
|
||||||
|
monkeypatch.setenv("ENV_DEBUG", "false")
|
||||||
|
assert get_debug_mode() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""get_debug_mode() returns False when ENV_DEBUG is not set."""
|
||||||
|
monkeypatch.delenv("ENV_DEBUG", raising=False)
|
||||||
|
assert get_debug_mode() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_debug_is_bool() -> None:
|
||||||
|
"""ENV_DEBUG should be a bool."""
|
||||||
|
assert isinstance(ENV_DEBUG, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_version_is_string() -> None:
|
||||||
|
"""APP_VERSION should be a string."""
|
||||||
|
assert isinstance(APP_VERSION, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_version_semver_format() -> None:
|
||||||
|
"""APP_VERSION should follow semver format X.Y.Z."""
|
||||||
|
assert re.match(r"^\d+\.\d+\.\d+", APP_VERSION), f"Not semver: {APP_VERSION!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_name_value() -> None:
|
||||||
|
"""APP_NAME should be 'Curator'."""
|
||||||
|
assert APP_NAME == "Curator"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_title_contains_name_and_version() -> None:
|
||||||
|
"""APP_TITLE should contain APP_NAME and APP_VERSION."""
|
||||||
|
assert APP_NAME in APP_TITLE
|
||||||
|
assert APP_VERSION in APP_TITLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""APP_TITLE ends with '-DEV' when ENV_DEBUG is True."""
|
||||||
|
import src.constants as consts
|
||||||
|
|
||||||
|
monkeypatch.setenv("ENV_DEBUG", "true")
|
||||||
|
monkeypatch.setattr(consts, "ENV_DEBUG", True)
|
||||||
|
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if True else "")
|
||||||
|
assert title.endswith("-DEV")
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_title_no_dev_suffix_when_not_debug(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""APP_TITLE does not end with '-DEV' when ENV_DEBUG is False."""
|
||||||
|
import src.constants as consts
|
||||||
|
|
||||||
|
monkeypatch.setattr(consts, "ENV_DEBUG", False)
|
||||||
|
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if False else "")
|
||||||
|
assert not title.endswith("-DEV")
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Tests for the lean ČSFD title-bridge scraper (`src.core.csfd`)."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core import csfd
|
||||||
|
from src.core.csfd import (
|
||||||
|
CsfdError,
|
||||||
|
CsfdFilm,
|
||||||
|
_extract_csfd_id,
|
||||||
|
_is_film_href,
|
||||||
|
_parse_origin,
|
||||||
|
_parse_original_title,
|
||||||
|
_solve_anubis_pow,
|
||||||
|
clean_filename_to_query,
|
||||||
|
fetch_film,
|
||||||
|
find_csfd_url,
|
||||||
|
search_films,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A trimmed ČSFD film page: Czech title in JSON-LD, USA origin, and a
|
||||||
|
# ``.film-names`` list whose USA-flagged entry is the original title.
|
||||||
|
FILM_HTML = """
|
||||||
|
<html><head>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{"@context":"https://schema.org/","@type":"Movie","name":"Vykoupení z věznice Shawshank",
|
||||||
|
"dateCreated":"1994","duration":"PT142M"}
|
||||||
|
</script>
|
||||||
|
</head><body>
|
||||||
|
<div class="film-header-name">
|
||||||
|
<h1>Vykoupení z věznice Shawshank</h1>
|
||||||
|
<ul class="film-names">
|
||||||
|
<li><img src="//x/us.svg" class="flag" title="USA" alt="USA"/> The Shawshank Redemption
|
||||||
|
<span class="normal more-name-link"><a class="more">více</a></span></li>
|
||||||
|
<li class="more-names hidden"><img src="//x/sk.svg" class="flag" title="Slovensko"/>
|
||||||
|
Vykúpenie z väznice Shawshank</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="origin">USA <span class="bullet"></span>
|
||||||
|
<span>1994 <span class="bullet"></span> </span>142 min</div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# A Czech film: ČSFD omits the Czech title from ``.film-names`` (it equals the
|
||||||
|
# <h1>) and lists only a foreign festival title — the original title must fall
|
||||||
|
# back to the <h1>, not "Cosy Dens".
|
||||||
|
CZECH_FILM_HTML = """
|
||||||
|
<html><head>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{"@type":"Movie","name":"Pelíšky","datePublished":"1999-04-01"}
|
||||||
|
</script>
|
||||||
|
</head><body>
|
||||||
|
<div class="film-header-name"><h1>Pelíšky</h1>
|
||||||
|
<ul class="film-names">
|
||||||
|
<li><img class="flag" title="angličtina"/> Cosy Dens</li>
|
||||||
|
<li><img class="flag" title="Slovensko"/> Pelíšky</li>
|
||||||
|
</ul></div>
|
||||||
|
<div class="origin">Česko <span class="bullet"></span><span>1999</span></div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The real search page groups hits into <section> blocks; a.film-title-name also
|
||||||
|
# matches series episodes, so search_films must scope to "Filmy" and drop the
|
||||||
|
# two-segment episode hrefs.
|
||||||
|
SEARCH_HTML = """
|
||||||
|
<html><body>
|
||||||
|
<section><div class="box-header"><h2>Filmy</h2></div>
|
||||||
|
<a href="/film/2294-vykoupeni-z-veznice-shawshank/prehled/" class="film-title-name">Vykoupení z věznice Shawshank</a>
|
||||||
|
<a href="/film/248803-film-o-filmu/prehled/" class="film-title-name">Film o filmu</a>
|
||||||
|
</section>
|
||||||
|
<section><div class="box-header"><h2>Seriály</h2></div>
|
||||||
|
<a href="/film/318290-arrow/620181-x/prehled/" class="film-title-name">Arrow - Vykoupení</a>
|
||||||
|
</section>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanFilenameToQuery:
|
||||||
|
def test_strips_release_tags_and_keeps_year(self):
|
||||||
|
assert (
|
||||||
|
clean_filename_to_query("Matrix.1999.1080p.BluRay.x264-GROUP.mkv")
|
||||||
|
== "Matrix 1999"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_handles_spaces_and_parens_year(self):
|
||||||
|
assert (
|
||||||
|
clean_filename_to_query("Forrest Gump (1994) 2160p HDR.mkv")
|
||||||
|
== "Forrest Gump 1994"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_year_no_markers(self):
|
||||||
|
assert clean_filename_to_query("Amelie.mkv") == "Amelie"
|
||||||
|
|
||||||
|
def test_underscores_and_resolution(self):
|
||||||
|
assert clean_filename_to_query("Sam_doma_720p.mkv") == "Sam doma"
|
||||||
|
|
||||||
|
def test_falls_back_to_stem_when_starting_with_marker(self):
|
||||||
|
assert clean_filename_to_query("1080p.mkv") == "1080p"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractCsfdId:
|
||||||
|
def test_extract_from_various_urls(self):
|
||||||
|
assert (
|
||||||
|
_extract_csfd_id("https://www.csfd.cz/film/2294-vykoupeni/prehled/") == 2294
|
||||||
|
)
|
||||||
|
assert _extract_csfd_id("/film/999/") == 999
|
||||||
|
|
||||||
|
def test_none_when_absent(self):
|
||||||
|
assert _extract_csfd_id("https://www.csfd.cz/") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnubisPoW:
|
||||||
|
def test_solve_difficulty_one_gives_one_leading_zero_nibble(self):
|
||||||
|
hash_hex, nonce, _ = _solve_anubis_pow("abc123", difficulty=1)
|
||||||
|
assert hash_hex[0] == "0"
|
||||||
|
assert hashlib.sha256(f"abc123{nonce}".encode()).hexdigest() == hash_hex
|
||||||
|
|
||||||
|
def test_solve_difficulty_two_gives_leading_zero_byte(self):
|
||||||
|
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
|
||||||
|
assert hash_hex[:2] == "00"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOrigin:
|
||||||
|
def test_country_and_year_from_bullet_format(self):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
soup = BeautifulSoup(FILM_HTML, "html.parser")
|
||||||
|
assert _parse_origin(soup) == ("USA", "1994")
|
||||||
|
|
||||||
|
def test_first_country_of_coproduction(self):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
html = (
|
||||||
|
'<div class="origin">Francie / Německo <span class="bullet"></span>'
|
||||||
|
"<span>2001</span> 120 min</div>"
|
||||||
|
)
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
assert _parse_origin(soup) == ("Francie", "2001")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOriginalTitle:
|
||||||
|
def test_picks_name_matching_origin_country(self):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
soup = BeautifulSoup(FILM_HTML, "html.parser")
|
||||||
|
assert _parse_original_title(soup, "USA") == "The Shawshank Redemption"
|
||||||
|
|
||||||
|
def test_none_without_country_match_so_caller_uses_czech_title(self):
|
||||||
|
# A Czech film lists only foreign/festival alt-titles here — none match
|
||||||
|
# the origin country, so we must NOT return one of them.
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
html = (
|
||||||
|
'<ul class="film-names">'
|
||||||
|
'<li><img class="flag" title="angličtina"/> Loners</li>'
|
||||||
|
'<li><img class="flag" title="Slovinsko"/> Samotarji</li></ul>'
|
||||||
|
)
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
assert _parse_original_title(soup, "Česko") is None
|
||||||
|
|
||||||
|
def test_cesko_matches_ceskoslovensko_flag(self):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
html = (
|
||||||
|
'<ul class="film-names">'
|
||||||
|
'<li><img class="flag" title="Československo"/> Ostře sledované vlaky</li>'
|
||||||
|
"</ul>"
|
||||||
|
)
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
assert _parse_original_title(soup, "Česko") == "Ostře sledované vlaky"
|
||||||
|
|
||||||
|
def test_none_when_no_names(self):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
soup = BeautifulSoup("<div>x</div>", "html.parser")
|
||||||
|
assert _parse_original_title(soup, "USA") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchFilm:
|
||||||
|
def test_parses_czech_and_original_title_and_year(self):
|
||||||
|
class _Resp:
|
||||||
|
text = FILM_HTML
|
||||||
|
|
||||||
|
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||||
|
film = fetch_film("https://www.csfd.cz/film/2294-x/")
|
||||||
|
|
||||||
|
assert film.czech_title == "Vykoupení z věznice Shawshank"
|
||||||
|
assert film.original_title == "The Shawshank Redemption"
|
||||||
|
assert film.year == "1994"
|
||||||
|
assert film.csfd_id == 2294
|
||||||
|
assert film.tmdb_query == ("The Shawshank Redemption", "1994")
|
||||||
|
|
||||||
|
def test_czech_film_original_title_falls_back_to_czech(self):
|
||||||
|
class _Resp:
|
||||||
|
text = CZECH_FILM_HTML
|
||||||
|
|
||||||
|
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||||
|
film = fetch_film("https://www.csfd.cz/film/15387-pelisky/")
|
||||||
|
|
||||||
|
assert film.czech_title == "Pelíšky"
|
||||||
|
assert film.original_title == "Pelíšky"
|
||||||
|
assert film.year == "1999"
|
||||||
|
assert film.tmdb_query == ("Pelíšky", "1999")
|
||||||
|
|
||||||
|
def test_wraps_network_error_in_csfd_error(self):
|
||||||
|
import requests
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"src.core.csfd._get_page", side_effect=requests.RequestException("boom")
|
||||||
|
),
|
||||||
|
pytest.raises(CsfdError),
|
||||||
|
):
|
||||||
|
fetch_film("https://www.csfd.cz/film/1/")
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsFilmHref:
|
||||||
|
def test_accepts_single_segment_film(self):
|
||||||
|
assert _is_film_href("/film/2294-vykoupeni/prehled/") is True
|
||||||
|
assert _is_film_href("/film/2294-vykoupeni/") is True
|
||||||
|
|
||||||
|
def test_rejects_series_episode(self):
|
||||||
|
assert _is_film_href("/film/318290-arrow/620181-vykoupeni/prehled/") is False
|
||||||
|
|
||||||
|
def test_rejects_non_film(self):
|
||||||
|
assert _is_film_href("/tvurce/123-nekdo/") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchFilms:
|
||||||
|
def test_scopes_to_films_section_and_drops_episodes(self):
|
||||||
|
class _Resp:
|
||||||
|
text = SEARCH_HTML
|
||||||
|
|
||||||
|
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||||
|
films = search_films("shawshank", limit=5)
|
||||||
|
|
||||||
|
# the Seriály-section episode link is excluded
|
||||||
|
assert [f.csfd_id for f in films] == [2294, 248803]
|
||||||
|
assert films[0].url == (
|
||||||
|
"https://www.csfd.cz/film/2294-vykoupeni-z-veznice-shawshank/prehled/"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_query_returns_empty(self):
|
||||||
|
assert search_films(" ") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindCsfdUrl:
|
||||||
|
def test_returns_first_hit_url(self):
|
||||||
|
hit = CsfdFilm(url="https://www.csfd.cz/film/2294-x/", csfd_id=2294)
|
||||||
|
with patch("src.core.csfd.search_films", return_value=[hit]):
|
||||||
|
assert find_csfd_url("shawshank 1994") == "https://www.csfd.cz/film/2294-x/"
|
||||||
|
|
||||||
|
def test_retries_without_trailing_year_when_first_query_misses(self):
|
||||||
|
hit = CsfdFilm(
|
||||||
|
url="https://www.csfd.cz/film/221006-vratne-lahve/", csfd_id=221006
|
||||||
|
)
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def fake_search(query, limit=1, session=None):
|
||||||
|
calls.append(query)
|
||||||
|
return [] if query.endswith("2006") else [hit]
|
||||||
|
|
||||||
|
with patch("src.core.csfd.search_films", side_effect=fake_search):
|
||||||
|
assert find_csfd_url("Vratne lahve 2006") == hit.url
|
||||||
|
assert calls == ["Vratne lahve 2006", "Vratne lahve"]
|
||||||
|
|
||||||
|
def test_none_for_blank_query(self):
|
||||||
|
assert find_csfd_url(" ") is None
|
||||||
|
|
||||||
|
def test_none_when_no_results(self):
|
||||||
|
with patch("src.core.csfd.search_films", return_value=[]):
|
||||||
|
assert find_csfd_url("nic") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDependencies:
|
||||||
|
def test_dependencies_available_in_test_env(self):
|
||||||
|
assert csfd.dependencies_available() is True
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.file import File
|
||||||
|
from src.core.pool_index import PoolIndex
|
||||||
|
from src.core.tag import Tag
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
|
|
||||||
|
class TestFile:
|
||||||
|
"""Testy pro třídu File (metadata žijí v pool indexu)"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tag_manager(self):
|
||||||
|
return TagManager()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def index(self, tmp_path):
|
||||||
|
"""Pool index that backs the File's metadata."""
|
||||||
|
return PoolIndex(tmp_path)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_file(self, tmp_path):
|
||||||
|
f = tmp_path / "test.txt"
|
||||||
|
f.write_text("test content")
|
||||||
|
return f
|
||||||
|
|
||||||
|
def test_file_creation(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
assert file_obj.file_path == test_file
|
||||||
|
assert file_obj.filename == "test.txt"
|
||||||
|
assert file_obj.new is True
|
||||||
|
|
||||||
|
def test_file_initial_tags(self, test_file, tag_manager, index):
|
||||||
|
"""Nový soubor nemá žádné automatické tagy (Stav/Nové odstraněn)."""
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
assert file_obj.tags == []
|
||||||
|
|
||||||
|
def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
|
||||||
|
"""Metadata jsou zapsána do indexu už při vytvoření."""
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
assert index.record_for(file_obj.file_path) is not None
|
||||||
|
|
||||||
|
def test_file_no_sidecar_written(self, test_file, tag_manager, index):
|
||||||
|
"""Žádný .!tag sidecar už nevzniká."""
|
||||||
|
File(test_file, tag_manager, index=index)
|
||||||
|
assert not (test_file.parent / ".test.txt.!tag").exists()
|
||||||
|
|
||||||
|
def test_index_less_file_is_in_memory_only(self, test_file, tag_manager):
|
||||||
|
"""Bez indexu je File jen v paměti — nic se nezapíše na disk."""
|
||||||
|
file_obj = File(test_file, tag_manager)
|
||||||
|
file_obj.add_tag("Video/HD")
|
||||||
|
file_obj.save_metadata() # no-op
|
||||||
|
assert not (test_file.parent / ".test.txt.!tag").exists()
|
||||||
|
|
||||||
|
def test_file_save_metadata(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.new = False
|
||||||
|
file_obj.ignored = True
|
||||||
|
file_obj.save_metadata()
|
||||||
|
|
||||||
|
data = index.record_for(file_obj.file_path)
|
||||||
|
assert data["new"] is False
|
||||||
|
assert data["ignored"] is True
|
||||||
|
|
||||||
|
def test_file_load_metadata(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
tag = tag_manager.add_tag("Video", "HD")
|
||||||
|
file_obj.tags.append(tag)
|
||||||
|
file_obj.date = "2025-01-15"
|
||||||
|
file_obj.save_metadata()
|
||||||
|
|
||||||
|
# A fresh File over the same index reloads the metadata
|
||||||
|
file_obj2 = File(test_file, tag_manager, index=index)
|
||||||
|
assert len(file_obj2.tags) == 1 # Video/HD
|
||||||
|
assert file_obj2.date == "2025-01-15"
|
||||||
|
assert "Video/HD" in {t.full_path for t in file_obj2.tags}
|
||||||
|
|
||||||
|
def test_file_set_date(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.set_date("2025-12-25")
|
||||||
|
assert file_obj.date == "2025-12-25"
|
||||||
|
assert index.record_for(file_obj.file_path)["date"] == "2025-12-25"
|
||||||
|
|
||||||
|
def test_file_set_date_to_none(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.set_date("2025-12-25")
|
||||||
|
file_obj.set_date(None)
|
||||||
|
assert file_obj.date is None
|
||||||
|
|
||||||
|
def test_file_set_date_empty_string(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.set_date("2025-12-25")
|
||||||
|
file_obj.set_date("")
|
||||||
|
assert file_obj.date is None
|
||||||
|
|
||||||
|
def test_file_add_tag_object(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
tag = Tag("Video", "4K")
|
||||||
|
file_obj.add_tag(tag)
|
||||||
|
|
||||||
|
assert tag in file_obj.tags
|
||||||
|
assert len(file_obj.tags) == 1
|
||||||
|
|
||||||
|
def test_file_add_tag_string(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.add_tag("Audio/MP3")
|
||||||
|
assert "Audio/MP3" in {t.full_path for t in file_obj.tags}
|
||||||
|
|
||||||
|
def test_file_add_tag_string_without_category(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.add_tag("SimpleTag")
|
||||||
|
assert "default/SimpleTag" in {t.full_path for t in file_obj.tags}
|
||||||
|
|
||||||
|
def test_file_add_duplicate_tag(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
tag = Tag("Video", "HD")
|
||||||
|
file_obj.add_tag(tag)
|
||||||
|
file_obj.add_tag(tag)
|
||||||
|
assert sum(1 for t in file_obj.tags if t == tag) == 1
|
||||||
|
|
||||||
|
def test_file_remove_tag_object(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
tag = Tag("Video", "HD")
|
||||||
|
file_obj.add_tag(tag)
|
||||||
|
file_obj.remove_tag(tag)
|
||||||
|
assert tag not in file_obj.tags
|
||||||
|
|
||||||
|
def test_file_remove_tag_string(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.add_tag("Video/HD")
|
||||||
|
file_obj.remove_tag("Video/HD")
|
||||||
|
assert "Video/HD" not in {t.full_path for t in file_obj.tags}
|
||||||
|
|
||||||
|
def test_file_remove_tag_string_without_category(
|
||||||
|
self, test_file, tag_manager, index
|
||||||
|
):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.add_tag("SimpleTag")
|
||||||
|
file_obj.remove_tag("SimpleTag")
|
||||||
|
assert "default/SimpleTag" not in {t.full_path for t in file_obj.tags}
|
||||||
|
|
||||||
|
def test_file_remove_nonexistent_tag(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
initial_count = len(file_obj.tags)
|
||||||
|
file_obj.remove_tag("Nonexistent/Tag")
|
||||||
|
assert len(file_obj.tags) == initial_count
|
||||||
|
|
||||||
|
def test_file_without_tagmanager(self, test_file):
|
||||||
|
file_obj = File(test_file, tagmanager=None)
|
||||||
|
assert file_obj.tagmanager is None
|
||||||
|
assert len(file_obj.tags) == 0
|
||||||
|
|
||||||
|
def test_file_metadata_persistence(self, test_file, tag_manager, index):
|
||||||
|
file_obj1 = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj1.add_tag("Video/HD")
|
||||||
|
file_obj1.add_tag("Audio/Stereo")
|
||||||
|
file_obj1.set_date("2025-01-01")
|
||||||
|
file_obj1.new = False
|
||||||
|
file_obj1.ignored = True
|
||||||
|
file_obj1.save_metadata()
|
||||||
|
|
||||||
|
file_obj2 = File(test_file, tag_manager, index=index)
|
||||||
|
assert file_obj2.new is False
|
||||||
|
assert file_obj2.ignored is True
|
||||||
|
assert file_obj2.date == "2025-01-01"
|
||||||
|
tag_paths = {t.full_path for t in file_obj2.tags}
|
||||||
|
assert "Video/HD" in tag_paths
|
||||||
|
assert "Audio/Stereo" in tag_paths
|
||||||
|
|
||||||
|
def test_file_metadata_record_shape(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.add_tag("Test/Tag")
|
||||||
|
file_obj.set_date("2025-06-15")
|
||||||
|
|
||||||
|
data = index.record_for(file_obj.file_path)
|
||||||
|
assert "new" in data
|
||||||
|
assert "ignored" in data
|
||||||
|
assert "tags" in data
|
||||||
|
assert "date" in data
|
||||||
|
assert isinstance(data["tags"], list)
|
||||||
|
|
||||||
|
def test_file_unicode_handling(self, tmp_path, tag_manager, index):
|
||||||
|
test_file = tmp_path / "český_soubor.txt"
|
||||||
|
test_file.write_text("obsah")
|
||||||
|
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.add_tag("Kategorie/Český tag")
|
||||||
|
file_obj.save_metadata()
|
||||||
|
|
||||||
|
file_obj2 = File(test_file, tag_manager, index=index)
|
||||||
|
assert "Kategorie/Český tag" in {t.full_path for t in file_obj2.tags}
|
||||||
|
|
||||||
|
def test_file_complex_scenario(self, test_file, tag_manager, index):
|
||||||
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
|
file_obj.add_tag("Video/HD")
|
||||||
|
file_obj.add_tag("Video/Stereo")
|
||||||
|
file_obj.add_tag("Stav/Zkontrolováno")
|
||||||
|
file_obj.set_date("2025-01-01")
|
||||||
|
file_obj.remove_tag("Stav/Nové")
|
||||||
|
|
||||||
|
tag_paths = {t.full_path for t in file_obj.tags}
|
||||||
|
assert "Video/HD" in tag_paths
|
||||||
|
assert "Video/Stereo" in tag_paths
|
||||||
|
assert "Stav/Zkontrolováno" in tag_paths
|
||||||
|
assert "Stav/Nové" not in tag_paths
|
||||||
|
assert file_obj.date == "2025-01-01"
|
||||||
|
|
||||||
|
file_obj2 = File(test_file, tag_manager, index=index)
|
||||||
|
assert {t.full_path for t in file_obj2.tags} == tag_paths
|
||||||
|
assert file_obj2.date == "2025-01-01"
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyNfoTags:
|
||||||
|
"""Tests for File.apply_nfo tag assignment (NFO parsed from disk)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tag_manager(self):
|
||||||
|
return TagManager()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def index(self, tmp_path):
|
||||||
|
return PoolIndex(tmp_path)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def movie_file(self, tmp_path, tag_manager, index):
|
||||||
|
path = tmp_path / "Matrix.mkv"
|
||||||
|
path.write_text("x")
|
||||||
|
return File(path, tag_manager, index=index)
|
||||||
|
|
||||||
|
def test_apply_nfo_assigns_expected_categories(self, movie_file):
|
||||||
|
from src.core.nfo import NfoData
|
||||||
|
|
||||||
|
nfo = NfoData(
|
||||||
|
title="Matrix",
|
||||||
|
year="1999",
|
||||||
|
genres=["Akční", "Sci-Fi"],
|
||||||
|
countries=["USA"],
|
||||||
|
rating=90,
|
||||||
|
)
|
||||||
|
result = movie_file.apply_nfo(nfo)
|
||||||
|
|
||||||
|
assert result["success"]
|
||||||
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
|
assert "Žánr/Akční" in paths
|
||||||
|
assert "Žánr/Sci-Fi" in paths
|
||||||
|
assert "Rok/1999" in paths
|
||||||
|
assert "Země původu/USA" in paths
|
||||||
|
assert "Hodnocení/90" in paths # exact value; folder grouping happens later
|
||||||
|
|
||||||
|
def test_apply_nfo_none_is_noop(self, movie_file):
|
||||||
|
result = movie_file.apply_nfo(None)
|
||||||
|
assert not result["success"]
|
||||||
|
assert movie_file.tags == []
|
||||||
|
|
||||||
|
def test_name_context_fields(self, movie_file):
|
||||||
|
movie_file.title = "Dr. No"
|
||||||
|
movie_file.meta = {"year": 1962, "rating": 75}
|
||||||
|
ctx = movie_file.name_context()
|
||||||
|
assert ctx["title"] == "Dr. No"
|
||||||
|
assert ctx["year"] == "1962" # coerced to str for uniform template fields
|
||||||
|
assert ctx["rating"] == 75
|
||||||
|
assert ctx["ext"] == movie_file.file_path.suffix
|
||||||
|
assert ctx["filename"] == movie_file.filename
|
||||||
|
assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}"
|
||||||
|
|
||||||
|
def test_name_context_year_from_tag_when_no_cache(self, movie_file):
|
||||||
|
movie_file.meta = None
|
||||||
|
movie_file.add_tag("Rok/1999")
|
||||||
|
assert movie_file.name_context()["year"] == "1999"
|
||||||
|
|
||||||
|
def test_added_timestamp_uses_stored_added(self, movie_file):
|
||||||
|
movie_file.added = "2026-06-16T12:00:00"
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
assert (
|
||||||
|
movie_file.added_timestamp()
|
||||||
|
== datetime.fromisoformat("2026-06-16T12:00:00").timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_added_timestamp_falls_back_to_mtime(self, movie_file):
|
||||||
|
movie_file.added = None
|
||||||
|
import os
|
||||||
|
|
||||||
|
expected = os.stat(movie_file.file_path).st_mtime
|
||||||
|
assert movie_file.added_timestamp() == expected
|
||||||
|
|
||||||
|
def test_set_attribute_persists_and_in_context(self, movie_file):
|
||||||
|
movie_file.set_attribute("collection_sort", "03")
|
||||||
|
assert movie_file.attributes["collection_sort"] == "03"
|
||||||
|
assert movie_file.name_context()["collection_sort"] == "03"
|
||||||
|
# reload (from index) keeps it
|
||||||
|
reloaded = File(
|
||||||
|
movie_file.file_path, movie_file.tagmanager, index=movie_file.index
|
||||||
|
)
|
||||||
|
assert reloaded.attributes["collection_sort"] == "03"
|
||||||
|
|
||||||
|
def test_set_attribute_empty_removes_it(self, movie_file):
|
||||||
|
movie_file.set_attribute("collection_sort", "03")
|
||||||
|
movie_file.set_attribute("collection_sort", None)
|
||||||
|
assert "collection_sort" not in movie_file.attributes
|
||||||
|
|
||||||
|
def test_attribute_usable_in_filename_template(self, movie_file):
|
||||||
|
movie_file.title = "Dr. No"
|
||||||
|
movie_file.set_attribute("collection_sort", "01")
|
||||||
|
ctx = movie_file.name_context()
|
||||||
|
assert (
|
||||||
|
"{collection_sort} - {title}{ext}".format(**ctx)
|
||||||
|
== f"01 - Dr. No{ctx['ext']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apply_nfo_honors_custom_schema(self, movie_file):
|
||||||
|
"""A schema without the rating entry produces no Hodnocení tags."""
|
||||||
|
from src.core.nfo import NfoData
|
||||||
|
|
||||||
|
schema = [
|
||||||
|
{
|
||||||
|
"category": "Žánr",
|
||||||
|
"csfd_field": "genres",
|
||||||
|
"transform": None,
|
||||||
|
"filmoteka_root": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
|
||||||
|
|
||||||
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
|
assert "Žánr/Drama" in paths
|
||||||
|
assert not any(p.startswith("Hodnocení/") for p in paths)
|
||||||
|
|
||||||
|
def test_apply_nfo_preserves_user_tags_on_refresh(self, movie_file):
|
||||||
|
"""Re-reading regenerates only auto (NFO) tags; user-added tags survive."""
|
||||||
|
from src.core.nfo import NfoData
|
||||||
|
|
||||||
|
movie_file.add_tag("Sbírka/Oblíbené") # user tag
|
||||||
|
movie_file.apply_nfo(NfoData(year="1999", genres=["Akční"]))
|
||||||
|
movie_file.apply_nfo(NfoData(year="2009", genres=["Drama"]))
|
||||||
|
|
||||||
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
|
assert "Sbírka/Oblíbené" in paths # user tag kept
|
||||||
|
assert "Žánr/Drama" in paths # new NFO tag
|
||||||
|
assert "Rok/2009" in paths
|
||||||
|
assert "Žánr/Akční" not in paths # old NFO tag regenerated away
|
||||||
|
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
|
||||||
|
assert "Rok/1999" not in paths
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.file import File
|
||||||
|
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
|
||||||
|
from src.core.tag import Tag
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
|
|
||||||
|
class TestHardlinkManager:
|
||||||
|
"""Testy pro HardlinkManager"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tag_manager(self):
|
||||||
|
"""Fixture pro TagManager"""
|
||||||
|
tm = TagManager()
|
||||||
|
# Remove default tags for cleaner tests
|
||||||
|
for cat in list(tm.tags_by_category.keys()):
|
||||||
|
tm.remove_category(cat)
|
||||||
|
return tm
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_source_dir(self, tmp_path):
|
||||||
|
"""Fixture pro zdrojovou složku s testovacími soubory"""
|
||||||
|
source_dir = tmp_path / "source"
|
||||||
|
source_dir.mkdir()
|
||||||
|
(source_dir / "file1.txt").write_text("content1")
|
||||||
|
(source_dir / "file2.txt").write_text("content2")
|
||||||
|
(source_dir / "file3.txt").write_text("content3")
|
||||||
|
return source_dir
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_output_dir(self, tmp_path):
|
||||||
|
"""Fixture pro výstupní složku"""
|
||||||
|
output_dir = tmp_path / "output"
|
||||||
|
output_dir.mkdir()
|
||||||
|
return output_dir
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def files_with_tags(self, temp_source_dir, tag_manager):
|
||||||
|
"""Fixture pro soubory s tagy"""
|
||||||
|
files = []
|
||||||
|
|
||||||
|
# File 1 with multiple tags
|
||||||
|
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f1.tags.clear() # ensure a clean tag set
|
||||||
|
f1.add_tag(Tag("žánr", "Komedie"))
|
||||||
|
f1.add_tag(Tag("žánr", "Akční"))
|
||||||
|
f1.add_tag(Tag("rok", "1988"))
|
||||||
|
files.append(f1)
|
||||||
|
|
||||||
|
# File 2 with one tag
|
||||||
|
f2 = File(temp_source_dir / "file2.txt", tag_manager)
|
||||||
|
f2.tags.clear() # ensure a clean tag set
|
||||||
|
f2.add_tag(Tag("žánr", "Drama"))
|
||||||
|
files.append(f2)
|
||||||
|
|
||||||
|
# File 3 with no tags
|
||||||
|
f3 = File(temp_source_dir / "file3.txt", tag_manager)
|
||||||
|
f3.tags.clear() # ensure a clean tag set
|
||||||
|
files.append(f3)
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
def test_hardlink_manager_creation(self, temp_output_dir):
|
||||||
|
"""Test vytvoření HardlinkManager"""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
assert manager.output_dir == temp_output_dir
|
||||||
|
assert manager.created_links == []
|
||||||
|
assert manager.errors == []
|
||||||
|
|
||||||
|
def test_create_structure_basic(self, files_with_tags, temp_output_dir):
|
||||||
|
"""Test základního vytvoření struktury"""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
success, fail = manager.create_structure_for_files(files_with_tags)
|
||||||
|
|
||||||
|
# File1 has 3 tags, File2 has 1 tag, File3 has 0 tags
|
||||||
|
# Should create 4 hardlinks total
|
||||||
|
assert success == 4
|
||||||
|
assert fail == 0
|
||||||
|
|
||||||
|
# Check directory structure
|
||||||
|
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||||
|
assert (temp_output_dir / "žánr" / "Akční" / "file1.txt").exists()
|
||||||
|
assert (temp_output_dir / "rok" / "1988" / "file1.txt").exists()
|
||||||
|
assert (temp_output_dir / "žánr" / "Drama" / "file2.txt").exists()
|
||||||
|
|
||||||
|
def test_hardlinks_are_same_inode(
|
||||||
|
self, files_with_tags, temp_output_dir, temp_source_dir
|
||||||
|
):
|
||||||
|
"""Test že vytvořené soubory jsou opravdu hardlinky (stejný inode)"""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.create_structure_for_files(files_with_tags)
|
||||||
|
|
||||||
|
original = temp_source_dir / "file1.txt"
|
||||||
|
hardlink = temp_output_dir / "žánr" / "Komedie" / "file1.txt"
|
||||||
|
|
||||||
|
# Same inode = hardlink
|
||||||
|
assert original.stat().st_ino == hardlink.stat().st_ino
|
||||||
|
|
||||||
|
def test_create_structure_with_category_filter(
|
||||||
|
self, files_with_tags, temp_output_dir
|
||||||
|
):
|
||||||
|
"""Test vytvoření struktury jen pro vybrané kategorie"""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
success, fail = manager.create_structure_for_files(
|
||||||
|
files_with_tags, categories=["žánr"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only "žánr" tags should be processed (3 links)
|
||||||
|
assert success == 3
|
||||||
|
assert fail == 0
|
||||||
|
|
||||||
|
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||||
|
assert not (temp_output_dir / "rok").exists()
|
||||||
|
|
||||||
|
def test_create_structure_with_category_roots(
|
||||||
|
self, files_with_tags, temp_output_dir
|
||||||
|
):
|
||||||
|
"""category_roots: genres sit at the output root, rok under 'Dle roku'."""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
roots = {"žánr": "", "rok": "Dle roku"}
|
||||||
|
manager.create_structure_for_files(files_with_tags, category_roots=roots)
|
||||||
|
|
||||||
|
# Genres directly at the output root (no "žánr" wrapper folder)
|
||||||
|
assert (temp_output_dir / "Komedie" / "file1.txt").exists()
|
||||||
|
assert (temp_output_dir / "Akční" / "file1.txt").exists()
|
||||||
|
assert (temp_output_dir / "Drama" / "file2.txt").exists()
|
||||||
|
assert not (temp_output_dir / "žánr").exists()
|
||||||
|
|
||||||
|
# Rok grouped under its own "Dle roku" folder
|
||||||
|
assert (temp_output_dir / "Dle roku" / "1988" / "file1.txt").exists()
|
||||||
|
|
||||||
|
def test_sync_with_roots_leaves_unmanaged_mirror_untouched(
|
||||||
|
self, files_with_tags, temp_source_dir, temp_output_dir
|
||||||
|
):
|
||||||
|
"""Cleanup must not delete links in a copy-as-is mirror (e.g. Seriály)."""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
roots = {"žánr": "", "rok": "Dle roku"}
|
||||||
|
manager.create_structure_for_files(files_with_tags, category_roots=roots)
|
||||||
|
|
||||||
|
# Simulate a copy-as-is mirror holding a hardlink to a source file
|
||||||
|
mirror = temp_output_dir / "Seriály"
|
||||||
|
mirror.mkdir()
|
||||||
|
mirror_link = mirror / "file1.txt"
|
||||||
|
os.link(temp_source_dir / "file1.txt", mirror_link)
|
||||||
|
|
||||||
|
manager.sync_structure(
|
||||||
|
files_with_tags, category_roots=roots, reserved_subfolders={"Seriály"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# The mirror (reserved folder) is left alone
|
||||||
|
assert mirror_link.exists()
|
||||||
|
|
||||||
|
def test_sync_removes_orphan_link_from_replaced_movie(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""A stale link pointing at a no-longer-pooled inode is cleaned up."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("žánr", "Drama")) # a root-level category enables the sweep
|
||||||
|
f.add_tag(Tag("rok", "1968"))
|
||||||
|
roots = {"žánr": "", "rok": "- Dle roku"}
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.sync_structure([f], category_roots=roots)
|
||||||
|
|
||||||
|
# Simulate a leftover from an OLD layout: a different file (different
|
||||||
|
# inode, not in the pool) hardlinked under an old unprefixed folder.
|
||||||
|
orphan_src = temp_source_dir / "orphan.txt"
|
||||||
|
orphan_src.write_text("old data")
|
||||||
|
old_dir = temp_output_dir / "Dle roku" / "1968"
|
||||||
|
old_dir.mkdir(parents=True)
|
||||||
|
os.link(orphan_src, old_dir / "file1.txt")
|
||||||
|
|
||||||
|
manager.sync_structure([f], category_roots=roots)
|
||||||
|
|
||||||
|
# new prefixed folder kept, stale unprefixed one (orphan) removed
|
||||||
|
assert (temp_output_dir / "- Dle roku" / "1968" / "file1.txt").exists()
|
||||||
|
assert not (temp_output_dir / "Dle roku").exists()
|
||||||
|
|
||||||
|
def test_sync_replaces_orphan_squatting_the_expected_name(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""An orphan link with the *expected name* but a stale inode is replaced.
|
||||||
|
|
||||||
|
Regression: when a movie is re-imported (new inode), the old hardlink
|
||||||
|
keeps the correct name but points at the dead inode. Name-only obsolete
|
||||||
|
detection spared it and forced the real link to a ``_1`` suffix, which
|
||||||
|
was then swept every run — an endless created==removed churn. Obsolete
|
||||||
|
detection now matches the inode too, so the orphan is removed and the
|
||||||
|
base name is restored, stably.
|
||||||
|
"""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("žánr", "Drama"))
|
||||||
|
roots = {"žánr": ""}
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.sync_structure([f], category_roots=roots)
|
||||||
|
link = temp_output_dir / "Drama" / "file1.txt"
|
||||||
|
assert link.stat().st_ino == f.file_path.stat().st_ino
|
||||||
|
|
||||||
|
# Simulate a re-import: the expected name now points at a *different*
|
||||||
|
# (orphan) inode, exactly as a replaced movie leaves behind.
|
||||||
|
orphan_src = temp_source_dir / "orphan.txt"
|
||||||
|
orphan_src.write_text("old content")
|
||||||
|
link.unlink()
|
||||||
|
os.link(orphan_src, link)
|
||||||
|
assert link.stat().st_ino != f.file_path.stat().st_ino
|
||||||
|
|
||||||
|
created, _, removed, _ = manager.sync_structure([f], category_roots=roots)
|
||||||
|
# The orphan was swept and the correct link recreated under the base name.
|
||||||
|
assert removed >= 1 and created >= 1
|
||||||
|
assert link.stat().st_ino == f.file_path.stat().st_ino
|
||||||
|
assert not (temp_output_dir / "Drama" / "file1_1.txt").exists()
|
||||||
|
|
||||||
|
# And it is now stable: a second run touches nothing (no churn).
|
||||||
|
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
|
||||||
|
assert (created2, removed2) == (0, 0)
|
||||||
|
|
||||||
|
def test_link_name_strips_year_by_default(self, tmp_path, tag_manager):
|
||||||
|
"""A pooled 'Title (YYYY).ext' hardlinks into the tag tree as 'Title.ext'."""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "Solaris (1972).mkv").write_text("x")
|
||||||
|
f = File(source / "Solaris (1972).mkv", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.title = "Solaris"
|
||||||
|
f.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.create_structure_for_files([f], category_roots={"žánr": ""})
|
||||||
|
|
||||||
|
assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped
|
||||||
|
assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
|
||||||
|
|
||||||
|
def test_link_name_strips_year_and_tmdbid(self, tmp_path, tag_manager):
|
||||||
|
"""A pooled 'Title (YYYY) [tmdbid-N].ext' links as clean 'Title.ext'."""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "Matrix (1999) [tmdbid-603].mkv").write_text("x")
|
||||||
|
f = File(source / "Matrix (1999) [tmdbid-603].mkv", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.create_structure_for_files([f], category_roots={"žánr": ""})
|
||||||
|
|
||||||
|
assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped
|
||||||
|
|
||||||
|
def test_link_name_keeps_year_on_collision(self, tmp_path, tag_manager):
|
||||||
|
"""Two same-title films in one folder keep the year to disambiguate."""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "Solaris (1972).mkv").write_text("a")
|
||||||
|
(source / "Solaris (2002).mkv").write_text("b")
|
||||||
|
f1 = File(source / "Solaris (1972).mkv", tag_manager)
|
||||||
|
f1.tags.clear()
|
||||||
|
f1.title = "Solaris"
|
||||||
|
f1.meta = {"year": 1972}
|
||||||
|
f1.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
|
f1.add_tag(Tag("rok", "1972"))
|
||||||
|
f2 = File(source / "Solaris (2002).mkv", tag_manager)
|
||||||
|
f2.tags.clear()
|
||||||
|
f2.title = "Solaris"
|
||||||
|
f2.meta = {"year": 2002}
|
||||||
|
f2.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
|
f2.add_tag(Tag("rok", "2002"))
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
roots = {"žánr": "", "rok": "Dle roku"}
|
||||||
|
manager.create_structure_for_files([f1, f2], category_roots=roots)
|
||||||
|
|
||||||
|
# Same genre folder → clash → both keep the year
|
||||||
|
assert (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
|
||||||
|
assert (output / "Sci-Fi" / "Solaris (2002).mkv").exists()
|
||||||
|
assert not (output / "Sci-Fi" / "Solaris.mkv").exists()
|
||||||
|
# Separate year folders → no clash → clean name
|
||||||
|
assert (output / "Dle roku" / "1972" / "Solaris.mkv").exists()
|
||||||
|
assert (output / "Dle roku" / "2002" / "Solaris.mkv").exists()
|
||||||
|
|
||||||
|
def test_clean_naming_cleanup_is_consistent(self, tmp_path, tag_manager):
|
||||||
|
"""Clean-named links are stable: a second sync removes/creates nothing."""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "Solaris (1972).mkv").write_text("x")
|
||||||
|
f = File(source / "Solaris (1972).mkv", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.title = "Solaris"
|
||||||
|
f.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
|
roots = {"žánr": ""}
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.sync_structure([f], category_roots=roots)
|
||||||
|
created, _, removed, _ = manager.sync_structure([f], category_roots=roots)
|
||||||
|
assert (created, removed) == (0, 0)
|
||||||
|
|
||||||
|
def test_sync_removes_root_genre_folder_when_last_movie_drops_tag(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""A genre folder at the output root is cleaned when no movie has it."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("žánr", "Akční"))
|
||||||
|
roots = {"žánr": ""}
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.sync_structure([f], category_roots=roots)
|
||||||
|
assert (temp_output_dir / "Akční").is_dir()
|
||||||
|
|
||||||
|
# last movie with the tag loses it → folder must go
|
||||||
|
f.tags.clear()
|
||||||
|
manager.sync_structure([f], category_roots=roots)
|
||||||
|
assert not (temp_output_dir / "Akční").exists()
|
||||||
|
|
||||||
|
def test_category_transform_groups_folder_by_band(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""Exact tag value, but the folder name goes through the transform."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("Hodnocení", "90")) # exact rating tag
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.create_structure_for_files(
|
||||||
|
[f],
|
||||||
|
category_roots={"Hodnocení": "Dle hodnocení"},
|
||||||
|
category_transforms={"Hodnocení": "decade_band"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# folder is the band; the file lands inside it
|
||||||
|
assert (temp_output_dir / "Dle hodnocení" / "90–100 %" / "file1.txt").exists()
|
||||||
|
# not a per-exact-value folder
|
||||||
|
assert not (temp_output_dir / "Dle hodnocení" / "90").exists()
|
||||||
|
|
||||||
|
def test_filename_template_applies_only_in_that_category(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""A per-category template renames the hardlink only inside its folder."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.title = "Dr. No"
|
||||||
|
f.meta = {"year": 1962}
|
||||||
|
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||||
|
f.add_tag(Tag("žánr", "Akční"))
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.create_structure_for_files(
|
||||||
|
[f],
|
||||||
|
category_roots={"Kolekce": "Dle kolekce", "žánr": ""},
|
||||||
|
category_filename_templates={"Kolekce": "{year} - {title}{ext}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Templated name inside the collection folder
|
||||||
|
assert (
|
||||||
|
temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt"
|
||||||
|
).exists()
|
||||||
|
# Other categories use the default clean title (no year, not the pool name)
|
||||||
|
assert (temp_output_dir / "Akční" / "Dr. No.txt").exists()
|
||||||
|
|
||||||
|
def test_filename_template_cleanup_is_consistent(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""sync twice with a template leaves no stale/duplicate templated link."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.title = "Dr. No"
|
||||||
|
f.meta = {"year": 1962}
|
||||||
|
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||||
|
roots = {"Kolekce": "Dle kolekce"}
|
||||||
|
templates = {"Kolekce": "{year} - {title}{ext}"}
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.sync_structure(
|
||||||
|
[f], category_roots=roots, category_filename_templates=templates
|
||||||
|
)
|
||||||
|
_created, _, removed, _ = manager.sync_structure(
|
||||||
|
[f], category_roots=roots, category_filename_templates=templates
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = temp_output_dir / "Dle kolekce" / "James Bond"
|
||||||
|
assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"]
|
||||||
|
assert removed == 0 # nothing treated as obsolete on the second run
|
||||||
|
|
||||||
|
def test_generate_random_tips_creates_hardlinks(
|
||||||
|
self, files_with_tags, temp_output_dir
|
||||||
|
):
|
||||||
|
"""Tips folder gets `count` random pool files as hardlinks."""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
created, fail = manager.generate_random_tips(files_with_tags, count=2)
|
||||||
|
|
||||||
|
tips = temp_output_dir / "Tipy dne"
|
||||||
|
assert created == 2
|
||||||
|
assert fail == 0
|
||||||
|
links = sorted(p.name for p in tips.iterdir())
|
||||||
|
assert len(links) == 2
|
||||||
|
# each is a hardlink to one of the source files
|
||||||
|
srcs = {f.filename for f in files_with_tags}
|
||||||
|
assert set(links) <= srcs
|
||||||
|
|
||||||
|
def test_generate_random_tips_caps_at_available(
|
||||||
|
self, files_with_tags, temp_output_dir
|
||||||
|
):
|
||||||
|
"""Asking for more than available yields all of them, no error."""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
created, _ = manager.generate_random_tips(files_with_tags, count=99)
|
||||||
|
assert created == len(files_with_tags)
|
||||||
|
|
||||||
|
def test_generate_random_tips_refreshes_each_run(
|
||||||
|
self, files_with_tags, temp_output_dir
|
||||||
|
):
|
||||||
|
"""A second run replaces the previous selection (folder is emptied)."""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.generate_random_tips(files_with_tags, count=1)
|
||||||
|
manager.generate_random_tips(files_with_tags, count=1)
|
||||||
|
tips = temp_output_dir / "Tipy dne"
|
||||||
|
assert len(list(tips.iterdir())) == 1 # not accumulated to 2
|
||||||
|
|
||||||
|
def test_generate_recently_added_orders_by_added(
|
||||||
|
self, files_with_tags, temp_output_dir
|
||||||
|
):
|
||||||
|
"""The newest-added movies (by added_timestamp) fill the folder."""
|
||||||
|
# explicit added timestamps: file2 newest, file1 middle, file3 oldest
|
||||||
|
files_with_tags[0].added = "2026-01-02T00:00:00"
|
||||||
|
files_with_tags[1].added = "2026-01-03T00:00:00"
|
||||||
|
files_with_tags[2].added = "2026-01-01T00:00:00"
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
created, fail = manager.generate_recently_added(
|
||||||
|
files_with_tags, count=2, subfolder="- Nově přidané"
|
||||||
|
)
|
||||||
|
|
||||||
|
recent = temp_output_dir / "- Nově přidané"
|
||||||
|
assert created == 2 and fail == 0
|
||||||
|
names = {p.name for p in recent.iterdir()}
|
||||||
|
assert names == {"file2.txt", "file1.txt"} # newest two, file3 excluded
|
||||||
|
|
||||||
|
def test_dry_run(self, files_with_tags, temp_output_dir):
|
||||||
|
"""Test dry run (bez skutečného vytváření)"""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
success, fail = manager.create_structure_for_files(
|
||||||
|
files_with_tags, dry_run=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success == 4
|
||||||
|
assert fail == 0
|
||||||
|
|
||||||
|
# No actual files should be created
|
||||||
|
assert not (temp_output_dir / "žánr").exists()
|
||||||
|
|
||||||
|
def test_get_preview(self, files_with_tags, temp_output_dir):
|
||||||
|
"""Test náhledu co bude vytvořeno"""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
preview = manager.get_preview(files_with_tags)
|
||||||
|
|
||||||
|
assert len(preview) == 4
|
||||||
|
|
||||||
|
# Check that preview contains expected paths
|
||||||
|
targets = [p[1] for p in preview]
|
||||||
|
assert temp_output_dir / "žánr" / "Komedie" / "file1.txt" in targets
|
||||||
|
assert temp_output_dir / "žánr" / "Drama" / "file2.txt" in targets
|
||||||
|
|
||||||
|
def test_get_preview_with_category_filter(self, files_with_tags, temp_output_dir):
|
||||||
|
"""Test náhledu s filtrem kategorií"""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
preview = manager.get_preview(files_with_tags, categories=["rok"])
|
||||||
|
|
||||||
|
assert len(preview) == 1
|
||||||
|
assert preview[0][1] == temp_output_dir / "rok" / "1988" / "file1.txt"
|
||||||
|
|
||||||
|
def test_remove_created_links(self, files_with_tags, temp_output_dir):
|
||||||
|
"""Test odstranění vytvořených hardlinků"""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.create_structure_for_files(files_with_tags)
|
||||||
|
|
||||||
|
# Verify links exist
|
||||||
|
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||||
|
|
||||||
|
# Remove links
|
||||||
|
removed = manager.remove_created_links()
|
||||||
|
assert removed == 4
|
||||||
|
|
||||||
|
# Links should be gone
|
||||||
|
assert not (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||||
|
|
||||||
|
# Empty directories should also be removed
|
||||||
|
assert not (temp_output_dir / "žánr" / "Komedie").exists()
|
||||||
|
|
||||||
|
def test_empty_files_list(self, temp_output_dir):
|
||||||
|
"""Test s prázdným seznamem souborů"""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
success, fail = manager.create_structure_for_files([])
|
||||||
|
|
||||||
|
assert success == 0
|
||||||
|
assert fail == 0
|
||||||
|
|
||||||
|
def test_files_without_tags(self, temp_source_dir, temp_output_dir, tag_manager):
|
||||||
|
"""Test se soubory bez tagů"""
|
||||||
|
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f1.tags.clear() # Remove default tags
|
||||||
|
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
success, fail = manager.create_structure_for_files([f1])
|
||||||
|
|
||||||
|
assert success == 0
|
||||||
|
assert fail == 0
|
||||||
|
|
||||||
|
def test_duplicate_link_same_file(self, files_with_tags, temp_output_dir):
|
||||||
|
"""Test že existující hardlink na stejný soubor je přeskočen"""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
|
||||||
|
# Create first time
|
||||||
|
_success1, _ = manager.create_structure_for_files(files_with_tags)
|
||||||
|
|
||||||
|
# Create second time - should skip existing
|
||||||
|
manager2 = HardlinkManager(temp_output_dir)
|
||||||
|
success2, fail2 = manager2.create_structure_for_files(files_with_tags)
|
||||||
|
|
||||||
|
# All should be skipped (same inode)
|
||||||
|
assert success2 == 0
|
||||||
|
assert fail2 == 0
|
||||||
|
|
||||||
|
def test_unique_name_on_conflict(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""Two different movies with the same clean name (no year to tell them
|
||||||
|
apart) in one folder get distinct links via a numeric suffix."""
|
||||||
|
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f1.tags.clear()
|
||||||
|
f1.add_tag(Tag("test", "tag"))
|
||||||
|
|
||||||
|
source2 = temp_source_dir / "subdir"
|
||||||
|
source2.mkdir()
|
||||||
|
(source2 / "file1.txt").write_text("different content")
|
||||||
|
f2 = File(source2 / "file1.txt", tag_manager)
|
||||||
|
f2.tags.clear()
|
||||||
|
f2.add_tag(Tag("test", "tag"))
|
||||||
|
|
||||||
|
# One generation over both files → the plan disambiguates the clash.
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
success, fail = manager.create_structure_for_files([f1, f2])
|
||||||
|
|
||||||
|
assert success == 2 and fail == 0
|
||||||
|
folder = temp_output_dir / "test" / "tag"
|
||||||
|
assert (folder / "file1.txt").exists()
|
||||||
|
assert (folder / "file1_2.txt").exists()
|
||||||
|
|
||||||
|
def test_czech_characters_in_tags(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""Test českých znaků v názvech tagů"""
|
||||||
|
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f1.tags.clear()
|
||||||
|
f1.add_tag(Tag("Žánr", "Česká komedie"))
|
||||||
|
f1.add_tag(Tag("Štítky", "Příběh"))
|
||||||
|
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
success, fail = manager.create_structure_for_files([f1])
|
||||||
|
|
||||||
|
assert success == 2
|
||||||
|
assert fail == 0
|
||||||
|
assert (temp_output_dir / "Žánr" / "Česká komedie" / "file1.txt").exists()
|
||||||
|
assert (temp_output_dir / "Štítky" / "Příběh" / "file1.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvenienceFunction:
|
||||||
|
"""Testy pro convenience funkci create_hardlink_structure"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tag_manager(self):
|
||||||
|
tm = TagManager()
|
||||||
|
for cat in list(tm.tags_by_category.keys()):
|
||||||
|
tm.remove_category(cat)
|
||||||
|
return tm
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_files(self, tmp_path, tag_manager):
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag"))
|
||||||
|
return [f]
|
||||||
|
|
||||||
|
def test_create_hardlink_structure_function(self, temp_files, tmp_path):
|
||||||
|
"""Test convenience funkce"""
|
||||||
|
output = tmp_path / "output"
|
||||||
|
output.mkdir()
|
||||||
|
|
||||||
|
success, fail, errors = create_hardlink_structure(temp_files, output)
|
||||||
|
|
||||||
|
assert success == 1
|
||||||
|
assert fail == 0
|
||||||
|
assert len(errors) == 0
|
||||||
|
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||||
|
|
||||||
|
def test_create_hardlink_structure_with_categories(self, tmp_path, tag_manager):
|
||||||
|
"""Test convenience funkce s filtrem kategorií"""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("include", "yes"))
|
||||||
|
f.add_tag(Tag("exclude", "no"))
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
output.mkdir()
|
||||||
|
|
||||||
|
success, _fail, _errors = create_hardlink_structure(
|
||||||
|
[f], output, categories=["include"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success == 1
|
||||||
|
assert (output / "include" / "yes" / "file.txt").exists()
|
||||||
|
assert not (output / "exclude").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncStructure:
|
||||||
|
"""Testy pro synchronizaci hardlink struktury"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tag_manager(self):
|
||||||
|
tm = TagManager()
|
||||||
|
for cat in list(tm.tags_by_category.keys()):
|
||||||
|
tm.remove_category(cat)
|
||||||
|
return tm
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup_dirs(self, tmp_path):
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
output = tmp_path / "output"
|
||||||
|
output.mkdir()
|
||||||
|
return source, output
|
||||||
|
|
||||||
|
def test_find_obsolete_links_empty_output(self, setup_dirs, tag_manager):
|
||||||
|
"""Test find_obsolete_links s prázdným výstupem"""
|
||||||
|
source, output = setup_dirs
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag"))
|
||||||
|
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
obsolete = manager.find_obsolete_links([f])
|
||||||
|
|
||||||
|
assert obsolete == []
|
||||||
|
|
||||||
|
def test_find_obsolete_links_detects_removed_tag(self, setup_dirs, tag_manager):
|
||||||
|
"""Test že find_obsolete_links najde hardlink pro odebraný tag"""
|
||||||
|
source, output = setup_dirs
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag1"))
|
||||||
|
f.add_tag(Tag("cat", "tag2"))
|
||||||
|
|
||||||
|
# Create structure with both tags
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.create_structure_for_files([f])
|
||||||
|
|
||||||
|
assert (output / "cat" / "tag1" / "file.txt").exists()
|
||||||
|
assert (output / "cat" / "tag2" / "file.txt").exists()
|
||||||
|
|
||||||
|
# Remove one tag from file
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag1")) # Only tag1 remains
|
||||||
|
|
||||||
|
# Find obsolete
|
||||||
|
obsolete = manager.find_obsolete_links([f])
|
||||||
|
|
||||||
|
assert len(obsolete) == 1
|
||||||
|
assert obsolete[0][0] == output / "cat" / "tag2" / "file.txt"
|
||||||
|
|
||||||
|
def test_remove_obsolete_links(self, setup_dirs, tag_manager):
|
||||||
|
"""Test odstranění zastaralých hardlinků"""
|
||||||
|
source, output = setup_dirs
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag1"))
|
||||||
|
f.add_tag(Tag("cat", "tag2"))
|
||||||
|
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.create_structure_for_files([f])
|
||||||
|
|
||||||
|
# Remove tag2
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag1"))
|
||||||
|
|
||||||
|
# Remove obsolete links
|
||||||
|
removed, _paths = manager.remove_obsolete_links([f])
|
||||||
|
|
||||||
|
assert removed == 1
|
||||||
|
assert not (output / "cat" / "tag2" / "file.txt").exists()
|
||||||
|
assert (output / "cat" / "tag1" / "file.txt").exists()
|
||||||
|
|
||||||
|
def test_remove_obsolete_links_dry_run(self, setup_dirs, tag_manager):
|
||||||
|
"""Test dry run pro remove_obsolete_links"""
|
||||||
|
source, output = setup_dirs
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag1"))
|
||||||
|
f.add_tag(Tag("cat", "tag2"))
|
||||||
|
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.create_structure_for_files([f])
|
||||||
|
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag1"))
|
||||||
|
|
||||||
|
removed, _paths = manager.remove_obsolete_links([f], dry_run=True)
|
||||||
|
|
||||||
|
assert removed == 1
|
||||||
|
# File should still exist (dry run)
|
||||||
|
assert (output / "cat" / "tag2" / "file.txt").exists()
|
||||||
|
|
||||||
|
def test_sync_structure_creates_and_removes(self, setup_dirs, tag_manager):
|
||||||
|
"""Test sync_structure vytvoří nové a odstraní staré hardlinky"""
|
||||||
|
source, output = setup_dirs
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "old_tag"))
|
||||||
|
|
||||||
|
# Create initial structure
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.create_structure_for_files([f])
|
||||||
|
|
||||||
|
assert (output / "cat" / "old_tag" / "file.txt").exists()
|
||||||
|
|
||||||
|
# Change tags
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "new_tag"))
|
||||||
|
|
||||||
|
# Sync
|
||||||
|
created, c_fail, removed, r_fail = manager.sync_structure([f])
|
||||||
|
|
||||||
|
assert created == 1
|
||||||
|
assert removed == 1
|
||||||
|
assert c_fail == 0
|
||||||
|
assert r_fail == 0
|
||||||
|
assert not (output / "cat" / "old_tag").exists()
|
||||||
|
assert (output / "cat" / "new_tag" / "file.txt").exists()
|
||||||
|
|
||||||
|
def test_sync_structure_no_changes_needed(self, setup_dirs, tag_manager):
|
||||||
|
"""Test sync_structure když není potřeba žádná změna"""
|
||||||
|
source, output = setup_dirs
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag"))
|
||||||
|
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.create_structure_for_files([f])
|
||||||
|
|
||||||
|
# Sync again without changes
|
||||||
|
_created, _c_fail, removed, _r_fail = manager.sync_structure([f])
|
||||||
|
|
||||||
|
# Nothing should change (existing links are skipped)
|
||||||
|
assert removed == 0
|
||||||
|
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||||
|
|
||||||
|
def test_find_obsolete_with_category_filter(self, setup_dirs, tag_manager):
|
||||||
|
"""Test find_obsolete_links s filtrem kategorií"""
|
||||||
|
source, output = setup_dirs
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat1", "tag"))
|
||||||
|
f.add_tag(Tag("cat2", "tag"))
|
||||||
|
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.create_structure_for_files([f])
|
||||||
|
|
||||||
|
# Remove both tags
|
||||||
|
f.tags.clear()
|
||||||
|
|
||||||
|
# Find obsolete only in cat1
|
||||||
|
obsolete = manager.find_obsolete_links([f], categories=["cat1"])
|
||||||
|
|
||||||
|
assert len(obsolete) == 1
|
||||||
|
assert obsolete[0][0] == output / "cat1" / "tag" / "file.txt"
|
||||||
|
|
||||||
|
def test_removes_empty_directories(self, setup_dirs, tag_manager):
|
||||||
|
"""Test že prázdné adresáře jsou odstraněny po sync"""
|
||||||
|
source, output = setup_dirs
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("category", "tag"))
|
||||||
|
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.create_structure_for_files([f])
|
||||||
|
|
||||||
|
# Remove all tags
|
||||||
|
f.tags.clear()
|
||||||
|
|
||||||
|
manager.remove_obsolete_links([f])
|
||||||
|
|
||||||
|
# Directory should be gone
|
||||||
|
assert not (output / "category" / "tag").exists()
|
||||||
|
assert not (output / "category").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Testy pro okrajové případy"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tag_manager(self):
|
||||||
|
tm = TagManager()
|
||||||
|
for cat in list(tm.tags_by_category.keys()):
|
||||||
|
tm.remove_category(cat)
|
||||||
|
return tm
|
||||||
|
|
||||||
|
def test_nonexistent_output_dir_created(self, tmp_path, tag_manager):
|
||||||
|
"""Test že výstupní složka je vytvořena pokud neexistuje"""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag"))
|
||||||
|
|
||||||
|
output = tmp_path / "output" / "nested" / "deep"
|
||||||
|
# output doesn't exist
|
||||||
|
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
success, _fail = manager.create_structure_for_files([f])
|
||||||
|
|
||||||
|
assert success == 1
|
||||||
|
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||||
|
|
||||||
|
def test_special_characters_in_filename(self, tmp_path, tag_manager):
|
||||||
|
"""Test souboru se speciálními znaky v názvu"""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
# Parentheses that are NOT a four-digit year must survive verbatim
|
||||||
|
# (a trailing "(YYYY)" would be stripped by the naming rule).
|
||||||
|
(source / "file with spaces (part 2).txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file with spaces (part 2).txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("test", "tag"))
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
output.mkdir()
|
||||||
|
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
success, _fail = manager.create_structure_for_files([f])
|
||||||
|
|
||||||
|
assert success == 1
|
||||||
|
assert (output / "test" / "tag" / "file with spaces (part 2).txt").exists()
|
||||||
|
|
||||||
|
def test_empty_category_filter(self, tmp_path, tag_manager):
|
||||||
|
"""Test s prázdným seznamem kategorií"""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "file.txt").write_text("content")
|
||||||
|
|
||||||
|
f = File(source / "file.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("cat", "tag"))
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
output.mkdir()
|
||||||
|
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
# Empty list = no categories = no links
|
||||||
|
success, _fail = manager.create_structure_for_files([f], categories=[])
|
||||||
|
|
||||||
|
assert success == 0
|
||||||
|
|
||||||
|
def test_is_same_file_method(self, tmp_path):
|
||||||
|
"""Test metody _is_same_file"""
|
||||||
|
file1 = tmp_path / "file1.txt"
|
||||||
|
file1.write_text("content")
|
||||||
|
|
||||||
|
link = tmp_path / "link.txt"
|
||||||
|
os.link(file1, link)
|
||||||
|
|
||||||
|
file2 = tmp_path / "file2.txt"
|
||||||
|
file2.write_text("different")
|
||||||
|
|
||||||
|
manager = HardlinkManager(tmp_path)
|
||||||
|
|
||||||
|
# Same inode
|
||||||
|
assert manager._is_same_file(file1, link) is True
|
||||||
|
|
||||||
|
# Different inode
|
||||||
|
assert manager._is_same_file(file1, file2) is False
|
||||||
|
|
||||||
|
# Non-existent file
|
||||||
|
assert manager._is_same_file(file1, tmp_path / "nonexistent") is False
|
||||||
|
|
||||||
|
def test_get_unique_name_method(self, tmp_path):
|
||||||
|
"""Test metody _get_unique_name"""
|
||||||
|
(tmp_path / "file.txt").write_text("1")
|
||||||
|
(tmp_path / "file_1.txt").write_text("2")
|
||||||
|
(tmp_path / "file_2.txt").write_text("3")
|
||||||
|
|
||||||
|
manager = HardlinkManager(tmp_path)
|
||||||
|
unique = manager._get_unique_name(tmp_path / "file.txt")
|
||||||
|
|
||||||
|
assert unique == tmp_path / "file_3.txt"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMirrorAsIs:
|
||||||
|
"""Testy pro copy-as-is zrcadlení (Seriály)"""
|
||||||
|
|
||||||
|
def test_mirror_clones_hierarchy_with_hardlinks(self, tmp_path):
|
||||||
|
"""Adresářová struktura se zrcadlí 1:1 a soubory jsou hardlinky"""
|
||||||
|
source = tmp_path / "Seriály"
|
||||||
|
(source / "Show" / "S01").mkdir(parents=True)
|
||||||
|
ep1 = source / "Show" / "S01" / "ep1.mkv"
|
||||||
|
ep2 = source / "Show" / "S01" / "ep2.mkv"
|
||||||
|
ep1.write_text("a")
|
||||||
|
ep2.write_text("b")
|
||||||
|
|
||||||
|
output = tmp_path / "out"
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
created, failed = manager.mirror_as_is(source, "Seriály")
|
||||||
|
|
||||||
|
assert failed == 0
|
||||||
|
assert created == 2
|
||||||
|
linked = output / "Seriály" / "Show" / "S01" / "ep1.mkv"
|
||||||
|
assert linked.exists()
|
||||||
|
assert linked.stat().st_ino == ep1.stat().st_ino
|
||||||
|
|
||||||
|
def test_mirror_skips_curator_metadata(self, tmp_path):
|
||||||
|
"""Metadata soubory (.!tag, .!index) se nezrcadlí"""
|
||||||
|
source = tmp_path / "Seriály"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "ep1.mkv").write_text("a")
|
||||||
|
(source / ".ep1.mkv.!tag").write_text("{}")
|
||||||
|
(source / ".Curator.!index").write_text("{}")
|
||||||
|
|
||||||
|
output = tmp_path / "out"
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
created, failed = manager.mirror_as_is(source, "Seriály")
|
||||||
|
|
||||||
|
assert created == 1
|
||||||
|
assert failed == 0
|
||||||
|
assert not (output / "Seriály" / ".ep1.mkv.!tag").exists()
|
||||||
|
|
||||||
|
def test_mirror_nonexistent_source_is_noop(self, tmp_path):
|
||||||
|
"""Neexistující zdroj nic neudělá"""
|
||||||
|
manager = HardlinkManager(tmp_path / "out")
|
||||||
|
assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveMirror:
|
||||||
|
"""Removing a whole mirrored library (or one bucket entry) from the output."""
|
||||||
|
|
||||||
|
def test_removes_a_whole_mirrored_folder(self, tmp_path):
|
||||||
|
output = tmp_path / "out"
|
||||||
|
(output / "Dokumenty" / "sub").mkdir(parents=True)
|
||||||
|
(output / "Dokumenty" / "sub" / "clip.mp4").write_bytes(b"x")
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
|
||||||
|
assert manager.remove_mirror("Dokumenty") is True
|
||||||
|
assert not (output / "Dokumenty").exists()
|
||||||
|
|
||||||
|
def test_removes_one_bucket_entry_by_compound_name(self, tmp_path):
|
||||||
|
output = tmp_path / "out"
|
||||||
|
(output / "Seriály" / "Show A").mkdir(parents=True)
|
||||||
|
(output / "Seriály" / "Show B").mkdir(parents=True)
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
|
||||||
|
assert manager.remove_mirror("Seriály/Show A") is True
|
||||||
|
assert not (output / "Seriály" / "Show A").exists()
|
||||||
|
assert (output / "Seriály" / "Show B").exists() # sibling untouched
|
||||||
|
|
||||||
|
def test_missing_target_returns_false(self, tmp_path):
|
||||||
|
manager = HardlinkManager(tmp_path / "out")
|
||||||
|
assert manager.remove_mirror("Nope") is False
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Tests for video integrity checking (src.core.integrity).
|
||||||
|
|
||||||
|
The FFmpeg subprocess call is monkeypatched so the tests don't need ffmpeg
|
||||||
|
installed and don't touch real video data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core import integrity
|
||||||
|
from src.core.integrity import IntegrityResult, check_video_integrity
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def video_file(tmp_path) -> Path:
|
||||||
|
f = tmp_path / "movie.mkv"
|
||||||
|
f.write_bytes(b"not really a video, but a real file")
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_run(returncode=0, stdout="", stderr=""):
|
||||||
|
def run(cmd, **kwargs):
|
||||||
|
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_clean_file_is_ok(monkeypatch, video_file):
|
||||||
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||||
|
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", ""))
|
||||||
|
result = check_video_integrity(video_file, deep=True)
|
||||||
|
assert result.ok and result.checked and result.error == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_reports_decode_errors(monkeypatch, video_file):
|
||||||
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
integrity.subprocess,
|
||||||
|
"run",
|
||||||
|
_fake_run(1, "", "[matroska @ 0x..] Read error\nmore noise"),
|
||||||
|
)
|
||||||
|
result = check_video_integrity(video_file, deep=True)
|
||||||
|
assert not result.ok and result.checked
|
||||||
|
assert result.error == "[matroska @ 0x..] Read error" # first line only
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_stderr_without_nonzero_rc_still_fails(monkeypatch, video_file):
|
||||||
|
"""ffmpeg can print decode errors while returning rc 0 — must still fail."""
|
||||||
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||||
|
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", "corrupt packet"))
|
||||||
|
result = check_video_integrity(video_file, deep=True)
|
||||||
|
assert not result.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_ok_when_video_stream_present(monkeypatch, video_file):
|
||||||
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffprobe")
|
||||||
|
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "video\n", ""))
|
||||||
|
result = check_video_integrity(video_file, deep=False)
|
||||||
|
assert result.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_fails_without_video_stream(monkeypatch, video_file):
|
||||||
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffprobe")
|
||||||
|
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", ""))
|
||||||
|
result = check_video_integrity(video_file, deep=False)
|
||||||
|
assert not result.ok and "video stream" in result.error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_file_is_unchecked_not_corrupt(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||||
|
result = check_video_integrity(tmp_path / "nope.mkv", deep=True)
|
||||||
|
assert not result.ok and not result.checked and "neexistuje" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_tool_is_unchecked(monkeypatch, video_file):
|
||||||
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: None)
|
||||||
|
result = check_video_integrity(video_file, deep=True)
|
||||||
|
assert not result.ok and not result.checked
|
||||||
|
assert "ffmpeg" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_reports_failure(monkeypatch, video_file):
|
||||||
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||||
|
|
||||||
|
def raise_timeout(cmd, **kwargs):
|
||||||
|
raise subprocess.TimeoutExpired(cmd, 1)
|
||||||
|
|
||||||
|
monkeypatch.setattr(integrity.subprocess, "run", raise_timeout)
|
||||||
|
result = check_video_integrity(video_file, deep=True, timeout=1)
|
||||||
|
assert not result.ok and result.checked and "timeout" in result.error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ffmpeg_available_reflects_which(monkeypatch):
|
||||||
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||||
|
assert integrity.ffmpeg_available(deep=True)
|
||||||
|
monkeypatch.setattr(integrity.shutil, "which", lambda _: None)
|
||||||
|
assert not integrity.ffmpeg_available(deep=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
|
||||||
|
"""FileManager.scan_video_integrity aggregates per-file results."""
|
||||||
|
from src.core.file import File
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
|
good = tmp_path / "good.mkv"
|
||||||
|
bad = tmp_path / "bad.mkv"
|
||||||
|
good.write_bytes(b"x")
|
||||||
|
bad.write_bytes(b"y")
|
||||||
|
|
||||||
|
tm = TagManager()
|
||||||
|
fm = FileManager(tm)
|
||||||
|
files = [File(good, tm), File(bad, tm)]
|
||||||
|
|
||||||
|
def fake_check(path, deep=True):
|
||||||
|
ok = Path(path).name == "good.mkv"
|
||||||
|
return IntegrityResult(Path(path), ok=ok, error="" if ok else "broken")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: True)
|
||||||
|
monkeypatch.setattr("src.core.file_manager.check_video_integrity", fake_check)
|
||||||
|
|
||||||
|
seen: list[int] = []
|
||||||
|
result = fm.scan_video_integrity(
|
||||||
|
files, deep=True, on_progress=lambda d, t, f: seen.append(d)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["ok"] and result["checked"] == 2
|
||||||
|
assert [r.path.name for r in result["problems"]] == ["bad.mkv"]
|
||||||
|
assert seen == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_stops_on_cancel(monkeypatch, tmp_path):
|
||||||
|
from src.core.file import File
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for i in range(3):
|
||||||
|
p = tmp_path / f"m{i}.mkv"
|
||||||
|
p.write_bytes(b"x")
|
||||||
|
files.append(File(p, TagManager()))
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.core.file_manager.check_video_integrity",
|
||||||
|
lambda path, deep=True: IntegrityResult(Path(path), ok=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
fm = FileManager(TagManager())
|
||||||
|
result = fm.scan_video_integrity(files, should_cancel=lambda: True)
|
||||||
|
assert result["cancelled"] and result["checked"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_reports_missing_ffmpeg(monkeypatch, tmp_path):
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: False)
|
||||||
|
fm = FileManager(TagManager())
|
||||||
|
result = fm.scan_video_integrity([], deep=True)
|
||||||
|
assert not result["ok"] and "ffmpeg" in result["error"]
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Tests for the Jellyfin client + pool verification. Network is monkeypatched."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core import jellyfin
|
||||||
|
from src.core.jellyfin import JellyfinError, JellyfinMovie
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _configured(monkeypatch):
|
||||||
|
monkeypatch.setenv("JELLYFIN_URL", "http://jf:8096/")
|
||||||
|
monkeypatch.setenv("JELLYFIN_API_KEY", "key")
|
||||||
|
|
||||||
|
|
||||||
|
def _resp(status=200, payload=None):
|
||||||
|
return SimpleNamespace(
|
||||||
|
status_code=status, ok=(200 <= status < 300), json=lambda: payload or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_url_strips_trailing_slash():
|
||||||
|
assert jellyfin.base_url() == "http://jf:8096"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_available(monkeypatch):
|
||||||
|
assert jellyfin.api_available()
|
||||||
|
monkeypatch.delenv("JELLYFIN_API_KEY", raising=False)
|
||||||
|
assert not jellyfin.api_available()
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_version(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
jellyfin.requests, "request", lambda *a, **k: _resp(200, {"Version": "10.9.11"})
|
||||||
|
)
|
||||||
|
assert jellyfin.server_version() == "10.9.11"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_all_posts_refresh(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake(method, url, **k):
|
||||||
|
seen["method"], seen["url"] = method, url
|
||||||
|
return _resp(200, {})
|
||||||
|
|
||||||
|
monkeypatch.setattr(jellyfin.requests, "request", fake)
|
||||||
|
jellyfin.scan_all()
|
||||||
|
assert seen["method"] == "POST" and seen["url"].endswith("/Library/Refresh")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_movies_maps_tmdb_id(monkeypatch):
|
||||||
|
payload = {
|
||||||
|
"Items": [
|
||||||
|
{
|
||||||
|
"Id": "a1",
|
||||||
|
"Name": "Matrix",
|
||||||
|
"ProductionYear": 1999,
|
||||||
|
"ProviderIds": {"Tmdb": "603"},
|
||||||
|
"Path": "/m/Matrix (1999) [tmdbid-603].mkv",
|
||||||
|
},
|
||||||
|
{"Id": "a2", "Name": "Bez ID", "ProductionYear": 2000, "ProviderIds": {}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
jellyfin.requests, "request", lambda *a, **k: _resp(200, payload)
|
||||||
|
)
|
||||||
|
movies = jellyfin.fetch_movies()
|
||||||
|
assert movies[0] == JellyfinMovie(
|
||||||
|
"a1", "Matrix", 1999, 603, "/m/Matrix (1999) [tmdbid-603].mkv"
|
||||||
|
)
|
||||||
|
assert movies[1].tmdb_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_config_raises(monkeypatch):
|
||||||
|
monkeypatch.delenv("JELLYFIN_URL", raising=False)
|
||||||
|
with pytest.raises(JellyfinError):
|
||||||
|
jellyfin.server_version()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_auth_raises(monkeypatch):
|
||||||
|
monkeypatch.setattr(jellyfin.requests, "request", lambda *a, **k: _resp(401))
|
||||||
|
with pytest.raises(JellyfinError):
|
||||||
|
jellyfin.scan_all()
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerifyAgainstJellyfin:
|
||||||
|
@pytest.fixture
|
||||||
|
def file_manager(self, tmp_path, monkeypatch):
|
||||||
|
import src.core.config as config_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
|
fm = FileManager(TagManager())
|
||||||
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
|
return fm
|
||||||
|
|
||||||
|
def test_match_by_tmdb_id_and_report_gaps(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"y")
|
||||||
|
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
|
||||||
|
file_manager.import_movie(tmp_path / "b.mkv", "Neznámý", year=2020) # no id
|
||||||
|
|
||||||
|
jf = [
|
||||||
|
JellyfinMovie("j1", "Matrix", 1999, 603, None), # matches a by tmdb
|
||||||
|
JellyfinMovie("j2", "Cizí film", 2011, 999, None), # jellyfin-only
|
||||||
|
]
|
||||||
|
result = file_manager.verify_against_jellyfin(jf)
|
||||||
|
|
||||||
|
assert result["matched"] == 1
|
||||||
|
assert result["pool_only"] == ["Neznámý (2020).mkv"]
|
||||||
|
assert result["jellyfin_only"] == ["Cizí film"]
|
||||||
|
assert result["pool_total"] == 2 and result["jellyfin_total"] == 2
|
||||||
|
|
||||||
|
def test_match_by_title_year_fallback(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
file_manager.import_movie(tmp_path / "a.mkv", "Kolja", year=1996) # no id
|
||||||
|
|
||||||
|
jf = [JellyfinMovie("j1", "Kolja", 1996, None, None)]
|
||||||
|
result = file_manager.verify_against_jellyfin(jf)
|
||||||
|
assert result["matched"] == 1 and result["pool_only"] == []
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Tests for recovering ČSFD link / title / year from pre-index layouts."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from src.core.legacy_meta import LegacyMeta, read_legacy_meta
|
||||||
|
|
||||||
|
|
||||||
|
def _video(tmp_path, name="Matrix (1999).mkv"):
|
||||||
|
v = tmp_path / name
|
||||||
|
v.write_bytes(b"x")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_tagger_era_sidecar(tmp_path):
|
||||||
|
video = _video(tmp_path)
|
||||||
|
(tmp_path / f".{video.name}.!tag").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"title": "Matrix",
|
||||||
|
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
|
||||||
|
"csfd_cache": {"year": 1999, "rating": 90},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = read_legacy_meta(video)
|
||||||
|
assert meta == LegacyMeta(
|
||||||
|
csfd_url="https://www.csfd.cz/film/9499-matrix/",
|
||||||
|
title="Matrix",
|
||||||
|
year="1999",
|
||||||
|
source="sidecar",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidecar_new_key_and_meta_block(tmp_path):
|
||||||
|
video = _video(tmp_path)
|
||||||
|
(tmp_path / f".{video.name}.!tag").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"title": "Matrix",
|
||||||
|
"csfd_url": "https://www.csfd.cz/film/9499/",
|
||||||
|
"meta": {"year": "1999"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = read_legacy_meta(video)
|
||||||
|
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
|
||||||
|
assert meta.year == "1999"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_old_pool_index_by_relative_key(tmp_path):
|
||||||
|
pool = tmp_path / "old-pool"
|
||||||
|
movies_dir = pool / "Filmy"
|
||||||
|
movies_dir.mkdir(parents=True)
|
||||||
|
video = _video(movies_dir)
|
||||||
|
(pool / ".Curator.!index").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"settings": {},
|
||||||
|
"movies": {
|
||||||
|
"Filmy/Matrix (1999).mkv": {
|
||||||
|
"title": "Matrix",
|
||||||
|
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
|
||||||
|
"csfd_cache": {"year": 1999},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = read_legacy_meta(video)
|
||||||
|
assert meta.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
|
||||||
|
assert meta.title == "Matrix"
|
||||||
|
assert meta.year == "1999"
|
||||||
|
assert meta.source == "index"
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_fallback_to_unique_basename_match(tmp_path):
|
||||||
|
pool = tmp_path / "pool"
|
||||||
|
pool.mkdir()
|
||||||
|
elsewhere = tmp_path / "loose"
|
||||||
|
elsewhere.mkdir()
|
||||||
|
video = _video(elsewhere)
|
||||||
|
(pool.parent / ".Curator.!index").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"movies": {
|
||||||
|
"whatever/Matrix (1999).mkv": {
|
||||||
|
"csfd_url": "https://www.csfd.cz/film/9499/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = read_legacy_meta(video)
|
||||||
|
assert meta is not None
|
||||||
|
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidecar_wins_over_index(tmp_path):
|
||||||
|
video = _video(tmp_path)
|
||||||
|
(tmp_path / f".{video.name}.!tag").write_text(
|
||||||
|
json.dumps({"csfd_link": "https://www.csfd.cz/film/SIDECAR/"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(tmp_path / ".Curator.!index").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"movies": {
|
||||||
|
"Matrix (1999).mkv": {
|
||||||
|
"csfd_link": "https://www.csfd.cz/film/INDEX/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert read_legacy_meta(video).csfd_url == "https://www.csfd.cz/film/SIDECAR/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_when_no_metadata(tmp_path):
|
||||||
|
assert read_legacy_meta(_video(tmp_path)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_on_broken_sidecar(tmp_path):
|
||||||
|
video = _video(tmp_path)
|
||||||
|
(tmp_path / f".{video.name}.!tag").write_text("{not json", encoding="utf-8")
|
||||||
|
assert read_legacy_meta(video) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_without_useful_fields_is_none(tmp_path):
|
||||||
|
video = _video(tmp_path)
|
||||||
|
(tmp_path / f".{video.name}.!tag").write_text(
|
||||||
|
json.dumps({"new": True, "ignored": False, "tags": []}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert read_legacy_meta(video) is None
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import tempfile
|
||||||
|
import tkinter as tk
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
|
from src.core.media_utils import load_icon
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def tk_root():
|
||||||
|
"""Fixture pro inicializaci Tkinteru (nutné pro ImageTk)."""
|
||||||
|
root = tk.Tk()
|
||||||
|
yield root
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_icon_returns_photoimage(tk_root):
|
||||||
|
"""Test že load_icon vrací PhotoImage"""
|
||||||
|
# vytvoříme dočasný obrázek
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
try:
|
||||||
|
# vytvoříme 100x100 červený obrázek
|
||||||
|
img = Image.new("RGB", (100, 100), color="red")
|
||||||
|
img.save(tmp_path)
|
||||||
|
|
||||||
|
icon = load_icon(tmp_path)
|
||||||
|
|
||||||
|
# musí být PhotoImage
|
||||||
|
assert isinstance(icon, ImageTk.PhotoImage)
|
||||||
|
|
||||||
|
# ověříme velikost 16x16
|
||||||
|
assert icon.width() == 16
|
||||||
|
assert icon.height() == 16
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_icon_resizes_image(tk_root):
|
||||||
|
"""Test že load_icon správně změní velikost obrázku"""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
try:
|
||||||
|
# vytvoříme velký obrázek 500x500
|
||||||
|
img = Image.new("RGB", (500, 500), color="blue")
|
||||||
|
img.save(tmp_path)
|
||||||
|
|
||||||
|
icon = load_icon(tmp_path)
|
||||||
|
|
||||||
|
# i velký obrázek by měl být zmenšen na 16x16
|
||||||
|
assert icon.width() == 16
|
||||||
|
assert icon.height() == 16
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_icon_different_formats(tk_root):
|
||||||
|
"""Test načítání různých formátů obrázků"""
|
||||||
|
formats = [".png", ".jpg", ".bmp"]
|
||||||
|
|
||||||
|
for fmt in formats:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=fmt, delete=False) as tmp:
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
try:
|
||||||
|
img = Image.new("RGB", (32, 32), color="green")
|
||||||
|
img.save(tmp_path)
|
||||||
|
|
||||||
|
icon = load_icon(tmp_path)
|
||||||
|
|
||||||
|
assert isinstance(icon, ImageTk.PhotoImage)
|
||||||
|
assert icon.width() == 16
|
||||||
|
assert icon.height() == 16
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||