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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
# (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
|
||||
|
||||
- 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
|
||||
import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus
|
||||
"Přejmenovat dle ČSFD (rok)" to bring older files up to the convention
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "curator"
|
||||
version = "1.11.0"
|
||||
version = "1.12.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "jan.doubravsky@gmail.com"}
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
"""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.
|
||||
# Each entry:
|
||||
# category — tag category name (e.g. "Žánr")
|
||||
# csfd_field — CSFDMovie attribute feeding it (genres / year / countries /
|
||||
# rating / directors / actors), or None for a user-only category
|
||||
# csfd_field — NFO source field feeding it (genres / year / countries /
|
||||
# rating), or None for a user-only category. (Key name kept for
|
||||
# index compatibility; the data now comes from the NFO.)
|
||||
# transform — named value transform: None (str) or "decade_band" (rating)
|
||||
# filmoteka_root — folder under the output: "" = at the root (e.g. genres),
|
||||
# "Dle X" = grouping folder, None = filterable but no folders
|
||||
|
||||
@@ -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 .tag import Tag
|
||||
|
||||
# Bump this when the csfd_cache schema changes to force re-fetch on next open.
|
||||
# v2: country (str) → countries (list[str]) for co-productions.
|
||||
CSFD_CACHE_VERSION = 2
|
||||
|
||||
|
||||
class File:
|
||||
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
|
||||
@@ -22,17 +18,20 @@ class File:
|
||||
self.date: str | None = None
|
||||
# movie-library fields
|
||||
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);
|
||||
# None for older/unknown → falls back to the file's ctime.
|
||||
self.added: str | None = None
|
||||
# Cached CSFD data — avoids re-fetching on every open
|
||||
self.csfd_cache: dict | None = None
|
||||
# full_paths of tags that came from ČSFD (vs. user-added); only these are
|
||||
# regenerated when the ČSFD link changes — user tags are preserved.
|
||||
self.csfd_tag_paths: set[str] = set()
|
||||
# Cached metadata used for folder grouping (year, rating) — filled from
|
||||
# the movie's NFO (which Jellyfin writes).
|
||||
self.meta: dict | None = None
|
||||
# full_paths of tags derived automatically from the NFO (vs. user-added);
|
||||
# only these are regenerated on refresh — user tags are preserved.
|
||||
self.auto_tag_paths: set[str] = set()
|
||||
# free-form per-movie attributes (e.g. "collection_sort"); usable in
|
||||
# Filmotéka filename templates via name_context.
|
||||
# miniDLNA filename templates via name_context.
|
||||
self.attributes: dict[str, str] = {}
|
||||
self.get_metadata()
|
||||
|
||||
@@ -50,9 +49,9 @@ class File:
|
||||
self.tags = []
|
||||
self.date = None
|
||||
self.title = None
|
||||
self.csfd_link = None
|
||||
self.csfd_cache = None
|
||||
self.csfd_tag_paths = set()
|
||||
self.csfd_url = None
|
||||
self.meta = None
|
||||
self.auto_tag_paths = set()
|
||||
self.attributes = {}
|
||||
self.added = None
|
||||
|
||||
@@ -62,17 +61,17 @@ class File:
|
||||
"ignored": self.ignored,
|
||||
# ukládáme full_path tagů
|
||||
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
|
||||
# which of the tags came from ČSFD (provenance)
|
||||
"csfd_tags": sorted(self.csfd_tag_paths),
|
||||
# which of the tags were derived automatically (from the NFO)
|
||||
"auto_tags": sorted(self.auto_tag_paths),
|
||||
# date může být None
|
||||
"date": self.date,
|
||||
"title": self.title,
|
||||
"csfd_link": self.csfd_link,
|
||||
"csfd_url": self.csfd_url,
|
||||
"attributes": self.attributes,
|
||||
"added": self.added,
|
||||
}
|
||||
if self.csfd_cache is not None:
|
||||
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache}
|
||||
if self.meta is not None:
|
||||
data["meta"] = dict(self.meta)
|
||||
return data
|
||||
|
||||
def _apply_record(self, data: dict) -> None:
|
||||
@@ -81,16 +80,16 @@ class File:
|
||||
self.tags = []
|
||||
self.date = data.get("date", None)
|
||||
self.title = data.get("title", None)
|
||||
self.csfd_link = data.get("csfd_link", None)
|
||||
# Legacy records have no provenance → treated as all-user (empty set)
|
||||
self.csfd_tag_paths = set(data.get("csfd_tags", []))
|
||||
self.csfd_url = data.get("csfd_url", None)
|
||||
# provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored
|
||||
self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", [])))
|
||||
self.attributes = dict(data.get("attributes", {}))
|
||||
self.added = data.get("added", None)
|
||||
raw_cache = data.get("csfd_cache")
|
||||
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION:
|
||||
self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"}
|
||||
else:
|
||||
self.csfd_cache = None
|
||||
raw_meta = data.get("meta")
|
||||
if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
|
||||
old = data.get("csfd_cache") or {}
|
||||
raw_meta = {"year": old.get("year"), "rating": old.get("rating")} if old else None
|
||||
self.meta = dict(raw_meta) if raw_meta else None
|
||||
|
||||
if not self.tagmanager:
|
||||
return
|
||||
@@ -126,13 +125,13 @@ class File:
|
||||
return 0.0
|
||||
|
||||
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
|
||||
metadata (year & rating come from the ČSFD cache, year falling back to a
|
||||
``Rok`` tag). Missing values render as an empty string.
|
||||
metadata (year & rating come from the NFO cache, year falling back to a
|
||||
``Rok`` tag, then the canonical filename). Missing values render empty.
|
||||
"""
|
||||
cache = self.csfd_cache or {}
|
||||
cache = self.meta or {}
|
||||
year = cache.get("year")
|
||||
if year is None:
|
||||
for t in self.tags:
|
||||
@@ -195,78 +194,49 @@ class File:
|
||||
self.date = date_str
|
||||
self.save_metadata()
|
||||
|
||||
def set_csfd_link(self, url: str | None) -> None:
|
||||
"""Nastaví CSFD odkaz nebo None. Invaliduje cache při změně odkazu."""
|
||||
new_url = url if url else None
|
||||
if new_url != self.csfd_link:
|
||||
self.csfd_cache = None # odkaz se změnil — stará cache je neplatná
|
||||
self.csfd_link = new_url
|
||||
self.save_metadata()
|
||||
def apply_nfo(self, nfo, schema: list[dict] | None = None) -> dict:
|
||||
"""Regenerate the auto (NFO-derived) tags from the movie's NFO.
|
||||
|
||||
def get_cached_movie(self):
|
||||
"""Vrátí CSFDMovie z cache, nebo None. Nevyžaduje síť."""
|
||||
if self.csfd_cache is None:
|
||||
return None
|
||||
try:
|
||||
from .csfd import CSFDMovie
|
||||
return CSFDMovie.from_dict(self.csfd_cache)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def apply_csfd_tags(self, schema: list[dict] | None = None) -> dict:
|
||||
"""Načte data z ČSFD a přegeneruje **jen ČSFD tagy** podle tag schématu.
|
||||
|
||||
``schema`` je seznam definic kategorií (viz ``DEFAULT_TAG_SCHEMA``); pro
|
||||
každý záznam s ``csfd_field`` vytvoří tagy z příslušného pole filmu
|
||||
(s volitelným ``transform``). Tagy přidané uživatelem (mimo provenance
|
||||
ČSFD) zůstávají nedotčené — proto změna ČSFD odkazu nepřepíše vlastní tagy.
|
||||
Režie/herci se stále cachují, tag se z nich tvoří jen je-li v schématu.
|
||||
Reads genres/year/countries/rating from ``nfo`` (``nfo.NfoData``) per the
|
||||
tag schema's source fields and (re)creates those tags; user-added tags are
|
||||
kept. Also caches year/rating (for folder grouping) and fills the title
|
||||
when missing. ``nfo`` of ``None`` (no NFO yet) is a no-op.
|
||||
|
||||
Returns:
|
||||
dict s klíči 'success', 'movie'/'error', 'tags_added'
|
||||
dict with 'success' and 'tags_added'.
|
||||
"""
|
||||
if not self.csfd_link:
|
||||
return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []}
|
||||
if nfo is None:
|
||||
return {"success": False, "error": "NFO nenalezeno", "tags_added": []}
|
||||
|
||||
if schema is None:
|
||||
from .config import DEFAULT_TAG_SCHEMA
|
||||
schema = DEFAULT_TAG_SCHEMA
|
||||
|
||||
try:
|
||||
from .csfd import fetch_movie
|
||||
movie = fetch_movie(self.csfd_link)
|
||||
self.csfd_cache = movie.to_dict()
|
||||
except ImportError as e:
|
||||
return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
|
||||
self.meta = {"year": nfo.year, "rating": nfo.rating}
|
||||
|
||||
from .csfd import csfd_field_values
|
||||
|
||||
# Drop previous ČSFD-sourced tags; keep user-added ones.
|
||||
self.tags = [t for t in self.tags if t.full_path not in self.csfd_tag_paths]
|
||||
self.csfd_tag_paths = set()
|
||||
# Drop previous auto (NFO) tags; keep user-added ones.
|
||||
self.tags = [t for t in self.tags if t.full_path not in self.auto_tag_paths]
|
||||
self.auto_tag_paths = set()
|
||||
|
||||
tags_added: list[str] = []
|
||||
for entry in schema:
|
||||
field = entry.get("csfd_field")
|
||||
field = entry.get("csfd_field") # schema key = source field name
|
||||
if not field:
|
||||
continue # user-only 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)
|
||||
if self.tagmanager else Tag(category, value))
|
||||
if tag_obj not in self.tags:
|
||||
self.tags.append(tag_obj)
|
||||
tags_added.append(tag_obj.full_path)
|
||||
self.csfd_tag_paths.add(tag_obj.full_path)
|
||||
self.auto_tag_paths.add(tag_obj.full_path)
|
||||
|
||||
# Use the CSFD title if we don't have one yet
|
||||
if movie.title and not self.title:
|
||||
self.title = movie.title
|
||||
if nfo.title and not self.title:
|
||||
self.title = nfo.title
|
||||
|
||||
self.save_metadata()
|
||||
return {"success": True, "movie": movie, "tags_added": tags_added}
|
||||
return {"success": True, "tags_added": tags_added}
|
||||
|
||||
def add_tag(self, tag):
|
||||
# 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 .utils import list_files
|
||||
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
|
||||
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem
|
||||
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem, parse_tmdb_id
|
||||
from typing import Callable, Iterable
|
||||
from src.core.config import (
|
||||
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
||||
@@ -17,10 +17,24 @@ from src.core.config import (
|
||||
POOL_MOVIES = "Filmy"
|
||||
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).
|
||||
# ``libraries`` is the source of truth; the legacy ``movie_libraries`` /
|
||||
# ``copyasis_folders`` are kept only to synthesise ``libraries`` for old pools.
|
||||
_LIBRARY_SETTINGS_DEFAULTS = {
|
||||
"tag_schema": DEFAULT_TAG_SCHEMA,
|
||||
"copyasis_folders": [POOL_SERIES],
|
||||
"movie_libraries": [POOL_MOVIES],
|
||||
}
|
||||
|
||||
# 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.
|
||||
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:
|
||||
def __init__(self, tagmanager: TagManager):
|
||||
@@ -107,15 +128,110 @@ class FileManager:
|
||||
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
|
||||
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
|
||||
def copyasis_folders(self) -> list[str]:
|
||||
"""Names of pool subfolders mirrored 1:1 (copy-as-is) into the output."""
|
||||
return self._get_setting("copyasis_folders")
|
||||
"""Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries."""
|
||||
return self.libraries_of_kind("series")
|
||||
|
||||
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()]
|
||||
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
|
||||
def tag_schema(self) -> list[dict]:
|
||||
@@ -164,12 +280,13 @@ class FileManager:
|
||||
return Path(value) if value else None
|
||||
|
||||
def set_pool_dir(self, pool_dir: Path) -> None:
|
||||
"""Set the managed pool root and create its top-level folders."""
|
||||
"""Set the managed pool root and create every library's top-level folder."""
|
||||
pool_dir = Path(pool_dir)
|
||||
(pool_dir / POOL_MOVIES).mkdir(parents=True, exist_ok=True)
|
||||
(pool_dir / POOL_SERIES).mkdir(parents=True, exist_ok=True)
|
||||
self.global_config["pool_dir"] = str(pool_dir)
|
||||
save_global_config(self.global_config)
|
||||
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:
|
||||
"""Set the Filmotéka output folder (generated hardlink tree)."""
|
||||
@@ -177,22 +294,40 @@ class FileManager:
|
||||
save_global_config(self.global_config)
|
||||
|
||||
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 = []
|
||||
movies = self.movies_dir
|
||||
pool = self.pool_dir
|
||||
if not (movies and movies.is_dir() and pool):
|
||||
if not pool:
|
||||
return
|
||||
|
||||
self._open_index(pool)
|
||||
for each in list_files(movies):
|
||||
if each.name.endswith(METADATA_SUFFIXES):
|
||||
for lib in self.libraries:
|
||||
if lib["kind"] not in ("movie", "mixed"):
|
||||
continue
|
||||
file_obj = File(each, self.tagmanager, index=self.index)
|
||||
self.filelist.append(file_obj)
|
||||
lib_dir = pool / lib["name"]
|
||||
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:
|
||||
"""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)
|
||||
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
|
||||
"""
|
||||
pool = self.pool_dir
|
||||
movies = self.movies_dir
|
||||
if not (pool and movies and movies.is_dir()):
|
||||
if not (pool and pool.is_dir()):
|
||||
return {"ok": False, "error": "Pool není nastaven.",
|
||||
"missing": [], "untracked": []}
|
||||
|
||||
@@ -213,10 +347,20 @@ class FileManager:
|
||||
index_keys = set(index.records.keys())
|
||||
|
||||
disk_keys = set()
|
||||
for f in list_files(movies):
|
||||
if f.name.endswith(METADATA_SUFFIXES):
|
||||
for lib in self.libraries:
|
||||
if lib["kind"] not in ("movie", "mixed"):
|
||||
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())
|
||||
untracked = sorted(disk_keys - index_keys)
|
||||
@@ -228,6 +372,51 @@ class FileManager:
|
||||
"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(
|
||||
self,
|
||||
files: list[File],
|
||||
@@ -279,7 +468,7 @@ class FileManager:
|
||||
"""
|
||||
files = self.filelist
|
||||
total_size = 0
|
||||
with_csfd = 0
|
||||
with_nfo = 0
|
||||
untagged = 0
|
||||
ratings: list[int] = []
|
||||
categories: dict[str, Counter] = {}
|
||||
@@ -289,21 +478,21 @@ class FileManager:
|
||||
total_size += f.file_path.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
if f.csfd_link:
|
||||
with_csfd += 1
|
||||
if f.meta is not None:
|
||||
with_nfo += 1
|
||||
if not f.tags:
|
||||
untagged += 1
|
||||
for tag in f.tags:
|
||||
categories.setdefault(tag.category, Counter())[tag.name] += 1
|
||||
rating = (f.csfd_cache or {}).get("rating")
|
||||
rating = (f.meta or {}).get("rating")
|
||||
if isinstance(rating, (int, float)):
|
||||
ratings.append(rating)
|
||||
|
||||
return {
|
||||
"count": len(files),
|
||||
"total_size": total_size,
|
||||
"with_csfd": with_csfd,
|
||||
"without_csfd": len(files) - with_csfd,
|
||||
"with_nfo": with_nfo,
|
||||
"without_nfo": len(files) - with_nfo,
|
||||
"untagged": untagged,
|
||||
"avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None,
|
||||
"categories": {
|
||||
@@ -325,16 +514,19 @@ class FileManager:
|
||||
self.filelist.remove(file_obj)
|
||||
|
||||
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",
|
||||
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:
|
||||
"""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``
|
||||
when a ``year`` is given (validated to four digits); without a year it
|
||||
Lands in ``pool/<library>/`` (``library`` defaults to the first movie
|
||||
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
|
||||
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``
|
||||
the source file is moved into the pool instead, leaving nothing behind.
|
||||
@@ -350,10 +542,11 @@ class FileManager:
|
||||
Raises:
|
||||
ValueError: a ``year`` was given but is not a four-digit number.
|
||||
"""
|
||||
movies = self.movies_dir
|
||||
pool = self.pool_dir
|
||||
if movies is None or pool is None:
|
||||
if pool is None:
|
||||
raise RuntimeError("Pool není nastaven.")
|
||||
lib = (library or "").strip() or self.movie_libraries[0]
|
||||
movies = pool / lib
|
||||
movies.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self.index is None:
|
||||
@@ -362,7 +555,8 @@ class FileManager:
|
||||
source = Path(source)
|
||||
safe_title = title.strip() or source.stem
|
||||
has_year = year is not None and str(year).strip() != ""
|
||||
stem = canonical_pool_stem(safe_title, year) if has_year else safe_title
|
||||
stem = (canonical_pool_stem(safe_title, year, tmdb_id) if has_year
|
||||
else safe_title)
|
||||
target = movies / f"{stem}{source.suffix}"
|
||||
|
||||
existing = self.pooled_with_stem(stem)
|
||||
@@ -389,7 +583,7 @@ class FileManager:
|
||||
|
||||
file_obj = File(target, self.tagmanager, index=self.index)
|
||||
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.save_metadata()
|
||||
|
||||
@@ -433,12 +627,13 @@ class FileManager:
|
||||
return file_obj
|
||||
|
||||
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
|
||||
(``name_context``: ČSFD cache → ``Rok`` tag → the current filename). A
|
||||
``(YYYY)`` that leaked into the title is stripped so it isn't doubled.
|
||||
Returns ``None`` when no four-digit year is known.
|
||||
(``name_context``: cache → ``Rok`` tag → the current filename). A
|
||||
``(YYYY)`` that leaked into the title is stripped so it isn't doubled, and
|
||||
an existing ``[tmdbid-N]`` on the current filename is preserved. Returns
|
||||
``None`` when no four-digit year is known.
|
||||
"""
|
||||
ctx = file_obj.name_context()
|
||||
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
|
||||
@@ -449,16 +644,19 @@ class FileManager:
|
||||
title = parsed[0]
|
||||
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()
|
||||
if not (title and is_valid_year(year)):
|
||||
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:
|
||||
"""Rename a pooled movie to ``Title (YYYY).ext`` from its metadata.
|
||||
|
||||
The year comes from the movie's ČSFD metadata (falling back to a ``Rok``
|
||||
tag or the existing filename). ``file_obj.title`` keeps the clean title
|
||||
The year comes from the movie's metadata (NFO cache, falling back to a
|
||||
``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title
|
||||
(without the year). Does not fire ``on_files_changed`` — the caller
|
||||
refreshes once after a batch.
|
||||
|
||||
@@ -537,6 +735,326 @@ class FileManager:
|
||||
"conflicts": conflicts, "unchanged": unchanged,
|
||||
"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):
|
||||
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
||||
for f in files_objs:
|
||||
|
||||
@@ -175,7 +175,7 @@ class HardlinkManager:
|
||||
band) only happens here, when naming the folder.
|
||||
"""
|
||||
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 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.
|
||||
|
||||
Every movie in the pool is named ``Title (YYYY).ext`` — a title followed by a
|
||||
four-digit year in parentheses. The year lets two films of the same name live
|
||||
side by side and makes the filename self-describing (the year can be recovered
|
||||
without any metadata). These helpers build, validate and parse that stem (the
|
||||
filename without its extension).
|
||||
Every movie in the pool is named ``Title (YYYY) [tmdbid-N].ext`` — a title, a
|
||||
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
|
||||
is exactly the form Jellyfin matches on. The ``[tmdbid-N]`` part is optional: a
|
||||
plain ``Title (YYYY)`` is still valid (Jellyfin then matches by name). The year
|
||||
lets two same-named films coexist and makes the filename self-describing. These
|
||||
helpers build, validate and parse the stem (the filename without its extension).
|
||||
"""
|
||||
import re
|
||||
|
||||
# A trailing four-digit year in parentheses. The title is non-greedy so a title
|
||||
# that itself contains parentheses keeps them: "Já, robot (2004)" but also
|
||||
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]``. The
|
||||
# title is non-greedy so a title that itself contains parentheses keeps them:
|
||||
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019.
|
||||
_POOL_STEM_RE = re.compile(r"^(?P<title>.+?) \((?P<year>\d{4})\)$")
|
||||
_FOUR_DIGITS = re.compile(r"\d{4}")
|
||||
_POOL_STEM_RE = re.compile(
|
||||
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?$")
|
||||
|
||||
|
||||
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()))
|
||||
|
||||
|
||||
def canonical_pool_stem(title: str, year: object) -> str:
|
||||
"""Build the canonical pool stem ``Title (YYYY)``.
|
||||
def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str:
|
||||
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` if given).
|
||||
|
||||
Raises:
|
||||
ValueError: the title is empty 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()
|
||||
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ý.")
|
||||
if not re.fullmatch(r"\d{4}", year_s):
|
||||
raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.")
|
||||
return f"{title} ({year_s})"
|
||||
stem = f"{title} ({year_s})"
|
||||
if tmdb_id is not None and str(tmdb_id).strip() != "":
|
||||
id_s = str(tmdb_id).strip()
|
||||
if not id_s.isdigit():
|
||||
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
|
||||
stem += f" [tmdbid-{id_s}]"
|
||||
return stem
|
||||
|
||||
|
||||
def parse_pool_stem(stem: str) -> tuple[str, int] | None:
|
||||
"""Split a canonical stem into ``(title, year)``; ``None`` if it isn't one."""
|
||||
"""Split a canonical stem into ``(title, year)``; ``None`` if it isn't one.
|
||||
|
||||
A trailing ``[tmdbid-N]`` is tolerated and dropped (use :func:`parse_tmdb_id`
|
||||
to read it), so the returned title is always the clean title.
|
||||
"""
|
||||
match = _POOL_STEM_RE.match((stem or "").strip())
|
||||
if not match:
|
||||
return None
|
||||
return match.group("title").strip(), int(match.group("year"))
|
||||
|
||||
|
||||
def parse_tmdb_id(stem: str) -> int | None:
|
||||
"""Read the ``[tmdbid-N]`` from a canonical stem, or ``None`` if absent."""
|
||||
match = _POOL_STEM_RE.match((stem or "").strip())
|
||||
if not match or not match.group("id"):
|
||||
return None
|
||||
return int(match.group("id"))
|
||||
|
||||
|
||||
def is_canonical_pool_stem(stem: str) -> bool:
|
||||
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
|
||||
return _POOL_STEM_RE.match((stem or "").strip()) is not None
|
||||
|
||||
+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"
|
||||
|
||||
|
||||
class TestApplyCsfdTags:
|
||||
"""Tests for File.apply_csfd_tags tag assignment (CSFD fetch is mocked)."""
|
||||
class TestApplyNfoTags:
|
||||
"""Tests for File.apply_nfo tag assignment (NFO parsed from disk)."""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
@@ -222,22 +222,14 @@ class TestApplyCsfdTags:
|
||||
def movie_file(self, tmp_path, tag_manager, index):
|
||||
path = tmp_path / "Matrix.mkv"
|
||||
path.write_text("x")
|
||||
f = File(path, tag_manager, index=index)
|
||||
f.set_csfd_link("https://www.csfd.cz/film/9499-matrix/")
|
||||
return f
|
||||
return File(path, tag_manager, index=index)
|
||||
|
||||
def test_apply_csfd_tags_assigns_expected_categories(self, movie_file):
|
||||
from unittest.mock import patch
|
||||
from src.core.csfd import CSFDMovie
|
||||
def test_apply_nfo_assigns_expected_categories(self, movie_file):
|
||||
from src.core.nfo import NfoData
|
||||
|
||||
movie = CSFDMovie(
|
||||
title="Matrix", url="u", year=1999, genres=["Akční", "Sci-Fi"],
|
||||
directors=["Lana Wachowski", "Lilly Wachowski"],
|
||||
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()
|
||||
nfo = NfoData(title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
|
||||
countries=["USA"], rating=90)
|
||||
result = movie_file.apply_nfo(nfo)
|
||||
|
||||
assert result["success"]
|
||||
paths = {t.full_path for t in movie_file.tags}
|
||||
@@ -247,28 +239,14 @@ class TestApplyCsfdTags:
|
||||
assert "Země původu/USA" in paths
|
||||
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):
|
||||
"""Režie/herci se jen cachují, netvoří se z nich tagy (bylo by jich moc)."""
|
||||
from unittest.mock import patch
|
||||
from src.core.csfd import CSFDMovie
|
||||
|
||||
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_apply_nfo_none_is_noop(self, movie_file):
|
||||
result = movie_file.apply_nfo(None)
|
||||
assert not result["success"]
|
||||
assert movie_file.tags == []
|
||||
|
||||
def test_name_context_fields(self, movie_file):
|
||||
movie_file.title = "Dr. No"
|
||||
movie_file.csfd_cache = {"year": 1962, "rating": 75}
|
||||
movie_file.meta = {"year": 1962, "rating": 75}
|
||||
ctx = movie_file.name_context()
|
||||
assert ctx["title"] == "Dr. No"
|
||||
assert ctx["year"] == "1962" # coerced to str for uniform template fields
|
||||
@@ -278,7 +256,7 @@ class TestApplyCsfdTags:
|
||||
assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}"
|
||||
|
||||
def test_name_context_year_from_tag_when_no_cache(self, movie_file):
|
||||
movie_file.csfd_cache = None
|
||||
movie_file.meta = None
|
||||
movie_file.add_tag("Rok/1999")
|
||||
assert movie_file.name_context()["year"] == "1999"
|
||||
|
||||
@@ -315,37 +293,30 @@ class TestApplyCsfdTags:
|
||||
assert "{collection_sort} - {title}{ext}".format(**ctx) == \
|
||||
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."""
|
||||
from unittest.mock import patch
|
||||
from src.core.csfd import CSFDMovie
|
||||
from src.core.nfo import NfoData
|
||||
|
||||
schema = [{"category": "Žánr", "csfd_field": "genres",
|
||||
"transform": None, "filmoteka_root": ""}]
|
||||
movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"])
|
||||
with patch("src.core.csfd.fetch_movie", return_value=movie):
|
||||
movie_file.apply_csfd_tags(schema)
|
||||
movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
|
||||
|
||||
paths = {t.full_path for t in movie_file.tags}
|
||||
assert "Žánr/Drama" in paths
|
||||
assert not any(p.startswith("Hodnocení/") for p in paths)
|
||||
|
||||
def test_apply_csfd_tags_preserves_user_tags_on_refetch(self, movie_file):
|
||||
"""Re-fetching regenerates only ČSFD tags; user-added tags survive."""
|
||||
from unittest.mock import patch
|
||||
from src.core.csfd import CSFDMovie
|
||||
def test_apply_nfo_preserves_user_tags_on_refresh(self, movie_file):
|
||||
"""Re-reading regenerates only auto (NFO) tags; user-added tags survive."""
|
||||
from src.core.nfo import NfoData
|
||||
|
||||
movie_file.add_tag("Sbírka/Oblíbené") # user tag
|
||||
first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"])
|
||||
with patch("src.core.csfd.fetch_movie", return_value=first):
|
||||
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()
|
||||
movie_file.apply_nfo(NfoData(year="1999", genres=["Akční"]))
|
||||
movie_file.apply_nfo(NfoData(year="2009", genres=["Drama"]))
|
||||
|
||||
paths = {t.full_path for t in movie_file.tags}
|
||||
assert "Sbírka/Oblíbené" in paths # user tag kept
|
||||
assert "Žánr/Drama" in paths # new ČSFD tag
|
||||
assert "Žánr/Drama" in paths # new NFO tag
|
||||
assert "Rok/2009" in paths
|
||||
assert "Žánr/Akční" not in paths # old NFO tag regenerated away
|
||||
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
|
||||
assert "Rok/1999" not in paths
|
||||
|
||||
+230
-11
@@ -203,22 +203,21 @@ class TestPoolManagement:
|
||||
source = tmp_path / "raw.mkv"
|
||||
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 source.exists() # non-destructive copy
|
||||
assert movie.title == "Matrix"
|
||||
assert movie.csfd_link == "https://csfd.cz/film/1"
|
||||
assert file_manager.index.get(movie.file_path) is not None
|
||||
|
||||
def test_statistics_aggregates_pool(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
(tmp_path / "a.mkv").write_bytes(b"x" * 100)
|
||||
(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/Sci-Fi")
|
||||
a.csfd_cache = {"rating": 90}
|
||||
a.meta = {"rating": 90}
|
||||
a.save_metadata()
|
||||
b = file_manager.import_movie(tmp_path / "b.mkv", "B")
|
||||
b.add_tag("Žánr/Akční")
|
||||
@@ -226,7 +225,7 @@ class TestPoolManagement:
|
||||
s = file_manager.statistics()
|
||||
assert s["count"] == 2
|
||||
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["avg_rating"] == 90
|
||||
# Akční on both movies → count 2, sorted first
|
||||
@@ -319,7 +318,7 @@ class TestPoolManagement:
|
||||
old_path = first.file_path
|
||||
|
||||
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 == old_path # same name reused
|
||||
@@ -328,7 +327,6 @@ class TestPoolManagement:
|
||||
# index record reflects the new import (fresh metadata, old tags dropped)
|
||||
record = file_manager.index.get(second.file_path)
|
||||
assert record is not None
|
||||
assert record["csfd_link"] == "x"
|
||||
assert record["tags"] == []
|
||||
assert second.tags == []
|
||||
|
||||
@@ -419,6 +417,223 @@ class TestPoolManagement:
|
||||
assert movie.file_path.name == "Matrix (1999).mkv"
|
||||
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):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
(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
|
||||
assert movie.file_path.name == "Matrix.mkv"
|
||||
|
||||
movie.csfd_cache = {"year": 1999}
|
||||
movie.meta = {"year": 1999}
|
||||
renamed = file_manager.rename_to_canonical(movie)
|
||||
|
||||
assert renamed is not None
|
||||
@@ -461,7 +676,7 @@ class TestPoolManagement:
|
||||
for n in ("a.mkv", "b.mkv", "c.mkv"):
|
||||
(tmp_path / n).write_bytes(b"x")
|
||||
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)
|
||||
_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")
|
||||
source = tmp_path / "raw.mkv"
|
||||
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.set_pool_dir(tmp_path / "pool")
|
||||
@@ -498,7 +713,11 @@ class TestPoolManagement:
|
||||
file_manager.set_copyasis_folders(["Seriály", "Dokumenty"])
|
||||
|
||||
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
|
||||
reloaded = FileManager(TagManager())
|
||||
|
||||
@@ -227,6 +227,21 @@ class TestHardlinkManager:
|
||||
assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped
|
||||
assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
|
||||
|
||||
def test_link_name_strips_year_and_tmdbid(self, tmp_path, tag_manager):
|
||||
"""A pooled 'Title (YYYY) [tmdbid-N].ext' links as clean 'Title.ext'."""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "Matrix (1999) [tmdbid-603].mkv").write_text("x")
|
||||
f = File(source / "Matrix (1999) [tmdbid-603].mkv", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("žánr", "Sci-Fi"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f], category_roots={"žánr": ""})
|
||||
|
||||
assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped
|
||||
|
||||
def test_link_name_keeps_year_on_collision(
|
||||
self, tmp_path, tag_manager
|
||||
):
|
||||
@@ -238,13 +253,13 @@ class TestHardlinkManager:
|
||||
f1 = File(source / "Solaris (1972).mkv", tag_manager)
|
||||
f1.tags.clear()
|
||||
f1.title = "Solaris"
|
||||
f1.csfd_cache = {"year": 1972}
|
||||
f1.meta = {"year": 1972}
|
||||
f1.add_tag(Tag("žánr", "Sci-Fi"))
|
||||
f1.add_tag(Tag("rok", "1972"))
|
||||
f2 = File(source / "Solaris (2002).mkv", tag_manager)
|
||||
f2.tags.clear()
|
||||
f2.title = "Solaris"
|
||||
f2.csfd_cache = {"year": 2002}
|
||||
f2.meta = {"year": 2002}
|
||||
f2.add_tag(Tag("žánr", "Sci-Fi"))
|
||||
f2.add_tag(Tag("rok", "2002"))
|
||||
|
||||
@@ -323,7 +338,7 @@ class TestHardlinkManager:
|
||||
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.title = "Dr. No"
|
||||
f.csfd_cache = {"year": 1962}
|
||||
f.meta = {"year": 1962}
|
||||
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||
f.add_tag(Tag("žánr", "Akční"))
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
@@ -345,7 +360,7 @@ class TestHardlinkManager:
|
||||
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.title = "Dr. No"
|
||||
f.csfd_cache = {"year": 1962}
|
||||
f.meta = {"year": 1962}
|
||||
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||
roots = {"Kolekce": "Dle kolekce"}
|
||||
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_valid_year,
|
||||
parse_pool_stem,
|
||||
parse_tmdb_id,
|
||||
year_from_filename,
|
||||
)
|
||||
|
||||
@@ -46,6 +47,33 @@ def test_parse_pool_stem_non_canonical():
|
||||
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():
|
||||
assert is_canonical_pool_stem("Matrix (1999)")
|
||||
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()
|
||||
f = File(movie, tm, index=index)
|
||||
f.title = "Matrix"
|
||||
f.csfd_link = "https://csfd.cz/film/1"
|
||||
f.meta = {"year": "1999", "rating": 87}
|
||||
f.add_tag("Žánr/Akční")
|
||||
f.set_date("1999-03-31")
|
||||
|
||||
@@ -82,7 +82,7 @@ class TestFileWithIndex:
|
||||
index2 = PoolIndex(tmp_path)
|
||||
f2 = File(movie, TagManager(), index=index2)
|
||||
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 "Žá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