diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e04b6..a690afc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,67 @@ Each version entry uses these sections (include only those that apply): ## Unreleased +### Added +- **Series support** (Jellyfin TV layout, phase 5; `src/core/series.py`, menu + **Seriály**). Episodes are matched by `SxxEyy` (also `1x02`) and laid out as + `Seriály/Series (rok) [tmdbid]/Season NN/Series SxxEyy.ext` (season 0 → + `Specials`). *"Importovat epizody…"* places episode files (series identified + via TMDb TV lookup, per-file SxxEyy parsed from the filename); *"Přehled + seriálů…"* shows a Series → Season → Episode tree with a naming status and a + per-series **"Přejmenovat epizody"** that renames mis-named episodes into the + right Season folder. `FileManager` gained `load_series`, `import_episodes`, + `rename_episodes_to_canonical`; `tmdb.lookup_tv` / `search_tv` resolve series. +- **NFO-driven genres, ČSFD removed** (phase 4). Curator's metadata source is now + the `.nfo` Jellyfin writes into the media folders (`src/core/nfo.py`, read-only). + *Filmy → "Načíst žánry z NFO"* (`FileManager.refresh_tags_from_nfo`) reads + genre/year/country/rating from each movie's NFO and (re)generates its tags + (user-added tags kept); generating the miniDLNA tree refreshes from NFO first, + so the genre-split tree reflects what Jellyfin knows. The **Filmotéka** menu is + now **miniDLNA** ("Vygenerovat miniDLNA strom"). +- **Jellyfin integration** (thin/hybrid, phase 3; `src/core/jellyfin.py`, menu + **Jellyfin**). NFO/artwork on disk stay authoritative; the client only + triggers a scan and reads back what Jellyfin sees. **"Skenovat knihovnu"** + (`POST /Library/Refresh`), **"Ověřit proti poolu…"** (matches pooled movies to + Jellyfin items by TMDb id, then title+year, and reports what is out of sync — + `FileManager.verify_against_jellyfin`), **"Zkontrolovat připojení"**. The + status bar shows the Jellyfin state (nenastaveno / nezkontrolováno / ● verze). + Config via `.env` — `JELLYFIN_URL`, `JELLYFIN_API_KEY`. +- **Movie libraries as physical partitions** (Jellyfin-first, phase 2). The pool + can now hold several top-level movie libraries (`movie_libraries` in the pool + index — default `Filmy`; add Kids/Dokumenty/Anime via *Pool → "Knihovny + (rozdělení)…"*). Import lands in a chosen library, and *Filmy → "Přesunout do + knihovny…"* (context menu too) moves a movie between them. The movie table + gained a **Knihovna** column and a **library filter** (Vše / per library) in + the toolbar. `FileManager` gained `movie_libraries` / `set_movie_libraries`, + `import_movie(library=…)`, `move_to_library`, and `library_of`; `load_pool_movies` + and the consistency check now scan every movie library and skip the sidecars + Jellyfin writes (`.nfo`, artwork, subtitles). +- **TMDb identity source** (`src/core/tmdb.py`) — start of the Jellyfin-first + pivot. Resolves a **TMDb id** (or a title/year search) into a canonical + **title + year**; the rich metadata/artwork are left to Jellyfin. Needs a free + `TMDB_API_KEY` in `.env`; degrades gracefully without it. +- **`[tmdbid-N]` in the pool naming convention** (`naming.py`): the pooled file + is now `Title (YYYY) [tmdbid-N].ext` when the id is known — the exact form + Jellyfin matches on. The id is optional (plain `Title (YYYY)` still valid), + parsed back by `parse_tmdb_id`, preserved across `rename_to_canonical`, and + stripped from the miniDLNA hardlink names (they stay clean `Title.ext`). + `FileManager.import_movie` takes a `tmdb_id`. + +### Changed +- **Import reworked around TMDb, ČSFD removed from the import flow.** The import + dialog's "ČSFD odkaz" column is now **TMDb ID**, and **"Dohledat z TMDb"** + fills Title/Year/id (by id, or by searching the filename) — a suggested value + is tinted blue, a name colliding with the pool red. Import no longer fetches + ČSFD tags. (The legacy ČSFD tag actions remain until the tag tree is replaced + by NFO-driven miniDLNA generation.) + +### Removed +- **ČSFD entirely.** Deleted `src/core/csfd.py` (scraping, Anubis PoW solver) and + its tests; dropped `File.apply_csfd_tags` / `set_csfd_link` / `get_cached_movie`, + the `csfd_link` field and the ČSFD menu/context actions. `File.csfd_cache` → + `File.meta` and `csfd_tag_paths` → `auto_tag_paths` (both read the old index + keys for back-compat); the folder-name transforms moved to `src/core/transforms.py`. + ## 1.11.0 — 2026-07-27 ### Added diff --git a/DESIGN_DOCUMENT_PYTHON.md b/DESIGN_DOCUMENT_PYTHON.md new file mode 100644 index 0000000..cee3199 --- /dev/null +++ b/DESIGN_DOCUMENT_PYTHON.md @@ -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__` + +--- + +## 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 # Add runtime dependency +poetry add --group dev # Add dev dependency +poetry remove # Remove dependency +poetry run # 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` | `.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`. diff --git a/PROJECT.md b/PROJECT.md index 7171c8b..e260011 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -164,6 +164,32 @@ movie table, and one-click Filmotéka generation. fall back to the file's mtime for the "recently added" ordering (ctime is unreliable — creating the Filmotéka hardlinks bumps it). +## Direction — Jellyfin pivot (in progress) + +Curator is being refocused from a ČSFD-tagging tool into a **Jellyfin-first +library manager**. Agreed shape: + +- **Pool = the single Jellyfin library** (merged; no separate output library). + Files are named `Title (rok) [tmdbid-N].ext` — the form Jellyfin matches on. + Libraries (Filmy/Kids/Dokumenty/Anime/Seriály) are physical partitions. +- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to) + writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall. + Curator only resolves a **TMDb id → title + year** for naming (`tmdb.py`); + ČSFD is dropped (it exposes no TMDb/IMDb id anyway — verified). +- **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. + +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 589 movies (title+year → TMDb search). Note: libraries hold **flat** files for now +(`pool//Title (rok) [tmdbid].ext`); per-movie folders are a later +refinement. + ## Tasks # (no open tasks — see Done) diff --git a/src/core/config.py b/src/core/config.py index cc4b115..8bfdeef 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -26,8 +26,9 @@ GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag" # they are derived from ČSFD, and how they map to the Filmotéka folder tree. # Each entry: # category — tag category name (e.g. "Žánr") -# csfd_field — CSFDMovie attribute feeding it (genres / year / countries / -# rating / directors / actors), or None for a user-only category +# csfd_field — NFO source field feeding it (genres / year / countries / +# rating), or None for a user-only category. (Key name kept for +# index compatibility; the data now comes from the NFO.) # transform — named value transform: None (str) or "decade_band" (rating) # filmoteka_root — folder under the output: "" = at the root (e.g. genres), # "Dle X" = grouping folder, None = filterable but no folders diff --git a/src/core/csfd.py b/src/core/csfd.py deleted file mode 100644 index db39e4c..0000000 --- a/src/core/csfd.py +++ /dev/null @@ -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 ``', - 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 ```` 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 diff --git a/src/core/file.py b/src/core/file.py index ec2aea9..0663d88 100644 --- a/src/core/file.py +++ b/src/core/file.py @@ -1,10 +1,6 @@ from pathlib import Path from .tag import Tag -# Bump this when the csfd_cache schema changes to force re-fetch on next open. -# v2: country (str) → countries (list[str]) for co-productions. -CSFD_CACHE_VERSION = 2 - class File: def __init__(self, file_path: Path, tagmanager=None, index=None) -> None: @@ -22,17 +18,17 @@ class File: self.date: str | None = None # movie-library fields self.title: str | None = None - self.csfd_link: str | None = None # ISO timestamp of when the movie was added to the pool (set on import); # None for older/unknown → falls back to the file's ctime. self.added: str | None = None - # Cached CSFD data — avoids re-fetching on every open - self.csfd_cache: dict | None = None - # full_paths of tags that came from ČSFD (vs. user-added); only these are - # regenerated when the ČSFD link changes — user tags are preserved. - self.csfd_tag_paths: set[str] = set() + # Cached metadata used for folder grouping (year, rating) — filled from + # the movie's NFO (which Jellyfin writes). + self.meta: dict | None = None + # full_paths of tags derived automatically from the NFO (vs. user-added); + # only these are regenerated on refresh — user tags are preserved. + self.auto_tag_paths: set[str] = set() # free-form per-movie attributes (e.g. "collection_sort"); usable in - # Filmotéka filename templates via name_context. + # miniDLNA filename templates via name_context. self.attributes: dict[str, str] = {} self.get_metadata() @@ -50,9 +46,8 @@ class File: self.tags = [] self.date = None self.title = None - self.csfd_link = None - self.csfd_cache = None - self.csfd_tag_paths = set() + self.meta = None + self.auto_tag_paths = set() self.attributes = {} self.added = None @@ -62,17 +57,16 @@ class File: "ignored": self.ignored, # ukládáme full_path tagů "tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags], - # which of the tags came from ČSFD (provenance) - "csfd_tags": sorted(self.csfd_tag_paths), + # which of the tags were derived automatically (from the NFO) + "auto_tags": sorted(self.auto_tag_paths), # date může být None "date": self.date, "title": self.title, - "csfd_link": self.csfd_link, "attributes": self.attributes, "added": self.added, } - if self.csfd_cache is not None: - data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache} + if self.meta is not None: + data["meta"] = dict(self.meta) return data def _apply_record(self, data: dict) -> None: @@ -81,16 +75,15 @@ class File: self.tags = [] self.date = data.get("date", None) self.title = data.get("title", None) - self.csfd_link = data.get("csfd_link", None) - # Legacy records have no provenance → treated as all-user (empty set) - self.csfd_tag_paths = set(data.get("csfd_tags", [])) + # provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored + self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", []))) self.attributes = dict(data.get("attributes", {})) self.added = data.get("added", None) - raw_cache = data.get("csfd_cache") - if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION: - self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"} - else: - self.csfd_cache = None + raw_meta = data.get("meta") + if raw_meta is None: # legacy: pull year/rating out of the old csfd cache + old = data.get("csfd_cache") or {} + raw_meta = {"year": old.get("year"), "rating": old.get("rating")} if old else None + self.meta = dict(raw_meta) if raw_meta else None if not self.tagmanager: return @@ -126,13 +119,13 @@ class File: return 0.0 def name_context(self) -> dict: - """Fields for a Filmotéka filename template (see config tag schema). + """Fields for a miniDLNA filename template (see config tag schema). Provides title / year / rating / ext / stem / filename from the movie's - metadata (year & rating come from the ČSFD cache, year falling back to a - ``Rok`` tag). Missing values render as an empty string. + metadata (year & rating come from the NFO cache, year falling back to a + ``Rok`` tag, then the canonical filename). Missing values render empty. """ - cache = self.csfd_cache or {} + cache = self.meta or {} year = cache.get("year") if year is None: for t in self.tags: @@ -195,78 +188,49 @@ class File: self.date = date_str self.save_metadata() - def set_csfd_link(self, url: str | None) -> None: - """Nastaví CSFD odkaz nebo None. Invaliduje cache při změně odkazu.""" - new_url = url if url else None - if new_url != self.csfd_link: - self.csfd_cache = None # odkaz se změnil — stará cache je neplatná - self.csfd_link = new_url - self.save_metadata() + def apply_nfo(self, nfo, schema: list[dict] | None = None) -> dict: + """Regenerate the auto (NFO-derived) tags from the movie's NFO. - def get_cached_movie(self): - """Vrátí CSFDMovie z cache, nebo None. Nevyžaduje síť.""" - if self.csfd_cache is None: - return None - try: - from .csfd import CSFDMovie - return CSFDMovie.from_dict(self.csfd_cache) - except Exception: - return None - - def apply_csfd_tags(self, schema: list[dict] | None = None) -> dict: - """Načte data z ČSFD a přegeneruje **jen ČSFD tagy** podle tag schématu. - - ``schema`` je seznam definic kategorií (viz ``DEFAULT_TAG_SCHEMA``); pro - každý záznam s ``csfd_field`` vytvoří tagy z příslušného pole filmu - (s volitelným ``transform``). Tagy přidané uživatelem (mimo provenance - ČSFD) zůstávají nedotčené — proto změna ČSFD odkazu nepřepíše vlastní tagy. - Režie/herci se stále cachují, tag se z nich tvoří jen je-li v schématu. + Reads genres/year/countries/rating from ``nfo`` (``nfo.NfoData``) per the + tag schema's source fields and (re)creates those tags; user-added tags are + kept. Also caches year/rating (for folder grouping) and fills the title + when missing. ``nfo`` of ``None`` (no NFO yet) is a no-op. Returns: - dict s klíči 'success', 'movie'/'error', 'tags_added' + dict with 'success' and 'tags_added'. """ - if not self.csfd_link: - return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []} + if nfo is None: + return {"success": False, "error": "NFO nenalezeno", "tags_added": []} if schema is None: from .config import DEFAULT_TAG_SCHEMA schema = DEFAULT_TAG_SCHEMA - try: - from .csfd import fetch_movie - movie = fetch_movie(self.csfd_link) - self.csfd_cache = movie.to_dict() - except ImportError as e: - return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []} - except Exception as e: - return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []} + self.meta = {"year": nfo.year, "rating": nfo.rating} - from .csfd import csfd_field_values - - # Drop previous ČSFD-sourced tags; keep user-added ones. - self.tags = [t for t in self.tags if t.full_path not in self.csfd_tag_paths] - self.csfd_tag_paths = set() + # Drop previous auto (NFO) tags; keep user-added ones. + self.tags = [t for t in self.tags if t.full_path not in self.auto_tag_paths] + self.auto_tag_paths = set() tags_added: list[str] = [] for entry in schema: - field = entry.get("csfd_field") + field = entry.get("csfd_field") # schema key = source field name if not field: continue # user-only category category = entry["category"] - for value in csfd_field_values(movie, field): + for value in nfo.field_values(field): tag_obj = (self.tagmanager.add_tag(category, value) if self.tagmanager else Tag(category, value)) if tag_obj not in self.tags: self.tags.append(tag_obj) tags_added.append(tag_obj.full_path) - self.csfd_tag_paths.add(tag_obj.full_path) + self.auto_tag_paths.add(tag_obj.full_path) - # Use the CSFD title if we don't have one yet - if movie.title and not self.title: - self.title = movie.title + if nfo.title and not self.title: + self.title = nfo.title self.save_metadata() - return {"success": True, "movie": movie, "tags_added": tags_added} + return {"success": True, "tags_added": tags_added} def add_tag(self, tag): # tag může být Tag nebo string diff --git a/src/core/file_manager.py b/src/core/file_manager.py index 44de199..556a9a1 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -7,7 +7,7 @@ from .tag_manager import TagManager from .pool_index import PoolIndex from .utils import list_files from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult -from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem +from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem, parse_tmdb_id from typing import Callable, Iterable from src.core.config import ( load_global_config, save_global_config, DEFAULT_TAG_SCHEMA @@ -21,6 +21,9 @@ POOL_SERIES = "Seriály" _LIBRARY_SETTINGS_DEFAULTS = { "tag_schema": DEFAULT_TAG_SCHEMA, "copyasis_folders": [POOL_SERIES], + # Top-level pool folders that hold movies — each is a Jellyfin library + # (physical partition). "Filmy" is the default; users add Kids/Dokumenty/… + "movie_libraries": [POOL_MOVIES], } # Curator metadata files that must never be treated as content. The legacy @@ -28,6 +31,13 @@ _LIBRARY_SETTINGS_DEFAULTS = { # stray ones are still skipped so they never leak into the Filmotéka. METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index") +# Sidecars Jellyfin writes next to a movie (NFO + artwork/subtitles). These are +# not movies and must be skipped when scanning a library. +SIDECAR_SUFFIXES = ( + ".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn", + ".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt", +) + class FileManager: def __init__(self, tagmanager: TagManager): @@ -117,6 +127,27 @@ class FileManager: cleaned = [n.strip() for n in names if n.strip()] self._set_setting("copyasis_folders", cleaned) + @property + def movie_libraries(self) -> list[str]: + """Top-level pool folders holding movies (each a Jellyfin library).""" + libs = self._get_setting("movie_libraries") + return libs if libs else [POOL_MOVIES] + + def set_movie_libraries(self, names: list[str]) -> None: + """Set the movie-library list (never empty — falls back to Filmy).""" + cleaned = [n.strip() for n in names if n.strip()] + self._set_setting("movie_libraries", cleaned or [POOL_MOVIES]) + + def library_of(self, file_obj: File) -> str | None: + """Which library (top-level pool folder) a pooled movie lives in.""" + pool = self.pool_dir + if not pool: + return None + try: + return file_obj.file_path.relative_to(pool).parts[0] + except (ValueError, IndexError): + return None + @property def tag_schema(self) -> list[dict]: """Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA).""" @@ -164,12 +195,13 @@ class FileManager: return Path(value) if value else None def set_pool_dir(self, pool_dir: Path) -> None: - """Set the managed pool root and create its top-level folders.""" + """Set the managed pool root and create every library's top-level folder.""" pool_dir = Path(pool_dir) - (pool_dir / POOL_MOVIES).mkdir(parents=True, exist_ok=True) - (pool_dir / POOL_SERIES).mkdir(parents=True, exist_ok=True) self.global_config["pool_dir"] = str(pool_dir) save_global_config(self.global_config) + self._open_index(pool_dir) # settings now readable through the index + for name in self.movie_libraries + self.copyasis_folders: + (pool_dir / name).mkdir(parents=True, exist_ok=True) def set_filmoteka_dir(self, filmoteka_dir: Path) -> None: """Set the Filmotéka output folder (generated hardlink tree).""" @@ -177,22 +209,33 @@ class FileManager: save_global_config(self.global_config) def load_pool_movies(self) -> None: - """Reload the movie list from pool/Filmy using the unified pool index.""" + """Reload the movie list from every movie library, via the pool index. + + Scans each configured movie library (``movie_libraries``) recursively — + a movie may sit directly in the library or inside its own per-movie + folder. Curator's own metadata and the sidecars Jellyfin writes (NFO, + artwork, subtitles) are skipped. + """ self.filelist = [] - movies = self.movies_dir pool = self.pool_dir - if not (movies and movies.is_dir() and pool): + if not pool: return self._open_index(pool) - for each in list_files(movies): - if each.name.endswith(METADATA_SUFFIXES): + for library in self.movie_libraries: + lib_dir = pool / library + if not lib_dir.is_dir(): continue - file_obj = File(each, self.tagmanager, index=self.index) - self.filelist.append(file_obj) + for each in list_files(lib_dir): + if each.name.endswith(METADATA_SUFFIXES): + continue + if each.suffix.lower() in SIDECAR_SUFFIXES: + continue + file_obj = File(each, self.tagmanager, index=self.index) + self.filelist.append(file_obj) def check_data_consistency(self) -> dict: - """Compare the pool index against the actual files in pool/Filmy. + """Compare the pool index against the actual files in the movie libraries. Detects drift such as a movie file deleted directly (metadata left behind) or a file dropped into the pool without importing (no metadata). @@ -204,8 +247,7 @@ class FileManager: index_count / disk_count — totals for the summary """ pool = self.pool_dir - movies = self.movies_dir - if not (pool and movies and movies.is_dir()): + if not (pool and pool.is_dir()): return {"ok": False, "error": "Pool není nastaven.", "missing": [], "untracked": []} @@ -213,10 +255,16 @@ class FileManager: index_keys = set(index.records.keys()) disk_keys = set() - for f in list_files(movies): - if f.name.endswith(METADATA_SUFFIXES): + for library in self.movie_libraries: + lib_dir = pool / library + if not lib_dir.is_dir(): continue - disk_keys.add(f.relative_to(pool).as_posix()) + for f in list_files(lib_dir): + if f.name.endswith(METADATA_SUFFIXES): + continue + if f.suffix.lower() in SIDECAR_SUFFIXES: + continue + disk_keys.add(f.relative_to(pool).as_posix()) missing = sorted(k for k in index_keys if not (pool / k).is_file()) untracked = sorted(disk_keys - index_keys) @@ -228,6 +276,51 @@ class FileManager: "disk_count": len(disk_keys), } + def verify_against_jellyfin(self, jf_movies: list) -> dict: + """Compare the pool against what Jellyfin currently sees. + + Matches each pooled movie to a Jellyfin item by **TMDb id** (from the + filename), falling back to (title, year). Reports what is out of sync. + + ``jf_movies`` is a list of ``jellyfin.JellyfinMovie``. Returns a dict: + matched — how many pooled movies Jellyfin has + pool_only — pooled filenames Jellyfin does not see (rescan/rematch) + jellyfin_only — Jellyfin titles with no matching pooled movie + pool_total / jellyfin_total — totals for the summary + """ + by_tmdb = {m.tmdb_id: m for m in jf_movies if m.tmdb_id} + by_name = {(m.title.strip().lower(), m.year): m for m in jf_movies if m.title} + + matched_ids: set = set() + pool_only: list[str] = [] + for f in self.filelist: + tmdb_id = parse_tmdb_id(f.file_path.stem) + ctx = f.name_context() + title = str(f.title or ctx.get("title") or "").strip().lower() + year_str = str(ctx.get("year") or "") + year = int(year_str) if year_str.isdigit() else None + + match = None + if tmdb_id is not None and tmdb_id in by_tmdb: + match = by_tmdb[tmdb_id] + elif (title, year) in by_name: + match = by_name[(title, year)] + + if match is not None: + matched_ids.add(match.jellyfin_id) + else: + pool_only.append(f.filename) + + jellyfin_only = sorted( + m.title for m in jf_movies if m.jellyfin_id not in matched_ids) + return { + "matched": len(self.filelist) - len(pool_only), + "pool_only": sorted(pool_only), + "jellyfin_only": jellyfin_only, + "pool_total": len(self.filelist), + "jellyfin_total": len(jf_movies), + } + def scan_video_integrity( self, files: list[File], @@ -279,7 +372,7 @@ class FileManager: """ files = self.filelist total_size = 0 - with_csfd = 0 + with_nfo = 0 untagged = 0 ratings: list[int] = [] categories: dict[str, Counter] = {} @@ -289,21 +382,21 @@ class FileManager: total_size += f.file_path.stat().st_size except OSError: pass - if f.csfd_link: - with_csfd += 1 + if f.meta is not None: + with_nfo += 1 if not f.tags: untagged += 1 for tag in f.tags: categories.setdefault(tag.category, Counter())[tag.name] += 1 - rating = (f.csfd_cache or {}).get("rating") + rating = (f.meta or {}).get("rating") if isinstance(rating, (int, float)): ratings.append(rating) return { "count": len(files), "total_size": total_size, - "with_csfd": with_csfd, - "without_csfd": len(files) - with_csfd, + "with_nfo": with_nfo, + "without_nfo": len(files) - with_nfo, "untagged": untagged, "avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None, "categories": { @@ -325,16 +418,19 @@ class FileManager: self.filelist.remove(file_obj) def import_movie( - self, source: Path, title: str, csfd_link: str | None = None, + self, source: Path, title: str, move: bool = False, on_conflict: str = "suffix", - year: int | str | None = None, + year: int | str | None = None, tmdb_id: int | str | None = None, + library: str | None = None, ) -> File | None: - """Bring a video file into pool/Filmy and index its metadata. + """Bring a video file into a movie library and index its metadata. - The pooled filename follows the naming convention ``Title (YYYY).ext`` - when a ``year`` is given (validated to four digits); without a year it + Lands in ``pool//`` (``library`` defaults to the first movie + library, "Filmy"). The pooled filename follows the naming convention + ``Title (YYYY) [tmdbid-N].ext`` when a ``year`` is given (and a + ``tmdb_id`` when known — the form Jellyfin matches on); without a year it falls back to ``Title.ext``. The GUI import always supplies a year, so a - library built through the app is uniformly ``Title (YYYY).ext``. + library built through the app is uniformly ``Title (YYYY) [tmdbid-N].ext``. By default the original is **copied** (non-destructive). With ``move=True`` the source file is moved into the pool instead, leaving nothing behind. @@ -350,10 +446,11 @@ class FileManager: Raises: ValueError: a ``year`` was given but is not a four-digit number. """ - movies = self.movies_dir pool = self.pool_dir - if movies is None or pool is None: + if pool is None: raise RuntimeError("Pool není nastaven.") + lib = (library or "").strip() or self.movie_libraries[0] + movies = pool / lib movies.mkdir(parents=True, exist_ok=True) if self.index is None: @@ -362,7 +459,8 @@ class FileManager: source = Path(source) safe_title = title.strip() or source.stem has_year = year is not None and str(year).strip() != "" - stem = canonical_pool_stem(safe_title, year) if has_year else safe_title + stem = (canonical_pool_stem(safe_title, year, tmdb_id) if has_year + else safe_title) target = movies / f"{stem}{source.suffix}" existing = self.pooled_with_stem(stem) @@ -389,7 +487,6 @@ class FileManager: file_obj = File(target, self.tagmanager, index=self.index) file_obj.title = safe_title - file_obj.csfd_link = csfd_link or None file_obj.added = datetime.now().isoformat(timespec="seconds") file_obj.save_metadata() @@ -433,12 +530,13 @@ class FileManager: return file_obj def _canonical_name_for(self, file_obj: File) -> str | None: - """The ``Title (YYYY)`` stem this movie should have, or ``None``. + """The ``Title (YYYY) [tmdbid-N]`` stem this movie should have, or ``None``. Uses the clean title and the year from the movie's metadata - (``name_context``: ČSFD cache → ``Rok`` tag → the current filename). A - ``(YYYY)`` that leaked into the title is stripped so it isn't doubled. - Returns ``None`` when no four-digit year is known. + (``name_context``: cache → ``Rok`` tag → the current filename). A + ``(YYYY)`` that leaked into the title is stripped so it isn't doubled, and + an existing ``[tmdbid-N]`` on the current filename is preserved. Returns + ``None`` when no four-digit year is known. """ ctx = file_obj.name_context() title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip() @@ -449,16 +547,19 @@ class FileManager: title = parsed[0] year = year or str(parsed[1]) + # Keep the TMDb id already in the pool filename, if any. + tmdb_id = parse_tmdb_id(file_obj.file_path.stem) + title = title.replace("/", "-").replace("\\", "-").strip() if not (title and is_valid_year(year)): return None - return canonical_pool_stem(title, year) + return canonical_pool_stem(title, year, tmdb_id) def rename_to_canonical(self, file_obj: File) -> File | None: """Rename a pooled movie to ``Title (YYYY).ext`` from its metadata. - The year comes from the movie's ČSFD metadata (falling back to a ``Rok`` - tag or the existing filename). ``file_obj.title`` keeps the clean title + The year comes from the movie's metadata (NFO cache, falling back to a + ``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title (without the year). Does not fire ``on_files_changed`` — the caller refreshes once after a batch. @@ -537,6 +638,173 @@ class FileManager: "conflicts": conflicts, "unchanged": unchanged, "cancelled": cancelled} + def move_to_library(self, file_obj: File, new_library: str) -> File: + """Move a pooled movie into another library (top-level pool folder). + + Moves the file into ``pool//`` 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.movie_libraries: + 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 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 hold series (the copy-as-is mirrors, e.g. Seriály).""" + return self.copyasis_folders + + 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 ``/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(title, season, ep, src.suffix) + if target.exists(): + if on_conflict == "skip": + continue + if on_conflict == "replace": + target.unlink() + else: # suffix + base = episode_filename(title, season, ep, "") + 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( + series.title, ep.season, ep.episode, 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 assign_tag_to_file_objects(self, files_objs: list[File], tag): """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" for f in files_objs: diff --git a/src/core/hardlink_manager.py b/src/core/hardlink_manager.py index f184859..be5d2bb 100644 --- a/src/core/hardlink_manager.py +++ b/src/core/hardlink_manager.py @@ -175,7 +175,7 @@ class HardlinkManager: band) only happens here, when naming the folder. """ if transforms and tag.category in transforms: - from .csfd import apply_transform + from .transforms import apply_transform return apply_transform(tag.name, transforms[tag.category]) return tag.name diff --git a/src/core/jellyfin.py b/src/core/jellyfin.py new file mode 100644 index 0000000..e890242 --- /dev/null +++ b/src/core/jellyfin.py @@ -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 diff --git a/src/core/naming.py b/src/core/naming.py index 24fcf39..7739cbd 100644 --- a/src/core/naming.py +++ b/src/core/naming.py @@ -1,18 +1,19 @@ """Pool file naming convention. -Every movie in the pool is named ``Title (YYYY).ext`` — a title followed by a -four-digit year in parentheses. The year lets two films of the same name live -side by side and makes the filename self-describing (the year can be recovered -without any metadata). These helpers build, validate and parse that stem (the -filename without its extension). +Every movie in the pool is named ``Title (YYYY) [tmdbid-N].ext`` — a title, a +four-digit year in parentheses, and (when known) the TMDb id in brackets, which +is exactly the form Jellyfin matches on. The ``[tmdbid-N]`` part is optional: a +plain ``Title (YYYY)`` is still valid (Jellyfin then matches by name). The year +lets two same-named films coexist and makes the filename self-describing. These +helpers build, validate and parse the stem (the filename without its extension). """ import re -# A trailing four-digit year in parentheses. The title is non-greedy so a title -# that itself contains parentheses keeps them: "Já, robot (2004)" but also +# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]``. The +# title is non-greedy so a title that itself contains parentheses keeps them: # "Kurz (special) (2019)" → title "Kurz (special)", year 2019. -_POOL_STEM_RE = re.compile(r"^(?P.+?) \((?P<year>\d{4})\)$") -_FOUR_DIGITS = re.compile(r"\d{4}") +_POOL_STEM_RE = re.compile( + r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?$") def is_valid_year(year: object) -> bool: @@ -20,11 +21,12 @@ def is_valid_year(year: object) -> bool: return bool(re.fullmatch(r"\d{4}", str(year).strip())) -def canonical_pool_stem(title: str, year: object) -> str: - """Build the canonical pool stem ``Title (YYYY)``. +def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str: + """Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` if given). Raises: - ValueError: the title is empty or the year is not four digits. + ValueError: the title is empty, the year is not four digits, or a given + ``tmdb_id`` is not a positive integer. """ title = (title or "").strip() year_s = str(year).strip() @@ -32,17 +34,35 @@ def canonical_pool_stem(title: str, year: object) -> str: raise ValueError("Název filmu nesmí být prázdný.") if not re.fullmatch(r"\d{4}", year_s): raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.") - return f"{title} ({year_s})" + stem = f"{title} ({year_s})" + if tmdb_id is not None and str(tmdb_id).strip() != "": + id_s = str(tmdb_id).strip() + if not id_s.isdigit(): + raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.") + stem += f" [tmdbid-{id_s}]" + return stem def parse_pool_stem(stem: str) -> tuple[str, int] | None: - """Split a canonical stem into ``(title, year)``; ``None`` if it isn't one.""" + """Split a canonical stem into ``(title, year)``; ``None`` if it isn't one. + + A trailing ``[tmdbid-N]`` is tolerated and dropped (use :func:`parse_tmdb_id` + to read it), so the returned title is always the clean title. + """ match = _POOL_STEM_RE.match((stem or "").strip()) if not match: return None return match.group("title").strip(), int(match.group("year")) +def parse_tmdb_id(stem: str) -> int | None: + """Read the ``[tmdbid-N]`` from a canonical stem, or ``None`` if absent.""" + match = _POOL_STEM_RE.match((stem or "").strip()) + if not match or not match.group("id"): + return None + return int(match.group("id")) + + def is_canonical_pool_stem(stem: str) -> bool: """True when ``stem`` matches the ``Title (YYYY)`` convention.""" return _POOL_STEM_RE.match((stem or "").strip()) is not None diff --git a/src/core/nfo.py b/src/core/nfo.py new file mode 100644 index 0000000..b58dde8 --- /dev/null +++ b/src/core/nfo.py @@ -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), + ) diff --git a/src/core/series.py b/src/core/series.py new file mode 100644 index 0000000..e1303ac --- /dev/null +++ b/src/core/series.py @@ -0,0 +1,139 @@ +"""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_filename(series_title: str, season: int, episode: int, ext: str) -> str: + """Canonical episode filename ``Series SxxEyy.ext``.""" + return f"{series_title} S{season:02d}E{episode:02d}{ext}" + + +def series_folder_stem(title: str, year: object = None, tmdb_id: object = None) -> str: + """Series folder name: ``Title (year) [tmdbid-N]`` (year/id optional). + + Reuses the movie convention when a year is present; otherwise falls back to + ``Title [tmdbid-N]`` or just ``Title`` so a year-less series still works. + """ + title = (title or "").strip() + if not title: + raise ValueError("Název seriálu nesmí být prázdný.") + year_s = str(year).strip() if year is not None else "" + if re.fullmatch(r"\d{4}", year_s): + return canonical_pool_stem(title, year_s, tmdb_id) + id_s = str(tmdb_id).strip() if tmdb_id is not None else "" + return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title + + +def is_canonical_episode(path: Path, series_title: str) -> bool: + """True when ``path`` already follows ``Series SxxEyy`` in the right season.""" + parsed = parse_episode(path.name) + if parsed is None: + return False + season, episode = parsed + expected = episode_filename(series_title, season, episode, 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 diff --git a/src/core/tmdb.py b/src/core/tmdb.py new file mode 100644 index 0000000..51bb809 --- /dev/null +++ b/src/core/tmdb.py @@ -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 diff --git a/src/core/transforms.py b/src/core/transforms.py new file mode 100644 index 0000000..fb24446 --- /dev/null +++ b/src/core/transforms.py @@ -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) diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py index 36fdd66..75a77cf 100644 --- a/src/ui/qt_app.py +++ b/src/ui/qt_app.py @@ -30,6 +30,7 @@ from src.core.tag import Tag from src.constants import APP_TITLE from src.core.hardlink_manager import HardlinkManager from src.core.naming import canonical_pool_stem, is_valid_year, year_from_filename +from src.core import series # Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV # browsers sort them ahead of the genre folders at the output root. @@ -40,44 +41,47 @@ RECENT_COUNT = 10 # newest by date added class ImportMoviesDialog(QDialog): - """Collect a Title + ČSFD link per file for a batch import into the pool. + """Collect a Title + Year + TMDb id per file for a batch import into the pool. - One row per source file (filename shown, Title and ČSFD link editable). More - files can be added from inside the dialog. A single toggle decides whether - the files are copied (default, non-destructive) or moved into the pool. + One row per source file (filename shown; Title, Year and TMDb id editable). + "Dohledat z TMDb" fills them from TMDb — by the given id, or by searching the + filename. A single toggle decides whether the files are copied (default) or + moved into the pool. """ def __init__( self, parent: QWidget, sources: List[Path], existing_names: set[str] | None = None, + libraries: list[str] | None = None, ) -> None: super().__init__(parent) self.setWindowTitle("Importovat filmy do poolu") - self.setMinimumSize(720, 360) + self.setMinimumSize(760, 360) + self._libraries = libraries or ["Filmy"] # Lower-cased canonical stems already present in the pool (collision check) self.existing_names = {n.lower() for n in (existing_names or set())} - # (source path, title field, year field, ČSFD field) per row + # (source path, title field, year field, tmdb-id field) per row self._rows: list[tuple[Path, QLineEdit, QLineEdit, QLineEdit]] = [] layout = QVBoxLayout(self) info = QLabel( - "Filmy se do poolu uloží jako „Název (rok).ext“. " - "Rok je povinný (čtyřmístné číslo). " - "Modře = rok navržený z ČSFD (ověř ho); červeně = název už v poolu je.") + "Filmy se uloží jako „Název (rok) [tmdbid-N].ext“ — přesně jak je " + "páruje Jellyfin. Rok je povinný; TMDb ID zajistí správné spárování " + "(nepovinné). Modře = navrženo z TMDb (ověř); červeně = název už v poolu je.") info.setWordWrap(True) layout.addWidget(info) self.table = QTableWidget(0, 5) self.table.setHorizontalHeaderLabels( - ["Soubor", "Název", "Rok", "ČSFD odkaz", ""]) + ["Soubor", "Název", "Rok", "TMDb ID", ""]) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) - header.setSectionResizeMode(3, QHeaderView.Stretch) + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeToContents) layout.addWidget(self.table) @@ -85,15 +89,23 @@ class ImportMoviesDialog(QDialog): add_btn = QPushButton("➕ Přidat soubory…") add_btn.clicked.connect(self._add_files) add_row.addWidget(add_btn) - find_btn = QPushButton("🔎 Najít ČSFD odkazy") - find_btn.setToolTip("Vyhledá na ČSFD podle názvu a vyplní prázdné odkazy") - find_btn.clicked.connect(self._autofill_csfd) + find_btn = QPushButton("🔎 Dohledat z TMDb") + find_btn.setToolTip( + "Podle TMDb ID doplní název + rok; bez ID vyhledá podle názvu souboru.") + find_btn.clicked.connect(self._autofill_tmdb) add_row.addWidget(find_btn) add_row.addStretch(1) layout.addLayout(add_row) + opts_row = QHBoxLayout() + opts_row.addWidget(QLabel("Knihovna:")) + self.library_combo = QComboBox() + self.library_combo.addItems(self._libraries) + opts_row.addWidget(self.library_combo) self.move_check = QCheckBox("Přesunout soubory do poolu (jinak zkopírovat)") - layout.addWidget(self.move_check) + opts_row.addWidget(self.move_check) + opts_row.addStretch(1) + layout.addLayout(opts_row) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) @@ -120,20 +132,26 @@ class ImportMoviesDialog(QDialog): QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), year_edit)) year_edit.textChanged.connect( lambda _t, e=title_edit: self._mark_collision(e)) - # Manual edits clear the "suggested" (blue) marker; ČSFD autofill re-adds it. + # Manual edits clear the "suggested" (blue) marker; TMDb autofill re-adds it. year_edit.textEdited.connect(lambda _t, e=year_edit: self._set_suggested(e, False)) - csfd_edit = QLineEdit() - csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...") + tmdb_edit = QLineEdit() + tmdb_edit.setPlaceholderText("TMDb ID") + tmdb_edit.setMaximumWidth(96) + tmdb_edit.setValidator( + QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), tmdb_edit)) + tmdb_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e)) + tmdb_edit.textEdited.connect(lambda _t, e=tmdb_edit: self._set_suggested(e, False)) + remove_btn = QPushButton("✕") remove_btn.setFixedWidth(28) remove_btn.setToolTip("Odebrat tento soubor z importu") remove_btn.clicked.connect(lambda _c, e=title_edit: self._remove_row(e)) self.table.setCellWidget(row, 1, title_edit) self.table.setCellWidget(row, 2, year_edit) - self.table.setCellWidget(row, 3, csfd_edit) + self.table.setCellWidget(row, 3, tmdb_edit) self.table.setCellWidget(row, 4, remove_btn) - self._rows.append((source, title_edit, year_edit, csfd_edit)) + self._rows.append((source, title_edit, year_edit, tmdb_edit)) self._mark_collision(title_edit) def _row_of(self, title_edit: QLineEdit) -> tuple[Path, QLineEdit, QLineEdit, QLineEdit] | None: @@ -142,22 +160,23 @@ class ImportMoviesDialog(QDialog): return entry return None - def _candidate_stem(self, title: str, year: str) -> str | None: - """The canonical 'Title (year)' stem, or None if it can't be built yet.""" + def _candidate_stem(self, title: str, year: str, tmdb_id: str) -> str | None: + """The canonical 'Title (year) [tmdbid]' stem, or None if incomplete.""" if not (title.strip() and is_valid_year(year)): return None try: - return canonical_pool_stem(title, year) + return canonical_pool_stem(title, year, tmdb_id or None) except ValueError: return None def _mark_collision(self, title_edit: QLineEdit) -> None: - """Red title when the resulting 'Title (year)' name already exists.""" + """Red title when the resulting pooled name already exists.""" entry = self._row_of(title_edit) if entry is None: return - _src, _title, year_edit, _csfd = entry - stem = self._candidate_stem(title_edit.text(), year_edit.text()) + _src, _title, year_edit, tmdb_edit = entry + stem = self._candidate_stem( + title_edit.text(), year_edit.text(), tmdb_edit.text()) clashes = stem is not None and stem.lower() in self.existing_names title_edit.setStyleSheet( "QLineEdit { color: #c0392b; font-weight: bold; }" if clashes else "") @@ -165,12 +184,10 @@ class ImportMoviesDialog(QDialog): "V poolu už existuje film „" + stem + "“ — při importu se tě zeptám, " "co s ním." if clashes else "") - def _set_suggested(self, year_edit: QLineEdit, suggested: bool) -> None: - """Blue tint marks a year proposed by ČSFD (not yet confirmed by the user).""" - year_edit.setStyleSheet( - "QLineEdit { color: #2980b9; }" if suggested else "") - year_edit.setToolTip( - "Rok navržený z ČSFD — ověř ho." if suggested else "") + def _set_suggested(self, edit: QLineEdit, suggested: bool) -> None: + """Blue tint marks a value proposed by TMDb (not yet confirmed).""" + edit.setStyleSheet("QLineEdit { color: #2980b9; }" if suggested else "") + edit.setToolTip("Navrženo z TMDb — ověř." if suggested else "") def _remove_row(self, title_edit: QLineEdit) -> None: """Drop a single file from the import list without restarting the dialog.""" @@ -185,50 +202,56 @@ class ImportMoviesDialog(QDialog): for path in paths: self._append_row(Path(path)) - def _autofill_csfd(self) -> None: - """Fill empty ČSFD links (and empty years) from a ČSFD search per row. + def _autofill_tmdb(self) -> None: + """Fill Title/Year/TMDb-id from TMDb per row (blue = suggested, verify). - Uses the top search hit: its URL fills an empty link, its year fills an - empty year field and is tinted blue (a suggestion to verify). + A row that already has a TMDb id is resolved by id; otherwise the + filename is searched (narrowed by the year if present) and the top hit's + id/title/year are filled in. """ - import requests - from src.core import csfd + import re + from src.core import tmdb - targets = [(t, y, c) for _, t, y, c in self._rows if not c.text().strip()] - if not targets: - QMessageBox.information(self, "ČSFD", "Všechny řádky už mají odkaz.") + if not tmdb.api_available(): + QMessageBox.warning( + self, "TMDb", + "Není nastaven TMDB_API_KEY (.env). Doplň ID ručně, nebo přidej klíč.") return found = 0 QApplication.setOverrideCursor(Qt.WaitCursor) try: - with requests.Session() as session: - for title_edit, year_edit, csfd_edit in targets: - query = csfd.clean_filename_to_query(title_edit.text()) - try: - hits = csfd.search_movies(query, limit=1, session=session) - except Exception: # noqa: BLE001 — network/parse failure for one row - hits = [] - if not hits: - continue - hit = hits[0] - if hit.url: - csfd_edit.setText(hit.url) - found += 1 - if hit.year and not year_edit.text().strip(): - year_edit.setText(str(hit.year)) - self._set_suggested(year_edit, True) + for _src, title_edit, year_edit, tmdb_edit in self._rows: + try: + if tmdb_edit.text().strip().isdigit(): + hit = tmdb.lookup_movie(int(tmdb_edit.text().strip())) + else: + query = re.sub(r"[._]+", " ", title_edit.text()).strip() + year = year_edit.text().strip() or None + hits = tmdb.search_movie(query, year=year, limit=1) + hit = hits[0] if hits else None + except tmdb.TmdbError: + hit = None + if hit is None: + continue + found += 1 + if not tmdb_edit.text().strip(): + tmdb_edit.setText(str(hit.tmdb_id)) + self._set_suggested(tmdb_edit, True) + title_edit.setText(hit.title) + if hit.year: + year_edit.setText(hit.year) + self._set_suggested(year_edit, True) finally: QApplication.restoreOverrideCursor() QMessageBox.information( - self, "ČSFD", f"Vyplněno {found} z {len(targets)} hledaných odkazů." - ) + self, "TMDb", f"Doplněno {found} z {len(self._rows)} řádků.") def accept(self) -> None: # noqa: D401 — Qt override """Block the import until every row has a title and a four-digit year.""" bad_year: list[str] = [] - for source, title_edit, year_edit, _csfd in self._rows: + for source, title_edit, year_edit, _tmdb in self._rows: title = title_edit.text().strip() or source.stem if not is_valid_year(year_edit.text()): bad_year.append(title) @@ -246,16 +269,257 @@ class ImportMoviesDialog(QDialog): def move_files(self) -> bool: return self.move_check.isChecked() + @property + def selected_library(self) -> str: + return self.library_combo.currentText() + def entries(self) -> list[tuple[Path, str, str, str]]: - """Return (source, title, year, csfd_link) per row; title falls back to stem.""" + """Return (source, title, year, tmdb_id) per row; title falls back to stem.""" result: list[tuple[Path, str, str, str]] = [] - for source, title_edit, year_edit, csfd_edit in self._rows: + for source, title_edit, year_edit, tmdb_edit in self._rows: title = title_edit.text().strip() or source.stem result.append( - (source, title, year_edit.text().strip(), csfd_edit.text().strip())) + (source, title, year_edit.text().strip(), tmdb_edit.text().strip())) return result +class ImportEpisodesDialog(QDialog): + """Collect a series identity + episode files, name them Series SxxEyy.ext.""" + + def __init__(self, parent: QWidget, sources: List[Path], + libraries: list[str] | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("Importovat epizody seriálu") + self.setMinimumSize(720, 460) + self._libraries = libraries or ["Seriály"] + self._rows: list[tuple[Path, QTableWidgetItem]] = [] + + layout = QVBoxLayout(self) + + form = QHBoxLayout() + form.addWidget(QLabel("Seriál:")) + self.title_edit = QLineEdit() + self.title_edit.setPlaceholderText("Název seriálu") + form.addWidget(self.title_edit) + form.addWidget(QLabel("Rok:")) + self.year_edit = QLineEdit() + self.year_edit.setMaximumWidth(64) + self.year_edit.setPlaceholderText("rok") + self.year_edit.setValidator( + QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), self.year_edit)) + form.addWidget(self.year_edit) + form.addWidget(QLabel("TMDb ID:")) + self.tmdb_edit = QLineEdit() + self.tmdb_edit.setMaximumWidth(96) + self.tmdb_edit.setPlaceholderText("TV ID") + self.tmdb_edit.setValidator( + QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit)) + form.addWidget(self.tmdb_edit) + find_btn = QPushButton("🔎 Dohledat") + find_btn.clicked.connect(self._autofill_tmdb) + form.addWidget(find_btn) + layout.addLayout(form) + + lib_row = QHBoxLayout() + lib_row.addWidget(QLabel("Knihovna:")) + self.library_combo = QComboBox() + self.library_combo.addItems(self._libraries) + lib_row.addWidget(self.library_combo) + lib_row.addStretch(1) + layout.addLayout(lib_row) + + info = QLabel( + "Epizody se uloží jako „Název SxxEyy.ext“ do Season složek. " + "Číslo sezóny/epizody se čte z názvu souboru.") + info.setWordWrap(True) + layout.addWidget(info) + + self.table = QTableWidget(0, 3) + self.table.setHorizontalHeaderLabels(["Soubor", "SxxEyy", ""]) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + layout.addWidget(self.table) + + add_row = QHBoxLayout() + add_btn = QPushButton("➕ Přidat epizody…") + add_btn.clicked.connect(self._add_files) + add_row.addWidget(add_btn) + add_row.addStretch(1) + layout.addLayout(add_row) + + self.move_check = QCheckBox("Přesunout soubory do poolu (jinak zkopírovat)") + layout.addWidget(self.move_check) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + for source in sources: + self._append_row(source) + + def _append_row(self, source: Path) -> None: + row = self.table.rowCount() + self.table.insertRow(row) + name_item = QTableWidgetItem(source.name) + name_item.setFlags(Qt.ItemIsEnabled) + self.table.setItem(row, 0, name_item) + code = series.parse_episode(source.name) + code_item = QTableWidgetItem( + f"S{code[0]:02d}E{code[1]:02d}" if code else "— nerozpoznáno") + code_item.setFlags(Qt.ItemIsEnabled) + if not code: + code_item.setForeground(Qt.red) + self.table.setItem(row, 1, code_item) + remove_btn = QPushButton("✕") + remove_btn.setFixedWidth(28) + remove_btn.clicked.connect(lambda _c, p=source: self._remove_row(p)) + self.table.setCellWidget(row, 2, remove_btn) + self._rows.append((source, code_item)) + + def _remove_row(self, source: Path) -> None: + for index, (path, _item) in enumerate(self._rows): + if path is source: + self.table.removeRow(index) + self._rows.pop(index) + return + + def _add_files(self) -> None: + paths, _ = QFileDialog.getOpenFileNames(self, "Vyber epizody") + for path in paths: + self._append_row(Path(path)) + + def _autofill_tmdb(self) -> None: + from src.core import tmdb + if not tmdb.api_available(): + QMessageBox.warning( + self, "TMDb", "Není nastaven TMDB_API_KEY (.env).") + return + try: + if self.tmdb_edit.text().strip().isdigit(): + hit = tmdb.lookup_tv(int(self.tmdb_edit.text().strip())) + else: + hits = tmdb.search_tv( + self.title_edit.text(), + year=self.year_edit.text().strip() or None, limit=1) + hit = hits[0] if hits else None + except tmdb.TmdbError as exc: + QMessageBox.warning(self, "TMDb", str(exc)) + return + if hit is None: + QMessageBox.information(self, "TMDb", "Nic nenalezeno.") + return + if not self.tmdb_edit.text().strip(): + self.tmdb_edit.setText(str(hit.tmdb_id)) + self.title_edit.setText(hit.title) + if hit.year: + self.year_edit.setText(hit.year) + + def accept(self) -> None: # noqa: D401 — Qt override + if not self.title_edit.text().strip(): + QMessageBox.warning(self, "Seriál", "Zadej název seriálu.") + return + super().accept() + + @property + def title(self) -> str: + return self.title_edit.text().strip() + + @property + def year(self) -> str: + return self.year_edit.text().strip() + + @property + def tmdb_id(self) -> str: + return self.tmdb_edit.text().strip() + + @property + def library(self) -> str: + return self.library_combo.currentText() + + @property + def move_files(self) -> bool: + return self.move_check.isChecked() + + def sources(self) -> list[Path]: + return [path for path, _ in self._rows] + + +class SeriesOverviewDialog(QDialog): + """Read-only Series → Season → Episode tree with a per-series rename action.""" + + def __init__(self, parent: QWidget, filehandler: FileManager) -> None: + super().__init__(parent) + self.filehandler = filehandler + self.setWindowTitle("Přehled seriálů") + self.setMinimumSize(720, 520) + self._series: list = [] + + layout = QVBoxLayout(self) + self.tree = QTreeWidget() + self.tree.setHeaderLabels(["Seriál / sezóna / epizoda", "Stav"]) + self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + layout.addWidget(self.tree) + + btns = QHBoxLayout() + rename_btn = QPushButton("Přejmenovat epizody vybraného seriálu") + rename_btn.clicked.connect(self._rename_selected) + btns.addWidget(rename_btn) + btns.addStretch(1) + close_btn = QPushButton("Zavřít") + close_btn.clicked.connect(self.accept) + btns.addWidget(close_btn) + layout.addLayout(btns) + + self._reload() + + def _reload(self) -> None: + self.tree.clear() + self._series = self.filehandler.load_series() + for idx, ser in enumerate(self._series): + label = ser.title + if ser.year: + label += f" ({ser.year})" + if ser.tmdb_id: + label += f" [tmdb-{ser.tmdb_id}]" + top = QTreeWidgetItem([label, f"{len(ser.episodes)} epizod"]) + top.setData(0, Qt.UserRole, idx) + self.tree.addTopLevelItem(top) + for season, eps in ser.seasons().items(): + sname = series.season_folder_name(season) if season >= 0 else "? bez sezóny" + snode = QTreeWidgetItem([sname, f"{len(eps)} dílů"]) + top.addChild(snode) + for ep in eps: + ok = series.is_canonical_episode(ep.path, ser.title) + enode = QTreeWidgetItem( + [f"{ep.code} {ep.path.name}", "OK" if ok else "přejmenovat"]) + if not ok: + enode.setForeground(1, Qt.red) + snode.addChild(enode) + top.setExpanded(True) + + def _selected_series_index(self): + item = self.tree.currentItem() + while item is not None and item.parent() is not None: + item = item.parent() + return item.data(0, Qt.UserRole) if item is not None else None + + def _rename_selected(self) -> None: + idx = self._selected_series_index() + if idx is None: + QMessageBox.information(self, "Seriály", "Vyber seriál.") + return + result = self.filehandler.rename_episodes_to_canonical(self._series[idx]) + self._reload() + QMessageBox.information( + self, "Přejmenování epizod", + f"Přejmenováno: {len(result['renamed'])}, " + f"přeskočeno: {len(result['skipped'])}, kolize: {len(result['conflicts'])}.") + + class AssignTagsDialog(QDialog): """Tri-state bulk tag assignment over the selected movies. @@ -359,23 +623,20 @@ class AssignTagsDialog(QDialog): class TagSchemaDialog(QDialog): - """Editor for the tag schema: categories, their ČSFD source and folder rules. + """Editor for the tag schema: categories, their NFO source and folder rules. - Each row defines a tag category, optionally which ČSFD field feeds it (with a - value transform), and how it maps into the Filmotéka tree (a grouping folder, + Each row defines a tag category, optionally which NFO field feeds it (with a + value transform), and how it maps into the miniDLNA tree (a grouping folder, the output root, or no folders at all). """ - # (label, csfd_field) — the CSFDMovie attributes a category can draw from + # (label, source field) — the NFO fields a category can draw from FIELD_CHOICES = [ ("(žádné — uživatelská)", None), ("Žánry (genres)", "genres"), ("Rok (year)", "year"), ("Země (countries)", "countries"), ("Hodnocení (rating)", "rating"), - ("Režie (directors)", "directors"), - ("Herci (actors)", "actors"), - ("Délka (duration)", "duration"), ] TRANSFORM_CHOICES = [ ("(žádný)", None), @@ -390,7 +651,7 @@ class TagSchemaDialog(QDialog): layout = QVBoxLayout(self) layout.addWidget(QLabel( - "Kategorie tagů, jejich zdroj z ČSFD a jak se z nich tvoří složky " + "Kategorie tagů, jejich zdroj z NFO a jak se z nich tvoří složky " "Filmotéky.\nSložka: prázdné = v kořeni výstupu, „Dle…\" = seskupit, " "vypnuté „Tvořit složky\" = jen filtr bez složek.\n" "Šablona názvu: jak pojmenovat hardlink jen v této kategorii, např. " @@ -398,7 +659,7 @@ class TagSchemaDialog(QDialog): self.table = QTableWidget(0, 6) self.table.setHorizontalHeaderLabels( - ["Kategorie", "ČSFD pole", "Transform", "Tvořit složky", "Složka", + ["Kategorie", "Zdroj (NFO)", "Transform", "Tvořit složky", "Složka", "Šablona názvu"]) hdr = self.table.horizontalHeader() hdr.setSectionResizeMode(0, QHeaderView.Stretch) @@ -505,10 +766,10 @@ class StatsDialog(QDialog): summary = ( f"<b>Filmů v poolu:</b> {stats['count']}<br>" f"<b>Celková velikost:</b> {size_text}<br>" - f"<b>Průměrné hodnocení ČSFD:</b> " + f"<b>Průměrné hodnocení:</b> " f"{str(avg) + ' %' if avg is not None else '—'}<br>" - f"<b>S ČSFD odkazem:</b> {stats['with_csfd']}  ·  " - f"<b>bez:</b> {stats['without_csfd']}<br>" + f"<b>S NFO metadaty:</b> {stats['with_nfo']}  ·  " + f"<b>bez:</b> {stats['without_nfo']}<br>" f"<b>Bez štítků:</b> {stats['untagged']}" ) layout.addWidget(QLabel(summary)) @@ -541,7 +802,7 @@ class QtApp(QMainWindow): self._active_filter: set[str] = set() self.filehandler.on_files_changed = lambda _=None: self.refresh_table() - self.setWindowTitle(f"{APP_TITLE} — Filmotéka") + self.setWindowTitle(f"{APP_TITLE} — Jellyfin knihovna") geometry = self.filehandler.global_config.get("window_geometry", "1200x800") try: w, h = (int(x) for x in geometry.lower().split("x")) @@ -569,6 +830,7 @@ class QtApp(QMainWindow): pool_menu = bar.addMenu("&Pool") self._add_action(pool_menu, "Nastavit pool…", self.set_pool, "Ctrl+P") self._add_action(pool_menu, "Nastavit Filmotéku (výstup)…", self.set_filmoteka) + self._add_action(pool_menu, "Knihovny (rozdělení)…", self.edit_libraries) pool_menu.addSeparator() self._add_action(pool_menu, "Znovu načíst pool", self.reload_pool, "F5") pool_menu.addSeparator() @@ -577,20 +839,31 @@ class QtApp(QMainWindow): movie_menu = bar.addMenu("&Filmy") self._add_action(movie_menu, "Importovat filmy…", self.import_movie, "Ctrl+I") self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2") + self._add_action(movie_menu, "Přesunout do knihovny…", self.move_to_library) self._add_action( - movie_menu, "Přejmenovat dle ČSFD (rok)…", self.rename_to_csfd_naming) + movie_menu, "Přejmenovat dle metadat (rok)…", self.rename_to_canonical_naming) self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T") self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D") self._add_action(movie_menu, "Nastavit atribut…", self.set_attribute) - self._add_action(movie_menu, "Upravit ČSFD odkaz…", self.edit_csfd) - self._add_action(movie_menu, "Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) + self._add_action(movie_menu, "Načíst žánry z NFO", self.refresh_from_nfo) movie_menu.addSeparator() self._add_action(movie_menu, "Odebrat z poolu…", self.remove_movies, "Del") - film_menu = bar.addMenu("Filmo&téka") - self._add_action(film_menu, "Vygenerovat Filmotéku", self.generate_filmoteka, "Ctrl+G") + series_menu = bar.addMenu("&Seriály") + self._add_action(series_menu, "Importovat epizody…", self.import_episodes) + self._add_action(series_menu, "Přehled seriálů…", self.show_series) + + film_menu = bar.addMenu("&miniDLNA") + self._add_action( + film_menu, "Vygenerovat miniDLNA strom", self.generate_filmoteka, "Ctrl+G") self._add_action(film_menu, "Copy-as-is složky…", self.edit_copyasis_folders) + jellyfin_menu = bar.addMenu("&Jellyfin") + self._add_action(jellyfin_menu, "Skenovat knihovnu", self.jellyfin_scan) + self._add_action(jellyfin_menu, "Ověřit proti poolu…", self.jellyfin_verify) + self._add_action( + jellyfin_menu, "Zkontrolovat připojení", self.jellyfin_check_connection) + settings_menu = bar.addMenu("&Nastavení") self._add_action(settings_menu, "Tag schéma…", self.edit_tag_schema) @@ -628,6 +901,10 @@ class QtApp(QMainWindow): main_layout.setContentsMargins(4, 4, 4, 4) search_row = QHBoxLayout() + search_row.addWidget(QLabel("Knihovna:")) + self.library_filter = QComboBox() + self.library_filter.currentIndexChanged.connect(self.refresh_table) + search_row.addWidget(self.library_filter) search_row.addWidget(QLabel("🔍")) self.search_edit = QLineEdit() self.search_edit.setPlaceholderText("Hledat film…") @@ -638,8 +915,8 @@ class QtApp(QMainWindow): search_row.addWidget(import_btn) main_layout.addLayout(search_row) - self.table = QTableWidget(0, 3) - self.table.setHorizontalHeaderLabels(["Název", "Štítky", "Velikost"]) + self.table = QTableWidget(0, 4) + self.table.setHorizontalHeaderLabels(["Název", "Knihovna", "Štítky", "Velikost"]) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) @@ -649,8 +926,10 @@ class QtApp(QMainWindow): self.table.itemSelectionChanged.connect(self._update_selection_status) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.Stretch) # Název - header.setSectionResizeMode(1, QHeaderView.Stretch) # Štítky + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Knihovna + header.setSectionResizeMode(2, QHeaderView.Stretch) # Štítky main_layout.addWidget(self.table) + self._refresh_library_filter() splitter.addWidget(main) splitter.setStretchFactor(0, 0) @@ -660,13 +939,26 @@ class QtApp(QMainWindow): def _build_statusbar(self) -> None: self.status = self.statusBar() - # Pool / Filmotéka paths as permanent widgets on the right so they stay - # visible even while the transient area shows counts or a selection. + # Pool / Filmotéka paths + Jellyfin state as permanent widgets on the + # right so they stay visible while the transient area shows counts. + self.jellyfin_label = QLabel() self.pool_label = QLabel() self.output_label = QLabel() + self.status.addPermanentWidget(self.jellyfin_label) self.status.addPermanentWidget(self.pool_label) self.status.addPermanentWidget(self.output_label) self._update_path_status() + self._update_jellyfin_status(None) + + def _update_jellyfin_status(self, version: str | None) -> None: + """Show Jellyfin connection state; None = not checked / not configured.""" + from src.core import jellyfin + if not jellyfin.api_available(): + self.jellyfin_label.setText("Jellyfin: ⚙ nenastaveno") + elif version is None: + self.jellyfin_label.setText("Jellyfin: ? nezkontrolováno") + else: + self.jellyfin_label.setText(f"Jellyfin: ● {version}") # ------------------------------------------------------------------ # Sidebar (tag filter) @@ -736,11 +1028,35 @@ class QtApp(QMainWindow): # Movie table # ------------------------------------------------------------------ + def _refresh_library_filter(self) -> None: + """Repopulate the library filter combo, keeping the current choice.""" + if not hasattr(self, "library_filter"): + return + current = self.library_filter.currentText() + self.library_filter.blockSignals(True) + self.library_filter.clear() + self.library_filter.addItem("Vše") + for name in self.filehandler.movie_libraries: + self.library_filter.addItem(name) + idx = self.library_filter.findText(current) + self.library_filter.setCurrentIndex(idx if idx >= 0 else 0) + self.library_filter.blockSignals(False) + + def _selected_library_filter(self) -> str | None: + """The library the table is filtered to, or None for 'Vše'.""" + if not hasattr(self, "library_filter"): + return None + text = self.library_filter.currentText() + return None if text in ("", "Vše") else text + def refresh_table(self, *_args) -> None: # Tag filter (AND) drives both the table and the sidebar counts; the # search box further narrows only the table. tag_filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags()) shown = tag_filtered + library = self._selected_library_filter() + if library: + shown = [f for f in shown if self.filehandler.library_of(f) == library] search = self.search_edit.text().lower() if hasattr(self, "search_edit") else "" if search: shown = [f for f in shown if search in (f.title or f.filename).lower()] @@ -751,12 +1067,13 @@ class QtApp(QMainWindow): for row, f in enumerate(shown): self.file_rows[row] = f name = f.title or f.filename + lib = self.filehandler.library_of(f) or "—" tags = ", ".join(t.name for t in f.tags) try: size = self._format_size(f.file_path.stat().st_size) except OSError: size = "?" - for col, value in enumerate([name, tags, size]): + for col, value in enumerate([name, lib, tags, size]): self.table.setItem(row, col, QTableWidgetItem(value)) self.refresh_sidebar(tag_filtered) @@ -779,11 +1096,11 @@ class QtApp(QMainWindow): menu = QMenu(self) menu.addAction("Otevřít", self.open_movies) menu.addAction("Přejmenovat…", self.rename_movie) + menu.addAction("Přesunout do knihovny…", self.move_to_library) menu.addAction("Přiřadit štítky…", self.assign_tags) menu.addAction("Nastavit datum…", self.set_date) menu.addAction("Nastavit atribut…", self.set_attribute) - menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd) - menu.addAction("Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) + menu.addAction("Načíst žánry z NFO", self.refresh_from_nfo) menu.addSeparator() menu.addAction("Odebrat z poolu…", self.remove_movies) menu.exec(self.table.viewport().mapToGlobal(pos)) @@ -818,6 +1135,7 @@ class QtApp(QMainWindow): return self.filehandler.set_pool_dir(Path(folder)) self.filehandler.load_pool_movies() + self._refresh_library_filter() self.refresh_table() self._update_path_status() @@ -844,20 +1162,22 @@ class QtApp(QMainWindow): return sources = [Path(p) for p in paths] existing_names = {Path(f.filename).stem for f in self.filehandler.filelist} - dialog = ImportMoviesDialog(self, sources, existing_names) + dialog = ImportMoviesDialog( + self, sources, existing_names, self.filehandler.movie_libraries) if dialog.exec() != QDialog.Accepted: return entries = dialog.entries() if not entries: return move = dialog.move_files + library = dialog.selected_library - # Rows whose canonical "Title (year)" name already exists in the pool + # Rows whose canonical "Title (year) [tmdbid]" name already exists colliding = [ - canonical_pool_stem(t, y) - for s, t, y, _ in entries + canonical_pool_stem(t, y, tid or None) + for s, t, y, tid in entries if is_valid_year(y) - and self.filehandler.pooled_with_stem(canonical_pool_stem(t, y)) + and self.filehandler.pooled_with_stem(canonical_pool_stem(t, y, tid or None)) ] on_conflict = "suffix" if colliding: @@ -868,11 +1188,11 @@ class QtApp(QMainWindow): imported: list[File] = [] skipped = 0 errors: list[str] = [] - for source, title, year, csfd_link in entries: + for source, title, year, tmdb_id in entries: try: movie = self.filehandler.import_movie( - source, title, csfd_link or None, move=move, - on_conflict=on_conflict, year=year or None) + source, title, move=move, on_conflict=on_conflict, + year=year or None, tmdb_id=tmdb_id or None, library=library) if movie is None: skipped += 1 else: @@ -880,22 +1200,10 @@ class QtApp(QMainWindow): except Exception as exc: # noqa: BLE001 — surface per-file import failures errors.append(f"{source.name}: {exc}") - # Enrich the freshly imported movies that carry a ČSFD link - with_links = [m for m in imported if m.csfd_link] - tags_total = 0 - if with_links: - self.status.showMessage(f"Načítám z ČSFD ({len(with_links)})…") - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - _, tags_total, csfd_errors = self._fetch_csfd_for(with_links) - finally: - QApplication.restoreOverrideCursor() - errors.extend(csfd_errors) - self.refresh_table() verb = "Přesunuto" if move else "Zkopírováno" - summary = f"{verb} {len(imported)}/{len(entries)} filmů (+{tags_total} tagů z ČSFD)." + summary = f"{verb} {len(imported)}/{len(entries)} filmů." if errors: QMessageBox.warning( self, "Import dokončen s chybami", @@ -1017,24 +1325,24 @@ class QtApp(QMainWindow): self.refresh_table() self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000) - def rename_to_csfd_naming(self) -> None: - """Rename movies to the 'Title (rok).ext' convention from their metadata.""" + def rename_to_canonical_naming(self) -> None: + """Rename movies to the 'Title (rok) [tmdbid].ext' convention from metadata.""" files = self._selected_movies() or self.filehandler.filelist if not files: - QMessageBox.information(self, "Přejmenovat dle ČSFD", "Pool je prázdný.") + QMessageBox.information(self, "Přejmenovat dle metadat", "Pool je prázdný.") return scope = "vybrané filmy" if self._selected_movies() else "celý pool" answer = QMessageBox.question( - self, "Přejmenovat dle ČSFD (rok)", - f"Přejmenovat {scope} ({len(files)}) na „Název (rok).ext“ podle " - "staženého roku z ČSFD?\n\nFilmy bez známého roku se přeskočí.", + self, "Přejmenovat dle metadat (rok)", + f"Přejmenovat {scope} ({len(files)}) na „Název (rok) [tmdbid].ext“ " + "podle metadat?\n\nFilmy bez známého roku se přeskočí.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if answer != QMessageBox.Yes: return progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, len(files), self) - progress.setWindowTitle("Přejmenovat dle ČSFD (rok)") + progress.setWindowTitle("Přejmenovat dle metadat (rok)") progress.setWindowModality(Qt.WindowModal) progress.setMinimumDuration(0) @@ -1073,61 +1381,61 @@ class QtApp(QMainWindow): detail += [f" • {old} — {msg}" for old, msg in conflicts] box = QMessageBox(self) box.setIcon(QMessageBox.Warning if conflicts else QMessageBox.Information) - box.setWindowTitle("Přejmenování dle ČSFD") + box.setWindowTitle("Přejmenování dle metadat") box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.") box.setDetailedText("\n".join(detail)) box.exec() self.status.showMessage(summary, 5000) - def edit_csfd(self) -> None: - files = self._selected_movies() - if len(files) != 1: - QMessageBox.information(self, "ČSFD", "Vyberte právě jeden film.") + def import_episodes(self) -> None: + """Import episode files into a series (Series/Season NN/Series SxxEyy).""" + if not self.filehandler.pool_dir: + QMessageBox.warning(self, "Pool", "Nejprve nastavte pool.") return - f = files[0] - text, ok = QInputDialog.getText(self, "ČSFD odkaz", "URL:", text=f.csfd_link or "") - if not ok: + paths, _ = QFileDialog.getOpenFileNames(self, "Vyber epizody seriálu") + if not paths: return - f.set_csfd_link(text.strip() or None) - self.refresh_table() + dialog = ImportEpisodesDialog( + self, [Path(p) for p in paths], self.filehandler.series_libraries) + if dialog.exec() != QDialog.Accepted: + return + result = self.filehandler.import_episodes( + dialog.sources(), dialog.title, year=dialog.year or None, + tmdb_id=dialog.tmdb_id or None, library=dialog.library, + move=dialog.move_files) + msg = (f"Naimportováno {result['imported']} epizod. " + f"Nerozpoznáno (bez SxxEyy): {len(result['skipped_no_code'])}.") + if result["errors"]: + msg += "\n\nChyby:\n" + "\n".join(result["errors"][:5]) + QMessageBox.warning(self, "Import epizod s chybami", msg) + else: + QMessageBox.information(self, "Import epizod", msg) + self.status.showMessage(msg, 5000) - def apply_csfd_tags_for_selected(self) -> None: - files = [f for f in self._selected_movies() if f.csfd_link] + def show_series(self) -> None: + if not self.filehandler.pool_dir: + QMessageBox.information(self, "Seriály", "Nejprve nastavte pool.") + return + SeriesOverviewDialog(self, self.filehandler).exec() + + def refresh_from_nfo(self) -> None: + """(Re)load genre/year/rating tags from the NFO Jellyfin wrote on disk.""" + files = self._selected_movies() or self.filehandler.filelist if not files: - QMessageBox.information( - self, "ČSFD", "Vyberte filmy, které mají nastavený ČSFD odkaz." - ) + QMessageBox.information(self, "NFO", "Pool je prázdný.") return - - self.status.showMessage(f"Načítám z ČSFD ({len(files)})…") QApplication.setOverrideCursor(Qt.WaitCursor) try: - ok_count, tags_total, errors = self._fetch_csfd_for(files) + result = self.filehandler.refresh_tags_from_nfo(files) finally: QApplication.restoreOverrideCursor() - self.refresh_table() - msg = f"Načteno: {ok_count}/{len(files)} filmů, přidáno {tags_total} tagů." - if errors: - msg += "\n\nChyby:\n" + "\n".join(errors[:5]) - QMessageBox.warning(self, "ČSFD dokončeno s chybami", msg) - else: - QMessageBox.information(self, "ČSFD", msg) - - def _fetch_csfd_for(self, files) -> tuple[int, int, list[str]]: - """Apply CSFD tags to each file; return (ok_count, tags_added, errors).""" - ok_count = 0 - tags_total = 0 - errors: list[str] = [] - schema = self.filehandler.tag_schema - for f in files: - result = f.apply_csfd_tags(schema) - if result["success"]: - ok_count += 1 - tags_total += len(result["tags_added"]) - else: - errors.append(f"{f.title or f.filename}: {result['error']}") - return ok_count, tags_total, errors + msg = (f"Načteno z NFO: {result['updated']}/{len(files)} filmů. " + f"Bez NFO: {result['without_nfo']}.") + if result["without_nfo"] and not result["updated"]: + msg += "\n\nNejspíš Jellyfin ještě neuložil NFO — nech ho naskenovat." + QMessageBox.information(self, "Načíst žánry z NFO", msg) + self.status.showMessage(msg, 5000) def remove_movies(self) -> None: files = self._selected_movies() @@ -1153,12 +1461,15 @@ class QtApp(QMainWindow): def generate_filmoteka(self) -> None: out = self.filehandler.filmoteka_dir if not out: - QMessageBox.warning(self, "Filmotéka", "Nejprve nastavte výstupní složku Filmotéky.") + QMessageBox.warning( + self, "miniDLNA", "Nejprve nastavte výstupní složku (Pool → Nastavit Filmotéku).") return files = self.filehandler.filelist if not files: - QMessageBox.information(self, "Filmotéka", "Pool je prázdný.") + QMessageBox.information(self, "miniDLNA", "Pool je prázdný.") return + # Refresh genres/year from the NFO Jellyfin wrote, so the tree is current. + self.filehandler.refresh_tags_from_nfo(files) manager = HardlinkManager(out) reserved = (set(self.filehandler.copyasis_folders) | {TIPS_SUBFOLDER, RECENT_SUBFOLDER}) @@ -1217,6 +1528,145 @@ class QtApp(QMainWindow): "Copy-as-is složky: " + ", ".join(self.filehandler.copyasis_folders), 5000 ) + def edit_libraries(self) -> None: + """Edit the movie-library list (physical partitions) and create folders.""" + if not self.filehandler.pool_dir: + QMessageBox.information(self, "Knihovny", "Nejprve nastavte pool.") + return + current = ", ".join(self.filehandler.movie_libraries) + text, ok = QInputDialog.getText( + self, "Knihovny (rozdělení)", + "Názvy filmových knihoven — složky v poolu (oddělené čárkou):", + text=current) + if not ok: + return + self.filehandler.set_movie_libraries(text.split(",")) + pool = self.filehandler.pool_dir + for name in self.filehandler.movie_libraries: + (pool / name).mkdir(parents=True, exist_ok=True) + self._refresh_library_filter() + self.refresh_table() + self.status.showMessage( + "Knihovny: " + ", ".join(self.filehandler.movie_libraries), 5000) + + def move_to_library(self) -> None: + """Move the selected movies into another library.""" + files = self._selected_movies() + if not files: + QMessageBox.information(self, "Přesun", "Nejprve vyberte filmy.") + return + libraries = self.filehandler.movie_libraries + target, ok = QInputDialog.getItem( + self, "Přesunout do knihovny", "Cílová knihovna:", libraries, 0, False) + if not ok or not target: + return + moved, errors = 0, [] + for f in files: + try: + self.filehandler.move_to_library(f, target) + moved += 1 + except (ValueError, FileExistsError, OSError) as exc: + errors.append(f"{f.filename}: {exc}") + self._refresh_library_filter() + self.refresh_table() + summary = f"Přesunuto {moved}/{len(files)} do „{target}“." + if errors: + QMessageBox.warning(self, "Přesun s chybami", + summary + "\n\n" + "\n".join(errors[:5])) + self.status.showMessage(summary, 5000) + + # ------------------------------------------------------------------ + # Jellyfin + # ------------------------------------------------------------------ + + def _jellyfin_ready(self) -> bool: + from src.core import jellyfin + if not jellyfin.api_available(): + QMessageBox.warning( + self, "Jellyfin", + "Nastav JELLYFIN_URL a JELLYFIN_API_KEY v .env " + "(klíč vytvoříš v Jellyfin → Dashboard → API Keys).") + return False + return True + + def _jellyfin_call(self, fn): + """Run a Jellyfin call under a wait cursor; return (result, error_str).""" + from src.core import jellyfin + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + return fn(), None + except jellyfin.JellyfinError as exc: + return None, str(exc) + finally: + QApplication.restoreOverrideCursor() + + def jellyfin_check_connection(self) -> None: + from src.core import jellyfin + if not self._jellyfin_ready(): + return + version, err = self._jellyfin_call(lambda: jellyfin.server_version(timeout=8)) + if err: + self._update_jellyfin_status(None) + QMessageBox.warning(self, "Jellyfin", err) + return + self._update_jellyfin_status(version) + QMessageBox.information(self, "Jellyfin", f"Připojeno — verze {version}.") + + def jellyfin_scan(self) -> None: + from src.core import jellyfin + if not self._jellyfin_ready(): + return + version, err = self._jellyfin_call( + lambda: jellyfin.scan_all() or jellyfin.server_version()) + if err: + self._update_jellyfin_status(None) + QMessageBox.warning(self, "Jellyfin", err) + return + self._update_jellyfin_status(version) + self.status.showMessage("Jellyfin: sken knihovny spuštěn", 5000) + QMessageBox.information(self, "Jellyfin", "Sken knihovny spuštěn.") + + def jellyfin_verify(self) -> None: + from src.core import jellyfin + if not self._jellyfin_ready(): + return + data, err = self._jellyfin_call( + lambda: (jellyfin.fetch_movies(), jellyfin.server_version())) + if err: + self._update_jellyfin_status(None) + QMessageBox.warning(self, "Jellyfin", err) + return + movies, version = data + self._update_jellyfin_status(version) + result = self.filehandler.verify_against_jellyfin(movies) + + pool_only, jf_only = result["pool_only"], result["jellyfin_only"] + summary = ( + f"Spárováno: {result['matched']}/{result['pool_total']} | " + f"Jellyfin má: {result['jellyfin_total']}") + if not pool_only and not jf_only: + QMessageBox.information( + self, "Ověření proti Jellyfin", "✅ Vše sedí.\n\n" + summary) + return + + detail: list[str] = [] + if pool_only: + detail.append( + f"■ V poolu, Jellyfin nevidí ({len(pool_only)}) — přeskenuj / " + "oprav match v Jellyfinu:") + detail += [f" • {n}" for n in pool_only] + detail.append("") + if jf_only: + detail.append(f"■ V Jellyfinu, ne v poolu ({len(jf_only)}):") + detail += [f" • {n}" for n in jf_only] + + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning) + box.setWindowTitle("Ověření proti Jellyfin — nesrovnalosti") + box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.") + box.setDetailedText("\n".join(detail)) + box.exec() + def edit_tag_schema(self) -> None: dialog = TagSchemaDialog(self, self.filehandler.tag_schema) if dialog.exec() != QDialog.Accepted or dialog.result_schema is None: @@ -1225,7 +1675,7 @@ class QtApp(QMainWindow): self.refresh_table() # categories/sidebar may have changed self.status.showMessage( f"Tag schéma uloženo ({len(dialog.result_schema)} kategorií). " - "Pro nové tagy spusťte „Načíst tagy z ČSFD\" a přegenerujte Filmotéku.", + "Pro nové tagy spusťte „Načíst žánry z NFO\" a přegenerujte miniDLNA.", 8000, ) diff --git a/tests/test_csfd.py b/tests/test_csfd.py deleted file mode 100644 index 117af21..0000000 --- a/tests/test_csfd.py +++ /dev/null @@ -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() diff --git a/tests/test_file.py b/tests/test_file.py index bceb5a1..c842465 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -207,8 +207,8 @@ class TestFile: assert file_obj2.date == "2025-01-01" -class TestApplyCsfdTags: - """Tests for File.apply_csfd_tags tag assignment (CSFD fetch is mocked).""" +class TestApplyNfoTags: + """Tests for File.apply_nfo tag assignment (NFO parsed from disk).""" @pytest.fixture def tag_manager(self): @@ -222,22 +222,14 @@ class TestApplyCsfdTags: def movie_file(self, tmp_path, tag_manager, index): path = tmp_path / "Matrix.mkv" path.write_text("x") - f = File(path, tag_manager, index=index) - f.set_csfd_link("https://www.csfd.cz/film/9499-matrix/") - return f + return File(path, tag_manager, index=index) - def test_apply_csfd_tags_assigns_expected_categories(self, movie_file): - from unittest.mock import patch - from src.core.csfd import CSFDMovie + def test_apply_nfo_assigns_expected_categories(self, movie_file): + from src.core.nfo import NfoData - movie = CSFDMovie( - title="Matrix", url="u", year=1999, genres=["Akční", "Sci-Fi"], - directors=["Lana Wachowski", "Lilly Wachowski"], - actors=["Keanu Reeves", "Laurence Fishburne"], - rating=90, countries=["USA"], - ) - with patch("src.core.csfd.fetch_movie", return_value=movie): - result = movie_file.apply_csfd_tags() + nfo = NfoData(title="Matrix", year="1999", genres=["Akční", "Sci-Fi"], + countries=["USA"], rating=90) + result = movie_file.apply_nfo(nfo) assert result["success"] paths = {t.full_path for t in movie_file.tags} @@ -247,28 +239,14 @@ class TestApplyCsfdTags: assert "Země původu/USA" in paths assert "Hodnocení/90" in paths # exact value; folder grouping happens later - def test_apply_csfd_tags_does_not_tag_directors_or_actors(self, movie_file): - """Režie/herci se jen cachují, netvoří se z nich tagy (bylo by jich moc).""" - from unittest.mock import patch - from src.core.csfd import CSFDMovie - - movie = CSFDMovie( - title="Matrix", url="u", directors=["Lana Wachowski"], - actors=["Keanu Reeves", "Laurence Fishburne"], genres=["Drama"], - ) - with patch("src.core.csfd.fetch_movie", return_value=movie): - movie_file.apply_csfd_tags() - - paths = {t.full_path for t in movie_file.tags} - assert not any(p.startswith("Režie/") for p in paths) - assert not any(p.startswith("Herec/") for p in paths) - cached = movie_file.get_cached_movie() - assert cached.directors == ["Lana Wachowski"] - assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"] + def test_apply_nfo_none_is_noop(self, movie_file): + result = movie_file.apply_nfo(None) + assert not result["success"] + assert movie_file.tags == [] def test_name_context_fields(self, movie_file): movie_file.title = "Dr. No" - movie_file.csfd_cache = {"year": 1962, "rating": 75} + movie_file.meta = {"year": 1962, "rating": 75} ctx = movie_file.name_context() assert ctx["title"] == "Dr. No" assert ctx["year"] == "1962" # coerced to str for uniform template fields @@ -278,7 +256,7 @@ class TestApplyCsfdTags: assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}" def test_name_context_year_from_tag_when_no_cache(self, movie_file): - movie_file.csfd_cache = None + movie_file.meta = None movie_file.add_tag("Rok/1999") assert movie_file.name_context()["year"] == "1999" @@ -315,37 +293,30 @@ class TestApplyCsfdTags: assert "{collection_sort} - {title}{ext}".format(**ctx) == \ f"01 - Dr. No{ctx['ext']}" - def test_apply_csfd_tags_honors_custom_schema(self, movie_file): + def test_apply_nfo_honors_custom_schema(self, movie_file): """A schema without the rating entry produces no Hodnocení tags.""" - from unittest.mock import patch - from src.core.csfd import CSFDMovie + from src.core.nfo import NfoData schema = [{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""}] - movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"]) - with patch("src.core.csfd.fetch_movie", return_value=movie): - movie_file.apply_csfd_tags(schema) + movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema) paths = {t.full_path for t in movie_file.tags} assert "Žánr/Drama" in paths assert not any(p.startswith("Hodnocení/") for p in paths) - def test_apply_csfd_tags_preserves_user_tags_on_refetch(self, movie_file): - """Re-fetching regenerates only ČSFD tags; user-added tags survive.""" - from unittest.mock import patch - from src.core.csfd import CSFDMovie + def test_apply_nfo_preserves_user_tags_on_refresh(self, movie_file): + """Re-reading regenerates only auto (NFO) tags; user-added tags survive.""" + from src.core.nfo import NfoData movie_file.add_tag("Sbírka/Oblíbené") # user tag - first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"]) - with patch("src.core.csfd.fetch_movie", return_value=first): - movie_file.apply_csfd_tags() - second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"]) - with patch("src.core.csfd.fetch_movie", return_value=second): - movie_file.apply_csfd_tags() + movie_file.apply_nfo(NfoData(year="1999", genres=["Akční"])) + movie_file.apply_nfo(NfoData(year="2009", genres=["Drama"])) paths = {t.full_path for t in movie_file.tags} assert "Sbírka/Oblíbené" in paths # user tag kept - assert "Žánr/Drama" in paths # new ČSFD tag + assert "Žánr/Drama" in paths # new NFO tag assert "Rok/2009" in paths + assert "Žánr/Akční" not in paths # old NFO tag regenerated away assert "Žánr/Akční" not in paths # stale ČSFD tag dropped assert "Rok/1999" not in paths diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index 2f4036b..9ce5f6f 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -203,22 +203,21 @@ class TestPoolManagement: source = tmp_path / "raw.mkv" source.write_bytes(b"x" * 10) - movie = file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1") + movie = file_manager.import_movie(source, "Matrix") assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv" assert source.exists() # non-destructive copy assert movie.title == "Matrix" - assert movie.csfd_link == "https://csfd.cz/film/1" assert file_manager.index.get(movie.file_path) is not None def test_statistics_aggregates_pool(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"x" * 100) (tmp_path / "b.mkv").write_bytes(b"y" * 50) - a = file_manager.import_movie(tmp_path / "a.mkv", "A", "https://csfd/1") + a = file_manager.import_movie(tmp_path / "a.mkv", "A") a.add_tag("Žánr/Akční") a.add_tag("Žánr/Sci-Fi") - a.csfd_cache = {"rating": 90} + a.meta = {"rating": 90} a.save_metadata() b = file_manager.import_movie(tmp_path / "b.mkv", "B") b.add_tag("Žánr/Akční") @@ -226,7 +225,7 @@ class TestPoolManagement: s = file_manager.statistics() assert s["count"] == 2 assert s["total_size"] == 150 - assert s["with_csfd"] == 1 and s["without_csfd"] == 1 + assert s["with_nfo"] == 1 and s["without_nfo"] == 1 assert s["untagged"] == 0 assert s["avg_rating"] == 90 # Akční on both movies → count 2, sorted first @@ -319,7 +318,7 @@ class TestPoolManagement: old_path = first.file_path second = file_manager.import_movie( - tmp_path / "b.mkv", "Matrix", csfd_link="x", on_conflict="replace") + tmp_path / "b.mkv", "Matrix", on_conflict="replace") assert second.file_path.name == "Matrix.mkv" assert second.file_path == old_path # same name reused @@ -328,7 +327,6 @@ class TestPoolManagement: # index record reflects the new import (fresh metadata, old tags dropped) record = file_manager.index.get(second.file_path) assert record is not None - assert record["csfd_link"] == "x" assert record["tags"] == [] assert second.tags == [] @@ -419,6 +417,91 @@ class TestPoolManagement: assert movie.file_path.name == "Matrix (1999).mkv" assert movie.title == "Matrix" # clean title, no year + def test_import_movie_with_tmdb_id_names_for_jellyfin(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "raw.mkv").write_bytes(b"x") + + movie = file_manager.import_movie( + tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603) + + assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv" + assert movie.title == "Matrix" # clean title, no year/id + + def test_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_rename_to_canonical_preserves_tmdb_id(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "raw.mkv").write_bytes(b"x") + movie = file_manager.import_movie( + tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603) + + movie.title = "Matrix Reloaded" # metadata title changed + renamed = file_manager.rename_to_canonical(movie) + + assert renamed is not None + assert movie.file_path.name == "Matrix Reloaded (1999) [tmdbid-603].mkv" + def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") @@ -441,7 +524,7 @@ class TestPoolManagement: movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix") # plain assert movie.file_path.name == "Matrix.mkv" - movie.csfd_cache = {"year": 1999} + movie.meta = {"year": 1999} renamed = file_manager.rename_to_canonical(movie) assert renamed is not None @@ -461,7 +544,7 @@ class TestPoolManagement: for n in ("a.mkv", "b.mkv", "c.mkv"): (tmp_path / n).write_bytes(b"x") with_year = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") - with_year.csfd_cache = {"year": 1999} + with_year.meta = {"year": 1999} already = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=1972) _no_year = file_manager.import_movie(tmp_path / "c.mkv", "Mystery") @@ -476,7 +559,7 @@ class TestPoolManagement: file_manager.set_pool_dir(tmp_path / "pool") source = tmp_path / "raw.mkv" source.write_bytes(b"x" * 10) - file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1") + file_manager.import_movie(source, "Matrix") reloaded = FileManager(TagManager()) reloaded.set_pool_dir(tmp_path / "pool") diff --git a/tests/test_hardlink_manager.py b/tests/test_hardlink_manager.py index 8c5cbb5..e7efd8d 100644 --- a/tests/test_hardlink_manager.py +++ b/tests/test_hardlink_manager.py @@ -227,6 +227,21 @@ class TestHardlinkManager: assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists() + def test_link_name_strips_year_and_tmdbid(self, tmp_path, tag_manager): + """A pooled 'Title (YYYY) [tmdbid-N].ext' links as clean 'Title.ext'.""" + source = tmp_path / "source" + source.mkdir() + (source / "Matrix (1999) [tmdbid-603].mkv").write_text("x") + f = File(source / "Matrix (1999) [tmdbid-603].mkv", tag_manager) + f.tags.clear() + f.add_tag(Tag("žánr", "Sci-Fi")) + + output = tmp_path / "output" + manager = HardlinkManager(output) + manager.create_structure_for_files([f], category_roots={"žánr": ""}) + + assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped + def test_link_name_keeps_year_on_collision( self, tmp_path, tag_manager ): @@ -238,13 +253,13 @@ class TestHardlinkManager: f1 = File(source / "Solaris (1972).mkv", tag_manager) f1.tags.clear() f1.title = "Solaris" - f1.csfd_cache = {"year": 1972} + f1.meta = {"year": 1972} f1.add_tag(Tag("žánr", "Sci-Fi")) f1.add_tag(Tag("rok", "1972")) f2 = File(source / "Solaris (2002).mkv", tag_manager) f2.tags.clear() f2.title = "Solaris" - f2.csfd_cache = {"year": 2002} + f2.meta = {"year": 2002} f2.add_tag(Tag("žánr", "Sci-Fi")) f2.add_tag(Tag("rok", "2002")) @@ -323,7 +338,7 @@ class TestHardlinkManager: f = File(temp_source_dir / "file1.txt", tag_manager) f.tags.clear() f.title = "Dr. No" - f.csfd_cache = {"year": 1962} + f.meta = {"year": 1962} f.add_tag(Tag("Kolekce", "James Bond")) f.add_tag(Tag("žánr", "Akční")) manager = HardlinkManager(temp_output_dir) @@ -345,7 +360,7 @@ class TestHardlinkManager: f = File(temp_source_dir / "file1.txt", tag_manager) f.tags.clear() f.title = "Dr. No" - f.csfd_cache = {"year": 1962} + f.meta = {"year": 1962} f.add_tag(Tag("Kolekce", "James Bond")) roots = {"Kolekce": "Dle kolekce"} templates = {"Kolekce": "{year} - {title}{ext}"} diff --git a/tests/test_jellyfin.py b/tests/test_jellyfin.py new file mode 100644 index 0000000..b503596 --- /dev/null +++ b/tests/test_jellyfin.py @@ -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"] == [] diff --git a/tests/test_naming.py b/tests/test_naming.py index 6e3ea9c..7c05d44 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -6,6 +6,7 @@ from src.core.naming import ( is_canonical_pool_stem, is_valid_year, parse_pool_stem, + parse_tmdb_id, year_from_filename, ) @@ -46,6 +47,33 @@ def test_parse_pool_stem_non_canonical(): assert parse_pool_stem("Matrix 1999") is None +def test_canonical_pool_stem_with_tmdb_id(): + assert canonical_pool_stem("Matrix", 1999, 603) == "Matrix (1999) [tmdbid-603]" + # empty/None id → plain stem, no brackets + assert canonical_pool_stem("Matrix", 1999, None) == "Matrix (1999)" + assert canonical_pool_stem("Matrix", 1999, "") == "Matrix (1999)" + + +def test_canonical_pool_stem_rejects_bad_id(): + with pytest.raises(ValueError): + canonical_pool_stem("Matrix", 1999, "tt0133093") + + +def test_parse_pool_stem_strips_trailing_tmdb_id(): + # title stays clean; the id is dropped from parse_pool_stem + assert parse_pool_stem("Matrix (1999) [tmdbid-603]") == ("Matrix", 1999) + + +def test_parse_tmdb_id(): + assert parse_tmdb_id("Matrix (1999) [tmdbid-603]") == 603 + assert parse_tmdb_id("Matrix (1999)") is None + assert parse_tmdb_id("Matrix") is None + + +def test_is_canonical_accepts_tmdb_id_form(): + assert is_canonical_pool_stem("Matrix (1999) [tmdbid-603]") + + def test_is_canonical_pool_stem(): assert is_canonical_pool_stem("Matrix (1999)") assert not is_canonical_pool_stem("Matrix") diff --git a/tests/test_nfo.py b/tests/test_nfo.py new file mode 100644 index 0000000..19c538f --- /dev/null +++ b/tests/test_nfo.py @@ -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 + 1999 + Akční + Sci-Fi + USA + 8.7 + 603 + +""" + + +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( + "7.3", 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( + "9.1" + "", 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( + "603", 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("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} diff --git a/tests/test_pool_index.py b/tests/test_pool_index.py index 9b5a927..9397704 100644 --- a/tests/test_pool_index.py +++ b/tests/test_pool_index.py @@ -74,7 +74,7 @@ class TestFileWithIndex: tm = TagManager() f = File(movie, tm, index=index) f.title = "Matrix" - f.csfd_link = "https://csfd.cz/film/1" + f.meta = {"year": "1999", "rating": 87} f.add_tag("Žánr/Akční") f.set_date("1999-03-31") @@ -82,7 +82,7 @@ class TestFileWithIndex: index2 = PoolIndex(tmp_path) f2 = File(movie, TagManager(), index=index2) assert f2.title == "Matrix" - assert f2.csfd_link == "https://csfd.cz/film/1" + assert f2.meta == {"year": "1999", "rating": 87} assert f2.date == "1999-03-31" assert "Žánr/Akční" in {t.full_path for t in f2.tags} diff --git a/tests/test_series.py b/tests/test_series.py new file mode 100644 index 0000000..c22756b --- /dev/null +++ b/tests/test_series.py @@ -0,0 +1,114 @@ +"""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(): + assert episode_filename("Breaking Bad", 1, 2, ".mkv") == "Breaking Bad S01E02.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 / "Breaking Bad S01E02.mkv" + good.write_bytes(b"x") + bad = folder / "bb.s01e02.mkv" + bad.write_bytes(b"x") + assert is_canonical_episode(good, "Breaking Bad") + assert not is_canonical_episode(bad, "Breaking 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 / "Breaking Bad S01E01.mkv").exists() + assert (base / "Breaking Bad S01E02.mkv").exists() + + 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", "Breaking Bad S01E03.mkv")] + assert (folder / "Season 01" / "Breaking Bad S01E03.mkv").exists() diff --git a/tests/test_tmdb.py b/tests/test_tmdb.py new file mode 100644 index 0000000..2e21717 --- /dev/null +++ b/tests/test_tmdb.py @@ -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)