Compare commits

..
3 Commits
29 changed files with 3710 additions and 1529 deletions
+124
View File
@@ -21,6 +21,130 @@ Each version entry uses these sections (include only those that apply):
## Unreleased ## Unreleased
### Fixed
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the
library filter offered only `movie`-kind libraries (falling back to a default
"Filmy"), so a pool of only mixed/series/homevideo libraries had nowhere to put
a movie. They now use `libraries_accepting_movies` (kind **movie or mixed**),
and `load_pool_movies` scans a mixed library's **top-level** files as movies
(its subfolders are series). Movies can be imported into / moved between mixed
libraries.
- **Cleanup of leftover library folders.** After editing libraries, Curator now
offers to delete **empty** pool folders that are no longer a library, and lists
non-empty ones (never auto-deleted). `FileManager.orphan_pool_folders` /
`remove_empty_pool_folder`.
### Added
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at
import (new column in the movie import dialog) and editable in *Upravit film*.
Kept only as a pointer next to the TMDb id; nothing is fetched from ČSFD.
- **Series import into `mixed` libraries.** `series_libraries` (import target +
`load_series` scan) now covers kind `series` **and** `mixed`, so episodes can be
imported into a mixed library and show up in the series overview/editor.
### Changed
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**.
(Genre tags come from the NFO; manual tag editing isn't needed in the Jellyfin
flow.)
- **Episode filenames now keep the original name:** `SxxEyy - {original}.ext`
(was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The
`SxxEyy` code is stripped from the original before re-applying, so repeated
renames don't double it (`series.episode_body`); `episode_filename` /
`is_canonical_episode` and the series import/rename/edit paths follow.
### Added
- **Library organizer** (*Pool → "Knihovny…"*). One dialog manages the pool's
top-level libraries as ``{name, kind}`` — kinds **movie / series / mixed /
homevideo** — stored in a unified ``libraries`` setting in the pool index
(`FileManager.libraries` / `set_libraries`; the legacy `movie_libraries` /
`copyasis_folders` are now derived from it and synthesised for old pools).
Setting the pool creates a folder for every library. Replaces the separate
"Knihovny (rozdělení)" and "Copy-as-is složky" dialogs. **mixed** and
**homevideo** can be defined now; their scanning/generation lands in the next
phases (for now only movie + series libraries are processed).
- **Pool migration to TMDb** (phase 6; `src/core/migrate.py`, *Nástroje →
"Spárovat pool s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet)
against TMDb by title + year and proposes an id, filling a missing year from
TMDb. Classification is conservative — **jisté** (exact diacritics-insensitive
title + matching year, unique), **nejednoznačné** (pick from candidates),
**bez shody**. A review dialog lets you apply just the confident ones or the
ones you pick; renames to `Title (rok) [tmdbid].ext`. Already-migrated files are
skipped, so it is re-runnable. `FileManager.match_pool_to_tmdb` /
`assign_tmdb_id` drive it.
- **Consolidated edit dialogs.** *Filmy → "Upravit film…"* (double-click a row)
opens one dialog for every Curator-owned parameter of a movie — title, year,
TMDb ID (with "Dohledat z TMDb"), library, date, free-form attributes and tags
— and applies them at once (rename `Title (rok) [tmdbid].ext`, move between
libraries, set metadata) via `FileManager.apply_movie_edits`. In *Seriály →
"Přehled seriálů…"* a series now opens an editor (double-click / "Upravit
seriál…"): edit the series' title/year/TMDb ID/library **and each episode's
season/episode number**, which renames the folder and lays the episodes out as
`Series SxxEyy.ext` (`FileManager.apply_series_edits`). The double-click on a
movie now edits it; "Otevřít" (playback) stays in the context menu.
## 1.12.0 — 2026-08-31
### Added
- **Series support** (Jellyfin TV layout, phase 5; `src/core/series.py`, menu
**Seriály**). Episodes are matched by `SxxEyy` (also `1x02`) and laid out as
`Seriály/Series (rok) [tmdbid]/Season NN/Series SxxEyy.ext` (season 0 →
`Specials`). *"Importovat epizody…"* places episode files (series identified
via TMDb TV lookup, per-file SxxEyy parsed from the filename); *"Přehled
seriálů…"* shows a Series → Season → Episode tree with a naming status and a
per-series **"Přejmenovat epizody"** that renames mis-named episodes into the
right Season folder. `FileManager` gained `load_series`, `import_episodes`,
`rename_episodes_to_canonical`; `tmdb.lookup_tv` / `search_tv` resolve series.
- **NFO-driven genres, ČSFD removed** (phase 4). Curator's metadata source is now
the `.nfo` Jellyfin writes into the media folders (`src/core/nfo.py`, read-only).
*Filmy → "Načíst žánry z NFO"* (`FileManager.refresh_tags_from_nfo`) reads
genre/year/country/rating from each movie's NFO and (re)generates its tags
(user-added tags kept); generating the miniDLNA tree refreshes from NFO first,
so the genre-split tree reflects what Jellyfin knows. The **Filmotéka** menu is
now **miniDLNA** ("Vygenerovat miniDLNA strom").
- **Jellyfin integration** (thin/hybrid, phase 3; `src/core/jellyfin.py`, menu
**Jellyfin**). NFO/artwork on disk stay authoritative; the client only
triggers a scan and reads back what Jellyfin sees. **"Skenovat knihovnu"**
(`POST /Library/Refresh`), **"Ověřit proti poolu…"** (matches pooled movies to
Jellyfin items by TMDb id, then title+year, and reports what is out of sync —
`FileManager.verify_against_jellyfin`), **"Zkontrolovat připojení"**. The
status bar shows the Jellyfin state (nenastaveno / nezkontrolováno / ● verze).
Config via `.env` — `JELLYFIN_URL`, `JELLYFIN_API_KEY`.
- **Movie libraries as physical partitions** (Jellyfin-first, phase 2). The pool
can now hold several top-level movie libraries (`movie_libraries` in the pool
index — default `Filmy`; add Kids/Dokumenty/Anime via *Pool → "Knihovny
(rozdělení)…"*). Import lands in a chosen library, and *Filmy → "Přesunout do
knihovny…"* (context menu too) moves a movie between them. The movie table
gained a **Knihovna** column and a **library filter** (Vše / per library) in
the toolbar. `FileManager` gained `movie_libraries` / `set_movie_libraries`,
`import_movie(library=…)`, `move_to_library`, and `library_of`; `load_pool_movies`
and the consistency check now scan every movie library and skip the sidecars
Jellyfin writes (`.nfo`, artwork, subtitles).
- **TMDb identity source** (`src/core/tmdb.py`) — start of the Jellyfin-first
pivot. Resolves a **TMDb id** (or a title/year search) into a canonical
**title + year**; the rich metadata/artwork are left to Jellyfin. Needs a free
`TMDB_API_KEY` in `.env`; degrades gracefully without it.
- **`[tmdbid-N]` in the pool naming convention** (`naming.py`): the pooled file
is now `Title (YYYY) [tmdbid-N].ext` when the id is known — the exact form
Jellyfin matches on. The id is optional (plain `Title (YYYY)` still valid),
parsed back by `parse_tmdb_id`, preserved across `rename_to_canonical`, and
stripped from the miniDLNA hardlink names (they stay clean `Title.ext`).
`FileManager.import_movie` takes a `tmdb_id`.
### Changed
- **Import reworked around TMDb, ČSFD removed from the import flow.** The import
dialog's "ČSFD odkaz" column is now **TMDb ID**, and **"Dohledat z TMDb"**
fills Title/Year/id (by id, or by searching the filename) — a suggested value
is tinted blue, a name colliding with the pool red. Import no longer fetches
ČSFD tags. (The legacy ČSFD tag actions remain until the tag tree is replaced
by NFO-driven miniDLNA generation.)
### Removed
- **ČSFD entirely.** Deleted `src/core/csfd.py` (scraping, Anubis PoW solver) and
its tests; dropped `File.apply_csfd_tags` / `set_csfd_link` / `get_cached_movie`,
the `csfd_link` field and the ČSFD menu/context actions. `File.csfd_cache` →
`File.meta` and `csfd_tag_paths` → `auto_tag_paths` (both read the old index
keys for back-compat); the folder-name transforms moved to `src/core/transforms.py`.
## 1.11.0 — 2026-07-27 ## 1.11.0 — 2026-07-27
### Added ### Added
+232
View File
@@ -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
View File
@@ -164,12 +164,124 @@ movie table, and one-click Filmotéka generation.
fall back to the file's mtime for the "recently added" ordering (ctime is fall back to the file's mtime for the "recently added" ordering (ctime is
unreliable — creating the Filmotéka hardlinks bumps it). unreliable — creating the Filmotéka hardlinks bumps it).
## Direction — Jellyfin pivot (in progress)
Curator is being refocused from a ČSFD-tagging tool into a **Jellyfin-first
library manager**. Agreed shape:
- **Pool = the Jellyfin library set.** Files are named `Title (rok) [tmdbid-N].ext`
— the form Jellyfin matches on. Libraries are physical top-level pool folders,
each with a **kind** (`libraries` setting; managed in *Pool → Knihovny…*):
- `movie` — single-file movies, TMDb naming (done)
- `series` — series/season/episode tree, mirrored 1:1 (done)
- `mixed` — movies **and** series in one folder (Jellyfin "Mixed" type;
supported-but-discouraged by Jellyfin, but low-risk here thanks to the strict
structure, and fully reversible — see decisions). **Scanning TBD (phase 9).**
- `homevideo` — files with no TMDb (Jellyfin "Home videos" type). **TBD (ph. 8).**
- **Target library layout (the user's plan):** `Hlavní` (mixed, incl. kids),
`Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda`
(homevideo). Kids are **not** a separate folder — see decisions.
- **Naming convention:**
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form.
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {filename}.ext`
(season 0 → `Specials`); the episode keeps its original filename after the
`SxxEyy` code. Episode numbers are recognised from the filename; editable in
the series GUI.
- **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by
`tmdbid` (yearless title search picks the wrong film, hence the earlier
"weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when
TMDb has no cs title); last fallback = the original filename. The filename is
always editable in the GUI.
- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to)
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is
kept **only** as an optional CZ-title source for the filename (not for tags/
matching — it exposes no TMDb/IMDb id, verified).
- **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link**
(CZ-title source/override); filename hand-editable before/after import.
- **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser,
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
`naming.py`), not from ČSFD tags.
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
key + URL in `.env`. NFO on disk stays authoritative.
### Decisions (locked)
- **Kids = Jellyfin parental rating, not a folder** (variant B). Kids content
stays in the normal libraries; a child Jellyfin account gets a max-rating limit
with **"block unrated" ON**. Ratings come from TMDb via Jellyfin. Caveat: Czech
certifications are sparse — set the library metadata country to one with fuller
ratings (US) and fix odd cases in Jellyfin (Identify + lock).
- **Mixed libraries are OK and reversible.** Jellyfin discourages them (unreliable
metadata) but our disciplined structure (series = `Season/SxxEyy`, movie =
single file + `[tmdbid]`) gives the scanner clear signals. Because metadata is
**NFO on disk** + **tmdbid in the name**, switching mixed↔separate later is just
moving folders (Jellyfin re-reads NFO, no re-fetch, no loss).
- **Home videos** (Chajda) use Jellyfin's **"Home videos"** content type — no
online lookup, just serves the files.
- **Always match by tmdbid, not by yearless title.** TMDb cs-CZ titles are good
when the right film is matched (lookup by id, or search with a year); a bare
title search trips on remakes/same-name films (verified: Home Alone → 2021
reboot, Vejška → 2006). Import supplies the id; migration searches with a year.
### Phasing
(1) TMDb identity + tmdbid naming + import rework [done]; (2) movie libraries as
physical partitions + import-into / move-between [done]; (3) Jellyfin API — scan +
verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done];
(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing
pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs +
library organizer (unified `libraries` model with kinds) [done].
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs);
**(9) mixed-library scanning + generation** (movies + series in one folder).
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
[tmdbid].ext`); per-movie folders are a later refinement.
## Tasks ## Tasks
# (no open tasks — see Done) ### Phase 8 — import without TMDb / without year (homevideo, no-TMDb docs)
- Relax the import dialog: allow a row with **no year and no tmdbid** (currently
a 4-digit year is mandatory). Pool name then falls back to `Title.ext`
(movies) / `Series/Season NN/SxxEyy.ext` (series), no `[tmdbid]`.
- Drive this by **library kind**: `homevideo` (and no-TMDb rows in `mixed`)
skip the TMDb requirement; `movie` keeps requiring a year (as today).
- `homevideo` library scanning: load its files as items (flat), no TMDb naming,
no genre tags. Consider a dedicated import path or a per-import "bez TMDb" toggle.
- Tests: import with no year/id lands correctly; homevideo library loads.
### Phase 9 — mixed-library scanning + generation
- Scan a `mixed` library by structure: a top-level **single video file = movie**;
a subfolder containing `Season NN/` (or `SxxEyy` files) = **series**. Reuse
`series.scan_series` for the series part and the flat movie scan for the rest.
- `load_pool_movies` / `load_series` become **kind-aware** (iterate `libraries`,
dispatch by kind); today they read `movie_libraries` / `copyasis_folders`.
- miniDLNA generation + NFO refresh must cover mixed libraries too.
- The library filter / table should surface mixed libraries once loadable.
- Tests: a mixed folder with both a movie and a series loads/generates correctly.
### Later / optional
- **ČSFD CZ-title fetch (deferred).** Live test showed TMDb cs-CZ titles are good
when matched by tmdbid, so ČSFD title-fetch was **not** rebuilt (would need the
Anubis PoW solver removed in phase 4). If TMDb gets a title wrong, edit the
filename in the GUI. The ČSFD **link** is already stored per movie
(`File.csfd_url`, set at import / in the edit dialog) as a pointer for later —
revisit fetching only if TMDb proves insufficient in practice.
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat files.
- Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO.
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
path-aware `library_of` (currently single-segment).
## Done ## Done
- Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie
libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD
removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs
- Library organizer (`libraries` unified model with kinds movie/series/mixed/
homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at - Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus
"Přejmenovat dle ČSFD (rok)" to bring older files up to the convention "Přejmenovat dle ČSFD (rok)" to bring older files up to the convention
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "curator" name = "curator"
version = "1.11.0" version = "1.12.0"
description = "" description = ""
authors = [ authors = [
{name = "jan.doubravsky@gmail.com"} {name = "jan.doubravsky@gmail.com"}
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually.""" """Auto-generated — do not edit manually."""
__version__ = "1.11.0" __version__ = "1.12.0"
+3 -2
View File
@@ -26,8 +26,9 @@ GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
# they are derived from ČSFD, and how they map to the Filmotéka folder tree. # they are derived from ČSFD, and how they map to the Filmotéka folder tree.
# Each entry: # Each entry:
# category — tag category name (e.g. "Žánr") # category — tag category name (e.g. "Žánr")
# csfd_field — CSFDMovie attribute feeding it (genres / year / countries / # csfd_field — NFO source field feeding it (genres / year / countries /
# rating / directors / actors), or None for a user-only category # rating), or None for a user-only category. (Key name kept for
# index compatibility; the data now comes from the NFO.)
# transform — named value transform: None (str) or "decade_band" (rating) # transform — named value transform: None (str) or "decade_band" (rating)
# filmoteka_root — folder under the output: "" = at the root (e.g. genres), # filmoteka_root — folder under the output: "" = at the root (e.g. genres),
# "Dle X" = grouping folder, None = filterable but no folders # "Dle X" = grouping folder, None = filterable but no folders
-682
View File
@@ -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
View File
@@ -1,10 +1,6 @@
from pathlib import Path from pathlib import Path
from .tag import Tag from .tag import Tag
# Bump this when the csfd_cache schema changes to force re-fetch on next open.
# v2: country (str) → countries (list[str]) for co-productions.
CSFD_CACHE_VERSION = 2
class File: class File:
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None: def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
@@ -22,17 +18,20 @@ class File:
self.date: str | None = None self.date: str | None = None
# movie-library fields # movie-library fields
self.title: str | None = None self.title: str | None = None
self.csfd_link: str | None = None # Optional ČSFD film URL — a second database pointer kept alongside the
# TMDb id (which lives in the filename). Stored only, not fetched.
self.csfd_url: str | None = None
# ISO timestamp of when the movie was added to the pool (set on import); # ISO timestamp of when the movie was added to the pool (set on import);
# None for older/unknown → falls back to the file's ctime. # None for older/unknown → falls back to the file's ctime.
self.added: str | None = None self.added: str | None = None
# Cached CSFD data — avoids re-fetching on every open # Cached metadata used for folder grouping (year, rating) — filled from
self.csfd_cache: dict | None = None # the movie's NFO (which Jellyfin writes).
# full_paths of tags that came from ČSFD (vs. user-added); only these are self.meta: dict | None = None
# regenerated when the ČSFD link changes — user tags are preserved. # full_paths of tags derived automatically from the NFO (vs. user-added);
self.csfd_tag_paths: set[str] = set() # only these are regenerated on refresh — user tags are preserved.
self.auto_tag_paths: set[str] = set()
# free-form per-movie attributes (e.g. "collection_sort"); usable in # free-form per-movie attributes (e.g. "collection_sort"); usable in
# Filmotéka filename templates via name_context. # miniDLNA filename templates via name_context.
self.attributes: dict[str, str] = {} self.attributes: dict[str, str] = {}
self.get_metadata() self.get_metadata()
@@ -50,9 +49,9 @@ class File:
self.tags = [] self.tags = []
self.date = None self.date = None
self.title = None self.title = None
self.csfd_link = None self.csfd_url = None
self.csfd_cache = None self.meta = None
self.csfd_tag_paths = set() self.auto_tag_paths = set()
self.attributes = {} self.attributes = {}
self.added = None self.added = None
@@ -62,17 +61,17 @@ class File:
"ignored": self.ignored, "ignored": self.ignored,
# ukládáme full_path tagů # ukládáme full_path tagů
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags], "tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
# which of the tags came from ČSFD (provenance) # which of the tags were derived automatically (from the NFO)
"csfd_tags": sorted(self.csfd_tag_paths), "auto_tags": sorted(self.auto_tag_paths),
# date může být None # date může být None
"date": self.date, "date": self.date,
"title": self.title, "title": self.title,
"csfd_link": self.csfd_link, "csfd_url": self.csfd_url,
"attributes": self.attributes, "attributes": self.attributes,
"added": self.added, "added": self.added,
} }
if self.csfd_cache is not None: if self.meta is not None:
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache} data["meta"] = dict(self.meta)
return data return data
def _apply_record(self, data: dict) -> None: def _apply_record(self, data: dict) -> None:
@@ -81,16 +80,16 @@ class File:
self.tags = [] self.tags = []
self.date = data.get("date", None) self.date = data.get("date", None)
self.title = data.get("title", None) self.title = data.get("title", None)
self.csfd_link = data.get("csfd_link", None) self.csfd_url = data.get("csfd_url", None)
# Legacy records have no provenance → treated as all-user (empty set) # provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored
self.csfd_tag_paths = set(data.get("csfd_tags", [])) self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", [])))
self.attributes = dict(data.get("attributes", {})) self.attributes = dict(data.get("attributes", {}))
self.added = data.get("added", None) self.added = data.get("added", None)
raw_cache = data.get("csfd_cache") raw_meta = data.get("meta")
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION: if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"} old = data.get("csfd_cache") or {}
else: raw_meta = {"year": old.get("year"), "rating": old.get("rating")} if old else None
self.csfd_cache = None self.meta = dict(raw_meta) if raw_meta else None
if not self.tagmanager: if not self.tagmanager:
return return
@@ -126,13 +125,13 @@ class File:
return 0.0 return 0.0
def name_context(self) -> dict: def name_context(self) -> dict:
"""Fields for a Filmotéka filename template (see config tag schema). """Fields for a miniDLNA filename template (see config tag schema).
Provides title / year / rating / ext / stem / filename from the movie's Provides title / year / rating / ext / stem / filename from the movie's
metadata (year & rating come from the ČSFD cache, year falling back to a metadata (year & rating come from the NFO cache, year falling back to a
``Rok`` tag). Missing values render as an empty string. ``Rok`` tag, then the canonical filename). Missing values render empty.
""" """
cache = self.csfd_cache or {} cache = self.meta or {}
year = cache.get("year") year = cache.get("year")
if year is None: if year is None:
for t in self.tags: for t in self.tags:
@@ -195,78 +194,49 @@ class File:
self.date = date_str self.date = date_str
self.save_metadata() self.save_metadata()
def set_csfd_link(self, url: str | None) -> None: def apply_nfo(self, nfo, schema: list[dict] | None = None) -> dict:
"""Nastaví CSFD odkaz nebo None. Invaliduje cache při změně odkazu.""" """Regenerate the auto (NFO-derived) tags from the movie's NFO.
new_url = url if url else None
if new_url != self.csfd_link:
self.csfd_cache = None # odkaz se změnil — stará cache je neplatná
self.csfd_link = new_url
self.save_metadata()
def get_cached_movie(self): Reads genres/year/countries/rating from ``nfo`` (``nfo.NfoData``) per the
"""Vrátí CSFDMovie z cache, nebo None. Nevyžaduje síť.""" tag schema's source fields and (re)creates those tags; user-added tags are
if self.csfd_cache is None: kept. Also caches year/rating (for folder grouping) and fills the title
return None when missing. ``nfo`` of ``None`` (no NFO yet) is a no-op.
try:
from .csfd import CSFDMovie
return CSFDMovie.from_dict(self.csfd_cache)
except Exception:
return None
def apply_csfd_tags(self, schema: list[dict] | None = None) -> dict:
"""Načte data z ČSFD a přegeneruje **jen ČSFD tagy** podle tag schématu.
``schema`` je seznam definic kategorií (viz ``DEFAULT_TAG_SCHEMA``); pro
každý záznam s ``csfd_field`` vytvoří tagy z příslušného pole filmu
(s volitelným ``transform``). Tagy přidané uživatelem (mimo provenance
ČSFD) zůstávají nedotčené — proto změna ČSFD odkazu nepřepíše vlastní tagy.
Režie/herci se stále cachují, tag se z nich tvoří jen je-li v schématu.
Returns: Returns:
dict s klíči 'success', 'movie'/'error', 'tags_added' dict with 'success' and 'tags_added'.
""" """
if not self.csfd_link: if nfo is None:
return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []} return {"success": False, "error": "NFO nenalezeno", "tags_added": []}
if schema is None: if schema is None:
from .config import DEFAULT_TAG_SCHEMA from .config import DEFAULT_TAG_SCHEMA
schema = DEFAULT_TAG_SCHEMA schema = DEFAULT_TAG_SCHEMA
try: self.meta = {"year": nfo.year, "rating": nfo.rating}
from .csfd import fetch_movie
movie = fetch_movie(self.csfd_link)
self.csfd_cache = movie.to_dict()
except ImportError as e:
return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []}
except Exception as e:
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
from .csfd import csfd_field_values # Drop previous auto (NFO) tags; keep user-added ones.
self.tags = [t for t in self.tags if t.full_path not in self.auto_tag_paths]
# Drop previous ČSFD-sourced tags; keep user-added ones. self.auto_tag_paths = set()
self.tags = [t for t in self.tags if t.full_path not in self.csfd_tag_paths]
self.csfd_tag_paths = set()
tags_added: list[str] = [] tags_added: list[str] = []
for entry in schema: for entry in schema:
field = entry.get("csfd_field") field = entry.get("csfd_field") # schema key = source field name
if not field: if not field:
continue # user-only category continue # user-only category
category = entry["category"] category = entry["category"]
for value in csfd_field_values(movie, field): for value in nfo.field_values(field):
tag_obj = (self.tagmanager.add_tag(category, value) tag_obj = (self.tagmanager.add_tag(category, value)
if self.tagmanager else Tag(category, value)) if self.tagmanager else Tag(category, value))
if tag_obj not in self.tags: if tag_obj not in self.tags:
self.tags.append(tag_obj) self.tags.append(tag_obj)
tags_added.append(tag_obj.full_path) tags_added.append(tag_obj.full_path)
self.csfd_tag_paths.add(tag_obj.full_path) self.auto_tag_paths.add(tag_obj.full_path)
# Use the CSFD title if we don't have one yet if nfo.title and not self.title:
if movie.title and not self.title: self.title = nfo.title
self.title = movie.title
self.save_metadata() self.save_metadata()
return {"success": True, "movie": movie, "tags_added": tags_added} return {"success": True, "tags_added": tags_added}
def add_tag(self, tag): def add_tag(self, tag):
# tag může být Tag nebo string # tag může být Tag nebo string
+562 -44
View File
@@ -7,7 +7,7 @@ from .tag_manager import TagManager
from .pool_index import PoolIndex from .pool_index import PoolIndex
from .utils import list_files from .utils import list_files
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem, parse_tmdb_id
from typing import Callable, Iterable from typing import Callable, Iterable
from src.core.config import ( from src.core.config import (
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
@@ -17,10 +17,24 @@ from src.core.config import (
POOL_MOVIES = "Filmy" POOL_MOVIES = "Filmy"
POOL_SERIES = "Seriály" POOL_SERIES = "Seriály"
# A pool library (top-level folder) has one of these kinds:
# movie — single-file movies, TMDb naming (Title (rok) [tmdbid].ext)
# series — series/season/episode tree, mirrored as-is (Jellyfin Shows)
# mixed — both movies and series in one folder (Jellyfin "Mixed"; wiring TBD)
# homevideo — files without TMDb (home videos; Jellyfin "Home videos"; wiring TBD)
LIBRARY_KINDS = ("movie", "series", "mixed", "homevideo")
_DEFAULT_LIBRARIES = [
{"name": POOL_MOVIES, "kind": "movie"},
{"name": POOL_SERIES, "kind": "series"},
]
# Library-level settings that live in the pool index (not the global config). # Library-level settings that live in the pool index (not the global config).
# ``libraries`` is the source of truth; the legacy ``movie_libraries`` /
# ``copyasis_folders`` are kept only to synthesise ``libraries`` for old pools.
_LIBRARY_SETTINGS_DEFAULTS = { _LIBRARY_SETTINGS_DEFAULTS = {
"tag_schema": DEFAULT_TAG_SCHEMA, "tag_schema": DEFAULT_TAG_SCHEMA,
"copyasis_folders": [POOL_SERIES], "copyasis_folders": [POOL_SERIES],
"movie_libraries": [POOL_MOVIES],
} }
# Curator metadata files that must never be treated as content. The legacy # Curator metadata files that must never be treated as content. The legacy
@@ -28,6 +42,13 @@ _LIBRARY_SETTINGS_DEFAULTS = {
# stray ones are still skipped so they never leak into the Filmotéka. # stray ones are still skipped so they never leak into the Filmotéka.
METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index") METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index")
# Sidecars Jellyfin writes next to a movie (NFO + artwork/subtitles). These are
# not movies and must be skipped when scanning a library.
SIDECAR_SUFFIXES = (
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn",
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt",
)
class FileManager: class FileManager:
def __init__(self, tagmanager: TagManager): def __init__(self, tagmanager: TagManager):
@@ -107,15 +128,110 @@ class FileManager:
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.") raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
idx.set_setting(key, value) idx.set_setting(key, value)
@property
def libraries(self) -> list[dict]:
"""All pool libraries as ``[{"name", "kind"}, …]`` (source of truth).
For pools predating the unified model this is synthesised from the legacy
``movie_libraries`` (→ kind ``movie``) + ``copyasis_folders`` (→ ``series``).
"""
idx = self._ensure_index()
raw = idx.get_setting("libraries", None) if idx is not None else None
if raw is None:
raw = (
[{"name": n, "kind": "movie"} for n in self._get_setting("movie_libraries")]
+ [{"name": n, "kind": "series"} for n in self._get_setting("copyasis_folders")]
)
out: list[dict] = []
seen: set[str] = set()
for spec in raw:
name = (spec.get("name") or "").strip()
kind = spec.get("kind") if spec.get("kind") in LIBRARY_KINDS else "movie"
if name and name.lower() not in seen:
out.append({"name": name, "kind": kind})
seen.add(name.lower())
return out or list(_DEFAULT_LIBRARIES)
def set_libraries(self, specs: list[dict]) -> None:
"""Replace the library list (list of ``{"name", "kind"}``) and persist it."""
cleaned: list[dict] = []
seen: set[str] = set()
for spec in specs:
name = (spec.get("name") or "").strip()
kind = spec.get("kind") if spec.get("kind") in LIBRARY_KINDS else "movie"
if name and name.lower() not in seen:
cleaned.append({"name": name, "kind": kind})
seen.add(name.lower())
self._set_setting("libraries", cleaned or list(_DEFAULT_LIBRARIES))
def libraries_of_kind(self, *kinds: str) -> list[str]:
"""Names of libraries matching any of ``kinds``."""
return [lib["name"] for lib in self.libraries if lib["kind"] in kinds]
@property @property
def copyasis_folders(self) -> list[str]: def copyasis_folders(self) -> list[str]:
"""Names of pool subfolders mirrored 1:1 (copy-as-is) into the output.""" """Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries."""
return self._get_setting("copyasis_folders") return self.libraries_of_kind("series")
def set_copyasis_folders(self, names: list[str]) -> None: def set_copyasis_folders(self, names: list[str]) -> None:
"""Set the copy-as-is subfolder list and persist it.""" """Set the series (copy-as-is) libraries, keeping the other kinds."""
cleaned = [n.strip() for n in names if n.strip()] cleaned = [n.strip() for n in names if n.strip()]
self._set_setting("copyasis_folders", cleaned) others = [lib for lib in self.libraries if lib["kind"] != "series"]
self.set_libraries(others + [{"name": n, "kind": "series"} for n in cleaned])
@property
def movie_libraries(self) -> list[str]:
"""Pool folders holding single-file movies (kind ``movie``)."""
return self.libraries_of_kind("movie") or [POOL_MOVIES]
@property
def libraries_accepting_movies(self) -> list[str]:
"""Libraries a movie can live in / be assigned to — kind movie or mixed."""
return self.libraries_of_kind("movie", "mixed") or [POOL_MOVIES]
def set_movie_libraries(self, names: list[str]) -> None:
"""Set the movie libraries, keeping the other kinds; never empty."""
cleaned = [n.strip() for n in names if n.strip()] or [POOL_MOVIES]
others = [lib for lib in self.libraries if lib["kind"] != "movie"]
self.set_libraries([{"name": n, "kind": "movie"} for n in cleaned] + others)
def orphan_pool_folders(self) -> list[tuple[str, bool]]:
"""Top-level pool folders that aren't a configured library.
Returns ``(name, is_empty)`` pairs. Only these are candidates for cleanup;
non-empty ones are reported but never auto-deleted (they may hold media).
"""
pool = self.pool_dir
if not (pool and pool.is_dir()):
return []
known = {lib["name"] for lib in self.libraries}
orphans: list[tuple[str, bool]] = []
for entry in sorted(pool.iterdir()):
if entry.is_dir() and entry.name not in known:
orphans.append((entry.name, not any(entry.iterdir())))
return orphans
def remove_empty_pool_folder(self, name: str) -> bool:
"""Delete an **empty** orphan folder in the pool root. Returns True if removed."""
pool = self.pool_dir
if not pool:
return False
known = {lib["name"] for lib in self.libraries}
target = pool / name
if (target.is_dir() and name not in known and not any(target.iterdir())):
target.rmdir()
return True
return False
def library_of(self, file_obj: File) -> str | None:
"""Which library (top-level pool folder) a pooled movie lives in."""
pool = self.pool_dir
if not pool:
return None
try:
return file_obj.file_path.relative_to(pool).parts[0]
except (ValueError, IndexError):
return None
@property @property
def tag_schema(self) -> list[dict]: def tag_schema(self) -> list[dict]:
@@ -164,12 +280,13 @@ class FileManager:
return Path(value) if value else None return Path(value) if value else None
def set_pool_dir(self, pool_dir: Path) -> None: def set_pool_dir(self, pool_dir: Path) -> None:
"""Set the managed pool root and create its top-level folders.""" """Set the managed pool root and create every library's top-level folder."""
pool_dir = Path(pool_dir) pool_dir = Path(pool_dir)
(pool_dir / POOL_MOVIES).mkdir(parents=True, exist_ok=True)
(pool_dir / POOL_SERIES).mkdir(parents=True, exist_ok=True)
self.global_config["pool_dir"] = str(pool_dir) self.global_config["pool_dir"] = str(pool_dir)
save_global_config(self.global_config) save_global_config(self.global_config)
self._open_index(pool_dir) # settings now readable through the index
for lib in self.libraries:
(pool_dir / lib["name"]).mkdir(parents=True, exist_ok=True)
def set_filmoteka_dir(self, filmoteka_dir: Path) -> None: def set_filmoteka_dir(self, filmoteka_dir: Path) -> None:
"""Set the Filmotéka output folder (generated hardlink tree).""" """Set the Filmotéka output folder (generated hardlink tree)."""
@@ -177,22 +294,40 @@ class FileManager:
save_global_config(self.global_config) save_global_config(self.global_config)
def load_pool_movies(self) -> None: def load_pool_movies(self) -> None:
"""Reload the movie list from pool/Filmy using the unified pool index.""" """Reload the movie list from every movie library, via the pool index.
Scans each configured movie library (``movie_libraries``) recursively —
a movie may sit directly in the library or inside its own per-movie
folder. Curator's own metadata and the sidecars Jellyfin writes (NFO,
artwork, subtitles) are skipped.
"""
self.filelist = [] self.filelist = []
movies = self.movies_dir
pool = self.pool_dir pool = self.pool_dir
if not (movies and movies.is_dir() and pool): if not pool:
return return
self._open_index(pool) self._open_index(pool)
for each in list_files(movies): for lib in self.libraries:
if each.name.endswith(METADATA_SUFFIXES): if lib["kind"] not in ("movie", "mixed"):
continue continue
file_obj = File(each, self.tagmanager, index=self.index) lib_dir = pool / lib["name"]
self.filelist.append(file_obj) if not lib_dir.is_dir():
continue
# A mixed library keeps movies at the top level and series in
# subfolders (Season NN) → only its top-level files are movies.
if lib["kind"] == "mixed":
candidates = [p for p in sorted(lib_dir.iterdir()) if p.is_file()]
else:
candidates = list_files(lib_dir)
for each in candidates:
if each.name.endswith(METADATA_SUFFIXES):
continue
if each.suffix.lower() in SIDECAR_SUFFIXES:
continue
self.filelist.append(File(each, self.tagmanager, index=self.index))
def check_data_consistency(self) -> dict: def check_data_consistency(self) -> dict:
"""Compare the pool index against the actual files in pool/Filmy. """Compare the pool index against the actual files in the movie libraries.
Detects drift such as a movie file deleted directly (metadata left behind) Detects drift such as a movie file deleted directly (metadata left behind)
or a file dropped into the pool without importing (no metadata). or a file dropped into the pool without importing (no metadata).
@@ -204,8 +339,7 @@ class FileManager:
index_count / disk_count — totals for the summary index_count / disk_count — totals for the summary
""" """
pool = self.pool_dir pool = self.pool_dir
movies = self.movies_dir if not (pool and pool.is_dir()):
if not (pool and movies and movies.is_dir()):
return {"ok": False, "error": "Pool není nastaven.", return {"ok": False, "error": "Pool není nastaven.",
"missing": [], "untracked": []} "missing": [], "untracked": []}
@@ -213,10 +347,20 @@ class FileManager:
index_keys = set(index.records.keys()) index_keys = set(index.records.keys())
disk_keys = set() disk_keys = set()
for f in list_files(movies): for lib in self.libraries:
if f.name.endswith(METADATA_SUFFIXES): if lib["kind"] not in ("movie", "mixed"):
continue continue
disk_keys.add(f.relative_to(pool).as_posix()) lib_dir = pool / lib["name"]
if not lib_dir.is_dir():
continue
files = ([p for p in lib_dir.iterdir() if p.is_file()]
if lib["kind"] == "mixed" else list_files(lib_dir))
for f in files:
if f.name.endswith(METADATA_SUFFIXES):
continue
if f.suffix.lower() in SIDECAR_SUFFIXES:
continue
disk_keys.add(f.relative_to(pool).as_posix())
missing = sorted(k for k in index_keys if not (pool / k).is_file()) missing = sorted(k for k in index_keys if not (pool / k).is_file())
untracked = sorted(disk_keys - index_keys) untracked = sorted(disk_keys - index_keys)
@@ -228,6 +372,51 @@ class FileManager:
"disk_count": len(disk_keys), "disk_count": len(disk_keys),
} }
def verify_against_jellyfin(self, jf_movies: list) -> dict:
"""Compare the pool against what Jellyfin currently sees.
Matches each pooled movie to a Jellyfin item by **TMDb id** (from the
filename), falling back to (title, year). Reports what is out of sync.
``jf_movies`` is a list of ``jellyfin.JellyfinMovie``. Returns a dict:
matched — how many pooled movies Jellyfin has
pool_only — pooled filenames Jellyfin does not see (rescan/rematch)
jellyfin_only — Jellyfin titles with no matching pooled movie
pool_total / jellyfin_total — totals for the summary
"""
by_tmdb = {m.tmdb_id: m for m in jf_movies if m.tmdb_id}
by_name = {(m.title.strip().lower(), m.year): m for m in jf_movies if m.title}
matched_ids: set = set()
pool_only: list[str] = []
for f in self.filelist:
tmdb_id = parse_tmdb_id(f.file_path.stem)
ctx = f.name_context()
title = str(f.title or ctx.get("title") or "").strip().lower()
year_str = str(ctx.get("year") or "")
year = int(year_str) if year_str.isdigit() else None
match = None
if tmdb_id is not None and tmdb_id in by_tmdb:
match = by_tmdb[tmdb_id]
elif (title, year) in by_name:
match = by_name[(title, year)]
if match is not None:
matched_ids.add(match.jellyfin_id)
else:
pool_only.append(f.filename)
jellyfin_only = sorted(
m.title for m in jf_movies if m.jellyfin_id not in matched_ids)
return {
"matched": len(self.filelist) - len(pool_only),
"pool_only": sorted(pool_only),
"jellyfin_only": jellyfin_only,
"pool_total": len(self.filelist),
"jellyfin_total": len(jf_movies),
}
def scan_video_integrity( def scan_video_integrity(
self, self,
files: list[File], files: list[File],
@@ -279,7 +468,7 @@ class FileManager:
""" """
files = self.filelist files = self.filelist
total_size = 0 total_size = 0
with_csfd = 0 with_nfo = 0
untagged = 0 untagged = 0
ratings: list[int] = [] ratings: list[int] = []
categories: dict[str, Counter] = {} categories: dict[str, Counter] = {}
@@ -289,21 +478,21 @@ class FileManager:
total_size += f.file_path.stat().st_size total_size += f.file_path.stat().st_size
except OSError: except OSError:
pass pass
if f.csfd_link: if f.meta is not None:
with_csfd += 1 with_nfo += 1
if not f.tags: if not f.tags:
untagged += 1 untagged += 1
for tag in f.tags: for tag in f.tags:
categories.setdefault(tag.category, Counter())[tag.name] += 1 categories.setdefault(tag.category, Counter())[tag.name] += 1
rating = (f.csfd_cache or {}).get("rating") rating = (f.meta or {}).get("rating")
if isinstance(rating, (int, float)): if isinstance(rating, (int, float)):
ratings.append(rating) ratings.append(rating)
return { return {
"count": len(files), "count": len(files),
"total_size": total_size, "total_size": total_size,
"with_csfd": with_csfd, "with_nfo": with_nfo,
"without_csfd": len(files) - with_csfd, "without_nfo": len(files) - with_nfo,
"untagged": untagged, "untagged": untagged,
"avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None, "avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None,
"categories": { "categories": {
@@ -325,16 +514,19 @@ class FileManager:
self.filelist.remove(file_obj) self.filelist.remove(file_obj)
def import_movie( def import_movie(
self, source: Path, title: str, csfd_link: str | None = None, self, source: Path, title: str,
move: bool = False, on_conflict: str = "suffix", move: bool = False, on_conflict: str = "suffix",
year: int | str | None = None, year: int | str | None = None, tmdb_id: int | str | None = None,
library: str | None = None, csfd_url: str | None = None,
) -> File | None: ) -> File | None:
"""Bring a video file into pool/Filmy and index its metadata. """Bring a video file into a movie library and index its metadata.
The pooled filename follows the naming convention ``Title (YYYY).ext`` Lands in ``pool/<library>/`` (``library`` defaults to the first movie
when a ``year`` is given (validated to four digits); without a year it library, "Filmy"). The pooled filename follows the naming convention
``Title (YYYY) [tmdbid-N].ext`` when a ``year`` is given (and a
``tmdb_id`` when known — the form Jellyfin matches on); without a year it
falls back to ``Title.ext``. The GUI import always supplies a year, so a falls back to ``Title.ext``. The GUI import always supplies a year, so a
library built through the app is uniformly ``Title (YYYY).ext``. library built through the app is uniformly ``Title (YYYY) [tmdbid-N].ext``.
By default the original is **copied** (non-destructive). With ``move=True`` By default the original is **copied** (non-destructive). With ``move=True``
the source file is moved into the pool instead, leaving nothing behind. the source file is moved into the pool instead, leaving nothing behind.
@@ -350,10 +542,11 @@ class FileManager:
Raises: Raises:
ValueError: a ``year`` was given but is not a four-digit number. ValueError: a ``year`` was given but is not a four-digit number.
""" """
movies = self.movies_dir
pool = self.pool_dir pool = self.pool_dir
if movies is None or pool is None: if pool is None:
raise RuntimeError("Pool není nastaven.") raise RuntimeError("Pool není nastaven.")
lib = (library or "").strip() or self.movie_libraries[0]
movies = pool / lib
movies.mkdir(parents=True, exist_ok=True) movies.mkdir(parents=True, exist_ok=True)
if self.index is None: if self.index is None:
@@ -362,7 +555,8 @@ class FileManager:
source = Path(source) source = Path(source)
safe_title = title.strip() or source.stem safe_title = title.strip() or source.stem
has_year = year is not None and str(year).strip() != "" has_year = year is not None and str(year).strip() != ""
stem = canonical_pool_stem(safe_title, year) if has_year else safe_title stem = (canonical_pool_stem(safe_title, year, tmdb_id) if has_year
else safe_title)
target = movies / f"{stem}{source.suffix}" target = movies / f"{stem}{source.suffix}"
existing = self.pooled_with_stem(stem) existing = self.pooled_with_stem(stem)
@@ -389,7 +583,7 @@ class FileManager:
file_obj = File(target, self.tagmanager, index=self.index) file_obj = File(target, self.tagmanager, index=self.index)
file_obj.title = safe_title file_obj.title = safe_title
file_obj.csfd_link = csfd_link or None file_obj.csfd_url = csfd_url or None
file_obj.added = datetime.now().isoformat(timespec="seconds") file_obj.added = datetime.now().isoformat(timespec="seconds")
file_obj.save_metadata() file_obj.save_metadata()
@@ -433,12 +627,13 @@ class FileManager:
return file_obj return file_obj
def _canonical_name_for(self, file_obj: File) -> str | None: def _canonical_name_for(self, file_obj: File) -> str | None:
"""The ``Title (YYYY)`` stem this movie should have, or ``None``. """The ``Title (YYYY) [tmdbid-N]`` stem this movie should have, or ``None``.
Uses the clean title and the year from the movie's metadata Uses the clean title and the year from the movie's metadata
(``name_context``: ČSFD cache → ``Rok`` tag → the current filename). A (``name_context``: cache → ``Rok`` tag → the current filename). A
``(YYYY)`` that leaked into the title is stripped so it isn't doubled. ``(YYYY)`` that leaked into the title is stripped so it isn't doubled, and
Returns ``None`` when no four-digit year is known. an existing ``[tmdbid-N]`` on the current filename is preserved. Returns
``None`` when no four-digit year is known.
""" """
ctx = file_obj.name_context() ctx = file_obj.name_context()
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip() title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
@@ -449,16 +644,19 @@ class FileManager:
title = parsed[0] title = parsed[0]
year = year or str(parsed[1]) year = year or str(parsed[1])
# Keep the TMDb id already in the pool filename, if any.
tmdb_id = parse_tmdb_id(file_obj.file_path.stem)
title = title.replace("/", "-").replace("\\", "-").strip() title = title.replace("/", "-").replace("\\", "-").strip()
if not (title and is_valid_year(year)): if not (title and is_valid_year(year)):
return None return None
return canonical_pool_stem(title, year) return canonical_pool_stem(title, year, tmdb_id)
def rename_to_canonical(self, file_obj: File) -> File | None: def rename_to_canonical(self, file_obj: File) -> File | None:
"""Rename a pooled movie to ``Title (YYYY).ext`` from its metadata. """Rename a pooled movie to ``Title (YYYY).ext`` from its metadata.
The year comes from the movie's ČSFD metadata (falling back to a ``Rok`` The year comes from the movie's metadata (NFO cache, falling back to a
tag or the existing filename). ``file_obj.title`` keeps the clean title ``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title
(without the year). Does not fire ``on_files_changed`` — the caller (without the year). Does not fire ``on_files_changed`` — the caller
refreshes once after a batch. refreshes once after a batch.
@@ -537,6 +735,326 @@ class FileManager:
"conflicts": conflicts, "unchanged": unchanged, "conflicts": conflicts, "unchanged": unchanged,
"cancelled": cancelled} "cancelled": cancelled}
def move_to_library(self, file_obj: File, new_library: str) -> File:
"""Move a pooled movie into another library (top-level pool folder).
Moves the file into ``pool/<new_library>/`` and re-indexes it under the
new key. Raises:
ValueError: the target isn't a known movie library.
FileExistsError: a file of that name already sits in the target.
"""
pool = self.pool_dir
if pool is None:
raise RuntimeError("Pool není nastaven.")
new_library = new_library.strip()
if new_library not in self.libraries_accepting_movies:
raise ValueError(f"Neznámá knihovna „{new_library}“.")
old_path = file_obj.file_path
dest_dir = pool / new_library
dest_dir.mkdir(parents=True, exist_ok=True)
new_path = dest_dir / old_path.name
if new_path == old_path:
return file_obj # already there
if new_path.exists():
raise FileExistsError(f"V knihovně „{new_library}“ už „{old_path.name}“ je.")
shutil.move(str(old_path), str(new_path))
file_obj.relocate(new_path) # drops the old index key, rebinds to the new
file_obj.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)
return file_obj
def apply_movie_edits(
self, file_obj: File, *, title: str, year: int | str | None = None,
tmdb_id: int | str | None = None, library: str | None = None,
date: str | None = None, attributes: dict | None = None,
tags: list[str] | None = None, csfd_url: str | None = None,
) -> File:
"""Apply all editable parameters of a movie at once (rename/move + metadata).
Renames the file to ``Title (rok) [tmdbid].ext`` (year permitting), moves
it into ``library``, and sets ``title`` / ``date`` / ``attributes`` /
``tags`` (a full ``["Category/Name", …]`` list; NFO-tag provenance is kept
for the ones that remain). No-ops that don't change the path skip the move.
Raises:
FileExistsError: the target name is already taken.
"""
pool = self.pool_dir
if pool is None:
raise RuntimeError("Pool není nastaven.")
title = (title or "").strip() or file_obj.file_path.stem
year_s = str(year).strip() if year not in (None, "") else ""
stem = (canonical_pool_stem(title, year_s, tmdb_id)
if is_valid_year(year_s) else title)
lib = ((library or "").strip()
or self.library_of(file_obj) or self.movie_libraries[0])
old_path = file_obj.file_path
new_path = pool / lib / f"{stem}{old_path.suffix}"
if new_path != old_path:
if new_path.exists():
raise FileExistsError(f"Soubor „{new_path.name}“ už existuje.")
new_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(old_path), str(new_path))
file_obj.relocate(new_path)
file_obj.title = title
if date is not None:
file_obj.date = date or None
if csfd_url is not None:
file_obj.csfd_url = csfd_url or None
if attributes is not None:
file_obj.attributes = dict(attributes)
if tags is not None:
new_tags: list = []
seen: set[str] = set()
for full_path in tags:
full_path = full_path.strip()
if "/" not in full_path or full_path in seen:
continue
cat, name = full_path.split("/", 1)
new_tags.append(self.tagmanager.add_tag(cat.strip(), name.strip()))
seen.add(full_path)
file_obj.tags = new_tags
file_obj.auto_tag_paths = {p for p in file_obj.auto_tag_paths if p in seen}
file_obj.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)
return file_obj
def match_pool_to_tmdb(
self, files: list[File] | None = None,
on_progress: Callable[[int, int, File], None] | None = None,
should_cancel: Callable[[], bool] | None = None,
) -> list:
"""Propose a TMDb id for every movie that doesn't have one yet.
Searches TMDb by each movie's title + year and classifies the result
(see :func:`migrate.classify`). Movies whose filename already carries a
``[tmdbid-N]`` are skipped. Returns a list of ``migrate.MatchProposal``.
"""
from . import tmdb
from .migrate import MatchProposal, classify
targets = files if files is not None else self.filelist
proposals: list = []
total = len(targets)
for i, f in enumerate(targets, 1):
if should_cancel is not None and should_cancel():
break
if parse_tmdb_id(f.file_path.stem) is not None:
if on_progress is not None:
on_progress(i, total, f)
continue # already migrated
ctx = f.name_context()
title = (f.title or ctx.get("title") or f.file_path.stem).strip()
parsed = parse_pool_stem(title)
if parsed is not None:
title = parsed[0]
year = str(ctx.get("year") or "").strip() or None
try:
candidates = tmdb.search_movie(title, year=year, limit=5)
status, chosen = classify(title, year, candidates)
proposals.append(MatchProposal(
f, f.filename, title, year, status, chosen, candidates))
except tmdb.TmdbError as exc:
proposals.append(MatchProposal(
f, f.filename, title, year, "error", None, [], str(exc)))
if on_progress is not None:
on_progress(i, total, f)
return proposals
def assign_tmdb_id(
self, file_obj: File, tmdb_id: int | str, year: int | str | None = None
) -> File:
"""Attach a TMDb id to a movie and rename it to the canonical convention.
``year`` (typically from the TMDb match) fills a missing local year so the
file becomes ``Title (rok) [tmdbid].ext``. Title/library/tags/date are kept.
"""
ctx = file_obj.name_context()
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
parsed = parse_pool_stem(title)
if parsed is not None:
title = parsed[0]
year_s = str(year).strip() if year not in (None, "") else str(ctx.get("year") or "")
return self.apply_movie_edits(
file_obj, title=title, year=year_s or None, tmdb_id=tmdb_id)
def refresh_tags_from_nfo(self, files: list[File] | None = None) -> dict:
"""(Re)derive each movie's auto tags from its NFO (written by Jellyfin).
Reads the ``.nfo`` next to each movie and regenerates the genre / year /
country / rating tags per the tag schema (`File.apply_nfo`); user-added
tags are preserved. Movies without an NFO yet are left untouched.
Returns ``{updated, without_nfo}``.
"""
from .nfo import read_nfo
targets = files if files is not None else self.filelist
schema = self.tag_schema
updated, without_nfo = 0, 0
for f in targets:
nfo = read_nfo(f.file_path)
if nfo is None:
without_nfo += 1
continue
f.apply_nfo(nfo, schema)
updated += 1
if updated and self.on_files_changed:
self.on_files_changed(self.filelist)
return {"updated": updated, "without_nfo": without_nfo}
# ------------------------------------------------------------------
# Series (Jellyfin TV layout: Series/Season NN/Series SxxEyy.ext)
# ------------------------------------------------------------------
@property
def series_libraries(self) -> list[str]:
"""Pool folders that can hold series — kind ``series`` or ``mixed``."""
return self.libraries_of_kind("series", "mixed")
def load_series(self) -> list:
"""Scan every series library into a list of ``series.Series``."""
from .series import scan_series
pool = self.pool_dir
if not pool:
return []
result: list = []
for library in self.series_libraries:
result.extend(scan_series(pool / library, library))
return result
def import_episodes(
self, sources: list[Path], title: str, year: int | str | None = None,
tmdb_id: int | str | None = None, library: str | None = None,
move: bool = False, on_conflict: str = "suffix",
) -> dict:
"""Import episode files into ``<library>/Series (rok) [tmdbid]/Season NN/``.
Each source's season/episode is parsed from its filename and it is copied
(or moved) in as ``Series SxxEyy.ext``. Files with no recognisable
``SxxEyy`` are skipped and reported.
Returns ``{imported, skipped_no_code, errors}``.
"""
from .series import (
parse_episode, series_folder_stem, season_folder_name, episode_filename)
pool = self.pool_dir
if pool is None:
raise RuntimeError("Pool není nastaven.")
lib = ((library or "").strip()
or (self.series_libraries[0] if self.series_libraries else POOL_SERIES))
stem = series_folder_stem(title, year, tmdb_id)
imported = 0
skipped_no_code: list[str] = []
errors: list[str] = []
for src in sources:
src = Path(src)
code = parse_episode(src.name)
if code is None:
skipped_no_code.append(src.name)
continue
season, ep = code
dest_dir = pool / lib / stem / season_folder_name(season)
dest_dir.mkdir(parents=True, exist_ok=True)
target = dest_dir / episode_filename(season, ep, src.stem, src.suffix)
if target.exists():
if on_conflict == "skip":
continue
if on_conflict == "replace":
target.unlink()
else: # suffix
base = episode_filename(season, ep, src.stem, "")
counter = 1
while target.exists():
target = dest_dir / f"{base}_{counter}{src.suffix}"
counter += 1
try:
if move:
shutil.move(str(src), str(target))
else:
shutil.copy2(src, target)
imported += 1
except OSError as exc:
errors.append(f"{src.name}: {exc}")
return {"imported": imported, "skipped_no_code": skipped_no_code,
"errors": errors}
def rename_episodes_to_canonical(self, series) -> dict:
"""Rename a series' episodes to ``Series SxxEyy.ext`` in the right season.
``series`` is a ``series.Series``. Episodes with no recognisable code are
skipped; a target name already taken is reported as a conflict.
Returns ``{renamed, skipped, conflicts}``.
"""
from .series import season_folder_name, episode_filename
renamed: list[tuple[str, str]] = []
skipped: list[str] = []
conflicts: list[tuple[str, str]] = []
for ep in series.episodes:
if ep.season is None or ep.episode is None:
skipped.append(ep.path.name)
continue
dest_dir = series.folder / season_folder_name(ep.season)
new_path = dest_dir / episode_filename(
ep.season, ep.episode, ep.path.stem, ep.path.suffix)
if new_path == ep.path:
continue
if new_path.exists():
conflicts.append((ep.path.name, f"„{new_path.name}“ už existuje"))
continue
try:
dest_dir.mkdir(parents=True, exist_ok=True)
old_name = ep.path.name
ep.path.rename(new_path)
ep.path = new_path
renamed.append((old_name, new_path.name))
except OSError as exc:
conflicts.append((ep.path.name, str(exc)))
return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts}
def apply_series_edits(
self, series, *, title: str, year: int | str | None = None,
tmdb_id: int | str | None = None, library: str | None = None,
) -> dict:
"""Rename/move a series folder and re-title its episodes to match.
Renames the series folder to ``Title (rok) [tmdbid]`` (moving it into
``library`` if changed), then re-derives the series and renames every
episode to ``NewTitle SxxEyy.ext`` in the right Season folder.
Returns ``{folder, episodes}`` (``episodes`` = rename_episodes result).
Raises:
FileExistsError: the target series folder already exists.
"""
from .series import scan_series, series_folder_stem
pool = self.pool_dir
if pool is None:
raise RuntimeError("Pool není nastaven.")
title = (title or "").strip() or series.title
new_stem = series_folder_stem(title, year or None, tmdb_id or None)
lib = ((library or "").strip() or series.library)
old_folder = series.folder
new_folder = pool / lib / new_stem
if new_folder != old_folder:
if new_folder.exists():
raise FileExistsError(f"Složka „{new_stem}“ už existuje.")
new_folder.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(old_folder), str(new_folder))
target = next((s for s in scan_series(new_folder.parent, lib)
if s.folder == new_folder), None)
result = (self.rename_episodes_to_canonical(target) if target
else {"renamed": [], "skipped": [], "conflicts": []})
return {"folder": new_folder, "episodes": result}
def assign_tag_to_file_objects(self, files_objs: list[File], tag): def assign_tag_to_file_objects(self, files_objs: list[File], tag):
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
for f in files_objs: for f in files_objs:
+1 -1
View File
@@ -175,7 +175,7 @@ class HardlinkManager:
band) only happens here, when naming the folder. band) only happens here, when naming the folder.
""" """
if transforms and tag.category in transforms: if transforms and tag.category in transforms:
from .csfd import apply_transform from .transforms import apply_transform
return apply_transform(tag.name, transforms[tag.category]) return apply_transform(tag.name, transforms[tag.category])
return tag.name return tag.name
+105
View File
@@ -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
+62
View File
@@ -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
View File
@@ -1,18 +1,19 @@
"""Pool file naming convention. """Pool file naming convention.
Every movie in the pool is named ``Title (YYYY).ext`` — a title followed by a Every movie in the pool is named ``Title (YYYY) [tmdbid-N].ext`` — a title, a
four-digit year in parentheses. The year lets two films of the same name live four-digit year in parentheses, and (when known) the TMDb id in brackets, which
side by side and makes the filename self-describing (the year can be recovered is exactly the form Jellyfin matches on. The ``[tmdbid-N]`` part is optional: a
without any metadata). These helpers build, validate and parse that stem (the plain ``Title (YYYY)`` is still valid (Jellyfin then matches by name). The year
filename without its extension). lets two same-named films coexist and makes the filename self-describing. These
helpers build, validate and parse the stem (the filename without its extension).
""" """
import re import re
# A trailing four-digit year in parentheses. The title is non-greedy so a title # A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]``. The
# that itself contains parentheses keeps them: "Já, robot (2004)" but also # title is non-greedy so a title that itself contains parentheses keeps them:
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019. # "Kurz (special) (2019)" → title "Kurz (special)", year 2019.
_POOL_STEM_RE = re.compile(r"^(?P<title>.+?) \((?P<year>\d{4})\)$") _POOL_STEM_RE = re.compile(
_FOUR_DIGITS = re.compile(r"\d{4}") r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?$")
def is_valid_year(year: object) -> bool: def is_valid_year(year: object) -> bool:
@@ -20,11 +21,12 @@ def is_valid_year(year: object) -> bool:
return bool(re.fullmatch(r"\d{4}", str(year).strip())) return bool(re.fullmatch(r"\d{4}", str(year).strip()))
def canonical_pool_stem(title: str, year: object) -> str: def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str:
"""Build the canonical pool stem ``Title (YYYY)``. """Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` if given).
Raises: Raises:
ValueError: the title is empty or the year is not four digits. ValueError: the title is empty, the year is not four digits, or a given
``tmdb_id`` is not a positive integer.
""" """
title = (title or "").strip() title = (title or "").strip()
year_s = str(year).strip() year_s = str(year).strip()
@@ -32,17 +34,35 @@ def canonical_pool_stem(title: str, year: object) -> str:
raise ValueError("Název filmu nesmí být prázdný.") raise ValueError("Název filmu nesmí být prázdný.")
if not re.fullmatch(r"\d{4}", year_s): if not re.fullmatch(r"\d{4}", year_s):
raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.") raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.")
return f"{title} ({year_s})" stem = f"{title} ({year_s})"
if tmdb_id is not None and str(tmdb_id).strip() != "":
id_s = str(tmdb_id).strip()
if not id_s.isdigit():
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
stem += f" [tmdbid-{id_s}]"
return stem
def parse_pool_stem(stem: str) -> tuple[str, int] | None: def parse_pool_stem(stem: str) -> tuple[str, int] | None:
"""Split a canonical stem into ``(title, year)``; ``None`` if it isn't one.""" """Split a canonical stem into ``(title, year)``; ``None`` if it isn't one.
A trailing ``[tmdbid-N]`` is tolerated and dropped (use :func:`parse_tmdb_id`
to read it), so the returned title is always the clean title.
"""
match = _POOL_STEM_RE.match((stem or "").strip()) match = _POOL_STEM_RE.match((stem or "").strip())
if not match: if not match:
return None return None
return match.group("title").strip(), int(match.group("year")) return match.group("title").strip(), int(match.group("year"))
def parse_tmdb_id(stem: str) -> int | None:
"""Read the ``[tmdbid-N]`` from a canonical stem, or ``None`` if absent."""
match = _POOL_STEM_RE.match((stem or "").strip())
if not match or not match.group("id"):
return None
return int(match.group("id"))
def is_canonical_pool_stem(stem: str) -> bool: def is_canonical_pool_stem(stem: str) -> bool:
"""True when ``stem`` matches the ``Title (YYYY)`` convention.""" """True when ``stem`` matches the ``Title (YYYY)`` convention."""
return _POOL_STEM_RE.match((stem or "").strip()) is not None return _POOL_STEM_RE.match((stem or "").strip()) is not None
+101
View File
@@ -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),
)
+157
View File
@@ -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
+137
View File
@@ -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
+41
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-455
View File
@@ -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
View File
@@ -207,8 +207,8 @@ class TestFile:
assert file_obj2.date == "2025-01-01" assert file_obj2.date == "2025-01-01"
class TestApplyCsfdTags: class TestApplyNfoTags:
"""Tests for File.apply_csfd_tags tag assignment (CSFD fetch is mocked).""" """Tests for File.apply_nfo tag assignment (NFO parsed from disk)."""
@pytest.fixture @pytest.fixture
def tag_manager(self): def tag_manager(self):
@@ -222,22 +222,14 @@ class TestApplyCsfdTags:
def movie_file(self, tmp_path, tag_manager, index): def movie_file(self, tmp_path, tag_manager, index):
path = tmp_path / "Matrix.mkv" path = tmp_path / "Matrix.mkv"
path.write_text("x") path.write_text("x")
f = File(path, tag_manager, index=index) return File(path, tag_manager, index=index)
f.set_csfd_link("https://www.csfd.cz/film/9499-matrix/")
return f
def test_apply_csfd_tags_assigns_expected_categories(self, movie_file): def test_apply_nfo_assigns_expected_categories(self, movie_file):
from unittest.mock import patch from src.core.nfo import NfoData
from src.core.csfd import CSFDMovie
movie = CSFDMovie( nfo = NfoData(title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
title="Matrix", url="u", year=1999, genres=["Akční", "Sci-Fi"], countries=["USA"], rating=90)
directors=["Lana Wachowski", "Lilly Wachowski"], result = movie_file.apply_nfo(nfo)
actors=["Keanu Reeves", "Laurence Fishburne"],
rating=90, countries=["USA"],
)
with patch("src.core.csfd.fetch_movie", return_value=movie):
result = movie_file.apply_csfd_tags()
assert result["success"] assert result["success"]
paths = {t.full_path for t in movie_file.tags} paths = {t.full_path for t in movie_file.tags}
@@ -247,28 +239,14 @@ class TestApplyCsfdTags:
assert "Země původu/USA" in paths assert "Země původu/USA" in paths
assert "Hodnocení/90" in paths # exact value; folder grouping happens later assert "Hodnocení/90" in paths # exact value; folder grouping happens later
def test_apply_csfd_tags_does_not_tag_directors_or_actors(self, movie_file): def test_apply_nfo_none_is_noop(self, movie_file):
"""Režie/herci se jen cachují, netvoří se z nich tagy (bylo by jich moc).""" result = movie_file.apply_nfo(None)
from unittest.mock import patch assert not result["success"]
from src.core.csfd import CSFDMovie assert movie_file.tags == []
movie = CSFDMovie(
title="Matrix", url="u", directors=["Lana Wachowski"],
actors=["Keanu Reeves", "Laurence Fishburne"], genres=["Drama"],
)
with patch("src.core.csfd.fetch_movie", return_value=movie):
movie_file.apply_csfd_tags()
paths = {t.full_path for t in movie_file.tags}
assert not any(p.startswith("Režie/") for p in paths)
assert not any(p.startswith("Herec/") for p in paths)
cached = movie_file.get_cached_movie()
assert cached.directors == ["Lana Wachowski"]
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"]
def test_name_context_fields(self, movie_file): def test_name_context_fields(self, movie_file):
movie_file.title = "Dr. No" movie_file.title = "Dr. No"
movie_file.csfd_cache = {"year": 1962, "rating": 75} movie_file.meta = {"year": 1962, "rating": 75}
ctx = movie_file.name_context() ctx = movie_file.name_context()
assert ctx["title"] == "Dr. No" assert ctx["title"] == "Dr. No"
assert ctx["year"] == "1962" # coerced to str for uniform template fields assert ctx["year"] == "1962" # coerced to str for uniform template fields
@@ -278,7 +256,7 @@ class TestApplyCsfdTags:
assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}" assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}"
def test_name_context_year_from_tag_when_no_cache(self, movie_file): def test_name_context_year_from_tag_when_no_cache(self, movie_file):
movie_file.csfd_cache = None movie_file.meta = None
movie_file.add_tag("Rok/1999") movie_file.add_tag("Rok/1999")
assert movie_file.name_context()["year"] == "1999" assert movie_file.name_context()["year"] == "1999"
@@ -315,37 +293,30 @@ class TestApplyCsfdTags:
assert "{collection_sort} - {title}{ext}".format(**ctx) == \ assert "{collection_sort} - {title}{ext}".format(**ctx) == \
f"01 - Dr. No{ctx['ext']}" f"01 - Dr. No{ctx['ext']}"
def test_apply_csfd_tags_honors_custom_schema(self, movie_file): def test_apply_nfo_honors_custom_schema(self, movie_file):
"""A schema without the rating entry produces no Hodnocení tags.""" """A schema without the rating entry produces no Hodnocení tags."""
from unittest.mock import patch from src.core.nfo import NfoData
from src.core.csfd import CSFDMovie
schema = [{"category": "Žánr", "csfd_field": "genres", schema = [{"category": "Žánr", "csfd_field": "genres",
"transform": None, "filmoteka_root": ""}] "transform": None, "filmoteka_root": ""}]
movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"]) movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
with patch("src.core.csfd.fetch_movie", return_value=movie):
movie_file.apply_csfd_tags(schema)
paths = {t.full_path for t in movie_file.tags} paths = {t.full_path for t in movie_file.tags}
assert "Žánr/Drama" in paths assert "Žánr/Drama" in paths
assert not any(p.startswith("Hodnocení/") for p in paths) assert not any(p.startswith("Hodnocení/") for p in paths)
def test_apply_csfd_tags_preserves_user_tags_on_refetch(self, movie_file): def test_apply_nfo_preserves_user_tags_on_refresh(self, movie_file):
"""Re-fetching regenerates only ČSFD tags; user-added tags survive.""" """Re-reading regenerates only auto (NFO) tags; user-added tags survive."""
from unittest.mock import patch from src.core.nfo import NfoData
from src.core.csfd import CSFDMovie
movie_file.add_tag("Sbírka/Oblíbené") # user tag movie_file.add_tag("Sbírka/Oblíbené") # user tag
first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"]) movie_file.apply_nfo(NfoData(year="1999", genres=["Akční"]))
with patch("src.core.csfd.fetch_movie", return_value=first): movie_file.apply_nfo(NfoData(year="2009", genres=["Drama"]))
movie_file.apply_csfd_tags()
second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"])
with patch("src.core.csfd.fetch_movie", return_value=second):
movie_file.apply_csfd_tags()
paths = {t.full_path for t in movie_file.tags} paths = {t.full_path for t in movie_file.tags}
assert "Sbírka/Oblíbené" in paths # user tag kept assert "Sbírka/Oblíbené" in paths # user tag kept
assert "Žánr/Drama" in paths # new ČSFD tag assert "Žánr/Drama" in paths # new NFO tag
assert "Rok/2009" in paths assert "Rok/2009" in paths
assert "Žánr/Akční" not in paths # old NFO tag regenerated away
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
assert "Rok/1999" not in paths assert "Rok/1999" not in paths
+230 -11
View File
@@ -203,22 +203,21 @@ class TestPoolManagement:
source = tmp_path / "raw.mkv" source = tmp_path / "raw.mkv"
source.write_bytes(b"x" * 10) source.write_bytes(b"x" * 10)
movie = file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1") movie = file_manager.import_movie(source, "Matrix")
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv" assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv"
assert source.exists() # non-destructive copy assert source.exists() # non-destructive copy
assert movie.title == "Matrix" assert movie.title == "Matrix"
assert movie.csfd_link == "https://csfd.cz/film/1"
assert file_manager.index.get(movie.file_path) is not None assert file_manager.index.get(movie.file_path) is not None
def test_statistics_aggregates_pool(self, file_manager, tmp_path): def test_statistics_aggregates_pool(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x" * 100) (tmp_path / "a.mkv").write_bytes(b"x" * 100)
(tmp_path / "b.mkv").write_bytes(b"y" * 50) (tmp_path / "b.mkv").write_bytes(b"y" * 50)
a = file_manager.import_movie(tmp_path / "a.mkv", "A", "https://csfd/1") a = file_manager.import_movie(tmp_path / "a.mkv", "A")
a.add_tag("Žánr/Akční") a.add_tag("Žánr/Akční")
a.add_tag("Žánr/Sci-Fi") a.add_tag("Žánr/Sci-Fi")
a.csfd_cache = {"rating": 90} a.meta = {"rating": 90}
a.save_metadata() a.save_metadata()
b = file_manager.import_movie(tmp_path / "b.mkv", "B") b = file_manager.import_movie(tmp_path / "b.mkv", "B")
b.add_tag("Žánr/Akční") b.add_tag("Žánr/Akční")
@@ -226,7 +225,7 @@ class TestPoolManagement:
s = file_manager.statistics() s = file_manager.statistics()
assert s["count"] == 2 assert s["count"] == 2
assert s["total_size"] == 150 assert s["total_size"] == 150
assert s["with_csfd"] == 1 and s["without_csfd"] == 1 assert s["with_nfo"] == 1 and s["without_nfo"] == 1
assert s["untagged"] == 0 assert s["untagged"] == 0
assert s["avg_rating"] == 90 assert s["avg_rating"] == 90
# Akční on both movies → count 2, sorted first # Akční on both movies → count 2, sorted first
@@ -319,7 +318,7 @@ class TestPoolManagement:
old_path = first.file_path old_path = first.file_path
second = file_manager.import_movie( second = file_manager.import_movie(
tmp_path / "b.mkv", "Matrix", csfd_link="x", on_conflict="replace") tmp_path / "b.mkv", "Matrix", on_conflict="replace")
assert second.file_path.name == "Matrix.mkv" assert second.file_path.name == "Matrix.mkv"
assert second.file_path == old_path # same name reused assert second.file_path == old_path # same name reused
@@ -328,7 +327,6 @@ class TestPoolManagement:
# index record reflects the new import (fresh metadata, old tags dropped) # index record reflects the new import (fresh metadata, old tags dropped)
record = file_manager.index.get(second.file_path) record = file_manager.index.get(second.file_path)
assert record is not None assert record is not None
assert record["csfd_link"] == "x"
assert record["tags"] == [] assert record["tags"] == []
assert second.tags == [] assert second.tags == []
@@ -419,6 +417,223 @@ class TestPoolManagement:
assert movie.file_path.name == "Matrix (1999).mkv" assert movie.file_path.name == "Matrix (1999).mkv"
assert movie.title == "Matrix" # clean title, no year assert movie.title == "Matrix" # clean title, no year
def test_import_movie_with_tmdb_id_names_for_jellyfin(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603)
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
assert movie.title == "Matrix" # clean title, no year/id
def test_libraries_default_and_derivation(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
assert file_manager.libraries == [
{"name": "Filmy", "kind": "movie"},
{"name": "Seriály", "kind": "series"},
]
assert file_manager.movie_libraries == ["Filmy"]
assert file_manager.copyasis_folders == ["Seriály"]
def test_set_libraries_with_all_kinds(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_libraries([
{"name": "Hlavní", "kind": "mixed"},
{"name": "Dokumenty", "kind": "mixed"},
{"name": "Anime", "kind": "series"},
{"name": "Chajda", "kind": "homevideo"},
])
assert file_manager.copyasis_folders == ["Anime"]
assert file_manager.movie_libraries == ["Filmy"] # none of kind movie → fallback
assert file_manager.libraries_of_kind("mixed") == ["Hlavní", "Dokumenty"]
assert file_manager.libraries_of_kind("homevideo") == ["Chajda"]
def test_set_pool_dir_creates_all_library_folders(self, file_manager, tmp_path):
pool = tmp_path / "pool"
file_manager.set_pool_dir(pool)
file_manager.set_libraries([
{"name": "Hlavní", "kind": "mixed"},
{"name": "Chajda", "kind": "homevideo"},
])
file_manager.set_pool_dir(pool) # re-run creates the newly defined folders
assert (pool / "Hlavní").is_dir()
assert (pool / "Chajda").is_dir()
def test_mixed_library_loads_movies_not_episodes(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
hlavni = tmp_path / "pool" / "Hlavní"
hlavni.mkdir(parents=True, exist_ok=True)
(hlavni / "Matrix (1999) [tmdbid-603].mkv").write_bytes(b"x") # movie (top level)
ep = hlavni / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
ep.mkdir(parents=True)
(ep / "S01E01 - pilot.mkv").write_bytes(b"y") # series episode (subfolder)
file_manager.load_pool_movies()
names = [f.filename for f in file_manager.filelist]
assert names == ["Matrix (1999) [tmdbid-603].mkv"] # episode not loaded as movie
assert file_manager.library_of(file_manager.filelist[0]) == "Hlavní"
def test_libraries_accepting_movies(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_libraries([
{"name": "Filmy", "kind": "movie"},
{"name": "Hlavní", "kind": "mixed"},
{"name": "Anime", "kind": "series"},
])
assert file_manager.libraries_accepting_movies == ["Filmy", "Hlavní"]
def test_orphan_folder_cleanup(self, file_manager, tmp_path):
pool = tmp_path / "pool"
file_manager.set_pool_dir(pool)
file_manager.set_libraries([{"name": "Filmy", "kind": "movie"}])
(pool / "OldKids").mkdir() # empty orphan
(pool / "OldStuff").mkdir()
(pool / "OldStuff" / "film.mkv").write_bytes(b"x") # non-empty orphan
orphans = dict(file_manager.orphan_pool_folders())
assert orphans.get("OldKids") is True
assert orphans.get("OldStuff") is False
assert "Filmy" not in orphans # a configured library is not an orphan
assert file_manager.remove_empty_pool_folder("OldKids") is True
assert not (pool / "OldKids").exists()
assert file_manager.remove_empty_pool_folder("OldStuff") is False # non-empty
assert file_manager.remove_empty_pool_folder("Filmy") is False # a library
def test_move_movie_into_mixed_library(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_libraries([
{"name": "Filmy", "kind": "movie"},
{"name": "Hlavní", "kind": "mixed"},
])
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
file_manager.move_to_library(movie, "Hlavní")
assert file_manager.library_of(movie) == "Hlavní"
def test_import_into_named_library(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_movie_libraries(["Filmy", "Kids"])
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "a.mkv", "Auta", year=2006, tmdb_id=920, library="Kids")
assert movie.file_path == tmp_path / "pool" / "Kids" / "Auta (2006) [tmdbid-920].mkv"
assert file_manager.library_of(movie) == "Kids"
def test_move_to_library_reindexes(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_movie_libraries(["Filmy", "Kids"])
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006)
old_key = movie.file_path.relative_to(tmp_path / "pool").as_posix()
file_manager.move_to_library(movie, "Kids")
assert file_manager.library_of(movie) == "Kids"
assert movie.file_path.exists()
assert file_manager.index.records.get(old_key) is None # old key dropped
new_key = movie.file_path.relative_to(tmp_path / "pool").as_posix()
assert file_manager.index.records.get(new_key) is not None
def test_move_to_unknown_library_rejected(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006)
with pytest.raises(ValueError):
file_manager.move_to_library(movie, "Neexistuje")
def test_load_scans_all_movie_libraries(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_movie_libraries(["Filmy", "Kids"])
(tmp_path / "a.mkv").write_bytes(b"x")
(tmp_path / "b.mkv").write_bytes(b"y")
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
file_manager.import_movie(tmp_path / "b.mkv", "Auta", year=2006, library="Kids")
reloaded = FileManager(TagManager())
reloaded.set_pool_dir(tmp_path / "pool")
reloaded.load_pool_movies()
libs = sorted(reloaded.library_of(f) for f in reloaded.filelist)
assert libs == ["Filmy", "Kids"]
def test_load_skips_jellyfin_sidecars(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
# Jellyfin writes these next to the movie — must not count as movies
movie.file_path.with_suffix(".nfo").write_text("<movie/>")
(movie.file_path.parent / "poster.jpg").write_bytes(b"img")
reloaded = FileManager(TagManager())
reloaded.set_pool_dir(tmp_path / "pool")
reloaded.load_pool_movies()
assert len(reloaded.filelist) == 1
assert reloaded.filelist[0].file_path.suffix == ".mkv"
def test_apply_movie_edits_all_at_once(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_movie_libraries(["Filmy", "Kids"])
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Puvodni", year=2000)
file_manager.apply_movie_edits(
movie, title="Auta", year=2006, tmdb_id=920, library="Kids",
date="2026-01-02", attributes={"collection_sort": "01"},
tags=["Žánr/Animovaný", "Sbírka/Oblíbené"])
assert movie.file_path == tmp_path / "pool" / "Kids" / "Auta (2006) [tmdbid-920].mkv"
assert movie.title == "Auta"
assert file_manager.library_of(movie) == "Kids"
assert movie.date == "2026-01-02"
assert movie.attributes == {"collection_sort": "01"}
assert {t.full_path for t in movie.tags} == {"Žánr/Animovaný", "Sbírka/Oblíbené"}
def test_import_and_edit_store_csfd_url(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603,
csfd_url="https://www.csfd.cz/film/9499/")
assert movie.csfd_url == "https://www.csfd.cz/film/9499/"
# persists across reload
reloaded = FileManager(TagManager())
reloaded.set_pool_dir(tmp_path / "pool")
reloaded.load_pool_movies()
assert reloaded.filelist[0].csfd_url == "https://www.csfd.cz/film/9499/"
# editable via apply_movie_edits
file_manager.apply_movie_edits(movie, title="Matrix", year=1999,
csfd_url="https://www.csfd.cz/film/9499-matrix/")
assert movie.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
def test_apply_movie_edits_rejects_name_clash(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
(tmp_path / "b.mkv").write_bytes(b"y")
file_manager.import_movie(tmp_path / "a.mkv", "Solaris", year=1972)
other = file_manager.import_movie(tmp_path / "b.mkv", "Jiny", year=2002)
with pytest.raises(FileExistsError):
file_manager.apply_movie_edits(other, title="Solaris", year=1972)
def test_rename_to_canonical_preserves_tmdb_id(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603)
movie.title = "Matrix Reloaded" # metadata title changed
renamed = file_manager.rename_to_canonical(movie)
assert renamed is not None
assert movie.file_path.name == "Matrix Reloaded (1999) [tmdbid-603].mkv"
def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path): def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"a") (tmp_path / "a.mkv").write_bytes(b"a")
@@ -441,7 +656,7 @@ class TestPoolManagement:
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix") # plain movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix") # plain
assert movie.file_path.name == "Matrix.mkv" assert movie.file_path.name == "Matrix.mkv"
movie.csfd_cache = {"year": 1999} movie.meta = {"year": 1999}
renamed = file_manager.rename_to_canonical(movie) renamed = file_manager.rename_to_canonical(movie)
assert renamed is not None assert renamed is not None
@@ -461,7 +676,7 @@ class TestPoolManagement:
for n in ("a.mkv", "b.mkv", "c.mkv"): for n in ("a.mkv", "b.mkv", "c.mkv"):
(tmp_path / n).write_bytes(b"x") (tmp_path / n).write_bytes(b"x")
with_year = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") with_year = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
with_year.csfd_cache = {"year": 1999} with_year.meta = {"year": 1999}
already = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=1972) already = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=1972)
_no_year = file_manager.import_movie(tmp_path / "c.mkv", "Mystery") _no_year = file_manager.import_movie(tmp_path / "c.mkv", "Mystery")
@@ -476,7 +691,7 @@ class TestPoolManagement:
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
source = tmp_path / "raw.mkv" source = tmp_path / "raw.mkv"
source.write_bytes(b"x" * 10) source.write_bytes(b"x" * 10)
file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1") file_manager.import_movie(source, "Matrix")
reloaded = FileManager(TagManager()) reloaded = FileManager(TagManager())
reloaded.set_pool_dir(tmp_path / "pool") reloaded.set_pool_dir(tmp_path / "pool")
@@ -498,7 +713,11 @@ class TestPoolManagement:
file_manager.set_copyasis_folders(["Seriály", "Dokumenty"]) file_manager.set_copyasis_folders(["Seriály", "Dokumenty"])
assert "copyasis_folders" not in file_manager.global_config assert "copyasis_folders" not in file_manager.global_config
assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Dokumenty"] # series libraries now persist in the unified "libraries" setting (in the index)
stored = file_manager.index.get_setting("libraries")
assert stored is not None
assert [lib["name"] for lib in stored if lib["kind"] == "series"] == \
["Seriály", "Dokumenty"]
# a fresh manager over the same pool reads the stored settings back # a fresh manager over the same pool reads the stored settings back
reloaded = FileManager(TagManager()) reloaded = FileManager(TagManager())
+19 -4
View File
@@ -227,6 +227,21 @@ class TestHardlinkManager:
assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped
assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists() assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
def test_link_name_strips_year_and_tmdbid(self, tmp_path, tag_manager):
"""A pooled 'Title (YYYY) [tmdbid-N].ext' links as clean 'Title.ext'."""
source = tmp_path / "source"
source.mkdir()
(source / "Matrix (1999) [tmdbid-603].mkv").write_text("x")
f = File(source / "Matrix (1999) [tmdbid-603].mkv", tag_manager)
f.tags.clear()
f.add_tag(Tag("žánr", "Sci-Fi"))
output = tmp_path / "output"
manager = HardlinkManager(output)
manager.create_structure_for_files([f], category_roots={"žánr": ""})
assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped
def test_link_name_keeps_year_on_collision( def test_link_name_keeps_year_on_collision(
self, tmp_path, tag_manager self, tmp_path, tag_manager
): ):
@@ -238,13 +253,13 @@ class TestHardlinkManager:
f1 = File(source / "Solaris (1972).mkv", tag_manager) f1 = File(source / "Solaris (1972).mkv", tag_manager)
f1.tags.clear() f1.tags.clear()
f1.title = "Solaris" f1.title = "Solaris"
f1.csfd_cache = {"year": 1972} f1.meta = {"year": 1972}
f1.add_tag(Tag("žánr", "Sci-Fi")) f1.add_tag(Tag("žánr", "Sci-Fi"))
f1.add_tag(Tag("rok", "1972")) f1.add_tag(Tag("rok", "1972"))
f2 = File(source / "Solaris (2002).mkv", tag_manager) f2 = File(source / "Solaris (2002).mkv", tag_manager)
f2.tags.clear() f2.tags.clear()
f2.title = "Solaris" f2.title = "Solaris"
f2.csfd_cache = {"year": 2002} f2.meta = {"year": 2002}
f2.add_tag(Tag("žánr", "Sci-Fi")) f2.add_tag(Tag("žánr", "Sci-Fi"))
f2.add_tag(Tag("rok", "2002")) f2.add_tag(Tag("rok", "2002"))
@@ -323,7 +338,7 @@ class TestHardlinkManager:
f = File(temp_source_dir / "file1.txt", tag_manager) f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear() f.tags.clear()
f.title = "Dr. No" f.title = "Dr. No"
f.csfd_cache = {"year": 1962} f.meta = {"year": 1962}
f.add_tag(Tag("Kolekce", "James Bond")) f.add_tag(Tag("Kolekce", "James Bond"))
f.add_tag(Tag("žánr", "Akční")) f.add_tag(Tag("žánr", "Akční"))
manager = HardlinkManager(temp_output_dir) manager = HardlinkManager(temp_output_dir)
@@ -345,7 +360,7 @@ class TestHardlinkManager:
f = File(temp_source_dir / "file1.txt", tag_manager) f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear() f.tags.clear()
f.title = "Dr. No" f.title = "Dr. No"
f.csfd_cache = {"year": 1962} f.meta = {"year": 1962}
f.add_tag(Tag("Kolekce", "James Bond")) f.add_tag(Tag("Kolekce", "James Bond"))
roots = {"Kolekce": "Dle kolekce"} roots = {"Kolekce": "Dle kolekce"}
templates = {"Kolekce": "{year} - {title}{ext}"} templates = {"Kolekce": "{year} - {title}{ext}"}
+107
View File
@@ -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"] == []
+95
View File
@@ -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"
+28
View File
@@ -6,6 +6,7 @@ from src.core.naming import (
is_canonical_pool_stem, is_canonical_pool_stem,
is_valid_year, is_valid_year,
parse_pool_stem, parse_pool_stem,
parse_tmdb_id,
year_from_filename, year_from_filename,
) )
@@ -46,6 +47,33 @@ def test_parse_pool_stem_non_canonical():
assert parse_pool_stem("Matrix 1999") is None assert parse_pool_stem("Matrix 1999") is None
def test_canonical_pool_stem_with_tmdb_id():
assert canonical_pool_stem("Matrix", 1999, 603) == "Matrix (1999) [tmdbid-603]"
# empty/None id → plain stem, no brackets
assert canonical_pool_stem("Matrix", 1999, None) == "Matrix (1999)"
assert canonical_pool_stem("Matrix", 1999, "") == "Matrix (1999)"
def test_canonical_pool_stem_rejects_bad_id():
with pytest.raises(ValueError):
canonical_pool_stem("Matrix", 1999, "tt0133093")
def test_parse_pool_stem_strips_trailing_tmdb_id():
# title stays clean; the id is dropped from parse_pool_stem
assert parse_pool_stem("Matrix (1999) [tmdbid-603]") == ("Matrix", 1999)
def test_parse_tmdb_id():
assert parse_tmdb_id("Matrix (1999) [tmdbid-603]") == 603
assert parse_tmdb_id("Matrix (1999)") is None
assert parse_tmdb_id("Matrix") is None
def test_is_canonical_accepts_tmdb_id_form():
assert is_canonical_pool_stem("Matrix (1999) [tmdbid-603]")
def test_is_canonical_pool_stem(): def test_is_canonical_pool_stem():
assert is_canonical_pool_stem("Matrix (1999)") assert is_canonical_pool_stem("Matrix (1999)")
assert not is_canonical_pool_stem("Matrix") assert not is_canonical_pool_stem("Matrix")
+103
View File
@@ -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}
+2 -2
View File
@@ -74,7 +74,7 @@ class TestFileWithIndex:
tm = TagManager() tm = TagManager()
f = File(movie, tm, index=index) f = File(movie, tm, index=index)
f.title = "Matrix" f.title = "Matrix"
f.csfd_link = "https://csfd.cz/film/1" f.meta = {"year": "1999", "rating": 87}
f.add_tag("Žánr/Akční") f.add_tag("Žánr/Akční")
f.set_date("1999-03-31") f.set_date("1999-03-31")
@@ -82,7 +82,7 @@ class TestFileWithIndex:
index2 = PoolIndex(tmp_path) index2 = PoolIndex(tmp_path)
f2 = File(movie, TagManager(), index=index2) f2 = File(movie, TagManager(), index=index2)
assert f2.title == "Matrix" assert f2.title == "Matrix"
assert f2.csfd_link == "https://csfd.cz/film/1" assert f2.meta == {"year": "1999", "rating": 87}
assert f2.date == "1999-03-31" assert f2.date == "1999-03-31"
assert "Žánr/Akční" in {t.full_path for t in f2.tags} assert "Žánr/Akční" in {t.full_path for t in f2.tags}
+148
View File
@@ -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()
+89
View File
@@ -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)