Compare commits

1 Commits
Author SHA1 Message Date
Honza 65b5b74c9f Rewrite Curator in Rust with an egui GUI 2026-10-17 11:28:17 +02:00
156 changed files with 24359 additions and 25795 deletions
+2 -19
View File
@@ -1,25 +1,9 @@
# Python
__pycache__/
*.pyc
*.pyo
# Virtual environment
.venv/
# Tooling caches
.mypy_cache/
.ruff_cache/
.pytest_cache/
# Rust build output
/target/
# Environment and secrets
.env
# Logs (loguru file sink)
logs/
# PyInstaller build artifacts (dist/ is committed — see DESIGN_DOCUMENT §13)
build/
# Curator runtime metadata (user/machine-specific, regenerated)
.Curator.!gtag
*.!gtag
@@ -35,4 +19,3 @@ AGENTS.md
CLAUDE.md
DESIGN_DOCUMENT.md
.claude/
+46 -1
View File
@@ -16,9 +16,54 @@ Each version entry uses these sections (include only those that apply):
- Follow semantic versioning: `MAJOR.MINOR.PATCH`
- Newest version goes at the top
- Always update this file before bumping the version in `pyproject.toml`
- Always update this file before bumping the version in `Cargo.toml`
- Document changes as they are made, not all at once at release time
## 2.0.0 — 2026-10-17
### Changed
- **Curator is now a Rust application.** The whole app — core and GUI — was
rewritten in Rust; the GUI is egui/eframe instead of PySide6. Behaviour and
on-disk formats are unchanged: the pool index (v2), tag schema, file naming,
versions/parts, series layout and miniDLNA output are the same, so an
existing pool opens as-is. Every menu action and dialog of the Python version
is there.
- Long operations (import, TMDb refresh, integrity scan, bulk renames, miniDLNA
generation) run in the background with a progress bar and a cancel button;
TMDb / ČSFD / Jellyfin lookups no longer freeze the window.
- **The global config moved** from `.Curator.!gtag` next to the script to the
platform config directory (`~/.config/curator/.Curator.!gtag` on Linux);
`CURATOR_CONFIG` overrides the location. An old `.Curator.!gtag` in the
working directory is read on first run, so the pool and output paths carry
over.
- **`tipy-dne` is a Rust binary** (built next to `curator`) replacing
`scripts/tipy_dne.py`; `scripts/tipy-dne.service` now runs it. Tips links are
named by the clean movie title, the same as the app's own tips folder.
- The status-bar paths are truncated (full path on hover) instead of
overlapping the status text.
### Fixed
- **"Tipy dne" from the systemd timer found no movies** since the pool index
moved to v2 — the script read index keys as file paths. `tipy-dne` reads the
media files of each movie record.
### Removed
- The Python sources, PyInstaller spec, `prebuild.py` and the committed
`dist/Curator` binary (a release build is `target/release/curator`).
- The one-off Python migration scripts for old ČSFD-era tag formats
(`migrate_tag_category`, `split_country_tags`, `strip_rating_bands`,
`strip_tag_categories`); they remain in the history of `devel`.
- The detailed per-function pages in `docs/modules/`; `cargo doc` generates the
API reference from the source.
### Dependencies
- Rust crates replace the Python stack: `eframe`/`egui`/`egui_extras` (GUI),
`rfd` (file dialogs), `reqwest` (HTTP, OS certificate store), `scraper` +
`ego-tree` (ČSFD), `sha2` (Anubis proof-of-work), `roxmltree` (NFO),
`serde`/`serde_json`, `regex`, `unicode-normalization`, `chrono`, `rand`,
`dirs`, `dotenvy`, `tracing`, `thiserror`/`anyhow`, `file-id`, `indexmap`;
dev: `tempfile`, `egui_kittest`.
## 1.14.0 — 2026-10-17
### Changed
Generated
+6242
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "curator"
version = "2.0.0"
edition = "2024"
description = "Jellyfin-first movie and series library manager"
authors = ["Jan Doubravský <jan.doubravsky@gmail.com>"]
default-run = "curator"
[dependencies]
anyhow = "1.0.104"
chrono = "0.4.45"
dirs = "7.0.0"
dotenvy = "0.15.7"
eframe = "0.37.0"
ego-tree = "0.11.0"
egui = "0.37.0"
egui_extras = "0.37.0"
file-id = "0.2.3"
indexmap = "2.14.2"
rand = "0.10.3"
regex = "1.13.1"
reqwest = { version = "0.13.5", features = ["blocking", "cookies", "json", "query"] }
rfd = "0.17.2"
roxmltree = "0.21.1"
scraper = "0.27.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.154", features = ["preserve_order"] }
sha2 = "0.11.1"
thiserror = "2.0.21"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
unicode-normalization = "0.1.26"
url = "2.5.8"
[dev-dependencies]
egui_kittest = { version = "0.37.0", features = ["eframe", "wgpu", "snapshot"] }
tempfile = "3.27.0"
-35
View File
@@ -1,35 +0,0 @@
# Imports
import contextlib
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
from src.ui.qt_app import run
def _use_system_certificates() -> None:
"""Make HTTPS (ČSFD fetching) use the OS certificate store.
Required behind corporate SSL inspection, where the proxy's root CA is in the
Windows trust store but not in certifi's bundle. Best-effort; ignored if the
optional `truststore` package is unavailable.
"""
with contextlib.suppress(Exception):
import truststore
truststore.inject_into_ssl()
class State:
def __init__(self) -> None:
self.tagmanager = TagManager()
self.filehandler = FileManager(self.tagmanager)
def main() -> None:
_use_system_certificates()
state = State()
run(state.filehandler, state.tagmanager)
if __name__ == "__main__":
main()
-38
View File
@@ -1,38 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['Curator.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Curator',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
-235
View File
@@ -1,235 +0,0 @@
# Python Development Guidelines
**Document Version:** v12
> **Note on Versioning:**
> - This document version is independent — reused across projects
> - **Project version** source of truth: `pyproject.toml`
> - Version propagates: `pyproject.toml` → `constants.py` → code
> - `CHANGELOG.md` uses project version from `pyproject.toml`
## Related Documents
- **README.md** — Project overview, tool descriptions, build instructions
- **AGENTS.md** — Rules for AI assistants
- **PROJECT.md** — Project goals and current state
- **CHANGELOG.md** — Version history
### Documentation Organization
All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root.
The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_PYTHON.md`, `PROJECT.md`, `CHANGELOG.md`.
---
## 1. Code Style
- **PEP8** with 120-character lines (Ruff)
- **4 spaces** indentation
- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants
- **Type hints** required on all functions
- **Import order**: stdlib → third-party → local
---
## 2. SOLID Principles
- **SRP** — One class = one responsibility
- **OCP** — Open for extension, closed for modification
- **LSP** — Subclasses substitutable for parents
- **ISP** — Small interfaces over large ones
- **DIP** — Depend on abstractions
---
## 3. Dependency Injection
Pass dependencies via constructor. Never instantiate dependencies inside a class.
---
## 4. Protocols Over Inheritance
Prefer `typing.Protocol` and composition over class inheritance.
---
## 5. Data Classes
Use `@dataclass` for internal data structures, `pydantic.BaseModel` for data that requires validation.
---
## 6. Logging and Console Output
### Logging — loguru
Use **loguru** for all internal logging. Never log secrets, passwords, tokens, or API keys.
#### Log sinks
| Sink | Level | Format |
|------|-------|--------|
| File `logs/{AppName}_{time}.log` | `DEBUG` | full (timestamp + level + message) |
| stdout | `INFO` | full |
File sink retains **max 10 log files** (`retention=10`). No rotation by size — each run creates a new file via `{time}` in the filename.
The `DEBUG` sink is only active when `constants.DEFAULT_DEBUG` is `True` (controlled by `ENV_DEBUG=true` in `.env`).
Additional sinks (e.g. GUI log panels) may be added per project as needed.
#### Log levels
| Level | When to use |
|-------|-------------|
| `DEBUG` | Per-item detail: individual file reads/writes, cache hits, per-row operations |
| `INFO` | User-visible milestones: file loaded, process started/finished, file saved |
| `WARNING` | Recoverable issues: fallback used, unexpected but non-fatal state |
| `ERROR` | Failures the user must know about: missing file, failed save, missing config — operation cannot continue |
### Console output — print()
`print()` is **allowed** for direct user-facing console communication: input prompts, progress lines, and result summaries. This applies to console tools that interact with the user through stdin/stdout.
`print()` is **not** a substitute for loguru. It must not be used for debugging or internal event tracking.
---
## 7. Environment and Secrets
- Secrets in `.env` file; controlled by `ENV_DEBUG=true/false`
- Load via `python-dotenv` and `os.getenv()`
- Never commit `.env`
---
## 8. Error Handling
Define specific exception types. Use a fail-fast approach — surface errors early rather than silently continuing.
---
## 9. Testing
- **pytest** only — no `unittest`, no `TestCase` classes, no `self.assert*`
- Arrange-Act-Assert pattern
- Test naming: `test_<action>_<context>`
---
## 10. Tooling
| Tool | Purpose |
|------|---------|
| **Ruff** | Formatting and linting |
| **mypy** | Static type checking |
| **pytest** | Testing |
Run before every commit:
```bash
uv run ruff check
uv run mypy
uv run pytest
```
---
## 11. uv
```bash
uv sync # Install all dependencies
uv add <pkg> # Add runtime dependency
uv add --dev <pkg> # Add dev dependency
uv remove <pkg> # Remove dependency
uv run <cmd> # Run command in virtualenv
uv python install <version> # Install a uv-managed Python
```
Never edit `pyproject.toml` directly to add or remove dependencies.
The interpreter is a uv-managed Python — no pyenv, no system Python.
### uv.lock
`uv.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible.
---
## 12. Project Structure
```
project/
├── entry_point.py # Entry point(s) — named by purpose or tool name
├── src/ # All application modules
├── tests/ # Tests
├── docs/ # Detailed documentation
├── .venv/ # Virtual environment (managed by uv)
└── pyproject.toml # Project config and dependencies
```
A project may have multiple entry points (e.g. `cli.py`, `gui.py`, or per-tool scripts).
---
## 13. Distribution and Deployment
When a project is distributed as a standalone executable (no Python required on target machine):
- Use **PyInstaller** to compile each entry point into a single `.exe`
- Each tool has its own `.spec` file in the project root
- **Console tools use `console=True`** in the `.spec` — they rely on `input()` and `print()` for user interaction. GUI applications turn the console off with `ENV_BUILD_CONSOLE=false`; that flag is independent of `ENV_DEBUG`
- Compiled executables are stored in `dist/` and **committed to the repository** — the repository serves as the distribution channel for internal teams
- `.gitignore` must **not** exclude `dist/` in projects that use this deployment model
### prebuild.py
`prebuild.py` runs before PyInstaller. It verifies that the active interpreter is the project `.venv`, prints the resolved version, and rewrites the `console=` line in the `.spec` to match `.env`.
| Variable | Default | Effect |
|----------|---------|--------|
| `ENV_BUILD_CONSOLE` | `true` | Value written into `console=` in the `.spec` |
| `ENV_BUILD_SPEC` | `<project folder>.spec` | Which `.spec` file to update |
`prebuild.py` touches one `.spec` per run — with several entry points, set `ENV_BUILD_SPEC` for each build.
Build commands:
```bash
uv run python prebuild.py
uv run pyinstaller ToolName.spec
```
> This section applies only to projects that produce standalone executables. Skip for libraries or web services.
---
## 14. Versioning
- Follow **semantic versioning**: `MAJOR.MINOR.PATCH`
- Version is defined in `pyproject.toml` under `[project]`
- Always ask before bumping the version — never increment automatically
- Update `CHANGELOG.md` before bumping the version
---
## 15. Documentation and Task Management
- Keep `PROJECT.md` and `CHANGELOG.md` up to date when making changes
- Document architectural changes in this file or in `docs/`
### Task notation
Tasks are written as single-line comments directly in code, using the **Todo Tree** tags defined in `AGENTS.md` (`TODO`, `FIXME`, `BUG`, `HACK`, `NOTE`). `PROJECT.md` carries only cross-cutting tasks that have no single place in the code.
```python
# TODO: extract this into a separate loader
# FIXME: crashes on an empty file
# BUG: rounding is off by one cent on negative amounts
# HACK: temporary workaround until the API adds paging
# NOTE: order matters here, the parser is stateful
```
No other task format is used — **no checkboxes, no numbered lists in documentation**.
If a tag already exists at a specific location in code, do not repeat it in `PROJECT.md`.
+223
View File
@@ -0,0 +1,223 @@
# Rust Application Development Guidelines
**Document Version:** v4
> **Note on Versioning:**
> - This document version is independent — reused across projects
> - **Project version** source of truth: `Cargo.toml` under `[package]`
> - `CHANGELOG.md` uses project version from `Cargo.toml`
## Related Documents
- **README.md** — Project overview, build instructions, usage
- **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_RUST.md`, `PROJECT.md`, `CHANGELOG.md`.
---
## 1. Code Style
- **Rust edition:** 2024 (requires Rust 1.85 or newer)
- **100-character lines** (rustfmt default `max_width`)
- Format with **rustfmt** — run `cargo fmt` before every commit
- Lint with **clippy** — run `cargo clippy -- -D warnings` before every commit
- **snake_case** functions/variables/modules, **PascalCase** types/traits, **SCREAMING_SNAKE_CASE** constants
- Keep functions short and focused — one responsibility per function
---
## 2. Cargo
```bash
cargo add <crate> # Add runtime dependency
cargo add --dev <crate> # Add dev dependency
cargo remove <crate> # Remove dependency
cargo build # Debug build
cargo build --release # Release build
cargo run # Run debug build
cargo run --release # Run release build
cargo test # Run all tests
cargo fmt # Format code
cargo clippy -- -D warnings # Lint (treat warnings as errors)
```
Never edit `Cargo.toml` dependency versions by hand — use `cargo add`.
---
## 3. Project Structure
```
project/
├── src/
│ ├── main.rs # Entry point
│ ├── lib.rs # Optional: shared library code
│ └── <module>/
│ └── mod.rs
├── tests/ # Integration tests
├── docs/ # Detailed documentation
├── Cargo.toml
└── Cargo.lock # Commit for applications, not for libraries
```
A project may have multiple binaries under `src/bin/`.
---
## 4. Error Handling
- Use **thiserror** for defining typed errors in library/domain code
- Use **anyhow** for application-level error propagation (`main`, CLI handlers)
- Never use `.unwrap()` in production code — use `?` or explicit handling
- `.expect("reason")` is acceptable only when the invariant is guaranteed and documented
---
## 5. Logging
Use **tracing** for structured logging.
```toml
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
```
```rust
use tracing::{debug, info, warn, error};
fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
}
```
Control log level via `RUST_LOG` environment variable:
```bash
RUST_LOG=debug cargo run
RUST_LOG=my_app=info,warn cargo run
```
#### Log levels
| Level | When to use |
|-------|-------------|
| `debug` | Per-item detail: internal state, cache hits, per-item operations |
| `info` | User-visible milestones: process started/finished, file saved |
| `warn` | Recoverable issues: fallback used, unexpected but non-fatal state |
| `error` | Failures the user must know about — operation cannot continue |
Never use `println!` for debugging or internal logging.
---
## 6. Environment and Secrets
- Store secrets in `.env` file
- Load via **dotenvy** crate and `std::env::var()`
- Never commit `.env`
```rust
dotenvy::dotenv().ok();
let api_key = std::env::var("API_KEY").expect("API_KEY must be set");
```
---
## 7. Testing
- Use Rust's built-in test framework — `#[test]` and `#[cfg(test)]`
- Unit tests live in the same file as the code, in a `mod tests` block
- Integration tests live in `tests/`
- Use `assert_eq!`, `assert!`, `assert_ne!` — no external test frameworks needed
- Test naming: `test_<action>_<context>`
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_input() {
assert_eq!(parse("42"), Ok(42));
}
}
```
---
## 8. Tooling
| Tool | Purpose |
|------|---------|
| **rustfmt** | Code formatting |
| **clippy** | Linting |
| **cargo test** | Testing |
| **cargo build --release** | Production build |
Run before every commit:
```bash
cargo fmt
cargo clippy -- -D warnings
cargo test
```
---
## 9. Distribution
For standalone executables:
```bash
cargo build --release
# Binary is at: target/release/<project-name>
```
For cross-compilation (e.g. Windows from Linux):
```bash
cargo install cross
cross build --release --target x86_64-pc-windows-gnu
```
`Cargo.lock` **is committed** for applications — it ensures reproducible builds.
---
## 10. Versioning
- Follow **semantic versioning**: `MAJOR.MINOR.PATCH`
- Version is defined in `Cargo.toml` under `[package]`
- Always ask before bumping the version — never increment automatically
- Update `CHANGELOG.md` before bumping the version
---
## 11. 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.
```rust
// TODO: extract this into a separate module
// FIXME: panics on an empty slice
// BUG: off-by-one when the buffer is exactly full
// HACK: temporary workaround until the crate 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`.
+18 -18
View File
@@ -39,27 +39,27 @@ Curator manages a personal movie library based on two folders:
## Current state
- Inherited from Tagger: `Tag`, `TagManager`, `File` (sidecar metadata),
`FileManager` (folder scan, filtering, ignore patterns), 3-level config,
`HardlinkManager` (create/sync/cleanup), pytest suite.
- Rename Tagger → Curator done across code, spec, config filenames
(`.Curator.!gtag` / `.Curator.!ftag`) and tests.
- **PySide6 GUI** (`src/ui/qt_app.py`) reframed around the Filmotéka workflow is
the entry point; the old tkinter `src/ui/gui.py` is retained for reference.
- **Pool + Filmotéka wired up:** global config holds `pool_dir` / `filmoteka_dir`;
`FileManager` creates `Filmy`/`Seriály`, imports movies (copy → `Title.ext`),
loads the pool, and the GUI generates the Filmotéka tree via `HardlinkManager`.
- `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index**
(`<pool>/.Curator.!index`, see `pool_index.py`); `File` writes there when an
index is injected. Without an index a `File` is an in-memory object only — the
old per-file `.!tag` sidecars and the per-folder `.!ftag` config have been
removed.
- **Rust rewrite (branch `rust`).** Curator is a Rust application: the core
(`src/core/`) is a library with the same behaviour and on-disk formats as the
Python version (pool index v2, tag schema, naming, global config keys), and
the GUI is egui/eframe (`src/ui/`). An existing pool opens unchanged. The
Python sources live on in the history of `devel` as the reference.
- `FileManager` is the service layer; `File` is a view on one media entry of an
identity-keyed movie record in `<pool>/.Curator.!index`; series are scanned
live from disk.
- The standalone `tipy-dne` binary replaces `scripts/tipy_dne.py` (which had
stopped finding movies after the index moved to v2). The one-off Python
migration scripts for old ČSFD tag formats were not ported.
- Global config moved from `.Curator.!gtag` next to the script to the platform
config directory (`CURATOR_CONFIG` overrides it); an old `.Curator.!gtag` in
the working directory is picked up on first run.
### GUI decision
The GUI was **reframed around the Filmotéka** (not kept as a generic tagger) and
**rewritten in PySide6**: Pool/Filmotéka setup, Import movie, tag-filter sidebar,
movie table, and one-click Filmotéka generation.
The GUI is **egui** (eframe): pure Rust, one static binary, straightforward
Windows builds. It mirrors the PySide6 layout — menu, tag-filter sidebar, movie
and series table, detail panel, status bar — and every dialog of the Python
version. Long operations run in background tasks with progress and cancel.
## Design decisions
+38 -35
View File
@@ -1,13 +1,13 @@
# Curator
Curator is a PySide6 desktop app for managing a **Jellyfin-first movie and series
library**. You import video files into a managed **pool**; Curator identifies them
through **TMDb**, names and lays them out the way Jellyfin expects, and can
generate a genre-split **miniDLNA / Filmotéka** tree of hardlinks for dumb DLNA
players — without ever duplicating the video data on disk.
Curator is a desktop app for managing a **Jellyfin-first movie and series
library**. You import video files into a managed **pool**; Curator identifies
them through **TMDb**, names and lays them out the way Jellyfin expects, and can
generate a genre-split **miniDLNA** tree of hardlinks for simple DLNA players —
without ever duplicating the video data on disk.
Metadata (posters, plots, `.nfo` files) is Jellyfin's job; Curator owns the files,
their names and the library structure.
Metadata (posters, plots, `.nfo` files) is Jellyfin's job; Curator owns the
files, their names and the library structure. Written in Rust with an egui GUI.
## Concepts
@@ -17,72 +17,75 @@ their names and the library structure.
| Kind | Holds | Layout |
|------|-------|--------|
| `movie` | movies | `Title (rok) [tmdbid].ext`, or a folder once a film has several versions |
| `series` | series | `Series (rok) [tmdbid]/Season NN/SxxEyy - Series.ext` |
| `movie` | movies | `Title (rok) [tmdbid-N].ext`, or a folder once a film has several versions |
| `series` | series | `Series (rok) [tmdbid-N]/Season NN/SxxEyy - Series.ext` |
| `mixed` | movies and series together | as above, side by side |
| `homevideo` | files without a TMDb id | mirrored as-is |
- **Pool index** — Curator's own metadata (TMDb id, ČSFD link, versions, tags,
library settings) lives in one `.Curator.!index` JSON file at the pool root.
- **miniDLNA output** — a generated tree of **hardlinks** into the pool, built
from the genres/years/ratings in Jellyfin's NFO files. It is disposable: delete
it and regenerate it at any time.
from genres / years / countries / ratings. It is disposable: delete it and
regenerate it at any time.
## Features
- Import movies and episodes with a mandatory **TMDb id**; Czech titles come from
TMDb, with **ČSFD** as a fallback and as a bridge for finding the right TMDb id.
- **Multiple versions** of one film (2D/3D, 4K, director's cut) and **multi-part**
films and episodes, named so Jellyfin groups them.
- Import movies and episodes with a **TMDb id**; Czech titles come from TMDb,
with **ČSFD** as a fallback and as a bridge for finding the right TMDb id.
- **Multiple versions** of one film (2D/3D, 4K, director's cut) and
**multi-part** films and episodes, named so Jellyfin groups them.
- Subtitle sidecars follow their video through renames.
- Duplicate check, video integrity check (FFmpeg), library statistics.
- Jellyfin integration: trigger a library scan, read what Jellyfin has.
- Duplicate check, video integrity check (FFmpeg), library statistics,
index/disk consistency check.
- Jellyfin integration: trigger a library scan, verify the pool against it.
- miniDLNA tree scoped per library, with "Tipy dne" and "Nově přidané" folders.
## Requirements
- [uv](https://docs.astral.sh/uv/) — manages Python 3.14 and all dependencies
- Rust 1.85+ (`rustup`), for building
- `ffmpeg` / `ffprobe` on `PATH` — optional, for the integrity check
## Configuration
Secrets and settings are read from a `.env` file in the project root (never
committed):
Secrets and settings are read from a `.env` file in the working directory
(never committed):
| Variable | Purpose |
|----------|---------|
| `TMDB_API_KEY` | TMDb API key — required for identifying movies and series |
| `TMDB_API_KEY` | TMDb API key — needed to identify movies and series |
| `TMDB_LANGUAGE` | TMDb language, default `cs-CZ` |
| `JELLYFIN_URL` | Jellyfin server URL — optional, for the Jellyfin menu |
| `JELLYFIN_API_KEY` | Jellyfin API key — optional |
| `ENV_DEBUG` | `true` enables debug mode |
| `ENV_DEBUG` | `true` enables debug logging and the `-DEV` title |
The pool and output folders are chosen in the app (*Pool* menu).
The pool and output folders are chosen in the app (*Pool* menu) and stored in
the global config (`~/.config/curator/.Curator.!gtag` on Linux, overridable with
`CURATOR_CONFIG`).
## Running
```bash
uv sync
uv run python Curator.py
cargo run --release
```
## Development
```bash
uv run pytest # tests
uv run ruff check # lint
uv run ruff format # format
uv run mypy # type checking
cargo fmt # format
cargo clippy -- -D warnings # lint
cargo test # unit tests + headless UI tests
cargo doc --open # API reference
```
Detailed documentation — architecture and a per-module reference — is in
[`docs/`](docs/README.md).
`cargo run --example screenshot -- out.png [--action edit]` renders the window
offscreen to a PNG. Architecture and module overview: [`docs/`](docs/README.md).
## Building a standalone executable
## Building
```bash
uv run python prebuild.py
uv run pyinstaller Curator.spec --noconfirm
cargo build --release
```
The result is a single self-contained `dist/Curator` executable.
The result is `target/release/curator`, plus `target/release/tipy-dne` — a
standalone "Tipy dne" refresher meant for a systemd timer (see
`scripts/tipy-dne.service` / `.timer`).
BIN
View File
Binary file not shown.
+35 -32
View File
@@ -1,41 +1,44 @@
# Curator documentation
Curator is a PySide6 desktop application that manages a Jellyfin-first movie and
series pool: it imports files, identifies them through TMDb, groups multiple
versions of a film, keeps series in Jellyfin's layout and generates a miniDLNA /
Filmotéka tree.
Curator is a Rust desktop application (egui) that manages a Jellyfin-first movie
and series pool: it imports files, identifies them through TMDb, groups multiple
versions of a film, keeps series in Jellyfin's layout and generates a miniDLNA
tree of hardlinks.
- [Architecture](architecture.md) — layers, on-disk layout, the pool index and the
rules about versions, series and subtitles. Read this first.
- [Module reference](#module-reference) — one document per source module, with a
description of the module and of every class and function.
- **API reference** — every public type and function is documented in the source;
`cargo doc --open` renders it.
## Module reference
## Module overview
| Source | Purpose |
|--------|---------|
| [`Curator.py`](modules/entry-point.md) | Application entry point. |
| [`src/_version.py`](modules/_version.md) | Auto-generated — do not edit manually. |
| [`src/constants.py`](modules/constants.md) | Application-level constants for Curator (build/runtime metadata). |
| [`src/core/config.py`](modules/core.config.md) | Configuration for Curator. |
| [`src/core/csfd.py`](modules/core.csfd.md) | ČSFD.cz scraper — a *title bridge* to TMDb. |
| [`src/core/file.py`](modules/core.file.md) | The `File` class: one playable movie file as the UI sees it. |
| [`src/core/file_manager.py`](modules/core.file_manager.md) | `FileManager`: the application's service layer. |
| [`src/core/hardlink_manager.py`](modules/core.hardlink_manager.md) | Hardlink Manager for Curator |
| [`src/core/integrity.py`](modules/core.integrity.md) | Video integrity checking via FFmpeg. |
| [`src/core/jellyfin.py`](modules/core.jellyfin.md) | Jellyfin server API client — thin, hybrid integration. |
| [`src/core/legacy_meta.py`](modules/core.legacy_meta.md) | Readers for Curator's *pre-index* metadata layouts. |
| [`src/core/list_manager.py`](modules/core.list_manager.md) | Sorting of file lists by name or by date added. |
| [`src/core/migrate.py`](modules/core.migrate.md) | One-off migration of an existing pool to the Jellyfin naming convention. |
| [`src/core/movie.py`](modules/core.movie.md) | The movie object — metadata first, files second. |
| [`src/core/naming.py`](modules/core.naming.md) | Pool file naming convention. |
| [`src/core/nfo.py`](modules/core.nfo.md) | NFO reader — Curator's metadata source after ČSFD. |
| [`src/core/pool_index.py`](modules/core.pool_index.md) | Unified metadata index for the Curator pool. |
| [`src/core/series.py`](modules/core.series.md) | Series naming + scanning for the Jellyfin TV layout. |
| [`src/core/subtitles.py`](modules/core.subtitles.md) | External subtitle sidecars — attach, discover, keep in sync on rename. |
| [`src/core/tag.py`](modules/core.tag.md) | The `Tag` value object: a `(category, name)` pair identified by its `full_path` (`Category/Name`). |
| [`src/core/tag_manager.py`](modules/core.tag_manager.md) | `TagManager`: the registry of all tag categories and their tags. |
| [`src/core/tmdb.py`](modules/core.tmdb.md) | TMDb (The Movie Database) client — Curator's identity source. |
| [`src/core/transforms.py`](modules/core.transforms.md) | Tag-schema value transforms for miniDLNA folder names. |
| [`src/core/utils.py`](modules/core.utils.md) | Small filesystem helpers shared across the core modules. |
| [`src/ui/qt_app.py`](modules/ui.qt_app.md) | PySide6 GUI for Curator — reframed around the Filmotéka (movie-library) workflow. |
| [`src/main.rs`](../src/main.rs) | Entry point: `.env`, logging, opens the window. |
| [`src/bin/tipy-dne.rs`](../src/bin/tipy-dne.rs) | Standalone "Tipy dne" generator for a systemd timer. |
| [`src/constants.rs`](../src/constants.rs) | App name, version (from `Cargo.toml`), debug flag, window title. |
| [`src/core/config.rs`](../src/core/config.rs) | Global config (window, pool and output paths) and the default tag schema. |
| [`src/core/error.rs`](../src/core/error.rs) | `CoreError`, the error type of every core operation. |
| [`src/core/paths.rs`](../src/core/paths.rs) | `pathlib`-style helpers: suffix/stem, POSIX-relative paths, recursive walk, moves. |
| [`src/core/naming.rs`](../src/core/naming.rs) | The `Title (YYYY) [tmdbid-N].3D.FMT` naming convention: build, parse, guess from filenames. |
| [`src/core/movie.rs`](../src/core/movie.rs) | The movie record (`Movie`, `MediaFile`), identity keys, version/part labels. |
| [`src/core/file.rs`](../src/core/file.rs) | `File`: one pooled video, a view on one media entry of a movie record. |
| [`src/core/pool_index.rs`](../src/core/pool_index.rs) | `PoolIndex`: the `.Curator.!index` JSON store (v1 → v2 migration). |
| [`src/core/series.rs`](../src/core/series.rs) | Series naming (`SxxEyy`, `Season NN`, `.ptN`) and scanning. |
| [`src/core/subtitles.rs`](../src/core/subtitles.rs) | Subtitle sidecars: find, attach, follow their video on rename. |
| [`src/core/tag.rs`](../src/core/tag.rs), [`tag_manager.rs`](../src/core/tag_manager.rs) | Tags (`Category/Name`) and the registry of known tags. |
| [`src/core/transforms.rs`](../src/core/transforms.rs) | Folder-name transforms (rating → ten-point band). |
| [`src/core/nfo.rs`](../src/core/nfo.rs) | Read-only NFO reader (genres, year, countries, rating, ids). |
| [`src/core/legacy_meta.rs`](../src/core/legacy_meta.rs) | Recovery of ČSFD link / title / year from pre-index layouts. |
| [`src/core/tmdb.rs`](../src/core/tmdb.rs) | TMDb client: lookups, searches, details for tags. |
| [`src/core/csfd.rs`](../src/core/csfd.rs) | ČSFD scraper with the Anubis proof-of-work solver — the title bridge to TMDb. |
| [`src/core/jellyfin.rs`](../src/core/jellyfin.rs) | Jellyfin client: library scan, read back movies. |
| [`src/core/http.rs`](../src/core/http.rs) | Shared blocking HTTP client (OS certificate store). |
| [`src/core/integrity.rs`](../src/core/integrity.rs) | Video integrity check via ffmpeg / ffprobe. |
| [`src/core/migrate.rs`](../src/core/migrate.rs) | Classification of TMDb match proposals for an old pool. |
| [`src/core/hardlink_manager.rs`](../src/core/hardlink_manager.rs) | The miniDLNA hardlink tree, mirrors, special folders, obsolete-link sweep. |
| [`src/core/file_manager/`](../src/core/file_manager/) | `FileManager`, the service layer: libraries and settings (`mod.rs`), movies (`movies.rs`), versions and parts (`versions.rs`), series (`series_ops.rs`), tools (`tools.rs`), miniDLNA generation (`minidlna.rs`). |
| [`src/ui/app.rs`](../src/ui/app.rs) | The main window: menu, tag filter, table, detail panel, status bar, actions. |
| [`src/ui/dialogs/`](../src/ui/dialogs/) | One file per dialog (import, edit, series, duplicates, libraries, migration, tags, schema, stats). |
| [`src/ui/common.rs`](../src/ui/common.rs), [`task.rs`](../src/ui/task.rs) | Message boxes, prompts, background jobs and tasks with progress. |
| [`examples/screenshot.rs`](../examples/screenshot.rs) | Developer tool: render the window offscreen to a PNG. |
+86 -54
View File
@@ -1,57 +1,75 @@
# Architecture
This document describes how Curator is put together: the layers, what owns what
on disk, and the handful of rules the rest of the code relies on. The per-module
API reference is in [`modules/`](README.md).
on disk, and the handful of rules the rest of the code relies on. The API
reference is generated from the source with `cargo doc --open`.
## Layers
```
Curator.py entry point — certificates, State, run()
src/main.rs entry point — .env, logging, ui::run()
│
▼
src/ui/qt_app.py PySide6 GUI: QtApp (main window) + dialogs
src/ui/ egui GUI: CuratorApp (main window) + dialogs/
│ never touches the filesystem directly
▼
src/core/file_manager.py FileManager — the service layer
│ owns: filelist, pool index, library config, global config
src/core/file_manager/ FileManager — the service layer
│ owns: filelist, tag manager, pool index, global config
│
├── domain model file.py (File), movie.py (Movie, MediaFile),
│ series.py (Series, Episode), tag.py, tag_manager.py
├── persistence pool_index.py (.Curator.!index), config.py
│ legacy_meta.py (reads pre-index layouts)
├── naming naming.py (movie stems, 3D), series.py (SxxEyy),
│ subtitles.py (sidecar names), movie.identity_key
├── external services tmdb.py, csfd.py, jellyfin.py, nfo.py (read-only)
└── output and checks hardlink_manager.py (miniDLNA / Filmotéka),
transforms.py, integrity.py (ffmpeg), migrate.py
├── domain model file.rs (File), movie.rs (Movie, MediaFile),
│ series.rs (Series, Episode), tag.rs, tag_manager.rs
├── persistence pool_index.rs (.Curator.!index), config.rs
│ legacy_meta.rs (reads pre-index layouts)
├── naming naming.rs (movie stems, 3D), series.rs (SxxEyy),
│ subtitles.rs (sidecar names), movie::identity_key
├── external services tmdb.rs, csfd.rs, jellyfin.rs (over http.rs),
│ nfo.rs (read-only)
└── output and checks hardlink_manager.rs (miniDLNA tree),
transforms.rs, integrity.rs (ffmpeg), migrate.rs
```
Dependencies only point downwards. The GUI calls `FileManager` methods and
receives plain objects back; the core modules never import Qt.
Dependencies only point downwards. The GUI calls `FileManager` methods and gets
plain values back; `core` never depends on egui. Files are addressed by their
current path: methods take a `&Path` and return the new path when they move it.
### Entry point
`Curator.py` builds a `State` (one `TagManager` and one `FileManager`) and hands
both to `src.ui.qt_app.run`. Before that it asks the OS certificate store for
HTTPS, because ČSFD and TMDb are reached from behind corporate proxies.
`main.rs` loads `.env`, sets up `tracing` (`RUST_LOG`, or debug when `ENV_DEBUG`
is set), builds a `FileManager` and hands it to `ui::run`. HTTPS uses the OS
certificate store (reqwest + rustls platform verifier), which matters behind
corporate SSL inspection.
### GUI
`QtApp` is the main window: the menu bar, a tag sidebar (an AND filter over the
movie list), the movie and series table, a detail panel and the status bar. Each
menu action is a method on `QtApp`; most of them open a dialog and then call one
`FileManager` method. The dialogs are separate classes in the same module —
import, edit, duplicate check, series overview, library organiser and so on.
`CuratorApp` (`ui/app.rs`) is the main window: the menu bar, a tag sidebar (an
AND filter over the movie list), the movie and series table, a detail panel and
the status bar. It keeps a small view model (rows, sidebar counts) recomputed
after every change. Each menu action is a method on the app; most open a dialog
from `ui/dialogs/` or ask a `Prompt`, then call one `FileManager` method.
Dialogs own their form state and talk back through `Env` (the manager, the
message queue and `Event`s such as *refresh*, *reload series* or *open another
dialog*). Questions inside a dialog (version popisek, confirmations, choices)
are `common::Prompt`s stacked on top of it.
Long operations run off the UI thread so the window keeps repainting:
- **Tasks** (`ui/task.rs`) need the manager — import, TMDb refresh, integrity
scan, bulk renames, miniDLNA generation. The `FileManager` is moved into a
worker thread and handed back with the result; a modal shows progress and a
cancel button meanwhile.
- **Jobs** (`common::Job`) are network calls that don't — TMDb / ČSFD lookups in
dialogs, Jellyfin checks.
### Service layer
`FileManager` is the only object that changes the pool. Everything the user can
do maps onto one of its public methods, for example `import_movie`,
`add_movie_version`, `remove_movie_version`, `merge_as_version`,
`apply_movie_edits`, `import_episodes`, the miniDLNA scope methods (`minidlna_scope`,
`minidlna_prune_targets`) and `find_duplicates`. Methods that change files also keep the pool index and the
in-memory `filelist` in step.
`apply_movie_edits`, `import_episodes`, `generate_minidlna` and
`find_duplicates`. Methods that change files also keep the pool index and the
in-memory `filelist` in step (`with_file` runs an operation on the loaded file,
or on a freshly loaded one when it isn't in the list).
## What lives where on disk
@@ -79,7 +97,7 @@ Outside the pool, the Filmotéka / miniDLNA output is a tree of hardlinks made b
## The pool index
`pool_index.py` keeps one JSON document per pool:
`pool_index.rs` keeps one JSON document per pool:
```json
{
@@ -103,14 +121,14 @@ The key of a movie record is its **identity**:
- `tmdb-<id>` when the TMDb id is known;
- otherwise `name-<slug>` built from the title and year, with diacritics folded
(`identity_key` in `movie.py`).
(`identity_key` in `movie.rs`).
Everything about a film — title, tags, ČSFD link, attributes, date added — is on
that one record. Its `media` list holds every playable file of the film. A version
therefore never carries metadata of its own, and removing one version never loses
the metadata of the others.
Version 1 of the index was keyed by file path. `PoolIndex.load` migrates it on
Version 1 of the index was keyed by file path. `PoolIndex::open` migrates it on
first open: metadata only, no file is moved, and the old file is kept as
`.Curator.!index.v1.bak`.
@@ -146,51 +164,65 @@ Rules that the code depends on:
- **Mixed libraries.** A movie's folder and a series folder have the same outer
name. They are told apart by content: a series always nests episodes in a
`Season NN` or `Specials` subfolder, a movie folder never does
(`series.looks_like_series_folder`).
(`series::looks_like_series_folder`).
## Series
A series is a folder with `Season NN` (and `Specials` for season 0) subfolders.
Episodes are named `SxxEyy - Series.ext`; an episode split across several physical
files gets Jellyfin's stacking suffix `.ptN`. Series carry no index record: they are
scanned from disk each time (`series.scan_series`), so deleting an episode file is
scanned from disk each time (`series::scan_series`), so deleting an episode file is
the whole of removing it.
## Subtitles
Subtitles are sidecar files that share the video's stem, with an optional language
and flag: `Name.cs.srt`, `Name.cs.forced.srt`. They are not index entries. Every
operation that renames or moves a video calls `subtitles.rename_subtitles_with`,
operation that renames or moves a video calls `subtitles::rename_subtitles_with`,
so the sidecars keep their match.
## Output and external services
- **miniDLNA / Filmotéka** — `FileManager.minidlna_scope` decides which libraries
take part; `HardlinkManager` builds the genre tree and mirrors the copy-as-is
libraries. Libraries left out of scope have their old links swept on the next run.
- **TMDb** (`tmdb.py`) — the identity source. Turns a TMDb id or a title/year search
into a canonical title and year, and supplies genres and years for tags.
- **ČSFD** (`csfd.py`) — a bridge for Czech titles: filename → ČSFD film → original
title and year → TMDb.
- **Jellyfin** (`jellyfin.py`) — library scan trigger and a read-back of what
- **miniDLNA** — `FileManager::minidlna_scope` decides which libraries take part;
`FileManager::generate_minidlna` builds the genre tree through
`HardlinkManager`, mirrors the series / homevideo libraries, prunes mirrors
that fell out of scope and refills "- Tipy dne" and "- Nově přidané". Libraries
left out of scope have their old links swept on the next run. The standalone
`tipy-dne` binary refreshes only the tips folder (for a systemd timer).
- **TMDb** (`tmdb.rs`) — the identity source. Turns a TMDb id or a title/year
search into a canonical title and year, and supplies genres, countries, year
and rating for tags.
- **ČSFD** (`csfd.rs`) — a bridge for Czech titles: filename → ČSFD film →
original title and year → TMDb. Solves ČSFD's Anubis proof-of-work wall.
- **Jellyfin** (`jellyfin.rs`) — library scan trigger and a read-back of what
Jellyfin sees, used to verify the pool.
- **NFO** (`nfo.py`) — read-only metadata written by Jellyfin, used to fill fields
of files imported without them.
- **NFO** (`nfo.rs`) — read-only metadata written by Jellyfin, used for tags and
to pre-fill imports.
## Configuration
Two stores, by responsibility (see `config.py`):
Two stores, by responsibility (see `config.rs`):
- the **global config** (`.Curator.!gtag`, next to `Curator.py`) holds application
state only — window geometry, recent folders and the pool and output paths;
- the **pool index** holds everything that describes a pool — libraries, tag schema,
miniDLNA selection and all movie records.
- the **global config** holds application state only — window size and the pool
and output paths. It lives in the platform config directory
(`~/.config/curator/.Curator.!gtag` on Linux), or wherever `CURATOR_CONFIG`
points. On first run a `.Curator.!gtag` left by the Python version in the
working directory is picked up, so the paths carry over;
- the **pool index** holds everything that describes a pool — libraries, tag
schema, miniDLNA selection and all movie records. Its format is unchanged from
the Python version, so an existing pool opens as-is.
Secrets come from `.env`: `TMDB_API_KEY`, `JELLYFIN_URL`, `JELLYFIN_API_KEY`,
and an optional debug flag read by `constants.py`.
Secrets come from `.env`: `TMDB_API_KEY`, `TMDB_LANGUAGE`, `JELLYFIN_URL`,
`JELLYFIN_API_KEY`, and `ENV_DEBUG`.
## Testing
Tests live in `tests/` and run with pytest. The core modules are tested directly
against temporary pools; the Qt dialogs are exercised headless
(`QT_QPA_PLATFORM=offscreen`) through their methods.
`cargo test` runs:
- unit tests next to the code (`#[cfg(test)] mod tests`), core modules tested
against temporary pools;
- `tests/ui.rs` — the GUI driven headlessly with `egui_kittest`: open a dialog,
type, click, then check the pool on disk.
`examples/screenshot.rs` renders the window (optionally with a dialog open) to a
PNG without showing it, for checking layout.
-5
View File
@@ -1,5 +0,0 @@
# `src/_version.py`
Auto-generated — do not edit manually.
Source: [`src/_version.py`](../../src/_version.py)
-34
View File
@@ -1,34 +0,0 @@
# `src/constants.py`
Application-level constants for Curator (build/runtime metadata).
This module derives the app version and debug flag. The version is loaded from
`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for
frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`).
It is the single source of truth for app metadata used across the GUI and build
tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`).
Source: [`src/constants.py`](../../src/constants.py)
## Functions
### `get_version`
*function · line 26*
```python
get_version() -> str
```
Return the project version (pyproject → _version.py → unknown).
### `get_debug_mode`
*function · line 55*
```python
get_debug_mode() -> bool
```
Return True when ENV_DEBUG is set to a truthy value in the environment.
-57
View File
@@ -1,57 +0,0 @@
# `src/core/config.py`
Configuration for Curator.
Two stores, by responsibility:
1. Global config (`.Curator.!gtag` next to Curator.py) — **application data
only**: window state, MRU folders, and the pointers to where the pool and the
Filmotéka output live (`pool_dir` / `filmoteka_dir`). Those pointers must
stay here because the pool index is found *through* them.
2. Pool index (`<pool>/.Curator.!index`, see `pool_index.py`) — everything
describing a particular library: per-movie metadata **and** library-level
settings (`tag_schema`, `copyasis_folders`). It travels with the pool.
Source: [`src/core/config.py`](../../src/core/config.py)
## Functions
### `load_global_config`
*function · line 68*
```python
load_global_config() -> dict
```
Load global application config
### `save_global_config`
*function · line 84*
```python
save_global_config(cfg: dict)
```
Save global application config
### `load_config`
*function · line 94*
```python
load_config()
```
Legacy function - returns global config
### `save_config`
*function · line 99*
```python
save_config(cfg: dict)
```
Legacy function - saves global config
-251
View File
@@ -1,251 +0,0 @@
# `src/core/csfd.py`
ČSFD.cz scraper — a *title bridge* to TMDb.
Curator identifies movies by **TMDb id**. A direct TMDb search on a Czech
distribution title is unreliable (remakes, localized names, "weird" hits).
ČSFD, on the other hand, indexes Czech release names well **and** lists a
film's *original* title. So the bridge is::
filename → ČSFD search → ČSFD film page → original title + year
→ TMDb search → tmdbid
This module only scrapes ČSFD; the TMDb half lives in
`src.core.file_manager` (`FileManager.resolve_via_csfd`). Nothing
here feeds tags/genres — those still come from TMDb via Jellyfin/NFO.
ČSFD serves an **Anubis** proof-of-work wall in front of every page: a plain
request gets a 200 with a JS challenge instead of the movie. We detect it,
solve the SHA-256 PoW the way the bundled worker JS does, and replay the
request through a shared `requests.Session` (so a batch pays the PoW
cost only once).
Source: [`src/core/csfd.py`](../../src/core/csfd.py)
## Classes
### `CsfdError`
*class · line 62*
```python
class CsfdError(RuntimeError):
```
A ČSFD lookup could not be completed (deps missing, network, anti-bot).
### `CsfdFilm`
*class · line 67*
```python
class CsfdFilm:
```
The slice of a ČSFD film page Curator needs to reach a TMDb id.
#### `CsfdFilm.tmdb_query`
*property · line 77*
```python
tmdb_query -> tuple[str, Optional[str]]
```
`(title, year)` best suited for a TMDb search — original title first.
## Functions
### `dependencies_available`
*function · line 82*
```python
dependencies_available() -> bool
```
True when `requests` + `beautifulsoup4` are importable.
### `_check_dependencies`
*function · line 87*
```python
_check_dependencies() -> None
```
Raise `CsfdError` when the optional `requests` / `beautifulsoup4` packages are missing (they are needed only for ČSFD scraping).
### `_extract_csfd_id`
*function · line 95*
```python
_extract_csfd_id(url: str) -> Optional[int]
```
Pull the numeric film id out of a ČSFD URL, or `None`.
### `_extract_json_blob`
*function · line 105*
```python
_extract_json_blob(html: str, element_id: str)
```
Return the parsed JSON from an Anubis `<script id=...>` blob, or None.
### `_solve_anubis_pow`
*function · line 120*
```python
_solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]
```
Brute-force the Anubis proof-of-work.
Mirrors the bundled `sha256-purejs` worker: find the smallest `nonce`
such that `sha256(random_data + str(nonce))` has `difficulty` leading
zero nibbles. Returns `(hash_hex, nonce, elapsed_ms)`.
### `_solve_anubis_challenge`
*function · line 143*
```python
_solve_anubis_challenge(session, html: str, url: str)
```
Solve the Anubis challenge in `html` and return the real page response.
### `_get_page`
*function · line 180*
```python
_get_page(session, url: str)
```
GET `url` through `session`, transparently clearing an Anubis wall.
### `_parse_json_ld`
*function · line 193*
```python
_parse_json_ld(soup) -> tuple[str, Optional[str]]
```
`(name, year)` from the film page's JSON-LD (name is the Czech title).
### `_parse_origin`
*function · line 215*
```python
_parse_origin(soup) -> tuple[Optional[str], Optional[str]]
```
`(origin_country, year)` from the `.origin` line.
ČSFD separates values with inline bullet `<span>` elements (no commas),
e.g. `USA • 1999 • 136 min`. For a co-production only the first country
is returned — enough to match it against a name's flag.
### `_parse_original_title`
*function · line 245*
```python
_parse_original_title(soup, origin_country: Optional[str]) -> Optional[str]
```
The film's original title from `.film-names` — or `None` to use the CZ title.
Each `<li>` carries a country/language flag (`img.flag` `title`) and a
title. The original title is the entry whose flag matches the **country of
origin** (USA → "The Shawshank Redemption"). ČSFD does **not** repeat the
Czech title here when it equals the `<h1>`, so a Czech film has only
foreign/festival alt-titles in this list (`[angličtina] Cosy Dens`,
`[Slovinsko] Samotarji`) — picking one of those would be wrong, so we
return `None` and let the caller fall back to the Czech `<h1>` title.
### `fetch_film`
*function · line 276*
```python
fetch_film(url: str, session=None) -> CsfdFilm
```
Fetch a ČSFD film page → `CsfdFilm` (Czech + original title, year).
Pass a shared `requests.Session` to reuse the Anubis auth cookie across a
batch. Raises `CsfdError` on a network / anti-bot / parse failure.
### `_films_section`
*function · line 311*
```python
_films_section(soup)
```
The `<section>` holding the *Filmy* results, or the whole document.
ČSFD's search page groups hits into `Filmy` / `Seriály` / `Tvůrci` /
`Uživatelé` sections; `a.film-title-name` matches in all of them, so
without scoping a bare-title search happily returns a series episode.
### `_is_film_href`
*function · line 325*
```python
_is_film_href(href: str) -> bool
```
True for a film URL `/film/<id>-slug/` — a series episode has two segments
(`/film/<series>/<episode>/`) and is rejected.
### `search_films`
*function · line 332*
```python
search_films(query: str, limit: int=5, session=None) -> list[CsfdFilm]
```
Search ČSFD for `query`; film hits only (no series/episodes), best first.
### `find_csfd_url`
*function · line 365*
```python
find_csfd_url(query: str, session=None) -> Optional[str]
```
Return the first ČSFD film URL matching `query`, or `None`.
ČSFD's full-text search treats a trailing year as a search term (not a
filter) and can miss on `"Title 2006"` while `"Title"` hits — so a
yearful query with no result is retried without the year.
### `clean_filename_to_query`
*function · line 397*
```python
clean_filename_to_query(filename: str) -> str
```
Turn a (possibly release-named) filename into a ČSFD search query.
Strips the path/extension, splits on common separators and keeps the words
before the first release marker (year, resolution, codec, source, …). The
detected year is appended back as a disambiguator::
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
-227
View File
@@ -1,227 +0,0 @@
# `src/core/file.py`
The `File` class: one playable movie file as the UI sees it. A `File` is a *view* onto one media entry of a movie record in the pool index — metadata (title, tags, ČSFD link, attributes, date) lives on the shared record, so every version of a film reads the same values. See `docs/architecture.md`.
Source: [`src/core/file.py`](../../src/core/file.py)
## Classes
### `File`
*class · line 5*
```python
class File:
```
One movie file in the pool, as the UI sees it. Holds the path, the list of tags and the metadata accessors; metadata itself is stored on the movie record in the pool index and the `File` reads it through it.
#### `File.__init__`
*method · line 6*
```python
__init__(file_path: Path, tagmanager=None, index=None) -> None
```
Create a file object for `file_path`. Metadata is loaded from the pool index when `index` is given; without an index the object is in-memory only.
#### `File._identity_from_path`
*method · line 41*
```python
_identity_from_path() -> tuple[str, str, str | None, int | None, str]
```
`(key, title, year, tmdb_id, label)` implied by this file's location.
A file inside its own `Title (rok) [tmdbid]` folder is one version of
that movie — identity comes from the **folder**, and the rest of its own
stem is the version label. A flat file carries both in its own name.
#### `File.get_metadata`
*method · line 67*
```python
get_metadata() -> None
```
Load this file's metadata from the movie record that owns it.
Since the pool index became identity-keyed (see `src.core.movie`),
a File is a *view* on one media entry of a movie record — every version
of the same film therefore reads the same title/tags/ČSFD/… A file the
index doesn't know yet joins the movie of the same identity when there
is one (that's how a newly added version inherits the shared metadata
instead of blanking it), otherwise it starts a new record.
#### `File._init_new_metadata`
*method · line 95*
```python
_init_new_metadata() -> None
```
Reset all metadata fields to their defaults for a file the index doesn't know yet.
#### `File._build_record`
*method · line 107*
```python
_build_record() -> dict
```
Serialise this file's metadata into the dictionary stored in the pool index (tags as full paths, auto-derived tags, date, title, ČSFD link, …).
#### `File._apply_record`
*method · line 126*
```python
_apply_record(data: dict) -> None
```
Load metadata from an index record dictionary into this object's fields (the inverse of `_build_record`). Missing keys fall back to defaults.
#### `File.save_metadata`
*method · line 152*
```python
save_metadata()
```
Persist metadata into this file's movie record (no-op without index).
Writes the shared fields onto the movie record and makes sure the
record lists this file among its media. A file the index has never seen
starts a new record under its own identity key.
#### `File._library_name`
*method · line 182*
```python
_library_name() -> str
```
Top-level pool folder this file sits in (its library), or `""`.
#### `File.added_timestamp`
*method · line 190*
```python
added_timestamp() -> float
```
Epoch seconds for 'date added' — the stored `added` or the file mtime.
Used to order the "recently added" folder (newer = larger). The fallback
is **mtime**, not ctime: creating the Filmotéka hardlinks bumps the source
inode's ctime, which would make ctime-based ordering meaningless, whereas
mtime (preserved from the source on import) stays stable.
#### `File.name_context`
*method · line 209*
```python
name_context() -> dict
```
Fields for a miniDLNA filename template (see config tag schema).
Provides title / year / rating / ext / stem / filename from the movie's
metadata (year & rating come from the NFO cache, year falling back to a
`Rok` tag, then the canonical filename). Missing values render empty.
#### `File.set_attribute`
*method · line 242*
```python
set_attribute(key: str, value: str | None) -> None
```
Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it.
#### `File.delete_metadata`
*method · line 253*
```python
delete_metadata() -> None
```
Drop this file from its movie record; the last one takes the record.
Removing one version of a multi-version movie leaves the movie (and its
metadata) in place — only when its last media entry goes does the whole
record go with it.
#### `File.relocate`
*method · line 273*
```python
relocate(new_path: Path) -> None
```
Point this File at a new path, moving its media entry along.
The physical file must already have been moved/renamed by the caller;
this only repoints the movie record's media entry (and the File), so a
rename never detaches a file from its movie.
#### `File.set_date`
*method · line 294*
```python
set_date(date_str: str | None)
```
Set the date the movie was added (`YYYY-MM-DD`) or clear it with `None` / an empty string, then save the metadata.
#### `File.apply_nfo`
*method · line 303*
```python
apply_nfo(nfo, schema: list[dict] | None=None) -> dict
```
Regenerate the auto (NFO-derived) tags from the movie's NFO.
Reads genres/year/countries/rating from `nfo` (`nfo.NfoData`) per the
tag schema's source fields and (re)creates those tags; user-added tags are
kept. Also caches year/rating (for folder grouping) and fills the title
when missing. `nfo` of `None` (no NFO yet) is a no-op.
Returns:
dict with 'success' and 'tags_added'.
#### `File.add_tag`
*method · line 347*
```python
add_tag(tag)
```
Attach a tag to this file. Accepts a `Tag` or a `category/name` string; a string without a category goes to the `default` category. Saves on change.
#### `File.remove_tag`
*method · line 364*
```python
remove_tag(tag)
```
Detach a tag, given as a `Tag` or a `category/name` string. Saves on change.
File diff suppressed because it is too large Load Diff
-440
View File
@@ -1,440 +0,0 @@
# `src/core/hardlink_manager.py`
Hardlink Manager for Curator
Creates directory structure based on file tags and creates hardlinks
to organize files without duplicating them on disk.
Example:
A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:
output/
├── žánr/
│ ├── Komedie/
│ │ └── film.mkv (hardlink)
│ └── Akční/
│ └── film.mkv (hardlink)
└── rok/
└── 1988/
└── film.mkv (hardlink)
Source: [`src/core/hardlink_manager.py`](../../src/core/hardlink_manager.py)
## Classes
### `_SafeDict`
*class · line 30*
```python
class _SafeDict(dict):
```
dict for str.format_map that leaves unknown fields as an empty string.
### `HardlinkManager`
*class · line 37*
```python
class HardlinkManager:
```
Manager for creating hardlink-based directory structures from tagged files.
The output layout is driven by a *category → root folder* mapping
(`category_roots`). Each tag is placed at
`output/<root>/<tag_name>/<file>`; an empty root means the tag's own
folders sit directly at the output root (e.g. genre folders next to the
"Dle roku" / "Dle země původu" folders). The legacy `categories` list
(folder == category name) is still accepted and treated as the identity
mapping `{cat: cat}`.
#### `HardlinkManager.__init__`
*method · line 49*
```python
__init__(output_dir: Path)
```
Initialize HardlinkManager.
Args:
output_dir: Base directory where the tag-based structure will be created
#### `HardlinkManager._resolve_roots`
*method · line 60*
```python
_resolve_roots(categories: Optional[List[str]], category_roots: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]
```
Normalize the two filter styles into a category → root-folder map.
`None` means "all categories", folder == category name.
#### `HardlinkManager._clean_title`
*method · line 75*
```python
_clean_title(file_obj: File) -> str
```
The movie's clean title (no trailing `(YYYY)`), path-safe.
#### `HardlinkManager._default_link_name`
*method · line 85*
```python
_default_link_name(file_obj: File) -> str
```
Filmotéka hardlink name: the clean title + extension (no year).
#### `HardlinkManager._year_link_name`
*method · line 89*
```python
_year_link_name(file_obj: File) -> Optional[str]
```
`Title (YYYY).ext` used to disambiguate a name collision, or None.
#### `HardlinkManager._render_template`
*method · line 96*
```python
_render_template(file_obj: File, template: str) -> str
```
Render a per-category filename template; fall back to the clean name.
#### `HardlinkManager._plan_links`
*method · line 105*
```python
_plan_links(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]], templates: Optional[Dict[str, str]]) -> List[Tuple[File, Path]]
```
Resolve the tag tree to a collision-free `(file, link_path)` plan.
The default hardlink name is the movie's **clean title** (no year). When
two *different* movies would land on the same name in the same folder,
the colliding ones fall back to the year-qualified `Title (YYYY).ext` to
tell them apart; a residual clash (same title *and* year) gets a stable
numeric suffix so every path is unique. A per-category
`filename_template` overrides the default inside that category's folders.
Both link creation and obsolete detection consume this one plan, so they
can never disagree on a name (which is what caused hardlink churn before).
#### `HardlinkManager._folder_value`
*method · line 172*
```python
_folder_value(tag, transforms: Optional[Dict[str, str]]) -> str
```
Folder name for a tag — its value run through the category transform.
The tag keeps its exact value; the grouping (e.g. rating → ten-point
band) only happens here, when naming the folder.
#### `HardlinkManager._target_dir`
*method · line 183*
```python
_target_dir(tag, roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None) -> Optional[Path]
```
Output directory for a tag, or None if its category is excluded.
#### `HardlinkManager._managed_top_dirs`
*method · line 197*
```python
_managed_top_dirs(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Optional[Set[str]]
```
Top-level output folders owned by the tag tree (None = all of them).
A category with a non-empty root owns that root folder. A category placed
at the output root (empty root, e.g. genres) owns its own folders at the
root — and, so a genre whose last movie dropped the tag still gets its
now-stale folder cleaned, *every* root-level folder is treated as owned
except the grouping roots and any `reserved_subfolders` (copy-as-is
mirrors, "Tipy dne", …). The obsolete check still only removes links to
current pool files, so reserved/foreign folders are doubly protected.
#### `HardlinkManager.create_structure_for_files`
*method · line 236*
```python
create_structure_for_files(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None) -> Tuple[int, int]
```
Create hardlink structure for given files based on their tags.
Args:
files: List of File objects to process
categories: Optional list of categories to include (None = all)
dry_run: If True, only simulate without creating actual links
category_roots: Optional category → root-folder map (see class doc);
overrides `categories` when given.
category_transforms: Optional category → transform-name map applied to
the tag value when naming its folder (e.g. rating → decade band).
category_filename_templates: Optional category → hardlink-name template
applied only inside that category's folders.
Returns:
Tuple of (successful_links, failed_links)
#### `HardlinkManager.mirror_as_is`
*method · line 294*
```python
mirror_as_is(source_dir: Path, subfolder: str | None=None, dry_run: bool=False) -> Tuple[int, int]
```
Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.
Recreates the exact directory hierarchy of `source_dir` under
`output_dir/subfolder` (or directly under `output_dir` when
`subfolder` is None) and hardlinks every file. Curator metadata files
(`.!tag` / `.!ftag` / `.!gtag` / `.!index`) are skipped.
Used for Seriály: the pool structure is the source of truth and is cloned
verbatim instead of being rebuilt from tags.
Returns:
Tuple of (successful_links, failed_links)
#### `HardlinkManager.remove_mirror`
*method · line 345*
```python
remove_mirror(name: str) -> bool
```
Remove `output_dir/name` entirely (file or folder tree).
Used to drop a whole mirrored library (or one entry of the shared
mixed-library-series bucket) when it falls out of the miniDLNA
generation's scope. Mirrors are pure hardlinks, so deleting the folder
loses nothing — it's fully regenerable from the pool. `name` may
contain "/" to address one entry inside a bucket folder.
Returns whether anything was actually removed.
#### `HardlinkManager._fill_special_folder`
*method · line 365*
```python
_fill_special_folder(chosen: List[File], subfolder: str, dry_run: bool) -> Tuple[int, int]
```
Empty `subfolder` and hardlink `chosen` files into it.
#### `HardlinkManager.generate_recently_added`
*method · line 405*
```python
generate_recently_added(files: List[File], count: int=10, subfolder: str='- Nově přidané', dry_run: bool=False) -> Tuple[int, int]
```
(Re)fill `subfolder` with the `count` most recently added movies.
Ordering is by `File.added_timestamp()` (import time, ctime fallback),
newest first. Returns (created_links, failed_links).
#### `HardlinkManager.generate_random_tips`
*method · line 420*
```python
generate_random_tips(files: List[File], count: int=10, subfolder: str='Tipy dne', dry_run: bool=False) -> Tuple[int, int]
```
(Re)fill `subfolder` with `count` random files as hardlinks.
The folder is emptied first so each run picks a fresh random selection.
`files` should be the pool movies only (copy-as-is mirrors excluded).
Returns:
Tuple of (created_links, failed_links)
#### `HardlinkManager._is_same_file`
*method · line 438*
```python
_is_same_file(path1: Path, path2: Path) -> bool
```
Check if two paths point to the same file (same inode).
#### `HardlinkManager._get_unique_name`
*method · line 445*
```python
_get_unique_name(path: Path) -> Path
```
Get a unique filename by adding a numeric suffix.
#### `HardlinkManager.remove_created_links`
*method · line 459*
```python
remove_created_links() -> int
```
Remove all hardlinks created by the last operation.
Returns:
Number of links removed
#### `HardlinkManager._remove_empty_parents`
*method · line 481*
```python
_remove_empty_parents(path: Path) -> None
```
Remove empty parent directories up to output_dir.
#### `HardlinkManager.get_preview`
*method · line 492*
```python
get_preview(files: List[File], categories: Optional[List[str]]=None, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None) -> List[Tuple[Path, Path]]
```
Get a preview of what links would be created.
Args:
files: List of File objects
categories: Optional list of categories to include
category_roots: Optional category → root-folder map (overrides
`categories` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
Returns:
List of tuples (source_path, target_path)
#### `HardlinkManager.find_obsolete_links`
*method · line 519*
```python
find_obsolete_links(files: List[File], categories: Optional[List[str]]=None, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> List[Tuple[Path, Path]]
```
Find hardlinks in the output directory that no longer match file tags.
Scans the managed parts of the output directory for hardlinks that point
to source files but whose path no longer matches the file's current tags.
Only the tag-tree's own top-level folders are scanned, so copy-as-is
mirrors (e.g. Seriály) are left untouched.
Args:
files: List of File objects (source files)
categories: Optional list of categories to check (None = all)
category_roots: Optional category → root-folder map (overrides
`categories` when given).
category_transforms: Optional category → transform-name map for folders.
Returns:
List of tuples (link_path, source_path) for obsolete links
#### `HardlinkManager.remove_obsolete_links`
*method · line 608*
```python
remove_obsolete_links(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, List[Path]]
```
Remove hardlinks that no longer match file tags.
Args:
files: List of File objects
categories: Optional list of categories to check
dry_run: If True, only return what would be removed
category_roots: Optional category → root-folder map (overrides
`categories` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
reserved_subfolders: Output top-level folders to never touch (mirrors,
"Tipy dne", …).
Returns:
Tuple of (removed_count, list_of_removed_paths)
#### `HardlinkManager.sync_structure`
*method · line 655*
```python
sync_structure(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, int, int, int]
```
Synchronize hardlink structure with current file tags.
This will:
1. Remove hardlinks for removed tags
2. Create new hardlinks for new tags
Args:
files: List of File objects
categories: Optional list of categories to sync
dry_run: If True, only simulate
category_roots: Optional category → root-folder map (overrides
`categories` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
reserved_subfolders: Output top-level folders to never touch (mirrors,
"Tipy dne", …).
Returns:
Tuple of (created, create_failed, removed, remove_failed)
## Functions
### `create_hardlink_structure`
*function · line 707*
```python
create_hardlink_structure(files: List[File], output_dir: Path, categories: Optional[List[str]]=None) -> Tuple[int, int, List[Tuple[Path, str]]]
```
Convenience function to create hardlink structure.
Args:
files: List of File objects to process
output_dir: Base directory for output
categories: Optional list of categories to include
Returns:
Tuple of (successful_count, failed_count, errors_list)
-77
View File
@@ -1,77 +0,0 @@
# `src/core/integrity.py`
Video integrity checking via FFmpeg.
Detects corrupted or unreadable video files. Two depths:
- **deep** (default): a full `ffmpeg` decode to the null muxer
(`-f null`). It actually decodes every frame, so it catches mid-file data
corruption (broken GOPs, truncated streams, bad packets). Thorough but slow —
it reads and decodes the whole file.
- **quick**: an `ffprobe` container/stream probe. Fast but shallow — it catches
an unreadable container, a truncated header or a missing video stream, but not
corruption buried inside otherwise-parseable data.
Both tools ship with FFmpeg; `ffmpeg_available` reports whether they are on the
PATH so callers can degrade gracefully.
Source: [`src/core/integrity.py`](../../src/core/integrity.py)
## Classes
### `IntegrityResult`
*class · line 30*
```python
class IntegrityResult:
```
Outcome of checking one file.
`ok` — the file decoded/probed cleanly.
`checked` — the check actually ran; False means it couldn't (missing tool,
missing file), which is *not* the same as "corrupted".
`error` — a short reason when not `ok`.
## Functions
### `ffmpeg_available`
*function · line 45*
```python
ffmpeg_available(deep: bool=True) -> bool
```
Is the required FFmpeg tool (ffmpeg for deep, ffprobe for quick) on PATH?
### `check_video_integrity`
*function · line 50*
```python
check_video_integrity(path: Path, deep: bool=True, timeout: Optional[int]=None) -> IntegrityResult
```
Check a single video file for corruption.
Args:
path: the video file to check.
deep: True → full ffmpeg decode (catches data corruption, slow);
False → ffprobe probe (catches unreadable/truncated files, fast).
timeout: per-file timeout in seconds (None = no limit).
Returns:
An `IntegrityResult`. `checked=False` when the check could not
be run at all (tool or file missing) — distinct from a real failure.
### `_first_error_line`
*function · line 108*
```python
_first_error_line(stderr: str) -> str
```
First non-empty stderr line, truncated — a compact reason for the report.
-105
View File
@@ -1,105 +0,0 @@
# `src/core/jellyfin.py`
Jellyfin server API client — thin, hybrid integration.
The NFO + artwork on disk stay authoritative (Jellyfin writes and reads them);
this client only does what the disk can't: **trigger a library scan** and **read
back what Jellyfin currently sees** (to verify the pool against it). Connection
details come from `.env` — `JELLYFIN_URL` and `JELLYFIN_API_KEY` (created in
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
Source: [`src/core/jellyfin.py`](../../src/core/jellyfin.py)
## Classes
### `JellyfinMovie`
*class · line 25*
```python
class JellyfinMovie:
```
A movie as Jellyfin currently sees it (for verification).
### `JellyfinError`
*class · line 35*
```python
class JellyfinError(RuntimeError):
```
A Jellyfin request could not be completed (no config, network, status).
## Functions
### `base_url`
*function · line 39*
```python
base_url() -> Optional[str]
```
The configured Jellyfin base URL (no trailing slash), or None.
### `api_key`
*function · line 45*
```python
api_key() -> Optional[str]
```
The configured Jellyfin API key, or None.
### `api_available`
*function · line 51*
```python
api_available() -> bool
```
True when both the Jellyfin URL and API key are configured.
### `_request`
*function · line 56*
```python
_request(method: str, path: str, params: Optional[dict]=None, timeout: Optional[int]=None) -> requests.Response
```
Send one authenticated request to the Jellyfin server (`X-Emby-Token` header). Raises `JellyfinError` when the URL/key are missing or the server can't be reached.
### `server_version`
*function · line 74*
```python
server_version(timeout: Optional[int]=None) -> str
```
Return the Jellyfin server version (a quick connectivity check).
### `scan_all`
*function · line 80*
```python
scan_all() -> None
```
Trigger a scan of all Jellyfin libraries (picks up on-disk changes).
### `fetch_movies`
*function · line 85*
```python
fetch_movies() -> list[JellyfinMovie]
```
Read every movie Jellyfin currently has, with its TMDb id and path.
-83
View File
@@ -1,83 +0,0 @@
# `src/core/legacy_meta.py`
Readers for Curator's *pre-index* metadata layouts.
Before the unified pool index (`.Curator.!index`), Tagger / early Curator stored
a movie's metadata in a **per-file sidecar** `.{filename}.!tag` (JSON) sitting
next to the video. When importing a file that came from such a pool we can
recover its **ČSFD link** (and title / year) from that sidecar — or from an old
`.Curator.!index` found by walking up from the file.
This is read-only recovery for the import dialog; nothing here writes.
Source: [`src/core/legacy_meta.py`](../../src/core/legacy_meta.py)
## Classes
### `LegacyMeta`
*class · line 25*
```python
class LegacyMeta:
```
What an old sidecar / index record can still tell us about a movie.
## Functions
### `_record_to_meta`
*function · line 34*
```python
_record_to_meta(record: object, source: str) -> Optional[LegacyMeta]
```
Pull the useful fields out of a stored movie record (any schema version).
### `_from_sidecar`
*function · line 51*
```python
_from_sidecar(video_path: Path) -> Optional[LegacyMeta]
```
Read the metadata from the per-file `.{name}.!tag` JSON sidecar next to a video, or return `None` when there is no readable sidecar.
### `_lookup_in_index`
*function · line 62*
```python
_lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> Optional[dict]
```
Find the record for `video_path` in an index's `movies` map.
Tries the exact pool-relative key first, then falls back to a unique
basename match (the file may have moved since the index was written).
### `_from_legacy_index`
*function · line 79*
```python
_from_legacy_index(video_path: Path) -> Optional[LegacyMeta]
```
Read the metadata from a `.Curator.!index` file found by walking up at most `_MAX_INDEX_WALK_UP` directories from the video. Returns `None` if nothing matches.
### `read_legacy_meta`
*function · line 99*
```python
read_legacy_meta(video_path: Path) -> Optional[LegacyMeta]
```
Recover ČSFD link / title / year from a pre-index layout, or `None`.
Order: a per-file `.{name}.!tag` sidecar next to the video, then a
`.Curator.!index` found walking up (max 4 levels) from the file.
-47
View File
@@ -1,47 +0,0 @@
# `src/core/list_manager.py`
Sorting of file lists by name or by date added. Small helper used by the file list views.
Source: [`src/core/list_manager.py`](../../src/core/list_manager.py)
## Classes
### `ListManager`
*class · line 4*
```python
class ListManager:
```
Holds the current sort mode (`name` or `date`) and sorts file lists accordingly.
#### `ListManager.__init__`
*method · line 5*
```python
__init__()
```
Start sorting by name.
#### `ListManager.set_sort`
*method · line 9*
```python
set_sort(mode: str)
```
Switch the sort mode to `name` or `date`; other values are ignored.
#### `ListManager.sort_files`
*method · line 13*
```python
sort_files(files: List[File]) -> List[File]
```
Return the files sorted by name (case-insensitive) or by date. Files without a date go last in date mode.
-62
View File
@@ -1,62 +0,0 @@
# `src/core/migrate.py`
One-off migration of an existing pool to the Jellyfin naming convention.
Older pools carry files like `Title.ext` / `Title (rok).ext` with **no TMDb
id**. This matches each such movie against TMDb by title + year and proposes a
`tmdbid` (and the missing year, which TMDb supplies), so the file can be renamed
to `Title (rok) [tmdbid].ext` and Jellyfin can match it deterministically.
The classification is deliberately **conservative**: only an exact
(diacritics-insensitive) title match with a matching year and a single candidate
is `auto`; everything else is `ambiguous` for the user to confirm.
Source: [`src/core/migrate.py`](../../src/core/migrate.py)
## Classes
### `MatchProposal`
*class · line 19*
```python
class MatchProposal:
```
One movie's proposed TMDb match.
## Functions
### `normalize`
*function · line 33*
```python
normalize(text: str) -> str
```
Fold a title for comparison: strip diacritics/punctuation, lowercase.
### `_year_close`
*function · line 41*
```python
_year_close(a: Optional[str], b: Optional[str]) -> bool
```
True when two year strings are both numeric and differ by at most one year (release years often disagree by one).
### `classify`
*function · line 47*
```python
classify(title: str, year: Optional[str], candidates: list) -> tuple[str, object]
```
Return `(status, chosen)` for a title/year against TMDb candidates.
`auto` requires an exact normalized-title match, a year within ±1, and that
exactly one candidate qualifies. Otherwise `ambiguous` (top hit preselected)
or `no_match` when there are no candidates.
-207
View File
@@ -1,207 +0,0 @@
# `src/core/movie.py`
The movie object — metadata first, files second.
A movie is identified by `(title, year, tmdb_id)` and owns **its files**, not
the other way round. That inversion is the point: the pool index used to key a
record by a file path, so metadata was a passenger on one particular file — and
everything about multi-version movies got awkward (which file "holds" the tags,
why renaming a grouped movie had to be blocked, why an import could silently
create a duplicate instead of a version).
Here the record is the movie; each playable file is a `MediaFile` under
it, told apart by a free-text `label` (`3D HSBS`, `4K`, `Director's
Cut`, …). Where those files *live* is then a pure function of the record —
`Movie.desired_layout` — so promoting to a folder, demoting back, or
renaming the whole thing are all the same operation: recompute and move what
doesn't match.
Layout (Jellyfin's own conventions — https://jellyfin.org/docs/general/server/media/movies/)::
one video <Library>/Title (rok) [tmdbid-N].ext
two or more <Library>/Title (rok) [tmdbid-N]/
Title (rok) [tmdbid-N] - Výchozí.ext
Title (rok) [tmdbid-N].3D.HSBS.ext
Every file in a movie folder must start with the folder's exact name; both
`" - label"` and `".3D.FMT"` are valid separators, so a 3D file keeps the
documented `.3D.<FORMAT>` spelling whether it sits flat or grouped.
Subtitles are not media entries — they ride along with their video by filename
stem (see `src.core.subtitles`).
Source: [`src/core/movie.py`](../../src/core/movie.py)
## Classes
### `MediaFile`
*class · line 102*
```python
class MediaFile:
```
One file belonging to a movie (today always a playable video).
#### `MediaFile.format_3d`
*property · line 110*
```python
format_3d -> Optional[str]
```
The 3D format this version's label denotes, if any.
#### `MediaFile.to_record`
*method · line 114*
```python
to_record(pool_dir: Path) -> dict
```
Serialise for the pool index (path stored pool-relative, POSIX).
#### `MediaFile.from_record`
*class method · line 123*
```python
from_record(data: dict, pool_dir: Path) -> 'MediaFile'
```
Build a `MediaFile` from one entry of a movie record's `media` list, resolving its relative path against the pool directory.
### `Movie`
*class · line 132*
```python
class Movie:
```
A film in the pool: its identity, its metadata, and its media files.
#### `Movie.videos`
*property · line 156*
```python
videos -> list[MediaFile]
```
Playable versions, in a stable order (labelled ones by label).
#### `Movie.is_grouped`
*property · line 163*
```python
is_grouped -> bool
```
True when the movie needs its own folder (more than one version).
#### `Movie.stem`
*property · line 168*
```python
stem -> str
```
Canonical `Title (rok) [tmdbid-N]` — the folder / filename base.
Falls back to the bare title when no four-digit year is known (a movie
can be imported without one).
#### `Movie.primary`
*property · line 182*
```python
primary -> Optional[MediaFile]
```
The version that represents the movie (first by `videos`).
#### `Movie.desired_layout`
*method · line 187*
```python
desired_layout(pool_dir: Path) -> list[tuple[MediaFile, Path]]
```
`(media, where it belongs)` for every file, from the record alone.
The whole flat-vs-folder rule lives here: one video sits directly in
the library, two or more move into `<Library>/<stem>/`. Callers move
whatever doesn't already match — which makes promotion, demotion and
renaming the same operation.
#### `Movie.to_record`
*method · line 208*
```python
to_record(pool_dir: Path) -> dict
```
Serialise the movie into the dictionary stored in the pool index, with media paths relative to the pool directory.
#### `Movie.from_record`
*class method · line 227*
```python
from_record(key: str, data: dict, pool_dir: Path, tagmanager=None) -> 'Movie'
```
Build a `Movie` from an index record stored under `key`. Tags are resolved through `tagmanager` when one is given.
## Functions
### `normalize_3d_label`
*function · line 53*
```python
normalize_3d_label(label: object) -> Optional[str]
```
The 3D format a version label denotes (upper-case), or `None`.
Lets a plain label double as the 3D marker — `"3D HSBS"` typed in the
version dialog and `"hsbs"` picked from a dropdown mean the same thing.
### `media_filename`
*function · line 63*
```python
media_filename(stem: str, label: str, ext: str, *, grouped: bool) -> str
```
Filename for one media file of a movie whose canonical stem is `stem`.
- no label → `stem.ext` (a lone, plain video)
- 3D label → `stem.3D.<FORMAT>.ext` (Jellyfin's documented 3D form)
- anything else → `stem - label.ext`
`grouped` only decides whether an empty label is allowed: inside a movie
folder every file gets one, so it falls back to `DEFAULT_VERSION_LABEL`.
### `identity_key`
*function · line 84*
```python
identity_key(tmdb_id: object=None, title: str='', year: object=None) -> str
```
The pool-index key for a movie — `tmdb-<id>`, else a title/year slug.
Keying by identity (rather than by file path) is what lets a second file of
the same film find its movie instead of becoming a separate entry.
-119
View File
@@ -1,119 +0,0 @@
# `src/core/naming.py`
Pool file naming convention.
Every movie in the pool is named `Title (YYYY) [tmdbid-N].ext` — a title, a
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
is exactly the form Jellyfin matches on. The `[tmdbid-N]` part is optional: a
plain `Title (YYYY)` is still valid (Jellyfin then matches by name). The year
lets two same-named films coexist and makes the filename self-describing.
A 3D movie additionally carries a `.3D.<FORMAT>` tag right before the
extension (Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/):
`Title (YYYY) [tmdbid-N].3D.HSBS.ext`. `FORMAT` is one of
`THREE_D_FORMATS`; Jellyfin reads it case-insensitively but Curator always
writes it upper-case for a single canonical form.
These helpers build, validate and parse the stem (the filename without its
extension).
Source: [`src/core/naming.py`](../../src/core/naming.py)
## Functions
### `is_valid_year`
*function · line 35*
```python
is_valid_year(year: object) -> bool
```
True when `year` is (or stringifies to) exactly four digits.
### `canonical_pool_stem`
*function · line 40*
```python
canonical_pool_stem(title: str, year: object, tmdb_id: object=None, format_3d: object=None) -> str
```
Build the canonical pool stem `Title (YYYY)` (+ ` [tmdbid-N]` /
`.3D.<FORMAT>` if given).
Raises:
ValueError: the title is empty, the year is not four digits, a given
`tmdb_id` is not a positive integer, or `format_3d` isn't one
of `THREE_D_FORMATS`.
### `parse_pool_stem`
*function · line 73*
```python
parse_pool_stem(stem: str) -> tuple[str, int] | None
```
Split a canonical stem into `(title, year)`; `None` if it isn't one.
A trailing `[tmdbid-N]` is tolerated and dropped (use `parse_tmdb_id`
to read it), so the returned title is always the clean title.
### `parse_tmdb_id`
*function · line 85*
```python
parse_tmdb_id(stem: str) -> int | None
```
Read the `[tmdbid-N]` from a canonical stem, or `None` if absent.
### `parse_3d_format`
*function · line 93*
```python
parse_3d_format(stem: str) -> str | None
```
Read the `.3D.<FORMAT>` tag from a canonical stem (upper-cased), or
`None` if absent.
### `is_canonical_pool_stem`
*function · line 102*
```python
is_canonical_pool_stem(stem: str) -> bool
```
True when `stem` matches the `Title (YYYY)` convention.
### `year_from_filename`
*function · line 107*
```python
year_from_filename(name: str) -> str | None
```
Best-effort four-digit year guessed from a raw (release) filename.
Prefers a parenthesised year (`… (1999) …`) and otherwise falls back to the
last standalone 19xx/20xx run. Used only to pre-fill the import dialog — the
user confirms it. Returns the year as a string, or `None`.
### `three_d_format_from_filename`
*function · line 129*
```python
three_d_format_from_filename(name: str) -> str | None
```
Best-effort 3D format guessed from a raw (release) filename, e.g.
`Avatar.2009.3D.HSBS.BluRay.mkv` → `"HSBS"`. Unlike `parse_3d_format`
this doesn't require the rest of the name to be a canonical pool stem — used
to pre-fill a version label while importing. Returns `None` if absent.
-103
View File
@@ -1,103 +0,0 @@
# `src/core/nfo.py`
NFO reader — Curator's metadata source after ČSFD.
Jellyfin (configured to save metadata into the media folders) writes a Kodi-style
`.nfo` next to each movie plus artwork. Curator only **reads** it — to drive the
miniDLNA genre tree and to fill the year/rating used by folder grouping. It never
writes NFO (Jellyfin owns that).
For a flat pool file `Title (rok) [tmdbid].mkv` the sidecar is
`Title (rok) [tmdbid].nfo`; a per-movie folder may instead hold `movie.nfo`.
Both are found. Missing or unparsable NFO → `None` (the movie simply has no
genres until Jellyfin has scanned it).
Source: [`src/core/nfo.py`](../../src/core/nfo.py)
## Classes
### `NfoData`
*class · line 20*
```python
class NfoData:
```
The bits of a movie `.nfo` Curator uses.
#### `NfoData.field_values`
*method · line 31*
```python
field_values(name: str) -> list[str]
```
Values for a tag-schema source field (genres/countries/year/rating).
## Functions
### `find_nfo`
*function · line 44*
```python
find_nfo(video_path: Path) -> Optional[Path]
```
Locate the `.nfo` for a movie: sibling `<stem>.nfo` or `movie.nfo`.
### `_texts`
*function · line 56*
```python
_texts(root: ET.Element, tag: str) -> list[str]
```
All non-empty stripped texts of the child elements called `tag`.
### `_parse_rating`
*function · line 60*
```python
_parse_rating(root: ET.Element) -> Optional[int]
```
Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands).
### `_parse_tmdb_id`
*function · line 74*
```python
_parse_tmdb_id(root: ET.Element) -> Optional[int]
```
The TMDb id from a Kodi NFO: a `<uniqueid type="tmdb">` element, or a legacy `<tmdbid>` element. `None` if neither holds a number.
### `_parse_csfd_url`
*function · line 82*
```python
_parse_csfd_url(root: ET.Element) -> Optional[str]
```
A ČSFD film URL from the NFO, if any.
Czech Kodi scrapers record ČSFD as `<uniqueid type="csfd">` (a bare id or a
full URL) or a legacy `<csfdid>` / `<csfd>` element. A bare id is expanded
to `https://www.csfd.cz/film/<id>/`.
### `read_nfo`
*function · line 105*
```python
read_nfo(video_path: Path) -> Optional[NfoData]
```
Read the movie's `.nfo` into `NfoData`, or None if absent/broken.
-191
View File
@@ -1,191 +0,0 @@
# `src/core/pool_index.py`
Unified metadata index for the Curator pool.
Instead of one sidecar file per movie, the whole pool keeps a single JSON index
at `<pool>/.Curator.!index`. Curator owns the pool (it inserts/removes files
itself), so files never move behind its back and a central index is safe.
The index also carries **library-level settings** (`tag_schema`,
`copyasis_folders`) under a `settings` section, so everything describing a
particular pool travels with the pool. The global config keeps only app data
(window state, MRU) and the pointers to where the pool/output live.
On-disk shape (**v2**)::
{"version": 2,
"settings": {...},
"movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
Records are keyed by the movie's **identity** (`tmdb-<id>`, or a title/year
slug when there's no id) and own their files — see `src.core.movie`. The
original **v1** shape keyed a record by one file's pool-relative path, which
made metadata a passenger on that file; `PoolIndex.load` migrates such an
index in place (backing the old file up first). Migration only rewrites
metadata — no file on disk is moved, because a one-video movie is already in
its v2 position.
Source: [`src/core/pool_index.py`](../../src/core/pool_index.py)
## Classes
### `PoolIndex`
*class · line 35*
```python
class PoolIndex:
```
Single-file JSON metadata store for all files in a pool.
#### `PoolIndex.__init__`
*method · line 38*
```python
__init__(pool_dir: Path) -> None
```
Open the index of the pool at `pool_dir` (loads it, migrating an older version if needed).
#### `PoolIndex._key`
*method · line 45*
```python
_key(file_path: Path) -> str
```
Pool-relative POSIX key for a file path.
#### `PoolIndex.load`
*method · line 53*
```python
load() -> None
```
Load the index from disk (missing/corrupt index = empty).
A v1 (path-keyed) index is migrated to v2 in place — the old file is
copied to `.Curator.!index.v1.bak` first.
#### `PoolIndex._migrate_v1_to_v2`
*method · line 76*
```python
_migrate_v1_to_v2() -> None
```
Re-key path-keyed records into identity-keyed movie records.
Each v1 record described one file; several of them can describe one
movie (the interim multi-version layout put them in a shared folder),
so they are merged by identity. Metadata only — nothing is moved on
disk, since a v1 movie's files already sit where v2 wants them.
#### `PoolIndex.save`
*method · line 139*
```python
save() -> None
```
Persist the index to disk.
#### `PoolIndex.get_setting`
*method · line 150*
```python
get_setting(key: str, default=None)
```
Return a library-level setting stored in the index.
#### `PoolIndex.set_setting`
*method · line 154*
```python
set_setting(key: str, value) -> None
```
Upsert a library-level setting and persist the index.
#### `PoolIndex.get_movie`
*method · line 163*
```python
get_movie(key: str) -> dict | None
```
Return the movie record under `key`, or None.
#### `PoolIndex.set_movie`
*method · line 167*
```python
set_movie(key: str, data: dict) -> None
```
Upsert a movie record and persist the index.
#### `PoolIndex.delete_movie`
*method · line 172*
```python
delete_movie(key: str) -> None
```
Remove a movie record (if present) and persist the index.
#### `PoolIndex.rekey_movie`
*method · line 177*
```python
rekey_movie(old_key: str, new_key: str) -> None
```
Move a record to a new identity key (e.g. its TMDb id was set/fixed).
#### `PoolIndex.all_movies`
*method · line 184*
```python
all_movies() -> dict[str, dict]
```
Every movie record, keyed by identity.
#### `PoolIndex.record_for`
*method · line 188*
```python
record_for(file_path: Path) -> dict | None
```
The movie record that owns `file_path`, or None if untracked.
#### `PoolIndex.find_movie_by_media`
*method · line 193*
```python
find_movie_by_media(file_path: Path) -> tuple[str, dict] | None
```
`(key, record)` of the movie owning `file_path`, or None.
The reverse lookup a file needs now that records are keyed by identity
rather than by path.
-227
View File
@@ -1,227 +0,0 @@
# `src/core/series.py`
Series naming + scanning for the Jellyfin TV layout.
Jellyfin wants series laid out as::
Seriály/Series (year) [tmdbid-N]/
tvshow.nfo (Jellyfin writes it)
Season 01/
Series (year) S01E01.ext
Specials/ (season 0)
Episodes are matched by `SxxEyy`. These helpers parse the episode number out of
a raw filename, build the canonical episode/season/series names, and scan a
series library into a `Series → Season → Episode` tree with a naming status.
Source: [`src/core/series.py`](../../src/core/series.py)
## Classes
### `Episode`
*class · line 49*
```python
class Episode:
```
One episode file found in a series folder: its path, its season and episode numbers (`None` when not recognised) and its part number when the episode is split across several files.
#### `Episode.code`
*property · line 58*
```python
code -> str
```
Human-readable episode code, e.g. `S01E02`, with `.ptN` appended for a part (`S01E02.pt3`). `?` when the season or episode is unknown.
### `Series`
*class · line 66*
```python
class Series:
```
One series folder with its title, year, TMDb id, library and the episodes found below it.
#### `Series.seasons`
*method · line 74*
```python
seasons() -> dict[int, list[Episode]]
```
Episodes grouped by season number (`-1` = unrecognised).
## Functions
### `parse_episode`
*function · line 84*
```python
parse_episode(name: str) -> Optional[tuple[int, int]]
```
Extract `(season, episode)` from a filename, or `None`.
### `parse_episode_part`
*function · line 94*
```python
parse_episode_part(name: str) -> Optional[int]
```
Part number implied by a Jellyfin stacking suffix in a filename, or `None`.
Looks for *cd*, *dvd*, *part*, *pt*, *disc* or *disk* + a number sitting
right before the extension (`Show S01E02.pt2.mkv`,
`Show S01E02-part-2.mkv`, …) — the same tokens Jellyfin itself scans for.
### `parse_episode_code`
*function · line 105*
```python
parse_episode_code(text: str) -> Optional[tuple[int, int, Optional[int]]]
```
Parse a compact user-typed code: `SxxEyy`, optionally + a part.
Accepts everything `parse_episode` does, plus a trailing part marker
right after it — `S01E02P3`, `S01E02 pt3`, `S01E02.part.3`,
`S01E02 cd2` — for a file that is only *one part* of that episode.
Returns `(season, episode, part)` with `part` `None` when absent, or
`None` when no `SxxEyy` is found at all.
### `collect_episode_files`
*function · line 126*
```python
collect_episode_files(folder: Path) -> list[Path]
```
Every video file under `folder` (recursively), sidecars excluded, sorted.
Lets the episode-import dialog take a whole `Series/Season NN/` tree (or any
nested layout) in one pick instead of file-by-file. Season/episode numbers
are still read per file from its name by `parse_episode`.
### `season_folder_name`
*function · line 142*
```python
season_folder_name(season: int) -> str
```
Folder name for a season — `Specials` for 0, else `Season NN`.
### `is_season_folder`
*function · line 147*
```python
is_season_folder(name: str) -> bool
```
True for a season folder name — `Specials` or `Season <n>` (any padding).
### `looks_like_series_folder`
*function · line 152*
```python
looks_like_series_folder(folder: Path) -> bool
```
True when `folder` holds at least one `Season NN`/`Specials` subfolder.
That's the shape Curator always gives a series (episodes always land in a
season folder, never loose at the series root) — a movie's version folder
never has one, its files sit flat inside it. This is how a `mixed`
library tells the two apart even though both use the identical outer
`Title (rok) [tmdbid]/` name: `FileManager.is_movie_group_member`
and `scan_series` both check it before deciding which one a
canonical-shaped subfolder is.
### `sanitize_title`
*function · line 173*
```python
sanitize_title(title: str) -> str
```
A series/episode title made safe to sit in a filename (spaces collapsed).
### `episode_filename`
*function · line 179*
```python
episode_filename(season: int, episode: int, series_title: str, ext: str, *, part: Optional[int]=None) -> str
```
Canonical episode filename `SxxEyy - {series title}.ext`.
The release string is deliberately **not** kept — identity is the `SxxEyy`
code, and Jellyfin fetches the episode title/metadata from TMDb (matched by
the code + the series folder's `[tmdbid-N]`). An empty title collapses to
`SxxEyy.ext`. `part` marks one physical part of that episode — an old
recording split across several files — with Jellyfin's own `.ptN`
stacking suffix right before the extension.
### `series_folder_stem`
*function · line 200*
```python
series_folder_stem(title: str, year: object=None, tmdb_id: object=None) -> str
```
Series folder name: `Title (year) [tmdbid-N]` (year/id optional).
Reuses the movie convention when a year is present; otherwise falls back to
`Title [tmdbid-N]` or just `Title` so a year-less series still works.
### `is_canonical_episode`
*function · line 216*
```python
is_canonical_episode(path: Path, series_title: str) -> bool
```
True when `path` is already `SxxEyy - {series title}.ext` in its season.
### `scan_series`
*function · line 229*
```python
scan_series(lib_dir: Path, library: str, *, skip_movie_folders: bool=False) -> list[Series]
```
Scan one series library into a list of `Series`.
Each immediate subfolder of `lib_dir` is a series; its name yields the
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
parsed from the filename). Non-video sidecars are skipped.
`skip_movie_folders=True` (a `mixed` library, where a movie's version
folder and a series folder both use the identical
`Title (rok) [tmdbid]/` shape) additionally skips a canonical-shaped
folder with no `Season NN`/`Specials` inside it — that's a movie's
version folder instead, left for `FileManager.load_pool_movies` to
read. Left `False` (the default, a plain `series`-kind library, which
never holds movies) every subfolder is read as a series regardless of
shape — a raw, not-yet-renamed import may have loose episode files
sitting at the series root, with no season folder yet.
-102
View File
@@ -1,102 +0,0 @@
# `src/core/subtitles.py`
External subtitle sidecars — attach, discover, keep in sync on rename.
Curator does not mux/embed subtitles. Jellyfin plays an **external** subtitle
file natively as long as it shares the video's filename stem, with an optional
language code and flag suffix::
Title (rok) [tmdbid-N].mkv
Title (rok) [tmdbid-N].cs.srt # language
Title (rok) [tmdbid-N].cs.forced.srt # + a flag (forced / sdh / default)
Subtitle presence is a **filesystem fact**, like NFO — nothing is cached in the
pool index. What Curator does own: copying a picked file into place with the
right name (`attach_subtitle`), and moving a subtitle along whenever its
video gets renamed so it never silently detaches (`rename_subtitles_with`).
Source: [`src/core/subtitles.py`](../../src/core/subtitles.py)
## Classes
### `SubtitleFile`
*class · line 30*
```python
class SubtitleFile:
```
One subtitle sidecar found next to a video.
#### `SubtitleFile.label`
*property · line 38*
```python
label -> str
```
Human-readable `jazyk (flag)` for display, e.g. `cs (forced)`.
## Functions
### `subtitle_filename`
*function · line 44*
```python
subtitle_filename(video_stem: str, ext: str, language: Optional[str]=None, flag: Optional[str]=None) -> str
```
Canonical subtitle filename: `{video_stem}[.language][.flag]{ext}`.
### `_parse_subtitle_name`
*function · line 57*
```python
_parse_subtitle_name(video_stem: str, filename: str) -> Optional[tuple[Optional[str], Optional[str]]]
```
`(language, flag)` if `filename` is a subtitle sidecar of `video_stem`.
### `find_subtitles`
*function · line 83*
```python
find_subtitles(video_path: Path) -> list[SubtitleFile]
```
Every external subtitle sidecar sitting next to `video_path`, sorted.
### `attach_subtitle`
*function · line 101*
```python
attach_subtitle(video_path: Path, source: Path, *, language: Optional[str]=None, flag: Optional[str]=None, move: bool=False) -> Path
```
Copy (or move) `source` next to `video_path`, named to match it.
An existing subtitle already at that exact name (e.g. replacing the Czech
track) is overwritten; nothing else next to the video is touched.
Raises:
ValueError: `source`'s extension isn't a recognised subtitle format.
### `rename_subtitles_with`
*function · line 128*
```python
rename_subtitles_with(old_video_path: Path, new_video_path: Path) -> list[Path]
```
Move every subtitle sidecar of `old_video_path` to match `new_video_path`.
Call this wherever a video gets renamed/moved so an attached subtitle never
silently detaches (its stem would stop matching and Jellyfin would drop it).
No-op when the video's path didn't actually change. Returns the new paths.
-37
View File
@@ -1,37 +0,0 @@
# `src/core/tag.py`
The `Tag` value object: a `(category, name)` pair identified by its `full_path` (`Category/Name`). Hashable and comparable, so tags can live in sets and be compared across the tag manager and the files.
Source: [`src/core/tag.py`](../../src/core/tag.py)
## Classes
### `Tag`
*class · line 1*
```python
class Tag:
```
A tag: a category and a name. Two tags are equal when both parts match.
#### `Tag.__init__`
*method · line 2*
```python
__init__(category: str, name: str)
```
Create a tag in `category` with the given `name`.
#### `Tag.full_path`
*property · line 7*
```python
full_path
```
The tag as `Category/Name`, the form used in the index and in strings.
-107
View File
@@ -1,107 +0,0 @@
# `src/core/tag_manager.py`
`TagManager`: the registry of all tag categories and their tags. Initialised with the default categories; the GUI tag tree and the AND-filter read from it.
Source: [`src/core/tag_manager.py`](../../src/core/tag_manager.py)
## Classes
### `TagManager`
*class · line 16*
```python
class TagManager:
```
Registry of tag categories (`tags_by_category`). Adding a tag also creates its category; removing the last tag of a category removes the category.
#### `TagManager.__init__`
*method · line 17*
```python
__init__()
```
Create the registry and fill it with the default tags.
#### `TagManager._init_default_tags`
*method · line 21*
```python
_init_default_tags()
```
Initialize default tags (ratings and colors)
#### `TagManager.add_category`
*method · line 27*
```python
add_category(category: str)
```
Create an empty category if it does not exist yet.
#### `TagManager.remove_category`
*method · line 31*
```python
remove_category(category: str)
```
Delete a category and all its tags, if it exists.
#### `TagManager.add_tag`
*method · line 35*
```python
add_tag(category: str, name: str) -> Tag
```
Register a tag in `category` (the category is created when missing) and return it.
#### `TagManager.remove_tag`
*method · line 41*
```python
remove_tag(category: str, name: str)
```
Unregister one tag. When it was the category's last tag, the category is removed too.
#### `TagManager.get_all_tags`
*method · line 48*
```python
get_all_tags()
```
Every registered tag as a `Category/Name` string.
#### `TagManager.get_categories`
*method · line 52*
```python
get_categories()
```
The names of all registered categories.
#### `TagManager.get_tags_in_category`
*method · line 55*
```python
get_tags_in_category(category: str) -> list[Tag]
```
Get tags in category, sorted by predefined order for default categories
-160
View File
@@ -1,160 +0,0 @@
# `src/core/tmdb.py`
TMDb (The Movie Database) client — Curator's identity source.
Replaces ČSFD. Its only job is to turn a **TMDb id** (or a title/year search)
into a canonical **title + year** for naming the pool file. The rich metadata
and artwork are fetched by **Jellyfin itself** — Curator does not need them here.
Needs a free API key in `TMDB_API_KEY` (loaded from `.env`). Everything
degrades gracefully when the key or the network is missing: `api_available`
reports readiness and the lookups raise `TmdbError` with a clear message.
Source: [`src/core/tmdb.py`](../../src/core/tmdb.py)
## Classes
### `TmdbResult`
*class · line 29*
```python
class TmdbResult:
```
A resolved TMDb entry — just enough to name the pool file.
### `TmdbDetails`
*class · line 39*
```python
class TmdbDetails:
```
Full TMDb data used to (re)generate a movie's tags (genres etc.).
Exposes `field_values` so it plugs into the tag schema the same way the
NFO reader does (fields: genres / countries / year / rating).
#### `TmdbDetails.field_values`
*method · line 53*
```python
field_values(name: str) -> list[str]
```
The values of one tag source field (`genres`, `countries`, `year`, `rating`, …) as a list of strings, ready to become tags. Unknown field names return an empty list.
### `TmdbError`
*class · line 65*
```python
class TmdbError(RuntimeError):
```
A TMDb request could not be completed (no key, network, bad status).
## Functions
### `api_key`
*function · line 69*
```python
api_key() -> Optional[str]
```
The configured TMDb API key, or None.
### `api_available`
*function · line 75*
```python
api_available() -> bool
```
True when a TMDb API key is configured.
### `_get`
*function · line 80*
```python
_get(path: str, params: dict) -> dict
```
GET a TMDb API path with the configured key and language. Raises `TmdbError` on a missing key, a network failure or an error response.
### `_year_of`
*function · line 98*
```python
_year_of(date_str: Optional[str]) -> Optional[str]
```
The four-digit year at the start of a TMDb date (`YYYY-MM-DD`), or `None`.
### `lookup_movie`
*function · line 104*
```python
lookup_movie(tmdb_id: int) -> Optional[TmdbResult]
```
Resolve a movie by its TMDb id → title + year, or None if it has no title.
### `movie_details`
*function · line 113*
```python
movie_details(tmdb_id: int) -> TmdbDetails
```
Full movie data from TMDb (genres/countries/rating/year), localized to cs.
### `lookup_tv`
*function · line 127*
```python
lookup_tv(tmdb_id: int) -> Optional[TmdbResult]
```
Resolve a series by its TMDb id → name + first-air year, or None.
### `lookup`
*function · line 136*
```python
lookup(tmdb_id: int, kind: str='movie') -> Optional[TmdbResult]
```
Resolve a TMDb id as a movie (default) or a series (`kind="tv"`).
### `search_movie`
*function · line 141*
```python
search_movie(query: str, year: Optional[str]=None, limit: int=5) -> list[TmdbResult]
```
Search movies by title (optionally narrowed by year); best matches first.
### `search_tv`
*function · line 161*
```python
search_tv(query: str, year: Optional[str]=None, limit: int=5) -> list[TmdbResult]
```
Search series by name (optionally narrowed by first-air year).
-43
View File
@@ -1,43 +0,0 @@
# `src/core/transforms.py`
Tag-schema value transforms for miniDLNA folder names.
Applied when building a folder name from a tag value — the tag keeps its exact
value (e.g. rating 87 → tag "Hodnocení/87"), while the folder groups it
(→ "…/80–89 %"). Config-driven and extensible via `TAG_TRANSFORMS`.
Source: [`src/core/transforms.py`](../../src/core/transforms.py)
## Functions
### `rating_band`
*function · line 11*
```python
rating_band(rating: int) -> str
```
Bucket a 0–100 rating into a ten-point band label (e.g. "80–89 %").
The top bucket spans 90–100 % so a perfect 100 still lands in a band.
### `_decade_band`
*function · line 21*
```python
_decade_band(value) -> str
```
`decade_band` transform: pull the leading integer and bucket it.
### `apply_transform`
*function · line 35*
```python
apply_transform(value: str, transform: Optional[str]) -> str
```
Apply a named tag-schema transform to a value, returning a string.
-17
View File
@@ -1,17 +0,0 @@
# `src/core/utils.py`
Small filesystem helpers shared across the core modules.
Source: [`src/core/utils.py`](../../src/core/utils.py)
## Functions
### `list_files`
*function · line 3*
```python
list_files(folder_path: str | Path) -> list[Path]
```
All regular files below `folder_path`, searched recursively. Raises `NotADirectoryError` when the path is not a folder.
-53
View File
@@ -1,53 +0,0 @@
# `Curator.py`
Application entry point. Creates the shared `FileManager` and `TagManager`, applies the OS certificate store for HTTPS, and starts the Qt main window.
Source: [`Curator.py`](../../Curator.py)
## Classes
### `State`
*class · line 21*
```python
class State:
```
Holds the application's shared services: one `TagManager` and one `FileManager` built on top of it. Created once in `main()`.
#### `State.__init__`
*method · line 22*
```python
__init__() -> None
```
Create the tag manager and the file manager that uses it.
## Functions
### `_use_system_certificates`
*function · line 7*
```python
_use_system_certificates() -> None
```
Make HTTPS (ČSFD fetching) use the OS certificate store.
Required behind corporate SSL inspection, where the proxy's root CA is in the
Windows trust store but not in certifi's bundle. Best-effort; ignored if the
optional `truststore` package is unavailable.
### `main`
*function · line 27*
```python
main() -> None
```
Program entry point: installs the system certificate store, builds the `State` and runs the Qt application on its file manager and tag manager.
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
//! Developer tool: render Curator offscreen (no window) against the pool in
//! `$CURATOR_CONFIG` and save a PNG. `--action <name>` opens a dialog first
//! (`edit`, `stats`, `schema`, `libraries`, `series`, `duplicates`, `tags`).
//!
//! ```text
//! CURATOR_CONFIG=/tmp/cfg.json cargo run --example screenshot -- /tmp/shot.png --action edit
//! ```
use std::path::PathBuf;
use curator::core::file_manager::FileManager;
use curator::ui::app::CuratorApp;
use egui_kittest::Harness;
fn main() -> Result<(), String> {
let args: Vec<String> = std::env::args().collect();
let out = PathBuf::from(
args.get(1)
.cloned()
.unwrap_or_else(|| "screenshot.png".into()),
);
let action = args.iter().skip_while(|a| *a != "--action").nth(1).cloned();
let fm = FileManager::new();
let mut harness = Harness::builder()
.with_size(egui::vec2(1300.0, 800.0))
.allow_missing_glyphs()
.with_accessibility_check(false)
.wgpu()
.build_eframe(|cc| CuratorApp::new(fm, cc.egui_ctx.clone()));
harness.run_steps(3);
if let Some(a) = action {
harness.state_mut().debug_action(&a);
harness.run_steps(3);
}
let image = harness.render()?;
image.save(&out).map_err(|e| e.to_string())?;
println!("saved {}", out.display());
Ok(())
}
-67
View File
@@ -1,67 +0,0 @@
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from src.constants import VERSION
load_dotenv()
print("=" * 50)
print("PREBUILD CONFIGURATION")
print("=" * 50)
# Check if running in virtual environment
project_root = Path(__file__).parent
expected_venv_path = project_root / ".venv"
current_executable = Path(sys.executable)
print(f"\nPython executable: {sys.executable}")
is_correct_venv = False
try:
current_executable.relative_to(expected_venv_path)
is_correct_venv = True
except ValueError:
is_correct_venv = False
if is_correct_venv:
print("✓ Correct environment selected for building")
else:
print("✗ Wrong environment selected")
print(f" Expected: {expected_venv_path}")
print(f" Current: {current_executable.parent.parent}")
print(f"✓ Version: {VERSION}")
env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true"
console_mode = env_debug
default_spec = Path(__file__).parent.name + ".spec"
spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec)
print(f"\n{'-' * 50}")
print("BUILD SETTINGS")
print(f"{'-' * 50}")
print(f"ENV_DEBUG: {env_debug}")
print(f"Console mode: {console_mode}")
print(f"Spec file: {spec_filename}")
spec_path = Path(__file__).parent / spec_filename
if spec_path.exists():
with open(spec_path, "r", encoding="utf-8") as f:
spec_content = f.read()
if f"console={not console_mode}" in spec_content:
new_spec_content = spec_content.replace(
f"console={not console_mode}", f"console={console_mode}"
)
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_spec_content)
print(f"✓ Updated {spec_filename}: console={console_mode}")
else:
print(f"✓ {spec_filename} already configured: console={console_mode}")
else:
print(f"✗ {spec_filename} not found!")
print(f"{'-' * 50}\n")
-26
View File
@@ -1,26 +0,0 @@
[project]
name = "curator"
version = "1.14.0"
requires-python = ">=3.14"
dependencies = [
"beautifulsoup4>=4.15.0",
"loguru>=0.7.3",
"pyside6>=6.12.0",
"python-dotenv>=1.2.4",
"requests>=2.34.2",
"truststore>=0.10.4",
]
[dependency-groups]
dev = [
"mypy>=2.4.0",
"pyinstaller>=6.22.3",
"pytest>=9.1.1",
"ruff>=0.17.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
files = ["Curator.py", "prebuild.py", "src", "tests", "scripts"]
-107
View File
@@ -1,107 +0,0 @@
"""One-off migration: rename a tag category inside a pool's metadata index.
Tags are stored in ``<pool>/.Curator.!index`` as ``"Category/Name"`` strings.
This rewrites every tag whose category matches ``--old`` to use ``--new``,
leaving the tag name untouched. A timestamped backup of the index is written
before saving.
Usage:
uv run python scripts/migrate_tag_category.py <pool_dir> \
--old "Země" --new "Země původu"
If ``<pool_dir>`` is omitted, the pool from the global config is used.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int]:
"""Return (rewritten tags, number of tags changed) for one record."""
prefix = f"{old}/"
changed = 0
result: list[str] = []
for tag in tags:
if isinstance(tag, str) and tag.startswith(prefix):
result.append(f"{new}/{tag[len(prefix) :]}")
changed += 1
else:
result.append(tag)
return result, changed
def migrate(index_path: Path, old: str, new: str) -> int:
"""Rewrite the category in place and return the number of tags changed."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_changed = 0
affected_records = 0
for key, record in movies.items():
tags = record.get("tags", [])
new_tags, changed = _rename_in_tags(tags, old, new)
if changed:
record["tags"] = new_tags
total_changed += changed
affected_records += 1
logger.debug(f"{key}: {changed} tag(s) renamed")
if total_changed == 0:
logger.info(f"No '{old}/…' tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Migrated '{old}' → '{new}': {total_changed} tag(s) "
f"across {affected_records} record(s)"
)
return total_changed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument("--old", default="Země", help="Category to rename from")
parser.add_argument("--new", default="Země původu", help="Category to rename to")
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.old, args.new)
if __name__ == "__main__":
main()
-124
View File
@@ -1,124 +0,0 @@
"""One-off migration: split combined country tags in a pool's metadata index.
Before multi-country support, a co-production fetched from ČSFD was stored as a
single ``"Země původu/USA / Velká Británie"`` tag. This rewrites each such tag
into one tag per country (``"Země původu/USA"`` + ``"Země původu/Velká
Británie"``), de-duplicating within each record. A timestamped backup of the
index is written before saving.
Usage:
uv run python scripts/split_country_tags.py [<pool_dir>] [--category "Země původu"]
If ``<pool_dir>`` is omitted, the pool from the global config is used.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
"""Return (rewritten tags, number of combined tags split) for one record.
Order is preserved; duplicates produced by the split are dropped.
"""
prefix = f"{category}/"
result: list[str] = []
seen: set[str] = set()
split_count = 0
def _add(tag: str) -> None:
if tag not in seen:
seen.add(tag)
result.append(tag)
for tag in tags:
if (
isinstance(tag, str)
and tag.startswith(prefix)
and "/" in tag[len(prefix) :]
):
value = tag[len(prefix) :]
countries = [c.strip() for c in value.split("/") if c.strip()]
for country in countries:
_add(f"{prefix}{country}")
split_count += 1
else:
_add(tag)
return result, split_count
def migrate(index_path: Path, category: str) -> int:
"""Split combined ``category`` tags in place; return number of tags split."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_split = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
new_tags, split_count = _split_record_tags(tags, category)
if split_count:
record["tags"] = new_tags
total_split += split_count
affected += 1
logger.debug(f"{key}: {split_count} combined tag(s) split")
if total_split == 0:
logger.info(f"No combined '{category}/…' tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Split {total_split} combined '{category}' tag(s) across {affected} record(s)"
)
return total_split
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument(
"--category", default="Země původu", help="Tag category to split"
)
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.category)
if __name__ == "__main__":
main()
-111
View File
@@ -1,111 +0,0 @@
"""One-off migration: drop the old decade-band rating tags from a pool index.
Earlier the ČSFD rating was stored bucketed (e.g. ``Hodnocení/90–100 %``). Now
the tag carries the exact value (``Hodnocení/90``) and the band is only a folder.
This removes the legacy band tags (``Hodnocení/<x>–<y> %``) so re-fetching from
ČSFD leaves only the exact ratings. Exact rating tags are kept. A timestamped
backup of the index is written first.
Usage:
uv run python scripts/strip_rating_bands.py [<pool_dir>] [--category "Hodnocení"]
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
# A band tag looks like "Hodnocení/90–100 %" — value has a dash range and a "%".
_BAND_RE = re.compile(r"\d+\s*[–-]\s*\d+\s*%")
def _strip_bands(tags: list[str], category: str) -> tuple[list[str], int]:
"""Return (kept tags, removed count), dropping ``category`` band tags."""
prefix = f"{category}/"
kept = [
t
for t in tags
if not (isinstance(t, str) and t.startswith(prefix) and _BAND_RE.search(t))
]
return kept, len(tags) - len(kept)
def migrate(index_path: Path, category: str) -> int:
"""Remove band rating tags in place; return number of tags removed."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_removed = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
kept, removed = _strip_bands(tags, category)
if removed:
record["tags"] = kept
# also drop them from the ČSFD provenance set, if present
if isinstance(record.get("csfd_tags"), list):
record["csfd_tags"] = [
t
for t in record["csfd_tags"]
if not (t.startswith(f"{category}/") and _BAND_RE.search(t))
]
total_removed += removed
affected += 1
logger.debug(f"{key}: removed {removed} band tag(s)")
if total_removed == 0:
logger.info(f"No '{category}/…–… %' band tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Removed {total_removed} band '{category}' tag(s) across {affected} record(s)"
)
return total_removed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument("--category", default="Hodnocení", help="Rating category")
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.category)
if __name__ == "__main__":
main()
-101
View File
@@ -1,101 +0,0 @@
"""One-off migration: drop all tags of given categories from a pool's index.
Used to remove tag categories that turned out to be a bad idea (e.g. Režie /
Herec produced far too many folders). Cached ČSFD data is left intact — only the
``tags`` lists are pruned. A timestamped backup of the index is written first.
Usage:
uv run python scripts/strip_tag_categories.py [<pool_dir>] \
--categories "Režie" "Herec"
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _strip(tags: list[str], prefixes: tuple[str, ...]) -> tuple[list[str], int]:
"""Return (kept tags, number removed) dropping tags under any prefix."""
kept = [t for t in tags if not (isinstance(t, str) and t.startswith(prefixes))]
return kept, len(tags) - len(kept)
def migrate(index_path: Path, categories: list[str]) -> int:
"""Remove all tags of ``categories`` in place; return number of tags removed."""
prefixes = tuple(f"{c}/" for c in categories)
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_removed = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
kept, removed = _strip(tags, prefixes)
if removed:
record["tags"] = kept
total_removed += removed
affected += 1
logger.debug(f"{key}: removed {removed} tag(s)")
if total_removed == 0:
logger.info(f"No tags in {categories} found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Removed {total_removed} tag(s) of {categories} across {affected} record(s)"
)
return total_removed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument(
"--categories",
nargs="+",
default=["Režie", "Herec"],
help="Tag categories to strip",
)
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.categories)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -7,6 +7,6 @@ RequiresMountsFor=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0
Type=oneshot
User=honza
Group=honza
ExecStart=/usr/bin/python3 /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka/Tools/tipy_dne.py --pool /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka --output /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmy --count 15
ExecStart=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka/Tools/tipy-dne --pool /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka --output /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmy --count 15
Nice=10
NoNewPrivileges=true
-143
View File
@@ -1,143 +0,0 @@
#!/usr/bin/env python3
"""Standalone "Tipy dne" generator — deployable next to a Filmotéka (e.g. cron).
On each run it reads the pool index (``<pool>/.Curator.!index``), picks N random
pool movies, clears the tips folder in the output and recreates it as hardlinks.
It does not import Curator — only the Python standard library — so it can live on
the server with the Filmotéka, independent of the app.
Paths come from CLI args, or fall back to the global config (``.Curator.!gtag``,
keys ``pool_dir`` / ``filmoteka_dir``) when ``--config`` is given or one is found.
Examples:
python scripts/tipy_dne.py --pool /mnt/srv/Filmoteka --output /mnt/srv/Filmy/Filmoteka_v2
python scripts/tipy_dne.py --count 15 # uses the repo's global config
"""
from __future__ import annotations
import argparse
import json
import os
import random
import sys
from pathlib import Path
INDEX_FILENAME = ".Curator.!index"
GLOBAL_CONFIG_FILENAME = ".Curator.!gtag"
DEFAULT_SUBFOLDER = "- Tipy dne" # "- " prefix sorts it first in DLNA/TV browsers
DEFAULT_COUNT = 15
def _load_config_paths(config_path: Path) -> tuple[Path | None, Path | None]:
"""Read (pool_dir, filmoteka_dir) from a global config file, if present."""
try:
with open(config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except OSError, json.JSONDecodeError:
return None, None
pool = cfg.get("pool_dir")
out = cfg.get("filmoteka_dir")
return (Path(pool) if pool else None, Path(out) if out else None)
def _pool_movies(pool_dir: Path) -> list[Path]:
"""Existing pool movie files listed in the index (copy-as-is folders aren't
indexed, so this is the pool/Filmy set only)."""
index_path = pool_dir / INDEX_FILENAME
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: list[Path] = []
for rel in data.get("movies", {}):
path = pool_dir / rel
if path.is_file():
movies.append(path)
return movies
def _clear_folder(folder: Path) -> int:
"""Remove the files in ``folder`` (the old tips). Returns how many removed."""
removed = 0
if folder.exists():
for child in folder.iterdir():
if child.is_file() or child.is_symlink():
try:
child.unlink()
removed += 1
except OSError as exc:
print(f" ! nelze smazat {child}: {exc}", file=sys.stderr)
return removed
def generate_tips(pool_dir: Path, output_dir: Path, count: int, subfolder: str) -> int:
"""Refresh the tips folder with ``count`` random pool movies as hardlinks."""
movies = _pool_movies(pool_dir)
if not movies:
print("Index neobsahuje žádné filmy.", file=sys.stderr)
return 0
tips_dir = output_dir / subfolder
cleared = _clear_folder(tips_dir)
tips_dir.mkdir(parents=True, exist_ok=True)
chosen = random.sample(movies, min(count, len(movies)))
created = 0
for src in chosen:
target = tips_dir / src.name
try:
if target.exists():
target.unlink()
os.link(src, target)
created += 1
print(f" + {src.name}")
except OSError as exc:
print(f" ! {src.name}: {exc}", file=sys.stderr)
print(f"\nTipy dne: smazáno {cleared}, vytvořeno {created} (z {len(movies)} filmů)")
return created
def main() -> None:
parser = argparse.ArgumentParser(description="Vygeneruje složku Tipy dne.")
parser.add_argument(
"--pool", type=Path, help="Pool root (obsahuje .Curator.!index)"
)
parser.add_argument("--output", type=Path, help="Výstupní složka Filmotéky")
parser.add_argument(
"--count",
type=int,
default=DEFAULT_COUNT,
help=f"Počet náhodných titulů (výchozí {DEFAULT_COUNT})",
)
parser.add_argument(
"--subfolder",
default=DEFAULT_SUBFOLDER,
help=f'Název složky tipů (výchozí "{DEFAULT_SUBFOLDER}")',
)
parser.add_argument(
"--config",
type=Path,
help="Cesta ke global configu (.Curator.!gtag) pro doplnění cest",
)
args = parser.parse_args()
pool, output = args.pool, args.output
if pool is None or output is None:
config_path = args.config or (
Path(__file__).resolve().parent.parent / GLOBAL_CONFIG_FILENAME
)
cfg_pool, cfg_out = _load_config_paths(config_path)
pool = pool or cfg_pool
output = output or cfg_out
if pool is None or output is None:
parser.error("Chybí --pool a/nebo --output (a nenalezeny v global configu).")
if not (pool / INDEX_FILENAME).is_file():
parser.error(f"Index nenalezen: {pool / INDEX_FILENAME}")
created = generate_tips(pool, output, args.count, args.subfolder)
sys.exit(0 if created else 1)
if __name__ == "__main__":
main()
View File
-2
View File
@@ -1,2 +0,0 @@
"""Auto-generated — do not edit manually."""
__version__ = "1.14.0"
+159
View File
@@ -0,0 +1,159 @@
//! Standalone "Tipy dne" generator — deployable next to the miniDLNA output
//! (e.g. a systemd timer). Each run reads the pool index, picks N random
//! movies, empties the tips folder and refills it with hardlinks.
//!
//! ```text
//! tipy-dne --pool /mnt/srv/Filmoteka --output /mnt/srv/Filmy --count 15
//! tipy-dne --count 15 # pool/output from the global config
//! ```
use std::path::PathBuf;
use std::process::ExitCode;
use curator::core::config::{global_config_path, load_global_config_from};
use curator::core::file::File;
use curator::core::file_manager::TIPS_SUBFOLDER;
use curator::core::hardlink_manager::HardlinkManager;
use curator::core::pool_index::INDEX_FILENAME;
const DEFAULT_COUNT: usize = 15;
struct Args {
pool: Option<PathBuf>,
output: Option<PathBuf>,
count: usize,
subfolder: String,
config: Option<PathBuf>,
}
fn usage() -> String {
format!(
"Vygeneruje složku Tipy dne.\n\n\
Použití: tipy-dne [--pool DIR] [--output DIR] [--count N] [--subfolder NAME] [--config FILE]\n\n\
--pool pool (obsahuje {INDEX_FILENAME})\n\
--output výstupní složka miniDLNA\n\
--count počet náhodných titulů (výchozí {DEFAULT_COUNT})\n\
--subfolder název složky tipů (výchozí \"{TIPS_SUBFOLDER}\")\n\
--config global config pro doplnění cest (výchozí: config Curatoru)"
)
}
fn parse_args() -> Result<Args, String> {
let mut args = Args {
pool: None,
output: None,
count: DEFAULT_COUNT,
subfolder: TIPS_SUBFOLDER.into(),
config: None,
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
let mut value = || it.next().ok_or_else(|| format!("{arg} potřebuje hodnotu"));
match arg.as_str() {
"--pool" => args.pool = Some(value()?.into()),
"--output" => args.output = Some(value()?.into()),
"--count" => {
args.count = value()?
.parse()
.map_err(|_| "--count musí být číslo".to_string())?
}
"--subfolder" => args.subfolder = value()?,
"--config" => args.config = Some(value()?.into()),
"-h" | "--help" => return Err(usage()),
other => return Err(format!("Neznámý argument: {other}\n\n{}", usage())),
}
}
Ok(args)
}
/// Pool movies listed in the index (v2: every media file of every record;
/// v1: the keys themselves are pool-relative paths). Missing files are skipped.
fn pool_movies(pool: &std::path::Path) -> Result<Vec<File>, String> {
let text = std::fs::read_to_string(pool.join(INDEX_FILENAME))
.map_err(|e| format!("Index nelze přečíst: {e}"))?;
let data: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("Index je poškozený: {e}"))?;
let Some(movies) = data.get("movies").and_then(|m| m.as_object()) else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for (key, record) in movies {
let title = record
.get("title")
.and_then(|t| t.as_str())
.filter(|t| !t.is_empty())
.map(String::from);
let files: Vec<String> = match record.get("media").and_then(|m| m.as_array()) {
Some(media) => media
.iter()
.filter_map(|m| m.get("file").and_then(|f| f.as_str()).map(String::from))
.take(1)
.collect(),
None => vec![key.clone()],
};
for rel in files {
let path = curator::core::paths::join_posix(pool, &rel);
if path.is_file() {
let mut f = File::bare(&path);
f.title = title.clone();
out.push(f);
}
}
}
Ok(out)
}
fn main() -> ExitCode {
let mut args = match parse_args() {
Ok(a) => a,
Err(msg) => {
eprintln!("{msg}");
return ExitCode::from(2);
}
};
if args.pool.is_none() || args.output.is_none() {
let cfg = load_global_config_from(&args.config.clone().unwrap_or_else(global_config_path));
args.pool = args.pool.or(cfg.pool_dir.map(PathBuf::from));
args.output = args.output.or(cfg.filmoteka_dir.map(PathBuf::from));
}
let (Some(pool), Some(output)) = (args.pool, args.output) else {
eprintln!(
"Chybí --pool a/nebo --output (a nenalezeny v global configu).\n\n{}",
usage()
);
return ExitCode::from(2);
};
if !pool.join(INDEX_FILENAME).is_file() {
eprintln!("Index nenalezen: {}", pool.join(INDEX_FILENAME).display());
return ExitCode::from(2);
}
let movies = match pool_movies(&pool) {
Ok(m) => m,
Err(e) => {
eprintln!("{e}");
return ExitCode::FAILURE;
}
};
if movies.is_empty() {
eprintln!("Index neobsahuje žádné filmy.");
return ExitCode::FAILURE;
}
let mut manager = HardlinkManager::new(&output);
let (created, failed) =
manager.generate_random_tips(&movies, args.count, &args.subfolder, false);
for link in &manager.created_links {
println!(" + {}", curator::core::paths::name(link));
}
for (src, err) in &manager.errors {
eprintln!(" ! {}: {err}", curator::core::paths::name(src));
}
println!(
"\nTipy dne: vytvořeno {created}, selhalo {failed} (z {} filmů)",
movies.len()
);
if created > 0 {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
-69
View File
@@ -1,69 +0,0 @@
"""
Application-level constants for Curator (build/runtime metadata).
This module derives the app version and debug flag. The version is loaded from
`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for
frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`).
It is the single source of truth for app metadata used across the GUI and build
tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`).
"""
import os
import tomllib
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
_PYPROJECT_PATH = Path(__file__).parent.parent / "pyproject.toml"
_VERSION_FILE = Path(__file__).parent / "_version.py"
APP_NAME: str = "Curator"
def get_version() -> str:
"""Return the project version (pyproject → _version.py → unknown)."""
# 1. pyproject.toml (preferred)
try:
with open(_PYPROJECT_PATH, "rb") as f:
version = tomllib.load(f)["project"]["version"]
# Cache a fallback for frozen/PyInstaller builds (best-effort).
try:
_VERSION_FILE.write_text(
f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n',
encoding="utf-8",
)
except OSError:
pass
return version
except FileNotFoundError, KeyError:
pass
# 2. generated _version.py
try:
from src._version import __version__ # type: ignore[import]
return __version__
except ImportError:
pass
# 3. last resort
return "0.0.0-unknown"
def get_debug_mode() -> bool:
"""Return True when ENV_DEBUG is set to a truthy value in the environment."""
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
APP_VERSION: str = get_version()
ENV_DEBUG: bool = get_debug_mode()
APP_TITLE: str = f"{APP_NAME} v{APP_VERSION}" + ("-DEV" if ENV_DEBUG else "")
# Backwards-compatible alias used by prebuild.py
VERSION: str = APP_VERSION
# Default main-window geometry (used when no saved geometry exists)
APP_VIEWPORT: str = "1000x700"
+24
View File
@@ -0,0 +1,24 @@
//! Application-level constants (name, version, debug flag, window title).
use std::env;
pub const APP_NAME: &str = "Curator";
/// The project version — `Cargo.toml` is the single source of truth.
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Default main-window size when no geometry has been saved yet.
pub const DEFAULT_WINDOW_SIZE: (f32, f32) = (1200.0, 800.0);
/// True when `ENV_DEBUG` is set to a truthy value (`true` / `1` / `yes`).
pub fn debug_mode() -> bool {
env::var("ENV_DEBUG")
.map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
.unwrap_or(false)
}
/// `Curator v1.2.3`, with a `-DEV` suffix in debug mode.
pub fn app_title() -> String {
let suffix = if debug_mode() { "-DEV" } else { "" };
format!("{APP_NAME} v{APP_VERSION}{suffix}")
}
View File
-121
View File
@@ -1,121 +0,0 @@
"""
Configuration for Curator.
Two stores, by responsibility:
1. Global config (``.Curator.!gtag`` next to Curator.py) — **application data
only**: window state, MRU folders, and the pointers to where the pool and the
Filmotéka output live (``pool_dir`` / ``filmoteka_dir``). Those pointers must
stay here because the pool index is found *through* them.
2. Pool index (``<pool>/.Curator.!index``, see ``pool_index.py``) — everything
describing a particular library: per-movie metadata **and** library-level
settings (``tag_schema``, ``copyasis_folders``). It travels with the pool.
"""
import json
from pathlib import Path
# Global config file (next to the main script)
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
# =============================================================================
# TAG SCHEMA - default library settings (stored per-pool in the index)
# =============================================================================
# Tag schema: the single source of truth for which tag categories exist, how
# they are derived from ČSFD, and how they map to the Filmotéka folder tree.
# Each entry:
# category — tag category name (e.g. "Žánr")
# csfd_field — NFO source field feeding it (genres / year / countries /
# rating), or None for a user-only category. (Key name kept for
# index compatibility; the data now comes from the NFO.)
# transform — named value transform: None (str) or "decade_band" (rating)
# filmoteka_root — folder under the output: "" = at the root (e.g. genres),
# "Dle X" = grouping folder, None = filterable but no folders
# filename_template — optional per-category hardlink name used only inside this
# category's folders (e.g. "{year} - {title}{ext}"); fields:
# title / year / rating / ext / stem / filename. Absent/None =
# keep the pool filename. Pool files are never renamed by this.
# Grouping folders are prefixed with "- " so DLNA/TV browsers sort the special
# folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root.
# This lives in the code as the default; the effective schema is stored per-pool
# in the index (``PoolIndex.settings["tag_schema"]``).
DEFAULT_TAG_SCHEMA: list[dict] = [
{
"category": "Žánr",
"csfd_field": "genres",
"transform": None,
"filmoteka_root": "",
},
{
"category": "Rok",
"csfd_field": "year",
"transform": None,
"filmoteka_root": "- Dle roku",
},
{
"category": "Země původu",
"csfd_field": "countries",
"transform": None,
"filmoteka_root": "- Dle země původu",
},
{
"category": "Hodnocení",
"csfd_field": "rating",
"transform": "decade_band",
"filmoteka_root": "- Dle hodnocení",
},
]
# =============================================================================
# GLOBAL CONFIG - Application settings only
# =============================================================================
DEFAULT_GLOBAL_CONFIG = {
"window_geometry": "1200x800",
"window_maximized": False,
"last_folder": None,
"sidebar_width": 250,
"recent_folders": [],
"pool_dir": None, # managed pool root (single source of truth)
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
}
def load_global_config() -> dict:
"""Load global application config"""
if GLOBAL_CONFIG_FILE.exists():
try:
with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Merge with defaults for any missing keys
for key, value in DEFAULT_GLOBAL_CONFIG.items():
if key not in config:
config[key] = value
return config
except OSError, ValueError, TypeError:
return DEFAULT_GLOBAL_CONFIG.copy()
return DEFAULT_GLOBAL_CONFIG.copy()
def save_global_config(cfg: dict):
"""Save global application config"""
with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
# =============================================================================
# BACKWARDS COMPATIBILITY
# =============================================================================
def load_config():
"""Legacy function - returns global config"""
return load_global_config()
def save_config(cfg: dict):
"""Legacy function - saves global config"""
save_global_config(cfg)
+220
View File
@@ -0,0 +1,220 @@
//! Configuration for Curator.
//!
//! Two stores, by responsibility:
//!
//! 1. Global config (per user, in the platform config directory) — **application
//! data only**: window state and the pointers to where the pool and the
//! miniDLNA output live (`pool_dir` / `filmoteka_dir`). Those pointers must stay
//! here because the pool index is found *through* them.
//! 2. Pool index (`<pool>/.Curator.!index`, see `pool_index`) — everything
//! describing a particular library: per-movie metadata **and** library-level
//! settings (`tag_schema`, …). It travels with the pool.
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::warn;
use super::error::CoreResult;
/// File name of the global config (the same name the Python version used, so an
/// old config can simply be copied over).
pub const GLOBAL_CONFIG_NAME: &str = ".Curator.!gtag";
/// One tag-schema entry: which tag category exists, which NFO field feeds it,
/// and how it maps to the miniDLNA folder tree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TagSchemaEntry {
/// Tag category name (e.g. "Žánr").
pub category: String,
/// NFO source field (genres / year / countries / rating), or `None` for a
/// user-only category. The key name is kept for index compatibility.
#[serde(default)]
pub csfd_field: Option<String>,
/// Named value transform (`None` = as-is, `"decade_band"` for ratings).
#[serde(default)]
pub transform: Option<String>,
/// Folder under the output: `""` = at the root (genres), `"- Dle X"` =
/// grouping folder, `None` = filterable but no folders.
#[serde(default)]
pub filmoteka_root: Option<String>,
/// Optional hardlink-name template used only inside this category's folders
/// (e.g. `"{year} - {title}{ext}"`). `None` keeps the pool filename.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename_template: Option<String>,
}
impl TagSchemaEntry {
fn new(category: &str, field: &str, transform: Option<&str>, root: &str) -> Self {
Self {
category: category.into(),
csfd_field: Some(field.into()),
transform: transform.map(Into::into),
filmoteka_root: Some(root.into()),
filename_template: None,
}
}
}
/// The built-in schema. Grouping folders are prefixed with "- " so DLNA/TV
/// browsers sort the special folders before the genre folders at the root. The
/// effective schema is stored per pool in the index.
pub fn default_tag_schema() -> Vec<TagSchemaEntry> {
vec![
TagSchemaEntry::new("Žánr", "genres", None, ""),
TagSchemaEntry::new("Rok", "year", None, "- Dle roku"),
TagSchemaEntry::new("Země původu", "countries", None, "- Dle země původu"),
TagSchemaEntry::new(
"Hodnocení",
"rating",
Some("decade_band"),
"- Dle hodnocení",
),
]
}
/// Global application settings. Unknown keys are kept and written back.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GlobalConfig {
#[serde(default = "default_geometry")]
pub window_geometry: String,
#[serde(default)]
pub window_maximized: bool,
/// Managed pool root (single source of truth).
#[serde(default)]
pub pool_dir: Option<String>,
/// Generated miniDLNA output (hardlink tree).
#[serde(default)]
pub filmoteka_dir: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
fn default_geometry() -> String {
"1200x800".into()
}
impl Default for GlobalConfig {
fn default() -> Self {
Self {
window_geometry: default_geometry(),
window_maximized: false,
pool_dir: None,
filmoteka_dir: None,
extra: Map::new(),
}
}
}
/// Where the global config lives: `$CURATOR_CONFIG` if set, otherwise
/// `<platform config dir>/curator/.Curator.!gtag`.
pub fn global_config_path() -> PathBuf {
if let Ok(p) = std::env::var("CURATOR_CONFIG") {
return PathBuf::from(p);
}
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("curator")
.join(GLOBAL_CONFIG_NAME)
}
/// Load the config from `path`; a missing or unreadable file gives the defaults.
pub fn load_global_config_from(path: &Path) -> GlobalConfig {
let Ok(text) = fs::read_to_string(path) else {
return GlobalConfig::default();
};
serde_json::from_str(&text).unwrap_or_else(|e| {
warn!(
"global config {} is invalid ({e}); using defaults",
path.display()
);
GlobalConfig::default()
})
}
/// Load the global config. On first run, a `.Curator.!gtag` left by the Python
/// version in the working directory is picked up so the pool paths carry over.
pub fn load_global_config() -> GlobalConfig {
let path = global_config_path();
if !path.exists() {
let legacy = PathBuf::from(GLOBAL_CONFIG_NAME);
if legacy.is_file() {
return load_global_config_from(&legacy);
}
}
load_global_config_from(&path)
}
pub fn save_global_config_to(cfg: &GlobalConfig, path: &Path) -> CoreResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_string_pretty(cfg)?)?;
Ok(())
}
pub fn save_global_config(cfg: &GlobalConfig) -> CoreResult<()> {
save_global_config_to(cfg, &global_config_path())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_missing_file_gives_defaults() {
let dir = tempfile::tempdir().unwrap();
let cfg = load_global_config_from(&dir.path().join("nope"));
assert_eq!(cfg, GlobalConfig::default());
}
#[test]
fn test_load_invalid_json_gives_defaults() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("cfg");
fs::write(&p, "{not json").unwrap();
assert_eq!(load_global_config_from(&p), GlobalConfig::default());
}
#[test]
fn test_save_and_load_roundtrip_keeps_unknown_keys() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("sub").join("cfg");
fs::write(dir.path().join("x"), "").unwrap();
let mut cfg = GlobalConfig {
pool_dir: Some("/pool".into()),
..Default::default()
};
cfg.extra.insert("sidebar_width".into(), Value::from(250));
save_global_config_to(&cfg, &p).unwrap();
assert_eq!(load_global_config_from(&p), cfg);
}
#[test]
fn test_load_python_config_merges_missing_keys() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("cfg");
fs::write(&p, r#"{"pool_dir": "/mnt/pool", "recent_folders": []}"#).unwrap();
let cfg = load_global_config_from(&p);
assert_eq!(cfg.pool_dir.as_deref(), Some("/mnt/pool"));
assert_eq!(cfg.window_geometry, "1200x800");
assert!(cfg.extra.contains_key("recent_folders"));
}
#[test]
fn test_default_schema_entries() {
let schema = default_tag_schema();
assert_eq!(schema.len(), 4);
assert_eq!(schema[0].filmoteka_root.as_deref(), Some(""));
assert_eq!(schema[3].transform.as_deref(), Some("decade_band"));
}
#[test]
fn test_schema_entry_missing_root_is_none() {
let e: TagSchemaEntry = serde_json::from_str(r#"{"category": "Vlastní"}"#).unwrap();
assert_eq!(e.filmoteka_root, None);
assert_eq!(e.csfd_field, None);
}
}
-471
View File
@@ -1,471 +0,0 @@
"""ČSFD.cz scraper — a *title bridge* to TMDb.
Curator identifies movies by **TMDb id**. A direct TMDb search on a Czech
distribution title is unreliable (remakes, localized names, "weird" hits).
ČSFD, on the other hand, indexes Czech release names well **and** lists a
film's *original* title. So the bridge is::
filename → ČSFD search → ČSFD film page → original title + year
→ TMDb search → tmdbid
This module only scrapes ČSFD; the TMDb half lives in
:mod:`src.core.file_manager` (:meth:`FileManager.resolve_via_csfd`). Nothing
here feeds tags/genres — those still come from TMDb via Jellyfin/NFO.
ČSFD serves an **Anubis** proof-of-work wall in front of every page: a plain
request gets a 200 with a JS challenge instead of the movie. We detect it,
solve the SHA-256 PoW the way the bundled worker JS does, and replay the
request through a shared :class:`requests.Session` (so a batch pays the PoW
cost only once).
"""
from __future__ import annotations
import hashlib
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlencode, urljoin
from loguru import logger
try:
import requests
from bs4 import BeautifulSoup
_HAS_DEPS = True
except ImportError: # pragma: no cover - exercised only without the extras
_HAS_DEPS = False
requests = None # type: ignore
BeautifulSoup = None # type: ignore
CSFD_BASE_URL = "https://www.csfd.cz"
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"Accept-Language": "cs,en;q=0.9",
}
# Anubis anti-bot wall: a plain GET returns a 200 challenge page, not the movie.
ANUBIS_CHALLENGE_MARKER = 'id="anubis_challenge"'
ANUBIS_PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
# Safety cap so a difficulty bump can never spin forever (difficulty 1 needs ~16).
ANUBIS_MAX_NONCE = 50_000_000
class CsfdError(RuntimeError):
"""A ČSFD lookup could not be completed (deps missing, network, anti-bot)."""
@dataclass
class CsfdFilm:
"""The slice of a ČSFD film page Curator needs to reach a TMDb id."""
url: str
csfd_id: int | None = None
czech_title: str = ""
original_title: str = ""
year: str | None = None # four-digit string, or None
@property
def tmdb_query(self) -> tuple[str, str | None]:
"""``(title, year)`` best suited for a TMDb search — original title first."""
return (self.original_title or self.czech_title, self.year)
def dependencies_available() -> bool:
"""True when ``requests`` + ``beautifulsoup4`` are importable."""
return _HAS_DEPS
def _check_dependencies() -> None:
if not _HAS_DEPS:
raise CsfdError(
"ČSFD vyžaduje balíčky 'requests' a 'beautifulsoup4' (uv sync je doplní)."
)
def _extract_csfd_id(url: str) -> int | None:
"""Pull the numeric film id out of a ČSFD URL, or ``None``."""
match = re.search(r"/film/(\d+)", url)
return int(match.group(1)) if match else None
# ---------------------------------------------------------------------------
# Anubis proof-of-work (ported verbatim from the pre-Jellyfin csfd.py)
# ---------------------------------------------------------------------------
def _extract_json_blob(html: str, element_id: str):
"""Return the parsed JSON from an Anubis ``<script id=...>`` blob, or None."""
match = re.search(
rf'<script id="{re.escape(element_id)}" type="application/json">(.*?)</script>',
html,
re.DOTALL,
)
if not match:
return None
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
return None
def _solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]:
"""Brute-force the Anubis proof-of-work.
Mirrors the bundled ``sha256-purejs`` worker: find the smallest ``nonce``
such that ``sha256(random_data + str(nonce))`` has ``difficulty`` leading
zero nibbles. Returns ``(hash_hex, nonce, elapsed_ms)``.
"""
full_zero_bytes = difficulty // 2
needs_half_byte = difficulty % 2 != 0
start = time.monotonic()
for nonce in range(ANUBIS_MAX_NONCE):
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).digest()
if any(digest[i] != 0 for i in range(full_zero_bytes)):
continue
if needs_half_byte and digest[full_zero_bytes] >> 4 != 0:
continue
elapsed_ms = int((time.monotonic() - start) * 1000)
return digest.hex(), nonce, elapsed_ms
raise CsfdError(
f"Anubis PoW nevyřešen do {ANUBIS_MAX_NONCE} pokusů (obtížnost {difficulty})"
)
def _solve_anubis_challenge(session, html: str, url: str):
"""Solve the Anubis challenge in ``html`` and return the real page response."""
payload = _extract_json_blob(html, "anubis_challenge")
if not payload:
raise CsfdError("ČSFD anti-bot stránka bez čitelné Anubis challenge.")
rules = payload.get("rules", {})
challenge = payload.get("challenge", {})
random_data = challenge.get("randomData")
difficulty = int(rules.get("difficulty", 1))
if not random_data:
raise CsfdError("Anubis challenge neobsahuje randomData.")
base_prefix = _extract_json_blob(html, "anubis_base_prefix") or ""
logger.debug(f"Solving Anubis challenge (difficulty {difficulty}) for {url}")
hash_hex, nonce, elapsed_ms = _solve_anubis_pow(random_data, difficulty)
logger.debug(f"Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms")
pass_url = urljoin(CSFD_BASE_URL, f"{base_prefix}{ANUBIS_PASS_PATH}")
response = session.get(
pass_url,
params={
"id": challenge.get("id"),
"response": hash_hex,
"nonce": nonce,
"redir": url,
"elapsedTime": elapsed_ms,
},
headers=HEADERS,
timeout=10,
)
response.raise_for_status()
if ANUBIS_CHALLENGE_MARKER in response.text:
raise CsfdError("ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto).")
return response
def _get_page(session, url: str):
"""GET ``url`` through ``session``, transparently clearing an Anubis wall."""
response = session.get(url, headers=HEADERS, timeout=10)
response.raise_for_status()
if ANUBIS_CHALLENGE_MARKER in response.text:
response = _solve_anubis_challenge(session, response.text, url)
return response
# ---------------------------------------------------------------------------
# Page parsing
# ---------------------------------------------------------------------------
def _parse_json_ld(soup) -> tuple[str, str | None]:
"""``(name, year)`` from the film page's JSON-LD (name is the Czech title)."""
for script in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(script.string or "")
except json.JSONDecodeError, TypeError:
continue
if isinstance(data, list):
data = next((d for d in data if d.get("@type") == "Movie"), None)
if not isinstance(data, dict) or data.get("@type") != "Movie":
continue
name = (data.get("name") or "").strip()
year: str | None = None
for key in ("dateCreated", "datePublished"):
match = re.search(r"(19|20)\d{2}", str(data.get(key) or ""))
if match:
year = match.group(0)
break
return name, year
return "", None
def _parse_origin(soup) -> tuple[str | None, str | None]:
"""``(origin_country, year)`` from the ``.origin`` line.
ČSFD separates values with inline bullet ``<span>`` elements (no commas),
e.g. ``USA • 1999 • 136 min``. For a co-production only the first country
is returned — enough to match it against a name's flag.
"""
elem = soup.select_one(".origin")
if not elem:
return None, None
raw = elem.get_text(separator="|", strip=True)
tokens = [t.strip() for part in raw.split("|") for t in part.split(",")]
country: str | None = None
year: str | None = None
for token in tokens:
if not token:
continue
if year is None and re.fullmatch(r"(19|20)\d{2}", token):
year = token
continue
if country is None and not token[0].isdigit() and re.search(r"[^\W\d_]", token):
country = token.split("/")[0].strip()
return country, year
# ČSFD flags older Czech films "Československo" while ``.origin`` may read "Česko".
_CZ_ORIGIN_ALIASES = {"česko", "československo"}
def _parse_original_title(soup, origin_country: str | None) -> str | None:
"""The film's original title from ``.film-names`` — or ``None`` to use the CZ title.
Each ``<li>`` carries a country/language flag (``img.flag`` ``title``) and a
title. The original title is the entry whose flag matches the **country of
origin** (USA → "The Shawshank Redemption"). ČSFD does **not** repeat the
Czech title here when it equals the ``<h1>``, so a Czech film has only
foreign/festival alt-titles in this list (``[angličtina] Cosy Dens``,
``[Slovinsko] Samotarji``) — picking one of those would be wrong, so we
return ``None`` and let the caller fall back to the Czech ``<h1>`` title.
"""
names: list[tuple[str, str]] = []
for li in soup.select(".film-names li"):
flag = li.select_one("img.flag")
country = str(flag.get("title") or "").strip() if flag else ""
for junk in li.select("span, img"):
junk.extract()
text = " ".join(li.get_text(" ").split()).strip(" ·")
if text:
names.append((country, text))
if not names or not origin_country:
return None
wanted = origin_country.casefold()
for country, text in names:
here = country.casefold()
if here == wanted or (
wanted in _CZ_ORIGIN_ALIASES and here in _CZ_ORIGIN_ALIASES
):
return text
return None
def fetch_film(url: str, session=None) -> CsfdFilm:
"""Fetch a ČSFD film page → :class:`CsfdFilm` (Czech + original title, year).
Pass a shared ``requests.Session`` to reuse the Anubis auth cookie across a
batch. Raises :class:`CsfdError` on a network / anti-bot / parse failure.
"""
_check_dependencies()
own_session = session is None
if own_session:
session = requests.Session()
try:
html = _get_page(session, url).text
except requests.RequestException as exc:
raise CsfdError(f"Nelze načíst ČSFD stránku: {exc}") from exc
finally:
if own_session:
session.close()
soup = BeautifulSoup(html, "html.parser")
czech_title, year = _parse_json_ld(soup)
origin_country, origin_year = _parse_origin(soup)
year = year or origin_year
if not czech_title:
header = soup.select_one(".film-header-name h1")
czech_title = header.get_text(strip=True) if header else ""
original_title = _parse_original_title(soup, origin_country) or czech_title
return CsfdFilm(
url=url,
csfd_id=_extract_csfd_id(url),
czech_title=czech_title,
original_title=original_title,
year=str(year) if year else None,
)
def _films_section(soup):
"""The ``<section>`` holding the *Filmy* results, or the whole document.
ČSFD's search page groups hits into ``Filmy`` / ``Seriály`` / ``Tvůrci`` /
``Uživatelé`` sections; ``a.film-title-name`` matches in all of them, so
without scoping a bare-title search happily returns a series episode.
"""
for section in soup.find_all("section"):
header = section.select_one(".box-header h2") or section.find("h2")
if header and header.get_text(strip=True).casefold() == "filmy":
return section
return soup
def _is_film_href(href: str) -> bool:
"""True for a film URL ``/film/<id>-slug/`` — a series episode has two segments
(``/film/<series>/<episode>/``) and is rejected."""
parts = [p for p in href.split("/") if p and p != "prehled"]
return len(parts) == 2 and parts[0] == "film"
def search_films(query: str, limit: int = 5, session=None) -> list[CsfdFilm]:
"""Search ČSFD for ``query``; film hits only (no series/episodes), best first."""
_check_dependencies()
if not query.strip():
return []
search_url = f"{CSFD_SEARCH_URL}?{urlencode({'q': query.strip()})}"
own_session = session is None
if own_session:
session = requests.Session()
try:
html = _get_page(session, search_url).text
except requests.RequestException as exc:
raise CsfdError(f"ČSFD hledání selhalo: {exc}") from exc
finally:
if own_session:
session.close()
soup = BeautifulSoup(html, "html.parser")
films: list[CsfdFilm] = []
for anchor in _films_section(soup).select("a.film-title-name"):
href = str(anchor.get("href") or "")
if not _is_film_href(href):
continue
films.append(
CsfdFilm(
url=urljoin(CSFD_BASE_URL, href),
csfd_id=_extract_csfd_id(href),
czech_title=anchor.get_text(strip=True),
)
)
if len(films) >= limit:
break
return films
def find_csfd_url(query: str, session=None) -> str | None:
"""Return the first ČSFD film URL matching ``query``, or ``None``.
ČSFD's full-text search treats a trailing year as a search term (not a
filter) and can miss on ``"Title 2006"`` while ``"Title"`` hits — so a
yearful query with no result is retried without the year.
"""
if not query or not query.strip():
return None
query = query.strip()
films = search_films(query, limit=1, session=session)
if not films:
without_year = re.sub(r"\s+(?:19|20)\d{2}$", "", query)
if without_year != query:
films = search_films(without_year, limit=1, session=session)
return films[0].url if films else None
# Release-name tokens that mark the end of the actual title in a filename.
_RELEASE_MARKERS = {
"bluray",
"blu-ray",
"brrip",
"bdrip",
"bdremux",
"remux",
"webrip",
"web",
"web-dl",
"webdl",
"hdtv",
"dvdrip",
"dvd",
"dvd5",
"dvd9",
"hdrip",
"cam",
"ts",
"tc",
"x264",
"x265",
"h264",
"h265",
"hevc",
"avc",
"xvid",
"divx",
"aac",
"ac3",
"eac3",
"dts",
"dd5",
"ddp5",
"truehd",
"atmos",
"flac",
"10bit",
"8bit",
"hdr",
"hdr10",
"dolby",
"sdr",
"proper",
"repack",
"extended",
"unrated",
"remastered",
"imax",
"multi",
"dual",
"complete",
"internal",
"limited",
"uncut",
}
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$|^[24]k$", re.IGNORECASE)
def clean_filename_to_query(filename: str) -> str:
"""Turn a (possibly release-named) filename into a ČSFD search query.
Strips the path/extension, splits on common separators and keeps the words
before the first release marker (year, resolution, codec, source, …). The
detected year is appended back as a disambiguator::
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
"""
stem = Path(filename).stem
tokens = [t for t in re.split(r"[.\s_]+", stem) if t]
title_words: list[str] = []
year: str | None = None
for token in tokens:
bare = token.strip("()[]{}")
if _YEAR_RE.match(bare):
year = bare
break
if _RESOLUTION_RE.match(bare) or bare.lower() in _RELEASE_MARKERS:
break
title_words.append(token)
title = " ".join(title_words).strip() or re.sub(r"[.\s_]+", " ", stem).strip()
return f"{title} {year}".strip() if year else title
+694
View File
@@ -0,0 +1,694 @@
//! ČSFD.cz scraper — a *title bridge* to TMDb.
//!
//! A direct TMDb search on a Czech distribution title is unreliable. ČSFD indexes
//! Czech release names well **and** lists a film's *original* title, so:
//!
//! ```text
//! filename → ČSFD search → ČSFD film page → original title + year
//! → TMDb search → tmdbid
//! ```
//!
//! ČSFD serves an **Anubis** proof-of-work wall in front of every page. We detect
//! it, solve the SHA-256 PoW the way the bundled worker JS does, and replay the
//! request through a shared session (so a batch pays the PoW cost only once).
use std::path::Path;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use ego_tree::NodeRef;
use regex::Regex;
use reqwest::blocking::{Client, Response};
use scraper::{ElementRef, Html, Node, Selector};
use serde_json::Value;
use sha2::{Digest, Sha256};
use tracing::debug;
use url::Url;
use super::error::CoreError;
use super::http::new_session;
pub const CSFD_BASE_URL: &str = "https://www.csfd.cz";
pub const CSFD_SEARCH_URL: &str = "https://www.csfd.cz/hledat/";
const ANUBIS_CHALLENGE_MARKER: &str = r#"id="anubis_challenge""#;
const ANUBIS_PASS_PATH: &str = "/.within.website/x/cmd/anubis/api/pass-challenge";
/// Safety cap so a difficulty bump can never spin forever.
const ANUBIS_MAX_NONCE: u64 = 50_000_000;
const TIMEOUT: Duration = Duration::from_secs(10);
/// ČSFD flags older Czech films "Československo" while `.origin` may read "Česko".
const CZ_ORIGIN_ALIASES: &[&str] = &["česko", "československo"];
/// Release-name tokens that mark the end of the actual title in a filename.
const RELEASE_MARKERS: &[&str] = &[
"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",
];
macro_rules! re {
($name:ident, $pat:expr) => {
static $name: LazyLock<Regex> = LazyLock::new(|| Regex::new($pat).expect("valid regex"));
};
}
re!(CSFD_ID_RE, r"/film/(\d+)");
re!(YEAR_ANY_RE, r"(19|20)\d{2}");
re!(YEAR_FULL_RE, r"^(19|20)\d{2}$");
re!(LETTER_RE, r"[^\W\d_]");
re!(RESOLUTION_RE, r"(?i)^\d{3,4}p$|^[24]k$");
re!(SPLIT_RE, r"[.\s_]+");
re!(TRAILING_YEAR_RE, r"\s+(?:19|20)\d{2}$");
fn sel(css: &str) -> Selector {
Selector::parse(css).expect("valid selector")
}
/// The slice of a ČSFD film page Curator needs to reach a TMDb id.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CsfdFilm {
pub url: String,
pub csfd_id: Option<i64>,
pub czech_title: String,
pub original_title: String,
pub year: Option<String>,
}
impl CsfdFilm {
/// `(title, year)` best suited for a TMDb search — original title first.
pub fn tmdb_query(&self) -> (String, Option<String>) {
let title = if self.original_title.is_empty() {
&self.czech_title
} else {
&self.original_title
};
(title.clone(), self.year.clone())
}
}
fn err(msg: impl Into<String>) -> CoreError {
CoreError::Remote(msg.into())
}
/// Pull the numeric film id out of a ČSFD URL.
pub fn extract_csfd_id(url: &str) -> Option<i64> {
CSFD_ID_RE.captures(url)?[1].parse().ok()
}
// ---------------------------------------------------------------------------
// Anubis proof-of-work
// ---------------------------------------------------------------------------
/// The parsed JSON of an Anubis `<script id=... type="application/json">` blob.
fn extract_json_blob(html: &str, element_id: &str) -> Option<Value> {
let pattern = format!(
r#"(?s)<script id="{}" type="application/json">(.*?)</script>"#,
regex::escape(element_id)
);
let caps = Regex::new(&pattern).ok()?.captures(html)?;
serde_json::from_str(&caps[1]).ok()
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Find the smallest `nonce` such that `sha256(random_data + nonce)` has
/// `difficulty` leading zero nibbles. Returns `(hash_hex, nonce, elapsed_ms)`.
pub fn solve_anubis_pow(
random_data: &str,
difficulty: usize,
) -> Result<(String, u64, u128), CoreError> {
let full_zero_bytes = difficulty / 2;
let needs_half_byte = !difficulty.is_multiple_of(2);
let start = Instant::now();
for nonce in 0..ANUBIS_MAX_NONCE {
let digest = Sha256::digest(format!("{random_data}{nonce}").as_bytes());
if digest[..full_zero_bytes].iter().any(|b| *b != 0) {
continue;
}
if needs_half_byte && digest[full_zero_bytes] >> 4 != 0 {
continue;
}
return Ok((hex(&digest), nonce, start.elapsed().as_millis()));
}
Err(err(format!(
"Anubis PoW nevyřešen do {ANUBIS_MAX_NONCE} pokusů (obtížnost {difficulty})"
)))
}
fn http_get(session: &Client, url: &str, query: &[(&str, String)]) -> Result<String, CoreError> {
let resp: Response = session
.get(url)
.header("Accept-Language", "cs,en;q=0.9")
.query(query)
.timeout(TIMEOUT)
.send()
.map_err(|e| err(format!("Nelze načíst ČSFD stránku: {e}")))?
.error_for_status()
.map_err(|e| err(format!("Nelze načíst ČSFD stránku: {e}")))?;
resp.text()
.map_err(|e| err(format!("Nelze načíst ČSFD stránku: {e}")))
}
/// Solve the Anubis challenge in `html` and return the real page.
fn solve_anubis_challenge(session: &Client, html: &str, url: &str) -> Result<String, CoreError> {
let payload = extract_json_blob(html, "anubis_challenge")
.ok_or_else(|| err("ČSFD anti-bot stránka bez čitelné Anubis challenge."))?;
let challenge = payload.get("challenge").cloned().unwrap_or(Value::Null);
let random_data = challenge
.get("randomData")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| err("Anubis challenge neobsahuje randomData."))?;
let difficulty = payload
.get("rules")
.and_then(|r| r.get("difficulty"))
.and_then(|d| {
d.as_u64()
.or_else(|| d.as_str().and_then(|s| s.parse().ok()))
})
.unwrap_or(1) as usize;
let base_prefix = extract_json_blob(html, "anubis_base_prefix")
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
debug!("Solving Anubis challenge (difficulty {difficulty}) for {url}");
let (hash_hex, nonce, elapsed_ms) = solve_anubis_pow(random_data, difficulty)?;
debug!("Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms");
let pass_url = Url::parse(CSFD_BASE_URL)
.and_then(|b| b.join(&format!("{base_prefix}{ANUBIS_PASS_PATH}")))
.map_err(|e| err(format!("Neplatná Anubis adresa: {e}")))?;
let id = challenge
.get("id")
.map(|v| v.as_str().map_or_else(|| v.to_string(), String::from))
.unwrap_or_default();
let text = http_get(
session,
pass_url.as_str(),
&[
("id", id),
("response", hash_hex),
("nonce", nonce.to_string()),
("redir", url.to_string()),
("elapsedTime", elapsed_ms.to_string()),
],
)?;
if text.contains(ANUBIS_CHALLENGE_MARKER) {
return Err(err(
"ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto).",
));
}
Ok(text)
}
/// GET `url` through `session`, transparently clearing an Anubis wall.
fn get_page(session: &Client, url: &str) -> Result<String, CoreError> {
let text = http_get(session, url, &[])?;
if text.contains(ANUBIS_CHALLENGE_MARKER) {
return solve_anubis_challenge(session, &text, url);
}
Ok(text)
}
// ---------------------------------------------------------------------------
// Page parsing
// ---------------------------------------------------------------------------
/// Text nodes of `el` stripped and joined with `sep`, skipping empty ones
/// (BeautifulSoup's `get_text(separator, strip=True)`).
fn joined_text(el: ElementRef<'_>, sep: &str) -> String {
el.text()
.map(str::trim)
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(sep)
}
/// `(name, year)` from the film page's JSON-LD (name is the Czech title).
fn parse_json_ld(doc: &Html) -> (String, Option<String>) {
for script in doc.select(&sel(r#"script[type="application/ld+json"]"#)) {
let Ok(mut data) = serde_json::from_str::<Value>(&script.text().collect::<String>()) else {
continue;
};
if let Value::Array(items) = &data {
data = items
.iter()
.find(|d| d.get("@type").and_then(Value::as_str) == Some("Movie"))
.cloned()
.unwrap_or(Value::Null);
}
if data.get("@type").and_then(Value::as_str) != Some("Movie") {
continue;
}
let name = data
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
let year = ["dateCreated", "datePublished"].iter().find_map(|k| {
let raw = data
.get(*k)
.map(|v| v.as_str().map_or_else(|| v.to_string(), String::from))
.unwrap_or_default();
YEAR_ANY_RE.find(&raw).map(|m| m.as_str().to_string())
});
return (name, year);
}
(String::new(), None)
}
/// `(origin_country, year)` from the `.origin` line (`USA • 1999 • 136 min`).
/// For a co-production only the first country is returned.
fn parse_origin(doc: &Html) -> (Option<String>, Option<String>) {
let Some(elem) = doc.select(&sel(".origin")).next() else {
return (None, None);
};
let raw = joined_text(elem, "|");
let mut country = None;
let mut year = None;
for token in raw.split('|').flat_map(|p| p.split(',')).map(str::trim) {
if token.is_empty() {
continue;
}
if year.is_none() && YEAR_FULL_RE.is_match(token) {
year = Some(token.to_string());
continue;
}
let starts_digit = token.chars().next().is_some_and(|c| c.is_numeric());
if country.is_none() && !starts_digit && LETTER_RE.is_match(token) {
country = Some(token.split('/').next().unwrap_or("").trim().to_string());
}
}
(country, year)
}
/// Text of `node`'s descendants, skipping anything inside `<span>` / `<img>`.
fn text_without_spans(node: NodeRef<'_, Node>, out: &mut Vec<String>) {
for child in node.children() {
match child.value() {
Node::Text(t) => out.push(t.to_string()),
Node::Element(e) if matches!(e.name(), "span" | "img") => {}
Node::Element(_) => text_without_spans(child, out),
_ => {}
}
}
}
/// The film's original title from `.film-names` — the entry whose flag matches
/// the country of origin — or `None` to fall back to the Czech title.
fn parse_original_title(doc: &Html, origin_country: Option<&str>) -> Option<String> {
let flag_sel = sel("img.flag");
let mut names: Vec<(String, String)> = Vec::new();
for li in doc.select(&sel(".film-names li")) {
let country = li
.select(&flag_sel)
.next()
.and_then(|f| f.value().attr("title"))
.unwrap_or("")
.trim()
.to_string();
let mut parts = Vec::new();
text_without_spans(*li, &mut parts);
let text = parts
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let text = text.trim_matches(|c| c == ' ' || c == '·').to_string();
if !text.is_empty() {
names.push((country, text));
}
}
let wanted = origin_country?.to_lowercase();
if names.is_empty() {
return None;
}
names.into_iter().find_map(|(country, text)| {
let here = country.to_lowercase();
let both_cz = CZ_ORIGIN_ALIASES.contains(&wanted.as_str())
&& CZ_ORIGIN_ALIASES.contains(&here.as_str());
(here == wanted || both_cz).then_some(text)
})
}
/// Parse a film page's HTML.
pub fn parse_film_page(html: &str, url: &str) -> CsfdFilm {
let doc = Html::parse_document(html);
let (mut czech_title, year) = parse_json_ld(&doc);
let (origin_country, origin_year) = parse_origin(&doc);
let year = year.or(origin_year);
if czech_title.is_empty() {
czech_title = doc
.select(&sel(".film-header-name h1"))
.next()
.map(|h| joined_text(h, ""))
.unwrap_or_default();
}
let original_title = parse_original_title(&doc, origin_country.as_deref())
.unwrap_or_else(|| czech_title.clone());
CsfdFilm {
url: url.into(),
csfd_id: extract_csfd_id(url),
czech_title,
original_title,
year,
}
}
/// Fetch a ČSFD film page. Pass a shared session to reuse the Anubis cookie.
pub fn fetch_film(url: &str, session: Option<&Client>) -> Result<CsfdFilm, CoreError> {
let own;
let session = match session {
Some(s) => s,
None => {
own = new_session();
&own
}
};
Ok(parse_film_page(&get_page(session, url)?, url))
}
/// True for a film URL `/film/<id>-slug/` — a series episode has two segments.
pub fn is_film_href(href: &str) -> bool {
let parts: Vec<&str> = href
.split('/')
.filter(|p| !p.is_empty() && *p != "prehled")
.collect();
parts.len() == 2 && parts[0] == "film"
}
/// Parse a search page's HTML — film hits only, scoped to the *Filmy* section.
pub fn parse_search_page(html: &str, limit: usize) -> Vec<CsfdFilm> {
let doc = Html::parse_document(html);
let header_sel = sel(".box-header h2");
let h2_sel = sel("h2");
let anchor_sel = sel("a.film-title-name");
let films_section = doc.select(&sel("section")).find(|section| {
let header = section
.select(&header_sel)
.next()
.or_else(|| section.select(&h2_sel).next());
header.is_some_and(|h| joined_text(h, "").to_lowercase() == "filmy")
});
let anchors: Vec<ElementRef<'_>> = match films_section {
Some(section) => section.select(&anchor_sel).collect(),
None => doc.select(&anchor_sel).collect(),
};
let base = Url::parse(CSFD_BASE_URL).expect("valid base url");
anchors
.into_iter()
.filter_map(|a| {
let href = a.value().attr("href").unwrap_or("");
if !is_film_href(href) {
return None;
}
Some(CsfdFilm {
url: base
.join(href)
.map(String::from)
.unwrap_or_else(|_| href.to_string()),
csfd_id: extract_csfd_id(href),
czech_title: joined_text(a, ""),
..Default::default()
})
})
.take(limit)
.collect()
}
/// Search ČSFD for `query`; film hits only, best first.
pub fn search_films(
query: &str,
limit: usize,
session: Option<&Client>,
) -> Result<Vec<CsfdFilm>, CoreError> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let mut url = Url::parse(CSFD_SEARCH_URL).expect("valid search url");
url.query_pairs_mut().append_pair("q", query);
let own;
let session = match session {
Some(s) => s,
None => {
own = new_session();
&own
}
};
let html =
get_page(session, url.as_str()).map_err(|e| err(format!("ČSFD hledání selhalo: {e}")))?;
Ok(parse_search_page(&html, limit))
}
/// The first ČSFD film URL matching `query`. A trailing year that makes the
/// search miss is retried without it.
pub fn find_csfd_url(query: &str, session: Option<&Client>) -> Result<Option<String>, CoreError> {
let query = query.trim();
if query.is_empty() {
return Ok(None);
}
let mut films = search_films(query, 1, session)?;
if films.is_empty() {
let without_year = TRAILING_YEAR_RE.replace(query, "").to_string();
if without_year != query {
films = search_films(&without_year, 1, session)?;
}
}
Ok(films.into_iter().next().map(|f| f.url))
}
/// Turn a (possibly release-named) filename into a ČSFD search query:
/// `"Matrix.1999.1080p.BluRay.x264-GROUP.mkv"` → `"Matrix 1999"`.
pub fn clean_filename_to_query(filename: &str) -> String {
let stem = super::paths::stem(Path::new(filename));
let mut title_words = Vec::new();
let mut year = None;
for token in SPLIT_RE.split(&stem).filter(|t| !t.is_empty()) {
let bare = token.trim_matches(|c| "()[]{}".contains(c));
if YEAR_FULL_RE.is_match(bare) {
year = Some(bare.to_string());
break;
}
if RESOLUTION_RE.is_match(bare) || RELEASE_MARKERS.contains(&bare.to_lowercase().as_str()) {
break;
}
title_words.push(token);
}
let mut title = title_words.join(" ").trim().to_string();
if title.is_empty() {
title = SPLIT_RE.replace_all(&stem, " ").trim().to_string();
}
match year {
Some(y) => format!("{title} {y}").trim().to_string(),
None => title,
}
}
#[cfg(test)]
mod tests {
use super::*;
const FILM_HTML: &str = r#"
<html><head>
<script type="application/ld+json">
{"@context":"https://schema.org/","@type":"Movie","name":"Vykoupení z věznice Shawshank",
"dateCreated":"1994","duration":"PT142M"}
</script>
</head><body>
<div class="film-header-name">
<h1>Vykoupení z věznice Shawshank</h1>
<ul class="film-names">
<li><img src="//x/us.svg" class="flag" title="USA" alt="USA"/> The Shawshank Redemption
<span class="normal more-name-link"><a class="more">více</a></span></li>
<li class="more-names hidden"><img src="//x/sk.svg" class="flag" title="Slovensko"/>
Vykúpenie z väznice Shawshank</li>
</ul>
</div>
<div class="origin">USA <span class="bullet"></span>
<span>1994 <span class="bullet"></span> </span>142 min</div>
</body></html>
"#;
const CZECH_FILM_HTML: &str = r#"
<html><head>
<script type="application/ld+json">
{"@type":"Movie","name":"Pelíšky","datePublished":"1999-04-01"}
</script>
</head><body>
<div class="film-header-name"><h1>Pelíšky</h1>
<ul class="film-names">
<li><img class="flag" title="angličtina"/> Cosy Dens</li>
<li><img class="flag" title="Slovensko"/> Pelíšky</li>
</ul></div>
<div class="origin">Česko <span class="bullet"></span><span>1999</span></div>
</body></html>
"#;
const SEARCH_HTML: &str = r#"
<html><body>
<section><div class="box-header"><h2>Filmy</h2></div>
<a href="/film/2294-vykoupeni-z-veznice-shawshank/prehled/" class="film-title-name">Vykoupení z věznice Shawshank</a>
<a href="/film/248803-film-o-filmu/prehled/" class="film-title-name">Film o filmu</a>
</section>
<section><div class="box-header"><h2>Seriály</h2></div>
<a href="/film/318290-arrow/620181-x/prehled/" class="film-title-name">Arrow - Vykoupení</a>
</section>
</body></html>
"#;
#[test]
fn test_clean_filename_to_query_cases() {
assert_eq!(
clean_filename_to_query("Matrix.1999.1080p.BluRay.x264-GROUP.mkv"),
"Matrix 1999"
);
assert_eq!(
clean_filename_to_query("Forrest Gump (1994) 2160p HDR.mkv"),
"Forrest Gump 1994"
);
assert_eq!(clean_filename_to_query("Amelie.mkv"), "Amelie");
assert_eq!(clean_filename_to_query("Sam_doma_720p.mkv"), "Sam doma");
assert_eq!(clean_filename_to_query("1080p.mkv"), "1080p");
}
#[test]
fn test_extract_csfd_id_cases() {
assert_eq!(
extract_csfd_id("https://www.csfd.cz/film/2294-vykoupeni/prehled/"),
Some(2294)
);
assert_eq!(extract_csfd_id("/film/999/"), Some(999));
assert_eq!(extract_csfd_id("https://www.csfd.cz/"), None);
}
#[test]
fn test_solve_anubis_pow_difficulty() {
let (hash_hex, nonce, _) = solve_anubis_pow("abc123", 1).unwrap();
assert!(hash_hex.starts_with('0'));
assert_eq!(
hex(&Sha256::digest(format!("abc123{nonce}").as_bytes())),
hash_hex
);
let (hash_hex, _, _) = solve_anubis_pow("seed", 2).unwrap();
assert!(hash_hex.starts_with("00"));
}
#[test]
fn test_parse_origin_cases() {
assert_eq!(
parse_origin(&Html::parse_document(FILM_HTML)),
(Some("USA".into()), Some("1994".into()))
);
let html = r#"<div class="origin">Francie / Německo <span class="bullet"></span><span>2001</span> 120 min</div>"#;
assert_eq!(
parse_origin(&Html::parse_document(html)),
(Some("Francie".into()), Some("2001".into()))
);
}
#[test]
fn test_parse_film_page_foreign_film() {
let film = parse_film_page(FILM_HTML, "https://www.csfd.cz/film/2294-vykoupeni/");
assert_eq!(film.czech_title, "Vykoupení z věznice Shawshank");
assert_eq!(film.original_title, "The Shawshank Redemption");
assert_eq!(film.year.as_deref(), Some("1994"));
assert_eq!(film.csfd_id, Some(2294));
assert_eq!(
film.tmdb_query(),
("The Shawshank Redemption".into(), Some("1994".into()))
);
}
#[test]
fn test_parse_film_page_czech_film_falls_back_to_h1() {
let film = parse_film_page(CZECH_FILM_HTML, "https://www.csfd.cz/film/1/");
assert_eq!(film.original_title, "Pelíšky");
assert_eq!(film.year.as_deref(), Some("1999"));
}
#[test]
fn test_parse_search_page_scopes_to_films() {
let films = parse_search_page(SEARCH_HTML, 5);
let urls: Vec<&str> = films.iter().map(|f| f.url.as_str()).collect();
assert_eq!(
urls,
vec![
"https://www.csfd.cz/film/2294-vykoupeni-z-veznice-shawshank/prehled/",
"https://www.csfd.cz/film/248803-film-o-filmu/prehled/",
]
);
assert_eq!(films[0].czech_title, "Vykoupení z věznice Shawshank");
assert_eq!(parse_search_page(SEARCH_HTML, 1).len(), 1);
}
#[test]
fn test_is_film_href_cases() {
assert!(is_film_href("/film/2294-vykoupeni/prehled/"));
assert!(!is_film_href("/film/318290-arrow/620181-x/prehled/"));
assert!(!is_film_href("/tvurce/1/"));
}
#[test]
fn test_extract_json_blob() {
let html = r#"<script id="anubis_challenge" type="application/json">{"rules":{"difficulty":2}}</script>"#;
assert_eq!(
extract_json_blob(html, "anubis_challenge").unwrap()["rules"]["difficulty"],
2
);
assert!(extract_json_blob(html, "other").is_none());
}
}
+33
View File
@@ -0,0 +1,33 @@
//! The error type shared by the core modules.
use std::io;
/// Everything a core operation can fail with. The `Display` text is shown to the
/// user as-is, so validation messages are written in Czech like the rest of the UI.
#[derive(Debug, thiserror::Error)]
pub enum CoreError {
/// Invalid input (bad year, empty title, unknown 3D format, …).
#[error("{0}")]
Invalid(String),
/// The target already exists and the caller asked not to overwrite it.
#[error("{0}")]
Exists(String),
/// Something expected on disk is missing.
#[error("{0}")]
NotFound(String),
/// A remote service (TMDb, ČSFD, Jellyfin) failed.
#[error("{0}")]
Remote(String),
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
impl CoreError {
pub fn invalid(msg: impl Into<String>) -> Self {
Self::Invalid(msg.into())
}
}
pub type CoreResult<T> = Result<T, CoreError>;
-422
View File
@@ -1,422 +0,0 @@
import re
from pathlib import Path
from .tag import Tag
class File:
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
self.file_path = file_path
self.filename = file_path.name
# Unified pool index; when set, metadata is persisted there (see
# PoolIndex). Without an index a File is an in-memory object only — no
# metadata is written to disk (there are no per-file sidecars anymore).
self.index = index
self.new = True
self.ignored = False
self.tags: list[Tag] = []
self.tagmanager = tagmanager
# new: optional date string "YYYY-MM-DD" (assigned manually)
self.date: str | None = None
# movie-library fields
self.title: str | None = None
# Optional ČSFD film URL — a second database pointer kept alongside the
# TMDb id (which lives in the filename). Stored only, not fetched.
self.csfd_url: str | None = None
# ISO timestamp of when the movie was added to the pool (set on import);
# None for older/unknown → falls back to the file's ctime.
self.added: str | None = None
# Cached metadata used for folder grouping (year, rating) — filled from
# the movie's NFO (which Jellyfin writes).
self.meta: dict | None = None
# full_paths of tags derived automatically from the NFO (vs. user-added);
# only these are regenerated on refresh — user tags are preserved.
self.auto_tag_paths: set[str] = set()
# free-form per-movie attributes (e.g. "collection_sort"); usable in
# miniDLNA filename templates via name_context.
self.attributes: dict[str, str] = {}
# Identity key of the movie record this file belongs to (set on load /
# first save); ``None`` only for an index-less, in-memory File.
self._movie_key: str | None = None
self.get_metadata()
def _identity_from_path(self) -> tuple[str, str, str | None, int | None, str]:
"""``(key, title, year, tmdb_id, label)`` implied by this file's location.
A file inside its own ``Title (rok) [tmdbid]`` folder is one version of
that movie — identity comes from the **folder**, and the rest of its own
stem is the version label. A flat file carries both in its own name.
"""
from .movie import identity_key
from .naming import (
is_canonical_pool_stem,
parse_3d_format,
parse_pool_stem,
parse_tmdb_id,
)
parent = self.file_path.parent.name
grouped = is_canonical_pool_stem(parent)
identity_stem = parent if grouped else self.file_path.stem
parsed = parse_pool_stem(identity_stem)
year = str(parsed[1]) if parsed else None
tmdb_id = parse_tmdb_id(identity_stem)
title = self.title or (parsed[0] if parsed else self.file_path.stem)
label = ""
part = (
re.fullmatch(
rf"{re.escape(parent)}[\s._-]*(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{{1,2}})",
self.file_path.stem,
re.IGNORECASE,
)
if grouped
else None
)
if part:
# one part of a film split across files: "<folder>-part2"
label = f"part{int(part.group(1))}"
elif grouped and self.file_path.stem.startswith(f"{parent} - "):
label = self.file_path.stem[len(parent) + 3 :]
elif parse_3d_format(self.file_path.stem):
label = parse_3d_format(self.file_path.stem) or ""
return identity_key(tmdb_id, title, year), title, year, tmdb_id, label
def get_metadata(self) -> None:
"""Load this file's metadata from the movie record that owns it.
Since the pool index became identity-keyed (see :mod:`src.core.movie`),
a File is a *view* on one media entry of a movie record — every version
of the same film therefore reads the same title/tags/ČSFD/… A file the
index doesn't know yet joins the movie of the same identity when there
is one (that's how a newly added version inherits the shared metadata
instead of blanking it), otherwise it starts a new record.
"""
if self.index is None:
self._init_new_metadata()
return
found = self.index.find_movie_by_media(self.file_path)
if found is not None:
self._movie_key, record = found
self._apply_record(record)
return
self._init_new_metadata()
key, *_ = self._identity_from_path()
existing = self.index.get_movie(key)
if existing is not None:
self._movie_key = key
self._apply_record(existing)
self.save_metadata()
def _init_new_metadata(self) -> None:
self.new = True
self.ignored = False
self.tags = []
self.date = None
self.title = None
self.csfd_url = None
self.meta = None
self.auto_tag_paths = set()
self.attributes = {}
self.added = None
def _build_record(self) -> dict:
data = {
"new": self.new,
"ignored": self.ignored,
# ukládáme full_path tagů
"tags": [
tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags
],
# which of the tags were derived automatically (from the NFO)
"auto_tags": sorted(self.auto_tag_paths),
# date může být None
"date": self.date,
"title": self.title,
"csfd_url": self.csfd_url,
"attributes": self.attributes,
"added": self.added,
}
if self.meta is not None:
data["meta"] = dict(self.meta)
return data
def _apply_record(self, data: dict) -> None:
self.new = data.get("new", True)
self.ignored = data.get("ignored", False)
self.tags = []
self.date = data.get("date", None)
self.title = data.get("title", None)
self.csfd_url = data.get("csfd_url", None)
# provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored
self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", [])))
self.attributes = dict(data.get("attributes", {}))
self.added = data.get("added", None)
raw_meta = data.get("meta")
if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
old = data.get("csfd_cache") or {}
raw_meta = (
{"year": old.get("year"), "rating": old.get("rating")} if old else None
)
self.meta = dict(raw_meta) if raw_meta else None
if not self.tagmanager:
return
for tag_str in data.get("tags", []):
if "/" in tag_str:
category, name = tag_str.split("/", 1)
tag = self.tagmanager.add_tag(category, name)
self.tags.append(tag)
def save_metadata(self):
"""Persist metadata into this file's movie record (no-op without index).
Writes the shared fields onto the movie record and makes sure the
record lists this file among its media. A file the index has never seen
starts a new record under its own identity key.
"""
if self.index is None:
return
from .movie import ROLE_VIDEO
derived_key, title, year, tmdb_id, label = self._identity_from_path()
key = self._movie_key or derived_key
record = self.index.get_movie(key) or {}
record.update(self._build_record())
# The file's actual location is the truth — a move must update this.
record["library"] = self._library_name()
record["title"] = title
record["year"] = year
record["tmdb_id"] = tmdb_id
# Keep this file listed exactly once, with the label its name implies.
rel = self.index._key(self.file_path)
media = [m for m in record.get("media", []) if m.get("file") != rel]
media.append({"role": ROLE_VIDEO, "label": label, "file": rel})
record["media"] = media
self.index.set_movie(key, record)
self._movie_key = key
def _library_name(self) -> str:
"""Top-level pool folder this file sits in (its library), or ``""``."""
if self.index is None:
return ""
rel = self.index._key(self.file_path)
parts = Path(rel).parts
return parts[0] if len(parts) > 1 else ""
def added_timestamp(self) -> float:
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
Used to order the "recently added" folder (newer = larger). The fallback
is **mtime**, not ctime: creating the Filmotéka hardlinks bumps the source
inode's ctime, which would make ctime-based ordering meaningless, whereas
mtime (preserved from the source on import) stays stable.
"""
if self.added:
try:
from datetime import datetime
return datetime.fromisoformat(self.added).timestamp()
except ValueError:
pass
try:
return self.file_path.stat().st_mtime
except OSError:
return 0.0
def name_context(self) -> dict:
"""Fields for a miniDLNA filename template (see config tag schema).
Provides title / year / rating / ext / stem / filename from the movie's
metadata (year & rating come from the NFO cache, year falling back to a
``Rok`` tag, then the canonical filename). Missing values render empty.
"""
cache = self.meta or {}
year = cache.get("year")
if year is None:
for t in self.tags:
if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit():
year = t.name
break
if year is None:
# Last resort: recover it from a canonical "Title (YYYY)" filename.
from .naming import parse_pool_stem
parsed = parse_pool_stem(self.file_path.stem)
if parsed is not None:
year = parsed[1]
rating = cache.get("rating")
# user attributes first; core fields take precedence over same-named keys
context = dict(self.attributes)
context.update(
{
"title": self.title or self.file_path.stem,
"year": "" if year is None else str(year),
"rating": "" if rating is None else rating,
"ext": self.file_path.suffix,
"stem": self.file_path.stem,
"filename": self.filename,
}
)
return context
def set_attribute(self, key: str, value: str | None) -> None:
"""Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it."""
key = key.strip()
if not key:
return
if value:
self.attributes[key] = value
else:
self.attributes.pop(key, None)
self.save_metadata()
def save_location(self) -> None:
"""Persist a rename/move of this file without touching shared metadata.
Every version of a film holds its own in-memory copy of the movie
record's shared fields (title, tags, ČSFD, …). A sibling loaded before
someone changed those fields would write its stale copy back on a plain
:meth:`save_metadata`. Re-reading the record first means only this
file's own media entry — path and popisek — actually changes.
"""
if self.index is not None and self.index.find_movie_by_media(self.file_path):
self.get_metadata()
self.save_metadata()
def delete_metadata(self) -> None:
"""Drop this file from its movie record; the last one takes the record.
Removing one version of a multi-version movie leaves the movie (and its
metadata) in place — only when its last media entry goes does the whole
record go with it.
"""
if self.index is None:
return
found = self.index.find_movie_by_media(self.file_path)
if found is None:
return
key, record = found
rel = self.index._key(self.file_path)
record["media"] = [m for m in record.get("media", []) if m.get("file") != rel]
if record["media"]:
self.index.set_movie(key, record)
else:
self.index.delete_movie(key)
def relocate(self, new_path: Path) -> None:
"""Point this File at a new path, moving its media entry along.
The physical file must already have been moved/renamed by the caller;
this only repoints the movie record's media entry (and the File), so a
rename never detaches a file from its movie.
"""
if self.index is not None:
found = self.index.find_movie_by_media(self.file_path)
if found is not None:
key, record = found
old_rel = self.index._key(self.file_path)
new_rel = self.index._key(Path(new_path))
for media in record.get("media", []):
if media.get("file") == old_rel:
media["file"] = new_rel
self.index.set_movie(key, record)
self._movie_key = key
self.file_path = Path(new_path)
self.filename = self.file_path.name
def set_date(self, date_str: str | None):
"""Nastaví datum (např. '2025-09-25') nebo None pro smazání."""
if date_str is None or date_str == "":
self.date = None
else:
# neprvádíme složitou validaci zde; očekáváme 'YYYY-MM-DD'
self.date = date_str
self.save_metadata()
def apply_nfo(self, nfo, schema: list[dict] | None = None) -> dict:
"""Regenerate the auto (NFO-derived) tags from the movie's NFO.
Reads genres/year/countries/rating from ``nfo`` (``nfo.NfoData``) per the
tag schema's source fields and (re)creates those tags; user-added tags are
kept. Also caches year/rating (for folder grouping) and fills the title
when missing. ``nfo`` of ``None`` (no NFO yet) is a no-op.
Returns:
dict with 'success' and 'tags_added'.
"""
if nfo is None:
return {"success": False, "error": "NFO nenalezeno", "tags_added": []}
if schema is None:
from .config import DEFAULT_TAG_SCHEMA
schema = DEFAULT_TAG_SCHEMA
self.meta = {"year": nfo.year, "rating": nfo.rating}
# Drop previous auto (NFO) tags; keep user-added ones.
self.tags = [t for t in self.tags if t.full_path not in self.auto_tag_paths]
self.auto_tag_paths = set()
tags_added: list[str] = []
for entry in schema:
field = entry.get("csfd_field") # schema key = source field name
if not field:
continue # user-only category
category = entry["category"]
for value in nfo.field_values(field):
tag_obj = (
self.tagmanager.add_tag(category, value)
if self.tagmanager
else Tag(category, value)
)
if tag_obj not in self.tags:
self.tags.append(tag_obj)
tags_added.append(tag_obj.full_path)
self.auto_tag_paths.add(tag_obj.full_path)
if nfo.title and not self.title:
self.title = nfo.title
self.save_metadata()
return {"success": True, "tags_added": tags_added}
def add_tag(self, tag):
# tag může být Tag nebo string
from .tag import Tag as TagClass
if isinstance(tag, str):
if "/" in tag and self.tagmanager:
category, name = tag.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
else:
tag_obj = TagClass("default", tag)
elif isinstance(tag, TagClass):
tag_obj = tag
else:
return
if tag_obj not in self.tags:
self.tags.append(tag_obj)
self.save_metadata()
def remove_tag(self, tag):
# tag může být Tag nebo string (full_path)
if isinstance(tag, str):
if "/" in tag:
category, name = tag.split("/", 1)
tag_obj = Tag(category, name)
else:
tag_obj = Tag("default", tag)
elif isinstance(tag, Tag):
tag_obj = tag
else:
return
if tag_obj in self.tags:
self.tags.remove(tag_obj)
self.save_metadata()
+736
View File
@@ -0,0 +1,736 @@
//! One pooled video file — a *view* on one media entry of a movie record.
//!
//! The pool index is identity-keyed (see `movie`), so every version of the same
//! film reads the same title/tags/ČSFD/… A file the index doesn't know yet joins
//! the movie of the same identity when there is one (that's how a newly added
//! version inherits the shared metadata), otherwise it starts a new record.
//!
//! Without an index a `File` is an in-memory object only.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone};
use regex::Regex;
use serde_json::{Map, Value, json};
use super::config::TagSchemaEntry;
use super::error::CoreResult;
use super::movie::{ROLE_VIDEO, identity_key_for};
use super::naming::{is_canonical_pool_stem, parse_3d_format, parse_pool_stem, parse_tmdb_id};
use super::nfo::NfoData;
use super::paths;
use super::pool_index::{PoolIndex, truthy};
use super::tag::Tag;
use super::tag_manager::TagManager;
use super::tmdb::TmdbDetails;
/// Where auto tags come from — an NFO or TMDb details.
pub trait TagSource {
fn year(&self) -> Option<String>;
fn rating(&self) -> Option<i64>;
fn title(&self) -> Option<String>;
fn field_values(&self, name: &str) -> Vec<String>;
}
impl TagSource for NfoData {
fn year(&self) -> Option<String> {
self.year.clone()
}
fn rating(&self) -> Option<i64> {
self.rating
}
fn title(&self) -> Option<String> {
self.title.clone()
}
fn field_values(&self, name: &str) -> Vec<String> {
NfoData::field_values(self, name)
}
}
impl TagSource for TmdbDetails {
fn year(&self) -> Option<String> {
self.year.clone()
}
fn rating(&self) -> Option<i64> {
self.rating
}
fn title(&self) -> Option<String> {
Some(self.title.clone()).filter(|t| !t.is_empty())
}
fn field_values(&self, name: &str) -> Vec<String> {
TmdbDetails::field_values(self, name)
}
}
/// What a file's location implies about the movie it belongs to.
#[derive(Debug, Clone, PartialEq)]
pub struct PathIdentity {
pub key: String,
pub title: String,
pub year: Option<String>,
pub tmdb_id: Option<i64>,
/// Version label implied by the filename (`""` for a plain file).
pub label: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct File {
pub file_path: PathBuf,
pub filename: String,
pub new: bool,
pub ignored: bool,
pub tags: Vec<Tag>,
/// Optional date string "YYYY-MM-DD" (assigned manually).
pub date: Option<String>,
pub title: Option<String>,
/// ČSFD film URL — a second database pointer kept alongside the TMDb id.
pub csfd_url: Option<String>,
/// ISO timestamp of when the movie was added to the pool.
pub added: Option<String>,
/// Cached `year` / `rating` used for folder grouping (from the NFO).
pub meta: Option<Map<String, Value>>,
/// Tags derived automatically (vs. user-added); only these are regenerated.
pub auto_tag_paths: BTreeSet<String>,
/// Free-form per-movie attributes usable in miniDLNA filename templates.
pub attributes: BTreeMap<String, String>,
/// Identity key of the movie record this file belongs to.
pub movie_key: Option<String>,
}
fn opt_str(v: Option<&Value>) -> Option<String> {
match v? {
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
impl File {
/// A bare in-memory file (no metadata loaded).
pub fn bare(path: &Path) -> Self {
Self {
file_path: path.to_path_buf(),
filename: paths::name(path),
new: true,
ignored: false,
tags: Vec::new(),
date: None,
title: None,
csfd_url: None,
added: None,
meta: None,
auto_tag_paths: BTreeSet::new(),
attributes: BTreeMap::new(),
movie_key: None,
}
}
/// Load a file's metadata from the movie record that owns it.
pub fn load(
path: &Path,
tm: &mut TagManager,
index: Option<&mut PoolIndex>,
) -> CoreResult<Self> {
let mut f = Self::bare(path);
if let Some(index) = index {
f.get_metadata(tm, index)?;
}
Ok(f)
}
/// (Re)read this file's metadata from the index; an unknown file joins the
/// movie of the same identity (or starts a new record) and is saved.
pub fn get_metadata(&mut self, tm: &mut TagManager, index: &mut PoolIndex) -> CoreResult<()> {
if let Some((key, record)) = index.find_movie_by_media(&self.file_path) {
let record = record.clone();
self.movie_key = Some(key);
self.apply_record(&record, tm);
return Ok(());
}
self.init_new_metadata();
let key = self.identity_from_path().key;
if let Some(existing) = index.get_movie(&key).cloned() {
self.movie_key = Some(key);
self.apply_record(&existing, tm);
}
self.save_metadata(index)
}
fn init_new_metadata(&mut self) {
let path = self.file_path.clone();
*self = Self::bare(&path);
}
/// Identity implied by this file's location. A file inside its own
/// `Title (rok) [tmdbid]` folder is one version of that movie — identity
/// comes from the folder, and the rest of its own stem is the label.
pub fn identity_from_path(&self) -> PathIdentity {
let parent = self.file_path.parent().map(paths::name).unwrap_or_default();
let stem = paths::stem(&self.file_path);
let grouped = is_canonical_pool_stem(&parent);
let identity_stem = if grouped {
parent.clone()
} else {
stem.clone()
};
let parsed = parse_pool_stem(&identity_stem);
let year = parsed.as_ref().map(|p| p.1.to_string());
let tmdb_id = parse_tmdb_id(&identity_stem);
let title = self
.title
.clone()
.filter(|t| !t.is_empty())
.unwrap_or_else(|| {
parsed
.as_ref()
.map_or_else(|| stem.clone(), |p| p.0.clone())
});
let part = if grouped {
Regex::new(&format!(
r"(?i)^{}[\s._-]*(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{{1,2}})$",
regex::escape(&parent)
))
.ok()
.and_then(|re| re.captures(&stem).and_then(|c| c[1].parse::<u32>().ok()))
} else {
None
};
let label = if let Some(n) = part {
format!("part{n}")
} else if let Some(rest) = grouped
.then(|| stem.strip_prefix(&format!("{parent} - ")))
.flatten()
{
rest.to_string()
} else {
parse_3d_format(&stem).unwrap_or_default()
};
PathIdentity {
key: identity_key_for(tmdb_id, &title, year.as_deref()),
title,
year,
tmdb_id,
label,
}
}
fn build_record(&self) -> Map<String, Value> {
let mut data = json!({
"new": self.new,
"ignored": self.ignored,
"tags": self.tags.iter().map(Tag::full_path).collect::<Vec<_>>(),
"auto_tags": self.auto_tag_paths.iter().collect::<Vec<_>>(),
"date": self.date,
"title": self.title,
"csfd_url": self.csfd_url,
"attributes": self.attributes,
"added": self.added,
});
if let (Some(meta), Some(obj)) = (&self.meta, data.as_object_mut()) {
obj.insert("meta".into(), Value::Object(meta.clone()));
}
match data {
Value::Object(m) => m,
_ => Map::new(),
}
}
fn apply_record(&mut self, data: &Value, tm: &mut TagManager) {
self.new = data.get("new").and_then(Value::as_bool).unwrap_or(true);
self.ignored = data
.get("ignored")
.and_then(Value::as_bool)
.unwrap_or(false);
self.tags.clear();
self.date = opt_str(data.get("date"));
self.title = opt_str(data.get("title"));
self.csfd_url = opt_str(data.get("csfd_url"));
let auto = data.get("auto_tags").or_else(|| data.get("csfd_tags"));
self.auto_tag_paths = auto
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
self.attributes = data
.get("attributes")
.and_then(Value::as_object)
.map(|o| {
o.iter()
.map(|(k, v)| {
(
k.clone(),
v.as_str().map_or_else(|| v.to_string(), String::from),
)
})
.collect()
})
.unwrap_or_default();
self.added = opt_str(data.get("added"));
let raw_meta = match data.get("meta") {
None | Some(Value::Null) => data
.get("csfd_cache")
.and_then(Value::as_object)
.filter(|o| !o.is_empty())
.map(|old| {
let mut m = Map::new();
m.insert(
"year".into(),
old.get("year").cloned().unwrap_or(Value::Null),
);
m.insert(
"rating".into(),
old.get("rating").cloned().unwrap_or(Value::Null),
);
m
}),
Some(v) => v.as_object().cloned(),
};
self.meta = raw_meta.filter(|m| !m.is_empty());
for full in data
.get("tags")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(tag) = full.as_str().and_then(Tag::from_full_path) {
self.tags.push(tm.add_tag(&tag.category, &tag.name));
}
}
}
/// Top-level pool folder this file sits in (its library), or `""`.
fn library_name(&self, index: &PoolIndex) -> String {
let rel = index.rel_key(&self.file_path);
let parts: Vec<&str> = rel.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() > 1 {
parts[0].to_string()
} else {
String::new()
}
}
/// Persist metadata into this file's movie record, listing the file among
/// its media exactly once with the label its name implies.
pub fn save_metadata(&mut self, index: &mut PoolIndex) -> CoreResult<()> {
let identity = self.identity_from_path();
let key = self
.movie_key
.clone()
.unwrap_or_else(|| identity.key.clone());
let mut record = index
.get_movie(&key)
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for (k, v) in self.build_record() {
record.insert(k, v);
}
record.insert("library".into(), Value::from(self.library_name(index)));
record.insert("title".into(), Value::from(identity.title));
record.insert("year".into(), json!(identity.year));
record.insert("tmdb_id".into(), json!(identity.tmdb_id));
let rel = index.rel_key(&self.file_path);
let mut media: Vec<Value> = record
.get("media")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter(|m| m.get("file").and_then(Value::as_str) != Some(rel.as_str()))
.cloned()
.collect()
})
.unwrap_or_default();
media.push(json!({"role": ROLE_VIDEO, "label": identity.label, "file": rel}));
record.insert("media".into(), Value::Array(media));
index.set_movie(&key, Value::Object(record))?;
self.movie_key = Some(key);
Ok(())
}
/// Persist a rename/move without touching shared metadata: re-read the
/// record first so a stale in-memory copy can't overwrite newer fields.
pub fn save_location(&mut self, tm: &mut TagManager, index: &mut PoolIndex) -> CoreResult<()> {
if index.find_movie_by_media(&self.file_path).is_some() {
self.get_metadata(tm, index)?;
}
self.save_metadata(index)
}
/// Drop this file from its movie record; the last one takes the record.
pub fn delete_metadata(&self, index: &mut PoolIndex) -> CoreResult<()> {
let Some((key, record)) = index.find_movie_by_media(&self.file_path) else {
return Ok(());
};
let mut record = record.clone();
let rel = index.rel_key(&self.file_path);
let remaining: Vec<Value> = record
.get("media")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter(|m| m.get("file").and_then(Value::as_str) != Some(rel.as_str()))
.cloned()
.collect()
})
.unwrap_or_default();
if remaining.is_empty() {
index.delete_movie(&key)
} else {
if let Some(obj) = record.as_object_mut() {
obj.insert("media".into(), Value::Array(remaining));
}
index.set_movie(&key, record)
}
}
/// Point this file at a new path, moving its media entry along. The physical
/// file must already have been moved by the caller.
pub fn relocate(&mut self, new_path: &Path, index: Option<&mut PoolIndex>) -> CoreResult<()> {
if let Some(index) = index
&& let Some((key, record)) = index.find_movie_by_media(&self.file_path)
{
let mut record = record.clone();
let old_rel = index.rel_key(&self.file_path);
let new_rel = index.rel_key(new_path);
if let Some(media) = record.get_mut("media").and_then(Value::as_array_mut) {
for m in media.iter_mut() {
if m.get("file").and_then(Value::as_str) == Some(old_rel.as_str()) {
m["file"] = Value::from(new_rel.clone());
}
}
}
index.set_movie(&key, record)?;
self.movie_key = Some(key);
}
self.file_path = new_path.to_path_buf();
self.filename = paths::name(new_path);
Ok(())
}
/// Epoch seconds for "date added" — the stored `added` or the file mtime
/// (not ctime: creating hardlinks bumps the source's ctime).
pub fn added_timestamp(&self) -> f64 {
if let Some(ts) = self.added.as_deref().and_then(parse_iso_local) {
return ts;
}
std::fs::metadata(&self.file_path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0.0, |d| d.as_secs_f64())
}
/// The movie's year: NFO cache → a `Rok` tag → the canonical filename.
pub fn year(&self) -> Option<String> {
let cached = self
.meta
.as_ref()
.and_then(|m| m.get("year"))
.filter(|v| !v.is_null());
if let Some(v) = cached {
return Some(v.as_str().map_or_else(|| v.to_string(), String::from));
}
if let Some(t) = self.tags.iter().find(|t| {
t.category == "Rok" && !t.name.is_empty() && t.name.chars().all(|c| c.is_ascii_digit())
}) {
return Some(t.name.clone());
}
parse_pool_stem(&paths::stem(&self.file_path)).map(|p| p.1.to_string())
}
/// The cached 0–100 rating, if any.
pub fn rating(&self) -> Option<f64> {
self.meta.as_ref()?.get("rating")?.as_f64()
}
/// Fields for a miniDLNA filename template: title / year / rating / ext /
/// stem / filename (+ user attributes, which core fields override).
pub fn name_context(&self) -> BTreeMap<String, String> {
let mut ctx = self.attributes.clone();
let stem = paths::stem(&self.file_path);
let rating = self
.meta
.as_ref()
.and_then(|m| m.get("rating"))
.filter(|v| !v.is_null())
.map(|v| v.as_str().map_or_else(|| v.to_string(), String::from))
.unwrap_or_default();
ctx.insert(
"title".into(),
self.title
.clone()
.filter(|t| !t.is_empty())
.unwrap_or_else(|| stem.clone()),
);
ctx.insert("year".into(), self.year().unwrap_or_default());
ctx.insert("rating".into(), rating);
ctx.insert("ext".into(), paths::suffix(&self.file_path));
ctx.insert("stem".into(), stem);
ctx.insert("filename".into(), self.filename.clone());
ctx
}
/// Regenerate the auto tags from an NFO / TMDb source per the tag schema;
/// user-added tags are kept. Returns the newly added tag paths.
pub fn apply_tags_from(
&mut self,
source: &dyn TagSource,
schema: &[TagSchemaEntry],
tm: &mut TagManager,
index: Option<&mut PoolIndex>,
) -> CoreResult<Vec<String>> {
let mut meta = Map::new();
meta.insert("year".into(), json!(source.year()));
meta.insert("rating".into(), json!(source.rating()));
self.meta = Some(meta);
let old_auto = std::mem::take(&mut self.auto_tag_paths);
self.tags.retain(|t| !old_auto.contains(&t.full_path()));
let mut added = Vec::new();
for entry in schema {
let Some(field) = entry.csfd_field.as_deref().filter(|f| !f.is_empty()) else {
continue;
};
for value in source.field_values(field) {
let tag = tm.add_tag(&entry.category, &value);
if !self.tags.contains(&tag) {
added.push(tag.full_path());
self.tags.push(tag.clone());
}
self.auto_tag_paths.insert(tag.full_path());
}
}
if let Some(t) = source
.title()
.filter(|_| self.title.as_deref().is_none_or(str::is_empty))
{
self.title = Some(t);
}
if let Some(index) = index {
self.save_metadata(index)?;
}
Ok(added)
}
pub fn has_tag(&self, tag: &Tag) -> bool {
self.tags.contains(tag)
}
pub fn tag_paths(&self) -> BTreeSet<String> {
self.tags.iter().map(Tag::full_path).collect()
}
}
/// Parse a Python-style ISO timestamp (naive = local time) to epoch seconds.
fn parse_iso_local(s: &str) -> Option<f64> {
let naive = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f"))
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M"))
.ok()
.or_else(|| {
NaiveDate::parse_from_str(s, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
})?;
Local
.from_local_datetime(&naive)
.earliest()
.map(|dt| dt.timestamp() as f64)
}
/// Current local time as `YYYY-MM-DDTHH:MM:SS` (what `added` stores).
pub fn now_iso() -> String {
Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()
}
/// Is a tag value considered set (Python truthiness on JSON)?
pub fn json_truthy(v: &Value) -> bool {
truthy(v)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn touch(p: &Path) {
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, "x").unwrap();
}
#[test]
fn test_identity_flat_and_grouped() {
let f = File::bare(Path::new(
"/pool/Filmy/Matrix (1999) [tmdbid-603].3D.HSBS.mkv",
));
let id = f.identity_from_path();
assert_eq!(
(id.key.as_str(), id.title.as_str(), id.label.as_str()),
("tmdb-603", "Matrix", "HSBS")
);
let g = File::bare(Path::new(
"/pool/Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995] - 4K.mkv",
));
assert_eq!(g.identity_from_path().label, "4K");
let p = File::bare(Path::new(
"/pool/Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995]-part2.mkv",
));
assert_eq!(p.identity_from_path().label, "part2");
let plain = File::bare(Path::new(
"/pool/Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995].mkv",
));
assert_eq!(plain.identity_from_path().label, "");
}
#[test]
fn test_save_and_reload_metadata_through_index() {
let dir = tempfile::tempdir().unwrap();
let video = dir.path().join("Filmy/Matrix (1999) [tmdbid-603].mkv");
touch(&video);
let mut index = PoolIndex::open(dir.path());
let mut tm = TagManager::new();
let mut f = File::load(&video, &mut tm, Some(&mut index)).unwrap();
f.title = Some("Matrix".into());
f.csfd_url = Some("https://www.csfd.cz/film/9499/".into());
f.tags.push(tm.add_tag("Žánr", "Sci-Fi"));
f.save_metadata(&mut index).unwrap();
let mut index2 = PoolIndex::open(dir.path());
let mut tm2 = TagManager::new();
let back = File::load(&video, &mut tm2, Some(&mut index2)).unwrap();
assert_eq!(
back.csfd_url.as_deref(),
Some("https://www.csfd.cz/film/9499/")
);
assert_eq!(
back.tag_paths(),
["Žánr/Sci-Fi".to_string()].into_iter().collect()
);
let rec = index2.get_movie("tmdb-603").unwrap();
assert_eq!(rec["library"], "Filmy");
assert_eq!(rec["year"], "1999");
assert_eq!(
rec["media"][0]["file"],
"Filmy/Matrix (1999) [tmdbid-603].mkv"
);
}
#[test]
fn test_two_versions_share_one_record() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("Filmy/Avatar (2009) [tmdbid-19995]");
let a = folder.join("Avatar (2009) [tmdbid-19995].mkv");
let b = folder.join("Avatar (2009) [tmdbid-19995].3D.HSBS.mkv");
touch(&a);
touch(&b);
let mut index = PoolIndex::open(dir.path());
let mut tm = TagManager::new();
let mut fa = File::load(&a, &mut tm, Some(&mut index)).unwrap();
fa.csfd_url = Some("u".into());
fa.save_metadata(&mut index).unwrap();
let fb = File::load(&b, &mut tm, Some(&mut index)).unwrap();
assert_eq!(fb.csfd_url.as_deref(), Some("u"));
assert_eq!(index.records.len(), 1);
assert_eq!(
index.get_movie("tmdb-19995").unwrap()["media"]
.as_array()
.unwrap()
.len(),
2
);
fb.delete_metadata(&mut index).unwrap();
assert_eq!(
index.get_movie("tmdb-19995").unwrap()["media"]
.as_array()
.unwrap()
.len(),
1
);
fa.delete_metadata(&mut index).unwrap();
assert!(index.records.is_empty());
}
#[test]
fn test_relocate_moves_media_entry() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("Filmy/Old (1999).mkv");
touch(&old);
let mut index = PoolIndex::open(dir.path());
let mut tm = TagManager::new();
let mut f = File::load(&old, &mut tm, Some(&mut index)).unwrap();
let new = dir.path().join("Kids/Old (1999).mkv");
f.relocate(&new, Some(&mut index)).unwrap();
assert!(index.find_movie_by_media(&new).is_some());
assert_eq!(f.filename, "Old (1999).mkv");
}
#[test]
fn test_name_context_and_year_fallbacks() {
let mut f = File::bare(Path::new("/p/Filmy/Matrix (1999).mkv"));
f.attributes.insert("collection_sort".into(), "01".into());
let ctx = f.name_context();
assert_eq!(ctx["year"], "1999");
assert_eq!(ctx["title"], "Matrix (1999)");
assert_eq!(ctx["ext"], ".mkv");
assert_eq!(ctx["collection_sort"], "01");
f.tags.push(Tag::new("Rok", "2001"));
assert_eq!(f.year().as_deref(), Some("2001"));
let mut meta = Map::new();
meta.insert("year".into(), Value::from("1998"));
meta.insert("rating".into(), Value::from(87));
f.meta = Some(meta);
assert_eq!(f.name_context()["year"], "1998");
assert_eq!(f.name_context()["rating"], "87");
}
#[test]
fn test_apply_tags_from_nfo_keeps_user_tags() {
let mut tm = TagManager::new();
let mut f = File::bare(Path::new("/p/Filmy/Matrix (1999).mkv"));
f.tags.push(tm.add_tag("Vlastní", "Oblíbené"));
f.tags.push(tm.add_tag("Žánr", "Starý"));
f.auto_tag_paths.insert("Žánr/Starý".into());
let nfo = NfoData {
title: Some("Matrix".into()),
year: Some("1999".into()),
genres: vec!["Akční".into(), "Sci-Fi".into()],
countries: vec!["USA".into()],
rating: Some(87),
..Default::default()
};
let schema = crate::core::config::default_tag_schema();
f.apply_tags_from(&nfo, &schema, &mut tm, None).unwrap();
let paths = f.tag_paths();
for p in [
"Vlastní/Oblíbené",
"Žánr/Akční",
"Žánr/Sci-Fi",
"Rok/1999",
"Země původu/USA",
"Hodnocení/87",
] {
assert!(paths.contains(p), "{p}");
}
assert!(!paths.contains("Žánr/Starý"));
assert!(!f.auto_tag_paths.contains("Vlastní/Oblíbené"));
assert_eq!(f.title.as_deref(), Some("Matrix"));
assert_eq!(f.meta.as_ref().unwrap()["rating"], 87);
}
#[test]
fn test_added_timestamp_parses_iso() {
let mut f = File::bare(Path::new("/nonexistent.mkv"));
assert_eq!(f.added_timestamp(), 0.0);
f.added = Some("2026-01-02T03:04:05".into());
assert!(f.added_timestamp() > 1.7e9);
}
}
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
//! Generating the miniDLNA / Filmotéka output from the pool.
use std::collections::HashSet;
use std::path::Path;
use super::FileManager;
use crate::core::error::{CoreError, CoreResult};
use crate::core::hardlink_manager::HardlinkManager;
use crate::core::paths;
/// Random selection, refreshed each run. The "- " prefix sorts the special
/// folders ahead of the genre folders on DLNA/TV browsers.
pub const TIPS_SUBFOLDER: &str = "- Tipy dne";
/// Most recently added movies.
pub const RECENT_SUBFOLDER: &str = "- Nově přidané";
pub const TIPS_COUNT: usize = 15;
pub const RECENT_COUNT: usize = 10;
/// Where series found inside a mixed library are hardcopied.
pub const MIXED_SERIES_SUBFOLDER: &str = "Seriály";
/// Outcome of one generation run.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GenerateReport {
pub created: usize,
pub removed: usize,
pub mirrored: usize,
pub pruned: usize,
pub tips: usize,
pub recent: usize,
pub failed: usize,
/// Number of libraries the run was scoped to (`None` = all).
pub scoped_to: Option<usize>,
}
impl FileManager {
/// Build the output tree into `out`: the tag tree (with obsolete links
/// swept), mirrored series/homevideo libraries, mixed-library series, the
/// pruning of mirrors that fell out of scope, and the special folders.
pub fn generate_minidlna(&self, out: &Path) -> CoreResult<GenerateReport> {
let scope = self.minidlna_scope();
let mut report = GenerateReport {
scoped_to: self.minidlna_libraries().map(|l| l.len()),
..Default::default()
};
if scope.movies.is_empty() && scope.mirror_libs.is_empty() && scope.mixed_series.is_empty()
{
return Err(CoreError::invalid(if report.scoped_to.is_none() {
"Pool je prázdný."
} else {
"Vybrané knihovny neobsahují nic k vygenerování — zkontroluj miniDLNA → „Knihovny pro generování…“."
}));
}
let mut manager = HardlinkManager::new(out);
// Every mirrored library stays reserved (protected from the tag-tree
// cleanup) even when this run skips mirroring it.
let mut reserved: HashSet<String> = self.mirror_as_is_libraries().into_iter().collect();
reserved
.extend([TIPS_SUBFOLDER, RECENT_SUBFOLDER, MIXED_SERIES_SUBFOLDER].map(String::from));
let (created, create_fail, removed, remove_fail) =
manager.sync_structure(&scope.movies, &self.tree_spec(), &reserved, false);
report.created = created;
report.removed = removed;
report.failed += create_fail + remove_fail;
let pool = self.pool_dir();
for name in &scope.mirror_libs {
let Some(src) = pool.as_ref().map(|p| p.join(name)) else {
continue;
};
if src.is_dir() && !paths::is_empty_dir(&src) {
let (ok, fail) = manager.mirror_as_is(&src, Some(name), false);
report.mirrored += ok;
report.failed += fail;
}
}
for ser in &scope.mixed_series {
if ser.folder.is_dir() {
let sub = format!("{MIXED_SERIES_SUBFOLDER}/{}", paths::name(&ser.folder));
let (ok, fail) = manager.mirror_as_is(&ser.folder, Some(&sub), false);
report.mirrored += ok;
report.failed += fail;
}
}
// Mirrors have no obsolete-link cleanup of their own — remove exactly
// what fell out of scope.
let (stale, valid_bucket) = self.minidlna_prune_targets(
&scope.mirror_libs,
&scope.mixed_series,
MIXED_SERIES_SUBFOLDER,
);
report.pruned = stale.iter().filter(|n| manager.remove_mirror(n)).count();
let bucket = out.join(MIXED_SERIES_SUBFOLDER);
if bucket.is_dir() {
for entry in paths::entries(&bucket) {
let name = paths::name(&entry);
if !valid_bucket.contains(&name)
&& manager.remove_mirror(&format!("{MIXED_SERIES_SUBFOLDER}/{name}"))
{
report.pruned += 1;
}
}
}
let (tips, tips_fail) =
manager.generate_random_tips(&scope.movies, TIPS_COUNT, TIPS_SUBFOLDER, false);
let (recent, recent_fail) =
manager.generate_recently_added(&scope.movies, RECENT_COUNT, RECENT_SUBFOLDER, false);
report.tips = tips;
report.recent = recent;
report.failed += tips_fail + recent_fail;
Ok(report)
}
}
#[cfg(test)]
mod tests {
use super::super::movies::ImportOptions;
use super::super::test_support::Fixture;
use super::super::{Library, LibraryKind};
use super::*;
use std::fs;
#[test]
fn test_generate_builds_tree_mirrors_and_special_folders() {
let mut fx = Fixture::new();
let out = fx.dir.path().join("out");
let p = fx
.fm
.import_movie(
&fx.source("a.mkv"),
"Matrix",
ImportOptions {
year: Some("1999".into()),
..Default::default()
},
)
.unwrap()
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&p), "Žánr/Sci-Fi")
.unwrap();
let ep = fx.pool().join("Seriály/Show/Season 01/S01E01 - Show.mkv");
fs::create_dir_all(ep.parent().unwrap()).unwrap();
fs::write(&ep, "e").unwrap();
let r = fx.fm.generate_minidlna(&out).unwrap();
assert_eq!(
(r.created, r.mirrored, r.tips, r.recent, r.failed),
(1, 1, 1, 1, 0)
);
assert!(out.join("Sci-Fi/Matrix.mkv").exists());
assert!(
out.join("Seriály/Show/Season 01/S01E01 - Show.mkv")
.exists()
);
assert!(out.join(TIPS_SUBFOLDER).join("Matrix.mkv").exists());
// deselecting the series library prunes its mirror
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Seriály", LibraryKind::Series),
Library::new("Doma", LibraryKind::HomeVideo),
])
.unwrap();
fx.fm
.set_minidlna_libraries(Some(vec!["Filmy".into(), "Doma".into()]))
.unwrap();
fs::create_dir_all(fx.pool().join("Doma")).unwrap();
fs::write(fx.pool().join("Doma/v.mp4"), "v").unwrap();
let r2 = fx.fm.generate_minidlna(&out).unwrap();
assert!(r2.pruned >= 1, "{r2:?}");
assert!(!out.join("Seriály/Show").exists());
assert!(out.join("Doma/v.mp4").exists());
assert_eq!(r2.scoped_to, Some(2));
}
#[test]
fn test_generate_errors_when_scope_is_empty() {
let mut fx = Fixture::new();
// a fresh pool still has the (empty) Seriály mirror in scope
assert_eq!(
fx.fm
.generate_minidlna(&fx.dir.path().join("out"))
.unwrap()
.created,
0
);
fx.fm
.set_minidlna_libraries(Some(vec!["Filmy".into()]))
.unwrap();
assert!(fx.fm.generate_minidlna(&fx.dir.path().join("out")).is_err());
}
}
+870
View File
@@ -0,0 +1,870 @@
//! `FileManager` — the service layer and the only thing that changes the pool.
//!
//! Everything the user can do maps onto one of its public methods. Methods that
//! change files also keep the pool index and the in-memory `filelist` in step.
//! Files are addressed by their current path.
mod minidlna;
mod movies;
mod series_ops;
mod tools;
mod versions;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::config::{
GlobalConfig, TagSchemaEntry, default_tag_schema, global_config_path, load_global_config_from,
save_global_config_to,
};
use super::error::{CoreError, CoreResult};
use super::file::File;
use super::naming::is_canonical_pool_stem;
use super::paths;
use super::pool_index::PoolIndex;
use super::series::{Series, looks_like_series_folder, scan_series};
use super::tag::Tag;
use super::tag_manager::TagManager;
pub use minidlna::{GenerateReport, MIXED_SERIES_SUBFOLDER, RECENT_SUBFOLDER, TIPS_SUBFOLDER};
pub use movies::{ImportOptions, MovieEdits, OnConflict};
pub use series_ops::{
EpisodeCode, ImportEpisodesResult, RenameAllSeriesResult, RenameEpisodesResult,
};
pub use tools::{
ConsistencyReport, DuplicateGroup, IntegrityReport, JellyfinReport, MinidlnaScope,
RefreshReport, RenameAllReport, ResolveResult, Stats,
};
pub use versions::{GroupMode, label_of, version_filename};
/// Top-level folders of a fresh pool.
pub const POOL_MOVIES: &str = "Filmy";
pub const POOL_SERIES: &str = "Seriály";
/// Curator metadata files that must never be treated as content.
pub const METADATA_SUFFIXES: &[&str] = &[".!tag", ".!ftag", ".!gtag", ".!index"];
/// Sidecars Jellyfin writes next to a movie (NFO + artwork / subtitles).
pub const SIDECAR_SUFFIXES: &[&str] = &[
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn", ".srt", ".ass", ".ssa",
".sub", ".idx", ".vtt",
];
/// True for files that are pool content (not Curator metadata / Jellyfin sidecars).
pub fn is_media_file(path: &Path) -> bool {
let name = paths::name(path);
!METADATA_SUFFIXES.iter().any(|s| name.ends_with(s))
&& !SIDECAR_SUFFIXES.contains(&paths::suffix_lower(path).as_str())
}
/// The kind of a pool library (top-level folder).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LibraryKind {
/// Single-file movies, TMDb naming.
Movie,
/// Series/season/episode tree (Jellyfin Shows).
Series,
/// Movies at the top level, series in subfolders.
Mixed,
/// Files without TMDb (home videos), mirrored as-is.
HomeVideo,
}
impl LibraryKind {
pub const ALL: [LibraryKind; 4] = [Self::Movie, Self::Series, Self::Mixed, Self::HomeVideo];
pub fn as_str(self) -> &'static str {
match self {
Self::Movie => "movie",
Self::Series => "series",
Self::Mixed => "mixed",
Self::HomeVideo => "homevideo",
}
}
/// Unknown kinds read as `movie`, like the Python version.
pub fn parse(s: &str) -> Self {
match s {
"series" => Self::Series,
"mixed" => Self::Mixed,
"homevideo" => Self::HomeVideo,
_ => Self::Movie,
}
}
/// Czech label for the library organiser.
pub fn label(self) -> &'static str {
match self {
Self::Movie => "Filmy (jednotlivé filmy)",
Self::Series => "Seriály",
Self::Mixed => "Mix (filmy + seriály)",
Self::HomeVideo => "Domácí videa (bez TMDb)",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Library {
pub name: String,
pub kind: LibraryKind,
}
impl Library {
pub fn new(name: &str, kind: LibraryKind) -> Self {
Self {
name: name.into(),
kind,
}
}
}
fn default_libraries() -> Vec<Library> {
vec![
Library::new(POOL_MOVIES, LibraryKind::Movie),
Library::new(POOL_SERIES, LibraryKind::Series),
]
}
/// Library settings that moved from the old global config into the index.
const LEGACY_LIBRARY_SETTINGS: &[&str] = &["tag_schema", "copyasis_folders", "movie_libraries"];
fn clean_libraries(specs: impl IntoIterator<Item = Library>) -> Vec<Library> {
let mut seen = HashSet::new();
specs
.into_iter()
.filter_map(|l| {
let name = l.name.trim().to_string();
(!name.is_empty() && seen.insert(name.to_lowercase()))
.then_some(Library { name, kind: l.kind })
})
.collect()
}
fn str_list(v: Option<&Value>) -> Option<Vec<String>> {
v.and_then(Value::as_array).map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
}
pub struct FileManager {
pub filelist: Vec<File>,
pub tagmanager: TagManager,
pub global_config: GlobalConfig,
/// Unified pool metadata + library settings.
pub index: Option<PoolIndex>,
config_path: PathBuf,
}
impl FileManager {
/// A manager using the per-user global config.
pub fn new() -> Self {
Self::with_config_path(global_config_path())
}
/// A manager reading/writing its global config at `config_path` (tests use
/// a temporary one). Falls back to a legacy `.Curator.!gtag` in the working
/// directory on first run.
pub fn with_config_path(config_path: PathBuf) -> Self {
let global_config = if config_path.exists() {
load_global_config_from(&config_path)
} else if config_path == global_config_path() {
super::config::load_global_config()
} else {
GlobalConfig::default()
};
Self {
filelist: Vec::new(),
tagmanager: TagManager::new(),
global_config,
index: None,
config_path,
}
}
pub fn save_global_config(&self) -> CoreResult<()> {
save_global_config_to(&self.global_config, &self.config_path)
}
// ------------------------------------------------------------------
// Pool and output folders
// ------------------------------------------------------------------
pub fn pool_dir(&self) -> Option<PathBuf> {
self.global_config
.pool_dir
.as_deref()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
pub fn filmoteka_dir(&self) -> Option<PathBuf> {
self.global_config
.filmoteka_dir
.as_deref()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
pub(crate) fn require_pool(&self) -> CoreResult<PathBuf> {
self.pool_dir()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))
}
/// Set the pool root and create every library's top-level folder.
pub fn set_pool_dir(&mut self, pool_dir: &Path) -> CoreResult<()> {
self.global_config.pool_dir = Some(pool_dir.to_string_lossy().into_owned());
self.save_global_config()?;
self.open_index(pool_dir)?;
for lib in self.libraries() {
fs::create_dir_all(pool_dir.join(&lib.name))?;
}
Ok(())
}
/// Set the miniDLNA output folder.
pub fn set_filmoteka_dir(&mut self, dir: &Path) -> CoreResult<()> {
self.global_config.filmoteka_dir = Some(dir.to_string_lossy().into_owned());
self.save_global_config()
}
// ------------------------------------------------------------------
// Pool index + library-level settings
// ------------------------------------------------------------------
/// (Re)load the pool index and migrate legacy global settings into it.
fn open_index(&mut self, pool: &Path) -> CoreResult<()> {
self.index = Some(PoolIndex::open(pool));
self.migrate_global_settings()
}
pub(crate) fn ensure_index(&mut self) -> Option<&mut PoolIndex> {
if self.index.is_none()
&& let Some(pool) = self.pool_dir()
{
let _ = self.open_index(&pool);
}
self.index.as_mut()
}
fn index_ref(&self) -> Option<&PoolIndex> {
self.index.as_ref()
}
/// Move library settings from the old global config into the index once.
fn migrate_global_settings(&mut self) -> CoreResult<()> {
let Some(index) = self.index.as_mut() else {
return Ok(());
};
let mut moved = false;
for key in LEGACY_LIBRARY_SETTINGS {
if let Some(value) = self.global_config.extra.shift_remove(*key) {
if !index.settings.contains_key(*key) {
index.set_setting(key, value)?;
}
moved = true;
}
}
if moved {
self.save_global_config()?;
}
Ok(())
}
fn setting(&self, key: &str) -> Option<&Value> {
self.index_ref()?.get_setting(key)
}
fn set_setting(&mut self, key: &str, value: Value) -> CoreResult<()> {
let index = self
.ensure_index()
.ok_or_else(|| CoreError::invalid("Pool není nastaven — není kam uložit nastavení."))?;
index.set_setting(key, value)
}
/// All pool libraries (source of truth). Old pools are synthesised from the
/// legacy `movie_libraries` + `copyasis_folders` settings.
pub fn libraries(&self) -> Vec<Library> {
let raw: Vec<Library> = match self.setting("libraries").and_then(Value::as_array) {
Some(arr) => arr
.iter()
.map(|spec| Library {
name: spec
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
kind: LibraryKind::parse(
spec.get("kind").and_then(Value::as_str).unwrap_or("movie"),
),
})
.collect(),
None => {
let movies = str_list(self.setting("movie_libraries"))
.unwrap_or_else(|| vec![POOL_MOVIES.into()]);
let series = str_list(self.setting("copyasis_folders"))
.unwrap_or_else(|| vec![POOL_SERIES.into()]);
movies
.iter()
.map(|n| Library::new(n, LibraryKind::Movie))
.chain(series.iter().map(|n| Library::new(n, LibraryKind::Series)))
.collect()
}
};
let out = clean_libraries(raw);
if out.is_empty() {
default_libraries()
} else {
out
}
}
/// Replace the library list and persist it.
pub fn set_libraries(&mut self, specs: Vec<Library>) -> CoreResult<()> {
let mut cleaned = clean_libraries(specs);
if cleaned.is_empty() {
cleaned = default_libraries();
}
let value = Value::Array(
cleaned
.iter()
.map(|l| json!({"name": l.name, "kind": l.kind.as_str()}))
.collect(),
);
self.set_setting("libraries", value)
}
pub fn libraries_of_kind(&self, kinds: &[LibraryKind]) -> Vec<String> {
self.libraries()
.into_iter()
.filter(|l| kinds.contains(&l.kind))
.map(|l| l.name)
.collect()
}
pub fn library_kind(&self, name: &str) -> Option<LibraryKind> {
self.libraries()
.into_iter()
.find(|l| l.name == name)
.map(|l| l.kind)
}
/// Libraries holding single-file movies (never empty).
pub fn movie_libraries(&self) -> Vec<String> {
let libs = self.libraries_of_kind(&[LibraryKind::Movie]);
if libs.is_empty() {
vec![POOL_MOVIES.into()]
} else {
libs
}
}
/// Libraries a movie can live in — kind movie or mixed (never empty).
pub fn libraries_accepting_movies(&self) -> Vec<String> {
let libs = self.libraries_of_kind(&[LibraryKind::Movie, LibraryKind::Mixed]);
if libs.is_empty() {
vec![POOL_MOVIES.into()]
} else {
libs
}
}
/// Libraries that can hold series — kind series or mixed.
pub fn series_libraries(&self) -> Vec<String> {
self.libraries_of_kind(&[LibraryKind::Series, LibraryKind::Mixed])
}
/// The series (copy-as-is) libraries.
pub fn copyasis_folders(&self) -> Vec<String> {
self.libraries_of_kind(&[LibraryKind::Series])
}
/// Libraries hardcopied 1:1 into miniDLNA — series and homevideo.
pub fn mirror_as_is_libraries(&self) -> Vec<String> {
self.libraries_of_kind(&[LibraryKind::Series, LibraryKind::HomeVideo])
}
/// Libraries included in miniDLNA generation, `None` = all. Unknown names
/// are dropped on read.
pub fn minidlna_libraries(&self) -> Option<Vec<String>> {
let raw = str_list(self.setting("minidlna_libraries"))?;
let known: HashSet<String> = self.libraries().into_iter().map(|l| l.name).collect();
Some(raw.into_iter().filter(|n| known.contains(n)).collect())
}
/// Restrict miniDLNA generation to `names`; `None` resets to "all".
pub fn set_minidlna_libraries(&mut self, names: Option<Vec<String>>) -> CoreResult<()> {
let value = match names {
None => Value::Null,
Some(list) => {
let mut seen = HashSet::new();
Value::Array(
list.into_iter()
.filter(|n| seen.insert(n.clone()))
.map(Value::from)
.collect(),
)
}
};
if value.is_null() {
let index = self.ensure_index().ok_or_else(|| {
CoreError::invalid("Pool není nastaven — není kam uložit nastavení.")
})?;
index
.settings
.insert("minidlna_libraries".into(), Value::Null);
return index.save();
}
self.set_setting("minidlna_libraries", value)
}
/// Top-level pool folders that aren't a configured library: `(name, is_empty)`.
pub fn orphan_pool_folders(&self) -> Vec<(String, bool)> {
let Some(pool) = self.pool_dir().filter(|p| p.is_dir()) else {
return Vec::new();
};
let known: HashSet<String> = self.libraries().into_iter().map(|l| l.name).collect();
paths::subdirs(&pool)
.into_iter()
.filter(|d| !known.contains(&paths::name(d)))
.map(|d| (paths::name(&d), paths::is_empty_dir(&d)))
.collect()
}
/// Delete an **empty** orphan folder in the pool root.
pub fn remove_empty_pool_folder(&self, name: &str) -> bool {
let Some(pool) = self.pool_dir() else {
return false;
};
let known = self.libraries().iter().any(|l| l.name == name);
let target = pool.join(name);
!known && paths::is_empty_dir(&target) && fs::remove_dir(&target).is_ok()
}
/// Which library (top-level pool folder) a pooled path lives in.
pub fn library_of(&self, path: &Path) -> Option<String> {
let pool = self.pool_dir()?;
let rel = path.strip_prefix(&pool).ok()?;
rel.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
}
pub fn tag_schema(&self) -> Vec<TagSchemaEntry> {
self.setting("tag_schema")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(default_tag_schema)
}
pub fn set_tag_schema(&mut self, schema: &[TagSchemaEntry]) -> CoreResult<()> {
self.set_setting("tag_schema", serde_json::to_value(schema)?)
}
/// The miniDLNA tree spec derived from the tag schema.
pub fn tree_spec(&self) -> super::hardlink_manager::TreeSpec {
let schema = self.tag_schema();
let mut spec = super::hardlink_manager::TreeSpec {
roots: Some(
schema
.iter()
.filter_map(|e| Some((e.category.clone(), e.filmoteka_root.clone()?)))
.collect(),
),
..Default::default()
};
for e in schema.iter().filter(|e| e.filmoteka_root.is_some()) {
if let Some(t) = e.transform.clone().filter(|t| !t.is_empty()) {
spec.transforms.insert(e.category.clone(), t);
}
if let Some(t) = e.filename_template.clone().filter(|t| !t.is_empty()) {
spec.templates.insert(e.category.clone(), t);
}
}
spec
}
// ------------------------------------------------------------------
// Loading
// ------------------------------------------------------------------
/// Reload the movie list from every movie/mixed library via the index.
pub fn load_pool_movies(&mut self) -> CoreResult<()> {
self.filelist.clear();
self.tagmanager.clear();
let Some(pool) = self.pool_dir() else {
return Ok(());
};
self.open_index(&pool)?;
let mut candidates = Vec::new();
for lib in self.libraries() {
let lib_dir = pool.join(&lib.name);
if !lib_dir.is_dir() {
continue;
}
match lib.kind {
LibraryKind::Movie => candidates.extend(paths::walk_files(&lib_dir)),
LibraryKind::Mixed => {
// A mixed library keeps movies at the top level and series in
// subfolders; a movie's version folder has the same shape as a
// series folder but never contains a Season folder.
candidates.extend(paths::entries(&lib_dir).into_iter().filter(|p| p.is_file()));
for sub in paths::subdirs(&lib_dir) {
if is_canonical_pool_stem(&paths::name(&sub))
&& !looks_like_series_folder(&sub)
{
candidates
.extend(paths::entries(&sub).into_iter().filter(|p| p.is_file()));
}
}
}
_ => {}
}
}
let index = self
.index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
for path in candidates.into_iter().filter(|p| is_media_file(p)) {
self.filelist
.push(File::load(&path, &mut self.tagmanager, Some(index))?);
}
Ok(())
}
/// Scan every series library.
pub fn load_series(&self) -> Vec<Series> {
let Some(pool) = self.pool_dir() else {
return Vec::new();
};
self.libraries()
.into_iter()
.filter(|l| matches!(l.kind, LibraryKind::Series | LibraryKind::Mixed))
.flat_map(|l| scan_series(&pool.join(&l.name), &l.name, l.kind == LibraryKind::Mixed))
.collect()
}
// ------------------------------------------------------------------
// File lookup helpers
// ------------------------------------------------------------------
pub fn position(&self, path: &Path) -> Option<usize> {
self.filelist.iter().position(|f| f.file_path == path)
}
pub fn file(&self, path: &Path) -> Option<&File> {
self.filelist.iter().find(|f| f.file_path == path)
}
/// Run `op` on the loaded file at `path`, or on a freshly loaded transient
/// one when it isn't in `filelist`.
pub(crate) fn with_file<R>(
&mut self,
path: &Path,
op: impl FnOnce(&mut File, &mut TagManager, &mut PoolIndex) -> CoreResult<R>,
) -> CoreResult<R> {
if self.ensure_index().is_none() {
return Err(CoreError::invalid("Pool není nastaven."));
}
let Self {
filelist,
tagmanager,
index,
..
} = self;
let index = index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
match filelist.iter_mut().find(|f| f.file_path == path) {
Some(f) => op(f, tagmanager, index),
None => {
let mut f = File::load(path, tagmanager, Some(index))?;
op(&mut f, tagmanager, index)
}
}
}
/// Set (or clear with `None`) the manual date of files.
pub fn set_date(&mut self, files: &[PathBuf], date: Option<&str>) -> CoreResult<()> {
let date = date
.map(str::trim)
.filter(|d| !d.is_empty())
.map(String::from);
for path in files {
let d = date.clone();
self.with_file(path, |f, _, index| {
f.date = d;
f.save_metadata(index)
})?;
}
Ok(())
}
/// Persist a loaded file's metadata.
pub fn save_file(&mut self, path: &Path) -> CoreResult<()> {
self.with_file(path, |f, _, index| f.save_metadata(index))
}
// ------------------------------------------------------------------
// Tags
// ------------------------------------------------------------------
/// Assign a `Category/Name` tag (no category → "default") to files.
pub fn assign_tag(&mut self, files: &[PathBuf], tag: &str) -> CoreResult<()> {
let tag = Tag::from_full_path(tag).unwrap_or_else(|| Tag::new("default", tag));
for path in files {
self.with_file(path, |f, tm, index| {
let t = tm.add_tag(&tag.category, &tag.name);
if !f.tags.contains(&t) {
f.tags.push(t);
f.save_metadata(index)?;
}
Ok(())
})?;
}
Ok(())
}
/// Remove a tag from files.
pub fn remove_tag(&mut self, files: &[PathBuf], tag: &str) -> CoreResult<()> {
let tag = Tag::from_full_path(tag).unwrap_or_else(|| Tag::new("default", tag));
for path in files {
self.with_file(path, |f, _, index| {
if let Some(i) = f.tags.iter().position(|t| *t == tag) {
f.tags.remove(i);
f.save_metadata(index)?;
}
Ok(())
})?;
}
Ok(())
}
/// Files carrying **all** of `tags` (full paths); empty = all files.
pub fn filter_files_by_tags(&self, tags: &HashSet<String>) -> Vec<&File> {
self.filelist
.iter()
.filter(|f| {
tags.iter()
.all(|t| f.tags.iter().any(|ft| &ft.full_path() == t))
})
.collect()
}
}
impl Default for FileManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
/// A manager with a temporary config and pool (`<tmp>/pool`).
pub struct Fixture {
pub dir: tempfile::TempDir,
pub fm: FileManager,
}
impl Fixture {
pub fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let mut fm = FileManager::with_config_path(dir.path().join("cfg.json"));
fm.set_pool_dir(&dir.path().join("pool")).unwrap();
Self { dir, fm }
}
pub fn pool(&self) -> PathBuf {
self.dir.path().join("pool")
}
/// A source file outside the pool.
pub fn source(&self, name: &str) -> PathBuf {
let p = self.dir.path().join("src").join(name);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, name.as_bytes()).unwrap();
p
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::Fixture;
use super::*;
#[test]
fn test_set_pool_creates_top_level_folders() {
let fx = Fixture::new();
assert!(fx.pool().join("Filmy").is_dir());
assert!(fx.pool().join("Seriály").is_dir());
}
#[test]
fn test_libraries_default_and_set_all_kinds() {
let mut fx = Fixture::new();
assert_eq!(fx.fm.libraries(), default_libraries());
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Kids", LibraryKind::Mixed),
Library::new("Domácí", LibraryKind::HomeVideo),
Library::new("filmy", LibraryKind::Series),
Library::new(" ", LibraryKind::Movie),
])
.unwrap();
let names: Vec<String> = fx.fm.libraries().into_iter().map(|l| l.name).collect();
assert_eq!(names, vec!["Filmy", "Kids", "Domácí"]);
assert_eq!(fx.fm.libraries_accepting_movies(), vec!["Filmy", "Kids"]);
assert_eq!(fx.fm.mirror_as_is_libraries(), vec!["Domácí"]);
assert!(fx.fm.copyasis_folders().is_empty());
}
#[test]
fn test_library_settings_stored_in_index_not_global() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![Library::new("A", LibraryKind::Movie)])
.unwrap();
let raw = fs::read_to_string(fx.pool().join(".Curator.!index")).unwrap();
assert!(raw.contains("\"libraries\""));
let cfg = fs::read_to_string(fx.dir.path().join("cfg.json")).unwrap();
assert!(!cfg.contains("libraries"));
}
#[test]
fn test_migrates_legacy_global_settings_into_index() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("cfg.json");
let pool = dir.path().join("pool");
fs::write(
&cfg,
json!({"pool_dir": pool, "copyasis_folders": ["Seriály", "Dokumenty"]}).to_string(),
)
.unwrap();
let mut fm = FileManager::with_config_path(cfg.clone());
fm.load_pool_movies().unwrap();
assert_eq!(fm.copyasis_folders(), vec!["Seriály", "Dokumenty"]);
assert!(
!fs::read_to_string(&cfg)
.unwrap()
.contains("copyasis_folders")
);
}
#[test]
fn test_minidlna_libraries_default_set_and_unknown() {
let mut fx = Fixture::new();
assert_eq!(fx.fm.minidlna_libraries(), None);
fx.fm
.set_minidlna_libraries(Some(vec!["Filmy".into(), "Gone".into(), "Filmy".into()]))
.unwrap();
assert_eq!(fx.fm.minidlna_libraries(), Some(vec!["Filmy".to_string()]));
fx.fm.set_minidlna_libraries(None).unwrap();
assert_eq!(fx.fm.minidlna_libraries(), None);
}
#[test]
fn test_orphan_folder_cleanup() {
let fx = Fixture::new();
fs::create_dir_all(fx.pool().join("Empty")).unwrap();
fs::create_dir_all(fx.pool().join("Full")).unwrap();
fs::write(fx.pool().join("Full/x.mkv"), "").unwrap();
assert_eq!(
fx.fm.orphan_pool_folders(),
vec![("Empty".to_string(), true), ("Full".to_string(), false)]
);
assert!(fx.fm.remove_empty_pool_folder("Empty"));
assert!(!fx.fm.remove_empty_pool_folder("Full"));
assert!(!fx.fm.remove_empty_pool_folder("Filmy"));
}
#[test]
fn test_tree_spec_from_schema() {
let fx = Fixture::new();
let spec = fx.fm.tree_spec();
let roots = spec.roots.unwrap();
assert_eq!(roots["Žánr"], "");
assert_eq!(roots["Rok"], "- Dle roku");
assert_eq!(spec.transforms["Hodnocení"], "decade_band");
}
#[test]
fn test_tag_assign_remove_and_filter() {
let mut fx = Fixture::new();
let a = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", Default::default())
.unwrap()
.unwrap();
let b = fx
.fm
.import_movie(&fx.source("b.mkv"), "B", Default::default())
.unwrap()
.unwrap();
fx.fm
.assign_tag(&[a.clone(), b.clone()], "Žánr/Drama")
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&a), "Rok/1999")
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&a), "Rok/1999")
.unwrap();
assert_eq!(fx.fm.file(&a).unwrap().tags.len(), 2);
let want: HashSet<String> = ["Žánr/Drama".to_string(), "Rok/1999".to_string()]
.into_iter()
.collect();
assert_eq!(fx.fm.filter_files_by_tags(&want).len(), 1);
assert_eq!(fx.fm.filter_files_by_tags(&HashSet::new()).len(), 2);
fx.fm
.remove_tag(std::slice::from_ref(&a), "Žánr/Drama")
.unwrap();
assert_eq!(fx.fm.file(&a).unwrap().tags.len(), 1);
fx.fm
.assign_tag(std::slice::from_ref(&b), "bezkategorie")
.unwrap();
assert!(
fx.fm
.file(&b)
.unwrap()
.tags
.contains(&Tag::new("default", "bezkategorie"))
);
}
#[test]
fn test_load_scans_movie_and_mixed_libraries_and_skips_sidecars() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Mix", LibraryKind::Mixed),
Library::new("Seriály", LibraryKind::Series),
])
.unwrap();
let pool = fx.pool();
for rel in [
"Filmy/A (2000).mkv",
"Filmy/A (2000).nfo",
"Filmy/A (2000).cs.srt",
"Filmy/sub/B (2001).mkv",
"Mix/C (2002).mkv",
"Mix/Show (2010) [tmdbid-1]/Season 01/S01E01 - Show.mkv",
"Seriály/X/Season 01/S01E01 - X.mkv",
] {
fs::create_dir_all(pool.join(rel).parent().unwrap()).unwrap();
fs::write(pool.join(rel), "x").unwrap();
}
fx.fm.load_pool_movies().unwrap();
let names: Vec<String> = fx.fm.filelist.iter().map(|f| f.filename.clone()).collect();
assert_eq!(names, vec!["A (2000).mkv", "B (2001).mkv", "C (2002).mkv"]);
let series: Vec<String> = fx.fm.load_series().into_iter().map(|s| s.title).collect();
assert_eq!(series, vec!["Show", "X"]);
}
}
+786
View File
@@ -0,0 +1,786 @@
//! Importing, renaming, moving and editing single movies.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use super::FileManager;
use crate::core::error::{CoreError, CoreResult};
use crate::core::file::{File, now_iso};
use crate::core::naming::{
canonical_pool_stem, is_valid_year, parse_3d_format, parse_pool_stem, parse_tmdb_id,
};
use crate::core::paths;
use crate::core::subtitles;
use crate::core::tag::Tag;
static TRAILING_3D_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\.3D\.(?:HSBS|FSBS|HTAB|FTAB|MVC)$").expect("valid regex"));
/// What to do when a movie of the same name is already pooled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OnConflict {
/// Keep both; the new file gets a `_N` suffix.
#[default]
Suffix,
/// Evict the existing movie (all its versions) first.
Replace,
/// Don't import.
Skip,
/// Add the file as another version of the pooled film.
Version,
}
/// Optional parameters of [`FileManager::import_movie`].
#[derive(Debug, Clone, Default)]
pub struct ImportOptions {
/// Move the source instead of copying it.
pub move_source: bool,
pub on_conflict: OnConflict,
pub year: Option<String>,
pub tmdb_id: Option<String>,
pub library: Option<String>,
pub csfd_url: Option<String>,
pub format_3d: Option<String>,
/// Popisek when `on_conflict == Version` (else derived from `format_3d`).
pub version_label: Option<String>,
}
/// Every editable parameter of a movie at once ([`FileManager::apply_movie_edits`]).
/// `None` fields are left unchanged.
#[derive(Debug, Clone, Default)]
pub struct MovieEdits {
pub title: String,
pub year: Option<String>,
pub tmdb_id: Option<String>,
pub library: Option<String>,
pub date: Option<String>,
pub attributes: Option<std::collections::BTreeMap<String, String>>,
/// Full `["Category/Name", …]` list.
pub tags: Option<Vec<String>>,
pub csfd_url: Option<String>,
/// `None` keeps the current 3D tag, `Some("")` clears it.
pub format_3d: Option<String>,
}
impl FileManager {
/// The movie-identity stem a pooled path belongs to: a version's folder
/// name, or a flat file's stem minus any `.3D.<FORMAT>` tag.
pub(crate) fn identity_stem(&self, path: &Path) -> String {
if self.is_movie_group_member(path) {
return path.parent().map(paths::name).unwrap_or_default();
}
TRAILING_3D_RE.replace(&paths::stem(path), "").to_string()
}
/// Pooled files of the movie identity `title` (case-insensitive).
pub fn pooled_with_stem(&self, title: &str) -> Vec<PathBuf> {
let key = title.trim().to_lowercase();
self.filelist
.iter()
.filter(|f| self.identity_stem(&f.file_path).to_lowercase() == key)
.map(|f| f.file_path.clone())
.collect()
}
/// Bring a video into a movie library and index it. Returns the pooled path,
/// or `None` when skipped.
///
/// Named `Title (YYYY) [tmdbid-N].ext` when a year is given (else
/// `Title.ext`); `format_3d` adds `.3D.<FORMAT>`. The source is copied unless
/// `move_source` is set.
pub fn import_movie(
&mut self,
source: &Path,
title: &str,
opts: ImportOptions,
) -> CoreResult<Option<PathBuf>> {
let pool = self.require_pool()?;
let lib = opts
.library
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.unwrap_or_else(|| self.movie_libraries()[0].clone());
let movies = pool.join(&lib);
fs::create_dir_all(&movies)?;
self.ensure_index();
let safe_title = if title.trim().is_empty() {
paths::stem(source)
} else {
title.trim().to_string()
};
let year = opts
.year
.as_deref()
.map(str::trim)
.filter(|y| !y.is_empty());
let fmt = opts.format_3d.as_deref().filter(|f| !f.is_empty());
let (identity, stem) = match year {
Some(y) => (
canonical_pool_stem(&safe_title, y, opts.tmdb_id.as_deref(), None)?,
canonical_pool_stem(&safe_title, y, opts.tmdb_id.as_deref(), fmt)?,
),
None => (safe_title.clone(), safe_title.clone()),
};
let ext = paths::suffix(source);
let mut target = movies.join(format!("{stem}{ext}"));
let existing = self.pooled_with_stem(&identity);
if !existing.is_empty() && opts.on_conflict == OnConflict::Version {
let label = opts.version_label.clone().unwrap_or_else(|| {
fmt.map(|f| format!("3D {}", f.to_uppercase()))
.unwrap_or_default()
});
return self
.add_movie_version(&existing[0], source, &label, opts.move_source, None)
.map(Some);
}
let conflict = !existing.is_empty() || target.exists() || movies.join(&identity).is_dir();
if conflict && opts.on_conflict == OnConflict::Skip {
return Ok(None);
}
if conflict && opts.on_conflict == OnConflict::Replace {
for path in existing {
if path.exists() {
self.remove_movie(&path)?;
}
}
if target.exists() {
fs::remove_file(&target)?;
}
} else {
let mut candidate = stem.clone();
let mut counter = 1;
while movies.join(format!("{candidate}{ext}")).exists()
|| movies.join(&candidate).is_dir()
{
candidate = format!("{stem}_{counter}");
counter += 1;
}
target = movies.join(format!("{candidate}{ext}"));
}
paths::transfer(source, &target, opts.move_source)?;
let csfd = opts.csfd_url.filter(|u| !u.is_empty());
let Self {
tagmanager,
index,
filelist,
..
} = self;
let index = index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
let mut file = File::load(&target, tagmanager, Some(index))?;
file.title = Some(safe_title);
file.csfd_url = csfd;
file.added = Some(now_iso());
file.save_metadata(index)?;
filelist.push(file);
Ok(Some(target))
}
/// Move a file on disk, carry its subtitles along and repoint its record.
pub(crate) fn relocate_file(
&mut self,
old: &Path,
new: &Path,
rename_only: bool,
) -> CoreResult<()> {
if rename_only {
fs::rename(old, new)?;
} else {
if let Some(parent) = new.parent() {
fs::create_dir_all(parent)?;
}
paths::move_file(old, new)?;
}
subtitles::rename_subtitles_with(old, new)?;
self.with_file(old, |f, _, index| f.relocate(new, Some(index)))?;
Ok(())
}
/// Rename a pooled movie's file to `<new_title>.<ext>`.
pub fn rename_movie(&mut self, path: &Path, new_title: &str) -> CoreResult<PathBuf> {
let new_title = new_title.trim();
if new_title.is_empty() {
return Err(CoreError::invalid("Název nesmí být prázdný."));
}
if new_title.contains(['/', '\\']) {
return Err(CoreError::invalid("Název nesmí obsahovat lomítka."));
}
if self.is_movie_group_member(path) {
return Err(CoreError::invalid(
"Film s více verzemi zatím nelze takhle přejmenovat — odeber verzi nebo uprav soubory ve složce filmu ručně.",
));
}
let new_path = path.with_file_name(format!("{new_title}{}", paths::suffix(path)));
if new_path == path {
return Ok(new_path);
}
if new_path.exists() {
return Err(CoreError::Exists(format!(
"Soubor „{}“ už v poolu existuje.",
paths::name(&new_path)
)));
}
self.relocate_file(path, &new_path, true)?;
let title = new_title.to_string();
self.with_file(&new_path, |f, _, index| {
f.title = Some(title);
f.save_metadata(index)
})?;
Ok(new_path)
}
/// The `Title (YYYY) [tmdbid-N]` stem a movie should have, or `None` when no
/// four-digit year is known. An existing tmdbid / 3D tag is kept
/// (`include_3d = false` drops the 3D tag — used for a version folder).
pub(crate) fn canonical_name_for(&self, file: &File, include_3d: bool) -> Option<String> {
let ctx = file.name_context();
let stem = paths::stem(&file.file_path);
let mut title = file
.title
.clone()
.filter(|t| !t.is_empty())
.or_else(|| ctx.get("title").cloned().filter(|t| !t.is_empty()))
.unwrap_or_else(|| stem.clone())
.trim()
.to_string();
let mut year = ctx
.get("year")
.cloned()
.unwrap_or_default()
.trim()
.to_string();
if let Some((t, y)) = parse_pool_stem(&title) {
title = t;
if year.is_empty() {
year = y.to_string();
}
}
let tmdb_id = parse_tmdb_id(&stem).map(|i| i.to_string());
let format_3d = if include_3d {
parse_3d_format(&stem)
} else {
None
};
let title = title.replace(['/', '\\'], "-").trim().to_string();
if title.is_empty() || !is_valid_year(&year) {
return None;
}
canonical_pool_stem(&title, &year, tmdb_id.as_deref(), format_3d.as_deref()).ok()
}
/// Rename a pooled movie to `Title (YYYY).ext` from its metadata. `Ok(None)`
/// when no year is known or the movie has several versions.
pub fn rename_to_canonical(&mut self, path: &Path) -> CoreResult<Option<PathBuf>> {
if self.is_movie_group_member(path) {
return Ok(None);
}
let file = match self.file(path) {
Some(f) => f.clone(),
None => return Ok(None),
};
let Some(new_stem) = self.canonical_name_for(&file, true) else {
return Ok(None);
};
let new_path = path.with_file_name(format!("{new_stem}{}", paths::suffix(path)));
if new_path == path {
return Ok(Some(new_path));
}
if new_path.exists() {
return Err(CoreError::Exists(format!(
"Soubor „{}“ už v poolu existuje.",
paths::name(&new_path)
)));
}
self.relocate_file(path, &new_path, true)?;
let parsed = parse_pool_stem(&new_stem);
self.with_file(&new_path, |f, _, index| {
if let Some((t, _)) = parsed {
f.title = Some(t);
}
f.save_metadata(index)
})?;
Ok(Some(new_path))
}
/// Move a pooled movie into another library.
pub fn move_to_library(&mut self, path: &Path, new_library: &str) -> CoreResult<PathBuf> {
let pool = self.require_pool()?;
let new_library = new_library.trim();
if !self
.libraries_accepting_movies()
.iter()
.any(|l| l == new_library)
{
return Err(CoreError::invalid(format!(
"Neznámá knihovna „{new_library}“."
)));
}
if self.is_movie_group_member(path) {
return Err(CoreError::invalid(
"Film s více verzemi zatím nelze takhle přesunout — odeber verzi, nebo přesuň celou složku filmu ručně.",
));
}
let new_path = pool.join(new_library).join(paths::name(path));
if new_path == path {
return Ok(new_path);
}
if new_path.exists() {
return Err(CoreError::Exists(format!(
"V knihovně „{new_library}“ už „{}“ je.",
paths::name(path)
)));
}
self.relocate_file(path, &new_path, false)?;
self.save_file(&new_path)?;
Ok(new_path)
}
/// Apply every editable parameter of a movie at once (rename/move + metadata).
pub fn apply_movie_edits(&mut self, path: &Path, edits: MovieEdits) -> CoreResult<PathBuf> {
let pool = self.require_pool()?;
let title = if edits.title.trim().is_empty() {
paths::stem(path)
} else {
edits.title.trim().to_string()
};
let year = edits
.year
.as_deref()
.map(str::trim)
.unwrap_or("")
.to_string();
let grouped = self.is_movie_group_member(path);
let format_3d = match edits.format_3d {
Some(f) => Some(f),
None if grouped => None,
None => parse_3d_format(&paths::stem(path)),
};
let stem = if is_valid_year(&year) {
canonical_pool_stem(
&title,
&year,
edits.tmdb_id.as_deref(),
format_3d.as_deref(),
)?
} else {
title.clone()
};
let current_lib = self.library_of(path);
let lib = edits
.library
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.or_else(|| current_lib.clone())
.unwrap_or_else(|| self.movie_libraries()[0].clone());
let mut new_path = path.to_path_buf();
if grouped {
let folder = path.parent().map(paths::name).unwrap_or_default();
if stem != folder || Some(&lib) != current_lib.as_ref() {
return Err(CoreError::invalid(
"Film s více verzemi zatím nelze takhle přejmenovat/přesunout (rok/název/TMDb ID/knihovna) — odeber verzi, nebo uprav soubory ve složce filmu ručně. Datum/atributy/tagy/ČSFD odkaz upravit lze.",
));
}
} else {
let target = pool
.join(&lib)
.join(format!("{stem}{}", paths::suffix(path)));
if target != path {
if target.exists() {
return Err(CoreError::Exists(format!(
"Soubor „{}“ už existuje.",
paths::name(&target)
)));
}
self.relocate_file(path, &target, false)?;
new_path = target;
}
}
self.with_file(&new_path, |f, tm, index| {
f.title = Some(title);
if let Some(d) = edits.date {
f.date = Some(d).filter(|d| !d.is_empty());
}
if let Some(u) = edits.csfd_url {
f.csfd_url = Some(u).filter(|u| !u.is_empty());
}
if let Some(a) = edits.attributes {
f.attributes = a;
}
if let Some(tags) = edits.tags {
let mut seen = std::collections::HashSet::new();
let mut new_tags = Vec::new();
for full in tags {
let full = full.trim().to_string();
let Some(tag) = Tag::from_full_path(&full) else {
continue;
};
if !seen.insert(full) {
continue;
}
new_tags.push(tm.add_tag(tag.category.trim(), tag.name.trim()));
}
f.tags = new_tags;
f.auto_tag_paths.retain(|p| seen.contains(p));
}
f.save_metadata(index)
})?;
Ok(new_path)
}
/// Attach a TMDb id to a movie and rename it to the canonical convention;
/// `year` (from the TMDb match) fills a missing local year.
pub fn assign_tmdb_id(
&mut self,
path: &Path,
tmdb_id: i64,
year: Option<&str>,
) -> CoreResult<PathBuf> {
let file = self.file(path).cloned().unwrap_or_else(|| File::bare(path));
let ctx = file.name_context();
let mut title = file
.title
.clone()
.filter(|t| !t.is_empty())
.or_else(|| ctx.get("title").cloned())
.unwrap_or_else(|| paths::stem(path))
.trim()
.to_string();
if let Some((t, _)) = parse_pool_stem(&title) {
title = t;
}
let year = year
.filter(|y| !y.is_empty())
.map(String::from)
.or_else(|| ctx.get("year").cloned())
.unwrap_or_default();
self.apply_movie_edits(
path,
MovieEdits {
title,
year: Some(year).filter(|y| !y.is_empty()),
tmdb_id: Some(tmdb_id.to_string()),
..Default::default()
},
)
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::Fixture;
use super::*;
use crate::core::file_manager::{Library, LibraryKind};
fn opts_year(year: &str, id: Option<&str>) -> ImportOptions {
ImportOptions {
year: Some(year.into()),
tmdb_id: id.map(String::from),
..Default::default()
}
}
#[test]
fn test_import_movie_copies_and_indexes() {
let mut fx = Fixture::new();
let src = fx.source("raw.mkv");
let p = fx
.fm
.import_movie(&src, "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
assert_eq!(p, fx.pool().join("Filmy/Matrix.mkv"));
assert!(src.exists());
let f = fx.fm.file(&p).unwrap();
assert_eq!(f.title.as_deref(), Some("Matrix"));
assert!(f.added.is_some());
assert!(
fx.fm
.index
.as_ref()
.unwrap()
.find_movie_by_media(&p)
.is_some()
);
}
#[test]
fn test_import_movie_move_and_naming_convention() {
let mut fx = Fixture::new();
let src = fx.source("raw.mkv");
let opts = ImportOptions {
move_source: true,
format_3d: Some("hsbs".into()),
..opts_year("1999", Some("603"))
};
let p = fx.fm.import_movie(&src, "Matrix", opts).unwrap().unwrap();
assert!(!src.exists());
assert_eq!(paths::name(&p), "Matrix (1999) [tmdbid-603].3D.HSBS.mkv");
assert!(
fx.fm
.import_movie(&fx.source("x.mkv"), "Matrix", opts_year("99", None))
.is_err()
);
}
#[test]
fn test_import_conflicts_suffix_replace_skip() {
let mut fx = Fixture::new();
fx.fm
.import_movie(&fx.source("a.mkv"), "Matrix", ImportOptions::default())
.unwrap();
let p2 = fx
.fm
.import_movie(&fx.source("b.mkv"), "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
assert_eq!(paths::name(&p2), "Matrix_1.mkv");
let skip = ImportOptions {
on_conflict: OnConflict::Skip,
..Default::default()
};
assert_eq!(
fx.fm
.import_movie(&fx.source("c.mkv"), "Matrix", skip)
.unwrap(),
None
);
let mut fx2 = Fixture::new();
fx2.fm
.import_movie(&fx2.source("a.mkv"), "Matrix", ImportOptions::default())
.unwrap();
let replace = ImportOptions {
on_conflict: OnConflict::Replace,
..Default::default()
};
fx2.fm
.import_movie(&fx2.source("b.mp4"), "Matrix", replace)
.unwrap();
let names: Vec<String> = fx2.fm.filelist.iter().map(|f| f.filename.clone()).collect();
assert_eq!(names, vec!["Matrix.mp4"]);
assert!(!fx2.pool().join("Filmy/Matrix.mkv").exists());
}
#[test]
fn test_same_title_different_year_coexist() {
let mut fx = Fixture::new();
fx.fm
.import_movie(&fx.source("a.mkv"), "Matrix", opts_year("1999", None))
.unwrap();
let p = fx
.fm
.import_movie(&fx.source("b.mkv"), "Matrix", opts_year("2021", None))
.unwrap()
.unwrap();
assert_eq!(paths::name(&p), "Matrix (2021).mkv");
}
#[test]
fn test_rename_movie_and_rejections() {
let mut fx = Fixture::new();
let p = fx
.fm
.import_movie(&fx.source("a.mkv"), "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
fx.fm
.import_movie(&fx.source("b.mkv"), "Other", ImportOptions::default())
.unwrap();
fs::write(fx.pool().join("Filmy/Matrix.cs.srt"), "s").unwrap();
let new = fx.fm.rename_movie(&p, "Matrix Reloaded").unwrap();
assert_eq!(paths::name(&new), "Matrix Reloaded.mkv");
assert!(fx.pool().join("Filmy/Matrix Reloaded.cs.srt").exists());
assert_eq!(
fx.fm.file(&new).unwrap().title.as_deref(),
Some("Matrix Reloaded")
);
assert!(
fx.fm
.index
.as_ref()
.unwrap()
.find_movie_by_media(&new)
.is_some()
);
assert!(fx.fm.rename_movie(&new, "Other").is_err());
assert!(fx.fm.rename_movie(&new, " ").is_err());
assert!(fx.fm.rename_movie(&new, "a/b").is_err());
}
#[test]
fn test_rename_to_canonical_preserves_id_and_3d() {
let mut fx = Fixture::new();
let src = fx.source("raw.mkv");
let p = fx
.fm
.import_movie(&src, "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
// no year known → skipped
assert_eq!(fx.fm.rename_to_canonical(&p).unwrap(), None);
fx.fm
.assign_tag(std::slice::from_ref(&p), "Rok/1999")
.unwrap();
let new = fx.fm.rename_to_canonical(&p).unwrap().unwrap();
assert_eq!(paths::name(&new), "Matrix (1999).mkv");
let mut fx2 = Fixture::new();
let opts = ImportOptions {
format_3d: Some("MVC".into()),
..opts_year("1999", Some("603"))
};
let p2 = fx2
.fm
.import_movie(&fx2.source("x.mkv"), "Matrix", opts)
.unwrap()
.unwrap();
let same = fx2.fm.rename_to_canonical(&p2).unwrap().unwrap();
assert_eq!(same, p2);
}
#[test]
fn test_move_to_library_reindexes_and_rejects_unknown() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Kids", LibraryKind::Mixed),
])
.unwrap();
let p = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap()
.unwrap();
fs::write(fx.pool().join("Filmy/A.srt"), "s").unwrap();
let new = fx.fm.move_to_library(&p, "Kids").unwrap();
assert_eq!(new, fx.pool().join("Kids/A.mkv"));
assert!(fx.pool().join("Kids/A.srt").exists());
let rec = fx
.fm
.index
.as_ref()
.unwrap()
.find_movie_by_media(&new)
.unwrap()
.1
.clone();
assert_eq!(rec["library"], "Kids");
assert!(fx.fm.move_to_library(&new, "Nope").is_err());
}
#[test]
fn test_apply_movie_edits_all_at_once_and_3d_handling() {
let mut fx = Fixture::new();
let p = fx
.fm
.import_movie(&fx.source("a.mkv"), "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&p), "Žánr/Akční")
.unwrap();
let edits = MovieEdits {
title: "Matrix".into(),
year: Some("1999".into()),
tmdb_id: Some("603".into()),
date: Some("2026-01-01".into()),
tags: Some(vec![
"Žánr/Sci-Fi".into(),
"Žánr/Sci-Fi".into(),
"bad".into(),
]),
csfd_url: Some("https://www.csfd.cz/film/9499/".into()),
format_3d: Some("hsbs".into()),
..Default::default()
};
let new = fx.fm.apply_movie_edits(&p, edits).unwrap();
assert_eq!(paths::name(&new), "Matrix (1999) [tmdbid-603].3D.HSBS.mkv");
let f = fx.fm.file(&new).unwrap();
assert_eq!(
f.tag_paths().into_iter().collect::<Vec<_>>(),
vec!["Žánr/Sci-Fi"]
);
assert_eq!(f.date.as_deref(), Some("2026-01-01"));
assert_eq!(
f.csfd_url.as_deref(),
Some("https://www.csfd.cz/film/9499/")
);
// omitted 3D keeps it, empty clears it
let kept = fx
.fm
.apply_movie_edits(
&new,
MovieEdits {
title: "Matrix".into(),
year: Some("1999".into()),
tmdb_id: Some("603".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(kept, new);
let cleared = fx
.fm
.apply_movie_edits(
&new,
MovieEdits {
title: "Matrix".into(),
year: Some("1999".into()),
tmdb_id: Some("603".into()),
format_3d: Some(String::new()),
..Default::default()
},
)
.unwrap();
assert_eq!(paths::name(&cleared), "Matrix (1999) [tmdbid-603].mkv");
}
#[test]
fn test_apply_movie_edits_rejects_name_clash() {
let mut fx = Fixture::new();
let a = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap()
.unwrap();
fx.fm
.import_movie(&fx.source("b.mkv"), "B", ImportOptions::default())
.unwrap();
assert!(matches!(
fx.fm.apply_movie_edits(
&a,
MovieEdits {
title: "B".into(),
..Default::default()
}
),
Err(CoreError::Exists(_))
));
}
#[test]
fn test_assign_tmdb_id_renames() {
let mut fx = Fixture::new();
let p = fx
.fm
.import_movie(&fx.source("a.mkv"), "Matrix", ImportOptions::default())
.unwrap()
.unwrap();
let new = fx.fm.assign_tmdb_id(&p, 603, Some("1999")).unwrap();
assert_eq!(paths::name(&new), "Matrix (1999) [tmdbid-603].mkv");
}
}
+539
View File
@@ -0,0 +1,539 @@
//! Series operations (Jellyfin TV layout). Series carry no index metadata —
//! they are scanned live from disk, so every operation is plain file work.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use super::movies::OnConflict;
use super::{FileManager, POOL_SERIES};
use crate::core::error::{CoreError, CoreResult};
use crate::core::paths;
use crate::core::series::{
Episode, Series, episode_filename, is_season_folder, parse_episode, parse_episode_part,
scan_series, season_folder_name, series_folder_stem,
};
use crate::core::subtitles;
/// An explicit `(season, episode, part)` override set at import time.
pub type EpisodeCode = (u32, u32, Option<u32>);
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ImportEpisodesResult {
pub imported: usize,
pub skipped_no_code: Vec<String>,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RenameEpisodesResult {
pub renamed: Vec<(String, String)>,
pub skipped: Vec<String>,
pub conflicts: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RenameAllSeriesResult {
pub series: usize,
/// `(series, old, new)`.
pub renamed: Vec<(String, String, String)>,
pub skipped_no_code: Vec<(String, String)>,
pub conflicts: Vec<(String, String, String)>,
pub empty_dirs_removed: Vec<String>,
pub cancelled: bool,
}
impl FileManager {
/// Import episode files into `<library>/Series (rok) [tmdbid]/Season NN/`.
/// Each file's code comes from `codes` or is parsed from its name; files
/// with neither are skipped.
#[allow(clippy::too_many_arguments)]
pub fn import_episodes(
&mut self,
sources: &[PathBuf],
title: &str,
year: Option<&str>,
tmdb_id: Option<i64>,
library: Option<&str>,
move_source: bool,
on_conflict: OnConflict,
codes: &HashMap<PathBuf, EpisodeCode>,
) -> CoreResult<ImportEpisodesResult> {
let pool = self.require_pool()?;
let lib = library
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.or_else(|| self.series_libraries().into_iter().next())
.unwrap_or_else(|| POOL_SERIES.into());
let stem = series_folder_stem(title, year, tmdb_id)?;
let mut result = ImportEpisodesResult::default();
for src in sources {
let (season, episode, part) = match codes.get(src) {
Some(code) => *code,
None => match parse_episode(&paths::name(src)) {
Some((s, e)) => (s, e, parse_episode_part(&paths::name(src))),
None => {
result.skipped_no_code.push(paths::name(src));
continue;
}
},
};
let dest_dir = pool.join(&lib).join(&stem).join(season_folder_name(season));
fs::create_dir_all(&dest_dir)?;
let ext = paths::suffix(src);
let mut target = dest_dir.join(episode_filename(season, episode, title, &ext, part));
if target.exists() {
match on_conflict {
OnConflict::Skip => continue,
OnConflict::Replace => fs::remove_file(&target)?,
_ => {
let base = episode_filename(season, episode, title, "", part);
let mut counter = 1;
while target.exists() {
target = dest_dir.join(format!("{base}_{counter}{ext}"));
counter += 1;
}
}
}
}
match paths::transfer(src, &target, move_source) {
Ok(()) => result.imported += 1,
Err(e) => result.errors.push(format!("{}: {e}", paths::name(src))),
}
}
Ok(result)
}
/// Rename a series' episodes to `SxxEyy - {title}.ext` in their season folder.
pub fn rename_episodes_to_canonical(&self, series: &mut Series) -> RenameEpisodesResult {
let mut result = RenameEpisodesResult::default();
for ep in &mut series.episodes {
let (Some(season), Some(episode)) = (ep.season, ep.episode) else {
result.skipped.push(paths::name(&ep.path));
continue;
};
let dest_dir = series.folder.join(season_folder_name(season));
let new_path = dest_dir.join(episode_filename(
season,
episode,
&series.title,
&paths::suffix(&ep.path),
ep.part,
));
if new_path == ep.path {
continue;
}
if new_path.exists() {
result.conflicts.push((
paths::name(&ep.path),
format!("„{}“ už existuje", paths::name(&new_path)),
));
continue;
}
let moved = fs::create_dir_all(&dest_dir)
.and_then(|_| fs::rename(&ep.path, &new_path))
.map_err(CoreError::from)
.and_then(|_| subtitles::rename_subtitles_with(&ep.path, &new_path).map(|_| ()));
match moved {
Ok(()) => {
result
.renamed
.push((paths::name(&ep.path), paths::name(&new_path)));
ep.path = new_path;
}
Err(e) => result
.conflicts
.push((paths::name(&ep.path), e.to_string())),
}
}
result
}
/// Canonicalise every series' episode filenames across the pool and drop
/// season folders left empty.
pub fn rename_all_series_to_canonical(
&self,
on_progress: &mut dyn FnMut(usize, usize, &str),
should_cancel: &dyn Fn() -> bool,
) -> CoreResult<RenameAllSeriesResult> {
let pool = self.require_pool()?;
let mut all = self.load_series();
let mut out = RenameAllSeriesResult {
series: all.len(),
..Default::default()
};
let total = all.len();
for (i, ser) in all.iter_mut().enumerate() {
if should_cancel() {
out.cancelled = true;
break;
}
on_progress(i + 1, total, &ser.title);
let res = self.rename_episodes_to_canonical(ser);
out.renamed.extend(
res.renamed
.into_iter()
.map(|(o, n)| (ser.title.clone(), o, n)),
);
out.skipped_no_code
.extend(res.skipped.into_iter().map(|n| (ser.title.clone(), n)));
out.conflicts.extend(
res.conflicts
.into_iter()
.map(|(n, m)| (ser.title.clone(), n, m)),
);
for sub in paths::subdirs(&ser.folder) {
if is_season_folder(&paths::name(&sub))
&& paths::is_empty_dir(&sub)
&& fs::remove_dir(&sub).is_ok()
{
out.empty_dirs_removed
.push(paths::posix_relative(&sub, &pool));
}
}
}
Ok(out)
}
/// Rename/move a series folder to `Title (rok) [tmdbid]` and re-title its
/// episodes to match. Returns the new folder and the episode renames.
pub fn apply_series_edits(
&self,
series: &Series,
title: &str,
year: Option<&str>,
tmdb_id: Option<i64>,
library: Option<&str>,
) -> CoreResult<(PathBuf, RenameEpisodesResult)> {
let pool = self.require_pool()?;
let title = if title.trim().is_empty() {
series.title.clone()
} else {
title.trim().to_string()
};
let new_stem = series_folder_stem(&title, year.filter(|y| !y.is_empty()), tmdb_id)?;
let lib = library
.map(str::trim)
.filter(|l| !l.is_empty())
.unwrap_or(&series.library)
.to_string();
let new_folder = pool.join(&lib).join(&new_stem);
if new_folder != series.folder {
if new_folder.exists() {
return Err(CoreError::Exists(format!(
"Složka „{new_stem}“ už existuje."
)));
}
if let Some(parent) = new_folder.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(&series.folder, &new_folder)?;
}
let target = scan_series(new_folder.parent().unwrap_or(&pool), &lib, false)
.into_iter()
.find(|s| s.folder == new_folder);
let result = match target {
Some(mut s) => self.rename_episodes_to_canonical(&mut s),
None => RenameEpisodesResult::default(),
};
Ok((new_folder, result))
}
/// Delete a whole series folder (refuses a folder outside the pool).
pub fn remove_series(&self, series: &Series) -> CoreResult<()> {
let pool = self.require_pool()?;
let inside = match (series.folder.canonicalize(), pool.canonicalize()) {
(Ok(f), Ok(p)) => f.starts_with(&p) && f != p,
_ => false,
};
if !inside {
return Err(CoreError::invalid(format!(
"Složka „{}“ není v poolu.",
series.folder.display()
)));
}
if series.folder.is_dir() {
fs::remove_dir_all(&series.folder)?;
}
Ok(())
}
/// Delete episode files (+ subtitles); a season folder left empty goes too.
pub fn remove_episodes(&self, episodes: &[Episode]) {
let mut touched: HashSet<PathBuf> = HashSet::new();
for ep in episodes {
for sub in subtitles::find_subtitles(&ep.path) {
let _ = fs::remove_file(&sub.path);
}
let _ = fs::remove_file(&ep.path);
if let Some(parent) = ep.path.parent() {
touched.insert(parent.to_path_buf());
}
}
for folder in touched {
if is_season_folder(&paths::name(&folder)) && paths::is_empty_dir(&folder) {
let _ = fs::remove_dir(&folder);
}
}
}
/// Delete every episode of one season (`-1` = unrecognised episodes).
pub fn remove_season(&self, series: &Series, season: i64) -> CoreResult<()> {
let eps = series.seasons().remove(&season).unwrap_or_default();
if eps.is_empty() {
return Err(CoreError::invalid("Tahle sezóna už žádné epizody nemá."));
}
self.remove_episodes(&eps);
Ok(())
}
/// The series currently at `folder`, freshly scanned.
pub fn series_at(&self, folder: &Path) -> Option<Series> {
self.load_series().into_iter().find(|s| s.folder == folder)
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::Fixture;
use super::super::{Library, LibraryKind};
use super::*;
fn none() -> HashMap<PathBuf, EpisodeCode> {
HashMap::new()
}
#[test]
fn test_import_episodes_places_and_skips() {
let mut fx = Fixture::new();
let srcs = vec![
fx.source("bb.S01E01.mkv"),
fx.source("bb.s01e02.mkv"),
fx.source("random.mkv"),
];
let r = fx
.fm
.import_episodes(
&srcs,
"Breaking Bad",
Some("2008"),
Some(1396),
None,
false,
OnConflict::Suffix,
&none(),
)
.unwrap();
assert_eq!(r.imported, 2);
assert_eq!(r.skipped_no_code, vec!["random.mkv"]);
let base = fx
.pool()
.join("Seriály/Breaking Bad (2008) [tmdbid-1396]/Season 01");
assert!(base.join("S01E01 - Breaking Bad.mkv").exists());
assert!(base.join("S01E02 - Breaking Bad.mkv").exists());
}
#[test]
fn test_import_episodes_overrides_and_parts() {
let mut fx = Fixture::new();
let a = fx.source("a.mkv");
let b = fx.source("b.mkv");
let c = fx.source("Show.S01E01.pt2.mkv");
let codes: HashMap<PathBuf, EpisodeCode> =
[(a.clone(), (1, 1, Some(1))), (b.clone(), (1, 1, Some(2)))]
.into_iter()
.collect();
let r = fx
.fm
.import_episodes(
&[a, b],
"Breaking Bad",
Some("2008"),
Some(1396),
None,
false,
OnConflict::Suffix,
&codes,
)
.unwrap();
assert_eq!(r.imported, 2);
let base = fx
.pool()
.join("Seriály/Breaking Bad (2008) [tmdbid-1396]/Season 01");
assert!(base.join("S01E01 - Breaking Bad.pt1.mkv").exists());
assert!(base.join("S01E01 - Breaking Bad.pt2.mkv").exists());
fx.fm
.import_episodes(
&[c],
"Show",
None,
None,
None,
false,
OnConflict::Suffix,
&none(),
)
.unwrap();
assert!(
fx.pool()
.join("Seriály/Show/Season 01/S01E01 - Show.pt2.mkv")
.exists()
);
}
#[test]
fn test_import_episodes_into_mixed_library() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![Library::new("Mix", LibraryKind::Mixed)])
.unwrap();
let src = fx.source("bb.S01E01.mkv");
let r = fx
.fm
.import_episodes(
&[src],
"Breaking Bad",
Some("2008"),
Some(1396),
Some("Mix"),
false,
OnConflict::Suffix,
&none(),
)
.unwrap();
assert_eq!(r.imported, 1);
let found: Vec<String> = fx.fm.load_series().into_iter().map(|s| s.title).collect();
assert_eq!(found, vec!["Breaking Bad"]);
}
#[test]
fn test_rename_episodes_carries_subtitles_and_parts() {
let fx = Fixture::new();
let folder = fx.pool().join("Seriály/Breaking Bad (2008) [tmdbid-1396]");
fs::create_dir_all(&folder).unwrap();
fs::write(folder.join("bb.1x03.HDTV.mkv"), "x").unwrap();
fs::write(folder.join("bb.1x03.HDTV.cs.srt"), "s").unwrap();
fs::write(folder.join("bb.1x04.HDTV-part2.mkv"), "x").unwrap();
let mut ser = fx.fm.load_series().remove(0);
let r = fx.fm.rename_episodes_to_canonical(&mut ser);
assert_eq!(
r.renamed,
vec![
(
"bb.1x03.HDTV.mkv".into(),
"S01E03 - Breaking Bad.mkv".into()
),
(
"bb.1x04.HDTV-part2.mkv".into(),
"S01E04 - Breaking Bad.pt2.mkv".into()
),
]
);
assert!(
folder
.join("Season 01/S01E03 - Breaking Bad.cs.srt")
.exists()
);
assert!(!folder.join("bb.1x03.HDTV.cs.srt").exists());
}
#[test]
fn test_rename_all_series_bulk_and_empty_dirs() {
let fx = Fixture::new();
let seri = fx.pool().join("Seriály");
let fargo = seri.join("Fargo (2014) [tmdbid-60622]/Season 1");
fs::create_dir_all(&fargo).unwrap();
fs::write(fargo.join("fargo.1x01.1080p.WEB.mkv"), "x").unwrap();
let most = seri.join("Most! (2019)");
fs::create_dir_all(&most).unwrap();
fs::write(most.join("necoZvlastniho.mkv"), "x").unwrap();
let r = fx
.fm
.rename_all_series_to_canonical(&mut |_, _, _| {}, &|| false)
.unwrap();
assert_eq!(r.series, 2);
assert!(r.renamed.contains(&(
"Fargo".into(),
"fargo.1x01.1080p.WEB.mkv".into(),
"S01E01 - Fargo.mkv".into()
)));
assert!(
seri.join("Fargo (2014) [tmdbid-60622]/Season 01/S01E01 - Fargo.mkv")
.exists()
);
assert!(!fargo.exists());
assert!(
r.skipped_no_code
.contains(&("Most!".into(), "necoZvlastniho.mkv".into()))
);
}
#[test]
fn test_apply_series_edits_renames_folder_and_episodes() {
let fx = Fixture::new();
let folder = fx.pool().join("Seriály/bb");
fs::create_dir_all(&folder).unwrap();
fs::write(folder.join("bb.S01E01.mkv"), "x").unwrap();
let ser = fx.fm.load_series().remove(0);
let (new_folder, _) = fx
.fm
.apply_series_edits(&ser, "Breaking Bad", Some("2008"), Some(1396), None)
.unwrap();
assert_eq!(
new_folder,
fx.pool().join("Seriály/Breaking Bad (2008) [tmdbid-1396]")
);
assert!(
new_folder
.join("Season 01/S01E01 - Breaking Bad.mkv")
.exists()
);
assert!(!folder.exists());
}
#[test]
fn test_remove_series_season_and_episodes() {
let fx = Fixture::new();
let folder = fx.pool().join("Seriály/Show (2010)");
for rel in [
"Season 01/S01E01 - Show.mkv",
"Season 01/S01E01 - Show.cs.srt",
"Season 01/S01E02 - Show.mkv",
"Season 02/S02E01 - Show.mkv",
] {
fs::create_dir_all(folder.join(rel).parent().unwrap()).unwrap();
fs::write(folder.join(rel), "x").unwrap();
}
let ser = fx.fm.series_at(&folder).unwrap();
let e1 = ser
.episodes
.iter()
.find(|e| e.episode == Some(1) && e.season == Some(1))
.unwrap()
.clone();
fx.fm.remove_episodes(std::slice::from_ref(&e1));
assert!(!e1.path.exists());
assert!(!folder.join("Season 01/S01E01 - Show.cs.srt").exists());
assert!(folder.join("Season 01").is_dir());
let ser = fx.fm.series_at(&folder).unwrap();
fx.fm.remove_season(&ser, 1).unwrap();
assert!(!folder.join("Season 01").exists());
let ser = fx.fm.series_at(&folder).unwrap();
assert!(fx.fm.remove_season(&ser, 7).is_err());
fx.fm.remove_series(&ser).unwrap();
assert!(!folder.exists());
assert!(fx.fm.load_series().is_empty());
let outside = Series {
folder: fx.dir.path().join("src"),
title: "x".into(),
year: None,
tmdb_id: None,
library: "Seriály".into(),
episodes: vec![],
};
fs::create_dir_all(&outside.folder).unwrap();
assert!(fx.fm.remove_series(&outside).is_err());
assert!(outside.folder.is_dir());
}
}
+949
View File
@@ -0,0 +1,949 @@
//! Pool-wide tools: consistency, statistics, duplicates, integrity, TMDb/ČSFD
//! lookups, subtitles and the miniDLNA scope.
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use super::{FileManager, LibraryKind, is_media_file};
use crate::core::csfd::{self, CsfdFilm};
use crate::core::error::CoreResult;
use crate::core::file::File;
use crate::core::integrity::{IntegrityResult, check_video_integrity, ffmpeg_available};
use crate::core::jellyfin::JellyfinMovie;
use crate::core::migrate::{MatchProposal, MatchStatus, classify};
use crate::core::movie::identity_key_for;
use crate::core::naming::{parse_pool_stem, parse_tmdb_id};
use crate::core::nfo::read_nfo;
use crate::core::paths;
use crate::core::pool_index::PoolIndex;
use crate::core::series::Series;
use crate::core::subtitles::{self, SubtitleFile};
use crate::core::tmdb::{self, TmdbResult};
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RenameAllReport {
pub renamed: Vec<(String, String)>,
pub skipped_no_year: Vec<String>,
pub conflicts: Vec<(String, String)>,
pub unchanged: usize,
pub cancelled: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConsistencyReport {
/// Indexed media files that no longer exist on disk.
pub missing: Vec<String>,
/// Pool files (pool-relative) with no index entry.
pub untracked: Vec<String>,
pub index_count: usize,
pub disk_count: usize,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct JellyfinReport {
pub matched: usize,
pub pool_only: Vec<String>,
pub jellyfin_only: Vec<String>,
pub pool_total: usize,
pub jellyfin_total: usize,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct IntegrityReport {
pub problems: Vec<IntegrityResult>,
pub checked: usize,
pub cancelled: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Stats {
pub count: usize,
pub total_size: u64,
pub with_nfo: usize,
pub without_nfo: usize,
pub untagged: usize,
pub avg_rating: Option<f64>,
/// Category → `(tag, count)` sorted by frequency (descending).
pub categories: BTreeMap<String, Vec<(String, usize)>>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DuplicateGroup {
/// The primary version of each movie, path-sorted.
pub movies: Vec<PathBuf>,
pub reasons: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RefreshReport {
pub updated: usize,
pub skipped: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ResolveResult {
pub csfd_url: Option<String>,
pub film: Option<CsfdFilm>,
pub tmdb: Option<TmdbResult>,
}
/// Movies + mirrored libraries + mixed-library series for one miniDLNA run.
#[derive(Debug, Clone, Default)]
pub struct MinidlnaScope {
pub movies: Vec<File>,
pub mirror_libs: Vec<String>,
pub mixed_series: Vec<Series>,
}
impl FileManager {
/// Rename each movie to `Title (YYYY).ext`; reports conflicts without aborting.
pub fn rename_all_to_canonical(
&mut self,
files: &[PathBuf],
on_progress: &mut dyn FnMut(usize, usize),
should_cancel: &dyn Fn() -> bool,
) -> RenameAllReport {
let mut report = RenameAllReport::default();
for (i, path) in files.iter().enumerate() {
if should_cancel() {
report.cancelled = true;
break;
}
let old_name = paths::name(path);
match self.rename_to_canonical(path) {
Err(e) => report.conflicts.push((old_name, e.to_string())),
Ok(None) => report.skipped_no_year.push(old_name),
Ok(Some(new)) if new != *path => report.renamed.push((old_name, paths::name(&new))),
Ok(Some(_)) => report.unchanged += 1,
}
on_progress(i + 1, files.len());
}
report
}
/// Compare the pool index against the actual files in the movie libraries.
pub fn check_data_consistency(&self) -> CoreResult<ConsistencyReport> {
let pool = self.require_pool()?;
let opened;
let index = match &self.index {
Some(i) => i,
None => {
opened = PoolIndex::open(&pool);
&opened
}
};
let index_keys: BTreeSet<String> = index
.records
.values()
.flat_map(|r| {
r.get("media")
.and_then(|m| m.as_array())
.cloned()
.unwrap_or_default()
})
.filter_map(|m| {
m.get("file")
.and_then(|f| f.as_str())
.filter(|s| !s.is_empty())
.map(String::from)
})
.collect();
let mut disk_keys = BTreeSet::new();
for lib in self.libraries() {
let lib_dir = pool.join(&lib.name);
if !lib_dir.is_dir() {
continue;
}
let files = match lib.kind {
LibraryKind::Movie => paths::walk_files(&lib_dir),
LibraryKind::Mixed => paths::entries(&lib_dir)
.into_iter()
.filter(|p| p.is_file())
.collect(),
_ => continue,
};
for f in files.into_iter().filter(|f| is_media_file(f)) {
disk_keys.insert(paths::posix_relative(&f, &pool));
}
}
Ok(ConsistencyReport {
missing: index_keys
.iter()
.filter(|k| !paths::join_posix(&pool, k).is_file())
.cloned()
.collect(),
untracked: disk_keys.difference(&index_keys).cloned().collect(),
index_count: index_keys.len(),
disk_count: disk_keys.len(),
})
}
/// Compare the pool against what Jellyfin sees (by TMDb id, else title+year).
pub fn verify_against_jellyfin(&self, jf: &[JellyfinMovie]) -> JellyfinReport {
let by_tmdb: HashMap<i64, &JellyfinMovie> =
jf.iter().filter_map(|m| Some((m.tmdb_id?, m))).collect();
let by_name: HashMap<(String, Option<i64>), &JellyfinMovie> = jf
.iter()
.filter(|m| !m.title.is_empty())
.map(|m| ((m.title.trim().to_lowercase(), m.year), m))
.collect();
let mut matched_ids = HashSet::new();
let mut pool_only = Vec::new();
for f in &self.filelist {
let tmdb_id = parse_tmdb_id(&paths::stem(&f.file_path));
let ctx = f.name_context();
let title = f
.title
.clone()
.filter(|t| !t.is_empty())
.or_else(|| ctx.get("title").cloned())
.unwrap_or_default()
.trim()
.to_lowercase();
let year = ctx.get("year").and_then(|y| y.parse::<i64>().ok());
let hit = tmdb_id
.and_then(|id| by_tmdb.get(&id))
.or_else(|| by_name.get(&(title, year)));
match hit {
Some(m) => {
matched_ids.insert(m.jellyfin_id.clone());
}
None => pool_only.push(f.filename.clone()),
}
}
pool_only.sort();
let mut jellyfin_only: Vec<String> = jf
.iter()
.filter(|m| !matched_ids.contains(&m.jellyfin_id))
.map(|m| m.title.clone())
.collect();
jellyfin_only.sort();
JellyfinReport {
matched: self.filelist.len() - pool_only.len(),
pool_only,
jellyfin_only,
pool_total: self.filelist.len(),
jellyfin_total: jf.len(),
}
}
/// Check `files` for corrupted video. Errors when the FFmpeg tool is missing.
pub fn scan_video_integrity(
files: &[PathBuf],
deep: bool,
on_progress: &mut dyn FnMut(usize, usize, &Path),
should_cancel: &dyn Fn() -> bool,
) -> Result<IntegrityReport, String> {
if !ffmpeg_available(deep) {
let tool = if deep { "ffmpeg" } else { "ffprobe" };
return Err(format!("{tool} není k dispozici (nainstaluj FFmpeg)."));
}
let mut report = IntegrityReport::default();
for (i, path) in files.iter().enumerate() {
if should_cancel() {
report.cancelled = true;
break;
}
let result = check_video_integrity(path, deep, None);
report.checked += 1;
if !result.ok {
report.problems.push(result);
}
on_progress(i + 1, files.len(), path);
}
Ok(report)
}
/// Aggregate stats over the loaded movies.
pub fn statistics(&self) -> Stats {
let mut stats = Stats {
count: self.filelist.len(),
..Default::default()
};
let mut ratings = Vec::new();
let mut cats: BTreeMap<String, HashMap<String, usize>> = BTreeMap::new();
for f in &self.filelist {
stats.total_size += fs::metadata(&f.file_path).map(|m| m.len()).unwrap_or(0);
if f.meta.is_some() {
stats.with_nfo += 1;
}
if f.tags.is_empty() {
stats.untagged += 1;
}
for t in &f.tags {
*cats
.entry(t.category.clone())
.or_default()
.entry(t.name.clone())
.or_default() += 1;
}
if let Some(r) = f.rating() {
ratings.push(r);
}
}
stats.without_nfo = stats.count - stats.with_nfo;
if !ratings.is_empty() {
let avg = ratings.iter().sum::<f64>() / ratings.len() as f64;
stats.avg_rating = Some((avg * 10.0).round() / 10.0);
}
stats.categories = cats
.into_iter()
.map(|(c, counts)| {
let mut v: Vec<(String, usize)> = counts.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
(c, v)
})
.collect();
stats
}
/// `filelist` grouped per movie: one list per flat file / version folder.
fn movie_units(&self) -> Vec<Vec<PathBuf>> {
let mut units: indexmap::IndexMap<PathBuf, Vec<PathBuf>> = indexmap::IndexMap::new();
for f in &self.filelist {
let anchor = if self.is_movie_group_member(&f.file_path) {
f.file_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
} else {
f.file_path.clone()
};
units.entry(anchor).or_default().push(f.file_path.clone());
}
units.into_values().collect()
}
/// Movies that show up in the pool more than once — same TMDb id, same
/// title + year, or a byte-identical file size. Overlapping hits merge.
pub fn find_duplicates(&self) -> Vec<DuplicateGroup> {
let units = self.movie_units();
let reps: Vec<PathBuf> = units.iter().map(|u| self.primary_version(u)).collect();
let mut parent: Vec<usize> = (0..units.len()).collect();
fn find(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
let mut reasons: Vec<(BTreeSet<usize>, String)> = Vec::new();
let mut flag = |parent: &mut Vec<usize>, idx: &[usize], reason: String| {
if idx.len() < 2 {
return;
}
for &i in &idx[1..] {
let (a, b) = (find(parent, i), find(parent, idx[0]));
parent[a] = b;
}
reasons.push((idx.iter().copied().collect(), reason));
};
let mut by_tmdb: indexmap::IndexMap<i64, Vec<usize>> = indexmap::IndexMap::new();
let mut by_name: indexmap::IndexMap<String, Vec<usize>> = indexmap::IndexMap::new();
let mut by_size: indexmap::IndexMap<u64, Vec<usize>> = indexmap::IndexMap::new();
let mut tmdb_of = Vec::new();
for (i, (unit, rep)) in units.iter().zip(&reps).enumerate() {
let file = self.file(rep).cloned().unwrap_or_else(|| File::bare(rep));
let id = file.identity_from_path();
tmdb_of.push(id.tmdb_id);
if let Some(t) = id.tmdb_id {
by_tmdb.entry(t).or_default().push(i);
}
if let Some(y) = id.year.as_deref().filter(|_| !id.title.is_empty()) {
by_name
.entry(identity_key_for(None, &id.title, Some(y)))
.or_default()
.push(i);
}
for p in unit {
let Ok(size) = fs::metadata(p).map(|m| m.len()) else {
continue;
};
let list = by_size.entry(size).or_default();
if size > 0 && !list.contains(&i) {
list.push(i);
}
}
}
for (t, idx) in &by_tmdb {
flag(&mut parent, idx, format!("stejné TMDb ID {t}"));
}
for idx in by_name.values() {
let ids: HashSet<Option<i64>> = idx.iter().map(|&i| tmdb_of[i]).collect();
if ids.len() == 1 && !ids.contains(&None) {
continue;
}
flag(&mut parent, idx, "stejný název a rok".into());
}
for idx in by_size.values() {
flag(&mut parent, idx, "shodná velikost souboru".into());
}
let mut groups: indexmap::IndexMap<usize, (BTreeSet<usize>, BTreeSet<String>)> =
indexmap::IndexMap::new();
for (members, why) in reasons {
let root = find(&mut parent, *members.iter().next().unwrap_or(&0));
let g = groups.entry(root).or_default();
g.0.extend(members);
g.1.insert(why);
}
let mut result: Vec<DuplicateGroup> = groups
.into_values()
.map(|(idx, why)| {
let mut movies: Vec<PathBuf> = idx.into_iter().map(|i| reps[i].clone()).collect();
movies.sort_by_key(|p| p.to_string_lossy().to_lowercase());
DuplicateGroup {
movies,
reasons: why.into_iter().collect(),
}
})
.collect();
result.sort_by_key(|g| {
let f = self.file(&g.movies[0]);
f.and_then(|f| f.title.clone())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| paths::name(&g.movies[0]))
.to_lowercase()
});
result
}
pub fn list_subtitles(&self, path: &Path) -> Vec<SubtitleFile> {
subtitles::find_subtitles(path)
}
pub fn attach_subtitle(
&self,
path: &Path,
source: &Path,
language: Option<&str>,
flag: Option<&str>,
move_source: bool,
) -> CoreResult<PathBuf> {
subtitles::attach_subtitle(path, source, language, flag, move_source)
}
pub fn remove_subtitle(&self, subtitle: &Path) -> CoreResult<()> {
fs::remove_file(subtitle)?;
Ok(())
}
/// (Re)derive tags from TMDb by the tmdbid in each filename.
pub fn refresh_tags_from_tmdb(
&mut self,
files: &[PathBuf],
on_progress: &mut dyn FnMut(usize, usize),
should_cancel: &dyn Fn() -> bool,
) -> Result<RefreshReport, String> {
if !tmdb::api_available() {
return Err("TMDB_API_KEY není nastaven (.env).".into());
}
let schema = self.tag_schema();
let mut report = RefreshReport::default();
for (i, path) in files.iter().enumerate() {
if should_cancel() {
break;
}
match parse_tmdb_id(&paths::stem(path)) {
None => report.skipped += 1,
Some(id) => match tmdb::movie_details(id) {
Ok(details) => {
let r = self.with_file(path, |f, tm, index| {
f.apply_tags_from(&details, &schema, tm, Some(index))
});
match r {
Ok(_) => report.updated += 1,
Err(e) => report.errors.push(format!("{}: {e}", paths::name(path))),
}
}
Err(e) => report.errors.push(format!("{}: {e}", paths::name(path))),
},
}
on_progress(i + 1, files.len());
}
Ok(report)
}
/// (Re)derive auto tags from each movie's NFO. `skipped` = without NFO.
pub fn refresh_tags_from_nfo(&mut self, files: &[PathBuf]) -> CoreResult<RefreshReport> {
let schema = self.tag_schema();
let mut report = RefreshReport::default();
for path in files {
let Some(nfo) = read_nfo(path) else {
report.skipped += 1;
continue;
};
self.with_file(path, |f, tm, index| {
f.apply_tags_from(&nfo, &schema, tm, Some(index))
})?;
report.updated += 1;
}
Ok(report)
}
/// ČSFD → TMDb bridge for one movie (best-effort: failures are `None`s).
pub fn resolve_via_csfd(
csfd_url: Option<&str>,
query: Option<&str>,
session: Option<&reqwest::blocking::Client>,
) -> ResolveResult {
let mut result = ResolveResult {
csfd_url: csfd_url.map(String::from),
..Default::default()
};
let url = match csfd_url.filter(|u| !u.is_empty()) {
Some(u) => Some(u.to_string()),
None => query
.filter(|q| !q.is_empty())
.and_then(|q| csfd::find_csfd_url(q, session).ok().flatten()),
};
let Some(url) = url else { return result };
result.csfd_url = Some(url.clone());
let Ok(film) = csfd::fetch_film(&url, session) else {
return result;
};
let (title, year) = film.tmdb_query();
if !title.is_empty() && tmdb::api_available() {
let mut hits = tmdb::search_movie(&title, year.as_deref(), 1).unwrap_or_default();
if hits.is_empty() && !film.czech_title.is_empty() && film.czech_title != title {
hits =
tmdb::search_movie(&film.czech_title, year.as_deref(), 1).unwrap_or_default();
}
result.tmdb = hits.into_iter().next();
}
result.film = Some(film);
result
}
/// Propose a TMDb id for every movie without one (ČSFD-bridged when the
/// movie carries a ČSFD link).
pub fn match_pool_to_tmdb(
&self,
files: &[PathBuf],
on_progress: &mut dyn FnMut(usize, usize),
should_cancel: &dyn Fn() -> bool,
) -> Vec<MatchProposal> {
let session = crate::core::http::new_session();
let mut proposals = Vec::new();
for (i, path) in files.iter().enumerate() {
if should_cancel() {
break;
}
if parse_tmdb_id(&paths::stem(path)).is_some() {
on_progress(i + 1, files.len());
continue;
}
let file = self.file(path).cloned().unwrap_or_else(|| File::bare(path));
let ctx = file.name_context();
let mut title = file
.title
.clone()
.filter(|t| !t.is_empty())
.or_else(|| ctx.get("title").cloned())
.unwrap_or_else(|| paths::stem(path))
.trim()
.to_string();
if let Some((t, _)) = parse_pool_stem(&title) {
title = t;
}
let mut year = ctx.get("year").cloned().filter(|y| !y.trim().is_empty());
let mut note = String::new();
if let Some(url) = &file.csfd_url
&& let Ok(film) = csfd::fetch_film(url, Some(&session))
{
let ft = if film.original_title.is_empty() {
film.czech_title.clone()
} else {
film.original_title.clone()
};
if !ft.is_empty() {
title = ft;
year = film.year.clone().or(year);
note = format!("přes ČSFD: {title}")
+ &film.year.map(|y| format!(" ({y})")).unwrap_or_default();
}
}
let key = file
.movie_key
.clone()
.unwrap_or_else(|| file.identity_from_path().key);
let proposal = match tmdb::search_movie(&title, year.as_deref(), 5) {
Ok(candidates) => {
let (status, chosen) = classify(&title, year.as_deref(), &candidates);
MatchProposal {
movie_key: key,
current_name: file.filename.clone(),
title,
year,
status,
chosen,
candidates,
error: String::new(),
note,
}
}
Err(e) => MatchProposal {
movie_key: key,
current_name: file.filename.clone(),
title,
year,
status: MatchStatus::Error,
chosen: None,
candidates: Vec::new(),
error: e.to_string(),
note,
},
};
proposals.push(proposal);
on_progress(i + 1, files.len());
}
proposals
}
/// The pooled path whose filename is `name` (used to map proposals back).
pub fn path_by_filename(&self, name: &str) -> Option<PathBuf> {
self.filelist
.iter()
.find(|f| f.filename == name)
.map(|f| f.file_path.clone())
}
/// What one miniDLNA run covers, narrowed to `minidlna_libraries` when set.
pub fn minidlna_scope(&self) -> MinidlnaScope {
let selected = self.minidlna_libraries();
let mut mixed: HashSet<String> = self
.libraries_of_kind(&[LibraryKind::Mixed])
.into_iter()
.collect();
if let Some(sel) = &selected {
mixed.retain(|m| sel.contains(m));
}
let mixed_series = if mixed.is_empty() {
Vec::new()
} else {
self.load_series()
.into_iter()
.filter(|s| mixed.contains(&s.library))
.collect()
};
let mirrors = self.mirror_as_is_libraries();
match selected {
None => MinidlnaScope {
movies: self.filelist.clone(),
mirror_libs: mirrors,
mixed_series,
},
Some(sel) => MinidlnaScope {
movies: self
.filelist
.iter()
.filter(|f| {
self.library_of(&f.file_path)
.is_some_and(|l| sel.contains(&l))
})
.cloned()
.collect(),
mirror_libs: mirrors.into_iter().filter(|m| sel.contains(m)).collect(),
mixed_series,
},
}
}
/// Mirrored output to remove for libraries that fell out of scope:
/// `(stale library folders, valid children of the mixed-series bucket)`.
pub fn minidlna_prune_targets(
&self,
mirror_libs: &[String],
mixed_series: &[Series],
bucket: &str,
) -> (Vec<String>, HashSet<String>) {
let in_scope: HashSet<&String> = mirror_libs.iter().collect();
let stale = self
.mirror_as_is_libraries()
.into_iter()
.filter(|n| !in_scope.contains(n) && n != bucket)
.collect();
let mut valid: HashSet<String> = mixed_series
.iter()
.map(|s| paths::name(&s.folder))
.collect();
if in_scope.contains(&bucket.to_string())
&& let Some(pool) = self.pool_dir()
{
valid.extend(
paths::subdirs(&pool.join(bucket))
.iter()
.map(|p| paths::name(p)),
);
}
(stale, valid)
}
}
#[cfg(test)]
mod tests {
use super::super::movies::ImportOptions;
use super::super::test_support::Fixture;
use super::super::{Library, LibraryKind};
use super::*;
#[test]
fn test_statistics_and_consistency() {
let mut fx = Fixture::new();
let a = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap()
.unwrap();
fx.fm
.import_movie(&fx.source("bb.mkv"), "B", ImportOptions::default())
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&a), "Žánr/Drama")
.unwrap();
let s = fx.fm.statistics();
assert_eq!((s.count, s.untagged, s.with_nfo), (2, 1, 0));
assert_eq!(s.total_size, 5 + 6);
assert_eq!(s.categories["Žánr"], vec![("Drama".to_string(), 1)]);
let c = fx.fm.check_data_consistency().unwrap();
assert!(c.missing.is_empty() && c.untracked.is_empty());
fs::remove_file(&a).unwrap();
fs::write(fx.pool().join("Filmy/stray.mkv"), "x").unwrap();
let c = fx.fm.check_data_consistency().unwrap();
assert_eq!(c.missing, vec!["Filmy/A.mkv"]);
assert_eq!(c.untracked, vec!["Filmy/stray.mkv"]);
}
#[test]
fn test_find_duplicates_cases() {
let mut fx = Fixture::new();
let y = |y: &str, id: Option<&str>| ImportOptions {
year: Some(y.into()),
tmdb_id: id.map(String::from),
..Default::default()
};
fx.fm
.import_movie(&fx.source("a.mkv"), "Matrix", y("1999", Some("603")))
.unwrap();
fx.fm
.import_movie(
&fx.source("b.mkv"),
"Matrix",
ImportOptions {
format_3d: Some("HSBS".into()),
..y("1999", Some("603"))
},
)
.unwrap();
fx.fm
.import_movie(&fx.source("cc.mkv"), "Pelíšky", y("1999", None))
.unwrap();
fx.fm
.import_movie(&fx.source("dd.mkv"), "Pelisky", y("1999", Some("1")))
.unwrap();
fx.fm
.import_movie(&fx.source("unique-size.mkv"), "Jiný", y("2000", None))
.unwrap();
let groups = fx.fm.find_duplicates();
assert_eq!(groups.len(), 2, "{groups:?}");
assert!(
groups
.iter()
.any(|g| g.reasons.contains(&"stejné TMDb ID 603".to_string()))
);
assert!(
groups
.iter()
.any(|g| g.reasons.contains(&"stejný název a rok".to_string()))
);
}
#[test]
fn test_versions_are_not_duplicates() {
let mut fx = Fixture::new();
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
..Default::default()
};
let m = fx
.fm
.import_movie(&fx.source("h1.mkv"), "Hobit", opts)
.unwrap()
.unwrap();
fx.fm
.add_movie_version(&m, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
assert!(fx.fm.find_duplicates().is_empty());
}
#[test]
fn test_verify_against_jellyfin() {
let mut fx = Fixture::new();
let opts = ImportOptions {
year: Some("1999".into()),
tmdb_id: Some("603".into()),
..Default::default()
};
fx.fm
.import_movie(&fx.source("a.mkv"), "Matrix", opts)
.unwrap();
fx.fm
.import_movie(
&fx.source("b.mkv"),
"Pelíšky",
ImportOptions {
year: Some("1999".into()),
..Default::default()
},
)
.unwrap();
fx.fm
.import_movie(&fx.source("c.mkv"), "Chybí", ImportOptions::default())
.unwrap();
let jm = |id: &str, t: &str, y: Option<i64>, tmdb: Option<i64>| JellyfinMovie {
jellyfin_id: id.into(),
title: t.into(),
year: y,
tmdb_id: tmdb,
path: None,
};
let r = fx.fm.verify_against_jellyfin(&[
jm("1", "The Matrix", Some(1999), Some(603)),
jm("2", "Pelíšky", Some(1999), None),
jm("3", "Navíc", None, None),
]);
assert_eq!(r.matched, 2);
assert_eq!(r.pool_only, vec!["Chybí.mkv"]);
assert_eq!(r.jellyfin_only, vec!["Navíc"]);
}
#[test]
fn test_minidlna_scope_and_prune() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Kids", LibraryKind::Movie),
Library::new("Seriály", LibraryKind::Series),
Library::new("Domácí", LibraryKind::HomeVideo),
Library::new("Mix", LibraryKind::Mixed),
])
.unwrap();
fx.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap();
fx.fm
.import_movie(
&fx.source("b.mkv"),
"B",
ImportOptions {
library: Some("Kids".into()),
..Default::default()
},
)
.unwrap();
let ep = fx
.pool()
.join("Mix/Show (2010)/Season 01/S01E01 - Show.mkv");
fs::create_dir_all(ep.parent().unwrap()).unwrap();
fs::write(&ep, "e").unwrap();
let all = fx.fm.minidlna_scope();
assert_eq!(all.movies.len(), 2);
assert_eq!(all.mirror_libs, vec!["Seriály", "Domácí"]);
assert_eq!(all.mixed_series.len(), 1);
fx.fm
.set_minidlna_libraries(Some(vec!["Filmy".into(), "Seriály".into()]))
.unwrap();
let some = fx.fm.minidlna_scope();
assert_eq!(some.movies.len(), 1);
assert_eq!(some.mirror_libs, vec!["Seriály"]);
assert!(some.mixed_series.is_empty());
fs::create_dir_all(fx.pool().join("Seriály/Real")).unwrap();
let (stale, valid) =
fx.fm
.minidlna_prune_targets(&some.mirror_libs, &all.mixed_series, "Seriály");
assert_eq!(stale, vec!["Domácí"]);
assert!(valid.contains("Real") && valid.contains("Show (2010)"));
let (stale2, valid2) = fx.fm.minidlna_prune_targets(&[], &[], "Seriály");
assert_eq!(stale2, vec!["Domácí"]);
assert!(valid2.is_empty());
}
#[test]
fn test_refresh_tags_from_nfo() {
let mut fx = Fixture::new();
let opts = ImportOptions {
year: Some("1999".into()),
tmdb_id: Some("603".into()),
..Default::default()
};
let p = fx
.fm
.import_movie(&fx.source("raw.mkv"), "Matrix", opts)
.unwrap()
.unwrap();
fx.fm
.import_movie(&fx.source("other.mkv"), "Other", ImportOptions::default())
.unwrap();
fs::write(p.with_extension("nfo"), "<movie><year>1999</year><genre>Akční</genre><genre>Sci-Fi</genre><country>USA</country><rating>8.7</rating></movie>").unwrap();
let files: Vec<PathBuf> = fx.fm.filelist.iter().map(|f| f.file_path.clone()).collect();
let r = fx.fm.refresh_tags_from_nfo(&files).unwrap();
assert_eq!((r.updated, r.skipped), (1, 1));
let f = fx.fm.file(&p).unwrap();
for t in ["Žánr/Akční", "Žánr/Sci-Fi", "Rok/1999", "Země původu/USA"] {
assert!(f.tag_paths().contains(t), "{t}");
}
assert_eq!(f.meta.as_ref().unwrap()["rating"], 87);
}
#[test]
fn test_rename_all_to_canonical_reports() {
let mut fx = Fixture::new();
let a = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap()
.unwrap();
let b = fx
.fm
.import_movie(&fx.source("b.mkv"), "B", ImportOptions::default())
.unwrap()
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&a), "Rok/2000")
.unwrap();
let r = fx
.fm
.rename_all_to_canonical(&[a, b], &mut |_, _| {}, &|| false);
assert_eq!(
r.renamed,
vec![("A.mkv".to_string(), "A (2000).mkv".to_string())]
);
assert_eq!(r.skipped_no_year, vec!["B.mkv"]);
}
#[test]
fn test_subtitle_attach_and_remove() {
let mut fx = Fixture::new();
let p = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", ImportOptions::default())
.unwrap()
.unwrap();
let sub = fx.source("x.srt");
let target = fx
.fm
.attach_subtitle(&p, &sub, Some("cs"), None, false)
.unwrap();
assert_eq!(fx.fm.list_subtitles(&p).len(), 1);
fx.fm.remove_subtitle(&target).unwrap();
assert!(fx.fm.list_subtitles(&p).is_empty());
}
}
+998
View File
@@ -0,0 +1,998 @@
//! Movie versions and parts (Jellyfin "multiple versions" and "stacking").
//!
//! A movie with one video file stays flat (`<Library>/Title (rok) [tmdbid].ext`).
//! The moment a second file is added it is promoted to its own folder, where
//! every file starts with the folder's exact name:
//!
//! ```text
//! <Library>/Title (rok) [tmdbid]/
//! Title (rok) [tmdbid].ext (default version, bare name)
//! Title (rok) [tmdbid] - 3D HSBS.ext (another version)
//! ```
//!
//! or, for a film split across files, `Title (rok) [tmdbid]-part1.ext`, `-part2`…
//! A folder holds either versions or parts, never both.
use std::fs;
use std::path::{Path, PathBuf};
use super::{FileManager, LibraryKind, is_media_file};
use crate::core::error::{CoreError, CoreResult};
use crate::core::file::{File, now_iso};
use crate::core::movie::{
DEFAULT_VERSION_LABEL, normalize_3d_label, normalize_part_label, part_label,
};
use crate::core::naming::{is_canonical_pool_stem, parse_3d_format};
use crate::core::paths;
use crate::core::series::looks_like_series_folder;
use crate::core::subtitles;
const PARTS_AND_VERSIONS_MIX: &str = "Film může mít buď díly (přehrají se za sebou), nebo verze (alternativy jako 2D/3D, 4K) — ne obojí v jedné složce. Uprav popisek, nebo nejdřív odeber soubory toho druhého druhu.";
/// How a film's files are laid out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GroupMode {
/// One file in the library root.
Flat,
/// A folder of alternatives.
Versions,
/// A folder of pieces played back to back.
Parts,
}
/// Filename of one version in a movie folder: the default version is bare
/// `<stem><ext>`, a part `<stem>-part<N><ext>`, anything else
/// `<stem> - <label><ext>`.
pub fn version_filename(stem: &str, label: &str, ext: &str) -> String {
let label = label.trim();
if label.is_empty() || label == DEFAULT_VERSION_LABEL {
return format!("{stem}{ext}");
}
match normalize_part_label(label) {
Some(part) => format!("{stem}-part{part}{ext}"),
None => format!("{stem} - {label}{ext}"),
}
}
/// True when `labels` contain both part labels and version labels.
fn mixes_parts_and_versions<'a>(labels: impl IntoIterator<Item = &'a str>) -> bool {
let kinds: std::collections::HashSet<bool> = labels
.into_iter()
.map(|l| normalize_part_label(l).is_some())
.collect();
kinds.len() > 1
}
/// The version label a pooled path implies.
pub fn label_of(path: &Path) -> String {
File::bare(path).identity_from_path().label
}
fn label_or_default(path: &Path) -> String {
let l = label_of(path);
if l.is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
l
}
}
impl FileManager {
/// True when `path` is one version inside its own per-movie folder (in a
/// movie or mixed library; in a mixed one the folder must not be a series).
pub fn is_movie_group_member(&self, path: &Path) -> bool {
let (Some(pool), Some(lib)) = (self.pool_dir(), self.library_of(path)) else {
return false;
};
if !self.libraries_accepting_movies().contains(&lib) {
return false;
}
let Some(parent) = path.parent() else {
return false;
};
if parent == pool.join(&lib) || !is_canonical_pool_stem(&paths::name(parent)) {
return false;
}
!(self.library_kind(&lib) == Some(LibraryKind::Mixed) && looks_like_series_folder(parent))
}
/// Every version sharing `path`'s movie folder, name-sorted (`[path]` when flat).
pub fn list_movie_versions(&self, path: &Path) -> Vec<PathBuf> {
if !self.is_movie_group_member(path) {
return vec![path.to_path_buf()];
}
let folder = path.parent().unwrap_or(Path::new("."));
paths::entries(folder)
.into_iter()
.filter(|p| p.is_file() && is_media_file(p))
.collect()
}
/// The version shown for the movie in the table: plain file first, then the
/// "Výchozí" one, then alphabetically.
pub fn primary_version(&self, versions: &[PathBuf]) -> PathBuf {
versions
.iter()
.min_by_key(|p| {
let label = label_of(p);
let order = if label.is_empty() {
0
} else if label == DEFAULT_VERSION_LABEL {
1
} else {
2
};
(order, paths::name(p).to_lowercase())
})
.cloned()
.unwrap_or_default()
}
/// `filelist` collapsed to one row per movie (a folder contributes only its
/// primary version).
pub fn visible_movies(&self) -> Vec<PathBuf> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for f in &self.filelist {
if !self.is_movie_group_member(&f.file_path) {
out.push(f.file_path.clone());
continue;
}
let folder = f
.file_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
if seen.insert(folder) {
out.push(self.primary_version(&self.list_movie_versions(&f.file_path)));
}
}
out
}
/// Add `source` as another playable version of `path`'s movie, promoting a
/// flat movie to its own folder on the first extra version. An empty
/// `label` becomes "Výchozí"; an unset `existing_label` is derived from the
/// existing file (its 3D tag, else "Výchozí", or `part1` when adding parts).
pub fn add_movie_version(
&mut self,
path: &Path,
source: &Path,
label: &str,
move_source: bool,
existing_label: Option<&str>,
) -> CoreResult<PathBuf> {
let label = if label.trim().is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
label.trim().to_string()
};
let pool = self.require_pool()?;
let (folder, stem) = if self.is_movie_group_member(path) {
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let existing: Vec<String> = self
.list_movie_versions(path)
.iter()
.map(|p| label_or_default(p))
.collect();
if mixes_parts_and_versions(existing.iter().map(String::as_str).chain([label.as_str()]))
{
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let stem = paths::name(&folder);
(folder, stem)
} else {
let file = self.file(path).cloned().unwrap_or_else(|| File::bare(path));
let stem = self.canonical_name_for(&file, false).ok_or_else(|| {
CoreError::invalid("Film nemá platný název/rok — nejde založit složku verzí.")
})?;
let existing_label = match existing_label {
Some(l) => l.to_string(),
None if normalize_part_label(&label).is_some() => "part1".into(),
None => parse_3d_format(&paths::stem(path))
.map_or_else(|| DEFAULT_VERSION_LABEL.to_string(), |f| format!("3D {f}")),
};
if mixes_parts_and_versions([existing_label.as_str(), label.as_str()]) {
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let lib = self
.library_of(path)
.unwrap_or_else(|| self.movie_libraries()[0].clone());
let folder = pool.join(lib).join(&stem);
if folder.exists() {
return Err(CoreError::Exists(format!(
"Složka „{stem}“ už v poolu existuje."
)));
}
fs::create_dir_all(&folder)?;
let existing_target = folder.join(version_filename(
&stem,
&existing_label,
&paths::suffix(path),
));
paths::move_file(path, &existing_target)?;
subtitles::rename_subtitles_with(path, &existing_target)?;
self.with_file(path, |f, tm, index| {
f.relocate(&existing_target, Some(index))?;
f.save_location(tm, index)
})?;
(folder, stem)
};
let target = folder.join(version_filename(&stem, &label, &paths::suffix(source)));
if target.exists() {
return Err(CoreError::Exists(format!("Verze „{label}“ už existuje.")));
}
paths::transfer(source, &target, move_source)?;
let Self {
tagmanager,
index,
filelist,
..
} = self;
let index = index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
let mut version = File::load(&target, tagmanager, Some(index))?;
version.added = Some(now_iso());
version.save_metadata(index)?;
filelist.push(version);
Ok(target)
}
/// Add `source` as the next part of `path`'s film (a flat film becomes
/// `part1` and `source` `part2`).
pub fn add_movie_part(
&mut self,
path: &Path,
source: &Path,
move_source: bool,
) -> CoreResult<PathBuf> {
if !self.is_movie_group_member(path) {
return self.add_movie_version(path, source, &part_label(2), move_source, None);
}
let numbers: Vec<Option<u32>> = self
.list_movie_versions(path)
.iter()
.map(|p| normalize_part_label(&label_of(p)))
.collect();
if numbers.iter().any(Option::is_none) {
return Err(CoreError::invalid(
"Film už má verze (2D/3D, kvalita…) — díly s verzemi zatím nejde kombinovat. Odeber nejdřív verze, nebo přidej soubor jako verzi.",
));
}
let next = numbers.into_iter().flatten().max().unwrap_or(0) + 1;
self.add_movie_version(path, source, &part_label(next), move_source, None)
}
pub fn movie_group_mode(&self, path: &Path) -> GroupMode {
if !self.is_movie_group_member(path) {
return GroupMode::Flat;
}
let labels: Vec<String> = self
.list_movie_versions(path)
.iter()
.map(|p| label_of(p))
.collect();
if !labels.is_empty() && labels.iter().all(|l| normalize_part_label(l).is_some()) {
GroupMode::Parts
} else {
GroupMode::Versions
}
}
/// The film's files in display order — parts by number, versions with the
/// default first, then by popisek.
pub fn ordered_media(&self, path: &Path) -> Vec<PathBuf> {
let mut versions = self.list_movie_versions(path);
versions.sort_by_key(|p| {
let label = label_of(p);
match normalize_part_label(&label) {
Some(n) => (0, n, String::new()),
None if label.is_empty() || label == DEFAULT_VERSION_LABEL => (0, 0, String::new()),
None => (1, 0, label.to_lowercase()),
}
});
versions
}
/// Give every file of the folder a new popisek at once (through temporary
/// names, so swapping two labels never overwrites a file).
fn relabel_movie_group(
&mut self,
path: &Path,
assignments: &[(PathBuf, String)],
) -> CoreResult<()> {
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let current: std::collections::HashSet<PathBuf> =
self.list_movie_versions(path).into_iter().collect();
let assigned: std::collections::HashSet<PathBuf> =
assignments.iter().map(|(p, _)| p.clone()).collect();
if assigned != current || assignments.len() != current.len() {
return Err(CoreError::invalid(
"Přeřazení musí zahrnout každý soubor složky právě jednou.",
));
}
let labels: Vec<String> = assignments
.iter()
.map(|(_, l)| {
if l.trim().is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
l.trim().to_string()
}
})
.collect();
if mixes_parts_and_versions(labels.iter().map(String::as_str)) {
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let stem = paths::name(&folder);
let targets: Vec<PathBuf> = assignments
.iter()
.zip(&labels)
.map(|((p, _), l)| folder.join(version_filename(&stem, l, &paths::suffix(p))))
.collect();
let unique: std::collections::HashSet<String> = targets
.iter()
.map(|t| paths::name(t).to_lowercase())
.collect();
if unique.len() != targets.len() {
return Err(CoreError::invalid(
"Dva soubory by dostaly stejný název — popisky se musí lišit.",
));
}
let mut temps = Vec::new();
for (i, (p, _)) in assignments.iter().enumerate() {
let temp = folder.join(format!("{stem} - .curator-tmp-{i}{}", paths::suffix(p)));
self.relocate_file(p, &temp, true)?;
temps.push(temp);
}
for (temp, target) in temps.iter().zip(&targets) {
self.relocate_file(temp, target, true)?;
self.with_file(target, |f, tm, index| f.save_location(tm, index))?;
}
Ok(())
}
/// Move one part earlier (`delta < 0`) or later; parts are renumbered.
pub fn move_movie_part(&mut self, path: &Path, delta: i32) -> CoreResult<()> {
if self.movie_group_mode(path) != GroupMode::Parts {
return Err(CoreError::invalid("Film nemá díly, které by šlo přeřadit."));
}
let mut order = self.ordered_media(path);
let Some(i) = order.iter().position(|p| p == path) else {
return Ok(());
};
let j = i as i64 + i64::from(delta);
if j < 0 || j >= order.len() as i64 {
return Ok(());
}
order.swap(i, j as usize);
let assignments: Vec<(PathBuf, String)> = order
.into_iter()
.enumerate()
.map(|(n, p)| (p, part_label(n as u32 + 1)))
.collect();
self.relabel_movie_group(path, &assignments)
}
/// Switch a folder between versions and parts. To versions, the first part
/// becomes the default and the others take `labels` in order.
pub fn set_movie_group_mode(
&mut self,
path: &Path,
mode: GroupMode,
labels: &[String],
) -> CoreResult<()> {
if mode == GroupMode::Flat {
return Err(CoreError::invalid("Neznámý režim „flat“."));
}
let current = self.movie_group_mode(path);
if current == GroupMode::Flat {
return Err(CoreError::invalid(
"Film má jen jeden soubor — režim složky zatím nemá co měnit.",
));
}
if current == mode {
return Ok(());
}
let order = self.ordered_media(path);
if mode == GroupMode::Parts {
let a: Vec<(PathBuf, String)> = order
.into_iter()
.enumerate()
.map(|(n, p)| (p, part_label(n as u32 + 1)))
.collect();
return self.relabel_movie_group(path, &a);
}
let clean: Vec<String> = labels.iter().map(|l| l.trim().to_string()).collect();
if clean.len() != order.len() - 1 || clean.iter().any(String::is_empty) {
return Err(CoreError::invalid(format!(
"Každý další soubor potřebuje popisek verze ({}).",
order.len() - 1
)));
}
if clean
.iter()
.any(|l| l.to_lowercase() == DEFAULT_VERSION_LABEL.to_lowercase())
{
return Err(CoreError::invalid(format!(
"„{DEFAULT_VERSION_LABEL}“ je vyhrazený pro první soubor."
)));
}
let unique: std::collections::HashSet<String> =
clean.iter().map(|l| l.to_lowercase()).collect();
if unique.len() != clean.len() {
return Err(CoreError::invalid("Popisky verzí se musí lišit."));
}
let mut a = vec![(order[0].clone(), String::new())];
a.extend(order[1..].iter().cloned().zip(clean));
self.relabel_movie_group(path, &a)
}
/// Rename one version's label within its folder (empty → "Výchozí").
pub fn rename_movie_version(&mut self, path: &Path, new_label: &str) -> CoreResult<PathBuf> {
if !self.is_movie_group_member(path) {
return Err(CoreError::invalid(
"Film nemá žádné další verze — popisek se uplatní až od druhé verze.",
));
}
let new_label = if new_label.trim().is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
new_label.trim().to_string()
};
let others: Vec<String> = self
.list_movie_versions(path)
.iter()
.filter(|p| p.as_path() != path)
.map(|p| label_or_default(p))
.collect();
if mixes_parts_and_versions(
others
.iter()
.map(String::as_str)
.chain([new_label.as_str()]),
) {
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let target = folder.join(version_filename(
&paths::name(&folder),
&new_label,
&paths::suffix(path),
));
if target == path {
return Ok(target);
}
if target.exists() {
return Err(CoreError::Exists(format!(
"Verze „{new_label}“ už existuje."
)));
}
self.relocate_file(path, &target, true)?;
self.with_file(&target, |f, tm, index| f.save_location(tm, index))?;
Ok(target)
}
/// Rename grouped default versions still called `<folder> - Výchozí.ext` to
/// the bare `<folder>.ext`. Returns the `(old, new)` names.
pub fn normalize_default_version_names(&mut self) -> CoreResult<Vec<(String, String)>> {
let mut renamed = Vec::new();
let paths_now: Vec<PathBuf> = self.filelist.iter().map(|f| f.file_path.clone()).collect();
for path in paths_now {
if !self.is_movie_group_member(&path) {
continue;
}
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let fname = paths::name(&folder);
if paths::stem(&path) != format!("{fname} - {DEFAULT_VERSION_LABEL}") {
continue;
}
let target = folder.join(format!("{fname}{}", paths::suffix(&path)));
if target.exists() {
continue;
}
self.relocate_file(&path, &target, true)?;
self.with_file(&target, |f, tm, index| f.save_location(tm, index))?;
renamed.push((paths::name(&path), paths::name(&target)));
}
Ok(renamed)
}
/// Remove one version (+ subtitles). When one version is left the movie is
/// demoted back to a flat file (keeping a 3D tag if the survivor was 3D).
pub fn remove_movie_version(&mut self, path: &Path) -> CoreResult<()> {
if !self.is_movie_group_member(path) {
return Err(CoreError::invalid(
"Film nemá žádné další verze k odebrání.",
));
}
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
self.with_file(path, |f, _, index| f.delete_metadata(index))?;
for sub in subtitles::find_subtitles(path) {
fs::remove_file(&sub.path)?;
}
fs::remove_file(path)?;
self.filelist.retain(|f| f.file_path != path);
let remaining: Vec<PathBuf> = paths::entries(&folder)
.into_iter()
.filter(|p| p.is_file() && is_media_file(p))
.collect();
if remaining.len() == 1 {
let sole = &remaining[0];
let fname = paths::name(&folder);
let prefix = format!("{fname} - ");
let sole_stem = paths::stem(sole);
let label = sole_stem.strip_prefix(&prefix).unwrap_or("");
let new_stem = match normalize_3d_label(label) {
Some(fmt) => format!("{fname}.3D.{fmt}"),
None => fname.clone(),
};
let new_path = folder
.parent()
.unwrap_or(Path::new("."))
.join(format!("{new_stem}{}", paths::suffix(sole)));
self.relocate_file(sole, &new_path, true)?;
self.with_file(&new_path, |f, tm, index| f.save_location(tm, index))?;
let _ = fs::remove_dir_all(&folder);
}
Ok(())
}
/// Delete a whole movie — every version, subtitle and record.
pub fn remove_movie(&mut self, path: &Path) -> CoreResult<()> {
let versions = self.list_movie_versions(path);
let grouped = self.is_movie_group_member(path);
for v in &versions {
self.with_file(v, |f, _, index| f.delete_metadata(index))?;
for sub in subtitles::find_subtitles(v) {
let _ = fs::remove_file(&sub.path);
}
let _ = fs::remove_file(v);
self.filelist.retain(|f| &f.file_path != v);
}
if grouped && let Some(folder) = path.parent() {
let _ = fs::remove_dir_all(folder);
}
Ok(())
}
/// Fold the duplicate movie `other` into `keep` as one more version. `keep`'s
/// metadata wins; `other` only contributes missing tags and a ČSFD link.
pub fn merge_as_version(
&mut self,
keep: &Path,
other: &Path,
label: &str,
keep_label: Option<&str>,
) -> CoreResult<PathBuf> {
if keep == other {
return Err(CoreError::invalid("Nelze sloučit film sám se sebou."));
}
if self.is_movie_group_member(other) {
return Err(CoreError::invalid(
"Slučovaný film už má více verzí — slouč opačně, nebo verze nejdřív odeber.",
));
}
if self.is_movie_group_member(keep) && keep.parent() == other.parent() {
return Err(CoreError::invalid(
"Oba soubory už jsou verzemi téhož filmu.",
));
}
let other_file = self
.file(other)
.cloned()
.unwrap_or_else(|| File::bare(other));
self.with_file(keep, |k, _, index| {
for tag in &other_file.tags {
if !k.tags.contains(tag) {
k.tags.push(tag.clone());
if other_file.auto_tag_paths.contains(&tag.full_path()) {
k.auto_tag_paths.insert(tag.full_path());
}
}
}
if k.csfd_url.is_none() && other_file.csfd_url.is_some() {
k.csfd_url = other_file.csfd_url.clone();
}
k.save_metadata(index)
})?;
self.with_file(other, |f, _, index| f.delete_metadata(index))?;
let new_version = match self.add_movie_version(keep, other, label, true, keep_label) {
Ok(p) => p,
Err(e) => {
let _ = self.with_file(other, |f, _, index| f.save_metadata(index));
return Err(e);
}
};
subtitles::rename_subtitles_with(other, &new_version)?;
self.filelist.retain(|f| f.file_path != other);
Ok(new_version)
}
}
#[cfg(test)]
mod tests {
use super::super::movies::ImportOptions;
use super::super::test_support::Fixture;
use super::*;
use crate::core::file_manager::Library;
const STEM: &str = "Hobit (2013) [tmdbid-49051]";
fn hobbit(fx: &mut Fixture) -> PathBuf {
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
..Default::default()
};
fx.fm
.import_movie(&fx.source("h1.mkv"), "Hobit", opts)
.unwrap()
.unwrap()
}
fn folder(fx: &Fixture) -> PathBuf {
fx.pool().join("Filmy").join(STEM)
}
#[test]
fn test_version_filename_variants() {
assert_eq!(version_filename("S", "", ".mkv"), "S.mkv");
assert_eq!(
version_filename("S", DEFAULT_VERSION_LABEL, ".mkv"),
"S.mkv"
);
assert_eq!(version_filename("S", "CD2", ".mkv"), "S-part2.mkv");
assert_eq!(version_filename("S", "3D HSBS", ".mkv"), "S - 3D HSBS.mkv");
}
#[test]
fn test_add_version_promotes_flat_movie_with_subtitles() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
assert!(!fx.fm.is_movie_group_member(&movie));
fs::write(fx.pool().join(format!("Filmy/{STEM}.cs.srt")), "s").unwrap();
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "3D HSBS", false, None)
.unwrap();
assert_eq!(v2, folder(&fx).join(format!("{STEM} - 3D HSBS.mkv")));
let promoted = folder(&fx).join(format!("{STEM}.mkv"));
assert!(promoted.exists());
assert!(folder(&fx).join(format!("{STEM}.cs.srt")).exists());
assert!(fx.fm.is_movie_group_member(&promoted));
assert_eq!(fx.fm.list_movie_versions(&promoted).len(), 2);
assert_eq!(fx.fm.visible_movies(), vec![promoted.clone()]);
assert!(matches!(
fx.fm
.add_movie_version(&promoted, &fx.source("h3.mkv"), "3D HSBS", false, None),
Err(CoreError::Exists(_))
));
// empty label → Výchozí → bare name, already taken
assert!(
fx.fm
.add_movie_version(&promoted, &fx.source("h4.mkv"), "", false, None)
.is_err()
);
}
#[test]
fn test_import_existing_film_adds_version_or_refuses() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "3D HSBS", false, None)
.unwrap();
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
on_conflict: super::super::OnConflict::Version,
version_label: Some("4K".into()),
..Default::default()
};
let p = fx
.fm
.import_movie(&fx.source("h5.mkv"), "Hobit", opts.clone())
.unwrap()
.unwrap();
assert_eq!(p, folder(&fx).join(format!("{STEM} - 4K.mkv")));
let plain = ImportOptions {
version_label: None,
..opts.clone()
};
assert!(matches!(
fx.fm.import_movie(&fx.source("h8.mkv"), "Hobit", plain),
Err(CoreError::Exists(_))
));
let ftab = ImportOptions {
version_label: None,
format_3d: Some("FTAB".into()),
..opts
};
let p3 = fx
.fm
.import_movie(&fx.source("h6.mkv"), "Hobit", ftab)
.unwrap()
.unwrap();
assert_eq!(paths::name(&p3), format!("{STEM} - 3D FTAB.mkv"));
let root_files: Vec<PathBuf> = paths::entries(&fx.pool().join("Filmy"))
.into_iter()
.filter(|p| p.is_file())
.collect();
assert!(root_files.is_empty());
}
#[test]
fn test_remove_version_demotes_and_keeps_3d_and_metadata() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.assign_tag(std::slice::from_ref(&movie), "Žánr/Fantasy")
.unwrap();
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "3D HSBS", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
fx.fm.remove_movie_version(&primary).unwrap();
let flat = fx.pool().join(format!("Filmy/{STEM}.3D.HSBS.mkv"));
assert!(flat.exists());
assert!(!folder(&fx).exists());
assert!(!v2.exists());
let f = fx.fm.file(&flat).unwrap();
assert!(f.tag_paths().contains("Žánr/Fantasy"));
assert!(fx.fm.remove_movie_version(&flat).is_err());
}
#[test]
fn test_grouped_movie_refuses_rename_and_move_but_allows_metadata() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
assert!(fx.fm.rename_movie(&primary, "Jiný").is_err());
assert!(fx.fm.move_to_library(&primary, "Filmy").is_err());
assert_eq!(fx.fm.rename_to_canonical(&primary).unwrap(), None);
let edits = super::super::movies::MovieEdits {
title: "Hobit".into(),
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
date: Some("2026-02-02".into()),
..Default::default()
};
fx.fm.apply_movie_edits(&primary, edits).unwrap();
let bad = super::super::movies::MovieEdits {
title: "Jiný".into(),
year: Some("2013".into()),
..Default::default()
};
assert!(fx.fm.apply_movie_edits(&primary, bad).is_err());
}
#[test]
fn test_rename_movie_version_cases() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&v2), "Žánr/Fantasy")
.unwrap();
let renamed = fx.fm.rename_movie_version(&v2, "Režisérská verze").unwrap();
assert_eq!(
paths::name(&renamed),
format!("{STEM} - Režisérská verze.mkv")
);
assert!(
fx.fm
.file(&renamed)
.unwrap()
.tag_paths()
.contains("Žánr/Fantasy")
);
assert_eq!(
fx.fm
.rename_movie_version(&renamed, "Režisérská verze")
.unwrap(),
renamed
);
assert!(fx.fm.rename_movie_version(&renamed, "").is_err()); // collides with Výchozí
assert!(fx.fm.rename_movie_version(&renamed, "part2").is_err()); // mixes
let flat = fx
.fm
.import_movie(
&fx.source("o.mkv"),
"Other",
ImportOptions {
year: Some("2000".into()),
..Default::default()
},
)
.unwrap()
.unwrap();
assert!(fx.fm.rename_movie_version(&flat, "x").is_err());
}
#[test]
fn test_parts_add_order_move_and_mode_switch() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
let p2 = fx
.fm
.add_movie_part(&movie, &fx.source("h2.mkv"), false)
.unwrap();
let p1 = folder(&fx).join(format!("{STEM}-part1.mkv"));
assert!(p1.exists());
assert_eq!(paths::name(&p2), format!("{STEM}-part2.mkv"));
let p3 = fx
.fm
.add_movie_part(&p1, &fx.source("h3.mkv"), false)
.unwrap();
assert_eq!(paths::name(&p3), format!("{STEM}-part3.mkv"));
assert_eq!(fx.fm.movie_group_mode(&p1), GroupMode::Parts);
assert!(
fx.fm
.add_movie_version(&p1, &fx.source("x.mkv"), "4K", false, None)
.is_err()
);
fs::write(folder(&fx).join(format!("{STEM}-part1.cs.srt")), "one").unwrap();
fx.fm.move_movie_part(&p1, 1).unwrap();
// the old part1 content is now part2, with its subtitle
assert_eq!(
fs::read_to_string(folder(&fx).join(format!("{STEM}-part2.mkv"))).unwrap(),
"h1.mkv"
);
assert_eq!(
fs::read_to_string(folder(&fx).join(format!("{STEM}-part2.cs.srt"))).unwrap(),
"one"
);
fx.fm
.move_movie_part(&folder(&fx).join(format!("{STEM}-part1.mkv")), -1)
.unwrap(); // beyond the end: no-op
let any = folder(&fx).join(format!("{STEM}-part1.mkv"));
assert!(
fx.fm
.set_movie_group_mode(&any, GroupMode::Versions, &["a".into()])
.is_err()
);
assert!(
fx.fm
.set_movie_group_mode(&any, GroupMode::Versions, &["a".into(), "A".into()])
.is_err()
);
fx.fm
.set_movie_group_mode(&any, GroupMode::Versions, &["4K".into(), "3D HSBS".into()])
.unwrap();
let first = folder(&fx).join(format!("{STEM}.mkv"));
assert_eq!(fx.fm.movie_group_mode(&first), GroupMode::Versions);
fx.fm
.set_movie_group_mode(&first, GroupMode::Parts, &[])
.unwrap();
assert_eq!(
fx.fm
.movie_group_mode(&folder(&fx).join(format!("{STEM}-part1.mkv"))),
GroupMode::Parts
);
}
#[test]
fn test_removing_down_to_one_part_demotes_to_flat() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
let p2 = fx
.fm
.add_movie_part(&movie, &fx.source("h2.mkv"), false)
.unwrap();
fx.fm.remove_movie_version(&p2).unwrap();
assert!(fx.pool().join(format!("Filmy/{STEM}.mkv")).exists());
}
#[test]
fn test_remove_movie_deletes_every_version() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
fx.fm.remove_movie(&primary).unwrap();
assert!(!folder(&fx).exists());
assert!(fx.fm.filelist.is_empty());
assert!(fx.fm.index.as_ref().unwrap().records.is_empty());
}
#[test]
fn test_merge_as_version_carries_tags_csfd_and_subtitles() {
let mut fx = Fixture::new();
let keep = hobbit(&mut fx);
let other = fx
.fm
.import_movie(
&fx.source("dup.mkv"),
"Hobit kopie",
ImportOptions::default(),
)
.unwrap()
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&other), "Žánr/Fantasy")
.unwrap();
fx.fm
.with_file(&other, |f, _, index| {
f.csfd_url = Some("u".into());
f.save_metadata(index)
})
.unwrap();
fs::write(fx.pool().join("Filmy/Hobit kopie.cs.srt"), "s").unwrap();
let v = fx.fm.merge_as_version(&keep, &other, "4K", None).unwrap();
assert_eq!(v, folder(&fx).join(format!("{STEM} - 4K.mkv")));
assert!(folder(&fx).join(format!("{STEM} - 4K.cs.srt")).exists());
let primary = folder(&fx).join(format!("{STEM}.mkv"));
let f = fx.fm.file(&primary).unwrap();
assert!(f.tag_paths().contains("Žánr/Fantasy"));
assert_eq!(f.csfd_url.as_deref(), Some("u"));
assert_eq!(fx.fm.index.as_ref().unwrap().records.len(), 1);
assert!(
fx.fm
.merge_as_version(&primary, &primary, "x", None)
.is_err()
);
}
#[test]
fn test_mixed_library_version_folder_vs_series() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![Library::new("Mix", LibraryKind::Mixed)])
.unwrap();
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
library: Some("Mix".into()),
..Default::default()
};
let movie = fx
.fm
.import_movie(&fx.source("h1.mkv"), "Hobit", opts)
.unwrap()
.unwrap();
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
assert!(fx.fm.is_movie_group_member(&v2));
let ep = fx
.pool()
.join("Mix/Show (2010) [tmdbid-1]/Season 01/S01E01 - Show.mkv");
fs::create_dir_all(ep.parent().unwrap()).unwrap();
fs::write(&ep, "e").unwrap();
assert!(!fx.fm.is_movie_group_member(&ep));
fx.fm.load_pool_movies().unwrap();
assert_eq!(fx.fm.filelist.len(), 2);
}
#[test]
fn test_normalize_default_version_names() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
let old = folder(&fx).join(format!("{STEM} - Výchozí.mkv"));
fx.fm.relocate_file(&primary, &old, true).unwrap();
let renamed = fx.fm.normalize_default_version_names().unwrap();
assert_eq!(renamed, vec![(paths::name(&old), paths::name(&primary))]);
assert!(primary.exists());
}
}
-758
View File
@@ -1,758 +0,0 @@
"""
Hardlink Manager for Curator
Creates directory structure based on file tags and creates hardlinks
to organize files without duplicating them on disk.
Example:
A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:
output/
├── žánr/
│ ├── Komedie/
│ │ └── film.mkv (hardlink)
│ └── Akční/
│ └── film.mkv (hardlink)
└── rok/
└── 1988/
└── film.mkv (hardlink)
"""
import os
import random
import shutil
from collections import defaultdict
from pathlib import Path
from .file import File
from .naming import is_valid_year, parse_pool_stem
class _SafeDict(dict):
"""dict for str.format_map that leaves unknown fields as an empty string."""
def __missing__(self, key):
return ""
class HardlinkManager:
"""Manager for creating hardlink-based directory structures from tagged files.
The output layout is driven by a *category → root folder* mapping
(``category_roots``). Each tag is placed at
``output/<root>/<tag_name>/<file>``; an empty root means the tag's own
folders sit directly at the output root (e.g. genre folders next to the
"Dle roku" / "Dle země původu" folders). The legacy ``categories`` list
(folder == category name) is still accepted and treated as the identity
mapping ``{cat: cat}``.
"""
def __init__(self, output_dir: Path):
"""
Initialize HardlinkManager.
Args:
output_dir: Base directory where the tag-based structure will be created
"""
self.output_dir = Path(output_dir)
self.created_links: list[Path] = []
self.errors: list[tuple[Path, str]] = []
def _resolve_roots(
self,
categories: list[str] | None,
category_roots: dict[str, str] | None,
) -> dict[str, str] | None:
"""Normalize the two filter styles into a category → root-folder map.
``None`` means "all categories", folder == category name.
"""
if category_roots is not None:
return dict(category_roots)
if categories is not None:
return {cat: cat for cat in categories}
return None
def _clean_title(self, file_obj: File) -> str:
"""The movie's clean title (no trailing ``(YYYY)``), path-safe."""
ctx = file_obj.name_context()
title = str(ctx.get("title") or file_obj.file_path.stem or "").strip()
parsed = parse_pool_stem(title)
if parsed is not None:
title = parsed[0]
title = title.replace("/", "-").replace("\\", "-").strip()
return title or file_obj.file_path.stem
def _default_link_name(self, file_obj: File) -> str:
"""Filmotéka hardlink name: the clean title + extension (no year)."""
return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}"
def _year_link_name(self, file_obj: File) -> str | None:
"""``Title (YYYY).ext`` used to disambiguate a name collision, or None."""
year = str(file_obj.name_context().get("year") or "").strip()
if not is_valid_year(year):
return None
return f"{self._clean_title(file_obj)} ({year}){file_obj.file_path.suffix}"
def _render_template(self, file_obj: File, template: str) -> str:
"""Render a per-category filename template; fall back to the clean name."""
try:
rendered = template.format_map(_SafeDict(file_obj.name_context()))
except ValueError, KeyError, IndexError, AttributeError:
return self._default_link_name(file_obj)
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
return rendered or self._default_link_name(file_obj)
def _plan_links(
self,
files: list[File],
roots: dict[str, str] | None,
transforms: dict[str, str] | None,
templates: dict[str, str] | None,
) -> list[tuple[File, Path]]:
"""Resolve the tag tree to a collision-free ``(file, link_path)`` plan.
The default hardlink name is the movie's **clean title** (no year). When
two *different* movies would land on the same name in the same folder,
the colliding ones fall back to the year-qualified ``Title (YYYY).ext`` to
tell them apart; a residual clash (same title *and* year) gets a stable
numeric suffix so every path is unique. A per-category
``filename_template`` overrides the default inside that category's folders.
Both link creation and obsolete detection consume this one plan, so they
can never disagree on a name (which is what caused hardlink churn before).
"""
# (file_obj, target_dir, name, is_templated), one per file+folder
entries: list[list] = []
seen_fd: set[tuple[str, str]] = set()
for file_obj in files:
for tag in file_obj.tags:
target_dir = self._target_dir(tag, roots, transforms)
if target_dir is None:
continue
key = (str(file_obj.file_path), str(target_dir))
if key in seen_fd: # one link per movie per folder
continue
seen_fd.add(key)
template = templates.get(tag.category) if templates else None
if template:
entries.append(
[
file_obj,
target_dir,
self._render_template(file_obj, template),
True,
]
)
else:
entries.append(
[file_obj, target_dir, self._default_link_name(file_obj), False]
)
# Disambiguate default-named collisions (different movies) by their year.
groups: dict[tuple[Path, str], set[str]] = defaultdict(set)
for file_obj, target_dir, name, templated in entries:
if not templated:
groups[(target_dir, name)].add(str(file_obj.file_path))
for entry in entries:
file_obj, target_dir, name, templated = entry
if not templated and len(groups[(target_dir, name)]) > 1:
year_name = self._year_link_name(file_obj)
if year_name:
entry[2] = year_name
# Final pass: guarantee unique paths (stable numeric suffix on any tie).
by_path: dict[tuple[Path, str], list[list]] = defaultdict(list)
for entry in entries:
by_path[(entry[1], entry[2])].append(entry)
plan: list[tuple[File, Path]] = []
for (target_dir, name), group in by_path.items():
if len(group) == 1:
plan.append((group[0][0], target_dir / name))
continue
ordered = sorted(group, key=lambda e: str(e[0].file_path))
stem, suffix = Path(name).stem, Path(name).suffix
for i, entry in enumerate(ordered):
fname = name if i == 0 else f"{stem}_{i + 1}{suffix}"
plan.append((entry[0], target_dir / fname))
return plan
def _folder_value(self, tag, transforms: dict[str, str] | None) -> str:
"""Folder name for a tag — its value run through the category transform.
The tag keeps its exact value; the grouping (e.g. rating → ten-point
band) only happens here, when naming the folder.
"""
if transforms and tag.category in transforms:
from .transforms import apply_transform
return apply_transform(tag.name, transforms[tag.category])
return tag.name
def _target_dir(
self,
tag,
roots: dict[str, str] | None,
transforms: dict[str, str] | None = None,
) -> Path | None:
"""Output directory for a tag, or None if its category is excluded."""
if roots is None:
folder = tag.category
elif tag.category in roots:
folder = roots[tag.category]
else:
return None
base = self.output_dir / folder if folder else self.output_dir
return base / self._folder_value(tag, transforms)
def _managed_top_dirs(
self,
files: list[File],
roots: dict[str, str] | None,
transforms: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = None,
) -> set[str] | None:
"""Top-level output folders owned by the tag tree (None = all of them).
A category with a non-empty root owns that root folder. A category placed
at the output root (empty root, e.g. genres) owns its own folders at the
root — and, so a genre whose last movie dropped the tag still gets its
now-stale folder cleaned, *every* root-level folder is treated as owned
except the grouping roots and any ``reserved_subfolders`` (copy-as-is
mirrors, "Tipy dne", …). The obsolete check still only removes links to
current pool files, so reserved/foreign folders are doubly protected.
"""
if roots is None:
return None
grouping_roots = {folder for folder in roots.values() if folder}
has_root_level = any(not folder for folder in roots.values())
tops: set[str] = set(grouping_roots)
# genre folders currently present on the movies
for cat, folder in roots.items():
if not folder:
for file_obj in files:
for tag in file_obj.tags:
if tag.category == cat:
tops.add(self._folder_value(tag, transforms))
# plus any other root-level folder (catches genres that lost their last
# movie), minus grouping roots and reserved (mirrors / Tipy dne / …)
if has_root_level and self.output_dir.exists():
reserved = set(reserved_subfolders or set())
for entry in self.output_dir.iterdir():
if (
entry.is_dir()
and entry.name not in grouping_roots
and entry.name not in reserved
):
tops.add(entry.name)
return tops
def create_structure_for_files(
self,
files: list[File],
categories: list[str] | None = None,
dry_run: bool = False,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
) -> tuple[int, int]:
"""
Create hardlink structure for given files based on their tags.
Args:
files: List of File objects to process
categories: Optional list of categories to include (None = all)
dry_run: If True, only simulate without creating actual links
category_roots: Optional category → root-folder map (see class doc);
overrides ``categories`` when given.
category_transforms: Optional category → transform-name map applied to
the tag value when naming its folder (e.g. rating → decade band).
category_filename_templates: Optional category → hardlink-name template
applied only inside that category's folders.
Returns:
Tuple of (successful_links, failed_links)
"""
self.created_links = []
self.errors = []
roots = self._resolve_roots(categories, category_roots)
success_count = 0
fail_count = 0
plan = self._plan_links(
files, roots, category_transforms, category_filename_templates
)
for file_obj, target_file in plan:
try:
if not dry_run:
target_file.parent.mkdir(parents=True, exist_ok=True)
if target_file.exists():
# Already the right link → nothing to do (not re-counted).
if self._is_same_file(file_obj.file_path, target_file):
continue
# A wrong occupant sits on the planned path; the plan is
# authoritative, so replace it.
target_file.unlink()
os.link(file_obj.file_path, target_file)
self.created_links.append(target_file)
success_count += 1
except OSError as e:
self.errors.append((file_obj.file_path, str(e)))
fail_count += 1
return success_count, fail_count
def mirror_as_is(
self, source_dir: Path, subfolder: str | None = None, dry_run: bool = False
) -> tuple[int, int]:
"""Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.
Recreates the exact directory hierarchy of ``source_dir`` under
``output_dir/subfolder`` (or directly under ``output_dir`` when
``subfolder`` is None) and hardlinks every file. Curator metadata files
(``.!tag`` / ``.!ftag`` / ``.!gtag`` / ``.!index``) are skipped.
Used for Seriály: the pool structure is the source of truth and is cloned
verbatim instead of being rebuilt from tags.
Returns:
Tuple of (successful_links, failed_links)
"""
source_dir = Path(source_dir)
if not source_dir.is_dir():
return 0, 0
base = self.output_dir / subfolder if subfolder else self.output_dir
success_count = 0
fail_count = 0
for src_file in source_dir.rglob("*"):
if not src_file.is_file():
continue
if src_file.name.endswith((".!tag", ".!ftag", ".!gtag", ".!index")):
continue
target_file = base / src_file.relative_to(source_dir)
try:
if not dry_run:
target_file.parent.mkdir(parents=True, exist_ok=True)
if target_file.exists():
if self._is_same_file(src_file, target_file):
success_count += 1
continue
target_file.unlink()
os.link(src_file, target_file)
self.created_links.append(target_file)
success_count += 1
except OSError as e:
self.errors.append((src_file, str(e)))
fail_count += 1
return success_count, fail_count
def remove_mirror(self, name: str) -> bool:
"""Remove ``output_dir/name`` entirely (file or folder tree).
Used to drop a whole mirrored library (or one entry of the shared
mixed-library-series bucket) when it falls out of the miniDLNA
generation's scope. Mirrors are pure hardlinks, so deleting the folder
loses nothing — it's fully regenerable from the pool. ``name`` may
contain "/" to address one entry inside a bucket folder.
Returns whether anything was actually removed.
"""
target = self.output_dir / name
if not target.exists():
return False
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
return True
def _fill_special_folder(
self, chosen: list[File], subfolder: str, dry_run: bool
) -> tuple[int, int]:
"""Empty ``subfolder`` and hardlink ``chosen`` files into it."""
base = self.output_dir / subfolder
if not dry_run and base.exists():
for child in base.iterdir():
if child.is_file() or child.is_symlink():
try:
child.unlink()
except OSError:
pass
created = 0
fail = 0
for file_obj in chosen:
target = base / self._default_link_name(file_obj)
try:
if not dry_run:
base.mkdir(parents=True, exist_ok=True)
if target.exists():
if self._is_same_file(file_obj.file_path, target):
created += 1
continue
# Collision with another movie: prefer the year-qualified
# name, else a numeric suffix.
year_name = self._year_link_name(file_obj)
if year_name and not (base / year_name).exists():
target = base / year_name
else:
target = self._get_unique_name(target)
os.link(file_obj.file_path, target)
self.created_links.append(target)
created += 1
except OSError as e:
self.errors.append((file_obj.file_path, str(e)))
fail += 1
return created, fail
def generate_recently_added(
self,
files: list[File],
count: int = 10,
subfolder: str = "- Nově přidané",
dry_run: bool = False,
) -> tuple[int, int]:
"""(Re)fill ``subfolder`` with the ``count`` most recently added movies.
Ordering is by ``File.added_timestamp()`` (import time, ctime fallback),
newest first. Returns (created_links, failed_links).
"""
chosen = sorted(files, key=lambda f: f.added_timestamp(), reverse=True)[:count]
return self._fill_special_folder(chosen, subfolder, dry_run)
def generate_random_tips(
self,
files: list[File],
count: int = 10,
subfolder: str = "Tipy dne",
dry_run: bool = False,
) -> tuple[int, int]:
"""(Re)fill ``subfolder`` with ``count`` random files as hardlinks.
The folder is emptied first so each run picks a fresh random selection.
``files`` should be the pool movies only (copy-as-is mirrors excluded).
Returns:
Tuple of (created_links, failed_links)
"""
chosen = random.sample(files, min(count, len(files)))
return self._fill_special_folder(chosen, subfolder, dry_run)
def _is_same_file(self, path1: Path, path2: Path) -> bool:
"""Check if two paths point to the same file (same inode)."""
try:
return path1.stat().st_ino == path2.stat().st_ino
except OSError:
return False
def _get_unique_name(self, path: Path) -> Path:
"""Get a unique filename by adding a numeric suffix."""
stem = path.stem
suffix = path.suffix
parent = path.parent
counter = 1
while True:
new_name = f"{stem}_{counter}{suffix}"
new_path = parent / new_name
if not new_path.exists():
return new_path
counter += 1
def remove_created_links(self) -> int:
"""
Remove all hardlinks created by the last operation.
Returns:
Number of links removed
"""
removed = 0
for link_path in self.created_links:
try:
if link_path.exists() and link_path.is_file():
link_path.unlink()
removed += 1
# Try to remove empty parent directories
self._remove_empty_parents(link_path.parent)
except OSError:
pass
self.created_links = []
return removed
def _remove_empty_parents(self, path: Path) -> None:
"""Remove empty parent directories up to output_dir."""
try:
while path != self.output_dir and path.is_dir():
if any(path.iterdir()):
break # Directory not empty
path.rmdir()
path = path.parent
except OSError:
pass
def get_preview(
self,
files: list[File],
categories: list[str] | None = None,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
) -> list[tuple[Path, Path]]:
"""
Get a preview of what links would be created.
Args:
files: List of File objects
categories: Optional list of categories to include
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
Returns:
List of tuples (source_path, target_path)
"""
roots = self._resolve_roots(categories, category_roots)
plan = self._plan_links(
files, roots, category_transforms, category_filename_templates
)
return [(file_obj.file_path, target_file) for file_obj, target_file in plan]
def find_obsolete_links(
self,
files: list[File],
categories: list[str] | None = None,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = None,
) -> list[tuple[Path, Path]]:
"""
Find hardlinks in the output directory that no longer match file tags.
Scans the managed parts of the output directory for hardlinks that point
to source files but whose path no longer matches the file's current tags.
Only the tag-tree's own top-level folders are scanned, so copy-as-is
mirrors (e.g. Seriály) are left untouched.
Args:
files: List of File objects (source files)
categories: Optional list of categories to check (None = all)
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
category_transforms: Optional category → transform-name map for folders.
Returns:
List of tuples (link_path, source_path) for obsolete links
"""
obsolete: list[tuple[Path, Path]] = []
if not self.output_dir.exists():
return obsolete
roots = self._resolve_roots(categories, category_roots)
# Build a map of source file inodes to File objects
inode_to_file: dict[int, File] = {}
for file_obj in files:
try:
inode = file_obj.file_path.stat().st_ino
inode_to_file[inode] = file_obj
except OSError:
continue
# Every link the tag tree *should* contain, mapped to the inode(s) it may
# point at — taken from the very same plan that creates the links, so the
# two never disagree on a name. Matching by inode too — not just the path
# — is what catches an **orphan that squats the expected name**: a link
# whose name is right but which points to an old inode (movie
# re-imported/replaced) is still obsolete, while the correctly created
# link is *not* wrongly swept.
expected: dict[Path, set[int]] = {}
for file_obj, path in self._plan_links(
files, roots, category_transforms, category_filename_templates
):
try:
expected.setdefault(path, set()).add(file_obj.file_path.stat().st_ino)
except OSError:
continue
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors).
# Inside them, any file that isn't an expected link is obsolete — whether
# a tag was removed, the movie was replaced (orphan inode), or the folder
# itself is a leftover from a renamed layout.
top_dirs = self._managed_top_dirs(
files, roots, category_transforms, reserved_subfolders
)
for top in self.output_dir.iterdir():
if not top.is_dir():
continue
if top_dirs is not None and top.name not in top_dirs:
continue
for link_file in top.rglob("*"):
if not link_file.is_file():
continue
try:
link_inode = link_file.stat().st_ino
except OSError:
continue
# Valid only when both the path *and* the inode match a current
# movie; a name-only match (orphan squatting the name) is stale.
if link_inode in expected.get(link_file, ()):
continue
source = inode_to_file.get(link_inode)
obsolete.append((link_file, source.file_path if source else link_file))
return obsolete
def remove_obsolete_links(
self,
files: list[File],
categories: list[str] | None = None,
dry_run: bool = False,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = None,
) -> tuple[int, list[Path]]:
"""
Remove hardlinks that no longer match file tags.
Args:
files: List of File objects
categories: Optional list of categories to check
dry_run: If True, only return what would be removed
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
reserved_subfolders: Output top-level folders to never touch (mirrors,
"Tipy dne", …).
Returns:
Tuple of (removed_count, list_of_removed_paths)
"""
obsolete = self.find_obsolete_links(
files,
categories,
category_roots,
category_transforms,
category_filename_templates,
reserved_subfolders,
)
removed_paths = []
if dry_run:
return len(obsolete), [link for link, _ in obsolete]
for link_path, _ in obsolete:
try:
link_path.unlink()
removed_paths.append(link_path)
# Try to remove empty parent directories
self._remove_empty_parents(link_path.parent)
except OSError:
pass
return len(removed_paths), removed_paths
def sync_structure(
self,
files: list[File],
categories: list[str] | None = None,
dry_run: bool = False,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = None,
) -> tuple[int, int, int, int]:
"""
Synchronize hardlink structure with current file tags.
This will:
1. Remove hardlinks for removed tags
2. Create new hardlinks for new tags
Args:
files: List of File objects
categories: Optional list of categories to sync
dry_run: If True, only simulate
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
reserved_subfolders: Output top-level folders to never touch (mirrors,
"Tipy dne", …).
Returns:
Tuple of (created, create_failed, removed, remove_failed)
"""
# First find how many obsolete links there are
obsolete_count = len(
self.find_obsolete_links(
files,
categories,
category_roots,
category_transforms,
category_filename_templates,
reserved_subfolders,
)
)
# Remove obsolete links
removed, _removed_paths = self.remove_obsolete_links(
files,
categories,
dry_run,
category_roots,
category_transforms,
category_filename_templates,
reserved_subfolders,
)
remove_failed = obsolete_count - removed if not dry_run else 0
# Then create new links
created, create_failed = self.create_structure_for_files(
files,
categories,
dry_run,
category_roots,
category_transforms,
category_filename_templates,
)
return created, create_failed, removed, remove_failed
def create_hardlink_structure(
files: list[File], output_dir: Path, categories: list[str] | None = None
) -> tuple[int, int, list[tuple[Path, str]]]:
"""
Convenience function to create hardlink structure.
Args:
files: List of File objects to process
output_dir: Base directory for output
categories: Optional list of categories to include
Returns:
Tuple of (successful_count, failed_count, errors_list)
"""
manager = HardlinkManager(output_dir)
success, fail = manager.create_structure_for_files(files, categories)
return success, fail, manager.errors
+926
View File
@@ -0,0 +1,926 @@
//! Builds the miniDLNA / Filmotéka tree: a directory structure derived from the
//! movies' tags, made only of **hardlinks** into the pool (no data duplicated).
//!
//! ```text
//! output/
//! ├── Komedie/film.mkv (category at the root, e.g. genres)
//! └── - Dle roku/1988/film.mkv (category under a grouping folder)
//! ```
//!
//! The layout is driven by a *category → root folder* map: each tag lands at
//! `output/<root>/<tag value>/<file>`; an empty root puts the tag's folders
//! directly at the output root.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use file_id::FileId;
use indexmap::IndexMap;
use rand::seq::IndexedRandom;
use super::file::File;
use super::naming::{is_valid_year, parse_pool_stem};
use super::paths;
use super::transforms::apply_transform;
/// Curator metadata files that never belong in the output.
const METADATA_SUFFIXES: &[&str] = &[".!tag", ".!ftag", ".!gtag", ".!index"];
/// Which categories get folders, where, and how they are named.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TreeSpec {
/// Category → root folder. `None` = every category, folder == category name.
pub roots: Option<IndexMap<String, String>>,
/// Category → transform name applied to the folder name (e.g. rating bands).
pub transforms: HashMap<String, String>,
/// Category → hardlink-name template used inside that category's folders.
pub templates: HashMap<String, String>,
}
impl TreeSpec {
/// Only `categories`, each in a folder of its own name.
pub fn categories(categories: &[&str]) -> Self {
Self {
roots: Some(
categories
.iter()
.map(|c| (c.to_string(), c.to_string()))
.collect(),
),
..Default::default()
}
}
/// An explicit category → root map.
pub fn with_roots(roots: IndexMap<String, String>) -> Self {
Self {
roots: Some(roots),
..Default::default()
}
}
}
pub fn file_id(path: &Path) -> Option<FileId> {
file_id::get_file_id(path).ok()
}
/// True when both paths are the same file on disk (same inode).
pub fn is_same_file(a: &Path, b: &Path) -> bool {
match (file_id(a), file_id(b)) {
(Some(x), Some(y)) => x == y,
_ => false,
}
}
/// Render a Python `str.format_map` style template: `{field}` (an optional
/// `:spec` / `!conv` is ignored), `{{` / `}}` escapes, unknown fields → "".
/// `None` when the braces are malformed.
pub fn render_template(template: &str, ctx: &BTreeMap<String, String>) -> Option<String> {
let mut out = String::new();
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' if chars.peek() == Some(&'{') => {
chars.next();
out.push('{');
}
'}' if chars.peek() == Some(&'}') => {
chars.next();
out.push('}');
}
'}' => return None,
'{' => {
let mut field = String::new();
loop {
match chars.next()? {
'}' => break,
'{' => return None,
ch => field.push(ch),
}
}
let name = field.split([':', '!']).next().unwrap_or("").trim();
out.push_str(ctx.get(name).map(String::as_str).unwrap_or(""));
}
_ => out.push(c),
}
}
Some(out)
}
struct Entry<'a> {
file: &'a File,
dir: PathBuf,
name: String,
templated: bool,
}
pub struct HardlinkManager {
pub output_dir: PathBuf,
pub created_links: Vec<PathBuf>,
pub errors: Vec<(PathBuf, String)>,
}
impl HardlinkManager {
pub fn new(output_dir: &Path) -> Self {
Self {
output_dir: output_dir.to_path_buf(),
created_links: Vec::new(),
errors: Vec::new(),
}
}
/// The movie's clean title (no trailing `(YYYY)`), path-safe.
fn clean_title(&self, file: &File) -> String {
let ctx = file.name_context();
let stem = paths::stem(&file.file_path);
let mut title = ctx
.get("title")
.cloned()
.filter(|t| !t.is_empty())
.unwrap_or_else(|| stem.clone())
.trim()
.to_string();
if let Some((t, _)) = parse_pool_stem(&title) {
title = t;
}
let title = title.replace(['/', '\\'], "-").trim().to_string();
if title.is_empty() { stem } else { title }
}
/// Default hardlink name: the clean title + extension (no year).
fn default_link_name(&self, file: &File) -> String {
format!(
"{}{}",
self.clean_title(file),
paths::suffix(&file.file_path)
)
}
/// `Title (YYYY).ext`, used to disambiguate a name collision.
fn year_link_name(&self, file: &File) -> Option<String> {
let year = file.name_context().get("year").cloned().unwrap_or_default();
let year = year.trim();
is_valid_year(year).then(|| {
format!(
"{} ({year}){}",
self.clean_title(file),
paths::suffix(&file.file_path)
)
})
}
fn render(&self, file: &File, template: &str) -> String {
match render_template(template, &file.name_context()) {
Some(r) => {
let r = r.replace(['/', '\\'], "-").trim().to_string();
if r.is_empty() {
self.default_link_name(file)
} else {
r
}
}
None => self.default_link_name(file),
}
}
fn folder_value(&self, category: &str, value: &str, spec: &TreeSpec) -> String {
match spec.transforms.get(category) {
Some(t) => apply_transform(value, Some(t)),
None => value.to_string(),
}
}
/// Output directory for a tag, or `None` if its category is excluded.
fn target_dir(&self, category: &str, value: &str, spec: &TreeSpec) -> Option<PathBuf> {
let folder = match &spec.roots {
None => category.to_string(),
Some(roots) => roots.get(category)?.clone(),
};
let base = if folder.is_empty() {
self.output_dir.clone()
} else {
self.output_dir.join(folder)
};
Some(base.join(self.folder_value(category, value, spec)))
}
/// Resolve the tag tree to a collision-free `(file, link path)` plan.
///
/// Default names are the clean title; when two *different* movies collide
/// in one folder they fall back to `Title (YYYY).ext`; a residual clash gets
/// a stable numeric suffix. Link creation and obsolete detection both use
/// this one plan, so they can never disagree on a name.
fn plan_links<'a>(&self, files: &'a [File], spec: &TreeSpec) -> Vec<(&'a File, PathBuf)> {
let mut entries: Vec<Entry<'a>> = Vec::new();
let mut seen: HashSet<(PathBuf, PathBuf)> = HashSet::new();
for file in files {
for tag in &file.tags {
let Some(dir) = self.target_dir(&tag.category, &tag.name, spec) else {
continue;
};
if !seen.insert((file.file_path.clone(), dir.clone())) {
continue; // one link per movie per folder
}
match spec.templates.get(&tag.category).filter(|t| !t.is_empty()) {
Some(t) => entries.push(Entry {
file,
dir,
name: self.render(file, t),
templated: true,
}),
None => entries.push(Entry {
file,
dir,
name: self.default_link_name(file),
templated: false,
}),
}
}
}
let mut groups: HashMap<(PathBuf, String), HashSet<PathBuf>> = HashMap::new();
for e in entries.iter().filter(|e| !e.templated) {
groups
.entry((e.dir.clone(), e.name.clone()))
.or_default()
.insert(e.file.file_path.clone());
}
for e in entries.iter_mut().filter(|e| !e.templated) {
if groups
.get(&(e.dir.clone(), e.name.clone()))
.is_some_and(|g| g.len() > 1)
&& let Some(year_name) = self.year_link_name(e.file)
{
e.name = year_name;
}
}
let mut by_path: IndexMap<(PathBuf, String), Vec<&Entry<'a>>> = IndexMap::new();
for e in &entries {
by_path
.entry((e.dir.clone(), e.name.clone()))
.or_default()
.push(e);
}
let mut plan = Vec::new();
for ((dir, name), mut group) in by_path {
if group.len() == 1 {
plan.push((group[0].file, dir.join(&name)));
continue;
}
group.sort_by(|a, b| a.file.file_path.cmp(&b.file.file_path));
let p = Path::new(&name);
let (stem, suffix) = (paths::stem(p), paths::suffix(p));
for (i, e) in group.into_iter().enumerate() {
let fname = if i == 0 {
name.clone()
} else {
format!("{stem}_{}{suffix}", i + 1)
};
plan.push((e.file, dir.join(fname)));
}
}
plan
}
/// Top-level output folders owned by the tag tree (`None` = all).
///
/// A root-level category (genres) owns its folders at the root — and so a
/// genre whose last movie dropped the tag still gets cleaned, every other
/// root-level folder counts as owned too, except grouping roots and
/// `reserved` (mirrors, "Tipy dne", …).
fn managed_top_dirs(
&self,
files: &[File],
spec: &TreeSpec,
reserved: &HashSet<String>,
) -> Option<HashSet<String>> {
let roots = spec.roots.as_ref()?;
let grouping: HashSet<String> = roots.values().filter(|f| !f.is_empty()).cloned().collect();
let has_root_level = roots.values().any(String::is_empty);
let mut tops = grouping.clone();
for (cat, folder) in roots {
if folder.is_empty() {
for file in files {
for tag in file.tags.iter().filter(|t| &t.category == cat) {
tops.insert(self.folder_value(&tag.category, &tag.name, spec));
}
}
}
}
if has_root_level && self.output_dir.exists() {
for entry in paths::subdirs(&self.output_dir) {
let name = paths::name(&entry);
if !grouping.contains(&name) && !reserved.contains(&name) {
tops.insert(name);
}
}
}
Some(tops)
}
/// Create the hardlink tree for `files`. Returns `(created, failed)`.
pub fn create_structure_for_files(
&mut self,
files: &[File],
spec: &TreeSpec,
dry_run: bool,
) -> (usize, usize) {
self.created_links.clear();
self.errors.clear();
let (mut ok, mut fail) = (0, 0);
for (file, target) in self.plan_links(files, spec) {
let result: io::Result<bool> = (|| {
if !dry_run {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
if target.exists() {
if is_same_file(&file.file_path, &target) {
return Ok(false); // already the right link
}
fs::remove_file(&target)?;
}
fs::hard_link(&file.file_path, &target)?;
}
Ok(true)
})();
match result {
Ok(true) => {
self.created_links.push(target);
ok += 1;
}
Ok(false) => {}
Err(e) => {
self.errors.push((file.file_path.clone(), e.to_string()));
fail += 1;
}
}
}
(ok, fail)
}
/// Mirror a "copy-as-is" folder 1:1 into `output/subfolder` as hardlinks.
pub fn mirror_as_is(
&mut self,
source_dir: &Path,
subfolder: Option<&str>,
dry_run: bool,
) -> (usize, usize) {
if !source_dir.is_dir() {
return (0, 0);
}
let base = match subfolder {
Some(s) if !s.is_empty() => self.output_dir.join(s),
_ => self.output_dir.clone(),
};
let (mut ok, mut fail) = (0, 0);
for src in paths::walk_files(source_dir) {
let name = paths::name(&src);
if METADATA_SUFFIXES.iter().any(|s| name.ends_with(s)) {
continue;
}
let Ok(rel) = src.strip_prefix(source_dir) else {
continue;
};
let target = base.join(rel);
let result: io::Result<bool> = (|| {
if !dry_run {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
if target.exists() {
if is_same_file(&src, &target) {
return Ok(false);
}
fs::remove_file(&target)?;
}
fs::hard_link(&src, &target)?;
}
Ok(true)
})();
match result {
Ok(created) => {
if created {
self.created_links.push(target);
}
ok += 1;
}
Err(e) => {
self.errors.push((src, e.to_string()));
fail += 1;
}
}
}
(ok, fail)
}
/// Remove `output/name` entirely (file or folder). `name` may contain "/".
pub fn remove_mirror(&self, name: &str) -> bool {
let target = paths::join_posix(&self.output_dir, name);
if !target.exists() {
return false;
}
let res = if target.is_dir() {
fs::remove_dir_all(&target)
} else {
fs::remove_file(&target)
};
res.is_ok()
}
/// Empty `subfolder` and hardlink `chosen` into it.
fn fill_special_folder(
&mut self,
chosen: &[&File],
subfolder: &str,
dry_run: bool,
) -> (usize, usize) {
let base = self.output_dir.join(subfolder);
if !dry_run && base.exists() {
for child in paths::entries(&base) {
if child.is_file() || child.is_symlink() {
let _ = fs::remove_file(&child);
}
}
}
let (mut ok, mut fail) = (0, 0);
for file in chosen {
let mut target = base.join(self.default_link_name(file));
let result: io::Result<bool> = (|| {
if !dry_run {
fs::create_dir_all(&base)?;
if target.exists() {
if is_same_file(&file.file_path, &target) {
return Ok(false);
}
target = match self.year_link_name(file) {
Some(y) if !base.join(&y).exists() => base.join(y),
_ => unique_name(&target),
};
}
fs::hard_link(&file.file_path, &target)?;
}
Ok(true)
})();
match result {
Ok(created) => {
if created {
self.created_links.push(target);
}
ok += 1;
}
Err(e) => {
self.errors.push((file.file_path.clone(), e.to_string()));
fail += 1;
}
}
}
(ok, fail)
}
/// (Re)fill `subfolder` with the `count` most recently added movies.
pub fn generate_recently_added(
&mut self,
files: &[File],
count: usize,
subfolder: &str,
dry_run: bool,
) -> (usize, usize) {
let mut sorted: Vec<&File> = files.iter().collect();
sorted.sort_by(|a, b| b.added_timestamp().total_cmp(&a.added_timestamp()));
sorted.truncate(count);
self.fill_special_folder(&sorted, subfolder, dry_run)
}
/// (Re)fill `subfolder` with `count` random movies.
pub fn generate_random_tips(
&mut self,
files: &[File],
count: usize,
subfolder: &str,
dry_run: bool,
) -> (usize, usize) {
let mut rng = rand::rng();
let chosen: Vec<&File> = files.sample(&mut rng, count.min(files.len())).collect();
self.fill_special_folder(&chosen, subfolder, dry_run)
}
/// Remove every link the last operation created (and emptied parents).
pub fn remove_created_links(&mut self) -> usize {
let mut removed = 0;
for link in std::mem::take(&mut self.created_links) {
if link.is_file() && fs::remove_file(&link).is_ok() {
removed += 1;
if let Some(parent) = link.parent() {
self.remove_empty_parents(parent);
}
}
}
removed
}
fn remove_empty_parents(&self, start: &Path) {
let mut path = start.to_path_buf();
while path != self.output_dir && path.is_dir() {
if !paths::is_empty_dir(&path) || fs::remove_dir(&path).is_err() {
break;
}
match path.parent() {
Some(p) => path = p.to_path_buf(),
None => break,
}
}
}
/// `(source, target)` pairs the tree would contain.
pub fn preview(&self, files: &[File], spec: &TreeSpec) -> Vec<(PathBuf, PathBuf)> {
self.plan_links(files, spec)
.into_iter()
.map(|(f, p)| (f.file_path.clone(), p))
.collect()
}
/// Links in the managed part of the output that no longer match the plan —
/// a removed tag, a replaced movie (orphan inode squatting the right name),
/// or a leftover folder. Returns `(link, source)` pairs.
pub fn find_obsolete_links(
&self,
files: &[File],
spec: &TreeSpec,
reserved: &HashSet<String>,
) -> Vec<(PathBuf, PathBuf)> {
if !self.output_dir.exists() {
return Vec::new();
}
let id_to_file: HashMap<FileId, &File> = files
.iter()
.filter_map(|f| file_id(&f.file_path).map(|id| (id, f)))
.collect();
let mut expected: HashMap<PathBuf, HashSet<FileId>> = HashMap::new();
for (file, path) in self.plan_links(files, spec) {
if let Some(id) = file_id(&file.file_path) {
expected.entry(path).or_default().insert(id);
}
}
let tops = self.managed_top_dirs(files, spec, reserved);
let mut obsolete = Vec::new();
for top in paths::subdirs(&self.output_dir) {
if tops
.as_ref()
.is_some_and(|t| !t.contains(&paths::name(&top)))
{
continue;
}
for link in paths::walk_files(&top) {
let Some(id) = file_id(&link) else { continue };
if expected.get(&link).is_some_and(|ids| ids.contains(&id)) {
continue;
}
let source = id_to_file
.get(&id)
.map_or_else(|| link.clone(), |f| f.file_path.clone());
obsolete.push((link, source));
}
}
obsolete
}
/// Remove obsolete links. Returns `(removed, removed paths)`.
pub fn remove_obsolete_links(
&self,
files: &[File],
spec: &TreeSpec,
reserved: &HashSet<String>,
dry_run: bool,
) -> (usize, Vec<PathBuf>) {
let obsolete = self.find_obsolete_links(files, spec, reserved);
if dry_run {
return (
obsolete.len(),
obsolete.into_iter().map(|(l, _)| l).collect(),
);
}
let mut removed = Vec::new();
for (link, _) in obsolete {
if fs::remove_file(&link).is_ok() {
if let Some(parent) = link.parent() {
self.remove_empty_parents(parent);
}
removed.push(link);
}
}
(removed.len(), removed)
}
/// Remove obsolete links, then create missing ones.
/// Returns `(created, create_failed, removed, remove_failed)`.
pub fn sync_structure(
&mut self,
files: &[File],
spec: &TreeSpec,
reserved: &HashSet<String>,
dry_run: bool,
) -> (usize, usize, usize, usize) {
let obsolete_count = self.find_obsolete_links(files, spec, reserved).len();
let (removed, _) = self.remove_obsolete_links(files, spec, reserved, dry_run);
let remove_failed = if dry_run { 0 } else { obsolete_count - removed };
let (created, failed) = self.create_structure_for_files(files, spec, dry_run);
(created, failed, removed, remove_failed)
}
}
/// `stem_N.ext` with the first free N.
pub fn unique_name(path: &Path) -> PathBuf {
let (stem, suffix) = (paths::stem(path), paths::suffix(path));
let parent = path.parent().unwrap_or(Path::new("."));
(1..)
.map(|n| parent.join(format!("{stem}_{n}{suffix}")))
.find(|p| !p.exists())
.unwrap_or_else(|| path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::tag::Tag;
struct Env {
_dir: tempfile::TempDir,
src: PathBuf,
out: PathBuf,
}
fn env() -> Env {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("pool");
let out = dir.path().join("out");
fs::create_dir_all(&src).unwrap();
Env {
_dir: dir,
src,
out,
}
}
fn movie(dir: &Path, name: &str, tags: &[(&str, &str)]) -> File {
let p = dir.join(name);
fs::write(&p, name).unwrap();
let mut f = File::bare(&p);
f.tags = tags.iter().map(|(c, n)| Tag::new(*c, *n)).collect();
f
}
fn roots(pairs: &[(&str, &str)]) -> TreeSpec {
TreeSpec::with_roots(
pairs
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect(),
)
}
#[test]
fn test_render_template() {
let ctx: BTreeMap<String, String> =
[("title", "Matrix"), ("year", "1999"), ("ext", ".mkv")]
.into_iter()
.map(|(a, b)| (a.into(), b.into()))
.collect();
assert_eq!(
render_template("{year} - {title}{ext}", &ctx).as_deref(),
Some("1999 - Matrix.mkv")
);
assert_eq!(
render_template("{missing}{title}", &ctx).as_deref(),
Some("Matrix")
);
assert_eq!(
render_template("{{x}} {title:>10}", &ctx).as_deref(),
Some("{x} Matrix")
);
assert_eq!(render_template("{title", &ctx), None);
assert_eq!(render_template("title}", &ctx), None);
}
#[test]
fn test_create_structure_basic_and_same_inode() {
let e = env();
let files = vec![movie(
&e.src,
"Film.mkv",
&[("Žánr", "Komedie"), ("Žánr", "Akční"), ("Rok", "1988")],
)];
let mut m = HardlinkManager::new(&e.out);
let (ok, fail) = m.create_structure_for_files(&files, &TreeSpec::default(), false);
assert_eq!((ok, fail), (3, 0));
let link = e.out.join("Žánr/Komedie/Film.mkv");
assert!(is_same_file(&link, &files[0].file_path));
assert!(e.out.join("Rok/1988/Film.mkv").exists());
// re-run creates nothing new
assert_eq!(
m.create_structure_for_files(&files, &TreeSpec::default(), false),
(0, 0)
);
}
#[test]
fn test_category_filter_and_roots() {
let e = env();
let files = vec![movie(
&e.src,
"Film.mkv",
&[("Žánr", "Komedie"), ("Rok", "1988")],
)];
let mut m = HardlinkManager::new(&e.out);
m.create_structure_for_files(
&files,
&roots(&[("Žánr", ""), ("Rok", "- Dle roku")]),
false,
);
assert!(e.out.join("Komedie/Film.mkv").exists());
assert!(e.out.join("- Dle roku/1988/Film.mkv").exists());
let e2 = env();
let mut m2 = HardlinkManager::new(&e2.out);
let files2 = vec![movie(
&e2.src,
"Film.mkv",
&[("Žánr", "Komedie"), ("Rok", "1988")],
)];
m2.create_structure_for_files(&files2, &TreeSpec::categories(&["Rok"]), false);
assert!(!e2.out.join("Žánr").exists());
assert!(e2.out.join("Rok/1988/Film.mkv").exists());
}
#[test]
fn test_link_name_strips_year_and_disambiguates() {
let e = env();
let mut a = movie(
&e.src,
"Matrix (1999) [tmdbid-603].mkv",
&[("Žánr", "Akční")],
);
a.title = Some("Matrix".into());
let mut b = movie(&e.src, "Matrix (2021).mkv", &[("Žánr", "Akční")]);
b.title = Some("Matrix".into());
let c = movie(&e.src, "Jiný (2000).mkv", &[("Žánr", "Akční")]);
let mut m = HardlinkManager::new(&e.out);
m.create_structure_for_files(&[a, b, c], &roots(&[("Žánr", "")]), false);
assert!(e.out.join("Akční/Matrix (1999).mkv").exists());
assert!(e.out.join("Akční/Matrix (2021).mkv").exists());
assert!(e.out.join("Akční/Jiný.mkv").exists());
}
#[test]
fn test_transform_and_template() {
let e = env();
let mut f = movie(
&e.src,
"Matrix (1999).mkv",
&[("Hodnocení", "87"), ("Rok", "1999")],
);
f.title = Some("Matrix".into());
let mut spec = roots(&[("Hodnocení", "- Dle hodnocení"), ("Rok", "- Dle roku")]);
spec.transforms
.insert("Hodnocení".into(), "decade_band".into());
spec.templates
.insert("Rok".into(), "{year} - {title}{ext}".into());
let files = vec![f];
let mut m = HardlinkManager::new(&e.out);
m.create_structure_for_files(&files, &spec, false);
assert!(e.out.join("- Dle hodnocení/80–89 %/Matrix.mkv").exists());
assert!(e.out.join("- Dle roku/1999/1999 - Matrix.mkv").exists());
assert!(
m.find_obsolete_links(&files, &spec, &HashSet::new())
.is_empty()
);
}
#[test]
fn test_sync_removes_dropped_tag_and_empty_root_genre() {
let e = env();
let mut files = vec![movie(
&e.src,
"Film.mkv",
&[("Žánr", "Komedie"), ("Žánr", "Drama")],
)];
let spec = roots(&[("Žánr", "")]);
let mut m = HardlinkManager::new(&e.out);
m.sync_structure(&files, &spec, &HashSet::new(), false);
assert!(e.out.join("Drama/Film.mkv").exists());
files[0].tags.retain(|t| t.name != "Drama");
let (created, failed, removed, rfail) =
m.sync_structure(&files, &spec, &HashSet::new(), false);
assert_eq!((created, failed, removed, rfail), (0, 0, 1, 0));
assert!(!e.out.join("Drama").exists());
assert!(e.out.join("Komedie/Film.mkv").exists());
}
#[test]
fn test_sync_leaves_reserved_mirror_untouched() {
let e = env();
let files = vec![movie(&e.src, "Film.mkv", &[("Žánr", "Komedie")])];
fs::create_dir_all(e.out.join("Seriály/X")).unwrap();
fs::write(e.out.join("Seriály/X/ep.mkv"), "x").unwrap();
let reserved: HashSet<String> = ["Seriály".to_string()].into_iter().collect();
let mut m = HardlinkManager::new(&e.out);
m.sync_structure(&files, &roots(&[("Žánr", "")]), &reserved, false);
assert!(e.out.join("Seriály/X/ep.mkv").exists());
}
#[test]
fn test_sync_replaces_orphan_squatting_expected_name() {
let e = env();
let files = vec![movie(&e.src, "Film.mkv", &[("Žánr", "Komedie")])];
fs::create_dir_all(e.out.join("Komedie")).unwrap();
fs::write(e.out.join("Komedie/Film.mkv"), "old copy").unwrap();
let mut m = HardlinkManager::new(&e.out);
let spec = roots(&[("Žánr", "")]);
m.sync_structure(&files, &spec, &HashSet::new(), false);
assert!(is_same_file(
&e.out.join("Komedie/Film.mkv"),
&files[0].file_path
));
}
#[test]
fn test_dry_run_creates_nothing_and_preview_lists() {
let e = env();
let files = vec![movie(&e.src, "Film.mkv", &[("Rok", "1988")])];
let mut m = HardlinkManager::new(&e.out);
assert_eq!(
m.create_structure_for_files(&files, &TreeSpec::default(), true),
(1, 0)
);
assert!(!e.out.exists());
assert_eq!(
m.preview(&files, &TreeSpec::default()),
vec![(files[0].file_path.clone(), e.out.join("Rok/1988/Film.mkv"))]
);
}
#[test]
fn test_remove_created_links_cleans_dirs() {
let e = env();
let files = vec![movie(&e.src, "Film.mkv", &[("Rok", "1988")])];
let mut m = HardlinkManager::new(&e.out);
m.create_structure_for_files(&files, &TreeSpec::default(), false);
assert_eq!(m.remove_created_links(), 1);
assert!(!e.out.join("Rok").exists());
}
#[test]
fn test_random_tips_and_recent() {
let e = env();
let mut files: Vec<File> = (0..5)
.map(|i| movie(&e.src, &format!("F{i}.mkv"), &[]))
.collect();
for (i, f) in files.iter_mut().enumerate() {
f.added = Some(format!("2026-01-0{}T00:00:00", i + 1));
}
let mut m = HardlinkManager::new(&e.out);
assert_eq!(m.generate_random_tips(&files, 3, "Tipy dne", false), (3, 0));
assert_eq!(paths::entries(&e.out.join("Tipy dne")).len(), 3);
assert_eq!(
m.generate_random_tips(&files, 10, "Tipy dne", false),
(5, 0)
);
m.generate_recently_added(&files, 2, "- Nově přidané", false);
let names: Vec<String> = paths::entries(&e.out.join("- Nově přidané"))
.iter()
.map(|p| paths::name(p))
.collect();
assert_eq!(names, vec!["F3.mkv", "F4.mkv"]);
}
#[test]
fn test_mirror_as_is_and_remove_mirror() {
let e = env();
let ser = e.src.join("Seriály/Show/Season 01");
fs::create_dir_all(&ser).unwrap();
fs::write(ser.join("S01E01.mkv"), "x").unwrap();
fs::write(e.src.join("Seriály/.Curator.!index"), "{}").unwrap();
let mut m = HardlinkManager::new(&e.out);
assert_eq!(
m.mirror_as_is(&e.src.join("Seriály"), Some("Seriály"), false),
(1, 0)
);
assert!(e.out.join("Seriály/Show/Season 01/S01E01.mkv").exists());
assert!(!e.out.join("Seriály/.Curator.!index").exists());
assert_eq!(m.mirror_as_is(&e.src.join("nope"), None, false), (0, 0));
assert!(m.remove_mirror("Seriály/Show"));
assert!(e.out.join("Seriály").exists());
assert!(!m.remove_mirror("Seriály/Show"));
}
#[test]
fn test_unique_name() {
let e = env();
let p = e.src.join("a.mkv");
fs::write(&p, "").unwrap();
fs::write(e.src.join("a_1.mkv"), "").unwrap();
assert_eq!(unique_name(&p), e.src.join("a_2.mkv"));
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Shared blocking HTTP client for the remote services.
use std::sync::LazyLock;
use std::time::Duration;
use reqwest::blocking::Client;
/// A browser-like user agent (ČSFD rejects obvious bots).
pub const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
static CLIENT: LazyLock<Client> = LazyLock::new(|| {
Client::builder()
.user_agent(USER_AGENT)
.cookie_store(true)
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new())
});
/// The process-wide client (connection pooling, the OS certificate store).
pub fn client() -> &'static Client {
&CLIENT
}
/// A fresh client with its own cookie jar (ČSFD's anti-bot cookie is per session).
pub fn new_session() -> Client {
Client::builder()
.user_agent(USER_AGENT)
.cookie_store(true)
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new())
}
/// An environment variable trimmed, `None` when unset or blank.
pub fn env_nonempty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
-127
View File
@@ -1,127 +0,0 @@
"""Video integrity checking via FFmpeg.
Detects corrupted or unreadable video files. Two depths:
- **deep** (default): a full ``ffmpeg`` decode to the null muxer
(``-f null``). It actually decodes every frame, so it catches mid-file data
corruption (broken GOPs, truncated streams, bad packets). Thorough but slow —
it reads and decodes the whole file.
- **quick**: an ``ffprobe`` container/stream probe. Fast but shallow — it catches
an unreadable container, a truncated header or a missing video stream, but not
corruption buried inside otherwise-parseable data.
Both tools ship with FFmpeg; ``ffmpeg_available`` reports whether they are on the
PATH so callers can degrade gracefully.
"""
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
FFMPEG = "ffmpeg"
FFPROBE = "ffprobe"
# Cap a single stderr excerpt so a chatty decoder can't flood the report.
_MAX_ERROR_LEN = 300
@dataclass
class IntegrityResult:
"""Outcome of checking one file.
``ok`` — the file decoded/probed cleanly.
``checked`` — the check actually ran; False means it couldn't (missing tool,
missing file), which is *not* the same as "corrupted".
``error`` — a short reason when not ``ok``.
"""
path: Path
ok: bool
error: str = ""
checked: bool = True
def ffmpeg_available(deep: bool = True) -> bool:
"""Is the required FFmpeg tool (ffmpeg for deep, ffprobe for quick) on PATH?"""
return shutil.which(FFMPEG if deep else FFPROBE) is not None
def check_video_integrity(
path: Path, deep: bool = True, timeout: int | None = None
) -> IntegrityResult:
"""Check a single video file for corruption.
Args:
path: the video file to check.
deep: True → full ffmpeg decode (catches data corruption, slow);
False → ffprobe probe (catches unreadable/truncated files, fast).
timeout: per-file timeout in seconds (None = no limit).
Returns:
An :class:`IntegrityResult`. ``checked=False`` when the check could not
be run at all (tool or file missing) — distinct from a real failure.
"""
path = Path(path)
if not path.is_file():
return IntegrityResult(path, ok=False, error="Soubor neexistuje", checked=False)
tool = FFMPEG if deep else FFPROBE
if shutil.which(tool) is None:
return IntegrityResult(
path, ok=False, error=f"{tool} není k dispozici", checked=False
)
if deep:
cmd = [FFMPEG, "-v", "error", "-xerror", "-i", str(path), "-f", "null", "-"]
else:
cmd = [
FFPROBE,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_type",
"-of",
"csv=p=0",
str(path),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
errors="replace",
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return IntegrityResult(path, ok=False, error="Kontrola vypršela (timeout)")
except OSError as exc:
return IntegrityResult(path, ok=False, error=str(exc), checked=False)
stderr = (result.stderr or "").strip()
if deep:
ok = result.returncode == 0 and not stderr
else:
# Quick probe: a healthy file returns rc 0 and a "video" codec_type row.
ok = result.returncode == 0 and "video" in (result.stdout or "")
if not ok and not stderr:
stderr = "Žádný video stream / nečitelný kontejner"
if ok:
return IntegrityResult(path, ok=True)
return IntegrityResult(
path, ok=False, error=_first_error_line(stderr) or "Neznámá chyba dekódování"
)
def _first_error_line(stderr: str) -> str:
"""First non-empty stderr line, truncated — a compact reason for the report."""
for line in stderr.splitlines():
line = line.strip()
if line:
return line[:_MAX_ERROR_LEN]
return ""
+240
View File
@@ -0,0 +1,240 @@
//! Video integrity checking via FFmpeg.
//!
//! - **deep** (default): a full `ffmpeg` decode to the null muxer. Catches
//! mid-file data corruption, but reads and decodes the whole file.
//! - **quick**: an `ffprobe` container/stream probe. Fast but shallow.
//!
//! [`ffmpeg_available`] reports whether the tools are on the PATH so callers can
//! degrade gracefully.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
pub const FFMPEG: &str = "ffmpeg";
pub const FFPROBE: &str = "ffprobe";
/// Cap a single stderr excerpt so a chatty decoder can't flood the report.
const MAX_ERROR_LEN: usize = 300;
/// Outcome of checking one file. `checked == false` means the check couldn't
/// run (missing tool / file), which is *not* the same as "corrupted".
#[derive(Debug, Clone, PartialEq)]
pub struct IntegrityResult {
pub path: PathBuf,
pub ok: bool,
pub error: String,
pub checked: bool,
}
impl IntegrityResult {
fn ok(path: &Path) -> Self {
Self {
path: path.into(),
ok: true,
error: String::new(),
checked: true,
}
}
fn failed(path: &Path, error: impl Into<String>, checked: bool) -> Self {
Self {
path: path.into(),
ok: false,
error: error.into(),
checked,
}
}
}
/// Locate an executable on the PATH.
pub fn which(tool: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
let exe_names: Vec<String> = if cfg!(windows) {
vec![format!("{tool}.exe"), tool.to_string()]
} else {
vec![tool.to_string()]
};
std::env::split_paths(&path_var)
.flat_map(|dir| exe_names.iter().map(move |n| dir.join(n)))
.find(|p| p.is_file())
}
/// Is the required tool (ffmpeg for deep, ffprobe for quick) on PATH?
pub fn ffmpeg_available(deep: bool) -> bool {
which(if deep { FFMPEG } else { FFPROBE }).is_some()
}
/// Output of a finished process; `None` stdout/stderr on timeout.
pub(crate) struct ProcOutput {
pub status_ok: bool,
pub stdout: String,
pub stderr: String,
}
/// Run `cmd` capturing stdout/stderr, killing it after `timeout`.
pub(crate) fn run_with_timeout(
mut cmd: Command,
timeout: Option<Duration>,
) -> std::io::Result<Option<ProcOutput>> {
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut out_pipe = child.stdout.take();
let mut err_pipe = child.stderr.take();
let out_t = thread::spawn(move || {
let mut buf = Vec::new();
if let Some(p) = out_pipe.as_mut() {
let _ = p.read_to_end(&mut buf);
}
buf
});
let err_t = thread::spawn(move || {
let mut buf = Vec::new();
if let Some(p) = err_pipe.as_mut() {
let _ = p.read_to_end(&mut buf);
}
buf
});
let start = Instant::now();
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if timeout.is_some_and(|t| start.elapsed() >= t) {
let _ = child.kill();
let _ = child.wait();
return Ok(None);
}
thread::sleep(Duration::from_millis(50));
};
let stdout = String::from_utf8_lossy(&out_t.join().unwrap_or_default()).into_owned();
let stderr = String::from_utf8_lossy(&err_t.join().unwrap_or_default()).into_owned();
Ok(Some(ProcOutput {
status_ok: status.success(),
stdout,
stderr,
}))
}
/// Check a single video file for corruption.
pub fn check_video_integrity(
path: &Path,
deep: bool,
timeout: Option<Duration>,
) -> IntegrityResult {
if !path.is_file() {
return IntegrityResult::failed(path, "Soubor neexistuje", false);
}
let tool = if deep { FFMPEG } else { FFPROBE };
let Some(exe) = which(tool) else {
return IntegrityResult::failed(path, format!("{tool} není k dispozici"), false);
};
let mut cmd = Command::new(exe);
if deep {
cmd.args(["-v", "error", "-xerror", "-i"])
.arg(path)
.args(["-f", "null", "-"]);
} else {
cmd.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_type",
"-of",
"csv=p=0",
])
.arg(path);
}
let out = match run_with_timeout(cmd, timeout) {
Ok(Some(out)) => out,
Ok(None) => return IntegrityResult::failed(path, "Kontrola vypršela (timeout)", true),
Err(e) => return IntegrityResult::failed(path, e.to_string(), false),
};
let mut stderr = out.stderr.trim().to_string();
let ok = if deep {
out.status_ok && stderr.is_empty()
} else {
let ok = out.status_ok && out.stdout.contains("video");
if !ok && stderr.is_empty() {
stderr = "Žádný video stream / nečitelný kontejner".into();
}
ok
};
if ok {
return IntegrityResult::ok(path);
}
let reason = first_error_line(&stderr);
IntegrityResult::failed(
path,
if reason.is_empty() {
"Neznámá chyba dekódování".into()
} else {
reason
},
true,
)
}
/// First non-empty stderr line, truncated.
fn first_error_line(stderr: &str) -> String {
stderr
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(|l| l.chars().take(MAX_ERROR_LEN).collect())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_missing_file_is_not_checked() {
let r = check_video_integrity(Path::new("/nonexistent/x.mkv"), true, None);
assert!(!r.ok);
assert!(!r.checked);
assert_eq!(r.error, "Soubor neexistuje");
}
#[test]
fn test_first_error_line_truncates() {
assert_eq!(first_error_line("\n\n boom \nsecond"), "boom");
assert_eq!(first_error_line(&"x".repeat(500)).len(), MAX_ERROR_LEN);
assert_eq!(first_error_line(""), "");
}
#[test]
fn test_garbage_file_fails_when_tool_present() {
if !ffmpeg_available(false) {
return;
}
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("bad.mkv");
std::fs::write(&f, "not a video").unwrap();
let r = check_video_integrity(&f, false, Some(Duration::from_secs(30)));
assert!(!r.ok);
assert!(r.checked);
}
#[test]
fn test_run_with_timeout_kills_slow_process() {
if which("sleep").is_none() {
return;
}
let mut cmd = Command::new("sleep");
cmd.arg("5");
assert!(
run_with_timeout(cmd, Some(Duration::from_millis(100)))
.unwrap()
.is_none()
);
}
}
-118
View File
@@ -1,118 +0,0 @@
"""Jellyfin server API client — thin, hybrid integration.
The NFO + artwork on disk stay authoritative (Jellyfin writes and reads them);
this client only does what the disk can't: **trigger a library scan** and **read
back what Jellyfin currently sees** (to verify the pool against it). Connection
details come from ``.env`` — ``JELLYFIN_URL`` and ``JELLYFIN_API_KEY`` (created in
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
"""
import contextlib
import os
from dataclasses import dataclass
import requests
with contextlib.suppress(
Exception
): # the values can also come from the real environment
from dotenv import load_dotenv
load_dotenv()
_TIMEOUT = 30
@dataclass
class JellyfinMovie:
"""A movie as Jellyfin currently sees it (for verification)."""
jellyfin_id: str
title: str
year: int | None
tmdb_id: int | None
path: str | None
class JellyfinError(RuntimeError):
"""A Jellyfin request could not be completed (no config, network, status)."""
def base_url() -> str | None:
"""The configured Jellyfin base URL (no trailing slash), or None."""
url = os.getenv("JELLYFIN_URL")
return url.strip().rstrip("/") if url and url.strip() else None
def api_key() -> str | None:
"""The configured Jellyfin API key, or None."""
key = os.getenv("JELLYFIN_API_KEY")
return key.strip() if key and key.strip() else None
def api_available() -> bool:
"""True when both the Jellyfin URL and API key are configured."""
return bool(base_url() and api_key())
def _request(
method: str, path: str, params: dict | None = None, timeout: int | None = None
) -> requests.Response:
url, key = base_url(), api_key()
if not (url and key):
raise JellyfinError("JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).")
try:
resp = requests.request(
method,
f"{url}{path}",
headers={"X-Emby-Token": key},
params=params,
timeout=timeout or _TIMEOUT,
)
except requests.RequestException as exc:
raise JellyfinError(f"Jellyfin nedostupný: {exc}") from exc
if resp.status_code in (401, 403):
raise JellyfinError("Neplatný JELLYFIN_API_KEY.")
if not resp.ok:
raise JellyfinError(f"Jellyfin odpověděl HTTP {resp.status_code}.")
return resp
def server_version(timeout: int | None = None) -> str:
"""Return the Jellyfin server version (a quick connectivity check)."""
data = _request("GET", "/System/Info", timeout=timeout).json()
return str(data.get("Version") or "?")
def scan_all() -> None:
"""Trigger a scan of all Jellyfin libraries (picks up on-disk changes)."""
_request("POST", "/Library/Refresh")
def fetch_movies() -> list[JellyfinMovie]:
"""Read every movie Jellyfin currently has, with its TMDb id and path."""
data = _request(
"GET",
"/Items",
params={
"IncludeItemTypes": "Movie",
"Recursive": "true",
"Fields": "ProviderIds,Path,ProductionYear",
"EnableTotalRecordCount": "false",
},
).json()
movies: list[JellyfinMovie] = []
for item in data.get("Items", []):
provider = item.get("ProviderIds") or {}
raw_tmdb = provider.get("Tmdb") or provider.get("tmdb")
tmdb_id = int(raw_tmdb) if raw_tmdb and str(raw_tmdb).isdigit() else None
movies.append(
JellyfinMovie(
jellyfin_id=str(item.get("Id")),
title=item.get("Name") or "",
year=item.get("ProductionYear"),
tmdb_id=tmdb_id,
path=item.get("Path"),
)
)
return movies
+165
View File
@@ -0,0 +1,165 @@
//! Jellyfin server API client — thin, hybrid integration.
//!
//! The NFO + artwork on disk stay authoritative; this client only does what the
//! disk can't: **trigger a library scan** and **read back what Jellyfin
//! currently sees**. Connection details come from `.env` — `JELLYFIN_URL` and
//! `JELLYFIN_API_KEY`.
use std::time::Duration;
use reqwest::Method;
use serde_json::Value;
use super::error::CoreError;
use super::http::{client, env_nonempty};
const TIMEOUT: Duration = Duration::from_secs(30);
/// A movie as Jellyfin currently sees it.
#[derive(Debug, Clone, PartialEq)]
pub struct JellyfinMovie {
pub jellyfin_id: String,
pub title: String,
pub year: Option<i64>,
pub tmdb_id: Option<i64>,
pub path: Option<String>,
}
fn err(msg: impl Into<String>) -> CoreError {
CoreError::Remote(msg.into())
}
/// The configured base URL (no trailing slash).
pub fn base_url() -> Option<String> {
env_nonempty("JELLYFIN_URL").map(|u| u.trim_end_matches('/').to_string())
}
pub fn api_key() -> Option<String> {
env_nonempty("JELLYFIN_API_KEY")
}
/// True when both the URL and the API key are configured.
pub fn api_available() -> bool {
base_url().is_some() && api_key().is_some()
}
fn request(
method: Method,
path: &str,
params: &[(&str, &str)],
timeout: Option<Duration>,
) -> Result<Value, CoreError> {
let (Some(url), Some(key)) = (base_url(), api_key()) else {
return Err(err(
"JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).",
));
};
let resp = client()
.request(method, format!("{url}{path}"))
.header("X-Emby-Token", key)
.query(params)
.timeout(timeout.unwrap_or(TIMEOUT))
.send()
.map_err(|e| err(format!("Jellyfin nedostupný: {e}")))?;
let status = resp.status();
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(err("Neplatný JELLYFIN_API_KEY."));
}
if !status.is_success() {
return Err(err(format!("Jellyfin odpověděl HTTP {}.", status.as_u16())));
}
let text = resp
.text()
.map_err(|e| err(format!("Jellyfin nedostupný: {e}")))?;
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
}
/// The server version (a quick connectivity check).
pub fn server_version(timeout: Option<Duration>) -> Result<String, CoreError> {
let data = request(Method::GET, "/System/Info", &[], timeout)?;
Ok(data
.get("Version")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("?")
.to_string())
}
/// Trigger a scan of all Jellyfin libraries.
pub fn scan_all() -> Result<(), CoreError> {
request(Method::POST, "/Library/Refresh", &[], None).map(|_| ())
}
/// Parse one `/Items` entry.
pub fn parse_movie(item: &Value) -> JellyfinMovie {
let provider = item.get("ProviderIds");
let raw_tmdb = provider.and_then(|p| p.get("Tmdb").or_else(|| p.get("tmdb")));
let tmdb_id = raw_tmdb.and_then(|v| match v {
Value::String(s) if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) => {
s.parse().ok()
}
Value::Number(n) => n.as_i64(),
_ => None,
});
JellyfinMovie {
jellyfin_id: item
.get("Id")
.map(|v| v.as_str().map_or_else(|| v.to_string(), String::from))
.unwrap_or_else(|| "None".into()),
title: item
.get("Name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
year: item.get("ProductionYear").and_then(Value::as_i64),
tmdb_id,
path: item.get("Path").and_then(Value::as_str).map(String::from),
}
}
/// Every movie Jellyfin currently has, with its TMDb id and path.
pub fn fetch_movies() -> Result<Vec<JellyfinMovie>, CoreError> {
let data = request(
Method::GET,
"/Items",
&[
("IncludeItemTypes", "Movie"),
("Recursive", "true"),
("Fields", "ProviderIds,Path,ProductionYear"),
("EnableTotalRecordCount", "false"),
],
None,
)?;
Ok(data
.get("Items")
.and_then(Value::as_array)
.map(|a| a.iter().map(parse_movie).collect())
.unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_movie() {
let m = parse_movie(
&json!({"Id": "abc", "Name": "Matrix", "ProductionYear": 1999,
"ProviderIds": {"Tmdb": "603"}, "Path": "/pool/Filmy/Matrix.mkv"}),
);
assert_eq!(
m,
JellyfinMovie {
jellyfin_id: "abc".into(),
title: "Matrix".into(),
year: Some(1999),
tmdb_id: Some(603),
path: Some("/pool/Filmy/Matrix.mkv".into())
}
);
let bare = parse_movie(&json!({"Id": "x", "ProviderIds": {"Tmdb": "tt1"}}));
assert_eq!(bare.tmdb_id, None);
assert_eq!(bare.title, "");
}
}
-110
View File
@@ -1,110 +0,0 @@
"""Readers for Curator's *pre-index* metadata layouts.
Before the unified pool index (`.Curator.!index`), Tagger / early Curator stored
a movie's metadata in a **per-file sidecar** ``.{filename}.!tag`` (JSON) sitting
next to the video. When importing a file that came from such a pool we can
recover its **ČSFD link** (and title / year) from that sidecar — or from an old
``.Curator.!index`` found by walking up from the file.
This is read-only recovery for the import dialog; nothing here writes.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from .pool_index import INDEX_FILENAME
SIDECAR_SUFFIX = ".!tag"
_MAX_INDEX_WALK_UP = 4
@dataclass
class LegacyMeta:
"""What an old sidecar / index record can still tell us about a movie."""
csfd_url: str | None = None
title: str | None = None
year: str | None = None # four-digit string
source: str = "" # "sidecar" | "index"
def _record_to_meta(record: object, source: str) -> LegacyMeta | None:
"""Pull the useful fields out of a stored movie record (any schema version)."""
if not isinstance(record, dict):
return None
csfd = record.get("csfd_url") or record.get("csfd_link") # new | Tagger-era key
title = record.get("title") or None
meta_raw = record.get("meta")
meta = meta_raw if isinstance(meta_raw, dict) else {}
cache_raw = record.get("csfd_cache")
cache = cache_raw if isinstance(cache_raw, dict) else {}
raw_year = meta.get("year") or cache.get("year")
year = (
str(raw_year).strip()
if raw_year is not None and str(raw_year).strip().isdigit()
else None
)
if not (csfd or title or year):
return None
return LegacyMeta(csfd_url=csfd or None, title=title, year=year, source=source)
def _from_sidecar(video_path: Path) -> LegacyMeta | None:
sidecar = video_path.parent / f".{video_path.name}{SIDECAR_SUFFIX}"
if not sidecar.is_file():
return None
try:
record = json.loads(sidecar.read_text(encoding="utf-8"))
except json.JSONDecodeError, OSError, ValueError:
return None
return _record_to_meta(record, "sidecar")
def _lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> dict | None:
"""Find the record for ``video_path`` in an index's ``movies`` map.
Tries the exact pool-relative key first, then falls back to a unique
basename match (the file may have moved since the index was written).
"""
try:
rel = video_path.relative_to(index_dir).as_posix()
if rel in movies:
return movies[rel]
except ValueError:
pass
name = video_path.name
matches = [v for k, v in movies.items() if PurePosixPath(k).name == name]
return matches[0] if len(matches) == 1 else None
def _from_legacy_index(video_path: Path) -> LegacyMeta | None:
directory = video_path.parent
for _ in range(_MAX_INDEX_WALK_UP + 1):
index_path = directory / INDEX_FILENAME
if index_path.is_file():
try:
data = json.loads(index_path.read_text(encoding="utf-8"))
except json.JSONDecodeError, OSError, ValueError:
return None
movies = data.get("movies", data) if isinstance(data, dict) else None
if not isinstance(movies, dict):
return None
record = _lookup_in_index(movies, video_path, directory)
return _record_to_meta(record, "index") if record is not None else None
if directory.parent == directory:
break
directory = directory.parent
return None
def read_legacy_meta(video_path: Path) -> LegacyMeta | None:
"""Recover ČSFD link / title / year from a pre-index layout, or ``None``.
Order: a per-file ``.{name}.!tag`` sidecar next to the video, then a
``.Curator.!index`` found walking up (max 4 levels) from the file.
"""
video_path = Path(video_path)
return _from_sidecar(video_path) or _from_legacy_index(video_path)
+210
View File
@@ -0,0 +1,210 @@
//! Readers for Curator's *pre-index* metadata layouts.
//!
//! Before the unified pool index, Tagger / early Curator stored a movie's
//! metadata in a per-file sidecar `.{filename}.!tag` (JSON) next to the video.
//! When importing a file from such a pool we can recover its ČSFD link (and
//! title / year) from that sidecar — or from an old `.Curator.!index` found by
//! walking up from the file. Read-only recovery for the import dialog.
use std::fs;
use std::path::Path;
use serde_json::Value;
use super::paths;
use super::pool_index::INDEX_FILENAME;
pub const SIDECAR_SUFFIX: &str = ".!tag";
const MAX_INDEX_WALK_UP: usize = 4;
/// What an old sidecar / index record can still tell us about a movie.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LegacyMeta {
pub csfd_url: Option<String>,
pub title: Option<String>,
pub year: Option<String>,
/// "sidecar" | "index".
pub source: String,
}
fn nonempty_str(v: Option<&Value>) -> Option<String> {
v.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from)
}
/// Pull the useful fields out of a stored movie record (any schema version).
fn record_to_meta(record: &Value, source: &str) -> Option<LegacyMeta> {
let rec = record.as_object()?;
let csfd = nonempty_str(rec.get("csfd_url")).or_else(|| nonempty_str(rec.get("csfd_link")));
let title = nonempty_str(rec.get("title"));
let from = |key: &str| {
rec.get(key)
.and_then(Value::as_object)
.and_then(|o| o.get("year"))
.cloned()
};
let raw_year = from("meta")
.filter(super::pool_index::truthy)
.or_else(|| from("csfd_cache"))
.filter(|v| !v.is_null());
let year = raw_year
.map(|v| {
v.as_str()
.map_or_else(|| v.to_string(), String::from)
.trim()
.to_string()
})
.filter(|y| !y.is_empty() && y.chars().all(|c| c.is_ascii_digit()));
if csfd.is_none() && title.is_none() && year.is_none() {
return None;
}
Some(LegacyMeta {
csfd_url: csfd,
title,
year,
source: source.into(),
})
}
fn read_json(path: &Path) -> Option<Value> {
serde_json::from_str(&fs::read_to_string(path).ok()?).ok()
}
fn from_sidecar(video_path: &Path) -> Option<LegacyMeta> {
let sidecar = video_path
.parent()?
.join(format!(".{}{SIDECAR_SUFFIX}", paths::name(video_path)));
if !sidecar.is_file() {
return None;
}
record_to_meta(&read_json(&sidecar)?, "sidecar")
}
/// The record for `video_path` in an index's `movies` map — exact pool-relative
/// key first, then a unique basename match.
fn lookup_in_index<'a>(
movies: &'a serde_json::Map<String, Value>,
video_path: &Path,
index_dir: &Path,
) -> Option<&'a Value> {
if video_path.starts_with(index_dir)
&& let Some(rec) = movies.get(&paths::posix_relative(video_path, index_dir))
{
return Some(rec);
}
let name = paths::name(video_path);
let matches: Vec<&Value> = movies
.iter()
.filter(|(k, _)| k.rsplit('/').next() == Some(name.as_str()))
.map(|(_, v)| v)
.collect();
(matches.len() == 1).then(|| matches[0])
}
fn from_legacy_index(video_path: &Path) -> Option<LegacyMeta> {
let mut directory = video_path.parent()?.to_path_buf();
for _ in 0..=MAX_INDEX_WALK_UP {
let index_path = directory.join(INDEX_FILENAME);
if index_path.is_file() {
let data = read_json(&index_path)?;
let obj = data.as_object()?;
let movies = match obj.get("movies") {
Some(m) => m.as_object()?,
None => obj,
};
return record_to_meta(lookup_in_index(movies, video_path, &directory)?, "index");
}
match directory.parent() {
Some(parent) if parent != directory => directory = parent.to_path_buf(),
_ => break,
}
}
None
}
/// Recover ČSFD link / title / year from a pre-index layout: a `.{name}.!tag`
/// sidecar next to the video, then a `.Curator.!index` up to 4 levels up.
pub fn read_legacy_meta(video_path: &Path) -> Option<LegacyMeta> {
from_sidecar(video_path).or_else(|| from_legacy_index(video_path))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn video(dir: &Path, rel: &str) -> std::path::PathBuf {
let p = dir.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, "x").unwrap();
p
}
#[test]
fn test_reads_tagger_era_sidecar() {
let dir = tempfile::tempdir().unwrap();
let v = video(dir.path(), "Matrix.mkv");
fs::write(dir.path().join(".Matrix.mkv.!tag"),
json!({"csfd_link": "https://www.csfd.cz/film/9499/", "title": "Matrix", "csfd_cache": {"year": 1999}}).to_string()).unwrap();
let meta = read_legacy_meta(&v).unwrap();
assert_eq!(
meta.csfd_url.as_deref(),
Some("https://www.csfd.cz/film/9499/")
);
assert_eq!(meta.year.as_deref(), Some("1999"));
assert_eq!(meta.source, "sidecar");
}
#[test]
fn test_reads_old_index_by_key_and_by_basename() {
let dir = tempfile::tempdir().unwrap();
let v = video(dir.path(), "Filmy/sub/Matrix.mkv");
fs::write(dir.path().join(INDEX_FILENAME),
json!({"movies": {"Filmy/sub/Matrix.mkv": {"csfd_url": "u1"}, "Old/Other.mkv": {"title": "Other"}}}).to_string()).unwrap();
assert_eq!(
read_legacy_meta(&v).unwrap().csfd_url.as_deref(),
Some("u1")
);
let moved = video(dir.path(), "Elsewhere/Other.mkv");
assert_eq!(
read_legacy_meta(&moved).unwrap().title.as_deref(),
Some("Other")
);
}
#[test]
fn test_sidecar_wins_over_index() {
let dir = tempfile::tempdir().unwrap();
let v = video(dir.path(), "Matrix.mkv");
fs::write(
dir.path().join(".Matrix.mkv.!tag"),
json!({"csfd_url": "side"}).to_string(),
)
.unwrap();
fs::write(
dir.path().join(INDEX_FILENAME),
json!({"movies": {"Matrix.mkv": {"csfd_url": "idx"}}}).to_string(),
)
.unwrap();
assert_eq!(
read_legacy_meta(&v).unwrap().csfd_url.as_deref(),
Some("side")
);
}
#[test]
fn test_none_cases() {
let dir = tempfile::tempdir().unwrap();
let v = video(dir.path(), "Matrix.mkv");
assert!(read_legacy_meta(&v).is_none());
fs::write(dir.path().join(".Matrix.mkv.!tag"), "{broken").unwrap();
assert!(read_legacy_meta(&v).is_none());
fs::write(
dir.path().join(".Matrix.mkv.!tag"),
json!({"tags": []}).to_string(),
)
.unwrap();
assert!(read_legacy_meta(&v).is_none());
}
}
-21
View File
@@ -1,21 +0,0 @@
from .file import File
class ListManager:
def __init__(self):
# 'name' nebo 'date'
self.sort_mode = "name"
def set_sort(self, mode: str):
if mode in ("name", "date"):
self.sort_mode = mode
def sort_files(self, files: list[File]) -> list[File]:
if self.sort_mode == "name":
return sorted(files, key=lambda f: f.filename.lower())
else:
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
def date_key(f):
return (f.date is None, f.date or "")
return sorted(files, key=date_key)
-62
View File
@@ -1,62 +0,0 @@
"""One-off migration of an existing pool to the Jellyfin naming convention.
Older pools carry files like ``Title.ext`` / ``Title (rok).ext`` with **no TMDb
id**. This matches each such movie against TMDb by title + year and proposes a
``tmdbid`` (and the missing year, which TMDb supplies), so the file can be renamed
to ``Title (rok) [tmdbid].ext`` and Jellyfin can match it deterministically.
The classification is deliberately **conservative**: only an exact
(diacritics-insensitive) title match with a matching year and a single candidate
is ``auto``; everything else is ``ambiguous`` for the user to confirm.
"""
import re
import unicodedata
from dataclasses import dataclass, field
@dataclass
class MatchProposal:
"""One movie's proposed TMDb match."""
file: object # core.file.File
current_name: str
title: str
year: str | None
status: str # "auto" | "ambiguous" | "no_match" | "error"
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
error: str = ""
note: str = "" # e.g. "přes ČSFD: The Shawshank Redemption (1994)"
def normalize(text: str) -> str:
"""Fold a title for comparison: strip diacritics/punctuation, lowercase."""
text = unicodedata.normalize("NFD", text or "")
text = "".join(c for c in text if not unicodedata.combining(c))
text = re.sub(r"[^0-9a-zA-Z ]+", " ", text.lower())
return re.sub(r"\s+", " ", text).strip()
def _year_close(a: str | None, b: str | None) -> bool:
if not (a and b and str(a).isdigit() and str(b).isdigit()):
return False
return abs(int(a) - int(b)) <= 1
def classify(title: str, year: str | None, candidates: list) -> tuple[str, object]:
"""Return ``(status, chosen)`` for a title/year against TMDb candidates.
``auto`` requires an exact normalized-title match, a year within ±1, and that
exactly one candidate qualifies. Otherwise ``ambiguous`` (top hit preselected)
or ``no_match`` when there are no candidates.
"""
if not candidates:
return "no_match", None
nt = normalize(title)
exact_year = [
c for c in candidates if normalize(c.title) == nt and _year_close(c.year, year)
]
if len(exact_year) == 1:
return "auto", exact_year[0]
return "ambiguous", candidates[0]
+134
View File
@@ -0,0 +1,134 @@
//! One-off migration of an existing pool to the Jellyfin naming convention.
//!
//! Older pools carry files like `Title.ext` / `Title (rok).ext` with **no TMDb
//! id**. This matches each against TMDb by title + year and proposes an id. The
//! classification is deliberately conservative: only an exact
//! (diacritics-insensitive) title match with a year within ±1 and a single
//! qualifying candidate is `auto`; everything else is for the user to confirm.
use std::sync::LazyLock;
use regex::Regex;
use super::movie::fold_diacritics;
use super::tmdb::TmdbResult;
static NON_ALNUM_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[^0-9a-zA-Z ]+").expect("valid regex"));
static SPACES_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").expect("valid regex"));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchStatus {
Auto,
Ambiguous,
NoMatch,
Error,
}
impl MatchStatus {
/// Czech label shown in the migration dialog.
pub fn label(self) -> &'static str {
match self {
Self::Auto => "jisté",
Self::Ambiguous => "nejednoznačné",
Self::NoMatch => "bez shody",
Self::Error => "chyba",
}
}
}
/// One movie's proposed TMDb match.
#[derive(Debug, Clone, PartialEq)]
pub struct MatchProposal {
/// Identity key of the movie in the pool index.
pub movie_key: String,
pub current_name: String,
pub title: String,
pub year: Option<String>,
pub status: MatchStatus,
/// Preselected candidate (index into `candidates`).
pub chosen: Option<usize>,
pub candidates: Vec<TmdbResult>,
pub error: String,
/// e.g. "přes ČSFD: The Shawshank Redemption (1994)".
pub note: String,
}
/// Fold a title for comparison: strip diacritics/punctuation, lowercase.
pub fn normalize(text: &str) -> String {
let folded = fold_diacritics(text).to_lowercase();
let cleaned = NON_ALNUM_RE.replace_all(&folded, " ");
SPACES_RE.replace_all(&cleaned, " ").trim().to_string()
}
fn year_close(a: Option<&str>, b: Option<&str>) -> bool {
match (
a.and_then(|s| s.parse::<i64>().ok()),
b.and_then(|s| s.parse::<i64>().ok()),
) {
(Some(x), Some(y)) => (x - y).abs() <= 1,
_ => false,
}
}
/// `(status, chosen index)` for a title/year against TMDb candidates.
pub fn classify(
title: &str,
year: Option<&str>,
candidates: &[TmdbResult],
) -> (MatchStatus, Option<usize>) {
if candidates.is_empty() {
return (MatchStatus::NoMatch, None);
}
let nt = normalize(title);
let exact: Vec<usize> = candidates
.iter()
.enumerate()
.filter(|(_, c)| normalize(&c.title) == nt && year_close(c.year.as_deref(), year))
.map(|(i, _)| i)
.collect();
if exact.len() == 1 {
(MatchStatus::Auto, Some(exact[0]))
} else {
(MatchStatus::Ambiguous, Some(0))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::tmdb::Kind;
fn cand(id: i64, title: &str, year: &str) -> TmdbResult {
TmdbResult {
tmdb_id: id,
title: title.into(),
year: Some(year.into()),
kind: Kind::Movie,
}
}
#[test]
fn test_normalize_folds() {
assert_eq!(normalize("Pelíšky! (Remaster)"), "pelisky remaster");
}
#[test]
fn test_classify_cases() {
assert_eq!(classify("X", None, &[]), (MatchStatus::NoMatch, None));
let c = [cand(1, "Pelíšky", "1999"), cand(2, "Pelisky 2", "2001")];
assert_eq!(
classify("Pelisky", Some("2000"), &c),
(MatchStatus::Auto, Some(0))
);
assert_eq!(
classify("Pelisky", Some("2005"), &c),
(MatchStatus::Ambiguous, Some(0))
);
let dup = [cand(1, "Matrix", "1999"), cand(2, "Matrix", "1999")];
assert_eq!(
classify("Matrix", Some("1999"), &dup).0,
MatchStatus::Ambiguous
);
}
}
+24
View File
@@ -0,0 +1,24 @@
//! Domain model and every operation on the pool. Never depends on the UI.
pub mod config;
pub mod csfd;
pub mod error;
pub mod file;
pub mod file_manager;
pub mod hardlink_manager;
pub mod http;
pub mod integrity;
pub mod jellyfin;
pub mod legacy_meta;
pub mod migrate;
pub mod movie;
pub mod naming;
pub mod nfo;
pub mod paths;
pub mod pool_index;
pub mod series;
pub mod subtitles;
pub mod tag;
pub mod tag_manager;
pub mod tmdb;
pub mod transforms;
-286
View File
@@ -1,286 +0,0 @@
"""The movie object — metadata first, files second.
A movie is identified by ``(title, year, tmdb_id)`` and owns **its files**, not
the other way round. That inversion is the point: the pool index used to key a
record by a file path, so metadata was a passenger on one particular file — and
everything about multi-version movies got awkward (which file "holds" the tags,
why renaming a grouped movie had to be blocked, why an import could silently
create a duplicate instead of a version).
Here the record is the movie; each playable file is a :class:`MediaFile` under
it, told apart by a free-text ``label`` (``3D HSBS``, ``4K``, ``Director's
Cut``, …). Where those files *live* is then a pure function of the record —
:meth:`Movie.desired_layout` — so promoting to a folder, demoting back, or
renaming the whole thing are all the same operation: recompute and move what
doesn't match.
Layout (Jellyfin's own conventions — https://jellyfin.org/docs/general/server/media/movies/)::
one video <Library>/Title (rok) [tmdbid-N].ext
two or more <Library>/Title (rok) [tmdbid-N]/
Title (rok) [tmdbid-N] - Výchozí.ext
Title (rok) [tmdbid-N].3D.HSBS.ext
Every file in a movie folder must start with the folder's exact name; both
``" - label"`` and ``".3D.FMT"`` are valid separators, so a 3D file keeps the
documented ``.3D.<FORMAT>`` spelling whether it sits flat or grouped.
Subtitles are not media entries — they ride along with their video by filename
stem (see :mod:`src.core.subtitles`).
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from .naming import THREE_D_FORMATS, canonical_pool_stem, is_valid_year
# Roles a media file can have. Only playable video for now — artwork stays
# Jellyfin's job (it writes poster/fanart into the folder itself).
ROLE_VIDEO = "video"
# A grouped movie needs a label on every file (all of Jellyfin's documented
# examples label every version); this is what an unlabelled one gets.
DEFAULT_VERSION_LABEL = "Výchozí"
# "HSBS" / "hsbs" / "3D HSBS" / "3d.hsbs" / "3D_HSBS" → "HSBS"
_LABEL_3D_RE = re.compile(
rf"^(?:3d[\s._-]*)?({'|'.join(THREE_D_FORMATS)})$", re.IGNORECASE
)
def normalize_3d_label(label: object) -> str | None:
"""The 3D format a version label denotes (upper-case), or ``None``.
Lets a plain label double as the 3D marker — ``"3D HSBS"`` typed in the
version dialog and ``"hsbs"`` picked from a dropdown mean the same thing.
"""
match = _LABEL_3D_RE.match(str(label or "").strip())
return match.group(1).upper() if match else None
# Jellyfin's multi-part ("stacking") keywords + a number: "part1", "CD 2", "pt-3".
_LABEL_PART_RE = re.compile(
r"^(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{1,2})$", re.IGNORECASE
)
def normalize_part_label(label: object) -> int | None:
"""The part number a version label denotes, or ``None``.
A *part* is one piece of a film split across several files (played back
to back as one movie), unlike a *version*, which is an alternative. Any of
Jellyfin's stacking spellings counts — ``part2``, ``CD2``, ``pt 2`` — and
Curator always writes it back as ``-part<N>`` (see :func:`part_label`).
"""
match = _LABEL_PART_RE.match(str(label or "").strip())
return int(match.group(1)) if match else None
def part_label(number: int) -> str:
"""Canonical version label of part ``number`` — ``"part<N>"``."""
return f"part{int(number)}"
def media_filename(stem: str, label: str, ext: str, *, grouped: bool) -> str:
"""Filename for one media file of a movie whose canonical stem is ``stem``.
- no label → ``stem.ext`` (a lone, plain video)
- 3D label → ``stem.3D.<FORMAT>.ext`` (Jellyfin's documented 3D form)
- anything else → ``stem - label.ext``
``grouped`` only decides whether an empty label is allowed: inside a movie
folder every file gets one, so it falls back to :data:`DEFAULT_VERSION_LABEL`.
"""
label = (label or "").strip()
if grouped and not label:
label = DEFAULT_VERSION_LABEL
if not label:
return f"{stem}{ext}"
fmt = normalize_3d_label(label)
if fmt:
return f"{stem}.3D.{fmt}{ext}"
return f"{stem} - {label}{ext}"
def identity_key(tmdb_id: object = None, title: str = "", year: object = None) -> str:
"""The pool-index key for a movie — ``tmdb-<id>``, else a title/year slug.
Keying by identity (rather than by file path) is what lets a second file of
the same film find its movie instead of becoming a separate entry.
"""
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
if id_s.isdigit():
return f"tmdb-{int(id_s)}"
raw = f"{(title or '').strip()} {str(year or '').strip()}".strip().lower()
# Fold diacritics so "Pelíšky" and "Pelisky" can't become two movies.
raw = "".join(
c for c in unicodedata.normalize("NFD", raw) if not unicodedata.combining(c)
)
slug = re.sub(r"[^0-9a-z]+", "-", raw).strip("-")
return f"name-{slug or 'bez-nazvu'}"
@dataclass
class MediaFile:
"""One file belonging to a movie (today always a playable video)."""
path: Path
label: str = ""
role: str = ROLE_VIDEO
@property
def format_3d(self) -> str | None:
"""The 3D format this version's label denotes, if any."""
return normalize_3d_label(self.label)
def to_record(self, pool_dir: Path) -> dict:
"""Serialise for the pool index (path stored pool-relative, POSIX)."""
try:
rel = Path(self.path).relative_to(pool_dir).as_posix()
except ValueError:
rel = Path(self.path).as_posix()
return {"role": self.role, "label": self.label, "file": rel}
@classmethod
def from_record(cls, data: dict, pool_dir: Path) -> MediaFile:
return cls(
path=pool_dir / str(data.get("file", "")),
label=str(data.get("label", "") or ""),
role=str(data.get("role") or ROLE_VIDEO),
)
@dataclass
class Movie:
"""A film in the pool: its identity, its metadata, and its media files."""
key: str
title: str = ""
year: str | None = None
tmdb_id: int | None = None
library: str = ""
media: list[MediaFile] = field(default_factory=list)
# --- metadata that used to live on the "primary" File ---
tags: list = field(default_factory=list)
auto_tag_paths: set[str] = field(default_factory=set)
csfd_url: str | None = None
date: str | None = None
added: str | None = None
attributes: dict[str, str] = field(default_factory=dict)
meta: dict | None = None
# ------------------------------------------------------------------
# Identity / shape
# ------------------------------------------------------------------
@property
def videos(self) -> list[MediaFile]:
"""Playable versions, in a stable order (labelled ones by label)."""
return sorted(
(m for m in self.media if m.role == ROLE_VIDEO),
key=lambda m: m.label.casefold(),
)
@property
def is_grouped(self) -> bool:
"""True when the movie needs its own folder (more than one version)."""
return len(self.videos) > 1
@property
def stem(self) -> str:
"""Canonical ``Title (rok) [tmdbid-N]`` — the folder / filename base.
Falls back to the bare title when no four-digit year is known (a movie
can be imported without one).
"""
title = (self.title or "").replace("/", "-").replace("\\", "-").strip()
if not title:
title = self.key
if is_valid_year(self.year or ""):
return canonical_pool_stem(title, self.year, self.tmdb_id)
return title
@property
def primary(self) -> MediaFile | None:
"""The version that represents the movie (first by :attr:`videos`)."""
videos = self.videos
return videos[0] if videos else None
def desired_layout(self, pool_dir: Path) -> list[tuple[MediaFile, Path]]:
"""``(media, where it belongs)`` for every file, from the record alone.
The whole flat-vs-folder rule lives here: one video sits directly in
the library, two or more move into ``<Library>/<stem>/``. Callers move
whatever doesn't already match — which makes promotion, demotion and
renaming the same operation.
"""
base = Path(pool_dir) / self.library
grouped = self.is_grouped
folder = base / self.stem if grouped else base
return [
(
m,
folder
/ media_filename(
self.stem, m.label, Path(m.path).suffix, grouped=grouped
),
)
for m in self.videos
]
# ------------------------------------------------------------------
# Index serialisation
# ------------------------------------------------------------------
def to_record(self, pool_dir: Path) -> dict:
data = {
"title": self.title,
"year": self.year,
"tmdb_id": self.tmdb_id,
"library": self.library,
"media": [m.to_record(pool_dir) for m in self.media],
"tags": [getattr(t, "full_path", t) for t in self.tags],
"auto_tags": sorted(self.auto_tag_paths),
"csfd_url": self.csfd_url,
"date": self.date,
"added": self.added,
"attributes": dict(self.attributes),
}
if self.meta is not None:
data["meta"] = dict(self.meta)
return data
@classmethod
def from_record(
cls, key: str, data: dict, pool_dir: Path, tagmanager=None
) -> Movie:
movie = cls(
key=key,
title=str(data.get("title") or ""),
year=(str(data["year"]) if data.get("year") else None),
tmdb_id=(
int(data["tmdb_id"])
if str(data.get("tmdb_id") or "").isdigit()
else None
),
library=str(data.get("library") or ""),
media=[
MediaFile.from_record(m, pool_dir) for m in (data.get("media") or [])
],
auto_tag_paths=set(data.get("auto_tags") or []),
csfd_url=data.get("csfd_url") or None,
date=data.get("date") or None,
added=data.get("added") or None,
attributes=dict(data.get("attributes") or {}),
meta=(dict(data["meta"]) if data.get("meta") else None),
)
if tagmanager is not None:
for full_path in data.get("tags") or []:
if "/" in str(full_path):
category, name = str(full_path).split("/", 1)
movie.tags.append(tagmanager.add_tag(category, name))
return movie
+552
View File
@@ -0,0 +1,552 @@
//! The movie object — metadata first, files second.
//!
//! A movie is identified by `(title, year, tmdb_id)` and owns **its files**. Each
//! playable file is a [`MediaFile`] under it, told apart by a free-text `label`
//! (`3D HSBS`, `4K`, `Director's Cut`, …). Where those files *live* is a pure
//! function of the record — [`Movie::desired_layout`] — so promoting to a folder,
//! demoting back, or renaming the whole thing are all the same operation:
//! recompute and move what doesn't match.
//!
//! Layout (Jellyfin's own conventions):
//!
//! ```text
//! one video <Library>/Title (rok) [tmdbid-N].ext
//! two or more <Library>/Title (rok) [tmdbid-N]/
//! Title (rok) [tmdbid-N] - Výchozí.ext
//! Title (rok) [tmdbid-N].3D.HSBS.ext
//! ```
//!
//! Subtitles are not media entries — they ride along with their video by
//! filename stem (see `subtitles`).
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use serde_json::{Map, Value, json};
use unicode_normalization::UnicodeNormalization;
use unicode_normalization::char::is_combining_mark;
use super::naming::{canonical_stem, is_valid_year};
use super::paths;
use super::tag::Tag;
use super::tag_manager::TagManager;
/// The only media role so far — artwork stays Jellyfin's job.
pub const ROLE_VIDEO: &str = "video";
/// A grouped movie needs a label on every file; this is what an unlabelled one gets.
pub const DEFAULT_VERSION_LABEL: &str = "Výchozí";
static LABEL_3D_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(?:3d[\s._-]*)?(hsbs|fsbs|htab|ftab|mvc)$").expect("valid regex")
});
static LABEL_PART_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{1,2})$").expect("valid regex")
});
static NON_SLUG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[^0-9a-z]+").expect("valid regex"));
/// The 3D format a version label denotes (upper-case), or `None`.
///
/// `"3D HSBS"` typed in the version dialog and `"hsbs"` picked from a dropdown
/// mean the same thing.
pub fn normalize_3d_label(label: &str) -> Option<String> {
LABEL_3D_RE
.captures(label.trim())
.map(|c| c[1].to_uppercase())
}
/// The part number a version label denotes (`part2`, `CD2`, `pt 2`), or `None`.
///
/// A *part* is one piece of a film split across several files, unlike a
/// *version*, which is an alternative.
pub fn normalize_part_label(label: &str) -> Option<u32> {
LABEL_PART_RE
.captures(label.trim())
.and_then(|c| c[1].parse().ok())
}
/// Canonical version label of part `number` — `"part<N>"`.
pub fn part_label(number: u32) -> String {
format!("part{number}")
}
/// Filename for one media file of a movie whose canonical stem is `stem`.
///
/// - no label → `stem.ext`
/// - 3D label → `stem.3D.<FORMAT>.ext`
/// - anything else → `stem - label.ext`
///
/// `grouped` only decides whether an empty label is allowed: inside a movie
/// folder every file gets one ([`DEFAULT_VERSION_LABEL`]).
pub fn media_filename(stem: &str, label: &str, ext: &str, grouped: bool) -> String {
let mut label = label.trim();
if grouped && label.is_empty() {
label = DEFAULT_VERSION_LABEL;
}
if label.is_empty() {
return format!("{stem}{ext}");
}
if let Some(fmt) = normalize_3d_label(label) {
return format!("{stem}.3D.{fmt}{ext}");
}
format!("{stem} - {label}{ext}")
}
/// Fold diacritics: "Pelíšky" → "Pelisky".
pub fn fold_diacritics(text: &str) -> String {
text.nfd().filter(|c| !is_combining_mark(*c)).collect()
}
/// The pool-index key for a movie — `tmdb-<id>`, else a title/year slug.
///
/// Keying by identity (rather than by file path) is what lets a second file of
/// the same film find its movie instead of becoming a separate entry.
pub fn identity_key(tmdb_id: Option<&str>, title: &str, year: Option<&str>) -> String {
let id_s = tmdb_id.unwrap_or("").trim();
if !id_s.is_empty()
&& id_s.chars().all(|c| c.is_ascii_digit())
&& let Ok(id) = id_s.parse::<u64>()
{
return format!("tmdb-{id}");
}
let raw = format!("{} {}", title.trim(), year.unwrap_or("").trim())
.trim()
.to_lowercase();
let folded = fold_diacritics(&raw);
let slug = NON_SLUG_RE
.replace_all(&folded, "-")
.trim_matches('-')
.to_string();
format!("name-{}", if slug.is_empty() { "bez-nazvu" } else { &slug })
}
/// [`identity_key`] for an integer id.
pub fn identity_key_for(tmdb_id: Option<i64>, title: &str, year: Option<&str>) -> String {
let id = tmdb_id.map(|i| i.to_string());
identity_key(id.as_deref(), title, year)
}
/// One file belonging to a movie (today always a playable video).
#[derive(Debug, Clone, PartialEq)]
pub struct MediaFile {
pub path: PathBuf,
pub label: String,
pub role: String,
}
impl MediaFile {
pub fn new(path: impl Into<PathBuf>, label: impl Into<String>) -> Self {
Self {
path: path.into(),
label: label.into(),
role: ROLE_VIDEO.into(),
}
}
/// The 3D format this version's label denotes, if any.
pub fn format_3d(&self) -> Option<String> {
normalize_3d_label(&self.label)
}
/// Serialise for the pool index (path stored pool-relative, POSIX).
pub fn to_record(&self, pool_dir: &Path) -> Value {
json!({
"role": self.role,
"label": self.label,
"file": paths::posix_relative(&self.path, pool_dir),
})
}
pub fn from_record(data: &Value, pool_dir: &Path) -> Self {
let role = str_field(data, "role").unwrap_or_else(|| ROLE_VIDEO.into());
Self {
path: paths::join_posix(pool_dir, &str_field(data, "file").unwrap_or_default()),
label: str_field(data, "label").unwrap_or_default(),
role,
}
}
}
/// A non-empty string field of a JSON object (numbers are stringified).
pub(crate) fn str_field(data: &Value, key: &str) -> Option<String> {
match data.get(key)? {
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(true) => Some("True".into()),
_ => None,
}
}
/// An integer field that may be stored as a number or a digit string.
pub(crate) fn int_field(data: &Value, key: &str) -> Option<i64> {
match data.get(key)? {
Value::Number(n) => n.as_i64(),
Value::String(s) if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) => {
s.parse().ok()
}
_ => None,
}
}
/// A film in the pool: its identity, its metadata, and its media files.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Movie {
pub key: String,
pub title: String,
pub year: Option<String>,
pub tmdb_id: Option<i64>,
pub library: String,
pub media: Vec<MediaFile>,
pub tags: Vec<Tag>,
pub auto_tag_paths: BTreeSet<String>,
pub csfd_url: Option<String>,
pub date: Option<String>,
pub added: Option<String>,
pub attributes: BTreeMap<String, String>,
pub meta: Option<Map<String, Value>>,
}
impl Movie {
/// Playable versions, in a stable order (by label, case-insensitively).
pub fn videos(&self) -> Vec<&MediaFile> {
let mut v: Vec<&MediaFile> = self.media.iter().filter(|m| m.role == ROLE_VIDEO).collect();
v.sort_by_key(|m| m.label.to_lowercase());
v
}
/// True when the movie needs its own folder (more than one version).
pub fn is_grouped(&self) -> bool {
self.videos().len() > 1
}
/// Canonical `Title (rok) [tmdbid-N]` — the folder / filename base. Falls back
/// to the bare title when no four-digit year is known.
pub fn stem(&self) -> String {
let mut title = self.title.replace(['/', '\\'], "-").trim().to_string();
if title.is_empty() {
title = self.key.clone();
}
match self.year.as_deref() {
Some(y) if is_valid_year(y) => {
canonical_stem(&title, y, self.tmdb_id, None).unwrap_or(title)
}
_ => title,
}
}
/// The version that represents the movie (first by [`Movie::videos`]).
pub fn primary(&self) -> Option<&MediaFile> {
self.videos().into_iter().next()
}
/// `(index into media, where it belongs)` for every video, from the record alone.
///
/// One video sits directly in the library, two or more move into
/// `<Library>/<stem>/`. Callers move whatever doesn't already match.
pub fn desired_layout(&self, pool_dir: &Path) -> Vec<(usize, PathBuf)> {
let base = pool_dir.join(&self.library);
let grouped = self.is_grouped();
let stem = self.stem();
let folder = if grouped { base.join(&stem) } else { base };
let mut order: Vec<usize> = (0..self.media.len())
.filter(|&i| self.media[i].role == ROLE_VIDEO)
.collect();
order.sort_by_key(|&i| self.media[i].label.to_lowercase());
order
.into_iter()
.map(|i| {
let m = &self.media[i];
let ext = paths::suffix(&m.path);
(
i,
folder.join(media_filename(&stem, &m.label, &ext, grouped)),
)
})
.collect()
}
pub fn tag_paths(&self) -> Vec<String> {
self.tags.iter().map(Tag::full_path).collect()
}
pub fn has_tag(&self, tag: &Tag) -> bool {
self.tags.contains(tag)
}
pub fn to_record(&self, pool_dir: &Path) -> Value {
let mut data = json!({
"title": self.title,
"year": self.year,
"tmdb_id": self.tmdb_id,
"library": self.library,
"media": self.media.iter().map(|m| m.to_record(pool_dir)).collect::<Vec<_>>(),
"tags": self.tag_paths(),
"auto_tags": self.auto_tag_paths.iter().collect::<Vec<_>>(),
"csfd_url": self.csfd_url,
"date": self.date,
"added": self.added,
"attributes": self.attributes,
});
if let (Some(meta), Some(obj)) = (&self.meta, data.as_object_mut()) {
obj.insert("meta".into(), Value::Object(meta.clone()));
}
data
}
/// Rebuild a movie from its index record. Tags are registered with
/// `tagmanager` when one is given.
pub fn from_record(
key: &str,
data: &Value,
pool_dir: &Path,
tagmanager: Option<&mut TagManager>,
) -> Self {
let media = data
.get("media")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.map(|m| MediaFile::from_record(m, pool_dir))
.collect()
})
.unwrap_or_default();
let auto_tag_paths = data
.get("auto_tags")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let attributes = data
.get("attributes")
.and_then(Value::as_object)
.map(|o| {
o.iter()
.map(|(k, v)| {
(
k.clone(),
v.as_str().map_or_else(|| v.to_string(), String::from),
)
})
.collect()
})
.unwrap_or_default();
let meta = data
.get("meta")
.and_then(Value::as_object)
.filter(|m| !m.is_empty())
.cloned();
let mut movie = Self {
key: key.into(),
title: str_field(data, "title").unwrap_or_default(),
year: str_field(data, "year"),
tmdb_id: int_field(data, "tmdb_id"),
library: str_field(data, "library").unwrap_or_default(),
media,
tags: Vec::new(),
auto_tag_paths,
csfd_url: str_field(data, "csfd_url"),
date: str_field(data, "date"),
added: str_field(data, "added"),
attributes,
meta,
};
if let Some(tm) = tagmanager {
for full in data
.get("tags")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(tag) = full.as_str().and_then(Tag::from_full_path) {
movie.tags.push(tm.add_tag(&tag.category, &tag.name));
}
}
}
movie
}
}
#[cfg(test)]
mod tests {
use super::*;
fn movie() -> Movie {
Movie {
key: "tmdb-19995".into(),
title: "Avatar".into(),
year: Some("2009".into()),
tmdb_id: Some(19995),
library: "Filmy".into(),
..Default::default()
}
}
#[test]
fn test_media_filename_variants() {
assert_eq!(
media_filename("Avatar (2009)", "", ".mkv", false),
"Avatar (2009).mkv"
);
assert_eq!(
media_filename("Avatar (2009)", "3D HSBS", ".mkv", false),
"Avatar (2009).3D.HSBS.mkv"
);
assert_eq!(
media_filename("Avatar (2009)", "4K", ".mkv", true),
"Avatar (2009) - 4K.mkv"
);
assert_eq!(
media_filename("Avatar (2009)", "", ".mkv", true),
format!("Avatar (2009) - {DEFAULT_VERSION_LABEL}.mkv")
);
}
#[test]
fn test_identity_key_variants() {
assert_eq!(
identity_key(Some("603"), "Matrix", Some("1999")),
"tmdb-603"
);
assert_eq!(
identity_key(None, "Matrix", Some("1999")),
"name-matrix-1999"
);
assert_eq!(
identity_key(None, "Pelíšky", Some("1999")),
"name-pelisky-1999"
);
assert_eq!(identity_key(None, "Nějaký film", None), "name-nejaky-film");
assert_eq!(identity_key(None, "", None), "name-bez-nazvu");
}
#[test]
fn test_normalize_labels() {
assert_eq!(normalize_3d_label("3D HSBS").as_deref(), Some("HSBS"));
assert_eq!(normalize_3d_label("hsbs").as_deref(), Some("HSBS"));
assert_eq!(normalize_3d_label("4K"), None);
for (label, expected) in [
("part1", Some(1)),
("Part 2", Some(2)),
("pt3", Some(3)),
("CD1", Some(1)),
("disc-2", Some(2)),
("dvd_4", Some(4)),
("disk12", Some(12)),
("4K", None),
("", None),
("partition", None),
] {
assert_eq!(normalize_part_label(label), expected, "{label}");
}
}
#[test]
fn test_stem_canonical_and_fallback() {
assert_eq!(movie().stem(), "Avatar (2009) [tmdbid-19995]");
let m = Movie {
year: None,
tmdb_id: None,
..movie()
};
assert_eq!(m.stem(), "Avatar");
}
#[test]
fn test_grouping_and_primary() {
let mut m = movie();
m.media = vec![MediaFile::new("/x.mkv", "")];
assert!(!m.is_grouped());
m.media = vec![
MediaFile::new("/a.mkv", "3D HSBS"),
MediaFile::new("/b.mkv", ""),
];
assert!(m.is_grouped());
assert_eq!(m.primary().unwrap().label, "");
}
#[test]
fn test_desired_layout_flat_and_grouped() {
let pool = Path::new("/pool");
let mut m = movie();
m.media = vec![MediaFile::new("/pool/kdekoli.mkv", "3D HSBS")];
let layout = m.desired_layout(pool);
assert_eq!(
layout[0].1,
pool.join("Filmy/Avatar (2009) [tmdbid-19995].3D.HSBS.mkv")
);
m.media = vec![
MediaFile::new("/a.mkv", ""),
MediaFile::new("/b.mkv", "3D HSBS"),
];
let targets: BTreeMap<String, PathBuf> = m
.desired_layout(pool)
.into_iter()
.map(|(i, p)| (m.media[i].label.clone(), p))
.collect();
let folder = pool.join("Filmy/Avatar (2009) [tmdbid-19995]");
assert_eq!(
targets[""],
folder.join(format!(
"Avatar (2009) [tmdbid-19995] - {DEFAULT_VERSION_LABEL}.mkv"
))
);
assert_eq!(
targets["3D HSBS"],
folder.join("Avatar (2009) [tmdbid-19995].3D.HSBS.mkv")
);
}
#[test]
fn test_layout_follows_library_move() {
let mut m = Movie {
library: "Kids".into(),
..movie()
};
m.media = vec![MediaFile::new("/a.mkv", "")];
assert_eq!(
m.desired_layout(Path::new("/p"))[0].1.parent().unwrap(),
Path::new("/p/Kids")
);
}
#[test]
fn test_record_roundtrip() {
let pool = Path::new("/pool");
let mut m = movie();
m.media = vec![MediaFile::new("/pool/Filmy/a.mkv", "3D HSBS")];
m.csfd_url = Some("https://www.csfd.cz/film/1/".into());
m.date = Some("2026-01-01".into());
m.attributes.insert("collection_sort".into(), "01".into());
let mut meta = Map::new();
meta.insert("year".into(), Value::from("2009"));
m.meta = Some(meta);
let record = m.to_record(pool);
assert_eq!(
record["media"],
json!([{"role": "video", "label": "3D HSBS", "file": "Filmy/a.mkv"}])
);
let back = Movie::from_record("tmdb-19995", &record, pool, None);
assert_eq!(back, m);
assert_eq!(back.media[0].format_3d().as_deref(), Some("HSBS"));
}
#[test]
fn test_from_record_registers_tags_and_reads_python_types() {
let record = json!({"title": "Avatar", "year": 2009, "tmdb_id": "19995",
"media": [], "tags": ["Žánr/Sci-Fi"]});
let mut tm = TagManager::new();
let back = Movie::from_record("k", &record, Path::new("/p"), Some(&mut tm));
assert_eq!(back.tag_paths(), vec!["Žánr/Sci-Fi"]);
assert_eq!(back.year.as_deref(), Some("2009"));
assert_eq!(back.tmdb_id, Some(19995));
assert_eq!(tm.all_tags(), vec!["Žánr/Sci-Fi"]);
}
}
-143
View File
@@ -1,143 +0,0 @@
"""Pool file naming convention.
Every movie in the pool is named ``Title (YYYY) [tmdbid-N].ext`` — a title, a
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
is exactly the form Jellyfin matches on. The ``[tmdbid-N]`` part is optional: a
plain ``Title (YYYY)`` is still valid (Jellyfin then matches by name). The year
lets two same-named films coexist and makes the filename self-describing.
A 3D movie additionally carries a ``.3D.<FORMAT>`` tag right before the
extension (Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/):
``Title (YYYY) [tmdbid-N].3D.HSBS.ext``. ``FORMAT`` is one of
:data:`THREE_D_FORMATS`; Jellyfin reads it case-insensitively but Curator always
writes it upper-case for a single canonical form.
These helpers build, validate and parse the stem (the filename without its
extension).
"""
import re
# Jellyfin's recognised 3D format tags (case-insensitive on read; Curator
# always writes upper-case). hsbs/fsbs = half/full side-by-side, htab/ftab =
# half/full top-and-bottom, mvc = Multiview Video Coding.
THREE_D_FORMATS = ("HSBS", "FSBS", "HTAB", "FTAB", "MVC")
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]`` and
# an optional ``.3D.<FORMAT>`` tag. The title is non-greedy so a title that
# itself contains parentheses keeps them: "Kurz (special) (2019)" → title
# "Kurz (special)", year 2019.
_POOL_STEM_RE = re.compile(
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?"
r"(?:\.3D\.(?P<format3d>hsbs|fsbs|htab|ftab|mvc))?$",
re.IGNORECASE,
)
def is_valid_year(year: object) -> bool:
"""True when ``year`` is (or stringifies to) exactly four digits."""
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
def canonical_pool_stem(
title: str,
year: object,
tmdb_id: object = None,
format_3d: object = None,
) -> str:
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` /
``.3D.<FORMAT>`` if given).
Raises:
ValueError: the title is empty, the year is not four digits, a given
``tmdb_id`` is not a positive integer, or ``format_3d`` isn't one
of :data:`THREE_D_FORMATS`.
"""
title = (title or "").strip()
year_s = str(year).strip()
if not title:
raise ValueError("Název filmu nesmí být prázdný.")
if not re.fullmatch(r"\d{4}", year_s):
raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.")
stem = f"{title} ({year_s})"
if tmdb_id is not None and str(tmdb_id).strip() != "":
id_s = str(tmdb_id).strip()
if not id_s.isdigit():
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
stem += f" [tmdbid-{id_s}]"
if format_3d is not None and str(format_3d).strip() != "":
fmt = str(format_3d).strip().upper()
if fmt not in THREE_D_FORMATS:
raise ValueError(
f"3D formát musí být jedno z {', '.join(THREE_D_FORMATS)}, "
f"ne „{format_3d}“."
)
stem += f".3D.{fmt}"
return stem
def parse_pool_stem(stem: str) -> tuple[str, int] | None:
"""Split a canonical stem into ``(title, year)``; ``None`` if it isn't one.
A trailing ``[tmdbid-N]`` is tolerated and dropped (use :func:`parse_tmdb_id`
to read it), so the returned title is always the clean title.
"""
match = _POOL_STEM_RE.match((stem or "").strip())
if not match:
return None
return match.group("title").strip(), int(match.group("year"))
def parse_tmdb_id(stem: str) -> int | None:
"""Read the ``[tmdbid-N]`` from a canonical stem, or ``None`` if absent."""
match = _POOL_STEM_RE.match((stem or "").strip())
if not match or not match.group("id"):
return None
return int(match.group("id"))
def parse_3d_format(stem: str) -> str | None:
"""Read the ``.3D.<FORMAT>`` tag from a canonical stem (upper-cased), or
``None`` if absent."""
match = _POOL_STEM_RE.match((stem or "").strip())
if not match or not match.group("format3d"):
return None
return match.group("format3d").upper()
def is_canonical_pool_stem(stem: str) -> bool:
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
return _POOL_STEM_RE.match((stem or "").strip()) is not None
def year_from_filename(name: str) -> str | None:
"""Best-effort four-digit year guessed from a raw (release) filename.
Prefers a parenthesised year (``… (1999) …``) and otherwise falls back to the
last standalone 19xx/20xx run. Used only to pre-fill the import dialog — the
user confirms it. Returns the year as a string, or ``None``.
"""
paren = re.search(r"\((19\d{2}|20\d{2})\)", name)
if paren:
return paren.group(1)
loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name)
return loose[-1] if loose else None
# "3D" + one of THREE_D_FORMATS, anywhere in a raw filename, bordered by a
# non-alphanumeric character (or the string's edge) on each side so it can't
# match inside an unrelated word.
_THREE_D_ANYWHERE_RE = re.compile(
rf"(?<![A-Za-z0-9])3d[\s._-]*({'|'.join(THREE_D_FORMATS)})(?![A-Za-z0-9])",
re.IGNORECASE,
)
def three_d_format_from_filename(name: str) -> str | None:
"""Best-effort 3D format guessed from a raw (release) filename, e.g.
``Avatar.2009.3D.HSBS.BluRay.mkv`` → ``"HSBS"``. Unlike :func:`parse_3d_format`
this doesn't require the rest of the name to be a canonical pool stem — used
to pre-fill a version label while importing. Returns ``None`` if absent.
"""
match = _THREE_D_ANYWHERE_RE.search(name or "")
return match.group(1).upper() if match else None
+314
View File
@@ -0,0 +1,314 @@
//! Pool file naming convention.
//!
//! Every movie in the pool is named `Title (YYYY) [tmdbid-N].ext` — a title, a
//! four-digit year in parentheses, and (when known) the TMDb id in brackets, which
//! is exactly the form Jellyfin matches on. The `[tmdbid-N]` part is optional: a
//! plain `Title (YYYY)` is still valid (Jellyfin then matches by name).
//!
//! A 3D movie additionally carries a `.3D.<FORMAT>` tag right before the
//! extension (Jellyfin's own convention): `Title (YYYY) [tmdbid-N].3D.HSBS.ext`.
//! `FORMAT` is one of [`THREE_D_FORMATS`]; Jellyfin reads it case-insensitively
//! but Curator always writes it upper-case.
use std::sync::LazyLock;
use regex::Regex;
use super::error::{CoreError, CoreResult};
/// Jellyfin's recognised 3D format tags. hsbs/fsbs = half/full side-by-side,
/// htab/ftab = half/full top-and-bottom, mvc = Multiview Video Coding.
pub const THREE_D_FORMATS: [&str; 5] = ["HSBS", "FSBS", "HTAB", "FTAB", "MVC"];
// A trailing four-digit year in parentheses, then an optional `[tmdbid-N]` and an
// optional `.3D.<FORMAT>` tag. The title is non-greedy so a title that itself
// contains parentheses keeps them: "Kurz (special) (2019)" → "Kurz (special)".
static POOL_STEM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?(?:\.3D\.(?P<format3d>hsbs|fsbs|htab|ftab|mvc))?$",
)
.expect("valid regex")
});
static YEAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d{4}$").expect("valid regex"));
static PAREN_YEAR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\((19\d{2}|20\d{2})\)").expect("valid regex"));
static DIGIT_RUN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+").expect("valid regex"));
static THREE_D_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)3d[\s._-]*(hsbs|fsbs|htab|ftab|mvc)").expect("valid regex"));
/// True when `year` is exactly four digits (surrounding whitespace ignored).
pub fn is_valid_year(year: &str) -> bool {
YEAR_RE.is_match(year.trim())
}
/// Build the canonical pool stem `Title (YYYY)` (+ ` [tmdbid-N]` / `.3D.<FORMAT>`).
///
/// Empty `tmdb_id` / `format_3d` count as absent.
pub fn canonical_pool_stem(
title: &str,
year: &str,
tmdb_id: Option<&str>,
format_3d: Option<&str>,
) -> CoreResult<String> {
let title = title.trim();
let year_s = year.trim();
if title.is_empty() {
return Err(CoreError::invalid("Název filmu nesmí být prázdný."));
}
if !YEAR_RE.is_match(year_s) {
return Err(CoreError::invalid(format!(
"Rok musí být čtyřmístné číslo, ne „{year}“."
)));
}
let mut stem = format!("{title} ({year_s})");
if let Some(id) = tmdb_id.map(str::trim).filter(|s| !s.is_empty()) {
if !id.chars().all(|c| c.is_ascii_digit()) {
return Err(CoreError::invalid(format!(
"TMDb ID musí být číslo, ne „{id}“."
)));
}
stem.push_str(&format!(" [tmdbid-{id}]"));
}
if let Some(fmt) = format_3d.map(str::trim).filter(|s| !s.is_empty()) {
let upper = fmt.to_uppercase();
if !THREE_D_FORMATS.contains(&upper.as_str()) {
return Err(CoreError::invalid(format!(
"3D formát musí být jedno z {}, ne „{fmt}“.",
THREE_D_FORMATS.join(", ")
)));
}
stem.push_str(&format!(".3D.{upper}"));
}
Ok(stem)
}
/// Convenience for the common integer-id case.
pub fn canonical_stem(
title: &str,
year: &str,
tmdb_id: Option<i64>,
format_3d: Option<&str>,
) -> CoreResult<String> {
let id = tmdb_id.map(|i| i.to_string());
canonical_pool_stem(title, year, id.as_deref(), format_3d)
}
/// Split a canonical stem into `(title, year)`; `None` if it isn't one.
///
/// A trailing `[tmdbid-N]` / `.3D.X` is tolerated and dropped.
pub fn parse_pool_stem(stem: &str) -> Option<(String, i32)> {
let caps = POOL_STEM_RE.captures(stem.trim())?;
let year = caps["year"].parse().ok()?;
Some((caps["title"].trim().to_string(), year))
}
/// Read the `[tmdbid-N]` from a canonical stem.
pub fn parse_tmdb_id(stem: &str) -> Option<i64> {
POOL_STEM_RE
.captures(stem.trim())?
.name("id")?
.as_str()
.parse()
.ok()
}
/// Read the `.3D.<FORMAT>` tag from a canonical stem (upper-cased).
pub fn parse_3d_format(stem: &str) -> Option<String> {
Some(
POOL_STEM_RE
.captures(stem.trim())?
.name("format3d")?
.as_str()
.to_uppercase(),
)
}
/// True when `stem` matches the `Title (YYYY)` convention.
pub fn is_canonical_pool_stem(stem: &str) -> bool {
POOL_STEM_RE.is_match(stem.trim())
}
/// Best-effort four-digit year guessed from a raw (release) filename.
///
/// Prefers a parenthesised year and otherwise falls back to the last standalone
/// 19xx/20xx run of exactly four digits. Only used to pre-fill the import dialog.
pub fn year_from_filename(name: &str) -> Option<String> {
if let Some(c) = PAREN_YEAR_RE.captures(name) {
return Some(c[1].to_string());
}
DIGIT_RUN_RE
.find_iter(name)
.map(|m| m.as_str())
.filter(|s| s.len() == 4 && (s.starts_with("19") || s.starts_with("20")))
.last()
.map(str::to_string)
}
fn is_alnum_ascii(c: Option<char>) -> bool {
c.is_some_and(|c| c.is_ascii_alphanumeric())
}
/// Best-effort 3D format guessed from a raw filename, e.g.
/// `Avatar.2009.3D.HSBS.BluRay.mkv` → `"HSBS"`. The `3D…` token must not be
/// glued to a neighbouring letter or digit on either side.
pub fn three_d_format_from_filename(name: &str) -> Option<String> {
let mut start = 0;
while let Some(caps) = THREE_D_RE.captures_at(name, start) {
let whole = caps.get(0)?;
let before = name[..whole.start()].chars().next_back();
let after = name[whole.end()..].chars().next();
if !is_alnum_ascii(before) && !is_alnum_ascii(after) {
return Some(caps[1].to_uppercase());
}
// advance by one character and retry
start = whole.start() + name[whole.start()..].chars().next()?.len_utf8();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_valid_year_cases() {
for (year, ok) in [
("1968", true),
("968", false),
("19688", false),
("abcd", false),
("", false),
(" 1999 ", true),
] {
assert_eq!(is_valid_year(year), ok, "{year}");
}
}
#[test]
fn test_canonical_pool_stem_builds_title_year() {
assert_eq!(
canonical_pool_stem("Matrix", "1999", None, None).unwrap(),
"Matrix (1999)"
);
assert_eq!(
canonical_pool_stem(" Matrix ", "1999", None, None).unwrap(),
"Matrix (1999)"
);
}
#[test]
fn test_canonical_pool_stem_rejects_bad_input() {
assert!(canonical_pool_stem("Matrix", "99", None, None).is_err());
assert!(canonical_pool_stem(" ", "1999", None, None).is_err());
assert!(canonical_pool_stem("Matrix", "1999", Some("tt0133093"), None).is_err());
assert!(canonical_pool_stem("Avatar", "2009", None, Some("bogus")).is_err());
}
#[test]
fn test_parse_pool_stem_roundtrip() {
assert_eq!(
parse_pool_stem("Matrix (1999)"),
Some(("Matrix".into(), 1999))
);
assert_eq!(
parse_pool_stem("Já, robot (2004)"),
Some(("Já, robot".into(), 2004))
);
assert_eq!(
parse_pool_stem("Kurz (special) (2019)"),
Some(("Kurz (special)".into(), 2019))
);
assert_eq!(
parse_pool_stem("Matrix (1999) [tmdbid-603]"),
Some(("Matrix".into(), 1999))
);
}
#[test]
fn test_parse_pool_stem_non_canonical() {
assert_eq!(parse_pool_stem("Matrix"), None);
assert_eq!(parse_pool_stem("Matrix (99)"), None);
assert_eq!(parse_pool_stem("Matrix 1999"), None);
}
#[test]
fn test_canonical_pool_stem_with_tmdb_id() {
assert_eq!(
canonical_pool_stem("Matrix", "1999", Some("603"), None).unwrap(),
"Matrix (1999) [tmdbid-603]"
);
assert_eq!(
canonical_pool_stem("Matrix", "1999", Some(""), None).unwrap(),
"Matrix (1999)"
);
}
#[test]
fn test_parse_tmdb_id_cases() {
assert_eq!(parse_tmdb_id("Matrix (1999) [tmdbid-603]"), Some(603));
assert_eq!(parse_tmdb_id("Matrix (1999)"), None);
assert_eq!(parse_tmdb_id("Matrix"), None);
assert!(is_canonical_pool_stem("Matrix (1999) [tmdbid-603]"));
assert!(!is_canonical_pool_stem("Matrix"));
}
#[test]
fn test_canonical_pool_stem_with_3d_format() {
assert_eq!(
canonical_pool_stem("Avatar", "2009", Some("19995"), Some("hsbs")).unwrap(),
"Avatar (2009) [tmdbid-19995].3D.HSBS"
);
assert_eq!(
canonical_pool_stem("Avatar", "2009", None, Some("fsbs")).unwrap(),
"Avatar (2009).3D.FSBS"
);
assert_eq!(
canonical_pool_stem("Avatar", "2009", None, Some("")).unwrap(),
"Avatar (2009)"
);
}
#[test]
fn test_parse_3d_format_roundtrip() {
for fmt in THREE_D_FORMATS {
let stem = canonical_pool_stem("Avatar", "2009", Some("19995"), Some(fmt)).unwrap();
assert_eq!(parse_3d_format(&stem).as_deref(), Some(fmt));
assert_eq!(parse_pool_stem(&stem), Some(("Avatar".into(), 2009)));
assert_eq!(parse_tmdb_id(&stem), Some(19995));
}
assert_eq!(
parse_3d_format("Avatar (2009).3D.hsbs").as_deref(),
Some("HSBS")
);
assert_eq!(parse_3d_format("Avatar (2009)"), None);
}
#[test]
fn test_year_from_filename_cases() {
for (name, year) in [
("Matrix.1999.1080p.BluRay.mkv", Some("1999")),
("Šíleně smutná princezna (1968).mkv", Some("1968")),
("Movie 2001 2010 remux.mkv", Some("2010")),
("No Year Here.mkv", None),
("Film 12345.mkv", None),
] {
assert_eq!(year_from_filename(name).as_deref(), year, "{name}");
}
}
#[test]
fn test_three_d_format_from_filename_cases() {
for (name, fmt) in [
("Avatar.2009.3D.HSBS.BluRay.mkv", Some("HSBS")),
("Avatar.2009.3d.hsbs.mkv", Some("HSBS")),
("Avatar (2009) 3D_FTAB 1080p.mkv", Some("FTAB")),
("Avatar (2009) - 3D-MVC.mkv", Some("MVC")),
("Avatar (2009).mkv", None),
("Premium3D.HSBS.mkv", None),
("Avatar.2009.HSBS.mkv", None),
] {
assert_eq!(three_d_format_from_filename(name).as_deref(), fmt, "{name}");
}
}
}
-126
View File
@@ -1,126 +0,0 @@
"""NFO reader — Curator's metadata source after ČSFD.
Jellyfin (configured to save metadata into the media folders) writes a Kodi-style
``.nfo`` next to each movie plus artwork. Curator only **reads** it — to drive the
miniDLNA genre tree and to fill the year/rating used by folder grouping. It never
writes NFO (Jellyfin owns that).
For a flat pool file ``Title (rok) [tmdbid].mkv`` the sidecar is
``Title (rok) [tmdbid].nfo``; a per-movie folder may instead hold ``movie.nfo``.
Both are found. Missing or unparsable NFO → ``None`` (the movie simply has no
genres until Jellyfin has scanned it).
"""
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class NfoData:
"""The bits of a movie ``.nfo`` Curator uses."""
title: str | None = None
year: str | None = None # four-digit string
genres: list[str] = field(default_factory=list)
countries: list[str] = field(default_factory=list)
rating: int | None = None # 0–100 (NFO 0–10 scaled up), or None
tmdb_id: int | None = None
csfd_url: str | None = None # ČSFD film URL, if the NFO carries a csfd id
def field_values(self, name: str) -> list[str]:
"""Values for a tag-schema source field (genres/countries/year/rating)."""
if name == "genres":
return list(self.genres)
if name == "countries":
return list(self.countries)
if name == "year":
return [self.year] if self.year else []
if name == "rating":
return [str(self.rating)] if self.rating is not None else []
return []
def find_nfo(video_path: Path) -> Path | None:
"""Locate the ``.nfo`` for a movie: sibling ``<stem>.nfo`` or ``movie.nfo``."""
video_path = Path(video_path)
sibling = video_path.with_suffix(".nfo")
if sibling.is_file():
return sibling
movie_nfo = video_path.parent / "movie.nfo"
if movie_nfo.is_file():
return movie_nfo
return None
def _texts(root: ET.Element, tag: str) -> list[str]:
return [e.text.strip() for e in root.findall(tag) if e.text and e.text.strip()]
def _parse_rating(root: ET.Element) -> int | None:
"""Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands)."""
raw = root.findtext("rating")
if raw is None:
value = root.find("ratings/rating/value")
raw = value.text if value is not None else None
if not raw:
return None
try:
return round(float(raw.strip().replace(",", ".")) * 10)
except ValueError:
return None
def _parse_tmdb_id(root: ET.Element) -> int | None:
for uid in root.findall("uniqueid"):
if uid.get("type") == "tmdb" and uid.text and uid.text.strip().isdigit():
return int(uid.text.strip())
raw = root.findtext("tmdbid")
return int(raw.strip()) if raw and raw.strip().isdigit() else None
def _parse_csfd_url(root: ET.Element) -> str | None:
"""A ČSFD film URL from the NFO, if any.
Czech Kodi scrapers record ČSFD as ``<uniqueid type="csfd">`` (a bare id or a
full URL) or a legacy ``<csfdid>`` / ``<csfd>`` element. A bare id is expanded
to ``https://www.csfd.cz/film/<id>/``.
"""
candidates: list[str] = []
for uid in root.findall("uniqueid"):
if uid.get("type") == "csfd" and uid.text and uid.text.strip():
candidates.append(uid.text.strip())
for tag in ("csfdid", "csfd"):
raw = root.findtext(tag)
if raw and raw.strip():
candidates.append(raw.strip())
for value in candidates:
if value.isdigit():
return f"https://www.csfd.cz/film/{value}/"
if "csfd.cz" in value:
return value if value.startswith("http") else f"https://{value.lstrip('/')}"
return None
def read_nfo(video_path: Path) -> NfoData | None:
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
path = find_nfo(video_path)
if path is None:
return None
try:
root = ET.parse(path).getroot()
except ET.ParseError, OSError:
return None
year = root.findtext("year")
year = year.strip() if year and year.strip().isdigit() else None
title = root.findtext("title")
return NfoData(
title=title.strip() if title and title.strip() else None,
year=year,
genres=_texts(root, "genre"),
countries=_texts(root, "country"),
rating=_parse_rating(root),
tmdb_id=_parse_tmdb_id(root),
csfd_url=_parse_csfd_url(root),
)
+311
View File
@@ -0,0 +1,311 @@
//! NFO reader — the movie metadata Jellyfin writes next to each film.
//!
//! Curator only **reads** it — to drive the miniDLNA genre tree and to fill the
//! year/rating used by folder grouping. For a flat pool file the sidecar is
//! `<stem>.nfo`; a per-movie folder may instead hold `movie.nfo`. Missing or
//! unparsable NFO → `None`.
use std::fs;
use std::path::{Path, PathBuf};
use roxmltree::{Document, Node, ParsingOptions};
/// The bits of a movie `.nfo` Curator uses.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NfoData {
pub title: Option<String>,
/// Digit string (normally four digits).
pub year: Option<String>,
pub genres: Vec<String>,
pub countries: Vec<String>,
/// 0–100 (the NFO's 0–10 scaled up).
pub rating: Option<i64>,
pub tmdb_id: Option<i64>,
/// ČSFD film URL, if the NFO carries a ČSFD id.
pub csfd_url: Option<String>,
}
impl NfoData {
/// Values for a tag-schema source field (genres/countries/year/rating).
pub fn field_values(&self, name: &str) -> Vec<String> {
match name {
"genres" => self.genres.clone(),
"countries" => self.countries.clone(),
"year" => self.year.iter().cloned().collect(),
"rating" => self.rating.iter().map(|r| r.to_string()).collect(),
_ => Vec::new(),
}
}
}
/// Locate the `.nfo` for a movie: sibling `<stem>.nfo`, else `movie.nfo`.
pub fn find_nfo(video_path: &Path) -> Option<PathBuf> {
let sibling = video_path.with_extension("nfo");
if sibling.is_file() {
return Some(sibling);
}
let movie_nfo = video_path.parent()?.join("movie.nfo");
movie_nfo.is_file().then_some(movie_nfo)
}
fn children<'a, 'i>(node: Node<'a, 'i>, tag: &'a str) -> impl Iterator<Item = Node<'a, 'i>> + 'a {
node.children()
.filter(move |n| n.is_element() && n.tag_name().name() == tag)
}
fn child<'a, 'i>(node: Node<'a, 'i>, tag: &'a str) -> Option<Node<'a, 'i>> {
children(node, tag).next()
}
/// Text of the first direct child `tag` (`Some("")` for an empty element).
fn child_text(node: Node<'_, '_>, tag: &str) -> Option<String> {
child(node, tag).map(|n| n.text().unwrap_or("").to_string())
}
fn texts(node: Node<'_, '_>, tag: &str) -> Vec<String> {
children(node, tag)
.filter_map(|n| {
n.text()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(String::from)
})
.collect()
}
fn is_digits(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
}
/// Read a 0–10 rating and scale it to 0–100 (banker's rounding, like Python).
fn parse_rating(root: Node<'_, '_>) -> Option<i64> {
let raw = match child_text(root, "rating") {
Some(r) => r,
None => child(root, "ratings")
.and_then(|r| child(r, "rating"))
.and_then(|r| child(r, "value"))
.map(|v| v.text().unwrap_or("").to_string())?,
};
if raw.is_empty() {
return None;
}
let value: f64 = raw.trim().replace(',', ".").parse().ok()?;
Some((value * 10.0).round_ties_even() as i64)
}
fn parse_tmdb_id(root: Node<'_, '_>) -> Option<i64> {
for uid in children(root, "uniqueid") {
let text = uid.text().unwrap_or("").trim();
if uid.attribute("type") == Some("tmdb") && is_digits(text) {
return text.parse().ok();
}
}
child_text(root, "tmdbid")
.map(|t| t.trim().to_string())
.filter(|t| is_digits(t))?
.parse()
.ok()
}
/// A ČSFD film URL from `<uniqueid type="csfd">` or a legacy `<csfdid>` /
/// `<csfd>` element. A bare id expands to `https://www.csfd.cz/film/<id>/`.
fn parse_csfd_url(root: Node<'_, '_>) -> Option<String> {
let mut candidates: Vec<String> = children(root, "uniqueid")
.filter(|u| u.attribute("type") == Some("csfd"))
.filter_map(|u| {
u.text()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(String::from)
})
.collect();
for tag in ["csfdid", "csfd"] {
if let Some(raw) = child_text(root, tag)
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
{
candidates.push(raw);
}
}
candidates.into_iter().find_map(|value| {
if is_digits(&value) {
Some(format!("https://www.csfd.cz/film/{value}/"))
} else if value.contains("csfd.cz") {
Some(if value.starts_with("http") {
value
} else {
format!("https://{}", value.trim_start_matches('/'))
})
} else {
None
}
})
}
/// Parse NFO XML text.
pub fn parse_nfo(text: &str) -> Option<NfoData> {
let opts = ParsingOptions {
allow_dtd: true,
..ParsingOptions::default()
};
let doc = Document::parse_with_options(text, opts).ok()?;
let root = doc.root_element();
let year = child_text(root, "year")
.map(|y| y.trim().to_string())
.filter(|y| is_digits(y));
let title = child_text(root, "title")
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty());
Some(NfoData {
title,
year,
genres: texts(root, "genre"),
countries: texts(root, "country"),
rating: parse_rating(root),
tmdb_id: parse_tmdb_id(root),
csfd_url: parse_csfd_url(root),
})
}
/// Read the movie's `.nfo`, or `None` if absent/broken.
pub fn read_nfo(video_path: &Path) -> Option<NfoData> {
let path = find_nfo(video_path)?;
let bytes = fs::read(path).ok()?;
parse_nfo(&String::from_utf8_lossy(&bytes))
}
#[cfg(test)]
mod tests {
use super::*;
const MOVIE_NFO: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<movie>
<title>Matrix</title>
<year>1999</year>
<genre>Akční</genre>
<genre>Sci-Fi</genre>
<country>USA</country>
<rating>8.7</rating>
<uniqueid type="tmdb">603</uniqueid>
</movie>
"#;
fn video_with_nfo(dir: &Path, nfo: &str) -> PathBuf {
let v = dir.join("Matrix (1999) [tmdbid-603].mkv");
fs::write(&v, "x").unwrap();
fs::write(v.with_extension("nfo"), nfo).unwrap();
v
}
#[test]
fn test_read_nfo_sibling() {
let dir = tempfile::tempdir().unwrap();
let nfo = read_nfo(&video_with_nfo(dir.path(), MOVIE_NFO)).unwrap();
assert_eq!(
nfo,
NfoData {
title: Some("Matrix".into()),
year: Some("1999".into()),
genres: vec!["Akční".into(), "Sci-Fi".into()],
countries: vec!["USA".into()],
rating: Some(87),
tmdb_id: Some(603),
csfd_url: None,
}
);
}
#[test]
fn test_rating_variants() {
assert_eq!(
parse_nfo("<movie><rating>7.3</rating></movie>")
.unwrap()
.rating,
Some(73)
);
assert_eq!(
parse_nfo("<movie><rating>7,3</rating></movie>")
.unwrap()
.rating,
Some(73)
);
let block =
"<movie><ratings><rating name='tmdb'><value>9.1</value></rating></ratings></movie>";
assert_eq!(parse_nfo(block).unwrap().rating, Some(91));
}
#[test]
fn test_ids_and_csfd() {
assert_eq!(
parse_nfo("<movie><tmdbid>603</tmdbid></movie>")
.unwrap()
.tmdb_id,
Some(603)
);
assert_eq!(
parse_nfo(r#"<movie><uniqueid type="csfd">9499</uniqueid></movie>"#)
.unwrap()
.csfd_url
.as_deref(),
Some("https://www.csfd.cz/film/9499/")
);
assert_eq!(
parse_nfo(r#"<movie><uniqueid type="csfd">https://www.csfd.cz/film/9499-matrix/</uniqueid></movie>"#)
.unwrap().csfd_url.as_deref(),
Some("https://www.csfd.cz/film/9499-matrix/")
);
assert_eq!(
parse_nfo("<movie><csfdid>12345</csfdid></movie>")
.unwrap()
.csfd_url
.as_deref(),
Some("https://www.csfd.cz/film/12345/")
);
assert_eq!(
parse_nfo("<movie><title>Matrix</title></movie>")
.unwrap()
.csfd_url,
None
);
}
#[test]
fn test_find_prefers_sibling_over_movie_nfo() {
let dir = tempfile::tempdir().unwrap();
let video = dir.path().join("Matrix (1999).mkv");
fs::write(&video, "x").unwrap();
fs::write(dir.path().join("movie.nfo"), MOVIE_NFO).unwrap();
assert_eq!(find_nfo(&video).unwrap().file_name().unwrap(), "movie.nfo");
fs::write(video.with_extension("nfo"), MOVIE_NFO).unwrap();
assert_eq!(
find_nfo(&video).unwrap().file_name().unwrap(),
"Matrix (1999).nfo"
);
}
#[test]
fn test_missing_or_broken_nfo_is_none() {
let dir = tempfile::tempdir().unwrap();
let v = dir.path().join("x.mkv");
fs::write(&v, "x").unwrap();
assert!(read_nfo(&v).is_none());
fs::write(v.with_extension("nfo"), "<movie><genre>unclosed").unwrap();
assert!(read_nfo(&v).is_none());
}
#[test]
fn test_field_values() {
let nfo = NfoData {
year: Some("1999".into()),
genres: vec!["A".into(), "B".into()],
countries: vec!["USA".into()],
rating: Some(87),
..Default::default()
};
assert_eq!(nfo.field_values("genres"), vec!["A", "B"]);
assert_eq!(nfo.field_values("countries"), vec!["USA"]);
assert_eq!(nfo.field_values("year"), vec!["1999"]);
assert_eq!(nfo.field_values("rating"), vec!["87"]);
assert!(nfo.field_values("directors").is_empty());
}
}
+171
View File
@@ -0,0 +1,171 @@
//! Small filesystem helpers mirroring the `pathlib` behaviour the pool relies on.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// The final extension including its dot (`".mkv"`), or `""`.
pub fn suffix(path: &Path) -> String {
path.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default()
}
/// Lower-cased [`suffix`].
pub fn suffix_lower(path: &Path) -> String {
suffix(path).to_lowercase()
}
/// The file name without its final extension.
pub fn stem(path: &Path) -> String {
path.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// The last path component as a string.
pub fn name(path: &Path) -> String {
path.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// `path` relative to `base` with `/` separators, or the whole path (POSIX
/// style) when it isn't under `base`. This is how the pool index stores paths.
pub fn posix_relative(path: &Path, base: &Path) -> String {
let rel = path.strip_prefix(base).unwrap_or(path);
rel.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
.replace("//", "/")
}
/// Join a POSIX-style relative path (as stored in the index) onto `base`.
pub fn join_posix(base: &Path, rel: &str) -> PathBuf {
let mut out = base.to_path_buf();
for part in rel.split('/').filter(|p| !p.is_empty()) {
out.push(part);
}
out
}
/// Every regular file under `dir`, recursively, sorted by path. Symlinked
/// directories are not followed. A missing directory yields an empty list.
pub fn walk_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
walk_into(dir, &mut out);
out.sort();
out
}
fn walk_into(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
let path = entry.path();
if ft.is_dir() {
walk_into(&path, out);
} else if path.is_file() {
out.push(path);
}
}
}
/// Immediate subdirectories of `dir`, sorted.
pub fn subdirs(dir: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = fs::read_dir(dir)
.map(|it| {
it.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect()
})
.unwrap_or_default();
out.sort();
out
}
/// Immediate entries (files and folders) of `dir`, sorted.
pub fn entries(dir: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = fs::read_dir(dir)
.map(|it| it.flatten().map(|e| e.path()).collect())
.unwrap_or_default();
out.sort();
out
}
/// Move a file, falling back to copy + delete across filesystems.
pub fn move_file(src: &Path, dst: &Path) -> io::Result<()> {
match fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(_) => {
fs::copy(src, dst)?;
fs::remove_file(src)
}
}
}
/// Copy a file keeping its modification time (like Python's `shutil.copy2`).
pub fn copy_preserving(src: &Path, dst: &Path) -> io::Result<()> {
fs::copy(src, dst)?;
if let Ok(mtime) = fs::metadata(src).and_then(|m| m.modified())
&& let Ok(f) = fs::OpenOptions::new().write(true).open(dst)
{
let _ = f.set_modified(mtime);
}
Ok(())
}
/// Copy or move `src` to `dst`.
pub fn transfer(src: &Path, dst: &Path, move_source: bool) -> io::Result<()> {
if move_source {
move_file(src, dst)
} else {
copy_preserving(src, dst)
}
}
/// True when `dir` exists and holds nothing.
pub fn is_empty_dir(dir: &Path) -> bool {
fs::read_dir(dir)
.map(|mut it| it.next().is_none())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_suffix_and_stem() {
let p = Path::new("/a/Film (2009).3D.HSBS.mkv");
assert_eq!(suffix(p), ".mkv");
assert_eq!(stem(p), "Film (2009).3D.HSBS");
assert_eq!(suffix(Path::new("/a/.hidden")), "");
}
#[test]
fn test_posix_relative_and_join_roundtrip() {
let base = Path::new("/pool");
let p = Path::new("/pool/Filmy/a b/x.mkv");
let rel = posix_relative(p, base);
assert_eq!(rel, "Filmy/a b/x.mkv");
assert_eq!(join_posix(base, &rel), p);
}
#[test]
fn test_walk_files_recurses_and_sorts() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("b/c")).unwrap();
fs::write(dir.path().join("b/c/2.txt"), "").unwrap();
fs::write(dir.path().join("a.txt"), "").unwrap();
let files = walk_files(dir.path());
assert_eq!(
files,
vec![dir.path().join("a.txt"), dir.path().join("b/c/2.txt")]
);
}
}
-211
View File
@@ -1,211 +0,0 @@
"""
Unified metadata index for the Curator pool.
Instead of one sidecar file per movie, the whole pool keeps a single JSON index
at ``<pool>/.Curator.!index``. Curator owns the pool (it inserts/removes files
itself), so files never move behind its back and a central index is safe.
The index also carries **library-level settings** (``tag_schema``,
``copyasis_folders``) under a ``settings`` section, so everything describing a
particular pool travels with the pool. The global config keeps only app data
(window state, MRU) and the pointers to where the pool/output live.
On-disk shape (**v2**)::
{"version": 2,
"settings": {...},
"movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
Records are keyed by the movie's **identity** (``tmdb-<id>``, or a title/year
slug when there's no id) and own their files — see :mod:`src.core.movie`. The
original **v1** shape keyed a record by one file's pool-relative path, which
made metadata a passenger on that file; :meth:`PoolIndex.load` migrates such an
index in place (backing the old file up first). Migration only rewrites
metadata — no file on disk is moved, because a one-video movie is already in
its v2 position.
"""
import json
import shutil
from pathlib import Path
INDEX_FILENAME = ".Curator.!index"
INDEX_VERSION = 2
class PoolIndex:
"""Single-file JSON metadata store for all files in a pool."""
def __init__(self, pool_dir: Path) -> None:
self.pool_dir = Path(pool_dir)
self.index_path = self.pool_dir / INDEX_FILENAME
self.records: dict[str, dict] = {}
self.settings: dict = {}
self.load()
def _key(self, file_path: Path) -> str:
"""Pool-relative POSIX key for a file path."""
path = Path(file_path)
try:
return path.relative_to(self.pool_dir).as_posix()
except ValueError:
return path.as_posix()
def load(self) -> None:
"""Load the index from disk (missing/corrupt index = empty).
A v1 (path-keyed) index is migrated to v2 in place — the old file is
copied to ``.Curator.!index.v1.bak`` first.
"""
if not self.index_path.exists():
self.records = {}
self.settings = {}
return
try:
with open(self.index_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.records = data.get("movies", {})
self.settings = data.get("settings", {})
except json.JSONDecodeError, OSError:
self.records = {}
self.settings = {}
return
if int(data.get("version", 1)) < INDEX_VERSION:
self._migrate_v1_to_v2()
def _migrate_v1_to_v2(self) -> None:
"""Re-key path-keyed records into identity-keyed movie records.
Each v1 record described one file; several of them can describe one
movie (the interim multi-version layout put them in a shared folder),
so they are merged by identity. Metadata only — nothing is moved on
disk, since a v1 movie's files already sit where v2 wants them.
"""
from .movie import ROLE_VIDEO, identity_key
from .naming import is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id
migrated: dict[str, dict] = {}
for rel_path, record in self.records.items():
if not isinstance(record, dict) or "media" in record:
continue # already v2-shaped, leave it alone
path = Path(rel_path)
parts = path.parts
library = parts[0] if len(parts) > 1 else ""
# A file inside its own "Title (rok) [tmdbid]" folder is one
# version of that movie; its own stem carries the " - label".
parent = path.parent.name
grouped = len(parts) > 2 and is_canonical_pool_stem(parent)
identity_stem = parent if grouped else path.stem
parsed = parse_pool_stem(identity_stem)
title = record.get("title") or (parsed[0] if parsed else path.stem)
year = str(parsed[1]) if parsed else None
tmdb_id = parse_tmdb_id(identity_stem)
label = ""
if grouped and path.stem.startswith(f"{parent} - "):
label = path.stem[len(parent) + 3 :]
elif grouped:
label = path.stem[len(parent) :].lstrip(". -") or ""
key = identity_key(tmdb_id, title, year)
entry = {"role": ROLE_VIDEO, "label": label, "file": rel_path}
if key in migrated:
migrated[key]["media"].append(entry)
continue
migrated[key] = {
"title": title,
"year": year,
"tmdb_id": tmdb_id,
"library": library,
"media": [entry],
"tags": record.get("tags", []),
"auto_tags": record.get("auto_tags", record.get("csfd_tags", [])),
"csfd_url": record.get("csfd_url") or record.get("csfd_link"),
"date": record.get("date"),
"added": record.get("added"),
"attributes": record.get("attributes", {}),
}
if record.get("meta"):
migrated[key]["meta"] = record["meta"]
try:
shutil.copy2(
self.index_path,
self.index_path.with_name(self.index_path.name + ".v1.bak"),
)
except OSError:
pass
self.records = migrated
self.save()
def save(self) -> None:
"""Persist the index to disk."""
self.pool_dir.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f:
json.dump(
{
"version": INDEX_VERSION,
"settings": self.settings,
"movies": self.records,
},
f,
indent=2,
ensure_ascii=False,
)
def get_setting(self, key: str, default=None):
"""Return a library-level setting stored in the index."""
return self.settings.get(key, default)
def set_setting(self, key: str, value) -> None:
"""Upsert a library-level setting and persist the index."""
self.settings[key] = value
self.save()
# ------------------------------------------------------------------
# Movie records (v2 — keyed by identity, own their media files)
# ------------------------------------------------------------------
def get_movie(self, key: str) -> dict | None:
"""Return the movie record under ``key``, or None."""
return self.records.get(key)
def set_movie(self, key: str, data: dict) -> None:
"""Upsert a movie record and persist the index."""
self.records[key] = data
self.save()
def delete_movie(self, key: str) -> None:
"""Remove a movie record (if present) and persist the index."""
if self.records.pop(key, None) is not None:
self.save()
def rekey_movie(self, old_key: str, new_key: str) -> None:
"""Move a record to a new identity key (e.g. its TMDb id was set/fixed)."""
if old_key == new_key or old_key not in self.records:
return
self.records[new_key] = self.records.pop(old_key)
self.save()
def all_movies(self) -> dict[str, dict]:
"""Every movie record, keyed by identity."""
return dict(self.records)
def record_for(self, file_path: Path) -> dict | None:
"""The movie record that owns ``file_path``, or None if untracked."""
found = self.find_movie_by_media(file_path)
return found[1] if found is not None else None
def find_movie_by_media(self, file_path: Path) -> tuple[str, dict] | None:
"""``(key, record)`` of the movie owning ``file_path``, or None.
The reverse lookup a file needs now that records are keyed by identity
rather than by path.
"""
rel = self._key(file_path)
for key, record in self.records.items():
for media in record.get("media", []):
if media.get("file") == rel:
return key, record
return None
+388
View File
@@ -0,0 +1,388 @@
//! Unified metadata index for the Curator pool.
//!
//! The whole pool keeps a single JSON index at `<pool>/.Curator.!index`. Curator
//! owns the pool, so files never move behind its back and a central index is safe.
//! The index also carries **library-level settings** (`tag_schema`, `libraries`,
//! …) under `settings`, so everything describing a pool travels with it.
//!
//! On-disk shape (**v2**):
//!
//! ```text
//! {"version": 2,
//! "settings": {...},
//! "movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
//! ```
//!
//! Records are keyed by the movie's identity and own their files. A **v1** index
//! (keyed by one file's pool-relative path) is migrated in place on load, after
//! backing the old file up. Migration only rewrites metadata.
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
use tracing::warn;
use super::error::CoreResult;
use super::movie::{ROLE_VIDEO, identity_key_for};
use super::naming::{is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id};
use super::paths;
pub const INDEX_FILENAME: &str = ".Curator.!index";
pub const INDEX_VERSION: i64 = 2;
/// Single-file JSON metadata store for a pool.
#[derive(Debug, Clone)]
pub struct PoolIndex {
pub pool_dir: PathBuf,
pub index_path: PathBuf,
pub records: Map<String, Value>,
pub settings: Map<String, Value>,
}
impl PoolIndex {
/// Open (and, if needed, migrate) the index of `pool_dir`.
pub fn open(pool_dir: &Path) -> Self {
let mut idx = Self {
pool_dir: pool_dir.to_path_buf(),
index_path: pool_dir.join(INDEX_FILENAME),
records: Map::new(),
settings: Map::new(),
};
idx.load();
idx
}
/// Pool-relative POSIX key for a file path.
pub fn rel_key(&self, file_path: &Path) -> String {
paths::posix_relative(file_path, &self.pool_dir)
}
/// Load from disk (missing/corrupt index = empty).
pub fn load(&mut self) {
self.records.clear();
self.settings.clear();
let Ok(text) = fs::read_to_string(&self.index_path) else {
return;
};
let data: Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => {
warn!("pool index {} is corrupt: {e}", self.index_path.display());
return;
}
};
self.records = data
.get("movies")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
self.settings = data
.get("settings")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let version = data.get("version").and_then(Value::as_i64).unwrap_or(1);
if version < INDEX_VERSION {
self.migrate_v1_to_v2();
}
}
/// Re-key path-keyed records into identity-keyed movie records, merging the
/// several v1 records that describe one multi-version movie.
fn migrate_v1_to_v2(&mut self) {
let mut migrated: Map<String, Value> = Map::new();
for (rel_path, record) in &self.records {
let Some(rec) = record.as_object() else {
continue;
};
if rec.contains_key("media") {
continue; // already v2-shaped
}
let parts: Vec<&str> = rel_path.split('/').filter(|p| !p.is_empty()).collect();
let library = if parts.len() > 1 { parts[0] } else { "" };
let path = Path::new(rel_path.as_str());
let file_stem = paths::stem(path);
let parent = if parts.len() > 1 {
parts[parts.len() - 2]
} else {
""
};
let grouped = parts.len() > 2 && is_canonical_pool_stem(parent);
let identity_stem = if grouped {
parent.to_string()
} else {
file_stem.clone()
};
let parsed = parse_pool_stem(&identity_stem);
let title = rec
.get("title")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from)
.unwrap_or_else(|| parsed.as_ref().map_or(file_stem.clone(), |p| p.0.clone()));
let year = parsed.as_ref().map(|p| p.1.to_string());
let tmdb_id = parse_tmdb_id(&identity_stem);
let label = if grouped {
match file_stem.strip_prefix(&format!("{parent} - ")) {
Some(l) => l.to_string(),
None => file_stem
.get(parent.len()..)
.unwrap_or("")
.trim_start_matches(['.', ' ', '-'])
.to_string(),
}
} else {
String::new()
};
let key = identity_key_for(tmdb_id, &title, year.as_deref());
let entry = json!({"role": ROLE_VIDEO, "label": label, "file": rel_path});
if let Some(existing) = migrated.get_mut(&key) {
if let Some(media) = existing.get_mut("media").and_then(Value::as_array_mut) {
media.push(entry);
}
continue;
}
let get = |k: &str| rec.get(k).cloned().unwrap_or(Value::Null);
let nonempty = |k: &str| rec.get(k).filter(|v| truthy(v)).cloned();
let mut new = json!({
"title": title,
"year": year,
"tmdb_id": tmdb_id,
"library": library,
"media": [entry],
"tags": rec.get("tags").cloned().unwrap_or_else(|| json!([])),
"auto_tags": rec.get("auto_tags").or_else(|| rec.get("csfd_tags")).cloned().unwrap_or_else(|| json!([])),
"csfd_url": nonempty("csfd_url").or_else(|| nonempty("csfd_link")).unwrap_or(Value::Null),
"date": get("date"),
"added": get("added"),
"attributes": rec.get("attributes").cloned().unwrap_or_else(|| json!({})),
});
if let (Some(meta), Some(obj)) = (nonempty("meta"), new.as_object_mut()) {
obj.insert("meta".into(), meta);
}
migrated.insert(key, new);
}
let backup = self
.index_path
.with_file_name(format!("{INDEX_FILENAME}.v1.bak"));
if let Err(e) = fs::copy(&self.index_path, &backup) {
warn!("could not back up the v1 index: {e}");
}
self.records = migrated;
if let Err(e) = self.save() {
warn!("could not save the migrated index: {e}");
}
}
/// Persist the index to disk.
pub fn save(&self) -> CoreResult<()> {
fs::create_dir_all(&self.pool_dir)?;
let data = json!({
"version": INDEX_VERSION,
"settings": self.settings,
"movies": self.records,
});
fs::write(&self.index_path, serde_json::to_string_pretty(&data)?)?;
Ok(())
}
pub fn get_setting(&self, key: &str) -> Option<&Value> {
self.settings.get(key)
}
/// Upsert a library-level setting and persist the index.
pub fn set_setting(&mut self, key: &str, value: Value) -> CoreResult<()> {
self.settings.insert(key.into(), value);
self.save()
}
pub fn get_movie(&self, key: &str) -> Option<&Value> {
self.records.get(key)
}
/// Upsert a movie record and persist the index.
pub fn set_movie(&mut self, key: &str, data: Value) -> CoreResult<()> {
self.records.insert(key.into(), data);
self.save()
}
/// Remove a movie record (if present) and persist the index.
pub fn delete_movie(&mut self, key: &str) -> CoreResult<()> {
if self.records.shift_remove(key).is_some() {
self.save()?;
}
Ok(())
}
/// Move a record to a new identity key (e.g. its TMDb id was set/fixed).
pub fn rekey_movie(&mut self, old_key: &str, new_key: &str) -> CoreResult<()> {
if old_key == new_key {
return Ok(());
}
if let Some(rec) = self.records.shift_remove(old_key) {
self.records.insert(new_key.into(), rec);
self.save()?;
}
Ok(())
}
/// `(key, record)` of the movie owning `file_path`.
pub fn find_movie_by_media(&self, file_path: &Path) -> Option<(String, &Value)> {
let rel = self.rel_key(file_path);
self.records.iter().find_map(|(key, record)| {
let owns = record
.get("media")
.and_then(Value::as_array)
.is_some_and(|media| {
media
.iter()
.any(|m| m.get("file").and_then(Value::as_str) == Some(rel.as_str()))
});
owns.then(|| (key.clone(), record))
})
}
}
/// Python-style truthiness of a JSON value.
pub(crate) fn truthy(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_index_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let idx = PoolIndex::open(dir.path());
assert!(idx.records.is_empty());
assert!(!idx.index_path.exists());
}
#[test]
fn test_set_persists_and_reloads() {
let dir = tempfile::tempdir().unwrap();
let mut idx = PoolIndex::open(dir.path());
idx.set_movie("tmdb-603", json!({"title": "Matrix", "media": []}))
.unwrap();
let raw: Value =
serde_json::from_str(&fs::read_to_string(&idx.index_path).unwrap()).unwrap();
assert_eq!(raw["version"], 2);
let again = PoolIndex::open(dir.path());
assert_eq!(again.get_movie("tmdb-603").unwrap()["title"], "Matrix");
}
#[test]
fn test_delete_and_rekey() {
let dir = tempfile::tempdir().unwrap();
let mut idx = PoolIndex::open(dir.path());
idx.set_movie("name-matrix-1999", json!({"title": "Matrix", "media": []}))
.unwrap();
idx.rekey_movie("name-matrix-1999", "tmdb-603").unwrap();
assert!(idx.get_movie("name-matrix-1999").is_none());
assert!(idx.get_movie("tmdb-603").is_some());
idx.delete_movie("tmdb-603").unwrap();
assert!(PoolIndex::open(dir.path()).records.is_empty());
}
#[test]
fn test_find_movie_by_media() {
let dir = tempfile::tempdir().unwrap();
let mut idx = PoolIndex::open(dir.path());
idx.set_movie(
"tmdb-603",
json!({"media": [{"role": "video", "label": "", "file": "Filmy/Matrix (1999).mkv"}]}),
)
.unwrap();
let found = idx.find_movie_by_media(&dir.path().join("Filmy/Matrix (1999).mkv"));
assert_eq!(found.map(|f| f.0), Some("tmdb-603".to_string()));
assert!(
idx.find_movie_by_media(&dir.path().join("Filmy/Other.mkv"))
.is_none()
);
}
#[test]
fn test_corrupt_index_loads_empty() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(INDEX_FILENAME), "{broken").unwrap();
assert!(PoolIndex::open(dir.path()).records.is_empty());
}
fn write_v1(dir: &Path, movies: Value) {
let data = json!({"settings": {"tag_schema": []}, "movies": movies});
fs::write(
dir.join(INDEX_FILENAME),
serde_json::to_string(&data).unwrap(),
)
.unwrap();
}
#[test]
fn test_v1_flat_movie_becomes_identity_keyed() {
let dir = tempfile::tempdir().unwrap();
write_v1(
dir.path(),
json!({"Filmy/Matrix (1999) [tmdbid-603].mkv": {
"title": "Matrix", "tags": ["Žánr/Akční"], "csfd_link": "https://csfd/1", "meta": {"year": "1999"}}}),
);
let idx = PoolIndex::open(dir.path());
let rec = idx.get_movie("tmdb-603").unwrap();
assert_eq!(rec["year"], "1999");
assert_eq!(rec["library"], "Filmy");
assert_eq!(rec["csfd_url"], "https://csfd/1");
assert_eq!(
rec["media"][0]["file"],
"Filmy/Matrix (1999) [tmdbid-603].mkv"
);
assert_eq!(rec["meta"]["year"], "1999");
assert!(dir.path().join(format!("{INDEX_FILENAME}.v1.bak")).exists());
let raw: Value =
serde_json::from_str(&fs::read_to_string(&idx.index_path).unwrap()).unwrap();
assert_eq!(raw["version"], 2);
assert_eq!(raw["settings"], json!({"tag_schema": []}));
}
#[test]
fn test_v1_grouped_versions_merge() {
let dir = tempfile::tempdir().unwrap();
let folder = "Filmy/Avatar (2009) [tmdbid-19995]";
write_v1(
dir.path(),
json!({
format!("{folder}/Avatar (2009) [tmdbid-19995] - 4K.mkv"): {"title": "Avatar"},
format!("{folder}/Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"): {"title": "Avatar"},
}),
);
let idx = PoolIndex::open(dir.path());
let labels: Vec<&str> = idx.get_movie("tmdb-19995").unwrap()["media"]
.as_array()
.unwrap()
.iter()
.map(|m| m["label"].as_str().unwrap())
.collect();
assert_eq!(labels, vec!["4K", "3D.HSBS"]);
}
#[test]
fn test_v1_without_tmdbid_gets_slug_key() {
let dir = tempfile::tempdir().unwrap();
write_v1(dir.path(), json!({"Filmy/Pelíšky (1999).mkv": {}}));
assert!(
PoolIndex::open(dir.path())
.get_movie("name-pelisky-1999")
.is_some()
);
}
}
-310
View File
@@ -1,310 +0,0 @@
"""Series naming + scanning for the Jellyfin TV layout.
Jellyfin wants series laid out as::
Seriály/Series (year) [tmdbid-N]/
tvshow.nfo (Jellyfin writes it)
Season 01/
Series (year) S01E01.ext
Specials/ (season 0)
Episodes are matched by ``SxxEyy``. These helpers parse the episode number out of
a raw filename, build the canonical episode/season/series names, and scan a
series library into a ``Series → Season → Episode`` tree with a naming status.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from .naming import (
canonical_pool_stem,
is_canonical_pool_stem,
parse_pool_stem,
parse_tmdb_id,
)
# SxxEyy / s1e2 / 1x02 — the episode marker in a filename. First match wins.
_EPISODE_RE = re.compile(
r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))"
)
# Jellyfin's own "stacking" tokens for one episode split across several
# physical files (an old rip cut into CDs, a two-tape recording, …) — as
# opposed to E01-E02, which is *two different* episodes in one file.
_PART_TYPES = r"cd|dvd|part|pt|disc|disk"
# Scanning a real filename: the token must sit at its own boundary right
# before the end of the stem, so a title that happens to contain "cd" or
# "part" as part of a word is never misread as a stacking suffix.
_PART_RE = re.compile(
rf"(?<![A-Za-z0-9])(?:{_PART_TYPES})[\s._-]?(\d{{1,2}})$", re.IGNORECASE
)
# The compact SxxEyy field a user types by hand (e.g. "S01E05P2") may also use
# a bare "P" and no separator at all — safe only because it is matched against
# just the tail *after* the recognised SxxEyy, never against a whole filename.
_PART_RE_LOOSE = re.compile(
rf"[\s._-]?(?:{_PART_TYPES}|p)[\s._-]?(\d{{1,2}})$", re.IGNORECASE
)
# Files next to an episode that are not video (NFO / artwork / subtitles).
_SIDECARS = {
".nfo",
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".gif",
".tbn",
".srt",
".ass",
".ssa",
".sub",
".idx",
".vtt",
}
@dataclass
class Episode:
path: Path
season: int | None
episode: int | None
# Which physical part of the episode this file is (Jellyfin stacking,
# e.g. an old recording split into two files) — None for a whole episode.
part: int | None = None
@property
def code(self) -> str:
if self.season is None or self.episode is None:
return "?"
base = f"S{self.season:02d}E{self.episode:02d}"
return f"{base}.pt{self.part}" if self.part else base
@dataclass
class Series:
folder: Path
title: str
year: str | None
tmdb_id: int | None
library: str
episodes: list[Episode] = field(default_factory=list)
def seasons(self) -> dict[int, list[Episode]]:
"""Episodes grouped by season number (``-1`` = unrecognised)."""
grouped: dict[int, list[Episode]] = {}
for ep in self.episodes:
grouped.setdefault(ep.season if ep.season is not None else -1, []).append(
ep
)
for eps in grouped.values():
eps.sort(key=lambda e: e.episode if e.episode is not None else 1_000_000)
return dict(sorted(grouped.items()))
def parse_episode(name: str) -> tuple[int, int] | None:
"""Extract ``(season, episode)`` from a filename, or ``None``."""
match = _EPISODE_RE.search(name)
if not match:
return None
if match.group(1) is not None:
return int(match.group(1)), int(match.group(2))
return int(match.group(3)), int(match.group(4))
def parse_episode_part(name: str) -> int | None:
"""Part number implied by a Jellyfin stacking suffix in a filename, or ``None``.
Looks for *cd*, *dvd*, *part*, *pt*, *disc* or *disk* + a number sitting
right before the extension (``Show S01E02.pt2.mkv``,
``Show S01E02-part-2.mkv``, …) — the same tokens Jellyfin itself scans for.
"""
match = _PART_RE.search(Path(name).stem)
return int(match.group(1)) if match else None
def parse_episode_code(text: str) -> tuple[int, int, int | None] | None:
"""Parse a compact user-typed code: ``SxxEyy``, optionally + a part.
Accepts everything :func:`parse_episode` does, plus a trailing part marker
right after it — ``S01E02P3``, ``S01E02 pt3``, ``S01E02.part.3``,
``S01E02 cd2`` — for a file that is only *one part* of that episode.
Returns ``(season, episode, part)`` with ``part`` ``None`` when absent, or
``None`` when no ``SxxEyy`` is found at all.
"""
text = (text or "").strip()
match = _EPISODE_RE.search(text)
if not match:
return None
if match.group(1) is not None:
season, episode = int(match.group(1)), int(match.group(2))
else:
season, episode = int(match.group(3)), int(match.group(4))
part_match = _PART_RE_LOOSE.match(text[match.end() :])
return season, episode, int(part_match.group(1)) if part_match else None
def collect_episode_files(folder: Path) -> list[Path]:
"""Every video file under ``folder`` (recursively), sidecars excluded, sorted.
Lets the episode-import dialog take a whole ``Series/Season NN/`` tree (or any
nested layout) in one pick instead of file-by-file. Season/episode numbers
are still read per file from its name by :func:`parse_episode`.
"""
folder = Path(folder)
if not folder.is_dir():
return []
return sorted(
f
for f in folder.rglob("*")
if f.is_file() and f.suffix.lower() not in _SIDECARS
)
def season_folder_name(season: int) -> str:
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
return "Specials" if season == 0 else f"Season {season:02d}"
def is_season_folder(name: str) -> bool:
"""True for a season folder name — ``Specials`` or ``Season <n>`` (any padding)."""
return name == "Specials" or re.fullmatch(r"Season \d{1,3}", name) is not None
def looks_like_series_folder(folder: Path) -> bool:
"""True when ``folder`` holds at least one ``Season NN``/``Specials`` subfolder.
That's the shape Curator always gives a series (episodes always land in a
season folder, never loose at the series root) — a movie's version folder
never has one, its files sit flat inside it. This is how a ``mixed``
library tells the two apart even though both use the identical outer
``Title (rok) [tmdbid]/`` name: :meth:`FileManager.is_movie_group_member`
and :func:`scan_series` both check it before deciding which one a
canonical-shaped subfolder is.
"""
folder = Path(folder)
if not folder.is_dir():
return False
return any(p.is_dir() and is_season_folder(p.name) for p in folder.iterdir())
# Characters not allowed in a filename on the common filesystems.
_FS_UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def sanitize_title(title: str) -> str:
"""A series/episode title made safe to sit in a filename (spaces collapsed)."""
cleaned = _FS_UNSAFE.sub("", title or "")
return re.sub(r"\s+", " ", cleaned).strip(" .")
def episode_filename(
season: int,
episode: int,
series_title: str,
ext: str,
*,
part: int | None = None,
) -> str:
"""Canonical episode filename ``SxxEyy - {series title}.ext``.
The release string is deliberately **not** kept — identity is the ``SxxEyy``
code, and Jellyfin fetches the episode title/metadata from TMDb (matched by
the code + the series folder's ``[tmdbid-N]``). An empty title collapses to
``SxxEyy.ext``. ``part`` marks one physical part of that episode — an old
recording split across several files — with Jellyfin's own ``.ptN``
stacking suffix right before the extension.
"""
code = f"S{season:02d}E{episode:02d}"
body = sanitize_title(series_title)
name = f"{code} - {body}" if body else code
if part:
name += f".pt{part}"
return f"{name}{ext}"
def series_folder_stem(title: str, year: object = None, tmdb_id: object = None) -> str:
"""Series folder name: ``Title (year) [tmdbid-N]`` (year/id optional).
Reuses the movie convention when a year is present; otherwise falls back to
``Title [tmdbid-N]`` or just ``Title`` so a year-less series still works.
"""
title = (title or "").strip()
if not title:
raise ValueError("Název seriálu nesmí být prázdný.")
year_s = str(year).strip() if year is not None else ""
if re.fullmatch(r"\d{4}", year_s):
return canonical_pool_stem(title, year_s, tmdb_id)
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
def is_canonical_episode(path: Path, series_title: str) -> bool:
"""True when ``path`` is already ``SxxEyy - {series title}.ext`` in its season."""
parsed = parse_episode(path.name)
if parsed is None:
return False
season, episode = parsed
part = parse_episode_part(path.name)
expected = episode_filename(season, episode, series_title, path.suffix, part=part)
if path.name != expected:
return False
return path.parent.name == season_folder_name(season)
def scan_series(
lib_dir: Path,
library: str,
*,
skip_movie_folders: bool = False,
) -> list[Series]:
"""Scan one series library into a list of :class:`Series`.
Each immediate subfolder of ``lib_dir`` is a series; its name yields the
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
parsed from the filename). Non-video sidecars are skipped.
``skip_movie_folders=True`` (a ``mixed`` library, where a movie's version
folder and a series folder both use the identical
``Title (rok) [tmdbid]/`` shape) additionally skips a canonical-shaped
folder with no ``Season NN``/``Specials`` inside it — that's a movie's
version folder instead, left for :meth:`FileManager.load_pool_movies` to
read. Left ``False`` (the default, a plain ``series``-kind library, which
never holds movies) every subfolder is read as a series regardless of
shape — a raw, not-yet-renamed import may have loose episode files
sitting at the series root, with no season folder yet.
"""
lib_dir = Path(lib_dir)
if not lib_dir.is_dir():
return []
series_list: list[Series] = []
for folder in sorted(p for p in lib_dir.iterdir() if p.is_dir()):
if (
skip_movie_folders
and is_canonical_pool_stem(folder.name)
and not looks_like_series_folder(folder)
):
continue
parsed = parse_pool_stem(folder.name)
if parsed is not None:
title, year = parsed[0], str(parsed[1])
else:
title, year = folder.name, None
tmdb_id = parse_tmdb_id(folder.name)
episodes: list[Episode] = []
for f in sorted(folder.rglob("*")):
if not f.is_file() or f.suffix.lower() in _SIDECARS:
continue
code = parse_episode(f.name)
episodes.append(
Episode(
f,
code[0] if code else None,
code[1] if code else None,
parse_episode_part(f.name),
)
)
series_list.append(Series(folder, title, year, tmdb_id, library, episodes))
return series_list
+497
View File
@@ -0,0 +1,497 @@
//! Series naming + scanning for the Jellyfin TV layout.
//!
//! ```text
//! Seriály/Series (year) [tmdbid-N]/
//! tvshow.nfo (Jellyfin writes it)
//! Season 01/
//! S01E01 - Series.ext
//! Specials/ (season 0)
//! ```
//!
//! Episodes are matched by `SxxEyy`. These helpers parse the episode number out
//! of a raw filename, build the canonical names, and scan a series library into
//! a `Series → Season → Episode` tree.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::{Captures, Regex};
use super::error::{CoreError, CoreResult};
use super::naming::{canonical_stem, is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id};
use super::paths;
// SxxEyy / s1e2 / 1x02 — the episode marker in a filename. First match wins.
static EPISODE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))")
.expect("valid regex")
});
// Jellyfin's "stacking" tokens for one episode split across several files. The
// token must end the stem and must not be glued to a preceding letter/digit
// (checked in code — the regex crate has no look-behind).
static PART_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(?:cd|dvd|part|pt|disc|disk)[\s._-]?(\d{1,2})$").expect("valid regex")
});
// The compact code a user types by hand ("S01E05P2") may also use a bare "P";
// only ever matched against the tail *after* the recognised SxxEyy.
static PART_RE_LOOSE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^[\s._-]?(?:cd|dvd|part|pt|disc|disk|p)[\s._-]?(\d{1,2})$")
.expect("valid regex")
});
static SEASON_DIR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^Season \d{1,3}$").expect("valid regex"));
static FS_UNSAFE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"[<>:"/\\|?*\x00-\x1f]"#).expect("valid regex"));
static WHITESPACE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").expect("valid regex"));
static YEAR4_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d{4}$").expect("valid regex"));
/// Files next to an episode that are not video (NFO / artwork / subtitles).
pub const SIDECARS: &[&str] = &[
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn", ".srt", ".ass", ".ssa",
".sub", ".idx", ".vtt",
];
pub fn is_sidecar(path: &Path) -> bool {
SIDECARS.contains(&paths::suffix_lower(path).as_str())
}
#[derive(Debug, Clone, PartialEq)]
pub struct Episode {
pub path: PathBuf,
pub season: Option<u32>,
pub episode: Option<u32>,
/// Which physical part of the episode this file is — `None` for a whole episode.
pub part: Option<u32>,
}
impl Episode {
/// `S01E02`, `S01E02.pt2`, or `?` when unrecognised.
pub fn code(&self) -> String {
match (self.season, self.episode) {
(Some(s), Some(e)) => {
let base = format!("S{s:02}E{e:02}");
match self.part {
Some(p) if p > 0 => format!("{base}.pt{p}"),
_ => base,
}
}
_ => "?".into(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Series {
pub folder: PathBuf,
pub title: String,
pub year: Option<String>,
pub tmdb_id: Option<i64>,
pub library: String,
pub episodes: Vec<Episode>,
}
impl Series {
/// Episodes grouped by season number (`-1` = unrecognised), each season
/// sorted by episode number.
pub fn seasons(&self) -> BTreeMap<i64, Vec<Episode>> {
let mut grouped: BTreeMap<i64, Vec<Episode>> = BTreeMap::new();
for ep in &self.episodes {
grouped
.entry(ep.season.map_or(-1, i64::from))
.or_default()
.push(ep.clone());
}
for eps in grouped.values_mut() {
eps.sort_by_key(|e| e.episode.unwrap_or(1_000_000));
}
grouped
}
}
fn episode_numbers(caps: &Captures<'_>) -> Option<(u32, u32)> {
if let (Some(s), Some(e)) = (caps.get(1), caps.get(2)) {
return Some((s.as_str().parse().ok()?, e.as_str().parse().ok()?));
}
Some((
caps.get(3)?.as_str().parse().ok()?,
caps.get(4)?.as_str().parse().ok()?,
))
}
/// Extract `(season, episode)` from a filename.
pub fn parse_episode(name: &str) -> Option<(u32, u32)> {
episode_numbers(&EPISODE_RE.captures(name)?)
}
/// Part number implied by a stacking suffix right before the extension
/// (`Show S01E02.pt2.mkv`, `Show S01E02-part-2.mkv`, …).
pub fn parse_episode_part(name: &str) -> Option<u32> {
let stem = paths::stem(Path::new(name));
let mut start = 0;
while let Some(caps) = PART_RE.captures_at(&stem, start) {
let whole = caps.get(0)?;
let before = stem[..whole.start()].chars().next_back();
if !before.is_some_and(|c| c.is_ascii_alphanumeric()) {
return caps[1].parse().ok();
}
start = whole.start() + stem[whole.start()..].chars().next()?.len_utf8();
}
None
}
/// Parse a compact user-typed code: `SxxEyy`, optionally followed by a part
/// marker (`S01E02P3`, `S01E02 pt3`, `S01E02.part.3`, `S01E02 cd2`).
pub fn parse_episode_code(text: &str) -> Option<(u32, u32, Option<u32>)> {
let text = text.trim();
let caps = EPISODE_RE.captures(text)?;
let (season, episode) = episode_numbers(&caps)?;
let end = caps.get(0)?.end();
let part = PART_RE_LOOSE
.captures(&text[end..])
.and_then(|c| c[1].parse().ok());
Some((season, episode, part))
}
/// Every video file under `folder` (recursively), sidecars excluded, sorted.
pub fn collect_episode_files(folder: &Path) -> Vec<PathBuf> {
if !folder.is_dir() {
return Vec::new();
}
paths::walk_files(folder)
.into_iter()
.filter(|f| !is_sidecar(f))
.collect()
}
/// Folder name for a season — `Specials` for 0, else `Season NN`.
pub fn season_folder_name(season: u32) -> String {
if season == 0 {
"Specials".into()
} else {
format!("Season {season:02}")
}
}
/// True for `Specials` or `Season <n>` (any padding).
pub fn is_season_folder(name: &str) -> bool {
name == "Specials" || SEASON_DIR_RE.is_match(name)
}
/// True when `folder` holds at least one season subfolder — the shape Curator
/// always gives a series; a movie's version folder never has one.
pub fn looks_like_series_folder(folder: &Path) -> bool {
folder.is_dir()
&& paths::subdirs(folder)
.iter()
.any(|p| is_season_folder(&paths::name(p)))
}
/// A title made safe to sit in a filename (spaces collapsed, edge dots trimmed).
pub fn sanitize_title(title: &str) -> String {
let cleaned = FS_UNSAFE_RE.replace_all(title, "");
WHITESPACE_RE
.replace_all(&cleaned, " ")
.trim_matches(|c| c == ' ' || c == '.')
.to_string()
}
/// Canonical episode filename `SxxEyy - {series title}[.ptN].ext`.
///
/// The release string is deliberately dropped — identity is the code, and
/// Jellyfin fetches the episode title from TMDb.
pub fn episode_filename(
season: u32,
episode: u32,
series_title: &str,
ext: &str,
part: Option<u32>,
) -> String {
let code = format!("S{season:02}E{episode:02}");
let body = sanitize_title(series_title);
let mut name = if body.is_empty() {
code
} else {
format!("{code} - {body}")
};
if let Some(p) = part.filter(|p| *p > 0) {
name.push_str(&format!(".pt{p}"));
}
format!("{name}{ext}")
}
/// Series folder name: `Title (year) [tmdbid-N]` (year/id optional).
pub fn series_folder_stem(
title: &str,
year: Option<&str>,
tmdb_id: Option<i64>,
) -> CoreResult<String> {
let title = title.trim();
if title.is_empty() {
return Err(CoreError::invalid("Název seriálu nesmí být prázdný."));
}
let year_s = year.unwrap_or("").trim();
if YEAR4_RE.is_match(year_s) {
return canonical_stem(title, year_s, tmdb_id, None);
}
Ok(match tmdb_id {
Some(id) if id >= 0 => format!("{title} [tmdbid-{id}]"),
_ => title.to_string(),
})
}
/// True when `path` is already `SxxEyy - {series title}.ext` in its season folder.
pub fn is_canonical_episode(path: &Path, series_title: &str) -> bool {
let name = paths::name(path);
let Some((season, episode)) = parse_episode(&name) else {
return false;
};
let part = parse_episode_part(&name);
let expected = episode_filename(season, episode, series_title, &paths::suffix(path), part);
name == expected
&& path.parent().map(paths::name).as_deref() == Some(season_folder_name(season).as_str())
}
/// Scan one series library into a list of [`Series`].
///
/// Each immediate subfolder is a series; video files anywhere below it are
/// episodes. With `skip_movie_folders` (a `mixed` library) a canonical-shaped
/// folder without any season folder is a movie's version folder and is skipped.
pub fn scan_series(lib_dir: &Path, library: &str, skip_movie_folders: bool) -> Vec<Series> {
if !lib_dir.is_dir() {
return Vec::new();
}
let mut out = Vec::new();
for folder in paths::subdirs(lib_dir) {
let folder_name = paths::name(&folder);
if skip_movie_folders
&& is_canonical_pool_stem(&folder_name)
&& !looks_like_series_folder(&folder)
{
continue;
}
let (title, year) = match parse_pool_stem(&folder_name) {
Some((t, y)) => (t, Some(y.to_string())),
None => (folder_name.clone(), None),
};
let episodes = paths::walk_files(&folder)
.into_iter()
.filter(|f| !is_sidecar(f))
.map(|f| {
let name = paths::name(&f);
let code = parse_episode(&name);
Episode {
season: code.map(|c| c.0),
episode: code.map(|c| c.1),
part: parse_episode_part(&name),
path: f,
}
})
.collect();
out.push(Series {
tmdb_id: parse_tmdb_id(&folder_name),
folder,
title,
year,
library: library.into(),
episodes,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_parse_episode_cases() {
for (name, expected) in [
("Breaking Bad S01E02.mkv", Some((1, 2))),
("bb.s1e2.mkv", Some((1, 2))),
("Show 01x02 x264.mkv", Some((1, 2))),
("Series.S01E02E03.mkv", Some((1, 2))),
("Series S12E145.mkv", Some((12, 145))),
("No code here.mkv", None),
] {
assert_eq!(parse_episode(name), expected, "{name}");
}
}
#[test]
fn test_names() {
assert_eq!(season_folder_name(0), "Specials");
assert_eq!(season_folder_name(12), "Season 12");
assert_eq!(
episode_filename(1, 2, "Most!", ".mkv", None),
"S01E02 - Most!.mkv"
);
assert_eq!(episode_filename(3, 4, "", ".mkv", None), "S03E04.mkv");
assert_eq!(episode_filename(3, 4, "/", ".mkv", None), "S03E04.mkv");
assert_eq!(
episode_filename(1, 2, "Most!", ".mkv", Some(3)),
"S01E02 - Most!.pt3.mkv"
);
assert_eq!(
episode_filename(1, 2, "", ".mkv", Some(2)),
"S01E02.pt2.mkv"
);
}
#[test]
fn test_sanitize_title_cases() {
assert_eq!(sanitize_title("Most!"), "Most!");
assert_eq!(sanitize_title("A: B / C D"), "A B C D");
assert_eq!(sanitize_title(" trailing. "), "trailing");
}
#[test]
fn test_is_season_folder_cases() {
assert!(is_season_folder("Season 01"));
assert!(is_season_folder("Season 1"));
assert!(is_season_folder("Specials"));
assert!(!is_season_folder("Extras"));
assert!(!is_season_folder("Season"));
}
#[test]
fn test_series_folder_stem_cases() {
assert_eq!(
series_folder_stem("Breaking Bad", Some("2008"), Some(1396)).unwrap(),
"Breaking Bad (2008) [tmdbid-1396]"
);
assert_eq!(
series_folder_stem("Neznámý", None, Some(42)).unwrap(),
"Neznámý [tmdbid-42]"
);
assert_eq!(
series_folder_stem("Jen název", None, None).unwrap(),
"Jen název"
);
assert!(series_folder_stem(" ", None, None).is_err());
}
#[test]
fn test_parse_episode_part_cases() {
for (name, expected) in [
("Show S01E02.pt2.mkv", Some(2)),
("Show S01E01-part-1.mkv", Some(1)),
("Show S01E01 part 1.mkv", Some(1)),
("Show S01E01_cd1.mkv", Some(1)),
("Show S01E01.disc2.mkv", Some(2)),
("Show S01E01.mkv", None),
("Show Abcd2.mkv", None),
("Show Part of Something.mkv", None),
] {
assert_eq!(parse_episode_part(name), expected, "{name}");
}
}
#[test]
fn test_parse_episode_code_cases() {
for (text, expected) in [
("S01E02P3", Some((1, 2, Some(3)))),
("s1e2p3", Some((1, 2, Some(3)))),
("S01E02 pt3", Some((1, 2, Some(3)))),
("S01E02.part.3", Some((1, 2, Some(3)))),
("S01E02 cd2", Some((1, 2, Some(2)))),
("S01E02", Some((1, 2, None))),
("1x02", Some((1, 2, None))),
("no code here", None),
] {
assert_eq!(parse_episode_code(text), expected, "{text}");
}
}
#[test]
fn test_episode_code_property() {
let ep = |s, e, p| Episode {
path: PathBuf::new(),
season: s,
episode: e,
part: p,
};
assert_eq!(ep(Some(1), Some(2), Some(3)).code(), "S01E02.pt3");
assert_eq!(ep(Some(1), Some(2), None).code(), "S01E02");
assert_eq!(ep(None, None, None).code(), "?");
}
#[test]
fn test_is_canonical_episode_cases() {
let dir = tempfile::tempdir().unwrap();
let season = dir.path().join("Season 01");
assert!(is_canonical_episode(
&season.join("S01E02 - Breaking Bad.mkv"),
"Breaking Bad"
));
assert!(!is_canonical_episode(
&dir.path().join("S01E02 - Breaking Bad.mkv"),
"Breaking Bad"
));
assert!(!is_canonical_episode(
&season.join("S01E02 - Pilot.mkv"),
"Breaking Bad"
));
assert!(is_canonical_episode(
&season.join("S01E02 - Show.pt2.mkv"),
"Show"
));
}
#[test]
fn test_scan_series_reads_tree() {
let dir = tempfile::tempdir().unwrap();
let s1 = dir
.path()
.join("Breaking Bad (2008) [tmdbid-1396]/Season 01");
fs::create_dir_all(&s1).unwrap();
fs::write(s1.join("S01E01 - Breaking Bad.mkv"), "").unwrap();
fs::write(s1.join("S01E02 - Breaking Bad.mkv"), "").unwrap();
fs::write(s1.join("S01E02 - Breaking Bad.cs.srt"), "").unwrap();
let found = scan_series(dir.path(), "Seriály", false);
assert_eq!(found.len(), 1);
let ser = &found[0];
assert_eq!(
(ser.title.as_str(), ser.year.as_deref(), ser.tmdb_id),
("Breaking Bad", Some("2008"), Some(1396))
);
assert_eq!(ser.episodes.len(), 2);
assert_eq!(ser.seasons().keys().copied().collect::<Vec<_>>(), vec![1]);
}
#[test]
fn test_scan_series_skips_movie_folders_in_mixed() {
let dir = tempfile::tempdir().unwrap();
let movie = dir.path().join("Avatar (2009) [tmdbid-19995]");
fs::create_dir_all(&movie).unwrap();
fs::write(movie.join("Avatar (2009) [tmdbid-19995] - 4K.mkv"), "").unwrap();
assert!(scan_series(dir.path(), "Mix", true).is_empty());
assert_eq!(scan_series(dir.path(), "Mix", false).len(), 1);
}
#[test]
fn test_collect_episode_files_walks_subfolders() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("Breaking.Bad.COMPLETE");
for f in [
"Season 01/BB S01E01.mkv",
"Season 01/BB S01E02.mkv",
"Season 01/BB S01E02.srt",
"Season 02/BB S02E01.avi",
"readme.nfo",
] {
fs::create_dir_all(root.join(f).parent().unwrap()).unwrap();
fs::write(root.join(f), "x").unwrap();
}
let names: Vec<String> = collect_episode_files(&root)
.iter()
.map(|p| paths::name(p))
.collect();
assert_eq!(
names,
vec!["BB S01E01.mkv", "BB S01E02.mkv", "BB S02E01.avi"]
);
assert!(collect_episode_files(&dir.path().join("nope")).is_empty());
}
}
-157
View File
@@ -1,157 +0,0 @@
"""External subtitle sidecars — attach, discover, keep in sync on rename.
Curator does not mux/embed subtitles. Jellyfin plays an **external** subtitle
file natively as long as it shares the video's filename stem, with an optional
language code and flag suffix::
Title (rok) [tmdbid-N].mkv
Title (rok) [tmdbid-N].cs.srt # language
Title (rok) [tmdbid-N].cs.forced.srt # + a flag (forced / sdh / default)
Subtitle presence is a **filesystem fact**, like NFO — nothing is cached in the
pool index. What Curator does own: copying a picked file into place with the
right name (:func:`attach_subtitle`), and moving a subtitle along whenever its
video gets renamed so it never silently detaches (:func:`rename_subtitles_with`).
"""
from __future__ import annotations
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".sub", ".vtt"}
_FLAGS = {"forced", "sdh", "default"}
_LANG_RE = re.compile(r"[a-z]{2,3}")
@dataclass
class SubtitleFile:
"""One subtitle sidecar found next to a video."""
path: Path
language: str | None = None # ISO 639-1/2 code, e.g. "cs"
flag: str | None = None # "forced" | "sdh" | "default"
@property
def label(self) -> str:
"""Human-readable ``jazyk (flag)`` for display, e.g. ``cs (forced)``."""
text = self.language or "?"
return f"{text} ({self.flag})" if self.flag else text
def subtitle_filename(
video_stem: str,
ext: str,
language: str | None = None,
flag: str | None = None,
) -> str:
"""Canonical subtitle filename: ``{video_stem}[.language][.flag]{ext}``."""
parts = [video_stem]
if language:
parts.append(language.strip().lower())
if flag:
parts.append(flag.strip().lower())
return ".".join(parts) + ext.lower()
def _parse_subtitle_name(
video_stem: str, filename: str
) -> tuple[str | None, str | None] | None:
"""``(language, flag)`` if ``filename`` is a subtitle sidecar of ``video_stem``."""
candidate = Path(filename)
if candidate.suffix.lower() not in SUBTITLE_EXTENSIONS:
return None
stem = candidate.stem
if stem == video_stem:
return None, None
if not stem.startswith(f"{video_stem}."):
return None
tokens = [t for t in stem[len(video_stem) + 1 :].split(".") if t]
if not tokens:
return None, None
language: str | None = None
flag: str | None = None
for token in tokens:
low = token.lower()
if low in _FLAGS:
flag = low
elif language is None and _LANG_RE.fullmatch(low):
language = low
return language, flag
def find_subtitles(video_path: Path) -> list[SubtitleFile]:
"""Every external subtitle sidecar sitting next to ``video_path``, sorted."""
video_path = Path(video_path)
folder = video_path.parent
if not folder.is_dir():
return []
found: list[SubtitleFile] = []
for f in sorted(folder.iterdir()):
if not f.is_file():
continue
parsed = _parse_subtitle_name(video_path.stem, f.name)
if parsed is None:
continue
language, flag = parsed
found.append(SubtitleFile(f, language, flag))
return found
def attach_subtitle(
video_path: Path,
source: Path,
*,
language: str | None = None,
flag: str | None = None,
move: bool = False,
) -> Path:
"""Copy (or move) ``source`` next to ``video_path``, named to match it.
An existing subtitle already at that exact name (e.g. replacing the Czech
track) is overwritten; nothing else next to the video is touched.
Raises:
ValueError: ``source``'s extension isn't a recognised subtitle format.
"""
video_path = Path(video_path)
source = Path(source)
ext = source.suffix.lower()
if ext not in SUBTITLE_EXTENSIONS:
raise ValueError(f"Nepodporovaný formát titulků: {source.suffix}")
target = video_path.parent / subtitle_filename(video_path.stem, ext, language, flag)
if target.exists():
target.unlink()
if move:
shutil.move(str(source), str(target))
else:
shutil.copy2(source, target)
return target
def rename_subtitles_with(old_video_path: Path, new_video_path: Path) -> list[Path]:
"""Move every subtitle sidecar of ``old_video_path`` to match ``new_video_path``.
Call this wherever a video gets renamed/moved so an attached subtitle never
silently detaches (its stem would stop matching and Jellyfin would drop it).
No-op when the video's path didn't actually change. Returns the new paths.
"""
old_video_path = Path(old_video_path)
new_video_path = Path(new_video_path)
if old_video_path == new_video_path:
return []
moved: list[Path] = []
for sub in find_subtitles(old_video_path):
target = new_video_path.parent / subtitle_filename(
new_video_path.stem, sub.path.suffix, sub.language, sub.flag
)
if sub.path == target:
continue
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
target.unlink()
sub.path.rename(target)
moved.append(target)
return moved
+282
View File
@@ -0,0 +1,282 @@
//! External subtitle sidecars — attach, discover, keep in sync on rename.
//!
//! Jellyfin plays an external subtitle file natively as long as it shares the
//! video's filename stem, with an optional language code and flag suffix:
//!
//! ```text
//! Title (rok) [tmdbid-N].mkv
//! Title (rok) [tmdbid-N].cs.srt # language
//! Title (rok) [tmdbid-N].cs.forced.srt # + a flag (forced / sdh / default)
//! ```
//!
//! Subtitle presence is a filesystem fact — nothing is cached in the pool index.
use std::fs;
use std::path::{Path, PathBuf};
use super::error::{CoreError, CoreResult};
use super::paths;
pub const SUBTITLE_EXTENSIONS: &[&str] = &[".srt", ".ass", ".ssa", ".sub", ".vtt"];
const FLAGS: &[&str] = &["forced", "sdh", "default"];
/// One subtitle sidecar found next to a video.
#[derive(Debug, Clone, PartialEq)]
pub struct SubtitleFile {
pub path: PathBuf,
/// ISO 639-1/2 code, e.g. "cs".
pub language: Option<String>,
/// "forced" | "sdh" | "default".
pub flag: Option<String>,
}
impl SubtitleFile {
/// Human-readable `jazyk (flag)`, e.g. `cs (forced)`.
pub fn label(&self) -> String {
let text = self.language.as_deref().unwrap_or("?");
match &self.flag {
Some(f) => format!("{text} ({f})"),
None => text.to_string(),
}
}
}
pub fn is_subtitle(path: &Path) -> bool {
SUBTITLE_EXTENSIONS.contains(&paths::suffix_lower(path).as_str())
}
/// Canonical subtitle filename: `{video_stem}[.language][.flag]{ext}`.
pub fn subtitle_filename(
video_stem: &str,
ext: &str,
language: Option<&str>,
flag: Option<&str>,
) -> String {
let mut parts = vec![video_stem.to_string()];
if let Some(l) = language.filter(|l| !l.is_empty()) {
parts.push(l.trim().to_lowercase());
}
if let Some(f) = flag.filter(|f| !f.is_empty()) {
parts.push(f.trim().to_lowercase());
}
parts.join(".") + &ext.to_lowercase()
}
fn is_language_code(token: &str) -> bool {
(2..=3).contains(&token.len()) && token.chars().all(|c| c.is_ascii_lowercase())
}
/// `(language, flag)` if `filename` is a subtitle sidecar of `video_stem`.
fn parse_subtitle_name(
video_stem: &str,
filename: &str,
) -> Option<(Option<String>, Option<String>)> {
let candidate = Path::new(filename);
if !is_subtitle(candidate) {
return None;
}
let stem = paths::stem(candidate);
if stem == video_stem {
return Some((None, None));
}
let rest = stem.strip_prefix(&format!("{video_stem}."))?;
let mut language = None;
let mut flag = None;
for token in rest.split('.').filter(|t| !t.is_empty()) {
let low = token.to_lowercase();
if FLAGS.contains(&low.as_str()) {
flag = Some(low);
} else if language.is_none() && is_language_code(&low) {
language = Some(low);
}
}
Some((language, flag))
}
/// Every external subtitle sidecar sitting next to `video_path`, sorted.
pub fn find_subtitles(video_path: &Path) -> Vec<SubtitleFile> {
let Some(folder) = video_path.parent().filter(|f| f.is_dir()) else {
return Vec::new();
};
let video_stem = paths::stem(video_path);
paths::entries(folder)
.into_iter()
.filter(|f| f.is_file())
.filter_map(|f| {
let (language, flag) = parse_subtitle_name(&video_stem, &paths::name(&f))?;
Some(SubtitleFile {
path: f,
language,
flag,
})
})
.collect()
}
/// Copy (or move) `source` next to `video_path`, named to match it. An existing
/// subtitle at that exact name is overwritten.
pub fn attach_subtitle(
video_path: &Path,
source: &Path,
language: Option<&str>,
flag: Option<&str>,
move_source: bool,
) -> CoreResult<PathBuf> {
let ext = paths::suffix_lower(source);
if !SUBTITLE_EXTENSIONS.contains(&ext.as_str()) {
return Err(CoreError::invalid(format!(
"Nepodporovaný formát titulků: {}",
paths::suffix(source)
)));
}
let folder = video_path.parent().unwrap_or(Path::new("."));
let target = folder.join(subtitle_filename(
&paths::stem(video_path),
&ext,
language,
flag,
));
if target.exists() {
fs::remove_file(&target)?;
}
if move_source {
paths::move_file(source, &target)?;
} else {
paths::copy_preserving(source, &target)?;
}
Ok(target)
}
/// Move every subtitle sidecar of `old_video` to match `new_video`, so an
/// attached subtitle never silently detaches. Returns the new paths.
pub fn rename_subtitles_with(old_video: &Path, new_video: &Path) -> CoreResult<Vec<PathBuf>> {
if old_video == new_video {
return Ok(Vec::new());
}
let new_folder = new_video.parent().unwrap_or(Path::new("."));
let new_stem = paths::stem(new_video);
let mut moved = Vec::new();
for sub in find_subtitles(old_video) {
let target = new_folder.join(subtitle_filename(
&new_stem,
&paths::suffix(&sub.path),
sub.language.as_deref(),
sub.flag.as_deref(),
));
if sub.path == target {
continue;
}
fs::create_dir_all(new_folder)?;
if target.exists() {
fs::remove_file(&target)?;
}
paths::move_file(&sub.path, &target)?;
moved.push(target);
}
Ok(moved)
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(p: &Path) {
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, "x").unwrap();
}
#[test]
fn test_subtitle_filename_variants() {
assert_eq!(
subtitle_filename("Film (2009)", ".srt", None, None),
"Film (2009).srt"
);
assert_eq!(
subtitle_filename("Film (2009)", ".srt", Some("cs"), None),
"Film (2009).cs.srt"
);
assert_eq!(
subtitle_filename("Film (2009)", ".SRT", Some("CS"), Some("Forced")),
"Film (2009).cs.forced.srt"
);
}
#[test]
fn test_find_subtitles_bare_language_and_flagged() {
let dir = tempfile::tempdir().unwrap();
let video = dir.path().join("Film (2009).mkv");
touch(&video);
touch(&dir.path().join("Film (2009).srt"));
touch(&dir.path().join("Film (2009).cs.srt"));
touch(&dir.path().join("Film (2009).en.forced.ass"));
touch(&dir.path().join("Film (2009) 2.cs.srt"));
touch(&dir.path().join("Jiný film.cs.srt"));
let found: Vec<(Option<String>, Option<String>)> = find_subtitles(&video)
.into_iter()
.map(|s| (s.language, s.flag))
.collect();
assert_eq!(
found,
vec![
(Some("cs".into()), None),
(Some("en".into()), Some("forced".into())),
(None, None),
]
);
assert!(find_subtitles(&dir.path().join("nope/x.mkv")).is_empty());
}
#[test]
fn test_label() {
let s = SubtitleFile {
path: PathBuf::new(),
language: Some("cs".into()),
flag: Some("forced".into()),
};
assert_eq!(s.label(), "cs (forced)");
let s = SubtitleFile {
path: PathBuf::new(),
language: None,
flag: None,
};
assert_eq!(s.label(), "?");
}
#[test]
fn test_attach_copies_moves_and_overwrites() {
let dir = tempfile::tempdir().unwrap();
let video = dir.path().join("movie/Film (2009).mkv");
touch(&video);
let src = dir.path().join("download/titulky.SRT");
touch(&src);
let target = attach_subtitle(&video, &src, Some("cs"), None, false).unwrap();
assert_eq!(target, dir.path().join("movie/Film (2009).cs.srt"));
assert!(src.exists());
fs::write(&src, "new").unwrap();
attach_subtitle(&video, &src, Some("cs"), None, true).unwrap();
assert!(!src.exists());
assert_eq!(fs::read_to_string(&target).unwrap(), "new");
let bad = dir.path().join("x.txt");
touch(&bad);
assert!(attach_subtitle(&video, &bad, None, None, false).is_err());
}
#[test]
fn test_rename_subtitles_with_moves_across_folders() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("Filmy/Old (2009).mkv");
touch(&old);
touch(&dir.path().join("Filmy/Old (2009).cs.forced.srt"));
let new = dir.path().join("Filmy/New (2009)/New (2009) - 4K.mkv");
let moved = rename_subtitles_with(&old, &new).unwrap();
assert_eq!(
moved,
vec![
dir.path()
.join("Filmy/New (2009)/New (2009) - 4K.cs.forced.srt")
]
);
assert!(moved[0].exists());
assert!(rename_subtitles_with(&old, &old).unwrap().is_empty());
}
}
-22
View File
@@ -1,22 +0,0 @@
class Tag:
def __init__(self, category: str, name: str):
self.category = category
self.name = name
@property
def full_path(self):
return f"{self.category}/{self.name}"
def __str__(self):
return self.full_path
def __repr__(self):
return f"Tag({self.full_path})"
def __eq__(self, other):
if isinstance(other, Tag):
return self.category == other.category and self.name == other.name
return False
def __hash__(self):
return hash((self.category, self.name))
+57
View File
@@ -0,0 +1,57 @@
//! A tag: a `Category/Name` pair attached to a movie.
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Tag {
pub category: String,
pub name: String,
}
impl Tag {
pub fn new(category: impl Into<String>, name: impl Into<String>) -> Self {
Self {
category: category.into(),
name: name.into(),
}
}
/// `Category/Name` — the form tags are stored and filtered by.
pub fn full_path(&self) -> String {
format!("{}/{}", self.category, self.name)
}
/// Parse a `Category/Name` path (split on the first `/`).
pub fn from_full_path(path: &str) -> Option<Self> {
let (category, name) = path.split_once('/')?;
Some(Self::new(category, name))
}
}
impl fmt::Display for Tag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.category, self.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_full_path_joins_category_and_name() {
assert_eq!(Tag::new("Žánr", "Drama").full_path(), "Žánr/Drama");
}
#[test]
fn test_from_full_path_splits_on_first_slash() {
assert_eq!(Tag::from_full_path("A/B/C"), Some(Tag::new("A", "B/C")));
assert_eq!(Tag::from_full_path("nocategory"), None);
}
#[test]
fn test_equality_compares_both_parts() {
assert_eq!(Tag::new("A", "x"), Tag::new("A", "x"));
assert_ne!(Tag::new("A", "x"), Tag::new("B", "x"));
}
}
-69
View File
@@ -1,69 +0,0 @@
from .tag import Tag
# Default tags that are always available (order in list = display order).
# The legacy Tagger presets (Hodnocení / Barva) were removed for Curator; the
# pool is driven by ČSFD-derived tags (Žánr / Rok / Země původu). Add entries here to
# reintroduce always-available predefined tags.
DEFAULT_TAGS: dict[str, list[str]] = {}
# Tag sort order for default categories (preserves display order)
DEFAULT_TAG_ORDER: dict[str, dict[str, int]] = {
category: {name: i for i, name in enumerate(names)}
for category, names in DEFAULT_TAGS.items()
}
class TagManager:
def __init__(self):
self.tags_by_category = {} # {category: set(Tag)}
self._init_default_tags()
def _init_default_tags(self):
"""Initialize default tags (ratings and colors)"""
for category, tags in DEFAULT_TAGS.items():
for tag_name in tags:
self.add_tag(category, tag_name)
def add_category(self, category: str):
if category not in self.tags_by_category:
self.tags_by_category[category] = set()
def remove_category(self, category: str):
if category in self.tags_by_category:
del self.tags_by_category[category]
def add_tag(self, category: str, name: str) -> Tag:
self.add_category(category)
tag = Tag(category, name)
self.tags_by_category[category].add(tag)
return tag
def remove_tag(self, category: str, name: str):
if category in self.tags_by_category:
tag = Tag(category, name)
self.tags_by_category[category].discard(tag)
if not self.tags_by_category[category]:
self.remove_category(category)
def get_all_tags(self):
"""Vrací list všech tagů full_path"""
return [
tag.full_path for tags in self.tags_by_category.values() for tag in tags
]
def get_categories(self):
return list(self.tags_by_category.keys())
def get_tags_in_category(self, category: str) -> list[Tag]:
"""Get tags in category, sorted by predefined order for default categories"""
tags = list(self.tags_by_category.get(category, []))
# Use predefined order for default categories
if category in DEFAULT_TAG_ORDER:
order = DEFAULT_TAG_ORDER[category]
tags.sort(key=lambda t: order.get(t.name, 999))
else:
# Sort alphabetically for custom categories
tags.sort(key=lambda t: t.name)
return tags
+130
View File
@@ -0,0 +1,130 @@
//! The registry of all tags currently known, grouped by category.
use std::collections::BTreeSet;
use super::tag::Tag;
/// Categories keep their insertion order (the sidebar shows them that way);
/// tags inside a category are sorted by name.
#[derive(Debug, Default, Clone)]
pub struct TagManager {
categories: Vec<(String, BTreeSet<Tag>)>,
}
impl TagManager {
pub fn new() -> Self {
Self::default()
}
fn position(&self, category: &str) -> Option<usize> {
self.categories.iter().position(|(c, _)| c == category)
}
pub fn add_category(&mut self, category: &str) {
if self.position(category).is_none() {
self.categories
.push((category.to_string(), BTreeSet::new()));
}
}
pub fn remove_category(&mut self, category: &str) {
if let Some(i) = self.position(category) {
self.categories.remove(i);
}
}
pub fn add_tag(&mut self, category: &str, name: &str) -> Tag {
self.add_category(category);
let tag = Tag::new(category, name);
if let Some(i) = self.position(category) {
self.categories[i].1.insert(tag.clone());
}
tag
}
/// Remove a tag; a category left empty disappears with it.
pub fn remove_tag(&mut self, category: &str, name: &str) {
if let Some(i) = self.position(category) {
self.categories[i].1.remove(&Tag::new(category, name));
if self.categories[i].1.is_empty() {
self.categories.remove(i);
}
}
}
pub fn clear(&mut self) {
self.categories.clear();
}
pub fn all_tags(&self) -> Vec<String> {
self.categories
.iter()
.flat_map(|(_, tags)| tags.iter().map(Tag::full_path))
.collect()
}
pub fn categories(&self) -> Vec<String> {
self.categories.iter().map(|(c, _)| c.clone()).collect()
}
/// Tags of one category, sorted by name.
pub fn tags_in_category(&self, category: &str) -> Vec<Tag> {
let mut tags: Vec<Tag> = self
.position(category)
.map(|i| self.categories[i].1.iter().cloned().collect())
.unwrap_or_default();
tags.sort_by(|a, b| a.name.cmp(&b.name));
tags
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_tag_creates_category() {
let mut tm = TagManager::new();
tm.add_tag("Žánr", "Drama");
assert_eq!(tm.categories(), vec!["Žánr"]);
assert_eq!(tm.all_tags(), vec!["Žánr/Drama"]);
}
#[test]
fn test_add_tag_is_idempotent() {
let mut tm = TagManager::new();
tm.add_tag("Rok", "1999");
tm.add_tag("Rok", "1999");
assert_eq!(tm.tags_in_category("Rok").len(), 1);
}
#[test]
fn test_remove_last_tag_drops_category() {
let mut tm = TagManager::new();
tm.add_tag("Rok", "1999");
tm.remove_tag("Rok", "1999");
assert!(tm.categories().is_empty());
}
#[test]
fn test_tags_in_category_sorted_by_name() {
let mut tm = TagManager::new();
tm.add_tag("Žánr", "Komedie");
tm.add_tag("Žánr", "Akční");
let names: Vec<String> = tm
.tags_in_category("Žánr")
.into_iter()
.map(|t| t.name)
.collect();
assert_eq!(names, vec!["Akční", "Komedie"]);
}
#[test]
fn test_categories_keep_insertion_order() {
let mut tm = TagManager::new();
tm.add_tag("Žánr", "Drama");
tm.add_tag("Rok", "1999");
tm.add_tag("Země původu", "Česko");
assert_eq!(tm.categories(), vec!["Žánr", "Rok", "Země původu"]);
}
}
-192
View File
@@ -1,192 +0,0 @@
"""TMDb (The Movie Database) client — Curator's identity source.
Replaces ČSFD. Its only job is to turn a **TMDb id** (or a title/year search)
into a canonical **title + year** for naming the pool file. The rich metadata
and artwork are fetched by **Jellyfin itself** — Curator does not need them here.
Needs a free API key in ``TMDB_API_KEY`` (loaded from ``.env``). Everything
degrades gracefully when the key or the network is missing: :func:`api_available`
reports readiness and the lookups raise :class:`TmdbError` with a clear message.
"""
import contextlib
import os
from dataclasses import dataclass
import requests
# optional — the key can also come from the real environment; dotenv is a
# convenience, never required
with contextlib.suppress(Exception):
from dotenv import load_dotenv
load_dotenv()
_BASE = "https://api.themoviedb.org/3"
_LANG = os.getenv("TMDB_LANGUAGE", "cs-CZ")
_TIMEOUT = 15
@dataclass
class TmdbResult:
"""A resolved TMDb entry — just enough to name the pool file."""
tmdb_id: int
title: str
year: str | None # four-digit string, or None when TMDb has no date
kind: str # "movie" | "tv"
@dataclass
class TmdbDetails:
"""Full TMDb data used to (re)generate a movie's tags (genres etc.).
Exposes :meth:`field_values` so it plugs into the tag schema the same way the
NFO reader does (fields: genres / countries / year / rating).
"""
tmdb_id: int
title: str
year: str | None
genres: list[str]
countries: list[str]
rating: int | None # 0–100 (TMDb vote_average 0–10 scaled up)
def field_values(self, name: str) -> list[str]:
if name == "genres":
return list(self.genres)
if name == "countries":
return list(self.countries)
if name == "year":
return [self.year] if self.year else []
if name == "rating":
return [str(self.rating)] if self.rating is not None else []
return []
class TmdbError(RuntimeError):
"""A TMDb request could not be completed (no key, network, bad status)."""
def api_key() -> str | None:
"""The configured TMDb API key, or None."""
key = os.getenv("TMDB_API_KEY")
return key.strip() if key and key.strip() else None
def api_available() -> bool:
"""True when a TMDb API key is configured."""
return api_key() is not None
def _get(path: str, params: dict) -> dict:
key = api_key()
if not key:
raise TmdbError("TMDB_API_KEY není nastaven (.env).")
query = {"api_key": key, "language": _LANG, **params}
try:
resp = requests.get(f"{_BASE}{path}", params=query, timeout=_TIMEOUT)
except requests.RequestException as exc:
raise TmdbError(f"Síťová chyba TMDb: {exc}") from exc
if resp.status_code == 401:
raise TmdbError("Neplatný TMDB_API_KEY.")
if resp.status_code == 404:
raise TmdbError("TMDb ID nenalezeno.")
if not resp.ok:
raise TmdbError(f"TMDb odpověděl HTTP {resp.status_code}.")
return resp.json()
def _year_of(date_str: str | None) -> str | None:
if date_str and len(date_str) >= 4 and date_str[:4].isdigit():
return date_str[:4]
return None
def lookup_movie(tmdb_id: int) -> TmdbResult | None:
"""Resolve a movie by its TMDb id → title + year, or None if it has no title."""
data = _get(f"/movie/{int(tmdb_id)}", {})
title = data.get("title") or data.get("original_title")
if not title:
return None
return TmdbResult(
int(data["id"]), title, _year_of(data.get("release_date")), "movie"
)
def movie_details(tmdb_id: int) -> TmdbDetails:
"""Full movie data from TMDb (genres/countries/rating/year), localized to cs."""
data = _get(f"/movie/{int(tmdb_id)}", {})
title = data.get("title") or data.get("original_title") or ""
genres = [g["name"] for g in (data.get("genres") or []) if g.get("name")]
countries = [
c["name"] for c in (data.get("production_countries") or []) if c.get("name")
]
vote = data.get("vote_average")
rating = round(float(vote) * 10) if vote else None
return TmdbDetails(
tmdb_id=int(data["id"]),
title=title,
year=_year_of(data.get("release_date")),
genres=genres,
countries=countries,
rating=rating,
)
def lookup_tv(tmdb_id: int) -> TmdbResult | None:
"""Resolve a series by its TMDb id → name + first-air year, or None."""
data = _get(f"/tv/{int(tmdb_id)}", {})
title = data.get("name") or data.get("original_name")
if not title:
return None
return TmdbResult(
int(data["id"]), title, _year_of(data.get("first_air_date")), "tv"
)
def lookup(tmdb_id: int, kind: str = "movie") -> TmdbResult | None:
"""Resolve a TMDb id as a movie (default) or a series (``kind="tv"``)."""
return lookup_tv(tmdb_id) if kind == "tv" else lookup_movie(tmdb_id)
def search_movie(
query: str, year: str | None = None, limit: int = 5
) -> list[TmdbResult]:
"""Search movies by title (optionally narrowed by year); best matches first."""
if not query.strip():
return []
params: dict = {"query": query.strip()}
if year:
params["year"] = year
data = _get("/search/movie", params)
results: list[TmdbResult] = []
for item in (data.get("results") or [])[:limit]:
title = item.get("title") or item.get("original_title")
if title and item.get("id"):
results.append(
TmdbResult(
int(item["id"]), title, _year_of(item.get("release_date")), "movie"
)
)
return results
def search_tv(query: str, year: str | None = None, limit: int = 5) -> list[TmdbResult]:
"""Search series by name (optionally narrowed by first-air year)."""
if not query.strip():
return []
params: dict = {"query": query.strip()}
if year:
params["first_air_date_year"] = year
data = _get("/search/tv", params)
results: list[TmdbResult] = []
for item in (data.get("results") or [])[:limit]:
title = item.get("name") or item.get("original_name")
if title and item.get("id"):
results.append(
TmdbResult(
int(item["id"]), title, _year_of(item.get("first_air_date")), "tv"
)
)
return results
+293
View File
@@ -0,0 +1,293 @@
//! TMDb (The Movie Database) client — Curator's identity source.
//!
//! Turns a **TMDb id** (or a title/year search) into a canonical **title +
//! year** for naming, and supplies genres/countries/rating for tags. Needs a
//! free API key in `TMDB_API_KEY` (from `.env`). Everything degrades gracefully
//! when the key or the network is missing.
use serde_json::Value;
use super::error::CoreError;
use super::http::{client, env_nonempty};
const BASE: &str = "https://api.themoviedb.org/3";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Movie,
Tv,
}
/// A resolved TMDb entry — just enough to name the pool file.
#[derive(Debug, Clone, PartialEq)]
pub struct TmdbResult {
pub tmdb_id: i64,
pub title: String,
/// Four-digit string, or `None` when TMDb has no date.
pub year: Option<String>,
pub kind: Kind,
}
/// Full movie data used to (re)generate a movie's tags.
#[derive(Debug, Clone, PartialEq)]
pub struct TmdbDetails {
pub tmdb_id: i64,
pub title: String,
pub year: Option<String>,
pub genres: Vec<String>,
pub countries: Vec<String>,
/// 0–100 (vote_average 0–10 scaled up).
pub rating: Option<i64>,
}
impl TmdbDetails {
pub fn field_values(&self, name: &str) -> Vec<String> {
match name {
"genres" => self.genres.clone(),
"countries" => self.countries.clone(),
"year" => self.year.iter().cloned().collect(),
"rating" => self.rating.iter().map(|r| r.to_string()).collect(),
_ => Vec::new(),
}
}
}
pub type TmdbResultT<T> = Result<T, CoreError>;
fn err(msg: impl Into<String>) -> CoreError {
CoreError::Remote(msg.into())
}
/// The configured TMDb API key.
pub fn api_key() -> Option<String> {
env_nonempty("TMDB_API_KEY")
}
pub fn api_available() -> bool {
api_key().is_some()
}
fn language() -> String {
env_nonempty("TMDB_LANGUAGE").unwrap_or_else(|| "cs-CZ".into())
}
fn get(path: &str, params: &[(&str, String)]) -> TmdbResultT<Value> {
let key = api_key().ok_or_else(|| err("TMDB_API_KEY není nastaven (.env)."))?;
let mut query: Vec<(&str, String)> = vec![("api_key", key), ("language", language())];
query.extend(params.iter().cloned());
let resp = client()
.get(format!("{BASE}{path}"))
.query(&query)
.timeout(std::time::Duration::from_secs(15))
.send()
.map_err(|e| err(format!("Síťová chyba TMDb: {e}")))?;
match resp.status().as_u16() {
401 => return Err(err("Neplatný TMDB_API_KEY.")),
404 => return Err(err("TMDb ID nenalezeno.")),
s if !resp.status().is_success() => return Err(err(format!("TMDb odpověděl HTTP {s}."))),
_ => {}
}
resp.json()
.map_err(|e| err(format!("Neplatná odpověď TMDb: {e}")))
}
fn year_of(date: Option<&str>) -> Option<String> {
let d = date?;
let head = d.get(..4)?;
head.chars()
.all(|c| c.is_ascii_digit())
.then(|| head.to_string())
}
fn str_of<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|k| v.get(*k).and_then(Value::as_str).filter(|s| !s.is_empty()))
}
fn id_of(v: &Value) -> Option<i64> {
v.get("id").and_then(Value::as_i64).filter(|i| *i != 0)
}
/// Parse a movie or TV payload (detail or search item) into a [`TmdbResult`].
pub fn parse_result(data: &Value, kind: Kind) -> Option<TmdbResult> {
let (title_keys, date_key): (&[&str], &str) = match kind {
Kind::Movie => (&["title", "original_title"], "release_date"),
Kind::Tv => (&["name", "original_name"], "first_air_date"),
};
Some(TmdbResult {
tmdb_id: id_of(data)?,
title: str_of(data, title_keys)?.to_string(),
year: year_of(data.get(date_key).and_then(Value::as_str)),
kind,
})
}
/// Parse a `/movie/{id}` payload into [`TmdbDetails`].
pub fn parse_details(data: &Value) -> Option<TmdbDetails> {
let names = |key: &str| -> Vec<String> {
data.get(key)
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|g| {
g.get("name")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from)
})
.collect()
})
.unwrap_or_default()
};
let rating = data
.get("vote_average")
.and_then(Value::as_f64)
.filter(|v| *v != 0.0)
.map(|v| (v * 10.0).round_ties_even() as i64);
Some(TmdbDetails {
tmdb_id: data.get("id").and_then(Value::as_i64)?,
title: str_of(data, &["title", "original_title"])
.unwrap_or("")
.to_string(),
year: year_of(data.get("release_date").and_then(Value::as_str)),
genres: names("genres"),
countries: names("production_countries"),
rating,
})
}
/// Resolve a TMDb id as a movie or a series; `Ok(None)` when it has no title.
pub fn lookup(tmdb_id: i64, kind: Kind) -> TmdbResultT<Option<TmdbResult>> {
let path = match kind {
Kind::Movie => format!("/movie/{tmdb_id}"),
Kind::Tv => format!("/tv/{tmdb_id}"),
};
Ok(parse_result(&get(&path, &[])?, kind))
}
pub fn lookup_movie(tmdb_id: i64) -> TmdbResultT<Option<TmdbResult>> {
lookup(tmdb_id, Kind::Movie)
}
/// Full movie data from TMDb (genres/countries/rating/year).
pub fn movie_details(tmdb_id: i64) -> TmdbResultT<TmdbDetails> {
parse_details(&get(&format!("/movie/{tmdb_id}"), &[])?)
.ok_or_else(|| err("Neplatná odpověď TMDb."))
}
fn search(
kind: Kind,
query: &str,
year: Option<&str>,
limit: usize,
) -> TmdbResultT<Vec<TmdbResult>> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let (path, year_param) = match kind {
Kind::Movie => ("/search/movie", "year"),
Kind::Tv => ("/search/tv", "first_air_date_year"),
};
let mut params = vec![("query", query.to_string())];
if let Some(y) = year.filter(|y| !y.is_empty()) {
params.push((year_param, y.to_string()));
}
let data = get(path, &params)?;
Ok(data
.get("results")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(limit)
.filter_map(|i| parse_result(i, kind))
.collect()
})
.unwrap_or_default())
}
/// Search movies by title (optionally narrowed by year); best matches first.
pub fn search_movie(query: &str, year: Option<&str>, limit: usize) -> TmdbResultT<Vec<TmdbResult>> {
search(Kind::Movie, query, year, limit)
}
/// Search series by name (optionally narrowed by first-air year).
pub fn search_tv(query: &str, year: Option<&str>, limit: usize) -> TmdbResultT<Vec<TmdbResult>> {
search(Kind::Tv, query, year, limit)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_movie_result() {
let r = parse_result(
&json!({"id": 603, "title": "Matrix", "release_date": "1999-03-30"}),
Kind::Movie,
)
.unwrap();
assert_eq!(
r,
TmdbResult {
tmdb_id: 603,
title: "Matrix".into(),
year: Some("1999".into()),
kind: Kind::Movie
}
);
}
#[test]
fn test_parse_falls_back_to_original_title_and_no_date() {
let r = parse_result(
&json!({"id": 1, "title": "", "original_title": "Orig", "release_date": ""}),
Kind::Movie,
)
.unwrap();
assert_eq!(r.title, "Orig");
assert_eq!(r.year, None);
assert!(parse_result(&json!({"id": 1}), Kind::Movie).is_none());
}
#[test]
fn test_parse_tv_result() {
let r = parse_result(
&json!({"id": 1396, "name": "Perníkový táta", "first_air_date": "2008-01-20"}),
Kind::Tv,
)
.unwrap();
assert_eq!(
(r.title.as_str(), r.year.as_deref()),
("Perníkový táta", Some("2008"))
);
}
#[test]
fn test_parse_details() {
let d = parse_details(&json!({
"id": 603, "title": "Matrix", "release_date": "1999-03-30", "vote_average": 8.25,
"genres": [{"name": "Akční"}, {"name": "Sci-Fi"}],
"production_countries": [{"name": "USA"}]
}))
.unwrap();
assert_eq!(d.genres, vec!["Akční", "Sci-Fi"]);
assert_eq!(d.countries, vec!["USA"]);
assert_eq!(d.rating, Some(82));
assert_eq!(d.field_values("year"), vec!["1999"]);
let none = parse_details(&json!({"id": 1, "vote_average": 0})).unwrap();
assert_eq!(none.rating, None);
}
#[test]
fn test_without_key_errors_cleanly() {
if api_available() {
return;
}
let e = lookup_movie(603).unwrap_err();
assert!(e.to_string().contains("TMDB_API_KEY"));
assert!(search_movie(" ", None, 5).unwrap().is_empty());
}
}
-42
View File
@@ -1,42 +0,0 @@
"""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 collections.abc import Callable
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) -> 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: dict[str | None, Callable[[str], str]] = {
None: str,
"identity": str,
"decade_band": _decade_band,
}
def apply_transform(value: str, transform: str | None) -> str:
"""Apply a named tag-schema transform to a value, returning a string."""
fn = TAG_TRANSFORMS.get(transform, str)
try:
return str(fn(value))
except ValueError, TypeError, AttributeError:
return str(value)

Some files were not shown because too many files have changed in this diff Show More