Compare commits
3
Commits
1c78ca9013
...
79c9e34a5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79c9e34a5d | ||
|
|
39712d7283 | ||
|
|
6a2baf243f |
+124
@@ -21,6 +21,130 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### 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
|
||||||
|
(its subfolders are series). Movies can be imported into / moved between mixed
|
||||||
|
libraries.
|
||||||
|
- **Cleanup of leftover library folders.** After editing libraries, Curator now
|
||||||
|
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`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at
|
||||||
|
import (new column in the movie import dialog) and editable in *Upravit film*.
|
||||||
|
Kept only as a pointer next to the TMDb id; nothing is fetched from ČSFD.
|
||||||
|
- **Series import into `mixed` libraries.** `series_libraries` (import target +
|
||||||
|
`load_series` scan) now covers kind `series` **and** `mixed`, so episodes can be
|
||||||
|
imported into a mixed library and show up in the series overview/editor.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**.
|
||||||
|
(Genre tags come from the NFO; manual tag editing isn't needed in the Jellyfin
|
||||||
|
flow.)
|
||||||
|
- **Episode filenames now keep the original name:** `SxxEyy - {original}.ext`
|
||||||
|
(was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The
|
||||||
|
`SxxEyy` code is stripped from the original before re-applying, so repeated
|
||||||
|
renames don't double it (`series.episode_body`); `episode_filename` /
|
||||||
|
`is_canonical_episode` and the series import/rename/edit paths follow.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **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`; the legacy `movie_libraries` /
|
||||||
|
`copyasis_folders` are now 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; their scanning/generation lands in the next
|
||||||
|
phases (for now only movie + series libraries are processed).
|
||||||
|
- **Pool migration to TMDb** (phase 6; `src/core/migrate.py`, *Nástroje →
|
||||||
|
"Spárovat pool s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet)
|
||||||
|
against TMDb by title + year and proposes an id, filling a missing year from
|
||||||
|
TMDb. Classification is conservative — **jisté** (exact diacritics-insensitive
|
||||||
|
title + matching year, unique), **nejednoznačné** (pick from candidates),
|
||||||
|
**bez shody**. A review dialog lets you apply just the confident ones or the
|
||||||
|
ones you pick; renames to `Title (rok) [tmdbid].ext`. Already-migrated files are
|
||||||
|
skipped, so it is re-runnable. `FileManager.match_pool_to_tmdb` /
|
||||||
|
`assign_tmdb_id` drive it.
|
||||||
|
- **Consolidated edit dialogs.** *Filmy → "Upravit film…"* (double-click a row)
|
||||||
|
opens one dialog for every Curator-owned parameter of a movie — title, year,
|
||||||
|
TMDb ID (with "Dohledat z TMDb"), library, date, free-form attributes and tags
|
||||||
|
— and applies them at once (rename `Title (rok) [tmdbid].ext`, move between
|
||||||
|
libraries, set metadata) via `FileManager.apply_movie_edits`. In *Seriály →
|
||||||
|
"Přehled seriálů…"* a series now opens an editor (double-click / "Upravit
|
||||||
|
seriál…"): edit the series' title/year/TMDb ID/library **and each episode's
|
||||||
|
season/episode number**, which renames the folder and lays the episodes out as
|
||||||
|
`Series SxxEyy.ext` (`FileManager.apply_series_edits`). The double-click on a
|
||||||
|
movie now edits it; "Otevřít" (playback) stays in the context menu.
|
||||||
|
|
||||||
|
## 1.12.0 — 2026-08-31
|
||||||
|
|
||||||
|
### 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
|
## 1.11.0 — 2026-07-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Python Development Guidelines
|
||||||
|
|
||||||
|
**Document Version:** v11
|
||||||
|
|
||||||
|
> **Note on Versioning:**
|
||||||
|
> - This document version is independent — reused across projects
|
||||||
|
> - **Project version** source of truth: `pyproject.toml`
|
||||||
|
> - Version propagates: `pyproject.toml` → `constants.py` → code
|
||||||
|
> - `CHANGELOG.md` uses project version from `pyproject.toml`
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- **README.md** — Project overview, tool descriptions, build instructions
|
||||||
|
- **AGENTS.md** — Rules for AI assistants
|
||||||
|
- **PROJECT.md** — Project goals and current state
|
||||||
|
- **CHANGELOG.md** — Version history
|
||||||
|
|
||||||
|
### Documentation Organization
|
||||||
|
|
||||||
|
All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root.
|
||||||
|
|
||||||
|
The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_PYTHON.md`, `PROJECT.md`, `CHANGELOG.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Code Style
|
||||||
|
|
||||||
|
- **PEP8** with 120-character lines (Ruff)
|
||||||
|
- **4 spaces** indentation
|
||||||
|
- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants
|
||||||
|
- **Type hints** required on all functions
|
||||||
|
- **Import order**: stdlib → third-party → local
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. SOLID Principles
|
||||||
|
|
||||||
|
- **SRP** — One class = one responsibility
|
||||||
|
- **OCP** — Open for extension, closed for modification
|
||||||
|
- **LSP** — Subclasses substitutable for parents
|
||||||
|
- **ISP** — Small interfaces over large ones
|
||||||
|
- **DIP** — Depend on abstractions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dependency Injection
|
||||||
|
|
||||||
|
Pass dependencies via constructor. Never instantiate dependencies inside a class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Protocols Over Inheritance
|
||||||
|
|
||||||
|
Prefer `typing.Protocol` and composition over class inheritance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data Classes
|
||||||
|
|
||||||
|
Use `@dataclass` for internal data structures, `pydantic.BaseModel` for data that requires validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Logging and Console Output
|
||||||
|
|
||||||
|
### Logging — loguru
|
||||||
|
|
||||||
|
Use **loguru** for all internal logging. Never log secrets, passwords, tokens, or API keys.
|
||||||
|
|
||||||
|
#### Log sinks
|
||||||
|
|
||||||
|
| Sink | Level | Format |
|
||||||
|
|------|-------|--------|
|
||||||
|
| File `logs/{AppName}_{time}.log` | `DEBUG` | full (timestamp + level + message) |
|
||||||
|
| stdout | `INFO` | full |
|
||||||
|
|
||||||
|
File sink retains **max 10 log files** (`retention=10`). No rotation by size — each run creates a new file via `{time}` in the filename.
|
||||||
|
|
||||||
|
The `DEBUG` sink is only active when `constants.DEFAULT_DEBUG` is `True` (controlled by `ENV_DEBUG=true` in `.env`).
|
||||||
|
|
||||||
|
Additional sinks (e.g. GUI log panels) may be added per project as needed.
|
||||||
|
|
||||||
|
#### Log levels
|
||||||
|
|
||||||
|
| Level | When to use |
|
||||||
|
|-------|-------------|
|
||||||
|
| `DEBUG` | Per-item detail: individual file reads/writes, cache hits, per-row operations |
|
||||||
|
| `INFO` | User-visible milestones: file loaded, process started/finished, file saved |
|
||||||
|
| `WARNING` | Recoverable issues: fallback used, unexpected but non-fatal state |
|
||||||
|
| `ERROR` | Failures the user must know about: missing file, failed save, missing config — operation cannot continue |
|
||||||
|
|
||||||
|
### Console output — print()
|
||||||
|
|
||||||
|
`print()` is **allowed** for direct user-facing console communication: input prompts, progress lines, and result summaries. This applies to console tools that interact with the user through stdin/stdout.
|
||||||
|
|
||||||
|
`print()` is **not** a substitute for loguru. It must not be used for debugging or internal event tracking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Environment and Secrets
|
||||||
|
|
||||||
|
- Secrets in `.env` file; controlled by `ENV_DEBUG=true/false`
|
||||||
|
- Load via `python-dotenv` and `os.getenv()`
|
||||||
|
- Never commit `.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Error Handling
|
||||||
|
|
||||||
|
Define specific exception types. Use a fail-fast approach — surface errors early rather than silently continuing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Testing
|
||||||
|
|
||||||
|
- **pytest** only — no `unittest`, no `TestCase` classes, no `self.assert*`
|
||||||
|
- Arrange-Act-Assert pattern
|
||||||
|
- Test naming: `test_<action>_<context>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Tooling
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| **Ruff** | Formatting and linting |
|
||||||
|
| **mypy** | Static type checking |
|
||||||
|
| **pytest** | Testing |
|
||||||
|
|
||||||
|
Run before every commit:
|
||||||
|
```bash
|
||||||
|
poetry run ruff check
|
||||||
|
poetry run mypy
|
||||||
|
poetry run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Poetry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry install # Install all dependencies
|
||||||
|
poetry add <pkg> # Add runtime dependency
|
||||||
|
poetry add --group dev <pkg> # Add dev dependency
|
||||||
|
poetry remove <pkg> # Remove dependency
|
||||||
|
poetry run <cmd> # Run command in virtualenv
|
||||||
|
```
|
||||||
|
|
||||||
|
Never edit `pyproject.toml` directly to add or remove dependencies.
|
||||||
|
|
||||||
|
### poetry.lock
|
||||||
|
|
||||||
|
`poetry.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
project/
|
||||||
|
├── entry_point.py # Entry point(s) — named by purpose or tool name
|
||||||
|
├── src/ # All application modules
|
||||||
|
├── tests/ # Tests
|
||||||
|
├── docs/ # Detailed documentation
|
||||||
|
├── .venv/ # Virtual environment (managed by Poetry)
|
||||||
|
└── pyproject.toml # Project config and dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
A project may have multiple entry points (e.g. `cli.py`, `gui.py`, or per-tool scripts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Distribution and Deployment
|
||||||
|
|
||||||
|
When a project is distributed as a standalone executable (no Python required on target machine):
|
||||||
|
|
||||||
|
- Use **PyInstaller** to compile each entry point into a single `.exe`
|
||||||
|
- Each tool has its own `.spec` file in the project root
|
||||||
|
- **Console tools use `console=True`** in the `.spec` — they rely on `input()` and `print()` for user interaction. GUI applications turn the console off with `ENV_BUILD_CONSOLE=false`; that flag is independent of `ENV_DEBUG`
|
||||||
|
- Compiled executables are stored in `dist/` and **committed to the repository** — the repository serves as the distribution channel for internal teams
|
||||||
|
- `.gitignore` must **not** exclude `dist/` in projects that use this deployment model
|
||||||
|
|
||||||
|
### prebuild.py
|
||||||
|
|
||||||
|
`prebuild.py` runs before PyInstaller. It verifies that the active interpreter is the project `.venv`, prints the resolved version, and rewrites the `console=` line in the `.spec` to match `.env`.
|
||||||
|
|
||||||
|
| Variable | Default | Effect |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| `ENV_BUILD_CONSOLE` | `true` | Value written into `console=` in the `.spec` |
|
||||||
|
| `ENV_BUILD_SPEC` | `<project folder>.spec` | Which `.spec` file to update |
|
||||||
|
|
||||||
|
`prebuild.py` touches one `.spec` per run — with several entry points, set `ENV_BUILD_SPEC` for each build.
|
||||||
|
|
||||||
|
Build commands:
|
||||||
|
```bash
|
||||||
|
poetry run python prebuild.py
|
||||||
|
poetry run pyinstaller ToolName.spec
|
||||||
|
```
|
||||||
|
|
||||||
|
> This section applies only to projects that produce standalone executables. Skip for libraries or web services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Versioning
|
||||||
|
|
||||||
|
- Follow **semantic versioning**: `MAJOR.MINOR.PATCH`
|
||||||
|
- Version is defined in `pyproject.toml` under `[project]`
|
||||||
|
- Always ask before bumping the version — never increment automatically
|
||||||
|
- Update `CHANGELOG.md` before bumping the version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Documentation and Task Management
|
||||||
|
|
||||||
|
- Keep `PROJECT.md` and `CHANGELOG.md` up to date when making changes
|
||||||
|
- Document architectural changes in this file or in `docs/`
|
||||||
|
|
||||||
|
### Task notation
|
||||||
|
|
||||||
|
Tasks are written as single-line comments directly in code, using the **Todo Tree** tags defined in `AGENTS.md` (`TODO`, `FIXME`, `BUG`, `HACK`, `NOTE`). `PROJECT.md` carries only cross-cutting tasks that have no single place in the code.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# TODO: extract this into a separate loader
|
||||||
|
# FIXME: crashes on an empty file
|
||||||
|
# BUG: rounding is off by one cent on negative amounts
|
||||||
|
# HACK: temporary workaround until the API adds paging
|
||||||
|
# NOTE: order matters here, the parser is stateful
|
||||||
|
```
|
||||||
|
|
||||||
|
No other task format is used — **no checkboxes, no numbered lists in documentation**.
|
||||||
|
|
||||||
|
If a tag already exists at a specific location in code, do not repeat it in `PROJECT.md`.
|
||||||
+113
-1
@@ -164,12 +164,124 @@ movie table, and one-click Filmotéka generation.
|
|||||||
fall back to the file's mtime for the "recently added" ordering (ctime is
|
fall back to the file's mtime for the "recently added" ordering (ctime is
|
||||||
unreliable — creating the Filmotéka hardlinks bumps it).
|
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.
|
||||||
|
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {filename}.ext`
|
||||||
|
(season 0 → `Specials`); the episode keeps its original filename after the
|
||||||
|
`SxxEyy` code. Episode numbers are recognised from the filename; editable in
|
||||||
|
the series GUI.
|
||||||
|
- **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 an optional CZ-title source for the filename (not for tags/
|
||||||
|
matching — it exposes no TMDb/IMDb id, verified).
|
||||||
|
- **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link**
|
||||||
|
(CZ-title source/override); filename hand-editable before/after import.
|
||||||
|
- **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.
|
||||||
|
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
|
||||||
|
key + URL in `.env`. NFO on disk stays authoritative.
|
||||||
|
|
||||||
|
### 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].
|
||||||
|
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs);
|
||||||
|
**(9) mixed-library scanning + generation** (movies + series in one folder).
|
||||||
|
|
||||||
|
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
|
||||||
|
[tmdbid].ext`); per-movie folders are a later refinement.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
# (no open tasks — see Done)
|
### Phase 8 — import without TMDb / without year (homevideo, no-TMDb docs)
|
||||||
|
|
||||||
|
- Relax the import dialog: allow a row with **no year and no tmdbid** (currently
|
||||||
|
a 4-digit year is mandatory). Pool name then falls back to `Title.ext`
|
||||||
|
(movies) / `Series/Season NN/SxxEyy.ext` (series), no `[tmdbid]`.
|
||||||
|
- Drive this by **library kind**: `homevideo` (and no-TMDb rows in `mixed`)
|
||||||
|
skip the TMDb requirement; `movie` keeps requiring a year (as today).
|
||||||
|
- `homevideo` library scanning: load its files as items (flat), no TMDb naming,
|
||||||
|
no genre tags. Consider a dedicated import path or a per-import "bez TMDb" toggle.
|
||||||
|
- Tests: import with no year/id lands correctly; homevideo library loads.
|
||||||
|
|
||||||
|
### Phase 9 — mixed-library scanning + generation
|
||||||
|
|
||||||
|
- Scan a `mixed` library by structure: a top-level **single video file = movie**;
|
||||||
|
a subfolder containing `Season NN/` (or `SxxEyy` files) = **series**. Reuse
|
||||||
|
`series.scan_series` for the series part and the flat movie scan for the rest.
|
||||||
|
- `load_pool_movies` / `load_series` become **kind-aware** (iterate `libraries`,
|
||||||
|
dispatch by kind); today they read `movie_libraries` / `copyasis_folders`.
|
||||||
|
- miniDLNA generation + NFO refresh must cover mixed libraries too.
|
||||||
|
- The library filter / table should surface mixed libraries once loadable.
|
||||||
|
- Tests: a mixed folder with both a movie and a series loads/generates correctly.
|
||||||
|
|
||||||
|
### Later / optional
|
||||||
|
|
||||||
|
- **ČSFD CZ-title fetch (deferred).** Live test showed TMDb cs-CZ titles are good
|
||||||
|
when matched by tmdbid, so ČSFD title-fetch was **not** rebuilt (would need the
|
||||||
|
Anubis PoW solver removed in phase 4). If TMDb gets a title wrong, edit the
|
||||||
|
filename in the GUI. The ČSFD **link** is already stored per movie
|
||||||
|
(`File.csfd_url`, set at import / in the edit dialog) as a pointer for later —
|
||||||
|
revisit fetching only if TMDb proves insufficient in practice.
|
||||||
|
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat files.
|
||||||
|
- 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
|
## 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
|
||||||
|
- 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
|
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
|
||||||
import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus
|
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
|
"Přejmenovat dle ČSFD (rok)" to bring older files up to the convention
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "curator"
|
name = "curator"
|
||||||
version = "1.11.0"
|
version = "1.12.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "jan.doubravsky@gmail.com"}
|
{name = "jan.doubravsky@gmail.com"}
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
"""Auto-generated — do not edit manually."""
|
"""Auto-generated — do not edit manually."""
|
||||||
__version__ = "1.11.0"
|
__version__ = "1.12.0"
|
||||||
|
|||||||
+3
-2
@@ -26,8 +26,9 @@ GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
|
|||||||
# they are derived from ČSFD, and how they map to the Filmotéka folder tree.
|
# they are derived from ČSFD, and how they map to the Filmotéka folder tree.
|
||||||
# Each entry:
|
# Each entry:
|
||||||
# category — tag category name (e.g. "Žánr")
|
# category — tag category name (e.g. "Žánr")
|
||||||
# csfd_field — CSFDMovie attribute feeding it (genres / year / countries /
|
# csfd_field — NFO source field feeding it (genres / year / countries /
|
||||||
# rating / directors / actors), or None for a user-only category
|
# 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)
|
# transform — named value transform: None (str) or "decade_band" (rating)
|
||||||
# filmoteka_root — folder under the output: "" = at the root (e.g. genres),
|
# filmoteka_root — folder under the output: "" = at the root (e.g. genres),
|
||||||
# "Dle X" = grouping folder, None = filterable but no folders
|
# "Dle X" = grouping folder, None = filterable but no folders
|
||||||
|
|||||||
@@ -1,682 +0,0 @@
|
|||||||
"""
|
|
||||||
CSFD.cz scraper module for fetching movie information.
|
|
||||||
|
|
||||||
This module provides functionality to fetch movie data from CSFD.cz (Czech-Slovak Film Database).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import hashlib
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional, TYPE_CHECKING
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
try:
|
|
||||||
import requests
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
HAS_DEPENDENCIES = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_DEPENDENCIES = False
|
|
||||||
requests = None # type: ignore
|
|
||||||
BeautifulSoup = None # type: ignore
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
|
|
||||||
CSFD_BASE_URL = "https://www.csfd.cz"
|
|
||||||
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
|
|
||||||
|
|
||||||
# User agent to avoid being blocked
|
|
||||||
HEADERS = {
|
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
||||||
"Accept-Language": "cs,en;q=0.9",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Anubis is the proof-of-work anti-bot wall ČSFD now puts in front of every page.
|
|
||||||
# A plain request gets a 200 with a JS challenge page (title "Ujišťujeme se, že
|
|
||||||
# nejste robot!") instead of the movie, so JSON-LD/genres/year all parse empty.
|
|
||||||
# We detect that page, solve the PoW the way the bundled worker JS does, and
|
|
||||||
# replay the request through the same session to obtain the auth cookie.
|
|
||||||
ANUBIS_CHALLENGE_MARKER = 'id="anubis_challenge"'
|
|
||||||
ANUBIS_PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
|
|
||||||
# Safety cap so a difficulty bump can never spin forever (difficulty 1 needs ~16).
|
|
||||||
ANUBIS_MAX_NONCE = 50_000_000
|
|
||||||
|
|
||||||
# Keep only the top-billed cast from a movie's actor list.
|
|
||||||
MAX_ACTORS = 10
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CSFDMovie:
|
|
||||||
"""Represents movie data from CSFD.cz"""
|
|
||||||
title: str
|
|
||||||
url: str
|
|
||||||
year: Optional[int] = None
|
|
||||||
genres: list[str] = field(default_factory=list)
|
|
||||||
directors: list[str] = field(default_factory=list)
|
|
||||||
actors: list[str] = field(default_factory=list)
|
|
||||||
rating: Optional[int] = None # Percentage 0-100
|
|
||||||
rating_count: Optional[int] = None
|
|
||||||
duration: Optional[int] = None # Minutes
|
|
||||||
# A movie can be a co-production, so the origin is a list of countries
|
|
||||||
# (ČSFD writes them slash-separated, e.g. "Japonsko / USA").
|
|
||||||
countries: list[str] = field(default_factory=list)
|
|
||||||
poster_url: Optional[str] = None
|
|
||||||
plot: Optional[str] = None
|
|
||||||
csfd_id: Optional[int] = None
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
"""Serialize to a plain dict for storage in .!tag cache."""
|
|
||||||
return {
|
|
||||||
"title": self.title,
|
|
||||||
"url": self.url,
|
|
||||||
"year": self.year,
|
|
||||||
"genres": self.genres,
|
|
||||||
"directors": self.directors,
|
|
||||||
"actors": self.actors,
|
|
||||||
"rating": self.rating,
|
|
||||||
"rating_count": self.rating_count,
|
|
||||||
"duration": self.duration,
|
|
||||||
"countries": self.countries,
|
|
||||||
"poster_url": self.poster_url,
|
|
||||||
"plot": self.plot,
|
|
||||||
"csfd_id": self.csfd_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict) -> "CSFDMovie":
|
|
||||||
"""Deserialize from a plain dict (e.g. loaded from .!tag cache)."""
|
|
||||||
countries = data.get("countries")
|
|
||||||
if countries is None:
|
|
||||||
# Legacy cache stored a single "country" string (possibly slash-joined)
|
|
||||||
countries = _split_countries(data.get("country"))
|
|
||||||
return cls(
|
|
||||||
title=data.get("title", ""),
|
|
||||||
url=data.get("url", ""),
|
|
||||||
year=data.get("year"),
|
|
||||||
genres=data.get("genres", []),
|
|
||||||
directors=data.get("directors", []),
|
|
||||||
actors=data.get("actors", []),
|
|
||||||
rating=data.get("rating"),
|
|
||||||
rating_count=data.get("rating_count"),
|
|
||||||
duration=data.get("duration"),
|
|
||||||
countries=countries,
|
|
||||||
poster_url=data.get("poster_url"),
|
|
||||||
plot=data.get("plot"),
|
|
||||||
csfd_id=data.get("csfd_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
parts = [self.title]
|
|
||||||
if self.year:
|
|
||||||
parts[0] += f" ({self.year})"
|
|
||||||
if self.rating is not None:
|
|
||||||
parts.append(f"Hodnocení: {self.rating}%")
|
|
||||||
if self.genres:
|
|
||||||
parts.append(f"Žánr: {', '.join(self.genres)}")
|
|
||||||
if self.countries:
|
|
||||||
parts.append(f"Země původu: {', '.join(self.countries)}")
|
|
||||||
if self.directors:
|
|
||||||
parts.append(f"Režie: {', '.join(self.directors)}")
|
|
||||||
return " | ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def rating_band(rating: int) -> str:
|
|
||||||
"""Bucket a 0–100 ČSFD rating into a ten-point band label (e.g. "80–89 %").
|
|
||||||
|
|
||||||
The top bucket spans 90–100 % so a perfect 100 still lands in a band.
|
|
||||||
"""
|
|
||||||
low = min((rating // 10) * 10, 90)
|
|
||||||
high = 100 if low == 90 else low + 9
|
|
||||||
return f"{low}–{high} %"
|
|
||||||
|
|
||||||
|
|
||||||
def _decade_band(value) -> str:
|
|
||||||
"""``decade_band`` transform: pull the leading integer and bucket it."""
|
|
||||||
match = re.search(r"\d+", str(value))
|
|
||||||
return rating_band(int(match.group())) if match else str(value)
|
|
||||||
|
|
||||||
|
|
||||||
# Named value transforms usable from the tag schema (config-driven, extensible).
|
|
||||||
# These are applied when building Filmotéka FOLDER names — the tag itself keeps
|
|
||||||
# the exact value (e.g. rating 90 → tag "Hodnocení/90", folder "…/90–100 %").
|
|
||||||
TAG_TRANSFORMS = {
|
|
||||||
None: str,
|
|
||||||
"identity": str,
|
|
||||||
"decade_band": _decade_band,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def apply_transform(value: str, transform: Optional[str]) -> str:
|
|
||||||
"""Apply a named tag-schema transform to a value, returning a string."""
|
|
||||||
fn = TAG_TRANSFORMS.get(transform, str)
|
|
||||||
try:
|
|
||||||
return str(fn(value))
|
|
||||||
except (ValueError, TypeError, AttributeError):
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def csfd_field_values(movie: "CSFDMovie", field: str) -> list[str]:
|
|
||||||
"""Exact tag values for a CSFDMovie attribute (scalar or list), as strings.
|
|
||||||
|
|
||||||
No transform is applied here — tags carry the precise value; any grouping
|
|
||||||
transform happens later, when the Filmotéka folder names are built.
|
|
||||||
Unknown field / missing value yields ``[]``.
|
|
||||||
"""
|
|
||||||
raw = getattr(movie, field, None)
|
|
||||||
if raw is None or raw == "":
|
|
||||||
return []
|
|
||||||
items = list(raw) if isinstance(raw, (list, tuple)) else [raw]
|
|
||||||
values = [str(item).strip() for item in items if item is not None and item != ""]
|
|
||||||
return [v for v in values if v]
|
|
||||||
|
|
||||||
|
|
||||||
def _split_countries(text: Optional[str]) -> list[str]:
|
|
||||||
"""Split a ČSFD origin country string into individual countries.
|
|
||||||
|
|
||||||
ČSFD writes co-productions slash-separated, e.g. ``"Japonsko / USA"`` →
|
|
||||||
``["Japonsko", "USA"]``. ``None``/empty yields an empty list.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
return [part.strip() for part in text.split("/") if part.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _check_dependencies():
|
|
||||||
"""Check if required dependencies are installed."""
|
|
||||||
if not HAS_DEPENDENCIES:
|
|
||||||
raise ImportError(
|
|
||||||
"CSFD module requires 'requests' and 'beautifulsoup4' packages. "
|
|
||||||
"Install them with: pip install requests beautifulsoup4"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_csfd_id(url: str) -> Optional[int]:
|
|
||||||
"""Extract CSFD movie ID from URL."""
|
|
||||||
match = re.search(r"/film/(\d+)", url)
|
|
||||||
return int(match.group(1)) if match else None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_duration(duration_str: str) -> Optional[int]:
|
|
||||||
"""Parse ISO 8601 duration (PT97M) to minutes."""
|
|
||||||
match = re.search(r"PT(\d+)M", duration_str)
|
|
||||||
return int(match.group(1)) if match else None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_json_blob(html: str, element_id: str):
|
|
||||||
"""Return the parsed JSON from an Anubis ``<script id=...>`` blob, or None."""
|
|
||||||
match = re.search(
|
|
||||||
rf'<script id="{re.escape(element_id)}" type="application/json">(.*?)</script>',
|
|
||||||
html,
|
|
||||||
re.S,
|
|
||||||
)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.loads(match.group(1))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]:
|
|
||||||
"""Brute-force the Anubis proof-of-work.
|
|
||||||
|
|
||||||
Mirrors the bundled ``sha256-purejs`` worker: find the smallest ``nonce``
|
|
||||||
such that ``sha256(random_data + str(nonce))`` has ``difficulty`` leading
|
|
||||||
zero nibbles. Returns ``(hash_hex, nonce, elapsed_ms)``.
|
|
||||||
"""
|
|
||||||
full_zero_bytes = difficulty // 2
|
|
||||||
needs_half_byte = difficulty % 2 != 0
|
|
||||||
start = time.monotonic()
|
|
||||||
for nonce in range(ANUBIS_MAX_NONCE):
|
|
||||||
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).digest()
|
|
||||||
if any(digest[i] != 0 for i in range(full_zero_bytes)):
|
|
||||||
continue
|
|
||||||
if needs_half_byte and digest[full_zero_bytes] >> 4 != 0:
|
|
||||||
continue
|
|
||||||
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
||||||
return digest.hex(), nonce, elapsed_ms
|
|
||||||
raise ValueError(
|
|
||||||
f"Anubis PoW unsolved within {ANUBIS_MAX_NONCE} attempts (difficulty {difficulty})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _solve_anubis_challenge(session, html: str, url: str):
|
|
||||||
"""Solve the Anubis challenge in ``html`` and return the real page response.
|
|
||||||
|
|
||||||
Posts the proof-of-work back to the pass-challenge endpoint through
|
|
||||||
``session`` (which stores the resulting auth cookie) and follows the
|
|
||||||
redirect to the originally requested page.
|
|
||||||
"""
|
|
||||||
payload = _extract_json_blob(html, "anubis_challenge")
|
|
||||||
if not payload:
|
|
||||||
raise ValueError("ČSFD anti-bot stránka bez čitelné Anubis challenge")
|
|
||||||
|
|
||||||
rules = payload.get("rules", {})
|
|
||||||
challenge = payload.get("challenge", {})
|
|
||||||
random_data = challenge.get("randomData")
|
|
||||||
difficulty = int(rules.get("difficulty", 1))
|
|
||||||
if not random_data:
|
|
||||||
raise ValueError("Anubis challenge neobsahuje randomData")
|
|
||||||
|
|
||||||
base_prefix = _extract_json_blob(html, "anubis_base_prefix") or ""
|
|
||||||
logger.debug(f"Solving Anubis challenge (difficulty {difficulty}) for {url}")
|
|
||||||
hash_hex, nonce, elapsed_ms = _solve_anubis_pow(random_data, difficulty)
|
|
||||||
logger.debug(f"Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms")
|
|
||||||
|
|
||||||
pass_url = urljoin(CSFD_BASE_URL, f"{base_prefix}{ANUBIS_PASS_PATH}")
|
|
||||||
response = session.get(
|
|
||||||
pass_url,
|
|
||||||
params={
|
|
||||||
"id": challenge.get("id"),
|
|
||||||
"response": hash_hex,
|
|
||||||
"nonce": nonce,
|
|
||||||
"redir": url,
|
|
||||||
"elapsedTime": elapsed_ms,
|
|
||||||
},
|
|
||||||
headers=HEADERS,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
if ANUBIS_CHALLENGE_MARKER in response.text:
|
|
||||||
raise ValueError("ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto)")
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _get_page(session, url: str):
|
|
||||||
"""GET ``url`` through ``session``, transparently clearing an Anubis wall."""
|
|
||||||
response = session.get(url, headers=HEADERS, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
if ANUBIS_CHALLENGE_MARKER in response.text:
|
|
||||||
response = _solve_anubis_challenge(session, response.text, url)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_movie(url: str, session=None) -> CSFDMovie:
|
|
||||||
"""
|
|
||||||
Fetch movie information from CSFD.cz URL.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Full CSFD.cz movie URL (e.g., https://www.csfd.cz/film/9423-pane-vy-jste-vdova/)
|
|
||||||
session: Optional ``requests.Session`` to reuse (keeps the Anubis auth
|
|
||||||
cookie across calls so only the first fetch pays the PoW cost).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
CSFDMovie object with extracted data
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ImportError: If required dependencies are not installed
|
|
||||||
requests.RequestException: If network request fails
|
|
||||||
ValueError: If URL is invalid or page cannot be parsed
|
|
||||||
"""
|
|
||||||
_check_dependencies()
|
|
||||||
|
|
||||||
own_session = session is None
|
|
||||||
if own_session:
|
|
||||||
session = requests.Session()
|
|
||||||
try:
|
|
||||||
response = _get_page(session, url)
|
|
||||||
finally:
|
|
||||||
if own_session:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
|
||||||
|
|
||||||
# Try to extract JSON-LD structured data first (most reliable)
|
|
||||||
movie_data = _extract_json_ld(soup)
|
|
||||||
|
|
||||||
# Extract additional data from HTML
|
|
||||||
movie_data["url"] = url
|
|
||||||
movie_data["csfd_id"] = _extract_csfd_id(url)
|
|
||||||
|
|
||||||
# Get rating from HTML if not in JSON-LD
|
|
||||||
if movie_data.get("rating") is None:
|
|
||||||
movie_data["rating"] = _extract_rating(soup)
|
|
||||||
|
|
||||||
# Get poster URL
|
|
||||||
if movie_data.get("poster_url") is None:
|
|
||||||
movie_data["poster_url"] = _extract_poster(soup)
|
|
||||||
|
|
||||||
# Get plot summary
|
|
||||||
if movie_data.get("plot") is None:
|
|
||||||
movie_data["plot"] = _extract_plot(soup)
|
|
||||||
|
|
||||||
# Get countries and year from origin info
|
|
||||||
origin_info = _extract_origin_info(soup)
|
|
||||||
if origin_info:
|
|
||||||
if not movie_data.get("countries"):
|
|
||||||
movie_data["countries"] = origin_info.get("countries", [])
|
|
||||||
if movie_data.get("year") is None:
|
|
||||||
movie_data["year"] = origin_info.get("year")
|
|
||||||
if movie_data.get("duration") is None:
|
|
||||||
movie_data["duration"] = origin_info.get("duration")
|
|
||||||
|
|
||||||
# Get genres from HTML if not in JSON-LD
|
|
||||||
if not movie_data.get("genres"):
|
|
||||||
movie_data["genres"] = _extract_genres(soup)
|
|
||||||
|
|
||||||
# Keep only the leading cast (ČSFD lists them in billing order)
|
|
||||||
movie_data["actors"] = movie_data.get("actors", [])[:MAX_ACTORS]
|
|
||||||
|
|
||||||
return CSFDMovie(**movie_data)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_json_ld(soup: BeautifulSoup) -> dict:
|
|
||||||
"""Extract movie data from JSON-LD structured data."""
|
|
||||||
data = {
|
|
||||||
"title": "",
|
|
||||||
"year": None,
|
|
||||||
"genres": [],
|
|
||||||
"directors": [],
|
|
||||||
"actors": [],
|
|
||||||
"rating": None,
|
|
||||||
"rating_count": None,
|
|
||||||
"duration": None,
|
|
||||||
"countries": [],
|
|
||||||
"poster_url": None,
|
|
||||||
"plot": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Find JSON-LD script
|
|
||||||
script_tags = soup.find_all("script", type="application/ld+json")
|
|
||||||
for script in script_tags:
|
|
||||||
try:
|
|
||||||
json_data = json.loads(script.string)
|
|
||||||
|
|
||||||
# Handle both single object and array
|
|
||||||
if isinstance(json_data, list):
|
|
||||||
for item in json_data:
|
|
||||||
if item.get("@type") == "Movie":
|
|
||||||
json_data = item
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if json_data.get("@type") != "Movie":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Title
|
|
||||||
data["title"] = json_data.get("name", "")
|
|
||||||
|
|
||||||
# Genres
|
|
||||||
genre = json_data.get("genre", [])
|
|
||||||
if isinstance(genre, str):
|
|
||||||
data["genres"] = [genre]
|
|
||||||
else:
|
|
||||||
data["genres"] = list(genre)
|
|
||||||
|
|
||||||
# Directors
|
|
||||||
directors = json_data.get("director", [])
|
|
||||||
if isinstance(directors, dict):
|
|
||||||
directors = [directors]
|
|
||||||
data["directors"] = [d.get("name", "") for d in directors if d.get("name")]
|
|
||||||
|
|
||||||
# Actors
|
|
||||||
actors = json_data.get("actor", [])
|
|
||||||
if isinstance(actors, dict):
|
|
||||||
actors = [actors]
|
|
||||||
data["actors"] = [a.get("name", "") for a in actors if a.get("name")]
|
|
||||||
|
|
||||||
# Rating
|
|
||||||
agg_rating = json_data.get("aggregateRating", {})
|
|
||||||
if agg_rating:
|
|
||||||
rating_value = agg_rating.get("ratingValue")
|
|
||||||
if rating_value is not None:
|
|
||||||
data["rating"] = round(float(rating_value))
|
|
||||||
data["rating_count"] = agg_rating.get("ratingCount")
|
|
||||||
|
|
||||||
# Duration
|
|
||||||
duration_str = json_data.get("duration", "")
|
|
||||||
if duration_str:
|
|
||||||
data["duration"] = _parse_duration(duration_str)
|
|
||||||
|
|
||||||
# Year (CSFD exposes it via dateCreated / datePublished)
|
|
||||||
for date_key in ("dateCreated", "datePublished"):
|
|
||||||
date_val = json_data.get(date_key)
|
|
||||||
if date_val:
|
|
||||||
year_match = re.search(r"(19\d{2}|20\d{2})", str(date_val))
|
|
||||||
if year_match:
|
|
||||||
data["year"] = int(year_match.group(1))
|
|
||||||
break
|
|
||||||
|
|
||||||
# Poster
|
|
||||||
image = json_data.get("image")
|
|
||||||
if image:
|
|
||||||
if isinstance(image, str):
|
|
||||||
data["poster_url"] = image
|
|
||||||
elif isinstance(image, dict):
|
|
||||||
data["poster_url"] = image.get("url")
|
|
||||||
|
|
||||||
# Description
|
|
||||||
data["plot"] = json_data.get("description")
|
|
||||||
|
|
||||||
break # Found movie data
|
|
||||||
|
|
||||||
except (json.JSONDecodeError, KeyError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_rating(soup: BeautifulSoup) -> Optional[int]:
|
|
||||||
"""Extract rating percentage from HTML."""
|
|
||||||
# Look for rating box
|
|
||||||
rating_elem = soup.select_one(".film-rating-average")
|
|
||||||
if rating_elem:
|
|
||||||
text = rating_elem.get_text(strip=True)
|
|
||||||
match = re.search(r"(\d+)%", text)
|
|
||||||
if match:
|
|
||||||
return int(match.group(1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_poster(soup: BeautifulSoup) -> Optional[str]:
|
|
||||||
"""Extract poster image URL from HTML."""
|
|
||||||
# Look for poster image
|
|
||||||
poster = soup.select_one(".film-poster img")
|
|
||||||
if poster:
|
|
||||||
src = poster.get("src") or poster.get("data-src")
|
|
||||||
if src:
|
|
||||||
if src.startswith("//"):
|
|
||||||
return "https:" + src
|
|
||||||
return src
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_plot(soup: BeautifulSoup) -> Optional[str]:
|
|
||||||
"""Extract plot summary from HTML."""
|
|
||||||
# Look for plot/description section
|
|
||||||
plot_elem = soup.select_one(".plot-full p")
|
|
||||||
if plot_elem:
|
|
||||||
return plot_elem.get_text(strip=True)
|
|
||||||
|
|
||||||
# Alternative: shorter plot
|
|
||||||
plot_elem = soup.select_one(".plot-preview p")
|
|
||||||
if plot_elem:
|
|
||||||
return plot_elem.get_text(strip=True)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_genres(soup: BeautifulSoup) -> list[str]:
|
|
||||||
"""Extract genres from HTML."""
|
|
||||||
genres = []
|
|
||||||
genre_links = soup.select(".genres a")
|
|
||||||
for link in genre_links:
|
|
||||||
genre = link.get_text(strip=True)
|
|
||||||
if genre:
|
|
||||||
genres.append(genre)
|
|
||||||
return genres
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_origin_info(soup: BeautifulSoup) -> dict:
|
|
||||||
"""Extract countries, year, duration from the origin info line.
|
|
||||||
|
|
||||||
CSFD separates the values with inline bullet ``<span>`` elements (no commas),
|
|
||||||
so ``get_text(strip=True)`` would glue them together (e.g. "USA1999136 min").
|
|
||||||
We tokenize on those inline boundaries (and on commas, for the older format)
|
|
||||||
before extracting each field. The country segment of a co-production is
|
|
||||||
slash-separated (e.g. "USA / Velká Británie") and is split into a list.
|
|
||||||
"""
|
|
||||||
info: dict = {}
|
|
||||||
|
|
||||||
origin_elem = soup.select_one(".origin")
|
|
||||||
if not origin_elem:
|
|
||||||
return info
|
|
||||||
|
|
||||||
# Split on inline element boundaries, then also on commas (older format).
|
|
||||||
raw = origin_elem.get_text(separator="|", strip=True)
|
|
||||||
tokens = [t.strip() for part in raw.split("|") for t in part.split(",")]
|
|
||||||
tokens = [t for t in tokens if t]
|
|
||||||
|
|
||||||
for token in tokens:
|
|
||||||
if "year" not in info and re.fullmatch(r"(19\d{2}|20\d{2})", token):
|
|
||||||
info["year"] = int(token)
|
|
||||||
continue
|
|
||||||
if "duration" not in info:
|
|
||||||
duration_match = re.search(r"(\d+)\s*min", token)
|
|
||||||
if duration_match:
|
|
||||||
info["duration"] = int(duration_match.group(1))
|
|
||||||
continue
|
|
||||||
# Countries: first alphabetic token that doesn't start with a digit;
|
|
||||||
# may list several slash-separated countries for a co-production.
|
|
||||||
if "countries" not in info and not token[0].isdigit() and re.search(r"[^\W\d_]", token):
|
|
||||||
info["countries"] = _split_countries(token)
|
|
||||||
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
def search_movies(query: str, limit: int = 10, session=None) -> list[CSFDMovie]:
|
|
||||||
"""
|
|
||||||
Search for movies on CSFD.cz.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query string
|
|
||||||
limit: Maximum number of results to return
|
|
||||||
session: Optional ``requests.Session`` to reuse (keeps the Anubis auth
|
|
||||||
cookie across calls so only the first lookup pays the PoW cost).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of CSFDMovie objects with basic info (title, url, year)
|
|
||||||
"""
|
|
||||||
_check_dependencies()
|
|
||||||
|
|
||||||
search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}"
|
|
||||||
own_session = session is None
|
|
||||||
if own_session:
|
|
||||||
session = requests.Session()
|
|
||||||
try:
|
|
||||||
response = _get_page(session, search_url)
|
|
||||||
finally:
|
|
||||||
if own_session:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# Find movie results
|
|
||||||
movie_items = soup.select(".film-title-name, .search-result-item a[href*='/film/']")
|
|
||||||
|
|
||||||
for item in movie_items[:limit]:
|
|
||||||
href = item.get("href", "")
|
|
||||||
if "/film/" not in href:
|
|
||||||
continue
|
|
||||||
|
|
||||||
title = item.get_text(strip=True)
|
|
||||||
url = urljoin(CSFD_BASE_URL, href)
|
|
||||||
|
|
||||||
# Try to get year from sibling/parent
|
|
||||||
year = None
|
|
||||||
parent = item.find_parent(class_="article-content")
|
|
||||||
if parent:
|
|
||||||
year_elem = parent.select_one(".info")
|
|
||||||
if year_elem:
|
|
||||||
year_match = re.search(r"\((\d{4})\)", year_elem.get_text())
|
|
||||||
if year_match:
|
|
||||||
year = int(year_match.group(1))
|
|
||||||
|
|
||||||
results.append(CSFDMovie(
|
|
||||||
title=title,
|
|
||||||
url=url,
|
|
||||||
year=year,
|
|
||||||
csfd_id=_extract_csfd_id(url)
|
|
||||||
))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_movie_by_id(csfd_id: int) -> CSFDMovie:
|
|
||||||
"""
|
|
||||||
Fetch movie by CSFD ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
csfd_id: CSFD movie ID number
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
CSFDMovie object with full data
|
|
||||||
"""
|
|
||||||
url = f"{CSFD_BASE_URL}/film/{csfd_id}/"
|
|
||||||
return fetch_movie(url)
|
|
||||||
|
|
||||||
|
|
||||||
# Release-name tokens that mark the end of the actual title in a filename.
|
|
||||||
_RELEASE_MARKERS = {
|
|
||||||
"bluray", "blu-ray", "brrip", "bdrip", "bdremux", "remux", "webrip", "web",
|
|
||||||
"web-dl", "webdl", "hdtv", "dvdrip", "dvd", "dvd5", "dvd9", "hdrip", "cam",
|
|
||||||
"ts", "tc", "x264", "x265", "h264", "h265", "hevc", "avc", "xvid", "divx",
|
|
||||||
"aac", "ac3", "eac3", "dts", "dd5", "ddp5", "truehd", "atmos", "flac",
|
|
||||||
"10bit", "8bit", "hdr", "hdr10", "dolby", "sdr", "proper", "repack",
|
|
||||||
"extended", "unrated", "remastered", "imax", "multi", "dual", "complete",
|
|
||||||
"internal", "limited", "uncut",
|
|
||||||
}
|
|
||||||
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
|
|
||||||
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$|^[24]k$", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def clean_filename_to_query(filename: str) -> str:
|
|
||||||
"""Turn a (possibly release-named) filename into a ČSFD search query.
|
|
||||||
|
|
||||||
Strips the path/extension, splits on common separators and keeps the words
|
|
||||||
before the first release marker (year, resolution, codec, source, …). The
|
|
||||||
detected year is appended back as a disambiguator. Example::
|
|
||||||
|
|
||||||
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
stem = Path(filename).stem
|
|
||||||
tokens = [t for t in re.split(r"[.\s_]+", stem) if t]
|
|
||||||
|
|
||||||
title_words: list[str] = []
|
|
||||||
year: Optional[str] = None
|
|
||||||
for token in tokens:
|
|
||||||
bare = token.strip("()[]{}")
|
|
||||||
if _YEAR_RE.match(bare):
|
|
||||||
year = bare
|
|
||||||
break
|
|
||||||
if _RESOLUTION_RE.match(bare) or bare.lower() in _RELEASE_MARKERS:
|
|
||||||
break
|
|
||||||
# also stop at a release group glued with a dash (e.g. "x264-GROUP")
|
|
||||||
title_words.append(token)
|
|
||||||
|
|
||||||
# If nothing survived (title started with a marker), fall back to the stem.
|
|
||||||
title = " ".join(title_words).strip() or re.sub(r"[.\s_]+", " ", stem).strip()
|
|
||||||
return f"{title} {year}".strip() if year else title
|
|
||||||
|
|
||||||
|
|
||||||
def find_csfd_url(query: str, session=None) -> Optional[str]:
|
|
||||||
"""Return the first ČSFD film URL matching a query, or None.
|
|
||||||
|
|
||||||
Thin wrapper over :func:`search_movies` that takes the top result. Pass a
|
|
||||||
shared ``session`` to reuse the Anubis auth cookie across several lookups.
|
|
||||||
"""
|
|
||||||
if not query.strip():
|
|
||||||
return None
|
|
||||||
results = search_movies(query, limit=1, session=session)
|
|
||||||
return results[0].url if results else None
|
|
||||||
+49
-79
@@ -1,10 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from .tag import Tag
|
from .tag import Tag
|
||||||
|
|
||||||
# Bump this when the csfd_cache schema changes to force re-fetch on next open.
|
|
||||||
# v2: country (str) → countries (list[str]) for co-productions.
|
|
||||||
CSFD_CACHE_VERSION = 2
|
|
||||||
|
|
||||||
|
|
||||||
class File:
|
class File:
|
||||||
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
|
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
|
||||||
@@ -22,17 +18,20 @@ class File:
|
|||||||
self.date: str | None = None
|
self.date: str | None = None
|
||||||
# movie-library fields
|
# movie-library fields
|
||||||
self.title: str | None = None
|
self.title: str | None = None
|
||||||
self.csfd_link: 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);
|
# 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.
|
# None for older/unknown → falls back to the file's ctime.
|
||||||
self.added: str | None = None
|
self.added: str | None = None
|
||||||
# Cached CSFD data — avoids re-fetching on every open
|
# Cached metadata used for folder grouping (year, rating) — filled from
|
||||||
self.csfd_cache: dict | None = None
|
# the movie's NFO (which Jellyfin writes).
|
||||||
# full_paths of tags that came from ČSFD (vs. user-added); only these are
|
self.meta: dict | None = None
|
||||||
# regenerated when the ČSFD link changes — user tags are preserved.
|
# full_paths of tags derived automatically from the NFO (vs. user-added);
|
||||||
self.csfd_tag_paths: set[str] = set()
|
# 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
|
# free-form per-movie attributes (e.g. "collection_sort"); usable in
|
||||||
# Filmotéka filename templates via name_context.
|
# miniDLNA filename templates via name_context.
|
||||||
self.attributes: dict[str, str] = {}
|
self.attributes: dict[str, str] = {}
|
||||||
self.get_metadata()
|
self.get_metadata()
|
||||||
|
|
||||||
@@ -50,9 +49,9 @@ class File:
|
|||||||
self.tags = []
|
self.tags = []
|
||||||
self.date = None
|
self.date = None
|
||||||
self.title = None
|
self.title = None
|
||||||
self.csfd_link = None
|
self.csfd_url = None
|
||||||
self.csfd_cache = None
|
self.meta = None
|
||||||
self.csfd_tag_paths = set()
|
self.auto_tag_paths = set()
|
||||||
self.attributes = {}
|
self.attributes = {}
|
||||||
self.added = None
|
self.added = None
|
||||||
|
|
||||||
@@ -62,17 +61,17 @@ class File:
|
|||||||
"ignored": self.ignored,
|
"ignored": self.ignored,
|
||||||
# ukládáme full_path tagů
|
# ukládáme full_path tagů
|
||||||
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
|
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
|
||||||
# which of the tags came from ČSFD (provenance)
|
# which of the tags were derived automatically (from the NFO)
|
||||||
"csfd_tags": sorted(self.csfd_tag_paths),
|
"auto_tags": sorted(self.auto_tag_paths),
|
||||||
# date může být None
|
# date může být None
|
||||||
"date": self.date,
|
"date": self.date,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"csfd_link": self.csfd_link,
|
"csfd_url": self.csfd_url,
|
||||||
"attributes": self.attributes,
|
"attributes": self.attributes,
|
||||||
"added": self.added,
|
"added": self.added,
|
||||||
}
|
}
|
||||||
if self.csfd_cache is not None:
|
if self.meta is not None:
|
||||||
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache}
|
data["meta"] = dict(self.meta)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _apply_record(self, data: dict) -> None:
|
def _apply_record(self, data: dict) -> None:
|
||||||
@@ -81,16 +80,16 @@ class File:
|
|||||||
self.tags = []
|
self.tags = []
|
||||||
self.date = data.get("date", None)
|
self.date = data.get("date", None)
|
||||||
self.title = data.get("title", None)
|
self.title = data.get("title", None)
|
||||||
self.csfd_link = data.get("csfd_link", None)
|
self.csfd_url = data.get("csfd_url", None)
|
||||||
# Legacy records have no provenance → treated as all-user (empty set)
|
# provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored
|
||||||
self.csfd_tag_paths = set(data.get("csfd_tags", []))
|
self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", [])))
|
||||||
self.attributes = dict(data.get("attributes", {}))
|
self.attributes = dict(data.get("attributes", {}))
|
||||||
self.added = data.get("added", None)
|
self.added = data.get("added", None)
|
||||||
raw_cache = data.get("csfd_cache")
|
raw_meta = data.get("meta")
|
||||||
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION:
|
if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
|
||||||
self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"}
|
old = data.get("csfd_cache") or {}
|
||||||
else:
|
raw_meta = {"year": old.get("year"), "rating": old.get("rating")} if old else None
|
||||||
self.csfd_cache = None
|
self.meta = dict(raw_meta) if raw_meta else None
|
||||||
|
|
||||||
if not self.tagmanager:
|
if not self.tagmanager:
|
||||||
return
|
return
|
||||||
@@ -126,13 +125,13 @@ class File:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
def name_context(self) -> dict:
|
def name_context(self) -> dict:
|
||||||
"""Fields for a Filmotéka filename template (see config tag schema).
|
"""Fields for a miniDLNA filename template (see config tag schema).
|
||||||
|
|
||||||
Provides title / year / rating / ext / stem / filename from the movie's
|
Provides title / year / rating / ext / stem / filename from the movie's
|
||||||
metadata (year & rating come from the ČSFD cache, year falling back to a
|
metadata (year & rating come from the NFO cache, year falling back to a
|
||||||
``Rok`` tag). Missing values render as an empty string.
|
``Rok`` tag, then the canonical filename). Missing values render empty.
|
||||||
"""
|
"""
|
||||||
cache = self.csfd_cache or {}
|
cache = self.meta or {}
|
||||||
year = cache.get("year")
|
year = cache.get("year")
|
||||||
if year is None:
|
if year is None:
|
||||||
for t in self.tags:
|
for t in self.tags:
|
||||||
@@ -195,78 +194,49 @@ class File:
|
|||||||
self.date = date_str
|
self.date = date_str
|
||||||
self.save_metadata()
|
self.save_metadata()
|
||||||
|
|
||||||
def set_csfd_link(self, url: str | None) -> None:
|
def apply_nfo(self, nfo, schema: list[dict] | None = None) -> dict:
|
||||||
"""Nastaví CSFD odkaz nebo None. Invaliduje cache při změně odkazu."""
|
"""Regenerate the auto (NFO-derived) tags from the movie's NFO.
|
||||||
new_url = url if url else None
|
|
||||||
if new_url != self.csfd_link:
|
|
||||||
self.csfd_cache = None # odkaz se změnil — stará cache je neplatná
|
|
||||||
self.csfd_link = new_url
|
|
||||||
self.save_metadata()
|
|
||||||
|
|
||||||
def get_cached_movie(self):
|
Reads genres/year/countries/rating from ``nfo`` (``nfo.NfoData``) per the
|
||||||
"""Vrátí CSFDMovie z cache, nebo None. Nevyžaduje síť."""
|
tag schema's source fields and (re)creates those tags; user-added tags are
|
||||||
if self.csfd_cache is None:
|
kept. Also caches year/rating (for folder grouping) and fills the title
|
||||||
return None
|
when missing. ``nfo`` of ``None`` (no NFO yet) is a no-op.
|
||||||
try:
|
|
||||||
from .csfd import CSFDMovie
|
|
||||||
return CSFDMovie.from_dict(self.csfd_cache)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def apply_csfd_tags(self, schema: list[dict] | None = None) -> dict:
|
|
||||||
"""Načte data z ČSFD a přegeneruje **jen ČSFD tagy** podle tag schématu.
|
|
||||||
|
|
||||||
``schema`` je seznam definic kategorií (viz ``DEFAULT_TAG_SCHEMA``); pro
|
|
||||||
každý záznam s ``csfd_field`` vytvoří tagy z příslušného pole filmu
|
|
||||||
(s volitelným ``transform``). Tagy přidané uživatelem (mimo provenance
|
|
||||||
ČSFD) zůstávají nedotčené — proto změna ČSFD odkazu nepřepíše vlastní tagy.
|
|
||||||
Režie/herci se stále cachují, tag se z nich tvoří jen je-li v schématu.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict s klíči 'success', 'movie'/'error', 'tags_added'
|
dict with 'success' and 'tags_added'.
|
||||||
"""
|
"""
|
||||||
if not self.csfd_link:
|
if nfo is None:
|
||||||
return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []}
|
return {"success": False, "error": "NFO nenalezeno", "tags_added": []}
|
||||||
|
|
||||||
if schema is None:
|
if schema is None:
|
||||||
from .config import DEFAULT_TAG_SCHEMA
|
from .config import DEFAULT_TAG_SCHEMA
|
||||||
schema = DEFAULT_TAG_SCHEMA
|
schema = DEFAULT_TAG_SCHEMA
|
||||||
|
|
||||||
try:
|
self.meta = {"year": nfo.year, "rating": nfo.rating}
|
||||||
from .csfd import fetch_movie
|
|
||||||
movie = fetch_movie(self.csfd_link)
|
|
||||||
self.csfd_cache = movie.to_dict()
|
|
||||||
except ImportError as e:
|
|
||||||
return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []}
|
|
||||||
except Exception as e:
|
|
||||||
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
|
|
||||||
|
|
||||||
from .csfd import csfd_field_values
|
# Drop previous 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]
|
||||||
# Drop previous ČSFD-sourced tags; keep user-added ones.
|
self.auto_tag_paths = set()
|
||||||
self.tags = [t for t in self.tags if t.full_path not in self.csfd_tag_paths]
|
|
||||||
self.csfd_tag_paths = set()
|
|
||||||
|
|
||||||
tags_added: list[str] = []
|
tags_added: list[str] = []
|
||||||
for entry in schema:
|
for entry in schema:
|
||||||
field = entry.get("csfd_field")
|
field = entry.get("csfd_field") # schema key = source field name
|
||||||
if not field:
|
if not field:
|
||||||
continue # user-only category
|
continue # user-only category
|
||||||
category = entry["category"]
|
category = entry["category"]
|
||||||
for value in csfd_field_values(movie, field):
|
for value in nfo.field_values(field):
|
||||||
tag_obj = (self.tagmanager.add_tag(category, value)
|
tag_obj = (self.tagmanager.add_tag(category, value)
|
||||||
if self.tagmanager else Tag(category, value))
|
if self.tagmanager else Tag(category, value))
|
||||||
if tag_obj not in self.tags:
|
if tag_obj not in self.tags:
|
||||||
self.tags.append(tag_obj)
|
self.tags.append(tag_obj)
|
||||||
tags_added.append(tag_obj.full_path)
|
tags_added.append(tag_obj.full_path)
|
||||||
self.csfd_tag_paths.add(tag_obj.full_path)
|
self.auto_tag_paths.add(tag_obj.full_path)
|
||||||
|
|
||||||
# Use the CSFD title if we don't have one yet
|
if nfo.title and not self.title:
|
||||||
if movie.title and not self.title:
|
self.title = nfo.title
|
||||||
self.title = movie.title
|
|
||||||
|
|
||||||
self.save_metadata()
|
self.save_metadata()
|
||||||
return {"success": True, "movie": movie, "tags_added": tags_added}
|
return {"success": True, "tags_added": tags_added}
|
||||||
|
|
||||||
def add_tag(self, tag):
|
def add_tag(self, tag):
|
||||||
# tag může být Tag nebo string
|
# tag může být Tag nebo string
|
||||||
|
|||||||
+562
-44
@@ -7,7 +7,7 @@ from .tag_manager import TagManager
|
|||||||
from .pool_index import PoolIndex
|
from .pool_index import PoolIndex
|
||||||
from .utils import list_files
|
from .utils import list_files
|
||||||
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
|
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
|
||||||
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem
|
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem, parse_tmdb_id
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
from src.core.config import (
|
from src.core.config import (
|
||||||
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
||||||
@@ -17,10 +17,24 @@ from src.core.config import (
|
|||||||
POOL_MOVIES = "Filmy"
|
POOL_MOVIES = "Filmy"
|
||||||
POOL_SERIES = "Seriály"
|
POOL_SERIES = "Seriály"
|
||||||
|
|
||||||
|
# A pool library (top-level folder) has one of these kinds:
|
||||||
|
# movie — single-file movies, TMDb naming (Title (rok) [tmdbid].ext)
|
||||||
|
# series — series/season/episode tree, mirrored as-is (Jellyfin Shows)
|
||||||
|
# mixed — both movies and series in one folder (Jellyfin "Mixed"; wiring TBD)
|
||||||
|
# homevideo — files without TMDb (home videos; Jellyfin "Home videos"; wiring TBD)
|
||||||
|
LIBRARY_KINDS = ("movie", "series", "mixed", "homevideo")
|
||||||
|
_DEFAULT_LIBRARIES = [
|
||||||
|
{"name": POOL_MOVIES, "kind": "movie"},
|
||||||
|
{"name": POOL_SERIES, "kind": "series"},
|
||||||
|
]
|
||||||
|
|
||||||
# Library-level settings that live in the pool index (not the global config).
|
# Library-level settings that live in the pool index (not the global config).
|
||||||
|
# ``libraries`` is the source of truth; the legacy ``movie_libraries`` /
|
||||||
|
# ``copyasis_folders`` are kept only to synthesise ``libraries`` for old pools.
|
||||||
_LIBRARY_SETTINGS_DEFAULTS = {
|
_LIBRARY_SETTINGS_DEFAULTS = {
|
||||||
"tag_schema": DEFAULT_TAG_SCHEMA,
|
"tag_schema": DEFAULT_TAG_SCHEMA,
|
||||||
"copyasis_folders": [POOL_SERIES],
|
"copyasis_folders": [POOL_SERIES],
|
||||||
|
"movie_libraries": [POOL_MOVIES],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Curator metadata files that must never be treated as content. The legacy
|
# Curator metadata files that must never be treated as content. The legacy
|
||||||
@@ -28,6 +42,13 @@ _LIBRARY_SETTINGS_DEFAULTS = {
|
|||||||
# stray ones are still skipped so they never leak into the Filmotéka.
|
# stray ones are still skipped so they never leak into the Filmotéka.
|
||||||
METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index")
|
METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index")
|
||||||
|
|
||||||
|
# Sidecars Jellyfin writes next to a movie (NFO + artwork/subtitles). These are
|
||||||
|
# not movies and must be skipped when scanning a library.
|
||||||
|
SIDECAR_SUFFIXES = (
|
||||||
|
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn",
|
||||||
|
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FileManager:
|
class FileManager:
|
||||||
def __init__(self, tagmanager: TagManager):
|
def __init__(self, tagmanager: TagManager):
|
||||||
@@ -107,15 +128,110 @@ class FileManager:
|
|||||||
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
|
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
|
||||||
idx.set_setting(key, value)
|
idx.set_setting(key, value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def libraries(self) -> list[dict]:
|
||||||
|
"""All pool libraries as ``[{"name", "kind"}, …]`` (source of truth).
|
||||||
|
|
||||||
|
For pools predating the unified model this is synthesised from the legacy
|
||||||
|
``movie_libraries`` (→ kind ``movie``) + ``copyasis_folders`` (→ ``series``).
|
||||||
|
"""
|
||||||
|
idx = self._ensure_index()
|
||||||
|
raw = idx.get_setting("libraries", None) if idx is not None else None
|
||||||
|
if raw is None:
|
||||||
|
raw = (
|
||||||
|
[{"name": n, "kind": "movie"} for n in self._get_setting("movie_libraries")]
|
||||||
|
+ [{"name": n, "kind": "series"} for n in self._get_setting("copyasis_folders")]
|
||||||
|
)
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for spec in raw:
|
||||||
|
name = (spec.get("name") or "").strip()
|
||||||
|
kind = spec.get("kind") if spec.get("kind") in LIBRARY_KINDS else "movie"
|
||||||
|
if name and name.lower() not in seen:
|
||||||
|
out.append({"name": name, "kind": kind})
|
||||||
|
seen.add(name.lower())
|
||||||
|
return out or list(_DEFAULT_LIBRARIES)
|
||||||
|
|
||||||
|
def set_libraries(self, specs: list[dict]) -> None:
|
||||||
|
"""Replace the library list (list of ``{"name", "kind"}``) and persist it."""
|
||||||
|
cleaned: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for spec in specs:
|
||||||
|
name = (spec.get("name") or "").strip()
|
||||||
|
kind = spec.get("kind") if spec.get("kind") in LIBRARY_KINDS else "movie"
|
||||||
|
if name and name.lower() not in seen:
|
||||||
|
cleaned.append({"name": name, "kind": kind})
|
||||||
|
seen.add(name.lower())
|
||||||
|
self._set_setting("libraries", cleaned or list(_DEFAULT_LIBRARIES))
|
||||||
|
|
||||||
|
def libraries_of_kind(self, *kinds: str) -> list[str]:
|
||||||
|
"""Names of libraries matching any of ``kinds``."""
|
||||||
|
return [lib["name"] for lib in self.libraries if lib["kind"] in kinds]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def copyasis_folders(self) -> list[str]:
|
def copyasis_folders(self) -> list[str]:
|
||||||
"""Names of pool subfolders mirrored 1:1 (copy-as-is) into the output."""
|
"""Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries."""
|
||||||
return self._get_setting("copyasis_folders")
|
return self.libraries_of_kind("series")
|
||||||
|
|
||||||
def set_copyasis_folders(self, names: list[str]) -> None:
|
def set_copyasis_folders(self, names: list[str]) -> None:
|
||||||
"""Set the copy-as-is subfolder list and persist it."""
|
"""Set the series (copy-as-is) libraries, keeping the other kinds."""
|
||||||
cleaned = [n.strip() for n in names if n.strip()]
|
cleaned = [n.strip() for n in names if n.strip()]
|
||||||
self._set_setting("copyasis_folders", cleaned)
|
others = [lib for lib in self.libraries if lib["kind"] != "series"]
|
||||||
|
self.set_libraries(others + [{"name": n, "kind": "series"} for n in cleaned])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def movie_libraries(self) -> list[str]:
|
||||||
|
"""Pool folders holding single-file movies (kind ``movie``)."""
|
||||||
|
return self.libraries_of_kind("movie") or [POOL_MOVIES]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def libraries_accepting_movies(self) -> list[str]:
|
||||||
|
"""Libraries a movie can live in / be assigned to — kind movie or mixed."""
|
||||||
|
return self.libraries_of_kind("movie", "mixed") or [POOL_MOVIES]
|
||||||
|
|
||||||
|
def set_movie_libraries(self, names: list[str]) -> None:
|
||||||
|
"""Set the movie libraries, keeping the other kinds; never empty."""
|
||||||
|
cleaned = [n.strip() for n in names if n.strip()] or [POOL_MOVIES]
|
||||||
|
others = [lib for lib in self.libraries if lib["kind"] != "movie"]
|
||||||
|
self.set_libraries([{"name": n, "kind": "movie"} for n in cleaned] + others)
|
||||||
|
|
||||||
|
def orphan_pool_folders(self) -> list[tuple[str, bool]]:
|
||||||
|
"""Top-level pool folders that aren't a configured library.
|
||||||
|
|
||||||
|
Returns ``(name, is_empty)`` pairs. Only these are candidates for cleanup;
|
||||||
|
non-empty ones are reported but never auto-deleted (they may hold media).
|
||||||
|
"""
|
||||||
|
pool = self.pool_dir
|
||||||
|
if not (pool and pool.is_dir()):
|
||||||
|
return []
|
||||||
|
known = {lib["name"] for lib in self.libraries}
|
||||||
|
orphans: list[tuple[str, bool]] = []
|
||||||
|
for entry in sorted(pool.iterdir()):
|
||||||
|
if entry.is_dir() and entry.name not in known:
|
||||||
|
orphans.append((entry.name, not any(entry.iterdir())))
|
||||||
|
return orphans
|
||||||
|
|
||||||
|
def remove_empty_pool_folder(self, name: str) -> bool:
|
||||||
|
"""Delete an **empty** orphan folder in the pool root. Returns True if removed."""
|
||||||
|
pool = self.pool_dir
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
known = {lib["name"] for lib in self.libraries}
|
||||||
|
target = pool / name
|
||||||
|
if (target.is_dir() and name not in known and not any(target.iterdir())):
|
||||||
|
target.rmdir()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def library_of(self, file_obj: File) -> str | None:
|
||||||
|
"""Which library (top-level pool folder) a pooled movie lives in."""
|
||||||
|
pool = self.pool_dir
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return file_obj.file_path.relative_to(pool).parts[0]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tag_schema(self) -> list[dict]:
|
def tag_schema(self) -> list[dict]:
|
||||||
@@ -164,12 +280,13 @@ class FileManager:
|
|||||||
return Path(value) if value else None
|
return Path(value) if value else None
|
||||||
|
|
||||||
def set_pool_dir(self, pool_dir: Path) -> None:
|
def set_pool_dir(self, pool_dir: Path) -> None:
|
||||||
"""Set the managed pool root and create its top-level folders."""
|
"""Set the managed pool root and create every library's top-level folder."""
|
||||||
pool_dir = Path(pool_dir)
|
pool_dir = Path(pool_dir)
|
||||||
(pool_dir / POOL_MOVIES).mkdir(parents=True, exist_ok=True)
|
|
||||||
(pool_dir / POOL_SERIES).mkdir(parents=True, exist_ok=True)
|
|
||||||
self.global_config["pool_dir"] = str(pool_dir)
|
self.global_config["pool_dir"] = str(pool_dir)
|
||||||
save_global_config(self.global_config)
|
save_global_config(self.global_config)
|
||||||
|
self._open_index(pool_dir) # settings now readable through the index
|
||||||
|
for lib in self.libraries:
|
||||||
|
(pool_dir / lib["name"]).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def set_filmoteka_dir(self, filmoteka_dir: Path) -> None:
|
def set_filmoteka_dir(self, filmoteka_dir: Path) -> None:
|
||||||
"""Set the Filmotéka output folder (generated hardlink tree)."""
|
"""Set the Filmotéka output folder (generated hardlink tree)."""
|
||||||
@@ -177,22 +294,40 @@ class FileManager:
|
|||||||
save_global_config(self.global_config)
|
save_global_config(self.global_config)
|
||||||
|
|
||||||
def load_pool_movies(self) -> None:
|
def load_pool_movies(self) -> None:
|
||||||
"""Reload the movie list from pool/Filmy using the unified pool index."""
|
"""Reload the movie list from every movie library, via the pool index.
|
||||||
|
|
||||||
|
Scans each configured movie library (``movie_libraries``) recursively —
|
||||||
|
a movie may sit directly in the library or inside its own per-movie
|
||||||
|
folder. Curator's own metadata and the sidecars Jellyfin writes (NFO,
|
||||||
|
artwork, subtitles) are skipped.
|
||||||
|
"""
|
||||||
self.filelist = []
|
self.filelist = []
|
||||||
movies = self.movies_dir
|
|
||||||
pool = self.pool_dir
|
pool = self.pool_dir
|
||||||
if not (movies and movies.is_dir() and pool):
|
if not pool:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._open_index(pool)
|
self._open_index(pool)
|
||||||
for each in list_files(movies):
|
for lib in self.libraries:
|
||||||
if each.name.endswith(METADATA_SUFFIXES):
|
if lib["kind"] not in ("movie", "mixed"):
|
||||||
continue
|
continue
|
||||||
file_obj = File(each, self.tagmanager, index=self.index)
|
lib_dir = pool / lib["name"]
|
||||||
self.filelist.append(file_obj)
|
if not lib_dir.is_dir():
|
||||||
|
continue
|
||||||
|
# A mixed library keeps movies at the top level and series in
|
||||||
|
# subfolders (Season NN) → only its top-level files are movies.
|
||||||
|
if lib["kind"] == "mixed":
|
||||||
|
candidates = [p for p in sorted(lib_dir.iterdir()) if p.is_file()]
|
||||||
|
else:
|
||||||
|
candidates = list_files(lib_dir)
|
||||||
|
for each in candidates:
|
||||||
|
if each.name.endswith(METADATA_SUFFIXES):
|
||||||
|
continue
|
||||||
|
if each.suffix.lower() in SIDECAR_SUFFIXES:
|
||||||
|
continue
|
||||||
|
self.filelist.append(File(each, self.tagmanager, index=self.index))
|
||||||
|
|
||||||
def check_data_consistency(self) -> dict:
|
def check_data_consistency(self) -> dict:
|
||||||
"""Compare the pool index against the actual files in pool/Filmy.
|
"""Compare the pool index against the actual files in the movie libraries.
|
||||||
|
|
||||||
Detects drift such as a movie file deleted directly (metadata left behind)
|
Detects drift such as a movie file deleted directly (metadata left behind)
|
||||||
or a file dropped into the pool without importing (no metadata).
|
or a file dropped into the pool without importing (no metadata).
|
||||||
@@ -204,8 +339,7 @@ class FileManager:
|
|||||||
index_count / disk_count — totals for the summary
|
index_count / disk_count — totals for the summary
|
||||||
"""
|
"""
|
||||||
pool = self.pool_dir
|
pool = self.pool_dir
|
||||||
movies = self.movies_dir
|
if not (pool and pool.is_dir()):
|
||||||
if not (pool and movies and movies.is_dir()):
|
|
||||||
return {"ok": False, "error": "Pool není nastaven.",
|
return {"ok": False, "error": "Pool není nastaven.",
|
||||||
"missing": [], "untracked": []}
|
"missing": [], "untracked": []}
|
||||||
|
|
||||||
@@ -213,10 +347,20 @@ class FileManager:
|
|||||||
index_keys = set(index.records.keys())
|
index_keys = set(index.records.keys())
|
||||||
|
|
||||||
disk_keys = set()
|
disk_keys = set()
|
||||||
for f in list_files(movies):
|
for lib in self.libraries:
|
||||||
if f.name.endswith(METADATA_SUFFIXES):
|
if lib["kind"] not in ("movie", "mixed"):
|
||||||
continue
|
continue
|
||||||
disk_keys.add(f.relative_to(pool).as_posix())
|
lib_dir = pool / lib["name"]
|
||||||
|
if not lib_dir.is_dir():
|
||||||
|
continue
|
||||||
|
files = ([p for p in lib_dir.iterdir() if p.is_file()]
|
||||||
|
if lib["kind"] == "mixed" else list_files(lib_dir))
|
||||||
|
for f in files:
|
||||||
|
if f.name.endswith(METADATA_SUFFIXES):
|
||||||
|
continue
|
||||||
|
if f.suffix.lower() in SIDECAR_SUFFIXES:
|
||||||
|
continue
|
||||||
|
disk_keys.add(f.relative_to(pool).as_posix())
|
||||||
|
|
||||||
missing = sorted(k for k in index_keys if not (pool / k).is_file())
|
missing = sorted(k for k in index_keys if not (pool / k).is_file())
|
||||||
untracked = sorted(disk_keys - index_keys)
|
untracked = sorted(disk_keys - index_keys)
|
||||||
@@ -228,6 +372,51 @@ class FileManager:
|
|||||||
"disk_count": len(disk_keys),
|
"disk_count": len(disk_keys),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def verify_against_jellyfin(self, jf_movies: list) -> dict:
|
||||||
|
"""Compare the pool against what Jellyfin currently sees.
|
||||||
|
|
||||||
|
Matches each pooled movie to a Jellyfin item by **TMDb id** (from the
|
||||||
|
filename), falling back to (title, year). Reports what is out of sync.
|
||||||
|
|
||||||
|
``jf_movies`` is a list of ``jellyfin.JellyfinMovie``. Returns a dict:
|
||||||
|
matched — how many pooled movies Jellyfin has
|
||||||
|
pool_only — pooled filenames Jellyfin does not see (rescan/rematch)
|
||||||
|
jellyfin_only — Jellyfin titles with no matching pooled movie
|
||||||
|
pool_total / jellyfin_total — totals for the summary
|
||||||
|
"""
|
||||||
|
by_tmdb = {m.tmdb_id: m for m in jf_movies if m.tmdb_id}
|
||||||
|
by_name = {(m.title.strip().lower(), m.year): m for m in jf_movies if m.title}
|
||||||
|
|
||||||
|
matched_ids: set = set()
|
||||||
|
pool_only: list[str] = []
|
||||||
|
for f in self.filelist:
|
||||||
|
tmdb_id = parse_tmdb_id(f.file_path.stem)
|
||||||
|
ctx = f.name_context()
|
||||||
|
title = str(f.title or ctx.get("title") or "").strip().lower()
|
||||||
|
year_str = str(ctx.get("year") or "")
|
||||||
|
year = int(year_str) if year_str.isdigit() else None
|
||||||
|
|
||||||
|
match = None
|
||||||
|
if tmdb_id is not None and tmdb_id in by_tmdb:
|
||||||
|
match = by_tmdb[tmdb_id]
|
||||||
|
elif (title, year) in by_name:
|
||||||
|
match = by_name[(title, year)]
|
||||||
|
|
||||||
|
if match is not None:
|
||||||
|
matched_ids.add(match.jellyfin_id)
|
||||||
|
else:
|
||||||
|
pool_only.append(f.filename)
|
||||||
|
|
||||||
|
jellyfin_only = sorted(
|
||||||
|
m.title for m in jf_movies if m.jellyfin_id not in matched_ids)
|
||||||
|
return {
|
||||||
|
"matched": len(self.filelist) - len(pool_only),
|
||||||
|
"pool_only": sorted(pool_only),
|
||||||
|
"jellyfin_only": jellyfin_only,
|
||||||
|
"pool_total": len(self.filelist),
|
||||||
|
"jellyfin_total": len(jf_movies),
|
||||||
|
}
|
||||||
|
|
||||||
def scan_video_integrity(
|
def scan_video_integrity(
|
||||||
self,
|
self,
|
||||||
files: list[File],
|
files: list[File],
|
||||||
@@ -279,7 +468,7 @@ class FileManager:
|
|||||||
"""
|
"""
|
||||||
files = self.filelist
|
files = self.filelist
|
||||||
total_size = 0
|
total_size = 0
|
||||||
with_csfd = 0
|
with_nfo = 0
|
||||||
untagged = 0
|
untagged = 0
|
||||||
ratings: list[int] = []
|
ratings: list[int] = []
|
||||||
categories: dict[str, Counter] = {}
|
categories: dict[str, Counter] = {}
|
||||||
@@ -289,21 +478,21 @@ class FileManager:
|
|||||||
total_size += f.file_path.stat().st_size
|
total_size += f.file_path.stat().st_size
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
if f.csfd_link:
|
if f.meta is not None:
|
||||||
with_csfd += 1
|
with_nfo += 1
|
||||||
if not f.tags:
|
if not f.tags:
|
||||||
untagged += 1
|
untagged += 1
|
||||||
for tag in f.tags:
|
for tag in f.tags:
|
||||||
categories.setdefault(tag.category, Counter())[tag.name] += 1
|
categories.setdefault(tag.category, Counter())[tag.name] += 1
|
||||||
rating = (f.csfd_cache or {}).get("rating")
|
rating = (f.meta or {}).get("rating")
|
||||||
if isinstance(rating, (int, float)):
|
if isinstance(rating, (int, float)):
|
||||||
ratings.append(rating)
|
ratings.append(rating)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"count": len(files),
|
"count": len(files),
|
||||||
"total_size": total_size,
|
"total_size": total_size,
|
||||||
"with_csfd": with_csfd,
|
"with_nfo": with_nfo,
|
||||||
"without_csfd": len(files) - with_csfd,
|
"without_nfo": len(files) - with_nfo,
|
||||||
"untagged": untagged,
|
"untagged": untagged,
|
||||||
"avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None,
|
"avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None,
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -325,16 +514,19 @@ class FileManager:
|
|||||||
self.filelist.remove(file_obj)
|
self.filelist.remove(file_obj)
|
||||||
|
|
||||||
def import_movie(
|
def import_movie(
|
||||||
self, source: Path, title: str, csfd_link: str | None = None,
|
self, source: Path, title: str,
|
||||||
move: bool = False, on_conflict: str = "suffix",
|
move: bool = False, on_conflict: str = "suffix",
|
||||||
year: int | str | None = None,
|
year: int | str | None = None, tmdb_id: int | str | None = None,
|
||||||
|
library: str | None = None, csfd_url: str | None = None,
|
||||||
) -> File | None:
|
) -> File | None:
|
||||||
"""Bring a video file into pool/Filmy and index its metadata.
|
"""Bring a video file into a movie library and index its metadata.
|
||||||
|
|
||||||
The pooled filename follows the naming convention ``Title (YYYY).ext``
|
Lands in ``pool/<library>/`` (``library`` defaults to the first movie
|
||||||
when a ``year`` is given (validated to four digits); without a year it
|
library, "Filmy"). The pooled filename follows the naming convention
|
||||||
|
``Title (YYYY) [tmdbid-N].ext`` when a ``year`` is given (and a
|
||||||
|
``tmdb_id`` when known — the form Jellyfin matches on); without a year it
|
||||||
falls back to ``Title.ext``. The GUI import always supplies a year, so a
|
falls back to ``Title.ext``. The GUI import always supplies a year, so a
|
||||||
library built through the app is uniformly ``Title (YYYY).ext``.
|
library built through the app is uniformly ``Title (YYYY) [tmdbid-N].ext``.
|
||||||
|
|
||||||
By default the original is **copied** (non-destructive). With ``move=True``
|
By default the original is **copied** (non-destructive). With ``move=True``
|
||||||
the source file is moved into the pool instead, leaving nothing behind.
|
the source file is moved into the pool instead, leaving nothing behind.
|
||||||
@@ -350,10 +542,11 @@ class FileManager:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: a ``year`` was given but is not a four-digit number.
|
ValueError: a ``year`` was given but is not a four-digit number.
|
||||||
"""
|
"""
|
||||||
movies = self.movies_dir
|
|
||||||
pool = self.pool_dir
|
pool = self.pool_dir
|
||||||
if movies is None or pool is None:
|
if pool is None:
|
||||||
raise RuntimeError("Pool není nastaven.")
|
raise RuntimeError("Pool není nastaven.")
|
||||||
|
lib = (library or "").strip() or self.movie_libraries[0]
|
||||||
|
movies = pool / lib
|
||||||
movies.mkdir(parents=True, exist_ok=True)
|
movies.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if self.index is None:
|
if self.index is None:
|
||||||
@@ -362,7 +555,8 @@ class FileManager:
|
|||||||
source = Path(source)
|
source = Path(source)
|
||||||
safe_title = title.strip() or source.stem
|
safe_title = title.strip() or source.stem
|
||||||
has_year = year is not None and str(year).strip() != ""
|
has_year = year is not None and str(year).strip() != ""
|
||||||
stem = canonical_pool_stem(safe_title, year) if has_year else safe_title
|
stem = (canonical_pool_stem(safe_title, year, tmdb_id) if has_year
|
||||||
|
else safe_title)
|
||||||
target = movies / f"{stem}{source.suffix}"
|
target = movies / f"{stem}{source.suffix}"
|
||||||
|
|
||||||
existing = self.pooled_with_stem(stem)
|
existing = self.pooled_with_stem(stem)
|
||||||
@@ -389,7 +583,7 @@ class FileManager:
|
|||||||
|
|
||||||
file_obj = File(target, self.tagmanager, index=self.index)
|
file_obj = File(target, self.tagmanager, index=self.index)
|
||||||
file_obj.title = safe_title
|
file_obj.title = safe_title
|
||||||
file_obj.csfd_link = csfd_link or None
|
file_obj.csfd_url = csfd_url or None
|
||||||
file_obj.added = datetime.now().isoformat(timespec="seconds")
|
file_obj.added = datetime.now().isoformat(timespec="seconds")
|
||||||
file_obj.save_metadata()
|
file_obj.save_metadata()
|
||||||
|
|
||||||
@@ -433,12 +627,13 @@ class FileManager:
|
|||||||
return file_obj
|
return file_obj
|
||||||
|
|
||||||
def _canonical_name_for(self, file_obj: File) -> str | None:
|
def _canonical_name_for(self, file_obj: File) -> str | None:
|
||||||
"""The ``Title (YYYY)`` stem this movie should have, or ``None``.
|
"""The ``Title (YYYY) [tmdbid-N]`` stem this movie should have, or ``None``.
|
||||||
|
|
||||||
Uses the clean title and the year from the movie's metadata
|
Uses the clean title and the year from the movie's metadata
|
||||||
(``name_context``: ČSFD cache → ``Rok`` tag → the current filename). A
|
(``name_context``: cache → ``Rok`` tag → the current filename). A
|
||||||
``(YYYY)`` that leaked into the title is stripped so it isn't doubled.
|
``(YYYY)`` that leaked into the title is stripped so it isn't doubled, and
|
||||||
Returns ``None`` when no four-digit year is known.
|
an existing ``[tmdbid-N]`` on the current filename is preserved. Returns
|
||||||
|
``None`` when no four-digit year is known.
|
||||||
"""
|
"""
|
||||||
ctx = file_obj.name_context()
|
ctx = file_obj.name_context()
|
||||||
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
|
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
|
||||||
@@ -449,16 +644,19 @@ class FileManager:
|
|||||||
title = parsed[0]
|
title = parsed[0]
|
||||||
year = year or str(parsed[1])
|
year = year or str(parsed[1])
|
||||||
|
|
||||||
|
# Keep the TMDb id already in the pool filename, if any.
|
||||||
|
tmdb_id = parse_tmdb_id(file_obj.file_path.stem)
|
||||||
|
|
||||||
title = title.replace("/", "-").replace("\\", "-").strip()
|
title = title.replace("/", "-").replace("\\", "-").strip()
|
||||||
if not (title and is_valid_year(year)):
|
if not (title and is_valid_year(year)):
|
||||||
return None
|
return None
|
||||||
return canonical_pool_stem(title, year)
|
return canonical_pool_stem(title, year, tmdb_id)
|
||||||
|
|
||||||
def rename_to_canonical(self, file_obj: File) -> File | None:
|
def rename_to_canonical(self, file_obj: File) -> File | None:
|
||||||
"""Rename a pooled movie to ``Title (YYYY).ext`` from its metadata.
|
"""Rename a pooled movie to ``Title (YYYY).ext`` from its metadata.
|
||||||
|
|
||||||
The year comes from the movie's ČSFD metadata (falling back to a ``Rok``
|
The year comes from the movie's metadata (NFO cache, falling back to a
|
||||||
tag or the existing filename). ``file_obj.title`` keeps the clean title
|
``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title
|
||||||
(without the year). Does not fire ``on_files_changed`` — the caller
|
(without the year). Does not fire ``on_files_changed`` — the caller
|
||||||
refreshes once after a batch.
|
refreshes once after a batch.
|
||||||
|
|
||||||
@@ -537,6 +735,326 @@ class FileManager:
|
|||||||
"conflicts": conflicts, "unchanged": unchanged,
|
"conflicts": conflicts, "unchanged": unchanged,
|
||||||
"cancelled": cancelled}
|
"cancelled": cancelled}
|
||||||
|
|
||||||
|
def move_to_library(self, file_obj: File, new_library: str) -> File:
|
||||||
|
"""Move a pooled movie into another library (top-level pool folder).
|
||||||
|
|
||||||
|
Moves the file into ``pool/<new_library>/`` and re-indexes it under the
|
||||||
|
new key. Raises:
|
||||||
|
ValueError: the target isn't a known movie library.
|
||||||
|
FileExistsError: a file of that name already sits in the target.
|
||||||
|
"""
|
||||||
|
pool = self.pool_dir
|
||||||
|
if pool is None:
|
||||||
|
raise RuntimeError("Pool není nastaven.")
|
||||||
|
new_library = new_library.strip()
|
||||||
|
if new_library not in self.libraries_accepting_movies:
|
||||||
|
raise ValueError(f"Neznámá knihovna „{new_library}“.")
|
||||||
|
|
||||||
|
old_path = file_obj.file_path
|
||||||
|
dest_dir = pool / new_library
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
new_path = dest_dir / old_path.name
|
||||||
|
if new_path == old_path:
|
||||||
|
return file_obj # already there
|
||||||
|
if new_path.exists():
|
||||||
|
raise FileExistsError(f"V knihovně „{new_library}“ už „{old_path.name}“ je.")
|
||||||
|
|
||||||
|
shutil.move(str(old_path), str(new_path))
|
||||||
|
file_obj.relocate(new_path) # drops the old index key, rebinds to the new
|
||||||
|
file_obj.save_metadata()
|
||||||
|
|
||||||
|
if self.on_files_changed:
|
||||||
|
self.on_files_changed(self.filelist)
|
||||||
|
return file_obj
|
||||||
|
|
||||||
|
def apply_movie_edits(
|
||||||
|
self, file_obj: File, *, title: str, year: int | str | None = None,
|
||||||
|
tmdb_id: int | str | None = None, library: str | None = None,
|
||||||
|
date: str | None = None, attributes: dict | None = None,
|
||||||
|
tags: list[str] | None = None, csfd_url: str | None = None,
|
||||||
|
) -> File:
|
||||||
|
"""Apply all editable parameters of a movie at once (rename/move + metadata).
|
||||||
|
|
||||||
|
Renames the file to ``Title (rok) [tmdbid].ext`` (year permitting), moves
|
||||||
|
it into ``library``, and sets ``title`` / ``date`` / ``attributes`` /
|
||||||
|
``tags`` (a full ``["Category/Name", …]`` list; NFO-tag provenance is kept
|
||||||
|
for the ones that remain). No-ops that don't change the path skip the move.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileExistsError: the target name is already taken.
|
||||||
|
"""
|
||||||
|
pool = self.pool_dir
|
||||||
|
if pool is None:
|
||||||
|
raise RuntimeError("Pool není nastaven.")
|
||||||
|
title = (title or "").strip() or file_obj.file_path.stem
|
||||||
|
year_s = str(year).strip() if year not in (None, "") else ""
|
||||||
|
stem = (canonical_pool_stem(title, year_s, tmdb_id)
|
||||||
|
if is_valid_year(year_s) else title)
|
||||||
|
lib = ((library or "").strip()
|
||||||
|
or self.library_of(file_obj) or self.movie_libraries[0])
|
||||||
|
old_path = file_obj.file_path
|
||||||
|
new_path = pool / lib / f"{stem}{old_path.suffix}"
|
||||||
|
if new_path != old_path:
|
||||||
|
if new_path.exists():
|
||||||
|
raise FileExistsError(f"Soubor „{new_path.name}“ už existuje.")
|
||||||
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(old_path), str(new_path))
|
||||||
|
file_obj.relocate(new_path)
|
||||||
|
|
||||||
|
file_obj.title = title
|
||||||
|
if date is not None:
|
||||||
|
file_obj.date = date or None
|
||||||
|
if csfd_url is not None:
|
||||||
|
file_obj.csfd_url = csfd_url or None
|
||||||
|
if attributes is not None:
|
||||||
|
file_obj.attributes = dict(attributes)
|
||||||
|
if tags is not None:
|
||||||
|
new_tags: list = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for full_path in tags:
|
||||||
|
full_path = full_path.strip()
|
||||||
|
if "/" not in full_path or full_path in seen:
|
||||||
|
continue
|
||||||
|
cat, name = full_path.split("/", 1)
|
||||||
|
new_tags.append(self.tagmanager.add_tag(cat.strip(), name.strip()))
|
||||||
|
seen.add(full_path)
|
||||||
|
file_obj.tags = new_tags
|
||||||
|
file_obj.auto_tag_paths = {p for p in file_obj.auto_tag_paths if p in seen}
|
||||||
|
|
||||||
|
file_obj.save_metadata()
|
||||||
|
if self.on_files_changed:
|
||||||
|
self.on_files_changed(self.filelist)
|
||||||
|
return file_obj
|
||||||
|
|
||||||
|
def match_pool_to_tmdb(
|
||||||
|
self, files: list[File] | None = None,
|
||||||
|
on_progress: Callable[[int, int, File], None] | None = None,
|
||||||
|
should_cancel: Callable[[], bool] | None = None,
|
||||||
|
) -> list:
|
||||||
|
"""Propose a TMDb id for every movie that doesn't have one yet.
|
||||||
|
|
||||||
|
Searches TMDb by each movie's title + year and classifies the result
|
||||||
|
(see :func:`migrate.classify`). Movies whose filename already carries a
|
||||||
|
``[tmdbid-N]`` are skipped. Returns a list of ``migrate.MatchProposal``.
|
||||||
|
"""
|
||||||
|
from . import tmdb
|
||||||
|
from .migrate import MatchProposal, classify
|
||||||
|
targets = files if files is not None else self.filelist
|
||||||
|
proposals: list = []
|
||||||
|
total = len(targets)
|
||||||
|
for i, f in enumerate(targets, 1):
|
||||||
|
if should_cancel is not None and should_cancel():
|
||||||
|
break
|
||||||
|
if parse_tmdb_id(f.file_path.stem) is not None:
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(i, total, f)
|
||||||
|
continue # already migrated
|
||||||
|
ctx = f.name_context()
|
||||||
|
title = (f.title or ctx.get("title") or f.file_path.stem).strip()
|
||||||
|
parsed = parse_pool_stem(title)
|
||||||
|
if parsed is not None:
|
||||||
|
title = parsed[0]
|
||||||
|
year = str(ctx.get("year") or "").strip() or None
|
||||||
|
try:
|
||||||
|
candidates = tmdb.search_movie(title, year=year, limit=5)
|
||||||
|
status, chosen = classify(title, year, candidates)
|
||||||
|
proposals.append(MatchProposal(
|
||||||
|
f, f.filename, title, year, status, chosen, candidates))
|
||||||
|
except tmdb.TmdbError as exc:
|
||||||
|
proposals.append(MatchProposal(
|
||||||
|
f, f.filename, title, year, "error", None, [], str(exc)))
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(i, total, f)
|
||||||
|
return proposals
|
||||||
|
|
||||||
|
def assign_tmdb_id(
|
||||||
|
self, file_obj: File, tmdb_id: int | str, year: int | str | None = None
|
||||||
|
) -> File:
|
||||||
|
"""Attach a TMDb id to a movie and rename it to the canonical convention.
|
||||||
|
|
||||||
|
``year`` (typically from the TMDb match) fills a missing local year so the
|
||||||
|
file becomes ``Title (rok) [tmdbid].ext``. Title/library/tags/date are kept.
|
||||||
|
"""
|
||||||
|
ctx = file_obj.name_context()
|
||||||
|
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
|
||||||
|
parsed = parse_pool_stem(title)
|
||||||
|
if parsed is not None:
|
||||||
|
title = parsed[0]
|
||||||
|
year_s = str(year).strip() if year not in (None, "") else str(ctx.get("year") or "")
|
||||||
|
return self.apply_movie_edits(
|
||||||
|
file_obj, title=title, year=year_s or None, tmdb_id=tmdb_id)
|
||||||
|
|
||||||
|
def refresh_tags_from_nfo(self, files: list[File] | None = None) -> dict:
|
||||||
|
"""(Re)derive each movie's auto tags from its NFO (written by Jellyfin).
|
||||||
|
|
||||||
|
Reads the ``.nfo`` next to each movie and regenerates the genre / year /
|
||||||
|
country / rating tags per the tag schema (`File.apply_nfo`); user-added
|
||||||
|
tags are preserved. Movies without an NFO yet are left untouched.
|
||||||
|
|
||||||
|
Returns ``{updated, without_nfo}``.
|
||||||
|
"""
|
||||||
|
from .nfo import read_nfo
|
||||||
|
targets = files if files is not None else self.filelist
|
||||||
|
schema = self.tag_schema
|
||||||
|
updated, without_nfo = 0, 0
|
||||||
|
for f in targets:
|
||||||
|
nfo = read_nfo(f.file_path)
|
||||||
|
if nfo is None:
|
||||||
|
without_nfo += 1
|
||||||
|
continue
|
||||||
|
f.apply_nfo(nfo, schema)
|
||||||
|
updated += 1
|
||||||
|
if updated and self.on_files_changed:
|
||||||
|
self.on_files_changed(self.filelist)
|
||||||
|
return {"updated": updated, "without_nfo": without_nfo}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Series (Jellyfin TV layout: Series/Season NN/Series SxxEyy.ext)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def series_libraries(self) -> list[str]:
|
||||||
|
"""Pool folders that can hold series — kind ``series`` or ``mixed``."""
|
||||||
|
return self.libraries_of_kind("series", "mixed")
|
||||||
|
|
||||||
|
def load_series(self) -> list:
|
||||||
|
"""Scan every series library into a list of ``series.Series``."""
|
||||||
|
from .series import scan_series
|
||||||
|
pool = self.pool_dir
|
||||||
|
if not pool:
|
||||||
|
return []
|
||||||
|
result: list = []
|
||||||
|
for library in self.series_libraries:
|
||||||
|
result.extend(scan_series(pool / library, library))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def import_episodes(
|
||||||
|
self, sources: list[Path], title: str, year: int | str | None = None,
|
||||||
|
tmdb_id: int | str | None = None, library: str | None = None,
|
||||||
|
move: bool = False, on_conflict: str = "suffix",
|
||||||
|
) -> dict:
|
||||||
|
"""Import episode files into ``<library>/Series (rok) [tmdbid]/Season NN/``.
|
||||||
|
|
||||||
|
Each source's season/episode is parsed from its filename and it is copied
|
||||||
|
(or moved) in as ``Series SxxEyy.ext``. Files with no recognisable
|
||||||
|
``SxxEyy`` are skipped and reported.
|
||||||
|
|
||||||
|
Returns ``{imported, skipped_no_code, errors}``.
|
||||||
|
"""
|
||||||
|
from .series import (
|
||||||
|
parse_episode, series_folder_stem, season_folder_name, episode_filename)
|
||||||
|
pool = self.pool_dir
|
||||||
|
if pool is None:
|
||||||
|
raise RuntimeError("Pool není nastaven.")
|
||||||
|
lib = ((library or "").strip()
|
||||||
|
or (self.series_libraries[0] if self.series_libraries else POOL_SERIES))
|
||||||
|
stem = series_folder_stem(title, year, tmdb_id)
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
skipped_no_code: list[str] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
for src in sources:
|
||||||
|
src = Path(src)
|
||||||
|
code = parse_episode(src.name)
|
||||||
|
if code is None:
|
||||||
|
skipped_no_code.append(src.name)
|
||||||
|
continue
|
||||||
|
season, ep = code
|
||||||
|
dest_dir = pool / lib / stem / season_folder_name(season)
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = dest_dir / episode_filename(season, ep, src.stem, src.suffix)
|
||||||
|
if target.exists():
|
||||||
|
if on_conflict == "skip":
|
||||||
|
continue
|
||||||
|
if on_conflict == "replace":
|
||||||
|
target.unlink()
|
||||||
|
else: # suffix
|
||||||
|
base = episode_filename(season, ep, src.stem, "")
|
||||||
|
counter = 1
|
||||||
|
while target.exists():
|
||||||
|
target = dest_dir / f"{base}_{counter}{src.suffix}"
|
||||||
|
counter += 1
|
||||||
|
try:
|
||||||
|
if move:
|
||||||
|
shutil.move(str(src), str(target))
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, target)
|
||||||
|
imported += 1
|
||||||
|
except OSError as exc:
|
||||||
|
errors.append(f"{src.name}: {exc}")
|
||||||
|
return {"imported": imported, "skipped_no_code": skipped_no_code,
|
||||||
|
"errors": errors}
|
||||||
|
|
||||||
|
def rename_episodes_to_canonical(self, series) -> dict:
|
||||||
|
"""Rename a series' episodes to ``Series SxxEyy.ext`` in the right season.
|
||||||
|
|
||||||
|
``series`` is a ``series.Series``. Episodes with no recognisable code are
|
||||||
|
skipped; a target name already taken is reported as a conflict.
|
||||||
|
|
||||||
|
Returns ``{renamed, skipped, conflicts}``.
|
||||||
|
"""
|
||||||
|
from .series import season_folder_name, episode_filename
|
||||||
|
renamed: list[tuple[str, str]] = []
|
||||||
|
skipped: list[str] = []
|
||||||
|
conflicts: list[tuple[str, str]] = []
|
||||||
|
for ep in series.episodes:
|
||||||
|
if ep.season is None or ep.episode is None:
|
||||||
|
skipped.append(ep.path.name)
|
||||||
|
continue
|
||||||
|
dest_dir = series.folder / season_folder_name(ep.season)
|
||||||
|
new_path = dest_dir / episode_filename(
|
||||||
|
ep.season, ep.episode, ep.path.stem, ep.path.suffix)
|
||||||
|
if new_path == ep.path:
|
||||||
|
continue
|
||||||
|
if new_path.exists():
|
||||||
|
conflicts.append((ep.path.name, f"„{new_path.name}“ už existuje"))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
old_name = ep.path.name
|
||||||
|
ep.path.rename(new_path)
|
||||||
|
ep.path = new_path
|
||||||
|
renamed.append((old_name, new_path.name))
|
||||||
|
except OSError as exc:
|
||||||
|
conflicts.append((ep.path.name, str(exc)))
|
||||||
|
return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts}
|
||||||
|
|
||||||
|
def apply_series_edits(
|
||||||
|
self, series, *, title: str, year: int | str | None = None,
|
||||||
|
tmdb_id: int | str | None = None, library: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Rename/move a series folder and re-title its episodes to match.
|
||||||
|
|
||||||
|
Renames the series folder to ``Title (rok) [tmdbid]`` (moving it into
|
||||||
|
``library`` if changed), then re-derives the series and renames every
|
||||||
|
episode to ``NewTitle SxxEyy.ext`` in the right Season folder.
|
||||||
|
|
||||||
|
Returns ``{folder, episodes}`` (``episodes`` = rename_episodes result).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileExistsError: the target series folder already exists.
|
||||||
|
"""
|
||||||
|
from .series import scan_series, series_folder_stem
|
||||||
|
pool = self.pool_dir
|
||||||
|
if pool is None:
|
||||||
|
raise RuntimeError("Pool není nastaven.")
|
||||||
|
title = (title or "").strip() or series.title
|
||||||
|
new_stem = series_folder_stem(title, year or None, tmdb_id or None)
|
||||||
|
lib = ((library or "").strip() or series.library)
|
||||||
|
old_folder = series.folder
|
||||||
|
new_folder = pool / lib / new_stem
|
||||||
|
if new_folder != old_folder:
|
||||||
|
if new_folder.exists():
|
||||||
|
raise FileExistsError(f"Složka „{new_stem}“ už existuje.")
|
||||||
|
new_folder.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(old_folder), str(new_folder))
|
||||||
|
|
||||||
|
target = next((s for s in scan_series(new_folder.parent, lib)
|
||||||
|
if s.folder == new_folder), None)
|
||||||
|
result = (self.rename_episodes_to_canonical(target) if target
|
||||||
|
else {"renamed": [], "skipped": [], "conflicts": []})
|
||||||
|
return {"folder": new_folder, "episodes": result}
|
||||||
|
|
||||||
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
||||||
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
||||||
for f in files_objs:
|
for f in files_objs:
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class HardlinkManager:
|
|||||||
band) only happens here, when naming the folder.
|
band) only happens here, when naming the folder.
|
||||||
"""
|
"""
|
||||||
if transforms and tag.category in transforms:
|
if transforms and tag.category in transforms:
|
||||||
from .csfd import apply_transform
|
from .transforms import apply_transform
|
||||||
return apply_transform(tag.name, transforms[tag.category])
|
return apply_transform(tag.name, transforms[tag.category])
|
||||||
return tag.name
|
return tag.name
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Jellyfin server API client — thin, hybrid integration.
|
||||||
|
|
||||||
|
The NFO + artwork on disk stay authoritative (Jellyfin writes and reads them);
|
||||||
|
this client only does what the disk can't: **trigger a library scan** and **read
|
||||||
|
back what Jellyfin currently sees** (to verify the pool against it). Connection
|
||||||
|
details come from ``.env`` — ``JELLYFIN_URL`` and ``JELLYFIN_API_KEY`` (created in
|
||||||
|
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try: # the values can also come from the real environment
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JellyfinMovie:
|
||||||
|
"""A movie as Jellyfin currently sees it (for verification)."""
|
||||||
|
|
||||||
|
jellyfin_id: str
|
||||||
|
title: str
|
||||||
|
year: Optional[int]
|
||||||
|
tmdb_id: Optional[int]
|
||||||
|
path: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinError(RuntimeError):
|
||||||
|
"""A Jellyfin request could not be completed (no config, network, status)."""
|
||||||
|
|
||||||
|
|
||||||
|
def base_url() -> Optional[str]:
|
||||||
|
"""The configured Jellyfin base URL (no trailing slash), or None."""
|
||||||
|
url = os.getenv("JELLYFIN_URL")
|
||||||
|
return url.strip().rstrip("/") if url and url.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def api_key() -> Optional[str]:
|
||||||
|
"""The configured Jellyfin API key, or None."""
|
||||||
|
key = os.getenv("JELLYFIN_API_KEY")
|
||||||
|
return key.strip() if key and key.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def api_available() -> bool:
|
||||||
|
"""True when both the Jellyfin URL and API key are configured."""
|
||||||
|
return bool(base_url() and api_key())
|
||||||
|
|
||||||
|
|
||||||
|
def _request(method: str, path: str, params: Optional[dict] = None,
|
||||||
|
timeout: Optional[int] = None) -> requests.Response:
|
||||||
|
url, key = base_url(), api_key()
|
||||||
|
if not (url and key):
|
||||||
|
raise JellyfinError("JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).")
|
||||||
|
try:
|
||||||
|
resp = requests.request(
|
||||||
|
method, f"{url}{path}", headers={"X-Emby-Token": key},
|
||||||
|
params=params, timeout=timeout or _TIMEOUT)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise JellyfinError(f"Jellyfin nedostupný: {exc}") from exc
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
raise JellyfinError("Neplatný JELLYFIN_API_KEY.")
|
||||||
|
if not resp.ok:
|
||||||
|
raise JellyfinError(f"Jellyfin odpověděl HTTP {resp.status_code}.")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def server_version(timeout: Optional[int] = None) -> str:
|
||||||
|
"""Return the Jellyfin server version (a quick connectivity check)."""
|
||||||
|
data = _request("GET", "/System/Info", timeout=timeout).json()
|
||||||
|
return str(data.get("Version") or "?")
|
||||||
|
|
||||||
|
|
||||||
|
def scan_all() -> None:
|
||||||
|
"""Trigger a scan of all Jellyfin libraries (picks up on-disk changes)."""
|
||||||
|
_request("POST", "/Library/Refresh")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_movies() -> list[JellyfinMovie]:
|
||||||
|
"""Read every movie Jellyfin currently has, with its TMDb id and path."""
|
||||||
|
data = _request("GET", "/Items", params={
|
||||||
|
"IncludeItemTypes": "Movie",
|
||||||
|
"Recursive": "true",
|
||||||
|
"Fields": "ProviderIds,Path,ProductionYear",
|
||||||
|
"EnableTotalRecordCount": "false",
|
||||||
|
}).json()
|
||||||
|
movies: list[JellyfinMovie] = []
|
||||||
|
for item in data.get("Items", []):
|
||||||
|
provider = item.get("ProviderIds") or {}
|
||||||
|
raw_tmdb = provider.get("Tmdb") or provider.get("tmdb")
|
||||||
|
tmdb_id = int(raw_tmdb) if raw_tmdb and str(raw_tmdb).isdigit() else None
|
||||||
|
movies.append(JellyfinMovie(
|
||||||
|
jellyfin_id=str(item.get("Id")),
|
||||||
|
title=item.get("Name") or "",
|
||||||
|
year=item.get("ProductionYear"),
|
||||||
|
tmdb_id=tmdb_id,
|
||||||
|
path=item.get("Path"),
|
||||||
|
))
|
||||||
|
return movies
|
||||||
@@ -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
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchProposal:
|
||||||
|
"""One movie's proposed TMDb match."""
|
||||||
|
|
||||||
|
file: object # core.file.File
|
||||||
|
current_name: str
|
||||||
|
title: str
|
||||||
|
year: Optional[str]
|
||||||
|
status: str # "auto" | "ambiguous" | "no_match" | "error"
|
||||||
|
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
|
||||||
|
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text: str) -> str:
|
||||||
|
"""Fold a title for comparison: strip diacritics/punctuation, lowercase."""
|
||||||
|
text = unicodedata.normalize("NFD", text or "")
|
||||||
|
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||||
|
text = re.sub(r"[^0-9a-zA-Z ]+", " ", text.lower())
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _year_close(a: Optional[str], b: Optional[str]) -> bool:
|
||||||
|
if not (a and b and str(a).isdigit() and str(b).isdigit()):
|
||||||
|
return False
|
||||||
|
return abs(int(a) - int(b)) <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def classify(title: str, year: Optional[str], candidates: list) -> tuple[str, object]:
|
||||||
|
"""Return ``(status, chosen)`` for a title/year against TMDb candidates.
|
||||||
|
|
||||||
|
``auto`` requires an exact normalized-title match, a year within ±1, and that
|
||||||
|
exactly one candidate qualifies. Otherwise ``ambiguous`` (top hit preselected)
|
||||||
|
or ``no_match`` when there are no candidates.
|
||||||
|
"""
|
||||||
|
if not candidates:
|
||||||
|
return "no_match", None
|
||||||
|
nt = normalize(title)
|
||||||
|
exact_year = [
|
||||||
|
c for c in candidates
|
||||||
|
if normalize(c.title) == nt and _year_close(c.year, year)
|
||||||
|
]
|
||||||
|
if len(exact_year) == 1:
|
||||||
|
return "auto", exact_year[0]
|
||||||
|
return "ambiguous", candidates[0]
|
||||||
+34
-14
@@ -1,18 +1,19 @@
|
|||||||
"""Pool file naming convention.
|
"""Pool file naming convention.
|
||||||
|
|
||||||
Every movie in the pool is named ``Title (YYYY).ext`` — a title followed by a
|
Every movie in the pool is named ``Title (YYYY) [tmdbid-N].ext`` — a title, a
|
||||||
four-digit year in parentheses. The year lets two films of the same name live
|
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
|
||||||
side by side and makes the filename self-describing (the year can be recovered
|
is exactly the form Jellyfin matches on. The ``[tmdbid-N]`` part is optional: a
|
||||||
without any metadata). These helpers build, validate and parse that stem (the
|
plain ``Title (YYYY)`` is still valid (Jellyfin then matches by name). The year
|
||||||
filename without its extension).
|
lets two same-named films coexist and makes the filename self-describing. These
|
||||||
|
helpers build, validate and parse the stem (the filename without its extension).
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# A trailing four-digit year in parentheses. The title is non-greedy so a title
|
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]``. The
|
||||||
# that itself contains parentheses keeps them: "Já, robot (2004)" but also
|
# title is non-greedy so a title that itself contains parentheses keeps them:
|
||||||
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019.
|
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019.
|
||||||
_POOL_STEM_RE = re.compile(r"^(?P<title>.+?) \((?P<year>\d{4})\)$")
|
_POOL_STEM_RE = re.compile(
|
||||||
_FOUR_DIGITS = re.compile(r"\d{4}")
|
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?$")
|
||||||
|
|
||||||
|
|
||||||
def is_valid_year(year: object) -> bool:
|
def is_valid_year(year: object) -> bool:
|
||||||
@@ -20,11 +21,12 @@ def is_valid_year(year: object) -> bool:
|
|||||||
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
|
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
|
||||||
|
|
||||||
|
|
||||||
def canonical_pool_stem(title: str, year: object) -> str:
|
def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str:
|
||||||
"""Build the canonical pool stem ``Title (YYYY)``.
|
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` if given).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: the title is empty or the year is not four digits.
|
ValueError: the title is empty, the year is not four digits, or a given
|
||||||
|
``tmdb_id`` is not a positive integer.
|
||||||
"""
|
"""
|
||||||
title = (title or "").strip()
|
title = (title or "").strip()
|
||||||
year_s = str(year).strip()
|
year_s = str(year).strip()
|
||||||
@@ -32,17 +34,35 @@ def canonical_pool_stem(title: str, year: object) -> str:
|
|||||||
raise ValueError("Název filmu nesmí být prázdný.")
|
raise ValueError("Název filmu nesmí být prázdný.")
|
||||||
if not re.fullmatch(r"\d{4}", year_s):
|
if not re.fullmatch(r"\d{4}", year_s):
|
||||||
raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.")
|
raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.")
|
||||||
return f"{title} ({year_s})"
|
stem = f"{title} ({year_s})"
|
||||||
|
if tmdb_id is not None and str(tmdb_id).strip() != "":
|
||||||
|
id_s = str(tmdb_id).strip()
|
||||||
|
if not id_s.isdigit():
|
||||||
|
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
|
||||||
|
stem += f" [tmdbid-{id_s}]"
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
def parse_pool_stem(stem: str) -> tuple[str, int] | None:
|
def parse_pool_stem(stem: str) -> tuple[str, int] | None:
|
||||||
"""Split a canonical stem into ``(title, year)``; ``None`` if it isn't one."""
|
"""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())
|
match = _POOL_STEM_RE.match((stem or "").strip())
|
||||||
if not match:
|
if not match:
|
||||||
return None
|
return None
|
||||||
return match.group("title").strip(), int(match.group("year"))
|
return match.group("title").strip(), int(match.group("year"))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tmdb_id(stem: str) -> int | None:
|
||||||
|
"""Read the ``[tmdbid-N]`` from a canonical stem, or ``None`` if absent."""
|
||||||
|
match = _POOL_STEM_RE.match((stem or "").strip())
|
||||||
|
if not match or not match.group("id"):
|
||||||
|
return None
|
||||||
|
return int(match.group("id"))
|
||||||
|
|
||||||
|
|
||||||
def is_canonical_pool_stem(stem: str) -> bool:
|
def is_canonical_pool_stem(stem: str) -> bool:
|
||||||
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
|
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
|
||||||
return _POOL_STEM_RE.match((stem or "").strip()) is not None
|
return _POOL_STEM_RE.match((stem or "").strip()) is not None
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
"""NFO reader — Curator's metadata source after ČSFD.
|
||||||
|
|
||||||
|
Jellyfin (configured to save metadata into the media folders) writes a Kodi-style
|
||||||
|
``.nfo`` next to each movie plus artwork. Curator only **reads** it — to drive the
|
||||||
|
miniDLNA genre tree and to fill the year/rating used by folder grouping. It never
|
||||||
|
writes NFO (Jellyfin owns that).
|
||||||
|
|
||||||
|
For a flat pool file ``Title (rok) [tmdbid].mkv`` the sidecar is
|
||||||
|
``Title (rok) [tmdbid].nfo``; a per-movie folder may instead hold ``movie.nfo``.
|
||||||
|
Both are found. Missing or unparsable NFO → ``None`` (the movie simply has no
|
||||||
|
genres until Jellyfin has scanned it).
|
||||||
|
"""
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NfoData:
|
||||||
|
"""The bits of a movie ``.nfo`` Curator uses."""
|
||||||
|
|
||||||
|
title: Optional[str] = None
|
||||||
|
year: Optional[str] = None # four-digit string
|
||||||
|
genres: list[str] = field(default_factory=list)
|
||||||
|
countries: list[str] = field(default_factory=list)
|
||||||
|
rating: Optional[int] = None # 0–100 (NFO 0–10 scaled up), or None
|
||||||
|
tmdb_id: Optional[int] = None
|
||||||
|
|
||||||
|
def field_values(self, name: str) -> list[str]:
|
||||||
|
"""Values for a tag-schema source field (genres/countries/year/rating)."""
|
||||||
|
if name == "genres":
|
||||||
|
return list(self.genres)
|
||||||
|
if name == "countries":
|
||||||
|
return list(self.countries)
|
||||||
|
if name == "year":
|
||||||
|
return [self.year] if self.year else []
|
||||||
|
if name == "rating":
|
||||||
|
return [str(self.rating)] if self.rating is not None else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def find_nfo(video_path: Path) -> Optional[Path]:
|
||||||
|
"""Locate the ``.nfo`` for a movie: sibling ``<stem>.nfo`` or ``movie.nfo``."""
|
||||||
|
video_path = Path(video_path)
|
||||||
|
sibling = video_path.with_suffix(".nfo")
|
||||||
|
if sibling.is_file():
|
||||||
|
return sibling
|
||||||
|
movie_nfo = video_path.parent / "movie.nfo"
|
||||||
|
if movie_nfo.is_file():
|
||||||
|
return movie_nfo
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _texts(root: ET.Element, tag: str) -> list[str]:
|
||||||
|
return [e.text.strip() for e in root.findall(tag) if e.text and e.text.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rating(root: ET.Element) -> Optional[int]:
|
||||||
|
"""Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands)."""
|
||||||
|
raw = root.findtext("rating")
|
||||||
|
if raw is None:
|
||||||
|
value = root.find("ratings/rating/value")
|
||||||
|
raw = value.text if value is not None else None
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(float(raw.strip().replace(",", ".")) * 10)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tmdb_id(root: ET.Element) -> Optional[int]:
|
||||||
|
for uid in root.findall("uniqueid"):
|
||||||
|
if uid.get("type") == "tmdb" and uid.text and uid.text.strip().isdigit():
|
||||||
|
return int(uid.text.strip())
|
||||||
|
raw = root.findtext("tmdbid")
|
||||||
|
return int(raw.strip()) if raw and raw.strip().isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def read_nfo(video_path: Path) -> Optional[NfoData]:
|
||||||
|
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
|
||||||
|
path = find_nfo(video_path)
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
except (ET.ParseError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
year = root.findtext("year")
|
||||||
|
year = year.strip() if year and year.strip().isdigit() else None
|
||||||
|
title = root.findtext("title")
|
||||||
|
return NfoData(
|
||||||
|
title=title.strip() if title and title.strip() else None,
|
||||||
|
year=year,
|
||||||
|
genres=_texts(root, "genre"),
|
||||||
|
countries=_texts(root, "country"),
|
||||||
|
rating=_parse_rating(root),
|
||||||
|
tmdb_id=_parse_tmdb_id(root),
|
||||||
|
)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Series naming + scanning for the Jellyfin TV layout.
|
||||||
|
|
||||||
|
Jellyfin wants series laid out as::
|
||||||
|
|
||||||
|
Seriály/Series (year) [tmdbid-N]/
|
||||||
|
tvshow.nfo (Jellyfin writes it)
|
||||||
|
Season 01/
|
||||||
|
Series (year) S01E01.ext
|
||||||
|
Specials/ (season 0)
|
||||||
|
|
||||||
|
Episodes are matched by ``SxxEyy``. These helpers parse the episode number out of
|
||||||
|
a raw filename, build the canonical episode/season/series names, and scan a
|
||||||
|
series library into a ``Series → Season → Episode`` tree with a naming status.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .naming import canonical_pool_stem, parse_pool_stem, parse_tmdb_id
|
||||||
|
|
||||||
|
# SxxEyy / s1e2 / 1x02 — the episode marker in a filename. First match wins.
|
||||||
|
_EPISODE_RE = re.compile(r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))")
|
||||||
|
|
||||||
|
# Files next to an episode that are not video (NFO / artwork / subtitles).
|
||||||
|
_SIDECARS = {
|
||||||
|
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn",
|
||||||
|
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Episode:
|
||||||
|
path: Path
|
||||||
|
season: Optional[int]
|
||||||
|
episode: Optional[int]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def code(self) -> str:
|
||||||
|
if self.season is None or self.episode is None:
|
||||||
|
return "?"
|
||||||
|
return f"S{self.season:02d}E{self.episode:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Series:
|
||||||
|
folder: Path
|
||||||
|
title: str
|
||||||
|
year: Optional[str]
|
||||||
|
tmdb_id: Optional[int]
|
||||||
|
library: str
|
||||||
|
episodes: list[Episode] = field(default_factory=list)
|
||||||
|
|
||||||
|
def seasons(self) -> dict[int, list[Episode]]:
|
||||||
|
"""Episodes grouped by season number (``-1`` = unrecognised)."""
|
||||||
|
grouped: dict[int, list[Episode]] = {}
|
||||||
|
for ep in self.episodes:
|
||||||
|
grouped.setdefault(ep.season if ep.season is not None else -1, []).append(ep)
|
||||||
|
for eps in grouped.values():
|
||||||
|
eps.sort(key=lambda e: (e.episode if e.episode is not None else 1_000_000))
|
||||||
|
return dict(sorted(grouped.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_episode(name: str) -> Optional[tuple[int, int]]:
|
||||||
|
"""Extract ``(season, episode)`` from a filename, or ``None``."""
|
||||||
|
match = _EPISODE_RE.search(name)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
if match.group(1) is not None:
|
||||||
|
return int(match.group(1)), int(match.group(2))
|
||||||
|
return int(match.group(3)), int(match.group(4))
|
||||||
|
|
||||||
|
|
||||||
|
def season_folder_name(season: int) -> str:
|
||||||
|
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
|
||||||
|
return "Specials" if season == 0 else f"Season {season:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def episode_body(stem: str) -> str:
|
||||||
|
"""The descriptive part of an episode filename, minus a leading ``SxxEyy`` code.
|
||||||
|
|
||||||
|
Keeps the original (release) filename so it survives into the canonical name,
|
||||||
|
while stripping an already-applied ``SxxEyy - `` prefix so repeated renames
|
||||||
|
don't double it. Returns ``""`` when the stem was only the code.
|
||||||
|
"""
|
||||||
|
s = _EPISODE_RE.sub("", stem, count=1) # drop the SxxEyy/NxNN token
|
||||||
|
s = re.sub(r"([\s._-])[\s._-]+", r"\1", s) # collapse separator runs
|
||||||
|
return s.strip(" ._-")
|
||||||
|
|
||||||
|
|
||||||
|
def episode_filename(season: int, episode: int, orig_stem: str, ext: str) -> str:
|
||||||
|
"""Canonical episode filename ``SxxEyy - {original name}.ext``.
|
||||||
|
|
||||||
|
Keeps the original filename after the code; when the original was only the
|
||||||
|
code (nothing descriptive left) it collapses to ``SxxEyy.ext``.
|
||||||
|
"""
|
||||||
|
code = f"S{season:02d}E{episode:02d}"
|
||||||
|
body = episode_body(orig_stem)
|
||||||
|
return f"{code} - {body}{ext}" if body else f"{code}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def series_folder_stem(title: str, year: object = None, tmdb_id: object = None) -> str:
|
||||||
|
"""Series folder name: ``Title (year) [tmdbid-N]`` (year/id optional).
|
||||||
|
|
||||||
|
Reuses the movie convention when a year is present; otherwise falls back to
|
||||||
|
``Title [tmdbid-N]`` or just ``Title`` so a year-less series still works.
|
||||||
|
"""
|
||||||
|
title = (title or "").strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Název seriálu nesmí být prázdný.")
|
||||||
|
year_s = str(year).strip() if year is not None else ""
|
||||||
|
if re.fullmatch(r"\d{4}", year_s):
|
||||||
|
return canonical_pool_stem(title, year_s, tmdb_id)
|
||||||
|
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
|
||||||
|
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
|
||||||
|
|
||||||
|
|
||||||
|
def is_canonical_episode(path: Path) -> bool:
|
||||||
|
"""True when ``path`` already follows ``SxxEyy - name`` in the right season."""
|
||||||
|
parsed = parse_episode(path.name)
|
||||||
|
if parsed is None:
|
||||||
|
return False
|
||||||
|
season, episode = parsed
|
||||||
|
expected = episode_filename(season, episode, path.stem, path.suffix)
|
||||||
|
if path.name != expected:
|
||||||
|
return False
|
||||||
|
return path.parent.name == season_folder_name(season)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_series(lib_dir: Path, library: str) -> list[Series]:
|
||||||
|
"""Scan one series library into a list of :class:`Series`.
|
||||||
|
|
||||||
|
Each immediate subfolder of ``lib_dir`` is a series; its name yields the
|
||||||
|
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
|
||||||
|
parsed from the filename). Non-video sidecars are skipped.
|
||||||
|
"""
|
||||||
|
lib_dir = Path(lib_dir)
|
||||||
|
if not lib_dir.is_dir():
|
||||||
|
return []
|
||||||
|
series_list: list[Series] = []
|
||||||
|
for folder in sorted(p for p in lib_dir.iterdir() if p.is_dir()):
|
||||||
|
parsed = parse_pool_stem(folder.name)
|
||||||
|
if parsed is not None:
|
||||||
|
title, year = parsed[0], str(parsed[1])
|
||||||
|
else:
|
||||||
|
title, year = folder.name, None
|
||||||
|
tmdb_id = parse_tmdb_id(folder.name)
|
||||||
|
|
||||||
|
episodes: list[Episode] = []
|
||||||
|
for f in sorted(folder.rglob("*")):
|
||||||
|
if not f.is_file() or f.suffix.lower() in _SIDECARS:
|
||||||
|
continue
|
||||||
|
code = parse_episode(f.name)
|
||||||
|
episodes.append(Episode(
|
||||||
|
f, code[0] if code else None, code[1] if code else None))
|
||||||
|
series_list.append(Series(folder, title, year, tmdb_id, library, episodes))
|
||||||
|
return series_list
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""TMDb (The Movie Database) client — Curator's identity source.
|
||||||
|
|
||||||
|
Replaces ČSFD. Its only job is to turn a **TMDb id** (or a title/year search)
|
||||||
|
into a canonical **title + year** for naming the pool file. The rich metadata
|
||||||
|
and artwork are fetched by **Jellyfin itself** — Curator does not need them here.
|
||||||
|
|
||||||
|
Needs a free API key in ``TMDB_API_KEY`` (loaded from ``.env``). Everything
|
||||||
|
degrades gracefully when the key or the network is missing: :func:`api_available`
|
||||||
|
reports readiness and the lookups raise :class:`TmdbError` with a clear message.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try: # optional — the key can also come from the real environment
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except Exception: # noqa: BLE001 — dotenv is a convenience, never required
|
||||||
|
pass
|
||||||
|
|
||||||
|
_BASE = "https://api.themoviedb.org/3"
|
||||||
|
_LANG = os.getenv("TMDB_LANGUAGE", "cs-CZ")
|
||||||
|
_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TmdbResult:
|
||||||
|
"""A resolved TMDb entry — just enough to name the pool file."""
|
||||||
|
|
||||||
|
tmdb_id: int
|
||||||
|
title: str
|
||||||
|
year: Optional[str] # four-digit string, or None when TMDb has no date
|
||||||
|
kind: str # "movie" | "tv"
|
||||||
|
|
||||||
|
|
||||||
|
class TmdbError(RuntimeError):
|
||||||
|
"""A TMDb request could not be completed (no key, network, bad status)."""
|
||||||
|
|
||||||
|
|
||||||
|
def api_key() -> Optional[str]:
|
||||||
|
"""The configured TMDb API key, or None."""
|
||||||
|
key = os.getenv("TMDB_API_KEY")
|
||||||
|
return key.strip() if key and key.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def api_available() -> bool:
|
||||||
|
"""True when a TMDb API key is configured."""
|
||||||
|
return api_key() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path: str, params: dict) -> dict:
|
||||||
|
key = api_key()
|
||||||
|
if not key:
|
||||||
|
raise TmdbError("TMDB_API_KEY není nastaven (.env).")
|
||||||
|
query = {"api_key": key, "language": _LANG, **params}
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{_BASE}{path}", params=query, timeout=_TIMEOUT)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise TmdbError(f"Síťová chyba TMDb: {exc}") from exc
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise TmdbError("Neplatný TMDB_API_KEY.")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise TmdbError("TMDb ID nenalezeno.")
|
||||||
|
if not resp.ok:
|
||||||
|
raise TmdbError(f"TMDb odpověděl HTTP {resp.status_code}.")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _year_of(date_str: Optional[str]) -> Optional[str]:
|
||||||
|
if date_str and len(date_str) >= 4 and date_str[:4].isdigit():
|
||||||
|
return date_str[:4]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_movie(tmdb_id: int) -> Optional[TmdbResult]:
|
||||||
|
"""Resolve a movie by its TMDb id → title + year, or None if it has no title."""
|
||||||
|
data = _get(f"/movie/{int(tmdb_id)}", {})
|
||||||
|
title = data.get("title") or data.get("original_title")
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
return TmdbResult(int(data["id"]), title, _year_of(data.get("release_date")), "movie")
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_tv(tmdb_id: int) -> Optional[TmdbResult]:
|
||||||
|
"""Resolve a series by its TMDb id → name + first-air year, or None."""
|
||||||
|
data = _get(f"/tv/{int(tmdb_id)}", {})
|
||||||
|
title = data.get("name") or data.get("original_name")
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
return TmdbResult(int(data["id"]), title, _year_of(data.get("first_air_date")), "tv")
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(tmdb_id: int, kind: str = "movie") -> Optional[TmdbResult]:
|
||||||
|
"""Resolve a TMDb id as a movie (default) or a series (``kind="tv"``)."""
|
||||||
|
return lookup_tv(tmdb_id) if kind == "tv" else lookup_movie(tmdb_id)
|
||||||
|
|
||||||
|
|
||||||
|
def search_movie(
|
||||||
|
query: str, year: Optional[str] = None, limit: int = 5
|
||||||
|
) -> list[TmdbResult]:
|
||||||
|
"""Search movies by title (optionally narrowed by year); best matches first."""
|
||||||
|
if not query.strip():
|
||||||
|
return []
|
||||||
|
params: dict = {"query": query.strip()}
|
||||||
|
if year:
|
||||||
|
params["year"] = year
|
||||||
|
data = _get("/search/movie", params)
|
||||||
|
results: list[TmdbResult] = []
|
||||||
|
for item in (data.get("results") or [])[:limit]:
|
||||||
|
title = item.get("title") or item.get("original_title")
|
||||||
|
if title and item.get("id"):
|
||||||
|
results.append(
|
||||||
|
TmdbResult(int(item["id"]), title,
|
||||||
|
_year_of(item.get("release_date")), "movie"))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def search_tv(
|
||||||
|
query: str, year: Optional[str] = None, limit: int = 5
|
||||||
|
) -> list[TmdbResult]:
|
||||||
|
"""Search series by name (optionally narrowed by first-air year)."""
|
||||||
|
if not query.strip():
|
||||||
|
return []
|
||||||
|
params: dict = {"query": query.strip()}
|
||||||
|
if year:
|
||||||
|
params["first_air_date_year"] = year
|
||||||
|
data = _get("/search/tv", params)
|
||||||
|
results: list[TmdbResult] = []
|
||||||
|
for item in (data.get("results") or [])[:limit]:
|
||||||
|
title = item.get("name") or item.get("original_name")
|
||||||
|
if title and item.get("id"):
|
||||||
|
results.append(
|
||||||
|
TmdbResult(int(item["id"]), title,
|
||||||
|
_year_of(item.get("first_air_date")), "tv"))
|
||||||
|
return results
|
||||||
@@ -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
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def rating_band(rating: int) -> str:
|
||||||
|
"""Bucket a 0–100 rating into a ten-point band label (e.g. "80–89 %").
|
||||||
|
|
||||||
|
The top bucket spans 90–100 % so a perfect 100 still lands in a band.
|
||||||
|
"""
|
||||||
|
low = min((rating // 10) * 10, 90)
|
||||||
|
high = 100 if low == 90 else low + 9
|
||||||
|
return f"{low}–{high} %"
|
||||||
|
|
||||||
|
|
||||||
|
def _decade_band(value) -> str:
|
||||||
|
"""``decade_band`` transform: pull the leading integer and bucket it."""
|
||||||
|
match = re.search(r"\d+", str(value))
|
||||||
|
return rating_band(int(match.group())) if match else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
# Named value transforms usable from the tag schema (config-driven, extensible).
|
||||||
|
TAG_TRANSFORMS = {
|
||||||
|
None: str,
|
||||||
|
"identity": str,
|
||||||
|
"decade_band": _decade_band,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_transform(value: str, transform: Optional[str]) -> str:
|
||||||
|
"""Apply a named tag-schema transform to a value, returning a string."""
|
||||||
|
fn = TAG_TRANSFORMS.get(transform, str)
|
||||||
|
try:
|
||||||
|
return str(fn(value))
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
return str(value)
|
||||||
+1142
-179
File diff suppressed because it is too large
Load Diff
@@ -1,455 +0,0 @@
|
|||||||
"""Tests for CSFD.cz scraper module."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from src.core.csfd import (
|
|
||||||
CSFDMovie,
|
|
||||||
fetch_movie,
|
|
||||||
search_movies,
|
|
||||||
fetch_movie_by_id,
|
|
||||||
_extract_csfd_id,
|
|
||||||
_parse_duration,
|
|
||||||
_extract_json_ld,
|
|
||||||
_extract_rating,
|
|
||||||
_extract_poster,
|
|
||||||
_extract_plot,
|
|
||||||
_extract_genres,
|
|
||||||
_extract_origin_info,
|
|
||||||
_check_dependencies,
|
|
||||||
_solve_anubis_pow,
|
|
||||||
_split_countries,
|
|
||||||
rating_band,
|
|
||||||
clean_filename_to_query,
|
|
||||||
find_csfd_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_session(mock_requests):
|
|
||||||
"""Wire ``mock_requests`` so ``requests.Session()`` (also as a context
|
|
||||||
manager) yields a single configurable session mock and return it."""
|
|
||||||
session = MagicMock()
|
|
||||||
session.__enter__.return_value = session
|
|
||||||
mock_requests.Session.return_value = session
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
# Sample HTML for testing
|
|
||||||
SAMPLE_JSON_LD = """
|
|
||||||
{
|
|
||||||
"@type": "Movie",
|
|
||||||
"name": "Test Movie",
|
|
||||||
"director": [{"@type": "Person", "name": "Test Director"}],
|
|
||||||
"actor": [{"@type": "Person", "name": "Actor 1"}, {"@type": "Person", "name": "Actor 2"}],
|
|
||||||
"aggregateRating": {"ratingValue": 85.5, "ratingCount": 1000},
|
|
||||||
"duration": "PT120M",
|
|
||||||
"description": "A test movie description."
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
SAMPLE_HTML = """
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<script type="application/ld+json">%s</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="film-rating-average">85%%</div>
|
|
||||||
<div class="genres">
|
|
||||||
<a href="/zanry/1/">Drama</a> /
|
|
||||||
<a href="/zanry/2/">Thriller</a>
|
|
||||||
</div>
|
|
||||||
<div class="origin">Česko, 2020, 120 min</div>
|
|
||||||
<div class="film-poster">
|
|
||||||
<img src="//image.example.com/poster.jpg">
|
|
||||||
</div>
|
|
||||||
<div class="plot-full"><p>Full plot description.</p></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""" % SAMPLE_JSON_LD
|
|
||||||
|
|
||||||
|
|
||||||
class TestCSFDMovie:
|
|
||||||
"""Tests for CSFDMovie dataclass."""
|
|
||||||
|
|
||||||
def test_csfd_movie_basic(self):
|
|
||||||
"""Test basic CSFDMovie creation."""
|
|
||||||
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
|
|
||||||
assert movie.title == "Test"
|
|
||||||
assert movie.url == "https://csfd.cz/film/123/"
|
|
||||||
assert movie.year is None
|
|
||||||
assert movie.genres == []
|
|
||||||
assert movie.rating is None
|
|
||||||
|
|
||||||
def test_csfd_movie_full(self):
|
|
||||||
"""Test CSFDMovie with all fields."""
|
|
||||||
movie = CSFDMovie(
|
|
||||||
title="Test Movie",
|
|
||||||
url="https://csfd.cz/film/123/",
|
|
||||||
year=2020,
|
|
||||||
genres=["Drama", "Thriller"],
|
|
||||||
directors=["Director 1"],
|
|
||||||
actors=["Actor 1", "Actor 2"],
|
|
||||||
rating=85,
|
|
||||||
rating_count=1000,
|
|
||||||
duration=120,
|
|
||||||
countries=["Česko"],
|
|
||||||
poster_url="https://image.example.com/poster.jpg",
|
|
||||||
plot="A test movie.",
|
|
||||||
csfd_id=123
|
|
||||||
)
|
|
||||||
assert movie.year == 2020
|
|
||||||
assert movie.genres == ["Drama", "Thriller"]
|
|
||||||
assert movie.rating == 85
|
|
||||||
assert movie.duration == 120
|
|
||||||
assert movie.countries == ["Česko"]
|
|
||||||
assert movie.csfd_id == 123
|
|
||||||
|
|
||||||
def test_csfd_movie_str(self):
|
|
||||||
"""Test CSFDMovie string representation."""
|
|
||||||
movie = CSFDMovie(
|
|
||||||
title="Test Movie",
|
|
||||||
url="https://csfd.cz/film/123/",
|
|
||||||
year=2020,
|
|
||||||
genres=["Drama"],
|
|
||||||
directors=["Director 1"],
|
|
||||||
rating=85
|
|
||||||
)
|
|
||||||
s = str(movie)
|
|
||||||
assert "Test Movie (2020)" in s
|
|
||||||
assert "85%" in s
|
|
||||||
assert "Drama" in s
|
|
||||||
assert "Director 1" in s
|
|
||||||
|
|
||||||
def test_csfd_movie_str_minimal(self):
|
|
||||||
"""Test CSFDMovie string with minimal data."""
|
|
||||||
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
|
|
||||||
s = str(movie)
|
|
||||||
assert "Test" in s
|
|
||||||
|
|
||||||
|
|
||||||
class TestHelperFunctions:
|
|
||||||
"""Tests for helper functions."""
|
|
||||||
|
|
||||||
def test_extract_csfd_id_valid(self):
|
|
||||||
"""Test extracting CSFD ID from valid URL."""
|
|
||||||
assert _extract_csfd_id("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/") == 9423
|
|
||||||
assert _extract_csfd_id("https://www.csfd.cz/film/123456/") == 123456
|
|
||||||
assert _extract_csfd_id("/film/999/prehled/") == 999
|
|
||||||
|
|
||||||
def test_extract_csfd_id_invalid(self):
|
|
||||||
"""Test extracting CSFD ID from invalid URL."""
|
|
||||||
assert _extract_csfd_id("https://www.csfd.cz/") is None
|
|
||||||
assert _extract_csfd_id("not-a-url") is None
|
|
||||||
|
|
||||||
def test_parse_duration_valid(self):
|
|
||||||
"""Test parsing ISO 8601 duration."""
|
|
||||||
assert _parse_duration("PT97M") == 97
|
|
||||||
assert _parse_duration("PT120M") == 120
|
|
||||||
assert _parse_duration("PT60M") == 60
|
|
||||||
|
|
||||||
def test_parse_duration_invalid(self):
|
|
||||||
"""Test parsing invalid duration."""
|
|
||||||
assert _parse_duration("") is None
|
|
||||||
assert _parse_duration("invalid") is None
|
|
||||||
|
|
||||||
def test_split_countries_single(self):
|
|
||||||
"""A single country yields a one-item list."""
|
|
||||||
assert _split_countries("USA") == ["USA"]
|
|
||||||
|
|
||||||
def test_split_countries_multiple(self):
|
|
||||||
"""Slash-separated co-production countries are split and trimmed."""
|
|
||||||
assert _split_countries("USA / Velká Británie") == ["USA", "Velká Británie"]
|
|
||||||
assert _split_countries("Japonsko/USA") == ["Japonsko", "USA"]
|
|
||||||
|
|
||||||
def test_split_countries_empty(self):
|
|
||||||
"""None/empty yields an empty list."""
|
|
||||||
assert _split_countries(None) == []
|
|
||||||
assert _split_countries("") == []
|
|
||||||
|
|
||||||
def test_from_dict_migrates_legacy_country(self):
|
|
||||||
"""Legacy cache with a single 'country' string maps to countries list."""
|
|
||||||
movie = CSFDMovie.from_dict({"title": "X", "country": "USA / Kanada"})
|
|
||||||
assert movie.countries == ["USA", "Kanada"]
|
|
||||||
|
|
||||||
def test_from_dict_uses_countries_when_present(self):
|
|
||||||
"""New cache with 'countries' is used verbatim."""
|
|
||||||
movie = CSFDMovie.from_dict({"title": "X", "countries": ["Japonsko", "USA"]})
|
|
||||||
assert movie.countries == ["Japonsko", "USA"]
|
|
||||||
|
|
||||||
def test_rating_band_buckets(self):
|
|
||||||
"""Rating is bucketed into ten-point bands, top band spans 90–100 %."""
|
|
||||||
assert rating_band(0) == "0–9 %"
|
|
||||||
assert rating_band(86) == "80–89 %"
|
|
||||||
assert rating_band(90) == "90–100 %"
|
|
||||||
assert rating_band(95) == "90–100 %"
|
|
||||||
assert rating_band(100) == "90–100 %"
|
|
||||||
|
|
||||||
def test_csfd_field_values_are_exact_no_transform(self):
|
|
||||||
from src.core.csfd import csfd_field_values
|
|
||||||
movie = CSFDMovie(title="X", url="u", year=1999, rating=86,
|
|
||||||
genres=["Akční", "Sci-Fi"], countries=["USA", "Kanada"])
|
|
||||||
assert csfd_field_values(movie, "genres") == ["Akční", "Sci-Fi"]
|
|
||||||
assert csfd_field_values(movie, "countries") == ["USA", "Kanada"]
|
|
||||||
assert csfd_field_values(movie, "year") == ["1999"]
|
|
||||||
# rating tag carries the EXACT value (transform happens only for folders)
|
|
||||||
assert csfd_field_values(movie, "rating") == ["86"]
|
|
||||||
# missing field / value → empty
|
|
||||||
assert csfd_field_values(CSFDMovie(title="X", url="u"), "rating") == []
|
|
||||||
assert csfd_field_values(movie, "genres") == csfd_field_values(movie, "genres")
|
|
||||||
|
|
||||||
def test_apply_transform_decade_band(self):
|
|
||||||
from src.core.csfd import apply_transform
|
|
||||||
assert apply_transform("86", "decade_band") == "80–89 %"
|
|
||||||
assert apply_transform("90", "decade_band") == "90–100 %"
|
|
||||||
assert apply_transform("Akční", None) == "Akční" # identity for non-rating
|
|
||||||
assert apply_transform("USA", "identity") == "USA"
|
|
||||||
assert _parse_duration("PT") is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestHTMLExtraction:
|
|
||||||
"""Tests for HTML extraction functions."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def soup(self):
|
|
||||||
"""Create BeautifulSoup object from sample HTML."""
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
return BeautifulSoup(SAMPLE_HTML, "html.parser")
|
|
||||||
|
|
||||||
def test_extract_json_ld(self, soup):
|
|
||||||
"""Test extracting data from JSON-LD."""
|
|
||||||
data = _extract_json_ld(soup)
|
|
||||||
assert data["title"] == "Test Movie"
|
|
||||||
assert data["directors"] == ["Test Director"]
|
|
||||||
assert data["actors"] == ["Actor 1", "Actor 2"]
|
|
||||||
assert data["rating"] == 86 # Rounded from 85.5
|
|
||||||
assert data["rating_count"] == 1000
|
|
||||||
assert data["duration"] == 120
|
|
||||||
|
|
||||||
def test_extract_rating(self, soup):
|
|
||||||
"""Test extracting rating from HTML."""
|
|
||||||
rating = _extract_rating(soup)
|
|
||||||
assert rating == 85
|
|
||||||
|
|
||||||
def test_extract_genres(self, soup):
|
|
||||||
"""Test extracting genres from HTML."""
|
|
||||||
genres = _extract_genres(soup)
|
|
||||||
assert "Drama" in genres
|
|
||||||
assert "Thriller" in genres
|
|
||||||
|
|
||||||
def test_extract_poster(self, soup):
|
|
||||||
"""Test extracting poster URL."""
|
|
||||||
poster = _extract_poster(soup)
|
|
||||||
assert poster == "https://image.example.com/poster.jpg"
|
|
||||||
|
|
||||||
def test_extract_plot(self, soup):
|
|
||||||
"""Test extracting plot."""
|
|
||||||
plot = _extract_plot(soup)
|
|
||||||
assert plot == "Full plot description."
|
|
||||||
|
|
||||||
def test_extract_origin_info(self, soup):
|
|
||||||
"""Test extracting origin info (comma-separated legacy format)."""
|
|
||||||
info = _extract_origin_info(soup)
|
|
||||||
assert info["countries"] == ["Česko"]
|
|
||||||
assert info["year"] == 2020
|
|
||||||
assert info["duration"] == 120
|
|
||||||
|
|
||||||
def test_extract_origin_info_bullet_format(self):
|
|
||||||
"""Test current CSFD format with inline bullet spans (no commas)."""
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
html = (
|
|
||||||
'<div class="origin">USA <span class="bullet"></span>'
|
|
||||||
'<span>1999 <span class="bullet"></span> </span>'
|
|
||||||
'136 min (Alternativní 131 min)</div>'
|
|
||||||
)
|
|
||||||
info = _extract_origin_info(BeautifulSoup(html, "html.parser"))
|
|
||||||
assert info["countries"] == ["USA"]
|
|
||||||
assert info["year"] == 1999
|
|
||||||
assert info["duration"] == 136
|
|
||||||
|
|
||||||
def test_extract_origin_info_multiple_countries(self):
|
|
||||||
"""A co-production lists several slash-separated countries."""
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
html = (
|
|
||||||
'<div class="origin">USA / Velká Británie '
|
|
||||||
'<span class="bullet"></span><span>2009 </span>'
|
|
||||||
'<span class="bullet"></span> 166 min</div>'
|
|
||||||
)
|
|
||||||
info = _extract_origin_info(BeautifulSoup(html, "html.parser"))
|
|
||||||
assert info["countries"] == ["USA", "Velká Británie"]
|
|
||||||
assert info["year"] == 2009
|
|
||||||
assert info["duration"] == 166
|
|
||||||
|
|
||||||
def test_extract_json_ld_year_from_date_created(self):
|
|
||||||
"""Year is taken from JSON-LD dateCreated when present."""
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
html = (
|
|
||||||
'<script type="application/ld+json">'
|
|
||||||
'{"@type": "Movie", "name": "Matrix", "dateCreated": 1999}'
|
|
||||||
'</script>'
|
|
||||||
)
|
|
||||||
data = _extract_json_ld(BeautifulSoup(html, "html.parser"))
|
|
||||||
assert data["year"] == 1999
|
|
||||||
|
|
||||||
|
|
||||||
class TestCleanFilenameToQuery:
|
|
||||||
"""Tests for turning a filename into a ČSFD search query."""
|
|
||||||
|
|
||||||
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):
|
|
||||||
# No real title words before the marker → fall back to the cleaned stem
|
|
||||||
assert clean_filename_to_query("1080p.mkv") == "1080p"
|
|
||||||
|
|
||||||
|
|
||||||
class TestFindCsfdUrl:
|
|
||||||
"""Tests for find_csfd_url (search is mocked)."""
|
|
||||||
|
|
||||||
def test_returns_first_result_url(self):
|
|
||||||
from unittest.mock import patch
|
|
||||||
movies = [
|
|
||||||
CSFDMovie(title="Matrix", url="https://www.csfd.cz/film/9499-matrix/"),
|
|
||||||
CSFDMovie(title="Matrix Reloaded", url="https://www.csfd.cz/film/9497-x/"),
|
|
||||||
]
|
|
||||||
with patch("src.core.csfd.search_movies", return_value=movies):
|
|
||||||
assert find_csfd_url("Matrix 1999") == "https://www.csfd.cz/film/9499-matrix/"
|
|
||||||
|
|
||||||
def test_returns_none_for_empty_query(self):
|
|
||||||
assert find_csfd_url(" ") is None
|
|
||||||
|
|
||||||
def test_returns_none_when_no_results(self):
|
|
||||||
from unittest.mock import patch
|
|
||||||
with patch("src.core.csfd.search_movies", return_value=[]):
|
|
||||||
assert find_csfd_url("nonexistent film") is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchMovie:
|
|
||||||
"""Tests for fetch_movie function."""
|
|
||||||
|
|
||||||
@patch("src.core.csfd.requests")
|
|
||||||
def test_fetch_movie_success(self, mock_requests):
|
|
||||||
"""Test successful movie fetch."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.text = SAMPLE_HTML
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
session = _mock_session(mock_requests)
|
|
||||||
session.get.return_value = mock_response
|
|
||||||
|
|
||||||
movie = fetch_movie("https://www.csfd.cz/film/123-test/")
|
|
||||||
|
|
||||||
assert movie.title == "Test Movie"
|
|
||||||
assert movie.csfd_id == 123
|
|
||||||
assert movie.rating == 86
|
|
||||||
assert "Drama" in movie.genres
|
|
||||||
session.get.assert_called_once()
|
|
||||||
|
|
||||||
@patch("src.core.csfd.requests")
|
|
||||||
def test_fetch_movie_caps_actors_at_ten(self, mock_requests):
|
|
||||||
"""Only the first MAX_ACTORS (10) of a long cast are kept."""
|
|
||||||
import json as _json
|
|
||||||
actors = [{"@type": "Person", "name": f"Actor {i}"} for i in range(25)]
|
|
||||||
json_ld = _json.dumps({
|
|
||||||
"@type": "Movie", "name": "Crowded", "actor": actors,
|
|
||||||
"director": [{"@type": "Person", "name": "Dir"}],
|
|
||||||
"aggregateRating": {"ratingValue": 70, "ratingCount": 5},
|
|
||||||
})
|
|
||||||
html = f'<html><head><script type="application/ld+json">{json_ld}</script></head></html>'
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.text = html
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
session = _mock_session(mock_requests)
|
|
||||||
session.get.return_value = mock_response
|
|
||||||
|
|
||||||
movie = fetch_movie("https://www.csfd.cz/film/1-crowded/")
|
|
||||||
|
|
||||||
assert movie.directors == ["Dir"]
|
|
||||||
assert movie.rating == 70
|
|
||||||
assert len(movie.actors) == 10
|
|
||||||
assert movie.actors[0] == "Actor 0"
|
|
||||||
assert movie.actors[-1] == "Actor 9"
|
|
||||||
|
|
||||||
@patch("src.core.csfd.requests")
|
|
||||||
def test_fetch_movie_network_error(self, mock_requests):
|
|
||||||
"""Test network error handling."""
|
|
||||||
import requests as real_requests
|
|
||||||
session = _mock_session(mock_requests)
|
|
||||||
session.get.side_effect = real_requests.RequestException("Network error")
|
|
||||||
|
|
||||||
with pytest.raises(real_requests.RequestException):
|
|
||||||
fetch_movie("https://www.csfd.cz/film/123/")
|
|
||||||
|
|
||||||
|
|
||||||
class TestSearchMovies:
|
|
||||||
"""Tests for search_movies function."""
|
|
||||||
|
|
||||||
@patch("src.core.csfd.requests")
|
|
||||||
def test_search_movies(self, mock_requests):
|
|
||||||
"""Test movie search."""
|
|
||||||
search_html = """
|
|
||||||
<html><body>
|
|
||||||
<a href="/film/123-test/" class="film-title-name">Test Movie</a>
|
|
||||||
<a href="/film/456-another/" class="film-title-name">Another Movie</a>
|
|
||||||
</body></html>
|
|
||||||
"""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.text = search_html
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
session = _mock_session(mock_requests)
|
|
||||||
session.get.return_value = mock_response
|
|
||||||
mock_requests.utils.quote = lambda x: x
|
|
||||||
|
|
||||||
results = search_movies("test", limit=10)
|
|
||||||
|
|
||||||
assert len(results) >= 1
|
|
||||||
assert any(m.csfd_id == 123 for m in results)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchMovieById:
|
|
||||||
"""Tests for fetch_movie_by_id function."""
|
|
||||||
|
|
||||||
@patch("src.core.csfd.fetch_movie")
|
|
||||||
def test_fetch_by_id(self, mock_fetch):
|
|
||||||
"""Test fetching movie by ID."""
|
|
||||||
mock_fetch.return_value = CSFDMovie(title="Test", url="https://csfd.cz/film/9423/")
|
|
||||||
|
|
||||||
movie = fetch_movie_by_id(9423)
|
|
||||||
|
|
||||||
mock_fetch.assert_called_once_with("https://www.csfd.cz/film/9423/")
|
|
||||||
assert movie.title == "Test"
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnubisPoW:
|
|
||||||
"""Tests for the Anubis proof-of-work solver."""
|
|
||||||
|
|
||||||
def test_solve_pow_difficulty_one(self):
|
|
||||||
"""Difficulty 1 requires a single leading zero nibble in the hash."""
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
random_data = "abc123"
|
|
||||||
hash_hex, nonce, _ = _solve_anubis_pow(random_data, difficulty=1)
|
|
||||||
assert hash_hex[0] == "0"
|
|
||||||
assert hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest() == hash_hex
|
|
||||||
|
|
||||||
def test_solve_pow_difficulty_two(self):
|
|
||||||
"""Difficulty 2 requires two leading zero nibbles (one zero byte)."""
|
|
||||||
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
|
|
||||||
assert hash_hex[:2] == "00"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDependencyCheck:
|
|
||||||
"""Tests for dependency checking."""
|
|
||||||
|
|
||||||
def test_dependencies_available(self):
|
|
||||||
"""Test that dependencies are available (they should be in test env)."""
|
|
||||||
# Should not raise
|
|
||||||
_check_dependencies()
|
|
||||||
+24
-53
@@ -207,8 +207,8 @@ class TestFile:
|
|||||||
assert file_obj2.date == "2025-01-01"
|
assert file_obj2.date == "2025-01-01"
|
||||||
|
|
||||||
|
|
||||||
class TestApplyCsfdTags:
|
class TestApplyNfoTags:
|
||||||
"""Tests for File.apply_csfd_tags tag assignment (CSFD fetch is mocked)."""
|
"""Tests for File.apply_nfo tag assignment (NFO parsed from disk)."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tag_manager(self):
|
def tag_manager(self):
|
||||||
@@ -222,22 +222,14 @@ class TestApplyCsfdTags:
|
|||||||
def movie_file(self, tmp_path, tag_manager, index):
|
def movie_file(self, tmp_path, tag_manager, index):
|
||||||
path = tmp_path / "Matrix.mkv"
|
path = tmp_path / "Matrix.mkv"
|
||||||
path.write_text("x")
|
path.write_text("x")
|
||||||
f = File(path, tag_manager, index=index)
|
return File(path, tag_manager, index=index)
|
||||||
f.set_csfd_link("https://www.csfd.cz/film/9499-matrix/")
|
|
||||||
return f
|
|
||||||
|
|
||||||
def test_apply_csfd_tags_assigns_expected_categories(self, movie_file):
|
def test_apply_nfo_assigns_expected_categories(self, movie_file):
|
||||||
from unittest.mock import patch
|
from src.core.nfo import NfoData
|
||||||
from src.core.csfd import CSFDMovie
|
|
||||||
|
|
||||||
movie = CSFDMovie(
|
nfo = NfoData(title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
|
||||||
title="Matrix", url="u", year=1999, genres=["Akční", "Sci-Fi"],
|
countries=["USA"], rating=90)
|
||||||
directors=["Lana Wachowski", "Lilly Wachowski"],
|
result = movie_file.apply_nfo(nfo)
|
||||||
actors=["Keanu Reeves", "Laurence Fishburne"],
|
|
||||||
rating=90, countries=["USA"],
|
|
||||||
)
|
|
||||||
with patch("src.core.csfd.fetch_movie", return_value=movie):
|
|
||||||
result = movie_file.apply_csfd_tags()
|
|
||||||
|
|
||||||
assert result["success"]
|
assert result["success"]
|
||||||
paths = {t.full_path for t in movie_file.tags}
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
@@ -247,28 +239,14 @@ class TestApplyCsfdTags:
|
|||||||
assert "Země původu/USA" in paths
|
assert "Země původu/USA" in paths
|
||||||
assert "Hodnocení/90" in paths # exact value; folder grouping happens later
|
assert "Hodnocení/90" in paths # exact value; folder grouping happens later
|
||||||
|
|
||||||
def test_apply_csfd_tags_does_not_tag_directors_or_actors(self, movie_file):
|
def test_apply_nfo_none_is_noop(self, movie_file):
|
||||||
"""Režie/herci se jen cachují, netvoří se z nich tagy (bylo by jich moc)."""
|
result = movie_file.apply_nfo(None)
|
||||||
from unittest.mock import patch
|
assert not result["success"]
|
||||||
from src.core.csfd import CSFDMovie
|
assert movie_file.tags == []
|
||||||
|
|
||||||
movie = CSFDMovie(
|
|
||||||
title="Matrix", url="u", directors=["Lana Wachowski"],
|
|
||||||
actors=["Keanu Reeves", "Laurence Fishburne"], genres=["Drama"],
|
|
||||||
)
|
|
||||||
with patch("src.core.csfd.fetch_movie", return_value=movie):
|
|
||||||
movie_file.apply_csfd_tags()
|
|
||||||
|
|
||||||
paths = {t.full_path for t in movie_file.tags}
|
|
||||||
assert not any(p.startswith("Režie/") for p in paths)
|
|
||||||
assert not any(p.startswith("Herec/") for p in paths)
|
|
||||||
cached = movie_file.get_cached_movie()
|
|
||||||
assert cached.directors == ["Lana Wachowski"]
|
|
||||||
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"]
|
|
||||||
|
|
||||||
def test_name_context_fields(self, movie_file):
|
def test_name_context_fields(self, movie_file):
|
||||||
movie_file.title = "Dr. No"
|
movie_file.title = "Dr. No"
|
||||||
movie_file.csfd_cache = {"year": 1962, "rating": 75}
|
movie_file.meta = {"year": 1962, "rating": 75}
|
||||||
ctx = movie_file.name_context()
|
ctx = movie_file.name_context()
|
||||||
assert ctx["title"] == "Dr. No"
|
assert ctx["title"] == "Dr. No"
|
||||||
assert ctx["year"] == "1962" # coerced to str for uniform template fields
|
assert ctx["year"] == "1962" # coerced to str for uniform template fields
|
||||||
@@ -278,7 +256,7 @@ class TestApplyCsfdTags:
|
|||||||
assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}"
|
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):
|
def test_name_context_year_from_tag_when_no_cache(self, movie_file):
|
||||||
movie_file.csfd_cache = None
|
movie_file.meta = None
|
||||||
movie_file.add_tag("Rok/1999")
|
movie_file.add_tag("Rok/1999")
|
||||||
assert movie_file.name_context()["year"] == "1999"
|
assert movie_file.name_context()["year"] == "1999"
|
||||||
|
|
||||||
@@ -315,37 +293,30 @@ class TestApplyCsfdTags:
|
|||||||
assert "{collection_sort} - {title}{ext}".format(**ctx) == \
|
assert "{collection_sort} - {title}{ext}".format(**ctx) == \
|
||||||
f"01 - Dr. No{ctx['ext']}"
|
f"01 - Dr. No{ctx['ext']}"
|
||||||
|
|
||||||
def test_apply_csfd_tags_honors_custom_schema(self, movie_file):
|
def test_apply_nfo_honors_custom_schema(self, movie_file):
|
||||||
"""A schema without the rating entry produces no Hodnocení tags."""
|
"""A schema without the rating entry produces no Hodnocení tags."""
|
||||||
from unittest.mock import patch
|
from src.core.nfo import NfoData
|
||||||
from src.core.csfd import CSFDMovie
|
|
||||||
|
|
||||||
schema = [{"category": "Žánr", "csfd_field": "genres",
|
schema = [{"category": "Žánr", "csfd_field": "genres",
|
||||||
"transform": None, "filmoteka_root": ""}]
|
"transform": None, "filmoteka_root": ""}]
|
||||||
movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"])
|
movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
|
||||||
with patch("src.core.csfd.fetch_movie", return_value=movie):
|
|
||||||
movie_file.apply_csfd_tags(schema)
|
|
||||||
|
|
||||||
paths = {t.full_path for t in movie_file.tags}
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
assert "Žánr/Drama" in paths
|
assert "Žánr/Drama" in paths
|
||||||
assert not any(p.startswith("Hodnocení/") for p in paths)
|
assert not any(p.startswith("Hodnocení/") for p in paths)
|
||||||
|
|
||||||
def test_apply_csfd_tags_preserves_user_tags_on_refetch(self, movie_file):
|
def test_apply_nfo_preserves_user_tags_on_refresh(self, movie_file):
|
||||||
"""Re-fetching regenerates only ČSFD tags; user-added tags survive."""
|
"""Re-reading regenerates only auto (NFO) tags; user-added tags survive."""
|
||||||
from unittest.mock import patch
|
from src.core.nfo import NfoData
|
||||||
from src.core.csfd import CSFDMovie
|
|
||||||
|
|
||||||
movie_file.add_tag("Sbírka/Oblíbené") # user tag
|
movie_file.add_tag("Sbírka/Oblíbené") # user tag
|
||||||
first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"])
|
movie_file.apply_nfo(NfoData(year="1999", genres=["Akční"]))
|
||||||
with patch("src.core.csfd.fetch_movie", return_value=first):
|
movie_file.apply_nfo(NfoData(year="2009", genres=["Drama"]))
|
||||||
movie_file.apply_csfd_tags()
|
|
||||||
second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"])
|
|
||||||
with patch("src.core.csfd.fetch_movie", return_value=second):
|
|
||||||
movie_file.apply_csfd_tags()
|
|
||||||
|
|
||||||
paths = {t.full_path for t in movie_file.tags}
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
assert "Sbírka/Oblíbené" in paths # user tag kept
|
assert "Sbírka/Oblíbené" in paths # user tag kept
|
||||||
assert "Žánr/Drama" in paths # new ČSFD tag
|
assert "Žánr/Drama" in paths # new NFO tag
|
||||||
assert "Rok/2009" in paths
|
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 "Žánr/Akční" not in paths # stale ČSFD tag dropped
|
||||||
assert "Rok/1999" not in paths
|
assert "Rok/1999" not in paths
|
||||||
|
|||||||
+230
-11
@@ -203,22 +203,21 @@ class TestPoolManagement:
|
|||||||
source = tmp_path / "raw.mkv"
|
source = tmp_path / "raw.mkv"
|
||||||
source.write_bytes(b"x" * 10)
|
source.write_bytes(b"x" * 10)
|
||||||
|
|
||||||
movie = file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1")
|
movie = file_manager.import_movie(source, "Matrix")
|
||||||
|
|
||||||
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv"
|
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv"
|
||||||
assert source.exists() # non-destructive copy
|
assert source.exists() # non-destructive copy
|
||||||
assert movie.title == "Matrix"
|
assert movie.title == "Matrix"
|
||||||
assert movie.csfd_link == "https://csfd.cz/film/1"
|
|
||||||
assert file_manager.index.get(movie.file_path) is not None
|
assert file_manager.index.get(movie.file_path) is not None
|
||||||
|
|
||||||
def test_statistics_aggregates_pool(self, file_manager, tmp_path):
|
def test_statistics_aggregates_pool(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
(tmp_path / "a.mkv").write_bytes(b"x" * 100)
|
(tmp_path / "a.mkv").write_bytes(b"x" * 100)
|
||||||
(tmp_path / "b.mkv").write_bytes(b"y" * 50)
|
(tmp_path / "b.mkv").write_bytes(b"y" * 50)
|
||||||
a = file_manager.import_movie(tmp_path / "a.mkv", "A", "https://csfd/1")
|
a = file_manager.import_movie(tmp_path / "a.mkv", "A")
|
||||||
a.add_tag("Žánr/Akční")
|
a.add_tag("Žánr/Akční")
|
||||||
a.add_tag("Žánr/Sci-Fi")
|
a.add_tag("Žánr/Sci-Fi")
|
||||||
a.csfd_cache = {"rating": 90}
|
a.meta = {"rating": 90}
|
||||||
a.save_metadata()
|
a.save_metadata()
|
||||||
b = file_manager.import_movie(tmp_path / "b.mkv", "B")
|
b = file_manager.import_movie(tmp_path / "b.mkv", "B")
|
||||||
b.add_tag("Žánr/Akční")
|
b.add_tag("Žánr/Akční")
|
||||||
@@ -226,7 +225,7 @@ class TestPoolManagement:
|
|||||||
s = file_manager.statistics()
|
s = file_manager.statistics()
|
||||||
assert s["count"] == 2
|
assert s["count"] == 2
|
||||||
assert s["total_size"] == 150
|
assert s["total_size"] == 150
|
||||||
assert s["with_csfd"] == 1 and s["without_csfd"] == 1
|
assert s["with_nfo"] == 1 and s["without_nfo"] == 1
|
||||||
assert s["untagged"] == 0
|
assert s["untagged"] == 0
|
||||||
assert s["avg_rating"] == 90
|
assert s["avg_rating"] == 90
|
||||||
# Akční on both movies → count 2, sorted first
|
# Akční on both movies → count 2, sorted first
|
||||||
@@ -319,7 +318,7 @@ class TestPoolManagement:
|
|||||||
old_path = first.file_path
|
old_path = first.file_path
|
||||||
|
|
||||||
second = file_manager.import_movie(
|
second = file_manager.import_movie(
|
||||||
tmp_path / "b.mkv", "Matrix", csfd_link="x", on_conflict="replace")
|
tmp_path / "b.mkv", "Matrix", on_conflict="replace")
|
||||||
|
|
||||||
assert second.file_path.name == "Matrix.mkv"
|
assert second.file_path.name == "Matrix.mkv"
|
||||||
assert second.file_path == old_path # same name reused
|
assert second.file_path == old_path # same name reused
|
||||||
@@ -328,7 +327,6 @@ class TestPoolManagement:
|
|||||||
# index record reflects the new import (fresh metadata, old tags dropped)
|
# index record reflects the new import (fresh metadata, old tags dropped)
|
||||||
record = file_manager.index.get(second.file_path)
|
record = file_manager.index.get(second.file_path)
|
||||||
assert record is not None
|
assert record is not None
|
||||||
assert record["csfd_link"] == "x"
|
|
||||||
assert record["tags"] == []
|
assert record["tags"] == []
|
||||||
assert second.tags == []
|
assert second.tags == []
|
||||||
|
|
||||||
@@ -419,6 +417,223 @@ class TestPoolManagement:
|
|||||||
assert movie.file_path.name == "Matrix (1999).mkv"
|
assert movie.file_path.name == "Matrix (1999).mkv"
|
||||||
assert movie.title == "Matrix" # clean title, no year
|
assert movie.title == "Matrix" # clean title, no year
|
||||||
|
|
||||||
|
def test_import_movie_with_tmdb_id_names_for_jellyfin(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
|
||||||
|
movie = file_manager.import_movie(
|
||||||
|
tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603)
|
||||||
|
|
||||||
|
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
||||||
|
assert movie.title == "Matrix" # clean title, no year/id
|
||||||
|
|
||||||
|
def test_libraries_default_and_derivation(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
assert file_manager.libraries == [
|
||||||
|
{"name": "Filmy", "kind": "movie"},
|
||||||
|
{"name": "Seriály", "kind": "series"},
|
||||||
|
]
|
||||||
|
assert file_manager.movie_libraries == ["Filmy"]
|
||||||
|
assert file_manager.copyasis_folders == ["Seriály"]
|
||||||
|
|
||||||
|
def test_set_libraries_with_all_kinds(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Hlavní", "kind": "mixed"},
|
||||||
|
{"name": "Dokumenty", "kind": "mixed"},
|
||||||
|
{"name": "Anime", "kind": "series"},
|
||||||
|
{"name": "Chajda", "kind": "homevideo"},
|
||||||
|
])
|
||||||
|
assert file_manager.copyasis_folders == ["Anime"]
|
||||||
|
assert file_manager.movie_libraries == ["Filmy"] # none of kind movie → fallback
|
||||||
|
assert file_manager.libraries_of_kind("mixed") == ["Hlavní", "Dokumenty"]
|
||||||
|
assert file_manager.libraries_of_kind("homevideo") == ["Chajda"]
|
||||||
|
|
||||||
|
def test_set_pool_dir_creates_all_library_folders(self, file_manager, tmp_path):
|
||||||
|
pool = tmp_path / "pool"
|
||||||
|
file_manager.set_pool_dir(pool)
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Hlavní", "kind": "mixed"},
|
||||||
|
{"name": "Chajda", "kind": "homevideo"},
|
||||||
|
])
|
||||||
|
file_manager.set_pool_dir(pool) # re-run creates the newly defined folders
|
||||||
|
assert (pool / "Hlavní").is_dir()
|
||||||
|
assert (pool / "Chajda").is_dir()
|
||||||
|
|
||||||
|
def test_mixed_library_loads_movies_not_episodes(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||||
|
hlavni = tmp_path / "pool" / "Hlavní"
|
||||||
|
hlavni.mkdir(parents=True, exist_ok=True)
|
||||||
|
(hlavni / "Matrix (1999) [tmdbid-603].mkv").write_bytes(b"x") # movie (top level)
|
||||||
|
ep = hlavni / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
||||||
|
ep.mkdir(parents=True)
|
||||||
|
(ep / "S01E01 - pilot.mkv").write_bytes(b"y") # series episode (subfolder)
|
||||||
|
|
||||||
|
file_manager.load_pool_movies()
|
||||||
|
names = [f.filename for f in file_manager.filelist]
|
||||||
|
assert names == ["Matrix (1999) [tmdbid-603].mkv"] # episode not loaded as movie
|
||||||
|
assert file_manager.library_of(file_manager.filelist[0]) == "Hlavní"
|
||||||
|
|
||||||
|
def test_libraries_accepting_movies(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Filmy", "kind": "movie"},
|
||||||
|
{"name": "Hlavní", "kind": "mixed"},
|
||||||
|
{"name": "Anime", "kind": "series"},
|
||||||
|
])
|
||||||
|
assert file_manager.libraries_accepting_movies == ["Filmy", "Hlavní"]
|
||||||
|
|
||||||
|
def test_orphan_folder_cleanup(self, file_manager, tmp_path):
|
||||||
|
pool = tmp_path / "pool"
|
||||||
|
file_manager.set_pool_dir(pool)
|
||||||
|
file_manager.set_libraries([{"name": "Filmy", "kind": "movie"}])
|
||||||
|
(pool / "OldKids").mkdir() # empty orphan
|
||||||
|
(pool / "OldStuff").mkdir()
|
||||||
|
(pool / "OldStuff" / "film.mkv").write_bytes(b"x") # non-empty orphan
|
||||||
|
|
||||||
|
orphans = dict(file_manager.orphan_pool_folders())
|
||||||
|
assert orphans.get("OldKids") is True
|
||||||
|
assert orphans.get("OldStuff") is False
|
||||||
|
assert "Filmy" not in orphans # a configured library is not an orphan
|
||||||
|
|
||||||
|
assert file_manager.remove_empty_pool_folder("OldKids") is True
|
||||||
|
assert not (pool / "OldKids").exists()
|
||||||
|
assert file_manager.remove_empty_pool_folder("OldStuff") is False # non-empty
|
||||||
|
assert file_manager.remove_empty_pool_folder("Filmy") is False # a library
|
||||||
|
|
||||||
|
def test_move_movie_into_mixed_library(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Filmy", "kind": "movie"},
|
||||||
|
{"name": "Hlavní", "kind": "mixed"},
|
||||||
|
])
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
|
||||||
|
file_manager.move_to_library(movie, "Hlavní")
|
||||||
|
assert file_manager.library_of(movie) == "Hlavní"
|
||||||
|
|
||||||
|
def test_import_into_named_library(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
|
||||||
|
movie = file_manager.import_movie(
|
||||||
|
tmp_path / "a.mkv", "Auta", year=2006, tmdb_id=920, library="Kids")
|
||||||
|
|
||||||
|
assert movie.file_path == tmp_path / "pool" / "Kids" / "Auta (2006) [tmdbid-920].mkv"
|
||||||
|
assert file_manager.library_of(movie) == "Kids"
|
||||||
|
|
||||||
|
def test_move_to_library_reindexes(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006)
|
||||||
|
old_key = movie.file_path.relative_to(tmp_path / "pool").as_posix()
|
||||||
|
|
||||||
|
file_manager.move_to_library(movie, "Kids")
|
||||||
|
|
||||||
|
assert file_manager.library_of(movie) == "Kids"
|
||||||
|
assert movie.file_path.exists()
|
||||||
|
assert file_manager.index.records.get(old_key) is None # old key dropped
|
||||||
|
new_key = movie.file_path.relative_to(tmp_path / "pool").as_posix()
|
||||||
|
assert file_manager.index.records.get(new_key) is not None
|
||||||
|
|
||||||
|
def test_move_to_unknown_library_rejected(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
file_manager.move_to_library(movie, "Neexistuje")
|
||||||
|
|
||||||
|
def test_load_scans_all_movie_libraries(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
||||||
|
(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)
|
||||||
|
file_manager.import_movie(tmp_path / "b.mkv", "Auta", year=2006, library="Kids")
|
||||||
|
|
||||||
|
reloaded = FileManager(TagManager())
|
||||||
|
reloaded.set_pool_dir(tmp_path / "pool")
|
||||||
|
reloaded.load_pool_movies()
|
||||||
|
|
||||||
|
libs = sorted(reloaded.library_of(f) for f in reloaded.filelist)
|
||||||
|
assert libs == ["Filmy", "Kids"]
|
||||||
|
|
||||||
|
def test_load_skips_jellyfin_sidecars(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
|
||||||
|
# Jellyfin writes these next to the movie — must not count as movies
|
||||||
|
movie.file_path.with_suffix(".nfo").write_text("<movie/>")
|
||||||
|
(movie.file_path.parent / "poster.jpg").write_bytes(b"img")
|
||||||
|
|
||||||
|
reloaded = FileManager(TagManager())
|
||||||
|
reloaded.set_pool_dir(tmp_path / "pool")
|
||||||
|
reloaded.load_pool_movies()
|
||||||
|
|
||||||
|
assert len(reloaded.filelist) == 1
|
||||||
|
assert reloaded.filelist[0].file_path.suffix == ".mkv"
|
||||||
|
|
||||||
|
def test_apply_movie_edits_all_at_once(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Puvodni", year=2000)
|
||||||
|
|
||||||
|
file_manager.apply_movie_edits(
|
||||||
|
movie, title="Auta", year=2006, tmdb_id=920, library="Kids",
|
||||||
|
date="2026-01-02", attributes={"collection_sort": "01"},
|
||||||
|
tags=["Žánr/Animovaný", "Sbírka/Oblíbené"])
|
||||||
|
|
||||||
|
assert movie.file_path == tmp_path / "pool" / "Kids" / "Auta (2006) [tmdbid-920].mkv"
|
||||||
|
assert movie.title == "Auta"
|
||||||
|
assert file_manager.library_of(movie) == "Kids"
|
||||||
|
assert movie.date == "2026-01-02"
|
||||||
|
assert movie.attributes == {"collection_sort": "01"}
|
||||||
|
assert {t.full_path for t in movie.tags} == {"Žánr/Animovaný", "Sbírka/Oblíbené"}
|
||||||
|
|
||||||
|
def test_import_and_edit_store_csfd_url(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(
|
||||||
|
tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603,
|
||||||
|
csfd_url="https://www.csfd.cz/film/9499/")
|
||||||
|
assert movie.csfd_url == "https://www.csfd.cz/film/9499/"
|
||||||
|
|
||||||
|
# persists across reload
|
||||||
|
reloaded = FileManager(TagManager())
|
||||||
|
reloaded.set_pool_dir(tmp_path / "pool")
|
||||||
|
reloaded.load_pool_movies()
|
||||||
|
assert reloaded.filelist[0].csfd_url == "https://www.csfd.cz/film/9499/"
|
||||||
|
|
||||||
|
# editable via apply_movie_edits
|
||||||
|
file_manager.apply_movie_edits(movie, title="Matrix", year=1999,
|
||||||
|
csfd_url="https://www.csfd.cz/film/9499-matrix/")
|
||||||
|
assert movie.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
|
||||||
|
|
||||||
|
def test_apply_movie_edits_rejects_name_clash(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"y")
|
||||||
|
file_manager.import_movie(tmp_path / "a.mkv", "Solaris", year=1972)
|
||||||
|
other = file_manager.import_movie(tmp_path / "b.mkv", "Jiny", year=2002)
|
||||||
|
with pytest.raises(FileExistsError):
|
||||||
|
file_manager.apply_movie_edits(other, title="Solaris", year=1972)
|
||||||
|
|
||||||
|
def test_rename_to_canonical_preserves_tmdb_id(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(
|
||||||
|
tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603)
|
||||||
|
|
||||||
|
movie.title = "Matrix Reloaded" # metadata title changed
|
||||||
|
renamed = file_manager.rename_to_canonical(movie)
|
||||||
|
|
||||||
|
assert renamed is not None
|
||||||
|
assert movie.file_path.name == "Matrix Reloaded (1999) [tmdbid-603].mkv"
|
||||||
|
|
||||||
def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path):
|
def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
(tmp_path / "a.mkv").write_bytes(b"a")
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
@@ -441,7 +656,7 @@ class TestPoolManagement:
|
|||||||
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix") # plain
|
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix") # plain
|
||||||
assert movie.file_path.name == "Matrix.mkv"
|
assert movie.file_path.name == "Matrix.mkv"
|
||||||
|
|
||||||
movie.csfd_cache = {"year": 1999}
|
movie.meta = {"year": 1999}
|
||||||
renamed = file_manager.rename_to_canonical(movie)
|
renamed = file_manager.rename_to_canonical(movie)
|
||||||
|
|
||||||
assert renamed is not None
|
assert renamed is not None
|
||||||
@@ -461,7 +676,7 @@ class TestPoolManagement:
|
|||||||
for n in ("a.mkv", "b.mkv", "c.mkv"):
|
for n in ("a.mkv", "b.mkv", "c.mkv"):
|
||||||
(tmp_path / n).write_bytes(b"x")
|
(tmp_path / n).write_bytes(b"x")
|
||||||
with_year = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
with_year = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
||||||
with_year.csfd_cache = {"year": 1999}
|
with_year.meta = {"year": 1999}
|
||||||
already = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=1972)
|
already = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=1972)
|
||||||
_no_year = file_manager.import_movie(tmp_path / "c.mkv", "Mystery")
|
_no_year = file_manager.import_movie(tmp_path / "c.mkv", "Mystery")
|
||||||
|
|
||||||
@@ -476,7 +691,7 @@ class TestPoolManagement:
|
|||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
source = tmp_path / "raw.mkv"
|
source = tmp_path / "raw.mkv"
|
||||||
source.write_bytes(b"x" * 10)
|
source.write_bytes(b"x" * 10)
|
||||||
file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1")
|
file_manager.import_movie(source, "Matrix")
|
||||||
|
|
||||||
reloaded = FileManager(TagManager())
|
reloaded = FileManager(TagManager())
|
||||||
reloaded.set_pool_dir(tmp_path / "pool")
|
reloaded.set_pool_dir(tmp_path / "pool")
|
||||||
@@ -498,7 +713,11 @@ class TestPoolManagement:
|
|||||||
file_manager.set_copyasis_folders(["Seriály", "Dokumenty"])
|
file_manager.set_copyasis_folders(["Seriály", "Dokumenty"])
|
||||||
|
|
||||||
assert "copyasis_folders" not in file_manager.global_config
|
assert "copyasis_folders" not in file_manager.global_config
|
||||||
assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Dokumenty"]
|
# series libraries now persist in the unified "libraries" setting (in the index)
|
||||||
|
stored = file_manager.index.get_setting("libraries")
|
||||||
|
assert stored is not None
|
||||||
|
assert [lib["name"] for lib in stored if lib["kind"] == "series"] == \
|
||||||
|
["Seriály", "Dokumenty"]
|
||||||
|
|
||||||
# a fresh manager over the same pool reads the stored settings back
|
# a fresh manager over the same pool reads the stored settings back
|
||||||
reloaded = FileManager(TagManager())
|
reloaded = FileManager(TagManager())
|
||||||
|
|||||||
@@ -227,6 +227,21 @@ class TestHardlinkManager:
|
|||||||
assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped
|
assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped
|
||||||
assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
|
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(
|
def test_link_name_keeps_year_on_collision(
|
||||||
self, tmp_path, tag_manager
|
self, tmp_path, tag_manager
|
||||||
):
|
):
|
||||||
@@ -238,13 +253,13 @@ class TestHardlinkManager:
|
|||||||
f1 = File(source / "Solaris (1972).mkv", tag_manager)
|
f1 = File(source / "Solaris (1972).mkv", tag_manager)
|
||||||
f1.tags.clear()
|
f1.tags.clear()
|
||||||
f1.title = "Solaris"
|
f1.title = "Solaris"
|
||||||
f1.csfd_cache = {"year": 1972}
|
f1.meta = {"year": 1972}
|
||||||
f1.add_tag(Tag("žánr", "Sci-Fi"))
|
f1.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
f1.add_tag(Tag("rok", "1972"))
|
f1.add_tag(Tag("rok", "1972"))
|
||||||
f2 = File(source / "Solaris (2002).mkv", tag_manager)
|
f2 = File(source / "Solaris (2002).mkv", tag_manager)
|
||||||
f2.tags.clear()
|
f2.tags.clear()
|
||||||
f2.title = "Solaris"
|
f2.title = "Solaris"
|
||||||
f2.csfd_cache = {"year": 2002}
|
f2.meta = {"year": 2002}
|
||||||
f2.add_tag(Tag("žánr", "Sci-Fi"))
|
f2.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
f2.add_tag(Tag("rok", "2002"))
|
f2.add_tag(Tag("rok", "2002"))
|
||||||
|
|
||||||
@@ -323,7 +338,7 @@ class TestHardlinkManager:
|
|||||||
f = File(temp_source_dir / "file1.txt", tag_manager)
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
f.tags.clear()
|
f.tags.clear()
|
||||||
f.title = "Dr. No"
|
f.title = "Dr. No"
|
||||||
f.csfd_cache = {"year": 1962}
|
f.meta = {"year": 1962}
|
||||||
f.add_tag(Tag("Kolekce", "James Bond"))
|
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||||
f.add_tag(Tag("žánr", "Akční"))
|
f.add_tag(Tag("žánr", "Akční"))
|
||||||
manager = HardlinkManager(temp_output_dir)
|
manager = HardlinkManager(temp_output_dir)
|
||||||
@@ -345,7 +360,7 @@ class TestHardlinkManager:
|
|||||||
f = File(temp_source_dir / "file1.txt", tag_manager)
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
f.tags.clear()
|
f.tags.clear()
|
||||||
f.title = "Dr. No"
|
f.title = "Dr. No"
|
||||||
f.csfd_cache = {"year": 1962}
|
f.meta = {"year": 1962}
|
||||||
f.add_tag(Tag("Kolekce", "James Bond"))
|
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||||
roots = {"Kolekce": "Dle kolekce"}
|
roots = {"Kolekce": "Dle kolekce"}
|
||||||
templates = {"Kolekce": "{year} - {title}{ext}"}
|
templates = {"Kolekce": "{year} - {title}{ext}"}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""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.tag_manager import TagManager
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
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,95 @@
|
|||||||
|
"""Tests for pool → TMDb migration (src.core.migrate + FileManager)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.migrate import classify, normalize
|
||||||
|
from src.core.tmdb import TmdbResult
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_folds_diacritics_and_punct():
|
||||||
|
assert normalize("Šíleně smutná princezna!") == "silene smutna princezna"
|
||||||
|
assert normalize("Amélie z Montmartru") == "amelie z montmartru"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_auto_on_exact_unique():
|
||||||
|
cands = [TmdbResult(603, "Matrix", "1999", "movie")]
|
||||||
|
status, chosen = classify("Matrix", "1999", cands)
|
||||||
|
assert status == "auto" and chosen.tmdb_id == 603
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_auto_disambiguates_by_year():
|
||||||
|
cands = [
|
||||||
|
TmdbResult(1, "Solaris", "1972", "movie"),
|
||||||
|
TmdbResult(2, "Solaris", "2002", "movie"),
|
||||||
|
]
|
||||||
|
# local year 1972 → unique exact-year match → auto picks the 1972 one
|
||||||
|
assert classify("Solaris", "1972", cands) == ("auto", cands[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_ambiguous_when_year_matches_none():
|
||||||
|
cands = [
|
||||||
|
TmdbResult(1, "Solaris", "1972", "movie"),
|
||||||
|
TmdbResult(2, "Solaris", "2002", "movie"),
|
||||||
|
]
|
||||||
|
status, chosen = classify("Solaris", "1990", cands)
|
||||||
|
# year close to neither → no unique exact-year → ambiguous (top hit preselected)
|
||||||
|
assert status == "ambiguous" and chosen.tmdb_id == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_ambiguous_without_year():
|
||||||
|
cands = [TmdbResult(603, "Matrix", "1999", "movie")]
|
||||||
|
status, _ = classify("Matrix", None, cands)
|
||||||
|
assert status == "ambiguous" # no local year → user confirms
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_no_match():
|
||||||
|
assert classify("Nic", "2000", []) == ("no_match", None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileManagerMigrate:
|
||||||
|
@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.tag_manager import TagManager
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
fm = FileManager(TagManager())
|
||||||
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
|
return fm
|
||||||
|
|
||||||
|
def test_match_and_assign(self, file_manager, tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
|
||||||
|
assert "[tmdbid" not in movie.file_path.name # not migrated yet
|
||||||
|
|
||||||
|
import src.core.tmdb as tmdb
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tmdb, "search_movie",
|
||||||
|
lambda q, year=None, limit=5: [TmdbResult(603, "Matrix", "1999", "movie")])
|
||||||
|
|
||||||
|
proposals = file_manager.match_pool_to_tmdb()
|
||||||
|
assert len(proposals) == 1
|
||||||
|
p = proposals[0]
|
||||||
|
assert p.status == "auto" and p.chosen.tmdb_id == 603
|
||||||
|
|
||||||
|
file_manager.assign_tmdb_id(p.file, p.chosen.tmdb_id, p.chosen.year)
|
||||||
|
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
||||||
|
|
||||||
|
def test_match_skips_already_migrated(self, file_manager, tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
|
||||||
|
|
||||||
|
import src.core.tmdb as tmdb
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tmdb, "search_movie",
|
||||||
|
lambda q, year=None, limit=5: called.append(q) or [])
|
||||||
|
proposals = file_manager.match_pool_to_tmdb()
|
||||||
|
assert proposals == [] and called == [] # already has id → not searched
|
||||||
|
|
||||||
|
def test_assign_fills_missing_year_from_tmdb(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") # no year
|
||||||
|
assert movie.file_path.name == "Matrix.mkv"
|
||||||
|
|
||||||
|
file_manager.assign_tmdb_id(movie, 603, "1999") # TMDb supplies the year
|
||||||
|
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
||||||
@@ -6,6 +6,7 @@ from src.core.naming import (
|
|||||||
is_canonical_pool_stem,
|
is_canonical_pool_stem,
|
||||||
is_valid_year,
|
is_valid_year,
|
||||||
parse_pool_stem,
|
parse_pool_stem,
|
||||||
|
parse_tmdb_id,
|
||||||
year_from_filename,
|
year_from_filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,6 +47,33 @@ def test_parse_pool_stem_non_canonical():
|
|||||||
assert parse_pool_stem("Matrix 1999") is None
|
assert parse_pool_stem("Matrix 1999") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_pool_stem_with_tmdb_id():
|
||||||
|
assert canonical_pool_stem("Matrix", 1999, 603) == "Matrix (1999) [tmdbid-603]"
|
||||||
|
# empty/None id → plain stem, no brackets
|
||||||
|
assert canonical_pool_stem("Matrix", 1999, None) == "Matrix (1999)"
|
||||||
|
assert canonical_pool_stem("Matrix", 1999, "") == "Matrix (1999)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_pool_stem_rejects_bad_id():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
canonical_pool_stem("Matrix", 1999, "tt0133093")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_pool_stem_strips_trailing_tmdb_id():
|
||||||
|
# title stays clean; the id is dropped from parse_pool_stem
|
||||||
|
assert parse_pool_stem("Matrix (1999) [tmdbid-603]") == ("Matrix", 1999)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_tmdb_id():
|
||||||
|
assert parse_tmdb_id("Matrix (1999) [tmdbid-603]") == 603
|
||||||
|
assert parse_tmdb_id("Matrix (1999)") is None
|
||||||
|
assert parse_tmdb_id("Matrix") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_canonical_accepts_tmdb_id_form():
|
||||||
|
assert is_canonical_pool_stem("Matrix (1999) [tmdbid-603]")
|
||||||
|
|
||||||
|
|
||||||
def test_is_canonical_pool_stem():
|
def test_is_canonical_pool_stem():
|
||||||
assert is_canonical_pool_stem("Matrix (1999)")
|
assert is_canonical_pool_stem("Matrix (1999)")
|
||||||
assert not is_canonical_pool_stem("Matrix")
|
assert not is_canonical_pool_stem("Matrix")
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Tests for the NFO reader (src.core.nfo)."""
|
||||||
|
from src.core.nfo import NfoData, find_nfo, read_nfo
|
||||||
|
|
||||||
|
MOVIE_NFO = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<movie>
|
||||||
|
<title>Matrix</title>
|
||||||
|
<year>1999</year>
|
||||||
|
<genre>Akční</genre>
|
||||||
|
<genre>Sci-Fi</genre>
|
||||||
|
<country>USA</country>
|
||||||
|
<rating>8.7</rating>
|
||||||
|
<uniqueid type="tmdb">603</uniqueid>
|
||||||
|
</movie>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _movie(tmp_path):
|
||||||
|
v = tmp_path / "Matrix (1999) [tmdbid-603].mkv"
|
||||||
|
v.write_bytes(b"x")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_nfo_sibling(tmp_path):
|
||||||
|
video = _movie(tmp_path)
|
||||||
|
video.with_suffix(".nfo").write_text(MOVIE_NFO, encoding="utf-8")
|
||||||
|
|
||||||
|
nfo = read_nfo(video)
|
||||||
|
assert nfo == NfoData(
|
||||||
|
title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
|
||||||
|
countries=["USA"], rating=87, tmdb_id=603)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rating_scaled_to_percent(tmp_path):
|
||||||
|
video = _movie(tmp_path)
|
||||||
|
video.with_suffix(".nfo").write_text(
|
||||||
|
"<movie><rating>7.3</rating></movie>", encoding="utf-8")
|
||||||
|
assert read_nfo(video).rating == 73
|
||||||
|
|
||||||
|
|
||||||
|
def test_rating_from_ratings_block(tmp_path):
|
||||||
|
video = _movie(tmp_path)
|
||||||
|
video.with_suffix(".nfo").write_text(
|
||||||
|
"<movie><ratings><rating name='tmdb'><value>9.1</value></rating>"
|
||||||
|
"</ratings></movie>", encoding="utf-8")
|
||||||
|
assert read_nfo(video).rating == 91
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdbid_from_tmdbid_tag(tmp_path):
|
||||||
|
video = _movie(tmp_path)
|
||||||
|
video.with_suffix(".nfo").write_text(
|
||||||
|
"<movie><tmdbid>603</tmdbid></movie>", encoding="utf-8")
|
||||||
|
assert read_nfo(video).tmdb_id == 603
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_prefers_sibling_over_movie_nfo(tmp_path):
|
||||||
|
folder = tmp_path / "Matrix (1999)"
|
||||||
|
folder.mkdir()
|
||||||
|
video = folder / "Matrix (1999).mkv"
|
||||||
|
video.write_bytes(b"x")
|
||||||
|
(folder / "movie.nfo").write_text(MOVIE_NFO, encoding="utf-8")
|
||||||
|
# no sibling → movie.nfo is used
|
||||||
|
assert find_nfo(video).name == "movie.nfo"
|
||||||
|
# sibling present → it wins
|
||||||
|
video.with_suffix(".nfo").write_text(MOVIE_NFO, encoding="utf-8")
|
||||||
|
assert find_nfo(video).name == "Matrix (1999).nfo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_nfo_returns_none(tmp_path):
|
||||||
|
assert read_nfo(_movie(tmp_path)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_nfo_returns_none(tmp_path):
|
||||||
|
video = _movie(tmp_path)
|
||||||
|
video.with_suffix(".nfo").write_text("<movie><genre>unclosed", encoding="utf-8")
|
||||||
|
assert read_nfo(video) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_values():
|
||||||
|
nfo = NfoData(year="1999", genres=["A", "B"], countries=["USA"], rating=87)
|
||||||
|
assert nfo.field_values("genres") == ["A", "B"]
|
||||||
|
assert nfo.field_values("countries") == ["USA"]
|
||||||
|
assert nfo.field_values("year") == ["1999"]
|
||||||
|
assert nfo.field_values("rating") == ["87"]
|
||||||
|
assert nfo.field_values("directors") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_tags_from_nfo(tmp_path, monkeypatch):
|
||||||
|
import src.core.config as config_module
|
||||||
|
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
||||||
|
from src.core.tag_manager import TagManager
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
|
||||||
|
fm = FileManager(TagManager())
|
||||||
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
movie = fm.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603)
|
||||||
|
movie.file_path.with_suffix(".nfo").write_text(MOVIE_NFO, encoding="utf-8")
|
||||||
|
|
||||||
|
result = fm.refresh_tags_from_nfo()
|
||||||
|
assert result == {"updated": 1, "without_nfo": 0}
|
||||||
|
paths = {t.full_path for t in movie.tags}
|
||||||
|
assert {"Žánr/Akční", "Žánr/Sci-Fi", "Rok/1999", "Země původu/USA"} <= paths
|
||||||
|
assert movie.meta == {"year": "1999", "rating": 87}
|
||||||
@@ -74,7 +74,7 @@ class TestFileWithIndex:
|
|||||||
tm = TagManager()
|
tm = TagManager()
|
||||||
f = File(movie, tm, index=index)
|
f = File(movie, tm, index=index)
|
||||||
f.title = "Matrix"
|
f.title = "Matrix"
|
||||||
f.csfd_link = "https://csfd.cz/film/1"
|
f.meta = {"year": "1999", "rating": 87}
|
||||||
f.add_tag("Žánr/Akční")
|
f.add_tag("Žánr/Akční")
|
||||||
f.set_date("1999-03-31")
|
f.set_date("1999-03-31")
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ class TestFileWithIndex:
|
|||||||
index2 = PoolIndex(tmp_path)
|
index2 = PoolIndex(tmp_path)
|
||||||
f2 = File(movie, TagManager(), index=index2)
|
f2 = File(movie, TagManager(), index=index2)
|
||||||
assert f2.title == "Matrix"
|
assert f2.title == "Matrix"
|
||||||
assert f2.csfd_link == "https://csfd.cz/film/1"
|
assert f2.meta == {"year": "1999", "rating": 87}
|
||||||
assert f2.date == "1999-03-31"
|
assert f2.date == "1999-03-31"
|
||||||
assert "Žánr/Akční" in {t.full_path for t in f2.tags}
|
assert "Žánr/Akční" in {t.full_path for t in f2.tags}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Tests for series naming/scanning (src.core.series) + FileManager episodes."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.series import (
|
||||||
|
episode_filename,
|
||||||
|
is_canonical_episode,
|
||||||
|
parse_episode,
|
||||||
|
scan_series,
|
||||||
|
season_folder_name,
|
||||||
|
series_folder_stem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,expected", [
|
||||||
|
("Breaking Bad S01E02.mkv", (1, 2)),
|
||||||
|
("bb.s1e2.mkv", (1, 2)),
|
||||||
|
("Show 01x02 x264.mkv", (1, 2)),
|
||||||
|
("Series.S01E02E03.mkv", (1, 2)), # multi-ep → first
|
||||||
|
("Series S12E145.mkv", (12, 145)),
|
||||||
|
("No code here.mkv", None),
|
||||||
|
])
|
||||||
|
def test_parse_episode(name, expected):
|
||||||
|
assert parse_episode(name) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_season_folder_name():
|
||||||
|
assert season_folder_name(0) == "Specials"
|
||||||
|
assert season_folder_name(1) == "Season 01"
|
||||||
|
assert season_folder_name(12) == "Season 12"
|
||||||
|
|
||||||
|
|
||||||
|
def test_episode_filename_keeps_original_name():
|
||||||
|
assert episode_filename(1, 2, "bb.pilot.hdtv", ".mkv") == "S01E02 - bb.pilot.hdtv.mkv"
|
||||||
|
# already-canonical stem → no doubled prefix
|
||||||
|
assert episode_filename(1, 2, "S01E02 - bb.pilot.hdtv", ".mkv") == \
|
||||||
|
"S01E02 - bb.pilot.hdtv.mkv"
|
||||||
|
# stem that was only the code → collapses to just the code
|
||||||
|
assert episode_filename(3, 4, "S03E04", ".mkv") == "S03E04.mkv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_folder_stem():
|
||||||
|
assert series_folder_stem("Breaking Bad", 2008, 1396) == "Breaking Bad (2008) [tmdbid-1396]"
|
||||||
|
assert series_folder_stem("Neznámý", None, 42) == "Neznámý [tmdbid-42]"
|
||||||
|
assert series_folder_stem("Jen název", None, None) == "Jen název"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_canonical_episode(tmp_path):
|
||||||
|
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
good = folder / "S01E02 - bb.pilot.mkv"
|
||||||
|
good.write_bytes(b"x")
|
||||||
|
bad = folder / "bb.s01e02.mkv"
|
||||||
|
bad.write_bytes(b"x")
|
||||||
|
assert is_canonical_episode(good)
|
||||||
|
assert not is_canonical_episode(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_series(tmp_path):
|
||||||
|
lib = tmp_path / "Seriály"
|
||||||
|
s1 = lib / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
||||||
|
s1.mkdir(parents=True)
|
||||||
|
(s1 / "Breaking Bad S01E01.mkv").write_bytes(b"x")
|
||||||
|
(s1 / "Breaking Bad S01E02.mkv").write_bytes(b"x")
|
||||||
|
(s1 / "poster.jpg").write_bytes(b"img") # sidecar → skipped
|
||||||
|
|
||||||
|
found = scan_series(lib, "Seriály")
|
||||||
|
assert len(found) == 1
|
||||||
|
ser = found[0]
|
||||||
|
assert ser.title == "Breaking Bad" and ser.year == "2008" and ser.tmdb_id == 1396
|
||||||
|
assert ser.library == "Seriály"
|
||||||
|
assert len(ser.episodes) == 2
|
||||||
|
seasons = ser.seasons()
|
||||||
|
assert list(seasons.keys()) == [1] and len(seasons[1]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileManagerSeries:
|
||||||
|
@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.tag_manager import TagManager
|
||||||
|
from src.core.file_manager import FileManager
|
||||||
|
fm = FileManager(TagManager())
|
||||||
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
|
return fm
|
||||||
|
|
||||||
|
def test_import_episodes_places_them(self, file_manager, tmp_path):
|
||||||
|
for n in ("bb.S01E01.mkv", "bb.S01E02.mkv", "notes.txt_S00.dummy"):
|
||||||
|
(tmp_path / n).write_bytes(b"x")
|
||||||
|
result = file_manager.import_episodes(
|
||||||
|
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S01E02.mkv"],
|
||||||
|
"Breaking Bad", year=2008, tmdb_id=1396)
|
||||||
|
|
||||||
|
assert result["imported"] == 2
|
||||||
|
base = (tmp_path / "pool" / "Seriály"
|
||||||
|
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
|
||||||
|
assert (base / "S01E01 - bb.mkv").exists()
|
||||||
|
assert (base / "S01E02 - bb.mkv").exists()
|
||||||
|
|
||||||
|
def test_import_episodes_into_mixed_library(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||||
|
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||||
|
result = file_manager.import_episodes(
|
||||||
|
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396,
|
||||||
|
library="Hlavní")
|
||||||
|
|
||||||
|
assert result["imported"] == 1
|
||||||
|
assert (tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]"
|
||||||
|
/ "Season 01" / "S01E01 - bb.mkv").exists()
|
||||||
|
# a mixed library is scanned for series too
|
||||||
|
found = file_manager.load_series()
|
||||||
|
assert [s.title for s in found] == ["Breaking Bad"]
|
||||||
|
|
||||||
|
def test_import_episodes_skips_uncoded(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "random.mkv").write_bytes(b"x")
|
||||||
|
result = file_manager.import_episodes(
|
||||||
|
[tmp_path / "random.mkv"], "Breaking Bad", year=2008)
|
||||||
|
assert result["imported"] == 0
|
||||||
|
assert result["skipped_no_code"] == ["random.mkv"]
|
||||||
|
|
||||||
|
def test_rename_episodes_to_canonical(self, file_manager, tmp_path):
|
||||||
|
# a mis-named episode sitting in the wrong place
|
||||||
|
folder = (tmp_path / "pool" / "Seriály"
|
||||||
|
/ "Breaking Bad (2008) [tmdbid-1396]")
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
|
||||||
|
|
||||||
|
series_list = file_manager.load_series()
|
||||||
|
assert len(series_list) == 1
|
||||||
|
result = file_manager.rename_episodes_to_canonical(series_list[0])
|
||||||
|
|
||||||
|
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - bb.HDTV.mkv")]
|
||||||
|
assert (folder / "Season 01" / "S01E03 - bb.HDTV.mkv").exists()
|
||||||
|
|
||||||
|
def test_apply_series_edits_renames_folder_and_episodes(self, file_manager, tmp_path):
|
||||||
|
folder = tmp_path / "pool" / "Seriály" / "Wrong Name"
|
||||||
|
(folder / "Season 01").mkdir(parents=True)
|
||||||
|
(folder / "Season 01" / "old S01E01.mkv").write_bytes(b"x")
|
||||||
|
|
||||||
|
ser = file_manager.load_series()[0]
|
||||||
|
result = file_manager.apply_series_edits(
|
||||||
|
ser, title="Breaking Bad", year=2008, tmdb_id=1396)
|
||||||
|
|
||||||
|
new_folder = (tmp_path / "pool" / "Seriály"
|
||||||
|
/ "Breaking Bad (2008) [tmdbid-1396]")
|
||||||
|
assert result["folder"] == new_folder
|
||||||
|
assert (new_folder / "Season 01" / "S01E01 - old.mkv").exists()
|
||||||
|
assert not folder.exists()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Tests for the TMDb client (src.core.tmdb). Network is monkeypatched."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core import tmdb
|
||||||
|
from src.core.tmdb import TmdbError, TmdbResult
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _with_key(monkeypatch):
|
||||||
|
monkeypatch.setenv("TMDB_API_KEY", "testkey")
|
||||||
|
|
||||||
|
|
||||||
|
def _resp(status=200, payload=None):
|
||||||
|
return SimpleNamespace(status_code=status, ok=(200 <= status < 300),
|
||||||
|
json=lambda: (payload or {}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_available_reflects_key(monkeypatch):
|
||||||
|
assert tmdb.api_available()
|
||||||
|
monkeypatch.delenv("TMDB_API_KEY", raising=False)
|
||||||
|
assert not tmdb.api_available()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_movie_returns_title_and_year(monkeypatch):
|
||||||
|
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
|
||||||
|
200, {"id": 603, "title": "Matrix", "release_date": "1999-03-31"}))
|
||||||
|
result = tmdb.lookup_movie(603)
|
||||||
|
assert result == TmdbResult(603, "Matrix", "1999", "movie")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_movie_falls_back_to_original_title(monkeypatch):
|
||||||
|
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
|
||||||
|
200, {"id": 1, "original_title": "Solyaris", "release_date": "1972-01-01"}))
|
||||||
|
assert tmdb.lookup_movie(1).title == "Solyaris"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_movie_missing_date_gives_no_year(monkeypatch):
|
||||||
|
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
|
||||||
|
200, {"id": 2, "title": "Bez data", "release_date": ""}))
|
||||||
|
assert tmdb.lookup_movie(2).year is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_tv_uses_name_and_first_air(monkeypatch):
|
||||||
|
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
|
||||||
|
200, {"id": 1396, "name": "Breaking Bad", "first_air_date": "2008-01-20"}))
|
||||||
|
result = tmdb.lookup(1396, kind="tv")
|
||||||
|
assert result == TmdbResult(1396, "Breaking Bad", "2008", "tv")
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_movie_maps_results(monkeypatch):
|
||||||
|
payload = {"results": [
|
||||||
|
{"id": 603, "title": "Matrix", "release_date": "1999-03-31"},
|
||||||
|
{"id": 605, "title": "Matrix Revolutions", "release_date": "2003-11-05"},
|
||||||
|
]}
|
||||||
|
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload))
|
||||||
|
results = tmdb.search_movie("Matrix", year="1999")
|
||||||
|
assert [r.tmdb_id for r in results] == [603, 605]
|
||||||
|
assert results[0].year == "1999"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_movie_empty_query_skips_network(monkeypatch):
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise AssertionError("should not hit network")
|
||||||
|
monkeypatch.setattr(tmdb.requests, "get", boom)
|
||||||
|
assert tmdb.search_movie(" ") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_key_raises(monkeypatch):
|
||||||
|
monkeypatch.delenv("TMDB_API_KEY", raising=False)
|
||||||
|
with pytest.raises(TmdbError):
|
||||||
|
tmdb.lookup_movie(603)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_status_raises(monkeypatch):
|
||||||
|
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(401, {}))
|
||||||
|
with pytest.raises(TmdbError):
|
||||||
|
tmdb.lookup_movie(603)
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_error_raises(monkeypatch):
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise requests.ConnectionError("down")
|
||||||
|
monkeypatch.setattr(tmdb.requests, "get", boom)
|
||||||
|
with pytest.raises(TmdbError):
|
||||||
|
tmdb.lookup_movie(603)
|
||||||
Reference in New Issue
Block a user