Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b6de20b67 | ||
|
|
d022e241a9 | ||
|
|
65b5b74c9f | ||
|
|
364b515e3c |
+78
-9
@@ -16,24 +16,91 @@ 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
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
- **The release binary is 2.5× smaller** (`curator` 42.7 → 17 MB, `tipy-dne`
|
||||
6 → 1.8 MB): the release profile strips debug symbols, uses LTO with a single
|
||||
codegen unit and optimises for size (`opt-level = "s"`), and the GUI renders
|
||||
through OpenGL (`glow`) instead of `wgpu`.
|
||||
|
||||
### Dependencies
|
||||
- `eframe` with the `glow` renderer only (default features off; `wgpu` and the
|
||||
web screen-reader feature dropped).
|
||||
|
||||
## 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
|
||||
- **The codebase is mypy-clean** and plain `uv run mypy` works (`[tool.mypy]`
|
||||
in `pyproject.toml` lists the targets). Qt enums are now fully qualified
|
||||
(`QDialog.DialogCode.Accepted`, `QMessageBox.StandardButton.Yes`,
|
||||
`Qt.GlobalColor.red`, …) instead of PySide6's short aliases, and the few
|
||||
loosely typed spots (tag schema, transforms, `on_files_changed`, dialog cell
|
||||
widgets) are annotated. No behaviour change.
|
||||
|
||||
### Removed
|
||||
- `src/core/media_utils.py` — Tagger-era leftovers (`load_icon` returned a
|
||||
Tkinter image, `add_video_resolution_tag` logged via `print()`); nothing in
|
||||
the app called either.
|
||||
|
||||
### Dependencies
|
||||
- Removed `pillow` — `media_utils` was its only user.
|
||||
|
||||
## 1.13.0 — 2026-10-12
|
||||
|
||||
### Changed
|
||||
- **The standalone build is now a single file.** `Curator.spec` produces one
|
||||
self-contained `dist/Curator` executable (onefile) instead of a
|
||||
`dist/Curator/` folder of libraries. It unpacks to a temp folder on every
|
||||
start, so launching takes a few seconds longer.
|
||||
|
||||
### Dependencies
|
||||
- Added `pyinstaller` to the dev group — `uv run pyinstaller Curator.spec`
|
||||
now works out of the box (it was used for builds but never declared).
|
||||
|
||||
## 1.13.0 — 2026-10-12
|
||||
|
||||
### Changed
|
||||
- **Custom attributes are gone from the GUI.** The attribute table in
|
||||
*Upravit film* and *Filmy → "Nastavit atribut…"* (menu and context menu) are
|
||||
removed. Values already stored keep working in miniDLNA filename templates
|
||||
@@ -473,6 +540,8 @@ Each version entry uses these sections (include only those that apply):
|
||||
Commands are now `uv sync` / `uv add` / `uv run` (`DESIGN_DOCUMENT_PYTHON.md`
|
||||
§11). Dependency lower bounds moved up to the versions uv resolved; Poetry's
|
||||
upper bounds (`<7.0.0`, …) are gone.
|
||||
- Added `pyinstaller` to the dev group — `uv run pyinstaller Curator.spec`
|
||||
now works out of the box (it was used for builds but never declared).
|
||||
- **ruff 0.15 → 0.17** (also mypy 2.4, pytest 9.1, PySide6 6.12, Pillow 12.3,
|
||||
python-dotenv 1.2.4). ruff 0.17 enables a much wider default rule set; the
|
||||
whole codebase was brought in line with it — see *Changed* below.
|
||||
|
||||
Generated
+6161
File diff suppressed because it is too large
Load Diff
+44
@@ -0,0 +1,44 @@
|
||||
[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 = { version = "0.37.0", default-features = false, features = ["accesskit", "clipboard", "default_fonts", "links", "system_fonts", "wayland", "x11", "glow"] }
|
||||
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"
|
||||
|
||||
[profile.release]
|
||||
# smaller binary: no debug symbols, whole-program optimisation
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
opt-level = "s"
|
||||
-35
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -1,60 +1,91 @@
|
||||
# Curator
|
||||
|
||||
Curator is a desktop **movie-library manager (Filmotéka)**. You import video
|
||||
files into a managed **pool**, tag them, and Curator generates a browsable
|
||||
**Filmotéka** — a directory tree of hardlinks into the pool — without ever
|
||||
duplicating the actual 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.
|
||||
|
||||
Curator is a fork of the former *Tagger* project; the tagging, filtering and
|
||||
hardlink-tree machinery is inherited and extended into a full library workflow.
|
||||
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
|
||||
|
||||
- **Pool** — the managed repository of files and the **single source of truth**.
|
||||
Curator owns it: it imports and removes files itself, so nothing moves behind
|
||||
its back. The pool has two top-level folders:
|
||||
- `Filmy` — movies, organized by tags into the generated tree.
|
||||
- `Seriály` — series (and any other **copy-as-is** folder), mirrored verbatim.
|
||||
- **Filmotéka (output)** — a generated tree of **hardlinks** into the pool. It is
|
||||
fully disposable: delete it and nothing is lost, because it is regenerated from
|
||||
the pool on demand.
|
||||
- **Pool index** — all pool metadata lives in a single `.Curator.!index` JSON
|
||||
file at the pool root (title, ČSFD link, tags, date per file).
|
||||
- **Pool** — the managed folder and the **single source of truth**. Curator
|
||||
imports, renames and removes files itself, so nothing moves behind its back.
|
||||
Each top-level folder is a **library** with a kind:
|
||||
|
||||
## Workflow
|
||||
| Kind | Holds | Layout |
|
||||
|------|-------|--------|
|
||||
| `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 |
|
||||
|
||||
1. Configure the **pool** and the **Filmotéka output** folder.
|
||||
2. **Import a movie**: pick a video, enter its **Title** and a **ČSFD link**. The
|
||||
file is copied (non-destructively) into `pool/Filmy` as `Title.ext` and
|
||||
recorded in the index. If a ČSFD link is given, Curator fetches the movie from
|
||||
[ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země původu** tags
|
||||
automatically (use "Načíst tagy z ČSFD" to (re)fetch later).
|
||||
3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI.
|
||||
4. **Generate the Filmotéka**: movies become a `Category/Tag/film` hardlink tree
|
||||
(from the `Rok` / `Žánr` / `Hodnocení` categories); copy-as-is folders such as
|
||||
`Seriály` are mirrored 1:1 as hardlinks.
|
||||
- **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 genres / years / countries / ratings. It is disposable: delete it and
|
||||
regenerate it at any time.
|
||||
|
||||
## Features
|
||||
|
||||
- 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,
|
||||
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
|
||||
|
||||
- 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 working directory
|
||||
(never committed):
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `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 logging and the `-DEV` title |
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
The GUI is built with **PySide6**.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv run pytest # tests
|
||||
uv run ruff check # lint / 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
|
||||
```
|
||||
|
||||
## Building a standalone executable
|
||||
`cargo run --example screenshot -- out.png [--action edit]` renders the window
|
||||
offscreen to a PNG. Architecture and module overview: [`docs/`](docs/README.md).
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
uv run pyinstaller Curator.spec
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The resulting `dist/Curator` is the distribution artifact.
|
||||
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`).
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+35
-33
@@ -1,42 +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/media_utils.py`](modules/core.media_utils.md) | Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/<height>p` tag. |
|
||||
| [`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
@@ -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.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# `src/_version.py`
|
||||
|
||||
Auto-generated — do not edit manually.
|
||||
|
||||
Source: [`src/_version.py`](../../src/_version.py)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,27 +0,0 @@
|
||||
# `src/core/media_utils.py`
|
||||
|
||||
Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/<height>p` tag.
|
||||
|
||||
Source: [`src/core/media_utils.py`](../../src/core/media_utils.py)
|
||||
|
||||
## Functions
|
||||
|
||||
### `load_icon`
|
||||
|
||||
*function · line 14*
|
||||
|
||||
```python
|
||||
load_icon(path) -> ImageTk.PhotoImage
|
||||
```
|
||||
|
||||
Load an image file and scale it to 16×16 px as a Qt-compatible icon.
|
||||
|
||||
### `add_video_resolution_tag`
|
||||
|
||||
*function · line 19*
|
||||
|
||||
```python
|
||||
add_video_resolution_tag(file_obj: File, tagmanager: TagManager)
|
||||
```
|
||||
|
||||
Read the vertical resolution of a video with `ffprobe` and tag the file with `Rozlišení/<height>p`. Needs FFmpeg on the PATH.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
@@ -1,24 +0,0 @@
|
||||
[project]
|
||||
name = "curator"
|
||||
version = "1.13.0"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"beautifulsoup4>=4.15.0",
|
||||
"loguru>=0.7.3",
|
||||
"pillow>=12.3.0",
|
||||
"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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Auto-generated — do not edit manually."""
|
||||
__version__ = "1.13.0"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -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 = [
|
||||
{
|
||||
"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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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()
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 ""
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -1,57 +0,0 @@
|
||||
# Module header
|
||||
import sys
|
||||
|
||||
from .file import File
|
||||
from .tag_manager import TagManager
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit("This module is not intended to be executed as the main program.")
|
||||
|
||||
# Imports
|
||||
import subprocess
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
|
||||
# Functions
|
||||
def load_icon(path) -> ImageTk.PhotoImage:
|
||||
img = Image.open(path)
|
||||
img = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||
return ImageTk.PhotoImage(img)
|
||||
|
||||
|
||||
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
|
||||
"""
|
||||
Zjistí vertikální rozlišení videa a přiřadí tag Rozlišení/{výška}p.
|
||||
Vyžaduje ffprobe (FFmpeg).
|
||||
"""
|
||||
path = str(file_obj.file_path)
|
||||
try:
|
||||
# ffprobe vrátí width a height ve formátu JSON
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height",
|
||||
"-of",
|
||||
"csv=p=0:s=x",
|
||||
path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
res = result.stdout.strip() # např. "1920x1080"
|
||||
if "x" not in res:
|
||||
return
|
||||
_width, height = map(int, res.split("x"))
|
||||
tag_name = f"Rozlišení/{height}p"
|
||||
tag_obj = tagmanager.add_tag("Rozlišení", f"{height}p")
|
||||
file_obj.add_tag(tag_obj)
|
||||
print(f"Přiřazen tag {tag_name} k {file_obj.filename}")
|
||||
except (OSError, subprocess.SubprocessError, ValueError) as e:
|
||||
print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}")
|
||||
@@ -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]
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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, ¶ms)?;
|
||||
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());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user