Add per-movie attributes and per-category filename templates
This commit is contained in:
@@ -21,6 +21,58 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 1.5.0 — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Free-form per-movie attributes** (`attributes` in the index, set via *Filmy →
|
||||||
|
Nastavit atribut…* / context menu): arbitrary `key → value` pairs (e.g.
|
||||||
|
`collection_sort`) that are exposed to Filmotéka filename templates through
|
||||||
|
`File.name_context`. So a Kolekce template `"{collection_sort} - {title}{ext}"`
|
||||||
|
orders the folder as `01 - Dr. No.mkv`, `02 - …`. Empty value removes the attr.
|
||||||
|
- **Per-category filename template** in the tag schema (`filename_template`,
|
||||||
|
editable in *Nastavení → Tag schéma…*): the hardlink inside that category's
|
||||||
|
folders is named from the template (fields `title`/`year`/`rating`/`ext`/
|
||||||
|
`stem`/`filename`), e.g. a *Kolekce* with `"{year} - {title}{ext}"` yields
|
||||||
|
`Dle kolekce/James Bond/1962 - Dr. No.mkv` while every other folder (and the
|
||||||
|
pool) keeps the plain filename. `HardlinkManager` takes
|
||||||
|
`category_filename_templates`; `File.name_context()` supplies the fields.
|
||||||
|
- The "Přiřadit štítky" dialog now lists **all tag-schema categories** (including
|
||||||
|
empty user ones like *Kolekce*) and has a **"➕ Nový štítek…"** button to create
|
||||||
|
a tag value (e.g. *Kolekce/James Bond*) on the spot and assign it. Tag values
|
||||||
|
are created on first assignment — the schema editor defines categories + rules,
|
||||||
|
this dialog defines/assigns the individual values.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Tag schema `transform` now shapes only the **Filmotéka folder name**, not the
|
||||||
|
tag value. ČSFD tags keep the **exact value** (rating → `Hodnocení/90`) while
|
||||||
|
the grouping transform is applied when building folders (→ `Dle hodnocení/
|
||||||
|
90–100 %`). `csfd_field_values` no longer transforms; `HardlinkManager` takes a
|
||||||
|
`category_transforms` map (`FileManager.filmoteka_category_transforms`) and
|
||||||
|
`apply_transform`/`decade_band` run at folder-generation time.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Sidebar filter tree no longer re-expands every category on each check/uncheck:
|
||||||
|
the expanded/collapsed state is preserved across the rebuild (new categories
|
||||||
|
still default to expanded).
|
||||||
|
|
||||||
|
## 1.3.0 — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Configurable tag schema** (`tag_schema` in the global config, edited via
|
||||||
|
*Nastavení → Tag schéma…*): a single source of truth for which tag categories
|
||||||
|
exist, which ČSFD field feeds each (with an optional value transform, e.g.
|
||||||
|
`decade_band` for the rating), and how each maps into the Filmotéka tree
|
||||||
|
(`""` = output root, `"Dle …"` = grouping folder, none = filter-only, no
|
||||||
|
folders). Replaces the hard-coded `FILMOTEKA_CATEGORY_ROOTS` and the fixed
|
||||||
|
category list in `apply_csfd_tags`; `HardlinkManager` roots are now derived
|
||||||
|
from the schema (`FileManager.filmoteka_category_roots`).
|
||||||
|
- **Tag provenance (ČSFD vs user)**: each file tracks which tags came from ČSFD
|
||||||
|
(`csfd_tags` in the index). `apply_csfd_tags` now **regenerates only the ČSFD
|
||||||
|
tags** from the schema and leaves user-added tags untouched — so changing a
|
||||||
|
movie's ČSFD link refreshes its ČSFD tags without wiping your own.
|
||||||
|
|
||||||
|
## 1.2.0 — 2026-06-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Fork of the former **Tagger** project as **Curator**, a movie-library manager.
|
- Fork of the former **Tagger** project as **Curator**, a movie-library manager.
|
||||||
- **Pool** concept (single source of truth) with `Filmy` / `Seriály` folders and
|
- **Pool** concept (single source of truth) with `Filmy` / `Seriály` folders and
|
||||||
@@ -36,6 +88,14 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
year) and fills in the first ČSFD search hit (`find_csfd_url` →
|
year) and fills in the first ČSFD search hit (`find_csfd_url` →
|
||||||
`search_movies`, reusing one Anubis session). Existing links are never
|
`search_movies`, reusing one Anubis session). Existing links are never
|
||||||
overwritten; results are a suggestion the user can review before importing.
|
overwritten; results are a suggestion the user can review before importing.
|
||||||
|
- Import dialog now **highlights in red any title that already exists in the
|
||||||
|
pool** (live as you type) and lets you **remove a single row** (✕ per row), so
|
||||||
|
you can drop one file without discarding the whole batch and re-picking.
|
||||||
|
- **Conflict resolution on import**: when a row is left red (name already in the
|
||||||
|
pool), import asks whether to **replace** the existing movie(s) with the new
|
||||||
|
file, **keep both** (numeric suffix), or **cancel**. `FileManager.import_movie`
|
||||||
|
gained an `on_conflict` policy (`suffix` / `replace` / `skip`); `replace`
|
||||||
|
evicts the existing same-named movie (file + index metadata) first.
|
||||||
- `File` now stores `title` and `csfd_link`.
|
- `File` now stores `title` and `csfd_link`.
|
||||||
- New **PySide6** GUI reframed around the Filmotéka workflow (pool setup, import,
|
- New **PySide6** GUI reframed around the Filmotéka workflow (pool setup, import,
|
||||||
tag filter sidebar, movie table, one-click Filmotéka generation), replacing the
|
tag filter sidebar, movie table, one-click Filmotéka generation), replacing the
|
||||||
@@ -115,6 +175,10 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
- `requires-python` narrowed to `>=3.14,<3.15` (PySide6 compatibility).
|
- `requires-python` narrowed to `>=3.14,<3.15` (PySide6 compatibility).
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
- Duplicate `src/core/constants.py` (hard-coded stale `VERSION = "v1.0.3"`). The
|
||||||
|
GUI window title imported from it, so it showed the wrong version. All imports
|
||||||
|
now use `src/constants.py` (version derived from `pyproject.toml`), which also
|
||||||
|
absorbed `APP_VIEWPORT`; the window title uses `APP_TITLE` → "Curator vX.Y.Z".
|
||||||
- Legacy Tagger predefined tags: the always-available **Hodnocení** (⭐ rating)
|
- Legacy Tagger predefined tags: the always-available **Hodnocení** (⭐ rating)
|
||||||
and **Barva** (color) categories in `TagManager`, and the automatic
|
and **Barva** (color) categories in `TagManager`, and the automatic
|
||||||
**Stav/Nové** tag assigned to every newly imported file. `DEFAULT_TAGS` is now
|
**Stav/Nové** tag assigned to every newly imported file. `DEFAULT_TAGS` is now
|
||||||
|
|||||||
+22
@@ -102,6 +102,28 @@ movie table, and one-click Filmotéka generation.
|
|||||||
`HardlinkManager` supports an empty root (tag folders placed directly at the
|
`HardlinkManager` supports an empty root (tag folders placed directly at the
|
||||||
output root) and restricts obsolete cleanup to the tag-tree's own top-level
|
output root) and restricts obsolete cleanup to the tag-tree's own top-level
|
||||||
folders so mirrors are never touched.
|
folders so mirrors are never touched.
|
||||||
|
- **Tag schema (config-driven, not hard-coded):** the categories, their ČSFD
|
||||||
|
source field + transform, and their Filmotéka folder mapping all live in
|
||||||
|
`tag_schema` in the global config (default `config.DEFAULT_TAG_SCHEMA`, edited
|
||||||
|
via *Nastavení → Tag schéma…*). Both `apply_csfd_tags` (which fields → tags)
|
||||||
|
and the Filmotéka layout (`FileManager.filmoteka_category_roots`) read from it,
|
||||||
|
so adding a category or changing a folder rule needs no code change. A category
|
||||||
|
can be made filter-only (no folders) by setting its `filmoteka_root` to null.
|
||||||
|
The `transform` (e.g. `decade_band`) shapes only the **folder name** — tags keep
|
||||||
|
the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90–100 %`);
|
||||||
|
it is applied at Filmotéka generation via `filmoteka_category_transforms`.
|
||||||
|
- **Per-category filename template** (`filename_template` in a schema entry): the
|
||||||
|
hardlink name **inside that category's folders only** is rendered from the
|
||||||
|
movie's metadata (`File.name_context`: title/year/rating/ext/stem/filename plus
|
||||||
|
any free-form attributes), e.g. a Kolekce with `"{collection_sort} - {title}{ext}"`.
|
||||||
|
Other folders and the pool file keep the plain name; applied via
|
||||||
|
`filmoteka_category_filename_templates`.
|
||||||
|
- **Free-form per-movie attributes** (`File.attributes`, set in the GUI): arbitrary
|
||||||
|
`key → value` metadata stored in the index and merged into `name_context`, so
|
||||||
|
custom fields like `collection_sort` can drive filename templates.
|
||||||
|
- **Tag provenance (ČSFD vs user):** each file records which tags came from ČSFD
|
||||||
|
(`csfd_tags`). Re-fetching regenerates only those; user-added tags are kept, so
|
||||||
|
changing a movie's ČSFD link refreshes ČSFD tags without losing manual ones.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "curator"
|
name = "curator"
|
||||||
version = "1.0.0"
|
version = "1.5.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "jan.doubravsky@gmail.com"}
|
{name = "jan.doubravsky@gmail.com"}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""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:
|
||||||
|
poetry run python scripts/strip_rating_bands.py [<pool_dir>] [--category "Hodnocení"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
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 # noqa: E402
|
||||||
|
from src.core.pool_index import INDEX_FILENAME # noqa: E402
|
||||||
|
|
||||||
|
# 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
-1
@@ -1,2 +1,2 @@
|
|||||||
"""Auto-generated — do not edit manually."""
|
"""Auto-generated — do not edit manually."""
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.5.0"
|
||||||
|
|||||||
+5
-3
@@ -5,9 +5,8 @@ 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
|
`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for
|
||||||
frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`).
|
frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`).
|
||||||
|
|
||||||
Note: per-feature constants (window size, tag colors, …) live in
|
It is the single source of truth for app metadata used across the GUI and build
|
||||||
`src/core/constants.py`; this module is only the version/debug surface used by
|
tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`).
|
||||||
the build tooling and frozen builds.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -64,3 +63,6 @@ APP_TITLE: str = f"{APP_NAME} v{APP_VERSION}" + ("-DEV" if ENV_DEBUG else "")
|
|||||||
|
|
||||||
# Backwards-compatible alias used by prebuild.py
|
# Backwards-compatible alias used by prebuild.py
|
||||||
VERSION: str = APP_VERSION
|
VERSION: str = APP_VERSION
|
||||||
|
|
||||||
|
# Default main-window geometry (used when no saved geometry exists)
|
||||||
|
APP_VIEWPORT: str = "1000x700"
|
||||||
|
|||||||
@@ -20,6 +20,28 @@ FOLDER_CONFIG_NAME = ".Curator.!ftag"
|
|||||||
# GLOBAL CONFIG - Application settings
|
# GLOBAL CONFIG - Application settings
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
# 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 — CSFDMovie attribute feeding it (genres / year / countries /
|
||||||
|
# rating / directors / actors), or None for a user-only category
|
||||||
|
# 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.
|
||||||
|
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í"},
|
||||||
|
]
|
||||||
|
|
||||||
DEFAULT_GLOBAL_CONFIG = {
|
DEFAULT_GLOBAL_CONFIG = {
|
||||||
"window_geometry": "1200x800",
|
"window_geometry": "1200x800",
|
||||||
"window_maximized": False,
|
"window_maximized": False,
|
||||||
@@ -29,6 +51,7 @@ DEFAULT_GLOBAL_CONFIG = {
|
|||||||
"pool_dir": None, # managed pool root (single source of truth)
|
"pool_dir": None, # managed pool root (single source of truth)
|
||||||
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
|
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
|
||||||
"copyasis_folders": ["Seriály"], # pool subfolders mirrored 1:1 (copy-as-is)
|
"copyasis_folders": ["Seriály"], # pool subfolders mirrored 1:1 (copy-as-is)
|
||||||
|
"tag_schema": DEFAULT_TAG_SCHEMA, # tag categories + ČSFD/Filmotéka rules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# src/core/constants.py
|
|
||||||
VERSION = "v1.0.3"
|
|
||||||
APP_NAME = "Curator"
|
|
||||||
APP_VIEWPORT = "1000x700"
|
|
||||||
@@ -137,6 +137,46 @@ def rating_band(rating: int) -> str:
|
|||||||
return f"{low}–{high} %"
|
return f"{low}–{high} %"
|
||||||
|
|
||||||
|
|
||||||
|
def _decade_band(value) -> str:
|
||||||
|
"""``decade_band`` transform: pull the leading integer and bucket it."""
|
||||||
|
match = re.search(r"\d+", str(value))
|
||||||
|
return rating_band(int(match.group())) if match else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
# Named value transforms usable from the tag schema (config-driven, extensible).
|
||||||
|
# These are applied when building Filmotéka FOLDER names — the tag itself keeps
|
||||||
|
# the exact value (e.g. rating 90 → tag "Hodnocení/90", folder "…/90–100 %").
|
||||||
|
TAG_TRANSFORMS = {
|
||||||
|
None: str,
|
||||||
|
"identity": str,
|
||||||
|
"decade_band": _decade_band,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_transform(value: str, transform: Optional[str]) -> str:
|
||||||
|
"""Apply a named tag-schema transform to a value, returning a string."""
|
||||||
|
fn = TAG_TRANSFORMS.get(transform, str)
|
||||||
|
try:
|
||||||
|
return str(fn(value))
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def csfd_field_values(movie: "CSFDMovie", field: str) -> list[str]:
|
||||||
|
"""Exact tag values for a CSFDMovie attribute (scalar or list), as strings.
|
||||||
|
|
||||||
|
No transform is applied here — tags carry the precise value; any grouping
|
||||||
|
transform happens later, when the Filmotéka folder names are built.
|
||||||
|
Unknown field / missing value yields ``[]``.
|
||||||
|
"""
|
||||||
|
raw = getattr(movie, field, None)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return []
|
||||||
|
items = list(raw) if isinstance(raw, (list, tuple)) else [raw]
|
||||||
|
values = [str(item).strip() for item in items if item is not None and item != ""]
|
||||||
|
return [v for v in values if v]
|
||||||
|
|
||||||
|
|
||||||
def _split_countries(text: Optional[str]) -> list[str]:
|
def _split_countries(text: Optional[str]) -> list[str]:
|
||||||
"""Split a ČSFD origin country string into individual countries.
|
"""Split a ČSFD origin country string into individual countries.
|
||||||
|
|
||||||
|
|||||||
+81
-30
@@ -26,6 +26,12 @@ class File:
|
|||||||
self.csfd_link: str | None = None
|
self.csfd_link: str | None = None
|
||||||
# Cached CSFD data — avoids re-fetching on every open
|
# Cached CSFD data — avoids re-fetching on every open
|
||||||
self.csfd_cache: dict | None = None
|
self.csfd_cache: dict | None = None
|
||||||
|
# full_paths of tags that came from ČSFD (vs. user-added); only these are
|
||||||
|
# regenerated when the ČSFD link changes — user tags are preserved.
|
||||||
|
self.csfd_tag_paths: set[str] = set()
|
||||||
|
# free-form per-movie attributes (e.g. "collection_sort"); usable in
|
||||||
|
# Filmotéka filename templates via name_context.
|
||||||
|
self.attributes: dict[str, str] = {}
|
||||||
self.get_metadata()
|
self.get_metadata()
|
||||||
|
|
||||||
def get_metadata(self) -> None:
|
def get_metadata(self) -> None:
|
||||||
@@ -52,6 +58,8 @@ class File:
|
|||||||
self.title = None
|
self.title = None
|
||||||
self.csfd_link = None
|
self.csfd_link = None
|
||||||
self.csfd_cache = None
|
self.csfd_cache = None
|
||||||
|
self.csfd_tag_paths = set()
|
||||||
|
self.attributes = {}
|
||||||
|
|
||||||
def _build_record(self) -> dict:
|
def _build_record(self) -> dict:
|
||||||
data = {
|
data = {
|
||||||
@@ -59,10 +67,13 @@ class File:
|
|||||||
"ignored": self.ignored,
|
"ignored": self.ignored,
|
||||||
# ukládáme full_path tagů
|
# ukládáme full_path tagů
|
||||||
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
|
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
|
||||||
|
# which of the tags came from ČSFD (provenance)
|
||||||
|
"csfd_tags": sorted(self.csfd_tag_paths),
|
||||||
# date může být None
|
# date může být None
|
||||||
"date": self.date,
|
"date": self.date,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"csfd_link": self.csfd_link,
|
"csfd_link": self.csfd_link,
|
||||||
|
"attributes": self.attributes,
|
||||||
}
|
}
|
||||||
if self.csfd_cache is not None:
|
if self.csfd_cache is not None:
|
||||||
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache}
|
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache}
|
||||||
@@ -75,6 +86,9 @@ class File:
|
|||||||
self.date = data.get("date", None)
|
self.date = data.get("date", None)
|
||||||
self.title = data.get("title", None)
|
self.title = data.get("title", None)
|
||||||
self.csfd_link = data.get("csfd_link", None)
|
self.csfd_link = data.get("csfd_link", None)
|
||||||
|
# Legacy records have no provenance → treated as all-user (empty set)
|
||||||
|
self.csfd_tag_paths = set(data.get("csfd_tags", []))
|
||||||
|
self.attributes = dict(data.get("attributes", {}))
|
||||||
raw_cache = data.get("csfd_cache")
|
raw_cache = data.get("csfd_cache")
|
||||||
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION:
|
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION:
|
||||||
self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"}
|
self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"}
|
||||||
@@ -103,6 +117,44 @@ class File:
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
self._apply_record(data)
|
self._apply_record(data)
|
||||||
|
|
||||||
|
def name_context(self) -> dict:
|
||||||
|
"""Fields for a Filmotéka filename template (see config tag schema).
|
||||||
|
|
||||||
|
Provides title / year / rating / ext / stem / filename from the movie's
|
||||||
|
metadata (year & rating come from the ČSFD cache, year falling back to a
|
||||||
|
``Rok`` tag). Missing values render as an empty string.
|
||||||
|
"""
|
||||||
|
cache = self.csfd_cache 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
|
||||||
|
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 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 delete_metadata(self) -> None:
|
def delete_metadata(self) -> None:
|
||||||
"""Remove this file's metadata (from the index, or its sidecar file)."""
|
"""Remove this file's metadata (from the index, or its sidecar file)."""
|
||||||
if self.index is not None:
|
if self.index is not None:
|
||||||
@@ -153,19 +205,14 @@ class File:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def apply_csfd_tags(
|
def apply_csfd_tags(self, schema: list[dict] | None = None) -> dict:
|
||||||
self,
|
"""Načte data z ČSFD a přegeneruje **jen ČSFD tagy** podle tag schématu.
|
||||||
add_genres: bool = True,
|
|
||||||
add_year: bool = True,
|
|
||||||
add_country: bool = True,
|
|
||||||
add_rating: bool = True,
|
|
||||||
) -> dict:
|
|
||||||
"""Načte informace z CSFD a přiřadí tagy; cachuje data.
|
|
||||||
|
|
||||||
Tagy: Žánr, Rok, Země původu a Hodnocení (procenta zařazená do desítkového
|
``schema`` je seznam definic kategorií (viz ``DEFAULT_TAG_SCHEMA``); pro
|
||||||
pásma, např. ``80–89 %``). Režie a herci se z ČSFD **stahují a cachují**
|
každý záznam s ``csfd_field`` vytvoří tagy z příslušného pole filmu
|
||||||
(``csfd_cache``), ale záměrně se z nich netvoří tagy ani složky — bylo by
|
(s volitelným ``transform``). Tagy přidané uživatelem (mimo provenance
|
||||||
jich příliš mnoho.
|
ČSFD) zůstávají nedotčené — proto změna ČSFD odkazu nepřepíše vlastní tagy.
|
||||||
|
Režie/herci se stále cachují, tag se z nich tvoří jen je-li v schématu.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict s klíči 'success', 'movie'/'error', 'tags_added'
|
dict s klíči 'success', 'movie'/'error', 'tags_added'
|
||||||
@@ -173,6 +220,10 @@ class File:
|
|||||||
if not self.csfd_link:
|
if not self.csfd_link:
|
||||||
return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []}
|
return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []}
|
||||||
|
|
||||||
|
if schema is None:
|
||||||
|
from .config import DEFAULT_TAG_SCHEMA
|
||||||
|
schema = DEFAULT_TAG_SCHEMA
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .csfd import fetch_movie
|
from .csfd import fetch_movie
|
||||||
movie = fetch_movie(self.csfd_link)
|
movie = fetch_movie(self.csfd_link)
|
||||||
@@ -182,25 +233,25 @@ class File:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
|
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
|
||||||
|
|
||||||
|
from .csfd import csfd_field_values
|
||||||
|
|
||||||
|
# Drop previous ČSFD-sourced tags; keep user-added ones.
|
||||||
|
self.tags = [t for t in self.tags if t.full_path not in self.csfd_tag_paths]
|
||||||
|
self.csfd_tag_paths = set()
|
||||||
|
|
||||||
tags_added: list[str] = []
|
tags_added: list[str] = []
|
||||||
|
for entry in schema:
|
||||||
def _add(category: str, name: str) -> None:
|
field = entry.get("csfd_field")
|
||||||
tag_obj = self.tagmanager.add_tag(category, name) if self.tagmanager else Tag(category, name)
|
if not field:
|
||||||
if tag_obj not in self.tags:
|
continue # user-only category
|
||||||
self.tags.append(tag_obj)
|
category = entry["category"]
|
||||||
tags_added.append(f"{category}/{name}")
|
for value in csfd_field_values(movie, field):
|
||||||
|
tag_obj = (self.tagmanager.add_tag(category, value)
|
||||||
if add_genres and movie.genres:
|
if self.tagmanager else Tag(category, value))
|
||||||
for genre in movie.genres:
|
if tag_obj not in self.tags:
|
||||||
_add("Žánr", genre)
|
self.tags.append(tag_obj)
|
||||||
if add_year and movie.year:
|
tags_added.append(tag_obj.full_path)
|
||||||
_add("Rok", str(movie.year))
|
self.csfd_tag_paths.add(tag_obj.full_path)
|
||||||
if add_country:
|
|
||||||
for country in movie.countries:
|
|
||||||
_add("Země původu", country)
|
|
||||||
if add_rating and movie.rating is not None:
|
|
||||||
from .csfd import rating_band
|
|
||||||
_add("Hodnocení", rating_band(movie.rating))
|
|
||||||
|
|
||||||
# Use the CSFD title if we don't have one yet
|
# Use the CSFD title if we don't have one yet
|
||||||
if movie.title and not self.title:
|
if movie.title and not self.title:
|
||||||
|
|||||||
@@ -60,6 +60,49 @@ class FileManager:
|
|||||||
self.global_config["copyasis_folders"] = cleaned
|
self.global_config["copyasis_folders"] = cleaned
|
||||||
save_global_config(self.global_config)
|
save_global_config(self.global_config)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tag_schema(self) -> list[dict]:
|
||||||
|
"""Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA)."""
|
||||||
|
from src.core.config import DEFAULT_TAG_SCHEMA
|
||||||
|
return self.global_config.get("tag_schema", DEFAULT_TAG_SCHEMA)
|
||||||
|
|
||||||
|
def set_tag_schema(self, schema: list[dict]) -> None:
|
||||||
|
"""Set the tag schema and persist it."""
|
||||||
|
self.global_config["tag_schema"] = schema
|
||||||
|
save_global_config(self.global_config)
|
||||||
|
|
||||||
|
def filmoteka_category_roots(self) -> dict[str, str]:
|
||||||
|
"""Category → output root-folder map derived from the tag schema.
|
||||||
|
|
||||||
|
Categories with ``filmoteka_root`` set to None are filterable but get no
|
||||||
|
folders in the generated tree.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
e["category"]: e["filmoteka_root"]
|
||||||
|
for e in self.tag_schema
|
||||||
|
if e.get("filmoteka_root") is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
def filmoteka_category_transforms(self) -> dict[str, str]:
|
||||||
|
"""Category → folder transform-name map (for grouping, e.g. rating bands).
|
||||||
|
|
||||||
|
Tags keep their exact value; this transform only shapes the folder name
|
||||||
|
in the generated tree. Categories without a transform are omitted.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
e["category"]: e["transform"]
|
||||||
|
for e in self.tag_schema
|
||||||
|
if e.get("filmoteka_root") is not None and e.get("transform")
|
||||||
|
}
|
||||||
|
|
||||||
|
def filmoteka_category_filename_templates(self) -> dict[str, str]:
|
||||||
|
"""Category → hardlink-name template map (applied inside that category)."""
|
||||||
|
return {
|
||||||
|
e["category"]: e["filename_template"]
|
||||||
|
for e in self.tag_schema
|
||||||
|
if e.get("filmoteka_root") is not None and e.get("filename_template")
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filmoteka_dir(self) -> Path | None:
|
def filmoteka_dir(self) -> Path | None:
|
||||||
value = self.global_config.get("filmoteka_dir")
|
value = self.global_config.get("filmoteka_dir")
|
||||||
@@ -93,13 +136,35 @@ class FileManager:
|
|||||||
file_obj = File(each, self.tagmanager, index=self.index)
|
file_obj = File(each, self.tagmanager, index=self.index)
|
||||||
self.filelist.append(file_obj)
|
self.filelist.append(file_obj)
|
||||||
|
|
||||||
|
def pooled_with_stem(self, title: str) -> list[File]:
|
||||||
|
"""Pooled movies whose filename stem matches ``title`` (case-insensitive)."""
|
||||||
|
stem = title.strip().lower()
|
||||||
|
return [f for f in self.filelist if Path(f.filename).stem.lower() == stem]
|
||||||
|
|
||||||
|
def _evict(self, file_obj: File) -> None:
|
||||||
|
"""Delete a pooled movie: its metadata, the file, and the list entry."""
|
||||||
|
file_obj.delete_metadata()
|
||||||
|
if file_obj.file_path.exists():
|
||||||
|
file_obj.file_path.unlink()
|
||||||
|
if file_obj in self.filelist:
|
||||||
|
self.filelist.remove(file_obj)
|
||||||
|
|
||||||
def import_movie(
|
def import_movie(
|
||||||
self, source: Path, title: str, csfd_link: str | None = None, move: bool = False
|
self, source: Path, title: str, csfd_link: str | None = None,
|
||||||
) -> File:
|
move: bool = False, on_conflict: str = "suffix",
|
||||||
|
) -> File | None:
|
||||||
"""Bring a video file into pool/Filmy as 'Title.ext' and index its metadata.
|
"""Bring a video file into pool/Filmy as 'Title.ext' and index its metadata.
|
||||||
|
|
||||||
By default the original is **copied** (non-destructive). With ``move=True``
|
By default the original is **copied** (non-destructive). With ``move=True``
|
||||||
the source file is moved into the pool instead, leaving nothing behind.
|
the source file is moved into the pool instead, leaving nothing behind.
|
||||||
|
|
||||||
|
``on_conflict`` decides what happens when a pooled movie of the same name
|
||||||
|
already exists:
|
||||||
|
|
||||||
|
- ``"suffix"`` (default): keep both, the new file gets a ``_N`` suffix.
|
||||||
|
- ``"replace"``: evict the existing same-named movie(s) (file + metadata)
|
||||||
|
and import the new one under the plain name.
|
||||||
|
- ``"skip"``: do not import; return ``None``.
|
||||||
"""
|
"""
|
||||||
movies = self.movies_dir
|
movies = self.movies_dir
|
||||||
pool = self.pool_dir
|
pool = self.pool_dir
|
||||||
@@ -114,11 +179,22 @@ class FileManager:
|
|||||||
safe_title = title.strip() or source.stem
|
safe_title = title.strip() or source.stem
|
||||||
target = movies / f"{safe_title}{source.suffix}"
|
target = movies / f"{safe_title}{source.suffix}"
|
||||||
|
|
||||||
# Avoid clobbering an existing movie of the same name
|
existing = self.pooled_with_stem(safe_title)
|
||||||
counter = 1
|
conflict = bool(existing) or target.exists()
|
||||||
while target.exists():
|
|
||||||
target = movies / f"{safe_title}_{counter}{source.suffix}"
|
if conflict and on_conflict == "skip":
|
||||||
counter += 1
|
return None
|
||||||
|
if conflict and on_conflict == "replace":
|
||||||
|
for f in existing:
|
||||||
|
self._evict(f)
|
||||||
|
if target.exists():
|
||||||
|
target.unlink()
|
||||||
|
else:
|
||||||
|
# "suffix": never clobber an existing exact filename
|
||||||
|
counter = 1
|
||||||
|
while target.exists():
|
||||||
|
target = movies / f"{safe_title}_{counter}{source.suffix}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
if move:
|
if move:
|
||||||
shutil.move(str(source), str(target))
|
shutil.move(str(source), str(target))
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ from typing import List, Tuple, Optional, Dict, Set
|
|||||||
from .file import File
|
from .file import File
|
||||||
|
|
||||||
|
|
||||||
|
class _SafeDict(dict):
|
||||||
|
"""dict for str.format_map that leaves unknown fields as an empty string."""
|
||||||
|
|
||||||
|
def __missing__(self, key): # noqa: ANN001
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class HardlinkManager:
|
class HardlinkManager:
|
||||||
"""Manager for creating hardlink-based directory structures from tagged files.
|
"""Manager for creating hardlink-based directory structures from tagged files.
|
||||||
|
|
||||||
@@ -61,7 +68,40 @@ class HardlinkManager:
|
|||||||
return {cat: cat for cat in categories}
|
return {cat: cat for cat in categories}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _target_dir(self, tag, roots: Optional[Dict[str, str]]) -> Optional[Path]:
|
def _link_name(
|
||||||
|
self, file_obj: File, tag, templates: Optional[Dict[str, str]]
|
||||||
|
) -> str:
|
||||||
|
"""Hardlink filename for a tag — a per-category template or the pool name.
|
||||||
|
|
||||||
|
Applies ``templates[tag.category]`` (e.g. ``"{year} - {title}{ext}"``) to
|
||||||
|
the file's ``name_context``; path separators are flattened. Any failure or
|
||||||
|
empty result falls back to the pool filename.
|
||||||
|
"""
|
||||||
|
template = templates.get(tag.category) if templates else None
|
||||||
|
if not template:
|
||||||
|
return file_obj.filename
|
||||||
|
try:
|
||||||
|
rendered = template.format_map(_SafeDict(file_obj.name_context()))
|
||||||
|
except (ValueError, KeyError, IndexError, AttributeError):
|
||||||
|
return file_obj.filename
|
||||||
|
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
|
||||||
|
return rendered or file_obj.filename
|
||||||
|
|
||||||
|
def _folder_value(self, 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.
|
||||||
|
"""
|
||||||
|
if transforms and tag.category in transforms:
|
||||||
|
from .csfd import apply_transform
|
||||||
|
return apply_transform(tag.name, transforms[tag.category])
|
||||||
|
return tag.name
|
||||||
|
|
||||||
|
def _target_dir(
|
||||||
|
self, 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."""
|
"""Output directory for a tag, or None if its category is excluded."""
|
||||||
if roots is None:
|
if roots is None:
|
||||||
folder = tag.category
|
folder = tag.category
|
||||||
@@ -70,17 +110,18 @@ class HardlinkManager:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
base = self.output_dir / folder if folder else self.output_dir
|
base = self.output_dir / folder if folder else self.output_dir
|
||||||
return base / tag.name
|
return base / self._folder_value(tag, transforms)
|
||||||
|
|
||||||
def _managed_top_dirs(
|
def _managed_top_dirs(
|
||||||
self, files: List[File], roots: Optional[Dict[str, str]]
|
self, files: List[File], roots: Optional[Dict[str, str]],
|
||||||
|
transforms: Optional[Dict[str, str]] = None,
|
||||||
) -> Optional[Set[str]]:
|
) -> Optional[Set[str]]:
|
||||||
"""Top-level output folders owned by the tag tree (None = all of them).
|
"""Top-level output folders owned by the tag tree (None = all of them).
|
||||||
|
|
||||||
For a category with a non-empty root the root folder is owned; for a
|
For a category with a non-empty root the root folder is owned; for a
|
||||||
category placed at the output root (empty root, e.g. genres) each of its
|
category placed at the output root (empty root, e.g. genres) each of its
|
||||||
tag names is its own top-level folder. This lets cleanup skip unrelated
|
(transformed) tag values is its own top-level folder. This lets cleanup
|
||||||
root entries such as the copy-as-is mirror (Seriály).
|
skip unrelated root entries such as the copy-as-is mirror (Seriály).
|
||||||
"""
|
"""
|
||||||
if roots is None:
|
if roots is None:
|
||||||
return None
|
return None
|
||||||
@@ -92,7 +133,7 @@ class HardlinkManager:
|
|||||||
for file_obj in files:
|
for file_obj in files:
|
||||||
for tag in file_obj.tags:
|
for tag in file_obj.tags:
|
||||||
if tag.category == cat:
|
if tag.category == cat:
|
||||||
tops.add(tag.name)
|
tops.add(self._folder_value(tag, transforms))
|
||||||
return tops
|
return tops
|
||||||
|
|
||||||
def create_structure_for_files(
|
def create_structure_for_files(
|
||||||
@@ -101,6 +142,8 @@ class HardlinkManager:
|
|||||||
categories: Optional[List[str]] = None,
|
categories: Optional[List[str]] = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
category_roots: Optional[Dict[str, str]] = None,
|
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]:
|
) -> Tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
Create hardlink structure for given files based on their tags.
|
Create hardlink structure for given files based on their tags.
|
||||||
@@ -111,6 +154,10 @@ class HardlinkManager:
|
|||||||
dry_run: If True, only simulate without creating actual links
|
dry_run: If True, only simulate without creating actual links
|
||||||
category_roots: Optional category → root-folder map (see class doc);
|
category_roots: Optional category → root-folder map (see class doc);
|
||||||
overrides ``categories`` when given.
|
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:
|
Returns:
|
||||||
Tuple of (successful_links, failed_links)
|
Tuple of (successful_links, failed_links)
|
||||||
@@ -128,10 +175,11 @@ class HardlinkManager:
|
|||||||
|
|
||||||
for tag in file_obj.tags:
|
for tag in file_obj.tags:
|
||||||
# Resolve the target dir; None means this category is excluded
|
# Resolve the target dir; None means this category is excluded
|
||||||
target_dir = self._target_dir(tag, roots)
|
target_dir = self._target_dir(tag, roots, category_transforms)
|
||||||
if target_dir is None:
|
if target_dir is None:
|
||||||
continue
|
continue
|
||||||
target_file = target_dir / file_obj.filename
|
target_file = target_dir / self._link_name(
|
||||||
|
file_obj, tag, category_filename_templates)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
@@ -269,6 +317,8 @@ class HardlinkManager:
|
|||||||
files: List[File],
|
files: List[File],
|
||||||
categories: Optional[List[str]] = None,
|
categories: Optional[List[str]] = None,
|
||||||
category_roots: Optional[Dict[str, 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]]:
|
) -> List[Tuple[Path, Path]]:
|
||||||
"""
|
"""
|
||||||
Get a preview of what links would be created.
|
Get a preview of what links would be created.
|
||||||
@@ -278,6 +328,8 @@ class HardlinkManager:
|
|||||||
categories: Optional list of categories to include
|
categories: Optional list of categories to include
|
||||||
category_roots: Optional category → root-folder map (overrides
|
category_roots: Optional category → root-folder map (overrides
|
||||||
``categories`` when given).
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of tuples (source_path, target_path)
|
List of tuples (source_path, target_path)
|
||||||
@@ -290,10 +342,11 @@ class HardlinkManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
for tag in file_obj.tags:
|
for tag in file_obj.tags:
|
||||||
target_dir = self._target_dir(tag, roots)
|
target_dir = self._target_dir(tag, roots, category_transforms)
|
||||||
if target_dir is None:
|
if target_dir is None:
|
||||||
continue
|
continue
|
||||||
target_file = target_dir / file_obj.filename
|
target_file = target_dir / self._link_name(
|
||||||
|
file_obj, tag, category_filename_templates)
|
||||||
|
|
||||||
preview.append((file_obj.file_path, target_file))
|
preview.append((file_obj.file_path, target_file))
|
||||||
|
|
||||||
@@ -304,6 +357,8 @@ class HardlinkManager:
|
|||||||
files: List[File],
|
files: List[File],
|
||||||
categories: Optional[List[str]] = None,
|
categories: Optional[List[str]] = None,
|
||||||
category_roots: Optional[Dict[str, 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]]:
|
) -> List[Tuple[Path, Path]]:
|
||||||
"""
|
"""
|
||||||
Find hardlinks in the output directory that no longer match file tags.
|
Find hardlinks in the output directory that no longer match file tags.
|
||||||
@@ -318,6 +373,7 @@ class HardlinkManager:
|
|||||||
categories: Optional list of categories to check (None = all)
|
categories: Optional list of categories to check (None = all)
|
||||||
category_roots: Optional category → root-folder map (overrides
|
category_roots: Optional category → root-folder map (overrides
|
||||||
``categories`` when given).
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of tuples (link_path, source_path) for obsolete links
|
List of tuples (link_path, source_path) for obsolete links
|
||||||
@@ -346,15 +402,16 @@ class HardlinkManager:
|
|||||||
expected_paths[inode] = set()
|
expected_paths[inode] = set()
|
||||||
|
|
||||||
for tag in file_obj.tags:
|
for tag in file_obj.tags:
|
||||||
target_dir = self._target_dir(tag, roots)
|
target_dir = self._target_dir(tag, roots, category_transforms)
|
||||||
if target_dir is None:
|
if target_dir is None:
|
||||||
continue
|
continue
|
||||||
expected_paths[inode].add(target_dir / file_obj.filename)
|
expected_paths[inode].add(target_dir / self._link_name(
|
||||||
|
file_obj, tag, category_filename_templates))
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors)
|
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors)
|
||||||
top_dirs = self._managed_top_dirs(files, roots)
|
top_dirs = self._managed_top_dirs(files, roots, category_transforms)
|
||||||
for top in self.output_dir.iterdir():
|
for top in self.output_dir.iterdir():
|
||||||
if not top.is_dir():
|
if not top.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -382,6 +439,8 @@ class HardlinkManager:
|
|||||||
categories: Optional[List[str]] = None,
|
categories: Optional[List[str]] = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
category_roots: Optional[Dict[str, str]] = None,
|
category_roots: Optional[Dict[str, str]] = None,
|
||||||
|
category_transforms: Optional[Dict[str, str]] = None,
|
||||||
|
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||||
) -> Tuple[int, List[Path]]:
|
) -> Tuple[int, List[Path]]:
|
||||||
"""
|
"""
|
||||||
Remove hardlinks that no longer match file tags.
|
Remove hardlinks that no longer match file tags.
|
||||||
@@ -392,11 +451,15 @@ class HardlinkManager:
|
|||||||
dry_run: If True, only return what would be removed
|
dry_run: If True, only return what would be removed
|
||||||
category_roots: Optional category → root-folder map (overrides
|
category_roots: Optional category → root-folder map (overrides
|
||||||
``categories`` when given).
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (removed_count, list_of_removed_paths)
|
Tuple of (removed_count, list_of_removed_paths)
|
||||||
"""
|
"""
|
||||||
obsolete = self.find_obsolete_links(files, categories, category_roots)
|
obsolete = self.find_obsolete_links(
|
||||||
|
files, categories, category_roots, category_transforms,
|
||||||
|
category_filename_templates)
|
||||||
removed_paths = []
|
removed_paths = []
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -420,6 +483,8 @@ class HardlinkManager:
|
|||||||
categories: Optional[List[str]] = None,
|
categories: Optional[List[str]] = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
category_roots: Optional[Dict[str, str]] = None,
|
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, int, int]:
|
) -> Tuple[int, int, int, int]:
|
||||||
"""
|
"""
|
||||||
Synchronize hardlink structure with current file tags.
|
Synchronize hardlink structure with current file tags.
|
||||||
@@ -434,22 +499,28 @@ class HardlinkManager:
|
|||||||
dry_run: If True, only simulate
|
dry_run: If True, only simulate
|
||||||
category_roots: Optional category → root-folder map (overrides
|
category_roots: Optional category → root-folder map (overrides
|
||||||
``categories`` when given).
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (created, create_failed, removed, remove_failed)
|
Tuple of (created, create_failed, removed, remove_failed)
|
||||||
"""
|
"""
|
||||||
# First find how many obsolete links there are
|
# First find how many obsolete links there are
|
||||||
obsolete_count = len(self.find_obsolete_links(files, categories, category_roots))
|
obsolete_count = len(self.find_obsolete_links(
|
||||||
|
files, categories, category_roots, category_transforms,
|
||||||
|
category_filename_templates))
|
||||||
|
|
||||||
# Remove obsolete links
|
# Remove obsolete links
|
||||||
removed, removed_paths = self.remove_obsolete_links(
|
removed, removed_paths = self.remove_obsolete_links(
|
||||||
files, categories, dry_run, category_roots
|
files, categories, dry_run, category_roots, category_transforms,
|
||||||
|
category_filename_templates
|
||||||
)
|
)
|
||||||
remove_failed = obsolete_count - removed if not dry_run else 0
|
remove_failed = obsolete_count - removed if not dry_run else 0
|
||||||
|
|
||||||
# Then create new links
|
# Then create new links
|
||||||
created, create_failed = self.create_structure_for_files(
|
created, create_failed = self.create_structure_for_files(
|
||||||
files, categories, dry_run, category_roots
|
files, categories, dry_run, category_roots, category_transforms,
|
||||||
|
category_filename_templates
|
||||||
)
|
)
|
||||||
|
|
||||||
return created, create_failed, removed, remove_failed
|
return created, create_failed, removed, remove_failed
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ from src.core.tag_manager import TagManager, DEFAULT_TAG_ORDER
|
|||||||
from src.core.file import File
|
from src.core.file import File
|
||||||
from src.core.tag import Tag
|
from src.core.tag import Tag
|
||||||
from src.core.list_manager import ListManager
|
from src.core.list_manager import ListManager
|
||||||
from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT
|
from src.constants import APP_NAME, VERSION, APP_VIEWPORT
|
||||||
from src.core.config import save_global_config
|
from src.core.config import save_global_config
|
||||||
from src.core.hardlink_manager import HardlinkManager
|
from src.core.hardlink_manager import HardlinkManager
|
||||||
|
|
||||||
|
|||||||
+336
-44
@@ -20,25 +20,16 @@ from PySide6.QtWidgets import (
|
|||||||
QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
|
QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
|
||||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox,
|
QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox,
|
||||||
QHeaderView, QMenu, QAbstractItemView, QCheckBox,
|
QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox,
|
||||||
)
|
)
|
||||||
|
|
||||||
from src.core.file_manager import FileManager
|
from src.core.file_manager import FileManager
|
||||||
from src.core.tag_manager import TagManager
|
from src.core.tag_manager import TagManager
|
||||||
from src.core.file import File
|
from src.core.file import File
|
||||||
from src.core.tag import Tag
|
from src.core.tag import Tag
|
||||||
from src.core.constants import APP_NAME, VERSION
|
from src.constants import APP_TITLE
|
||||||
from src.core.hardlink_manager import HardlinkManager
|
from src.core.hardlink_manager import HardlinkManager
|
||||||
|
|
||||||
# Layout of the generated Filmotéka tree: category → root folder under the
|
|
||||||
# output (see PROJECT.md). Genres sit directly at the output root (next to the
|
|
||||||
# copy-as-is Seriály mirror); Rok and Země původu get their own grouping folder.
|
|
||||||
FILMOTEKA_CATEGORY_ROOTS = {
|
|
||||||
"Žánr": "",
|
|
||||||
"Rok": "Dle roku",
|
|
||||||
"Země původu": "Dle země původu",
|
|
||||||
"Hodnocení": "Dle hodnocení",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ImportMoviesDialog(QDialog):
|
class ImportMoviesDialog(QDialog):
|
||||||
@@ -49,23 +40,29 @@ class ImportMoviesDialog(QDialog):
|
|||||||
the files are copied (default, non-destructive) or moved into the pool.
|
the files are copied (default, non-destructive) or moved into the pool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, parent: QWidget, sources: List[Path]) -> None:
|
def __init__(
|
||||||
|
self, parent: QWidget, sources: List[Path],
|
||||||
|
existing_names: set[str] | None = None,
|
||||||
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Importovat filmy do poolu")
|
self.setWindowTitle("Importovat filmy do poolu")
|
||||||
self.setMinimumSize(680, 360)
|
self.setMinimumSize(720, 360)
|
||||||
|
|
||||||
|
# Lower-cased names already present in the pool (for collision highlight)
|
||||||
|
self.existing_names = {n.lower() for n in (existing_names or set())}
|
||||||
# (source path, title field, ČSFD field) per row
|
# (source path, title field, ČSFD field) per row
|
||||||
self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = []
|
self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = []
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
self.table = QTableWidget(0, 3)
|
self.table = QTableWidget(0, 4)
|
||||||
self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz"])
|
self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz", ""])
|
||||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
header = self.table.horizontalHeader()
|
header = self.table.horizontalHeader()
|
||||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||||
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||||
layout.addWidget(self.table)
|
layout.addWidget(self.table)
|
||||||
|
|
||||||
add_row = QHBoxLayout()
|
add_row = QHBoxLayout()
|
||||||
@@ -97,11 +94,35 @@ class ImportMoviesDialog(QDialog):
|
|||||||
name_item.setFlags(Qt.ItemIsEnabled)
|
name_item.setFlags(Qt.ItemIsEnabled)
|
||||||
self.table.setItem(row, 0, name_item)
|
self.table.setItem(row, 0, name_item)
|
||||||
title_edit = QLineEdit(source.stem)
|
title_edit = QLineEdit(source.stem)
|
||||||
|
title_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e))
|
||||||
csfd_edit = QLineEdit()
|
csfd_edit = QLineEdit()
|
||||||
csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
|
csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
|
||||||
|
remove_btn = QPushButton("✕")
|
||||||
|
remove_btn.setFixedWidth(28)
|
||||||
|
remove_btn.setToolTip("Odebrat tento soubor z importu")
|
||||||
|
remove_btn.clicked.connect(lambda _c, e=title_edit: self._remove_row(e))
|
||||||
self.table.setCellWidget(row, 1, title_edit)
|
self.table.setCellWidget(row, 1, title_edit)
|
||||||
self.table.setCellWidget(row, 2, csfd_edit)
|
self.table.setCellWidget(row, 2, csfd_edit)
|
||||||
|
self.table.setCellWidget(row, 3, remove_btn)
|
||||||
self._rows.append((source, title_edit, csfd_edit))
|
self._rows.append((source, title_edit, csfd_edit))
|
||||||
|
self._mark_collision(title_edit)
|
||||||
|
|
||||||
|
def _mark_collision(self, title_edit: QLineEdit) -> None:
|
||||||
|
"""Highlight the title in red when that name already exists in the pool."""
|
||||||
|
clashes = title_edit.text().strip().lower() in self.existing_names
|
||||||
|
title_edit.setStyleSheet(
|
||||||
|
"QLineEdit { color: #c0392b; font-weight: bold; }" if clashes else "")
|
||||||
|
title_edit.setToolTip(
|
||||||
|
"V poolu už existuje film s tímto názvem — při importu dostane "
|
||||||
|
"číselnou příponu." if clashes else "")
|
||||||
|
|
||||||
|
def _remove_row(self, title_edit: QLineEdit) -> None:
|
||||||
|
"""Drop a single file from the import list without restarting the dialog."""
|
||||||
|
for index, (_src, edit, _csfd) in enumerate(self._rows):
|
||||||
|
if edit is title_edit:
|
||||||
|
self.table.removeRow(index)
|
||||||
|
self._rows.pop(index)
|
||||||
|
return
|
||||||
|
|
||||||
def _add_files(self) -> None:
|
def _add_files(self) -> None:
|
||||||
paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory")
|
paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory")
|
||||||
@@ -157,13 +178,19 @@ class AssignTagsDialog(QDialog):
|
|||||||
Result maps full_path -> 1 (assign), 0 (remove), 2 (leave mixed/unchanged).
|
Result maps full_path -> 1 (assign), 0 (remove), 2 (leave mixed/unchanged).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, parent: QWidget, tagmanager: TagManager, files: List[File]) -> None:
|
def __init__(
|
||||||
|
self, parent: QWidget, tagmanager: TagManager, files: List[File],
|
||||||
|
categories: List[str] | None = None,
|
||||||
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Přiřadit štítky")
|
self.setWindowTitle("Přiřadit štítky")
|
||||||
self.setMinimumSize(380, 480)
|
self.setMinimumSize(380, 480)
|
||||||
self.result_map: dict[str, int] = {}
|
self.result_map: dict[str, int] = {}
|
||||||
|
self.tagmanager = tagmanager
|
||||||
file_tag_sets = [{t.full_path for t in f.tags} for f in files]
|
self._file_tag_sets = [{t.full_path for t in f.tags} for f in files]
|
||||||
|
self._file_count = len(files)
|
||||||
|
self._items: list[tuple[str, QTreeWidgetItem]] = []
|
||||||
|
self._cat_items: dict[str, QTreeWidgetItem] = {}
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.addWidget(QLabel(f"Vybráno filmů: {len(files)}"))
|
layout.addWidget(QLabel(f"Vybráno filmů: {len(files)}"))
|
||||||
@@ -172,25 +199,17 @@ class AssignTagsDialog(QDialog):
|
|||||||
self.tree.setHeaderHidden(True)
|
self.tree.setHeaderHidden(True)
|
||||||
layout.addWidget(self.tree)
|
layout.addWidget(self.tree)
|
||||||
|
|
||||||
self._items: list[tuple[str, QTreeWidgetItem]] = []
|
# Show schema categories (incl. empty ones) ∪ categories that have tags
|
||||||
for category in self.tagmanager_categories(tagmanager):
|
all_categories = list(dict.fromkeys(
|
||||||
cat_item = QTreeWidgetItem([category])
|
(categories or []) + self.tagmanager_categories(tagmanager)))
|
||||||
cat_item.setFlags(Qt.ItemIsEnabled)
|
for category in all_categories:
|
||||||
self.tree.addTopLevelItem(cat_item)
|
self._category_item(category)
|
||||||
cat_item.setExpanded(True)
|
|
||||||
for tag in tagmanager.get_tags_in_category(category):
|
for tag in tagmanager.get_tags_in_category(category):
|
||||||
have = sum(1 for s in file_tag_sets if tag.full_path in s)
|
self._add_tag_item(category, tag.name)
|
||||||
if have == 0:
|
|
||||||
state = Qt.Unchecked
|
add_btn = QPushButton("➕ Nový štítek…")
|
||||||
elif have == len(files):
|
add_btn.clicked.connect(self._add_new_tag)
|
||||||
state = Qt.Checked
|
layout.addWidget(add_btn)
|
||||||
else:
|
|
||||||
state = Qt.PartiallyChecked
|
|
||||||
item = QTreeWidgetItem([tag.name])
|
|
||||||
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsAutoTristate)
|
|
||||||
item.setCheckState(0, state)
|
|
||||||
cat_item.addChild(item)
|
|
||||||
self._items.append((tag.full_path, item))
|
|
||||||
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
buttons.accepted.connect(self.accept)
|
buttons.accepted.connect(self.accept)
|
||||||
@@ -201,12 +220,194 @@ class AssignTagsDialog(QDialog):
|
|||||||
def tagmanager_categories(tagmanager: TagManager) -> List[str]:
|
def tagmanager_categories(tagmanager: TagManager) -> List[str]:
|
||||||
return sorted(tagmanager.get_categories())
|
return sorted(tagmanager.get_categories())
|
||||||
|
|
||||||
|
def _category_item(self, category: str) -> QTreeWidgetItem:
|
||||||
|
"""Return (creating if needed) the header item for a category."""
|
||||||
|
if category not in self._cat_items:
|
||||||
|
cat_item = QTreeWidgetItem([category])
|
||||||
|
cat_item.setFlags(Qt.ItemIsEnabled)
|
||||||
|
self.tree.addTopLevelItem(cat_item)
|
||||||
|
cat_item.setExpanded(True)
|
||||||
|
self._cat_items[category] = cat_item
|
||||||
|
return self._cat_items[category]
|
||||||
|
|
||||||
|
def _add_tag_item(self, category: str, name: str, force_checked: bool = False):
|
||||||
|
"""Add a checkable tag row under its category (deduplicated)."""
|
||||||
|
full_path = f"{category}/{name}"
|
||||||
|
for fp, item in self._items:
|
||||||
|
if fp == full_path: # already listed → optionally (re)check it
|
||||||
|
if force_checked:
|
||||||
|
item.setCheckState(0, Qt.Checked)
|
||||||
|
return item
|
||||||
|
have = sum(1 for s in self._file_tag_sets if full_path in s)
|
||||||
|
if force_checked or have == self._file_count and self._file_count:
|
||||||
|
state = Qt.Checked
|
||||||
|
elif have == 0:
|
||||||
|
state = Qt.Unchecked
|
||||||
|
else:
|
||||||
|
state = Qt.PartiallyChecked
|
||||||
|
item = QTreeWidgetItem([name])
|
||||||
|
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsAutoTristate)
|
||||||
|
item.setCheckState(0, state)
|
||||||
|
self._category_item(category).addChild(item)
|
||||||
|
self._items.append((full_path, item))
|
||||||
|
return item
|
||||||
|
|
||||||
|
def _add_new_tag(self) -> None:
|
||||||
|
"""Prompt for a category + value and add it as a checked tag."""
|
||||||
|
known = list(self._cat_items.keys())
|
||||||
|
category, ok = QInputDialog.getItem(
|
||||||
|
self, "Nový štítek", "Kategorie:", known, editable=True)
|
||||||
|
category = category.strip()
|
||||||
|
if not ok or not category:
|
||||||
|
return
|
||||||
|
name, ok = QInputDialog.getText(
|
||||||
|
self, "Nový štítek", f"Název štítku v kategorii „{category}\":")
|
||||||
|
name = name.strip()
|
||||||
|
if not ok or not name:
|
||||||
|
return
|
||||||
|
item = self._add_tag_item(category, name, force_checked=True)
|
||||||
|
self.tree.scrollToItem(item)
|
||||||
|
|
||||||
def accept(self) -> None:
|
def accept(self) -> None:
|
||||||
mapping = {Qt.Checked: 1, Qt.Unchecked: 0, Qt.PartiallyChecked: 2}
|
mapping = {Qt.Checked: 1, Qt.Unchecked: 0, Qt.PartiallyChecked: 2}
|
||||||
self.result_map = {fp: mapping[item.checkState(0)] for fp, item in self._items}
|
self.result_map = {fp: mapping[item.checkState(0)] for fp, item in self._items}
|
||||||
super().accept()
|
super().accept()
|
||||||
|
|
||||||
|
|
||||||
|
class TagSchemaDialog(QDialog):
|
||||||
|
"""Editor for the tag schema: categories, their ČSFD source and folder rules.
|
||||||
|
|
||||||
|
Each row defines a tag category, optionally which ČSFD field feeds it (with a
|
||||||
|
value transform), and how it maps into the Filmotéka tree (a grouping folder,
|
||||||
|
the output root, or no folders at all).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# (label, csfd_field) — the CSFDMovie attributes a category can draw from
|
||||||
|
FIELD_CHOICES = [
|
||||||
|
("(žádné — uživatelská)", None),
|
||||||
|
("Žánry (genres)", "genres"),
|
||||||
|
("Rok (year)", "year"),
|
||||||
|
("Země (countries)", "countries"),
|
||||||
|
("Hodnocení (rating)", "rating"),
|
||||||
|
("Režie (directors)", "directors"),
|
||||||
|
("Herci (actors)", "actors"),
|
||||||
|
("Délka (duration)", "duration"),
|
||||||
|
]
|
||||||
|
TRANSFORM_CHOICES = [
|
||||||
|
("(žádný)", None),
|
||||||
|
("Pásmo po 10 (decade_band)", "decade_band"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget, schema: list[dict]) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Tag schéma — kategorie a pravidla")
|
||||||
|
self.setMinimumSize(760, 420)
|
||||||
|
self.result_schema: list[dict] | None = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.addWidget(QLabel(
|
||||||
|
"Kategorie tagů, jejich zdroj z ČSFD a jak se z nich tvoří složky "
|
||||||
|
"Filmotéky.\nSložka: prázdné = v kořeni výstupu, „Dle…\" = seskupit, "
|
||||||
|
"vypnuté „Tvořit složky\" = jen filtr bez složek.\n"
|
||||||
|
"Šablona názvu: jak pojmenovat hardlink jen v této kategorii, např. "
|
||||||
|
"„{year} - {title}{ext}\" (pole: title/year/rating/ext/stem/filename)."))
|
||||||
|
|
||||||
|
self.table = QTableWidget(0, 6)
|
||||||
|
self.table.setHorizontalHeaderLabels(
|
||||||
|
["Kategorie", "ČSFD pole", "Transform", "Tvořit složky", "Složka",
|
||||||
|
"Šablona názvu"])
|
||||||
|
hdr = self.table.horizontalHeader()
|
||||||
|
hdr.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
hdr.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||||
|
hdr.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||||
|
hdr.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||||
|
hdr.setSectionResizeMode(4, QHeaderView.Stretch)
|
||||||
|
hdr.setSectionResizeMode(5, QHeaderView.Stretch)
|
||||||
|
layout.addWidget(self.table)
|
||||||
|
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
add_btn = QPushButton("➕ Přidat kategorii")
|
||||||
|
add_btn.clicked.connect(lambda: self._append_row({}))
|
||||||
|
del_btn = QPushButton("➖ Odebrat vybranou")
|
||||||
|
del_btn.clicked.connect(self._remove_selected)
|
||||||
|
btn_row.addWidget(add_btn)
|
||||||
|
btn_row.addWidget(del_btn)
|
||||||
|
btn_row.addStretch(1)
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
|
buttons.accepted.connect(self._save)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
for entry in schema:
|
||||||
|
self._append_row(entry)
|
||||||
|
|
||||||
|
def _append_row(self, entry: dict) -> None:
|
||||||
|
row = self.table.rowCount()
|
||||||
|
self.table.insertRow(row)
|
||||||
|
|
||||||
|
self.table.setCellWidget(row, 0, QLineEdit(entry.get("category", "")))
|
||||||
|
|
||||||
|
field_combo = QComboBox()
|
||||||
|
for label, value in self.FIELD_CHOICES:
|
||||||
|
field_combo.addItem(label, value)
|
||||||
|
field_combo.setCurrentIndex(
|
||||||
|
self._combo_index(self.FIELD_CHOICES, entry.get("csfd_field")))
|
||||||
|
self.table.setCellWidget(row, 1, field_combo)
|
||||||
|
|
||||||
|
transform_combo = QComboBox()
|
||||||
|
for label, value in self.TRANSFORM_CHOICES:
|
||||||
|
transform_combo.addItem(label, value)
|
||||||
|
transform_combo.setCurrentIndex(
|
||||||
|
self._combo_index(self.TRANSFORM_CHOICES, entry.get("transform")))
|
||||||
|
self.table.setCellWidget(row, 2, transform_combo)
|
||||||
|
|
||||||
|
root = entry.get("filmoteka_root", "")
|
||||||
|
make_folders = QCheckBox()
|
||||||
|
make_folders.setChecked(root is not None)
|
||||||
|
self.table.setCellWidget(row, 3, make_folders)
|
||||||
|
|
||||||
|
folder_edit = QLineEdit("" if root is None else root)
|
||||||
|
folder_edit.setPlaceholderText("prázdné = kořen výstupu")
|
||||||
|
self.table.setCellWidget(row, 4, folder_edit)
|
||||||
|
|
||||||
|
template_edit = QLineEdit(entry.get("filename_template") or "")
|
||||||
|
template_edit.setPlaceholderText("výchozí název souboru")
|
||||||
|
self.table.setCellWidget(row, 5, template_edit)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _combo_index(choices: list, value) -> int:
|
||||||
|
for i, (_label, v) in enumerate(choices):
|
||||||
|
if v == value:
|
||||||
|
return i
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _remove_selected(self) -> None:
|
||||||
|
row = self.table.currentRow()
|
||||||
|
if row >= 0:
|
||||||
|
self.table.removeRow(row)
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
schema: list[dict] = []
|
||||||
|
for row in range(self.table.rowCount()):
|
||||||
|
category = self.table.cellWidget(row, 0).text().strip()
|
||||||
|
if not category:
|
||||||
|
continue
|
||||||
|
make_folders = self.table.cellWidget(row, 3).isChecked()
|
||||||
|
folder = self.table.cellWidget(row, 4).text().strip()
|
||||||
|
template = self.table.cellWidget(row, 5).text().strip()
|
||||||
|
schema.append({
|
||||||
|
"category": category,
|
||||||
|
"csfd_field": self.table.cellWidget(row, 1).currentData(),
|
||||||
|
"transform": self.table.cellWidget(row, 2).currentData(),
|
||||||
|
"filmoteka_root": folder if make_folders else None,
|
||||||
|
"filename_template": template or None,
|
||||||
|
})
|
||||||
|
self.result_schema = schema
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
|
||||||
class QtApp(QMainWindow):
|
class QtApp(QMainWindow):
|
||||||
def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None:
|
def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -218,7 +419,7 @@ class QtApp(QMainWindow):
|
|||||||
self._active_filter: set[str] = set()
|
self._active_filter: set[str] = set()
|
||||||
self.filehandler.on_files_changed = lambda _=None: self.refresh_table()
|
self.filehandler.on_files_changed = lambda _=None: self.refresh_table()
|
||||||
|
|
||||||
self.setWindowTitle(f"{APP_NAME} {VERSION} — Filmotéka")
|
self.setWindowTitle(f"{APP_TITLE} — Filmotéka")
|
||||||
geometry = self.filehandler.global_config.get("window_geometry", "1200x800")
|
geometry = self.filehandler.global_config.get("window_geometry", "1200x800")
|
||||||
try:
|
try:
|
||||||
w, h = (int(x) for x in geometry.lower().split("x"))
|
w, h = (int(x) for x in geometry.lower().split("x"))
|
||||||
@@ -256,6 +457,7 @@ class QtApp(QMainWindow):
|
|||||||
self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2")
|
self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2")
|
||||||
self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T")
|
self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T")
|
||||||
self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D")
|
self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D")
|
||||||
|
self._add_action(movie_menu, "Nastavit atribut…", self.set_attribute)
|
||||||
self._add_action(movie_menu, "Upravit ČSFD odkaz…", self.edit_csfd)
|
self._add_action(movie_menu, "Upravit ČSFD odkaz…", self.edit_csfd)
|
||||||
self._add_action(movie_menu, "Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected)
|
self._add_action(movie_menu, "Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected)
|
||||||
movie_menu.addSeparator()
|
movie_menu.addSeparator()
|
||||||
@@ -265,6 +467,9 @@ class QtApp(QMainWindow):
|
|||||||
self._add_action(film_menu, "Vygenerovat Filmotéku", self.generate_filmoteka, "Ctrl+G")
|
self._add_action(film_menu, "Vygenerovat Filmotéku", self.generate_filmoteka, "Ctrl+G")
|
||||||
self._add_action(film_menu, "Copy-as-is složky…", self.edit_copyasis_folders)
|
self._add_action(film_menu, "Copy-as-is složky…", self.edit_copyasis_folders)
|
||||||
|
|
||||||
|
settings_menu = bar.addMenu("&Nastavení")
|
||||||
|
self._add_action(settings_menu, "Tag schéma…", self.edit_tag_schema)
|
||||||
|
|
||||||
def _add_action(self, menu: QMenu, text: str, slot, shortcut: str | None = None) -> QAction:
|
def _add_action(self, menu: QMenu, text: str, slot, shortcut: str | None = None) -> QAction:
|
||||||
action = QAction(text, self)
|
action = QAction(text, self)
|
||||||
if shortcut:
|
if shortcut:
|
||||||
@@ -347,13 +552,20 @@ class QtApp(QMainWindow):
|
|||||||
for t in f.tags:
|
for t in f.tags:
|
||||||
counts[t.full_path] = counts.get(t.full_path, 0) + 1
|
counts[t.full_path] = counts.get(t.full_path, 0) + 1
|
||||||
|
|
||||||
|
# Preserve each category's expanded/collapsed state across the rebuild
|
||||||
|
# (new categories default to expanded).
|
||||||
|
expanded = {}
|
||||||
|
for i in range(self.tag_tree.topLevelItemCount()):
|
||||||
|
cat = self.tag_tree.topLevelItem(i)
|
||||||
|
expanded[cat.text(0)] = cat.isExpanded()
|
||||||
|
|
||||||
self.tag_tree.blockSignals(True)
|
self.tag_tree.blockSignals(True)
|
||||||
self.tag_tree.clear()
|
self.tag_tree.clear()
|
||||||
for category in self.tagmanager.get_categories():
|
for category in self.tagmanager.get_categories():
|
||||||
cat_item = QTreeWidgetItem([category])
|
cat_item = QTreeWidgetItem([category])
|
||||||
cat_item.setFlags(Qt.ItemIsEnabled)
|
cat_item.setFlags(Qt.ItemIsEnabled)
|
||||||
self.tag_tree.addTopLevelItem(cat_item)
|
self.tag_tree.addTopLevelItem(cat_item)
|
||||||
cat_item.setExpanded(True)
|
cat_item.setExpanded(expanded.get(category, True))
|
||||||
for tag in self.tagmanager.get_tags_in_category(category):
|
for tag in self.tagmanager.get_tags_in_category(category):
|
||||||
count = counts.get(tag.full_path, 0)
|
count = counts.get(tag.full_path, 0)
|
||||||
item = QTreeWidgetItem([f"{tag.name} ({count})"])
|
item = QTreeWidgetItem([f"{tag.name} ({count})"])
|
||||||
@@ -433,6 +645,7 @@ class QtApp(QMainWindow):
|
|||||||
menu.addAction("Přejmenovat…", self.rename_movie)
|
menu.addAction("Přejmenovat…", self.rename_movie)
|
||||||
menu.addAction("Přiřadit štítky…", self.assign_tags)
|
menu.addAction("Přiřadit štítky…", self.assign_tags)
|
||||||
menu.addAction("Nastavit datum…", self.set_date)
|
menu.addAction("Nastavit datum…", self.set_date)
|
||||||
|
menu.addAction("Nastavit atribut…", self.set_attribute)
|
||||||
menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd)
|
menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd)
|
||||||
menu.addAction("Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected)
|
menu.addAction("Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected)
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
@@ -495,7 +708,8 @@ class QtApp(QMainWindow):
|
|||||||
if not paths:
|
if not paths:
|
||||||
return
|
return
|
||||||
sources = [Path(p) for p in paths]
|
sources = [Path(p) for p in paths]
|
||||||
dialog = ImportMoviesDialog(self, sources)
|
existing_names = {Path(f.filename).stem for f in self.filehandler.filelist}
|
||||||
|
dialog = ImportMoviesDialog(self, sources, existing_names)
|
||||||
if dialog.exec() != QDialog.Accepted:
|
if dialog.exec() != QDialog.Accepted:
|
||||||
return
|
return
|
||||||
entries = dialog.entries()
|
entries = dialog.entries()
|
||||||
@@ -503,12 +717,25 @@ class QtApp(QMainWindow):
|
|||||||
return
|
return
|
||||||
move = dialog.move_files
|
move = dialog.move_files
|
||||||
|
|
||||||
|
# Files whose name already exists in the pool (the red-highlighted rows)
|
||||||
|
colliding = [t for s, t, _ in entries if self.filehandler.pooled_with_stem(t)]
|
||||||
|
on_conflict = "suffix"
|
||||||
|
if colliding:
|
||||||
|
on_conflict = self._resolve_import_conflicts(colliding)
|
||||||
|
if on_conflict is None:
|
||||||
|
return # user cancelled the whole import
|
||||||
|
|
||||||
imported: list[File] = []
|
imported: list[File] = []
|
||||||
|
skipped = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
for source, title, csfd_link in entries:
|
for source, title, csfd_link in entries:
|
||||||
try:
|
try:
|
||||||
movie = self.filehandler.import_movie(source, title, csfd_link or None, move=move)
|
movie = self.filehandler.import_movie(
|
||||||
imported.append(movie)
|
source, title, csfd_link or None, move=move, on_conflict=on_conflict)
|
||||||
|
if movie is None:
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
imported.append(movie)
|
||||||
except Exception as exc: # noqa: BLE001 — surface per-file import failures
|
except Exception as exc: # noqa: BLE001 — surface per-file import failures
|
||||||
errors.append(f"{source.name}: {exc}")
|
errors.append(f"{source.name}: {exc}")
|
||||||
|
|
||||||
@@ -537,6 +764,29 @@ class QtApp(QMainWindow):
|
|||||||
QMessageBox.information(self, "Import", summary)
|
QMessageBox.information(self, "Import", summary)
|
||||||
self.status.showMessage(summary, 5000)
|
self.status.showMessage(summary, 5000)
|
||||||
|
|
||||||
|
def _resolve_import_conflicts(self, names: list[str]) -> str | None:
|
||||||
|
"""Ask how to handle names already in the pool. Returns the on_conflict
|
||||||
|
policy ("replace"/"suffix") or None if the user cancels the import."""
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Question)
|
||||||
|
box.setWindowTitle("Název už v poolu existuje")
|
||||||
|
listing = "\n".join(f" • {n}" for n in names[:10])
|
||||||
|
more = f"\n … a další ({len(names) - 10})" if len(names) > 10 else ""
|
||||||
|
box.setText(
|
||||||
|
f"V poolu už existují filmy s těmito názvy:\n{listing}{more}\n\n"
|
||||||
|
"Co s nimi?"
|
||||||
|
)
|
||||||
|
replace_btn = box.addButton("Nahradit novými", QMessageBox.AcceptRole)
|
||||||
|
keep_btn = box.addButton("Ponechat oba (přípona)", QMessageBox.ActionRole)
|
||||||
|
box.addButton("Zrušit import", QMessageBox.RejectRole)
|
||||||
|
box.exec()
|
||||||
|
clicked = box.clickedButton()
|
||||||
|
if clicked is replace_btn:
|
||||||
|
return "replace"
|
||||||
|
if clicked is keep_btn:
|
||||||
|
return "suffix"
|
||||||
|
return None
|
||||||
|
|
||||||
def open_movies(self) -> None:
|
def open_movies(self) -> None:
|
||||||
for f in self._selected_movies():
|
for f in self._selected_movies():
|
||||||
self._open_path(f.file_path)
|
self._open_path(f.file_path)
|
||||||
@@ -557,7 +807,8 @@ class QtApp(QMainWindow):
|
|||||||
if not files:
|
if not files:
|
||||||
QMessageBox.information(self, "Štítky", "Nejprve vyberte filmy.")
|
QMessageBox.information(self, "Štítky", "Nejprve vyberte filmy.")
|
||||||
return
|
return
|
||||||
dialog = AssignTagsDialog(self, self.tagmanager, files)
|
schema_categories = [e["category"] for e in self.filehandler.tag_schema]
|
||||||
|
dialog = AssignTagsDialog(self, self.tagmanager, files, schema_categories)
|
||||||
if dialog.exec() != QDialog.Accepted:
|
if dialog.exec() != QDialog.Accepted:
|
||||||
return
|
return
|
||||||
for full_path, state in dialog.result_map.items():
|
for full_path, state in dialog.result_map.items():
|
||||||
@@ -580,6 +831,30 @@ class QtApp(QMainWindow):
|
|||||||
f.set_date(text.strip() or None)
|
f.set_date(text.strip() or None)
|
||||||
self.refresh_table()
|
self.refresh_table()
|
||||||
|
|
||||||
|
def set_attribute(self) -> None:
|
||||||
|
files = self._selected_movies()
|
||||||
|
if not files:
|
||||||
|
QMessageBox.information(self, "Atribut", "Nejprve vyberte filmy.")
|
||||||
|
return
|
||||||
|
known = sorted({k for f in files for k in f.attributes})
|
||||||
|
key, ok = QInputDialog.getItem(
|
||||||
|
self, "Nastavit atribut",
|
||||||
|
"Název atributu (např. collection_sort):", known, editable=True)
|
||||||
|
key = key.strip()
|
||||||
|
if not ok or not key:
|
||||||
|
return
|
||||||
|
# prefill with the current value when editing a single movie
|
||||||
|
current = files[0].attributes.get(key, "") if len(files) == 1 else ""
|
||||||
|
value, ok = QInputDialog.getText(
|
||||||
|
self, "Nastavit atribut", f"Hodnota „{key}\" (prázdné = smazat):",
|
||||||
|
text=current)
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
for f in files:
|
||||||
|
f.set_attribute(key, value.strip() or None)
|
||||||
|
self.refresh_table()
|
||||||
|
self.status.showMessage(f"Atribut „{key}\" nastaven u {len(files)} filmů.", 5000)
|
||||||
|
|
||||||
def rename_movie(self) -> None:
|
def rename_movie(self) -> None:
|
||||||
files = self._selected_movies()
|
files = self._selected_movies()
|
||||||
if len(files) != 1:
|
if len(files) != 1:
|
||||||
@@ -641,8 +916,9 @@ class QtApp(QMainWindow):
|
|||||||
ok_count = 0
|
ok_count = 0
|
||||||
tags_total = 0
|
tags_total = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
schema = self.filehandler.tag_schema
|
||||||
for f in files:
|
for f in files:
|
||||||
result = f.apply_csfd_tags()
|
result = f.apply_csfd_tags(schema)
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
ok_count += 1
|
ok_count += 1
|
||||||
tags_total += len(result["tags_added"])
|
tags_total += len(result["tags_added"])
|
||||||
@@ -682,7 +958,11 @@ class QtApp(QMainWindow):
|
|||||||
return
|
return
|
||||||
manager = HardlinkManager(out)
|
manager = HardlinkManager(out)
|
||||||
created, create_fail, removed, remove_fail = manager.sync_structure(
|
created, create_fail, removed, remove_fail = manager.sync_structure(
|
||||||
files, category_roots=FILMOTEKA_CATEGORY_ROOTS
|
files,
|
||||||
|
category_roots=self.filehandler.filmoteka_category_roots(),
|
||||||
|
category_transforms=self.filehandler.filmoteka_category_transforms(),
|
||||||
|
category_filename_templates=(
|
||||||
|
self.filehandler.filmoteka_category_filename_templates()),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked)
|
# Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked)
|
||||||
@@ -722,6 +1002,18 @@ class QtApp(QMainWindow):
|
|||||||
"Copy-as-is složky: " + ", ".join(self.filehandler.copyasis_folders), 5000
|
"Copy-as-is složky: " + ", ".join(self.filehandler.copyasis_folders), 5000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def edit_tag_schema(self) -> None:
|
||||||
|
dialog = TagSchemaDialog(self, self.filehandler.tag_schema)
|
||||||
|
if dialog.exec() != QDialog.Accepted or dialog.result_schema is None:
|
||||||
|
return
|
||||||
|
self.filehandler.set_tag_schema(dialog.result_schema)
|
||||||
|
self.refresh_table() # categories/sidebar may have changed
|
||||||
|
self.status.showMessage(
|
||||||
|
f"Tag schéma uloženo ({len(dialog.result_schema)} kategorií). "
|
||||||
|
"Pro nové tagy spusťte „Načíst tagy z ČSFD\" a přegenerujte Filmotéku.",
|
||||||
|
8000,
|
||||||
|
)
|
||||||
|
|
||||||
def closeEvent(self, event) -> None: # noqa: N802 — Qt override
|
def closeEvent(self, event) -> None: # noqa: N802 — Qt override
|
||||||
self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}"
|
self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}"
|
||||||
from src.core.config import save_global_config
|
from src.core.config import save_global_config
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class TestGlobalConfig:
|
|||||||
"pool_dir": None,
|
"pool_dir": None,
|
||||||
"filmoteka_dir": None,
|
"filmoteka_dir": None,
|
||||||
"copyasis_folders": ["Seriály"],
|
"copyasis_folders": ["Seriály"],
|
||||||
|
"tag_schema": DEFAULT_GLOBAL_CONFIG["tag_schema"],
|
||||||
}
|
}
|
||||||
|
|
||||||
save_global_config(test_config)
|
save_global_config(test_config)
|
||||||
|
|||||||
@@ -182,6 +182,26 @@ class TestHelperFunctions:
|
|||||||
assert rating_band(90) == "90–100 %"
|
assert rating_band(90) == "90–100 %"
|
||||||
assert rating_band(95) == "90–100 %"
|
assert rating_band(95) == "90–100 %"
|
||||||
assert rating_band(100) == "90–100 %"
|
assert rating_band(100) == "90–100 %"
|
||||||
|
|
||||||
|
def test_csfd_field_values_are_exact_no_transform(self):
|
||||||
|
from src.core.csfd import csfd_field_values
|
||||||
|
movie = CSFDMovie(title="X", url="u", year=1999, rating=86,
|
||||||
|
genres=["Akční", "Sci-Fi"], countries=["USA", "Kanada"])
|
||||||
|
assert csfd_field_values(movie, "genres") == ["Akční", "Sci-Fi"]
|
||||||
|
assert csfd_field_values(movie, "countries") == ["USA", "Kanada"]
|
||||||
|
assert csfd_field_values(movie, "year") == ["1999"]
|
||||||
|
# rating tag carries the EXACT value (transform happens only for folders)
|
||||||
|
assert csfd_field_values(movie, "rating") == ["86"]
|
||||||
|
# missing field / value → empty
|
||||||
|
assert csfd_field_values(CSFDMovie(title="X", url="u"), "rating") == []
|
||||||
|
assert csfd_field_values(movie, "genres") == csfd_field_values(movie, "genres")
|
||||||
|
|
||||||
|
def test_apply_transform_decade_band(self):
|
||||||
|
from src.core.csfd import apply_transform
|
||||||
|
assert apply_transform("86", "decade_band") == "80–89 %"
|
||||||
|
assert apply_transform("90", "decade_band") == "90–100 %"
|
||||||
|
assert apply_transform("Akční", None) == "Akční" # identity for non-rating
|
||||||
|
assert apply_transform("USA", "identity") == "USA"
|
||||||
assert _parse_duration("PT") is None
|
assert _parse_duration("PT") is None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+64
-3
@@ -297,7 +297,7 @@ class TestApplyCsfdTags:
|
|||||||
assert "Žánr/Sci-Fi" in paths
|
assert "Žánr/Sci-Fi" in paths
|
||||||
assert "Rok/1999" in paths
|
assert "Rok/1999" in paths
|
||||||
assert "Země původu/USA" in paths
|
assert "Země původu/USA" in paths
|
||||||
assert "Hodnocení/90–100 %" in paths
|
assert "Hodnocení/90" in paths # exact value; folder grouping happens later
|
||||||
|
|
||||||
def test_apply_csfd_tags_does_not_tag_directors_or_actors(self, movie_file):
|
def test_apply_csfd_tags_does_not_tag_directors_or_actors(self, movie_file):
|
||||||
"""Režie/herci se jen cachují, netvoří se z nich tagy (bylo by jich moc)."""
|
"""Režie/herci se jen cachují, netvoří se z nich tagy (bylo by jich moc)."""
|
||||||
@@ -319,14 +319,75 @@ class TestApplyCsfdTags:
|
|||||||
assert cached.directors == ["Lana Wachowski"]
|
assert cached.directors == ["Lana Wachowski"]
|
||||||
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"]
|
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"]
|
||||||
|
|
||||||
def test_apply_csfd_tags_can_skip_rating(self, movie_file):
|
def test_name_context_fields(self, movie_file):
|
||||||
|
movie_file.title = "Dr. No"
|
||||||
|
movie_file.csfd_cache = {"year": 1962, "rating": 75}
|
||||||
|
ctx = movie_file.name_context()
|
||||||
|
assert ctx["title"] == "Dr. No"
|
||||||
|
assert ctx["year"] == 1962
|
||||||
|
assert ctx["rating"] == 75
|
||||||
|
assert ctx["ext"] == movie_file.file_path.suffix
|
||||||
|
assert ctx["filename"] == movie_file.filename
|
||||||
|
assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}"
|
||||||
|
|
||||||
|
def test_name_context_year_from_tag_when_no_cache(self, movie_file):
|
||||||
|
movie_file.csfd_cache = None
|
||||||
|
movie_file.add_tag("Rok/1999")
|
||||||
|
assert movie_file.name_context()["year"] == "1999"
|
||||||
|
|
||||||
|
def test_set_attribute_persists_and_in_context(self, movie_file):
|
||||||
|
movie_file.set_attribute("collection_sort", "03")
|
||||||
|
assert movie_file.attributes["collection_sort"] == "03"
|
||||||
|
assert movie_file.name_context()["collection_sort"] == "03"
|
||||||
|
# reload (from index) keeps it
|
||||||
|
reloaded = File(movie_file.file_path, movie_file.tagmanager,
|
||||||
|
index=movie_file.index)
|
||||||
|
assert reloaded.attributes["collection_sort"] == "03"
|
||||||
|
|
||||||
|
def test_set_attribute_empty_removes_it(self, movie_file):
|
||||||
|
movie_file.set_attribute("collection_sort", "03")
|
||||||
|
movie_file.set_attribute("collection_sort", None)
|
||||||
|
assert "collection_sort" not in movie_file.attributes
|
||||||
|
|
||||||
|
def test_attribute_usable_in_filename_template(self, movie_file):
|
||||||
|
movie_file.title = "Dr. No"
|
||||||
|
movie_file.set_attribute("collection_sort", "01")
|
||||||
|
ctx = movie_file.name_context()
|
||||||
|
assert "{collection_sort} - {title}{ext}".format(**ctx) == \
|
||||||
|
f"01 - Dr. No{ctx['ext']}"
|
||||||
|
|
||||||
|
def test_apply_csfd_tags_honors_custom_schema(self, movie_file):
|
||||||
|
"""A schema without the rating entry produces no Hodnocení tags."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from src.core.csfd import CSFDMovie
|
from src.core.csfd import CSFDMovie
|
||||||
|
|
||||||
|
schema = [{"category": "Žánr", "csfd_field": "genres",
|
||||||
|
"transform": None, "filmoteka_root": ""}]
|
||||||
movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"])
|
movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"])
|
||||||
with patch("src.core.csfd.fetch_movie", return_value=movie):
|
with patch("src.core.csfd.fetch_movie", return_value=movie):
|
||||||
movie_file.apply_csfd_tags(add_rating=False)
|
movie_file.apply_csfd_tags(schema)
|
||||||
|
|
||||||
paths = {t.full_path for t in movie_file.tags}
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
assert "Žánr/Drama" in paths
|
assert "Žánr/Drama" in paths
|
||||||
assert not any(p.startswith("Hodnocení/") for p in paths)
|
assert not any(p.startswith("Hodnocení/") for p in paths)
|
||||||
|
|
||||||
|
def test_apply_csfd_tags_preserves_user_tags_on_refetch(self, movie_file):
|
||||||
|
"""Re-fetching regenerates only ČSFD tags; user-added tags survive."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
from src.core.csfd import CSFDMovie
|
||||||
|
|
||||||
|
movie_file.add_tag("Sbírka/Oblíbené") # user tag
|
||||||
|
first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"])
|
||||||
|
with patch("src.core.csfd.fetch_movie", return_value=first):
|
||||||
|
movie_file.apply_csfd_tags()
|
||||||
|
# different movie on re-fetch
|
||||||
|
second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"])
|
||||||
|
with patch("src.core.csfd.fetch_movie", return_value=second):
|
||||||
|
movie_file.apply_csfd_tags()
|
||||||
|
|
||||||
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
|
assert "Sbírka/Oblíbené" in paths # user tag kept
|
||||||
|
assert "Žánr/Drama" in paths # new ČSFD tag
|
||||||
|
assert "Rok/2009" in paths
|
||||||
|
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
|
||||||
|
assert "Rok/1999" not in paths
|
||||||
|
|||||||
@@ -603,6 +603,70 @@ class TestPoolManagement:
|
|||||||
assert movie.file_path.exists()
|
assert movie.file_path.exists()
|
||||||
assert not source.exists() # moved, not copied
|
assert not source.exists() # moved, not copied
|
||||||
|
|
||||||
|
def test_filmoteka_category_roots_from_schema(self, file_manager):
|
||||||
|
file_manager.set_tag_schema([
|
||||||
|
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
|
||||||
|
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku"},
|
||||||
|
{"category": "Herec", "csfd_field": "actors", "transform": None, "filmoteka_root": None},
|
||||||
|
])
|
||||||
|
roots = file_manager.filmoteka_category_roots()
|
||||||
|
assert roots == {"Žánr": "", "Rok": "Dle roku"} # None-root excluded
|
||||||
|
assert "Herec" not in roots
|
||||||
|
|
||||||
|
def test_import_movie_suffix_keeps_both(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"b")
|
||||||
|
first = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
||||||
|
second = file_manager.import_movie(tmp_path / "b.mkv", "Matrix") # default suffix
|
||||||
|
|
||||||
|
assert first.file_path.name == "Matrix.mkv"
|
||||||
|
assert second.file_path.name == "Matrix_1.mkv"
|
||||||
|
assert len(file_manager.filelist) == 2
|
||||||
|
|
||||||
|
def test_import_movie_replace_evicts_existing(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"bb")
|
||||||
|
first = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
||||||
|
first.add_tag("Žánr/Akční")
|
||||||
|
old_path = first.file_path
|
||||||
|
|
||||||
|
second = file_manager.import_movie(
|
||||||
|
tmp_path / "b.mkv", "Matrix", csfd_link="x", on_conflict="replace")
|
||||||
|
|
||||||
|
assert second.file_path.name == "Matrix.mkv"
|
||||||
|
assert second.file_path == old_path # same name reused
|
||||||
|
assert second.file_path.read_bytes() == b"bb" # new content in place
|
||||||
|
assert [f.file_path.name for f in file_manager.filelist] == ["Matrix.mkv"]
|
||||||
|
# index record reflects the new import (fresh metadata, old tags dropped)
|
||||||
|
record = file_manager.index.get(second.file_path)
|
||||||
|
assert record is not None
|
||||||
|
assert record["csfd_link"] == "x"
|
||||||
|
assert record["tags"] == []
|
||||||
|
assert second.tags == []
|
||||||
|
|
||||||
|
def test_import_movie_replace_across_extensions(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
(tmp_path / "b.mp4").write_bytes(b"b")
|
||||||
|
file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
||||||
|
file_manager.import_movie(tmp_path / "b.mp4", "Matrix", on_conflict="replace")
|
||||||
|
|
||||||
|
names = [f.file_path.name for f in file_manager.filelist]
|
||||||
|
assert names == ["Matrix.mp4"]
|
||||||
|
assert not (tmp_path / "pool" / "Filmy" / "Matrix.mkv").exists()
|
||||||
|
|
||||||
|
def test_import_movie_skip_returns_none(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"b")
|
||||||
|
file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
||||||
|
result = file_manager.import_movie(tmp_path / "b.mkv", "Matrix", on_conflict="skip")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert len(file_manager.filelist) == 1
|
||||||
|
|
||||||
def test_rename_movie_renames_file_and_reindexes(self, file_manager, tmp_path):
|
def test_rename_movie_renames_file_and_reindexes(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
source = tmp_path / "raw.mkv"
|
source = tmp_path / "raw.mkv"
|
||||||
|
|||||||
@@ -141,6 +141,67 @@ class TestHardlinkManager:
|
|||||||
# The mirror (not a managed tag folder) is left alone
|
# The mirror (not a managed tag folder) is left alone
|
||||||
assert mirror_link.exists()
|
assert mirror_link.exists()
|
||||||
|
|
||||||
|
def test_category_transform_groups_folder_by_band(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""Exact tag value, but the folder name goes through the transform."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("Hodnocení", "90")) # exact rating tag
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.create_structure_for_files(
|
||||||
|
[f],
|
||||||
|
category_roots={"Hodnocení": "Dle hodnocení"},
|
||||||
|
category_transforms={"Hodnocení": "decade_band"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# folder is the band; the file lands inside it
|
||||||
|
assert (temp_output_dir / "Dle hodnocení" / "90–100 %" / "file1.txt").exists()
|
||||||
|
# not a per-exact-value folder
|
||||||
|
assert not (temp_output_dir / "Dle hodnocení" / "90").exists()
|
||||||
|
|
||||||
|
def test_filename_template_applies_only_in_that_category(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""A per-category template renames the hardlink only inside its folder."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.title = "Dr. No"
|
||||||
|
f.csfd_cache = {"year": 1962}
|
||||||
|
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||||
|
f.add_tag(Tag("žánr", "Akční"))
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.create_structure_for_files(
|
||||||
|
[f],
|
||||||
|
category_roots={"Kolekce": "Dle kolekce", "žánr": ""},
|
||||||
|
category_filename_templates={"Kolekce": "{year} - {title}{ext}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Templated name inside the collection folder
|
||||||
|
assert (temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt").exists()
|
||||||
|
# Other categories keep the pool filename
|
||||||
|
assert (temp_output_dir / "Akční" / "file1.txt").exists()
|
||||||
|
|
||||||
|
def test_filename_template_cleanup_is_consistent(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""sync twice with a template leaves no stale/duplicate templated link."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.title = "Dr. No"
|
||||||
|
f.csfd_cache = {"year": 1962}
|
||||||
|
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||||
|
roots = {"Kolekce": "Dle kolekce"}
|
||||||
|
templates = {"Kolekce": "{year} - {title}{ext}"}
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.sync_structure([f], category_roots=roots, category_filename_templates=templates)
|
||||||
|
created, _, removed, _ = manager.sync_structure(
|
||||||
|
[f], category_roots=roots, category_filename_templates=templates)
|
||||||
|
|
||||||
|
folder = temp_output_dir / "Dle kolekce" / "James Bond"
|
||||||
|
assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"]
|
||||||
|
assert removed == 0 # nothing treated as obsolete on the second run
|
||||||
|
|
||||||
def test_dry_run(self, files_with_tags, temp_output_dir):
|
def test_dry_run(self, files_with_tags, temp_output_dir):
|
||||||
"""Test dry run (bez skutečného vytváření)"""
|
"""Test dry run (bez skutečného vytváření)"""
|
||||||
manager = HardlinkManager(temp_output_dir)
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user