Store library settings in the pool index and drop legacy sidecars

This commit is contained in:
2026-07-03 16:06:45 +02:00
parent f0f38d4257
commit 7ae0709f38
13 changed files with 379 additions and 2292 deletions
+23
View File
@@ -21,6 +21,29 @@ Each version entry uses these sections (include only those that apply):
## Unreleased
## 1.8.0 — 2026-07-03
### Changed
- **Split config responsibilities by scope.** The global config (`.Curator.!gtag`)
now carries **application data only** — window state, MRU folders, and the
`pool_dir` / `filmoteka_dir` pointers. Everything describing a particular
library — **`tag_schema`** and **`copyasis_folders`** — moved into the pool
index (`.Curator.!index`, under a new `settings` section), so it travels with
the pool. `FileManager` reads/writes those via the index (`PoolIndex.get_setting`
/ `set_setting`) and **migrates** any values left in an old global config into
the index once, on first open (`FileManager._migrate_global_settings`).
### Removed
- **Per-folder config (`.Curator.!ftag`)** and the whole legacy folder-scan path
(`FileManager.append` / `get_folder_config` / `save_folder_config` /
`set_ignore_patterns` / `get_ignore_patterns`, `config.load_folder_config` etc.)
— inherited from Tagger and unused by the Filmotéka workflow.
- **Per-file metadata sidecars (`.{filename}.!tag`).** `File` metadata is now
stored only in the pool index; an index-less `File` is an in-memory object and
writes nothing to disk.
- **Old tkinter GUI** (`src/ui/gui.py`), orphaned since the PySide6 rewrite and
the only remaining consumer of the folder-config/sidecar code.
## 1.7.0 — 2026-07-02
### Added
+12 -2
View File
@@ -51,8 +51,9 @@ Curator manages a personal movie library based on two folders:
loads the pool, and the GUI generates the Filmotéka tree via `HardlinkManager`.
- `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index**
(`<pool>/.Curator.!index`, see `pool_index.py`); `File` writes there when an
index is injected, and still falls back to per-file `.!tag` sidecars for
arbitrary (non-pool) folders.
index is injected. Without an index a `File` is an in-memory object only — the
old per-file `.!tag` sidecars and the per-folder `.!ftag` config have been
removed.
### GUI decision
@@ -65,6 +66,15 @@ movie table, and one-click Filmotéka generation.
- **Metadata storage:** one **unified metadata file** for the whole pool (a
central index), not per-file sidecars. Justified because Curator owns the pool
and files are never moved manually, so it is not exposed to path drift.
- **Config split by scope:** the global config (`.Curator.!gtag`) holds **app
data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir`
pointers (which must stay global: the index is found *through* them). Everything
that describes a specific library — `tag_schema` and `copyasis_folders` — lives
in the pool index under a `settings` section, so it travels with the pool.
`FileManager` migrates any pre-existing global values into the index on first
open. The old per-folder `.!ftag` config and per-file `.!tag` sidecars were
removed (they were unused Tagger leftovers), and the orphaned tkinter
`src/ui/gui.py` went with them.
- **Import dialog:** **multi-file** — pick several videos at once and give each
its own **Title** + **ČSFD link** (one row per file, more can be added from the
dialog), or auto-filled with **"Najít ČSFD odkazy"** (cleans each filename into
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "curator"
version = "1.7.0"
version = "1.8.0"
description = ""
authors = [
{name = "jan.doubravsky@gmail.com"}
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually."""
__version__ = "1.7.0"
__version__ = "1.8.0"
+18 -58
View File
@@ -1,10 +1,15 @@
"""
Configuration management for Curator
Configuration for Curator.
Three levels of configuration:
1. Global config (.Curator.!gtag next to Curator.py) - app-wide settings
2. Folder config (.Curator.!ftag in project root) - folder-specific settings
3. File tags (.{filename}.!tag) - per-file metadata (handled in file.py)
Two stores, by responsibility:
1. Global config (``.Curator.!gtag`` next to Curator.py) — **application data
only**: window state, MRU folders, and the pointers to where the pool and the
Filmotéka output live (``pool_dir`` / ``filmoteka_dir``). Those pointers must
stay here because the pool index is found *through* them.
2. Pool index (``<pool>/.Curator.!index``, see ``pool_index.py``) — everything
describing a particular library: per-movie metadata **and** library-level
settings (``tag_schema``, ``copyasis_folders``). It travels with the pool.
"""
import json
from pathlib import Path
@@ -12,12 +17,9 @@ from pathlib import Path
# Global config file (next to the main script)
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
# Folder config filename
FOLDER_CONFIG_NAME = ".Curator.!ftag"
# =============================================================================
# GLOBAL CONFIG - Application settings
# TAG SCHEMA - default library settings (stored per-pool in the index)
# =============================================================================
# Tag schema: the single source of truth for which tag categories exist, how
@@ -35,6 +37,8 @@ FOLDER_CONFIG_NAME = ".Curator.!ftag"
# keep the pool filename. Pool files are never renamed by this.
# Grouping folders are prefixed with "- " so DLNA/TV browsers sort the special
# folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root.
# This lives in the code as the default; the effective schema is stored per-pool
# in the index (``PoolIndex.settings["tag_schema"]``).
DEFAULT_TAG_SCHEMA = [
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "- Dle roku"},
@@ -44,6 +48,11 @@ DEFAULT_TAG_SCHEMA = [
"filmoteka_root": "- Dle hodnocení"},
]
# =============================================================================
# GLOBAL CONFIG - Application settings only
# =============================================================================
DEFAULT_GLOBAL_CONFIG = {
"window_geometry": "1200x800",
"window_maximized": False,
@@ -52,8 +61,6 @@ DEFAULT_GLOBAL_CONFIG = {
"recent_folders": [],
"pool_dir": None, # managed pool root (single source of truth)
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
"copyasis_folders": ["Seriály"], # pool subfolders mirrored 1:1 (copy-as-is)
"tag_schema": DEFAULT_TAG_SCHEMA, # tag categories + ČSFD/Filmotéka rules
}
@@ -79,53 +86,6 @@ def save_global_config(cfg: dict):
json.dump(cfg, f, indent=2, ensure_ascii=False)
# =============================================================================
# FOLDER CONFIG - Per-folder settings
# =============================================================================
DEFAULT_FOLDER_CONFIG = {
"ignore_patterns": [],
"custom_tags": {}, # Additional tags specific to this folder
"recursive": True, # Whether to scan subfolders
"hardlink_output_dir": None, # Output directory for hardlink structure
"hardlink_categories": None, # Categories to include in hardlink (None = all)
}
def get_folder_config_path(folder: Path) -> Path:
"""Get path to folder config file"""
return folder / FOLDER_CONFIG_NAME
def load_folder_config(folder: Path) -> dict:
"""Load folder-specific config"""
config_path = get_folder_config_path(folder)
if config_path.exists():
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
# Merge with defaults for any missing keys
for key, value in DEFAULT_FOLDER_CONFIG.items():
if key not in config:
config[key] = value
return config
except Exception:
return DEFAULT_FOLDER_CONFIG.copy()
return DEFAULT_FOLDER_CONFIG.copy()
def save_folder_config(folder: Path, cfg: dict):
"""Save folder-specific config"""
config_path = get_folder_config_path(folder)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def folder_has_config(folder: Path) -> bool:
"""Check if folder has a tagger config"""
return get_folder_config_path(folder).exists()
# =============================================================================
# BACKWARDS COMPATIBILITY
# =============================================================================
+9 -32
View File
@@ -1,5 +1,4 @@
from pathlib import Path
import json
from .tag import Tag
# Bump this when the csfd_cache schema changes to force re-fetch on next open.
@@ -11,9 +10,9 @@ class File:
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
self.file_path = file_path
self.filename = file_path.name
self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag"
# Optional unified pool index; when set, metadata lives there instead of
# in the sidecar file (see PoolIndex).
# Unified pool index; when set, metadata is persisted there (see
# PoolIndex). Without an index a File is an in-memory object only — no
# metadata is written to disk (there are no per-file sidecars anymore).
self.index = index
self.new = True
self.ignored = False
@@ -38,20 +37,12 @@ class File:
self.get_metadata()
def get_metadata(self) -> None:
if self.index is not None:
record = self.index.get(self.file_path)
record = self.index.get(self.file_path) if self.index is not None else None
if record is None:
self._init_new_metadata()
self.save_metadata()
else:
self._apply_record(record)
return
if not self.metadata_filename.exists():
self._init_new_metadata()
self.save_metadata()
else:
self.load_metadata()
def _init_new_metadata(self) -> None:
self.new = True
@@ -111,17 +102,9 @@ class File:
self.tags.append(tag)
def save_metadata(self):
data = self._build_record()
"""Persist metadata to the pool index; a no-op for an index-less File."""
if self.index is not None:
self.index.set(self.file_path, data)
return
with open(self.metadata_filename, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def load_metadata(self) -> None:
with open(self.metadata_filename, "r", encoding="utf-8") as f:
data = json.load(f)
self._apply_record(data)
self.index.set(self.file_path, self._build_record())
def added_timestamp(self) -> float:
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
@@ -181,27 +164,21 @@ class File:
self.save_metadata()
def delete_metadata(self) -> None:
"""Remove this file's metadata (from the index, or its sidecar file)."""
"""Remove this file's metadata from the pool index (no-op without one)."""
if self.index is not None:
self.index.delete(self.file_path)
elif self.metadata_filename.exists():
self.metadata_filename.unlink()
def relocate(self, new_path: Path) -> None:
"""Point this File at a new path, moving its metadata along.
The physical file must already have been moved/renamed by the caller.
Drops the metadata under the old path (index key or sidecar) and rebinds
to the new path; call ``save_metadata()`` afterwards to write it back.
Drops the metadata under the old index key and rebinds to the new path;
call ``save_metadata()`` afterwards to write it back.
"""
old_metadata_filename = self.metadata_filename
if self.index is not None:
self.index.delete(self.file_path)
self.file_path = Path(new_path)
self.filename = self.file_path.name
self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag"
if self.index is None and old_metadata_filename.exists():
old_metadata_filename.rename(self.metadata_filename)
def set_date(self, date_str: str | None):
"""Nastaví datum (např. '2025-09-25') nebo None pro smazání."""
+68 -97
View File
@@ -7,30 +7,33 @@ from .tag_manager import TagManager
from .pool_index import PoolIndex
from .utils import list_files
from typing import Iterable
import fnmatch
from src.core.config import (
load_global_config, save_global_config,
load_folder_config, save_folder_config
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
)
# Top-level folders inside the managed pool
POOL_MOVIES = "Filmy"
POOL_SERIES = "Seriály"
# Curator metadata files that must never be treated as content
# Library-level settings that live in the pool index (not the global config).
_LIBRARY_SETTINGS_DEFAULTS = {
"tag_schema": DEFAULT_TAG_SCHEMA,
"copyasis_folders": [POOL_SERIES],
}
# Curator metadata files that must never be treated as content. The legacy
# per-file (.!tag) and per-folder (.!ftag) sidecars are no longer written, but
# stray ones are still skipped so they never leak into the Filmotéka.
METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index")
class FileManager:
def __init__(self, tagmanager: TagManager):
self.filelist: list[File] = []
self.folders: list[Path] = []
self.tagmanager = tagmanager
self.on_files_changed = None # callback do GUI
self.global_config = load_global_config()
self.folder_configs: dict[Path, dict] = {} # folder -> config
self.current_folder: Path | None = None
self.index: PoolIndex | None = None # unified pool metadata index
self.index: PoolIndex | None = None # unified pool metadata + settings
# ------------------------------------------------------------------
# Pool (single source of truth) and Filmotéka (generated output)
@@ -51,27 +54,75 @@ class FileManager:
pool = self.pool_dir
return pool / POOL_SERIES if pool else None
# ------------------------------------------------------------------
# Pool index + library-level settings (stored in the index, per pool)
# ------------------------------------------------------------------
def _open_index(self, pool: Path) -> PoolIndex:
"""(Re)load the pool index and migrate any legacy global settings."""
self.index = PoolIndex(pool)
self._migrate_global_settings()
return self.index
def _ensure_index(self) -> PoolIndex | None:
"""Return the pool index, opening it lazily when a pool is configured."""
if self.index is None and self.pool_dir:
self._open_index(self.pool_dir)
return self.index
def _migrate_global_settings(self) -> None:
"""Move library settings from the old global config into the index once.
Earlier versions stored ``tag_schema`` / ``copyasis_folders`` in the
global ``.!gtag``. They now belong to the pool; seed the index from the
old values (if present and not already migrated) and drop them from the
global config so it carries app data only.
"""
if self.index is None:
return
moved = False
for key in _LIBRARY_SETTINGS_DEFAULTS:
if key in self.global_config:
if key not in self.index.settings:
self.index.set_setting(key, self.global_config[key])
del self.global_config[key]
moved = True
if moved:
save_global_config(self.global_config)
def _get_setting(self, key: str):
"""Read a library setting from the index (falls back to the default)."""
default = _LIBRARY_SETTINGS_DEFAULTS[key]
idx = self._ensure_index()
if idx is None:
return default
return idx.get_setting(key, default)
def _set_setting(self, key: str, value) -> None:
"""Persist a library setting into the index (needs a configured pool)."""
idx = self._ensure_index()
if idx is None:
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
idx.set_setting(key, value)
@property
def copyasis_folders(self) -> list[str]:
"""Names of pool subfolders mirrored 1:1 (copy-as-is) into the output."""
return self.global_config.get("copyasis_folders", [POOL_SERIES])
return self._get_setting("copyasis_folders")
def set_copyasis_folders(self, names: list[str]) -> None:
"""Set the copy-as-is subfolder list and persist it."""
cleaned = [n.strip() for n in names if n.strip()]
self.global_config["copyasis_folders"] = cleaned
save_global_config(self.global_config)
self._set_setting("copyasis_folders", cleaned)
@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)
return self._get_setting("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)
self._set_setting("tag_schema", schema)
def filmoteka_category_roots(self) -> dict[str, str]:
"""Category → output root-folder map derived from the tag schema.
@@ -131,7 +182,7 @@ class FileManager:
if not (movies and movies.is_dir() and pool):
return
self.index = PoolIndex(pool)
self._open_index(pool)
for each in list_files(movies):
if each.name.endswith(METADATA_SUFFIXES):
continue
@@ -252,7 +303,7 @@ class FileManager:
movies.mkdir(parents=True, exist_ok=True)
if self.index is None:
self.index = PoolIndex(pool)
self._open_index(pool)
source = Path(source)
safe_title = title.strip() or source.stem
@@ -325,80 +376,6 @@ class FileManager:
self.on_files_changed(self.filelist)
return file_obj
def append(self, folder: Path) -> None:
"""Add a folder to scan for files"""
self.folders.append(folder)
self.current_folder = folder
# Update global config with last folder
self.global_config["last_folder"] = str(folder)
# Update recent folders list
recent = self.global_config.get("recent_folders", [])
folder_str = str(folder)
if folder_str in recent:
recent.remove(folder_str)
recent.insert(0, folder_str)
self.global_config["recent_folders"] = recent[:10] # Keep max 10
save_global_config(self.global_config)
# Load folder-specific config
folder_config = load_folder_config(folder)
self.folder_configs[folder] = folder_config
# Get ignore patterns from folder config
ignore_patterns = folder_config.get("ignore_patterns", [])
for each in list_files(folder):
# Skip all Curator metadata files (.!tag / .!ftag / .!gtag / .!index)
if each.name.endswith(METADATA_SUFFIXES):
continue
full_path = each.as_posix()
# Check against ignore patterns
if any(
fnmatch.fnmatch(each.name, pat) or fnmatch.fnmatch(full_path, pat)
for pat in ignore_patterns
):
continue
file_obj = File(each, self.tagmanager)
self.filelist.append(file_obj)
def get_folder_config(self, folder: Path = None) -> dict:
"""Get config for a folder (or current folder if not specified)"""
if folder is None:
folder = self.current_folder
if folder is None:
return {}
if folder not in self.folder_configs:
self.folder_configs[folder] = load_folder_config(folder)
return self.folder_configs[folder]
def save_folder_config(self, folder: Path = None, config: dict = None):
"""Save config for a folder"""
if folder is None:
folder = self.current_folder
if folder is None:
return
if config is None:
config = self.folder_configs.get(folder, {})
self.folder_configs[folder] = config
save_folder_config(folder, config)
def set_ignore_patterns(self, patterns: list[str], folder: Path = None):
"""Set ignore patterns for a folder"""
config = self.get_folder_config(folder)
config["ignore_patterns"] = patterns
self.save_folder_config(folder, config)
def get_ignore_patterns(self, folder: Path = None) -> list[str]:
"""Get ignore patterns for a folder"""
config = self.get_folder_config(folder)
return config.get("ignore_patterns", [])
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
for f in files_objs:
@@ -460,9 +437,3 @@ class FileManager:
if all(tag in file_tags for tag in target_full_paths):
filtered.append(f)
return filtered
# Legacy property for backwards compatibility
@property
def config(self):
"""Legacy: returns global config"""
return self.global_config
+26 -1
View File
@@ -5,6 +5,15 @@ Instead of one sidecar file per movie, the whole pool keeps a single JSON index
at ``<pool>/.Curator.!index``. Curator owns the pool (it inserts/removes files
itself), so files never move behind its back and a central index is safe.
The index also carries **library-level settings** (``tag_schema``,
``copyasis_folders``) under a ``settings`` section, so everything describing a
particular pool travels with the pool. The global config keeps only app data
(window state, MRU) and the pointers to where the pool/output live.
On-disk shape::
{"settings": {...}, "movies": {"Filmy/Matrix.mkv": {...}, ...}}
Records are keyed by the file's path relative to the pool root (POSIX form),
e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines.
"""
@@ -21,6 +30,7 @@ class PoolIndex:
self.pool_dir = Path(pool_dir)
self.index_path = self.pool_dir / INDEX_FILENAME
self.records: dict[str, dict] = {}
self.settings: dict = {}
self.load()
def _key(self, file_path: Path) -> str:
@@ -35,19 +45,34 @@ class PoolIndex:
"""Load the index from disk (missing/corrupt index = empty)."""
if not self.index_path.exists():
self.records = {}
self.settings = {}
return
try:
with open(self.index_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.records = data.get("movies", {})
self.settings = data.get("settings", {})
except (json.JSONDecodeError, OSError):
self.records = {}
self.settings = {}
def save(self) -> None:
"""Persist the index to disk."""
self.pool_dir.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f:
json.dump({"movies": self.records}, f, indent=2, ensure_ascii=False)
json.dump(
{"settings": self.settings, "movies": self.records},
f, indent=2, ensure_ascii=False,
)
def get_setting(self, key: str, default=None):
"""Return a library-level setting stored in the index."""
return self.settings.get(key, default)
def set_setting(self, key: str, value) -> None:
"""Upsert a library-level setting and persist the index."""
self.settings[key] = value
self.save()
def get(self, file_path: Path) -> dict | None:
"""Return the record for a file, or None if it is not indexed."""
-1296
View File
File diff suppressed because it is too large Load Diff
+8 -182
View File
@@ -2,8 +2,6 @@ import pytest
import json
from src.core.config import (
load_global_config, save_global_config, DEFAULT_GLOBAL_CONFIG,
load_folder_config, save_folder_config, DEFAULT_FOLDER_CONFIG,
get_folder_config_path, folder_has_config, FOLDER_CONFIG_NAME,
load_config, save_config # Legacy functions
)
@@ -20,16 +18,23 @@ class TestGlobalConfig:
return config_path
def test_default_global_config_structure(self):
"""Test struktury defaultní globální konfigurace"""
"""Test struktury defaultní globální konfigurace (jen data aplikace)"""
assert "window_geometry" in DEFAULT_GLOBAL_CONFIG
assert "window_maximized" in DEFAULT_GLOBAL_CONFIG
assert "last_folder" in DEFAULT_GLOBAL_CONFIG
assert "sidebar_width" in DEFAULT_GLOBAL_CONFIG
assert "recent_folders" in DEFAULT_GLOBAL_CONFIG
assert "pool_dir" in DEFAULT_GLOBAL_CONFIG
assert "filmoteka_dir" in DEFAULT_GLOBAL_CONFIG
assert DEFAULT_GLOBAL_CONFIG["window_geometry"] == "1200x800"
assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False
assert DEFAULT_GLOBAL_CONFIG["last_folder"] is None
def test_global_config_holds_no_library_settings(self):
"""Library settings (tag_schema / copyasis_folders) žijí v indexu, ne tady."""
assert "tag_schema" not in DEFAULT_GLOBAL_CONFIG
assert "copyasis_folders" not in DEFAULT_GLOBAL_CONFIG
def test_load_global_config_nonexistent_file(self, temp_global_config):
"""Test načtení globální konfigurace když soubor neexistuje"""
config = load_global_config()
@@ -62,8 +67,6 @@ class TestGlobalConfig:
"recent_folders": [],
"pool_dir": None,
"filmoteka_dir": None,
"copyasis_folders": ["Seriály"],
"tag_schema": DEFAULT_GLOBAL_CONFIG["tag_schema"],
}
save_global_config(test_config)
@@ -125,150 +128,6 @@ class TestGlobalConfig:
assert len(loaded["recent_folders"]) == 3
class TestFolderConfig:
"""Testy pro složkový config"""
def test_default_folder_config_structure(self):
"""Test struktury defaultní složkové konfigurace"""
assert "ignore_patterns" in DEFAULT_FOLDER_CONFIG
assert "custom_tags" in DEFAULT_FOLDER_CONFIG
assert "recursive" in DEFAULT_FOLDER_CONFIG
assert isinstance(DEFAULT_FOLDER_CONFIG["ignore_patterns"], list)
assert isinstance(DEFAULT_FOLDER_CONFIG["custom_tags"], dict)
assert DEFAULT_FOLDER_CONFIG["recursive"] is True
def test_get_folder_config_path(self, tmp_path):
"""Test získání cesty ke složkovému configu"""
path = get_folder_config_path(tmp_path)
assert path == tmp_path / FOLDER_CONFIG_NAME
assert path.name == ".Curator.!ftag"
def test_load_folder_config_nonexistent(self, tmp_path):
"""Test načtení neexistujícího složkového configu"""
config = load_folder_config(tmp_path)
assert config == DEFAULT_FOLDER_CONFIG
def test_save_folder_config(self, tmp_path):
"""Test uložení složkového configu"""
test_config = {
"ignore_patterns": ["*.tmp", "*.log"],
"custom_tags": {"Projekt": ["Web", "API"]},
"recursive": False,
}
save_folder_config(tmp_path, test_config)
config_path = get_folder_config_path(tmp_path)
assert config_path.exists()
with open(config_path, "r", encoding="utf-8") as f:
saved_data = json.load(f)
assert saved_data == test_config
def test_load_folder_config_existing(self, tmp_path):
"""Test načtení existujícího složkového configu"""
test_config = {
"ignore_patterns": ["*.pyc"],
"custom_tags": {},
"recursive": True,
"hardlink_output_dir": None,
"hardlink_categories": None,
}
save_folder_config(tmp_path, test_config)
loaded = load_folder_config(tmp_path)
assert loaded == test_config
def test_load_folder_config_merges_defaults(self, tmp_path):
"""Test že chybějící klíče jsou doplněny z defaultů"""
partial_config = {"ignore_patterns": ["*.tmp"]}
config_path = get_folder_config_path(tmp_path)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(partial_config, f)
loaded = load_folder_config(tmp_path)
assert loaded["ignore_patterns"] == ["*.tmp"]
assert loaded["custom_tags"] == DEFAULT_FOLDER_CONFIG["custom_tags"]
assert loaded["recursive"] == DEFAULT_FOLDER_CONFIG["recursive"]
def test_folder_has_config_true(self, tmp_path):
"""Test folder_has_config když config existuje"""
save_folder_config(tmp_path, DEFAULT_FOLDER_CONFIG)
assert folder_has_config(tmp_path) is True
def test_folder_has_config_false(self, tmp_path):
"""Test folder_has_config když config neexistuje"""
assert folder_has_config(tmp_path) is False
def test_folder_config_ignore_patterns(self, tmp_path):
"""Test ukládání ignore patterns"""
patterns = ["*.tmp", "*.log", "*.cache", "*/node_modules/*", "*.pyc"]
test_config = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": patterns}
save_folder_config(tmp_path, test_config)
loaded = load_folder_config(tmp_path)
assert loaded["ignore_patterns"] == patterns
assert len(loaded["ignore_patterns"]) == 5
def test_folder_config_custom_tags(self, tmp_path):
"""Test ukládání custom tagů"""
custom_tags = {
"Projekt": ["Frontend", "Backend", "API"],
"Stav": ["Hotovo", "Rozpracováno"],
}
test_config = {**DEFAULT_FOLDER_CONFIG, "custom_tags": custom_tags}
save_folder_config(tmp_path, test_config)
loaded = load_folder_config(tmp_path)
assert loaded["custom_tags"] == custom_tags
def test_folder_config_corrupted_file(self, tmp_path):
"""Test načtení poškozeného folder config souboru"""
config_path = get_folder_config_path(tmp_path)
with open(config_path, "w") as f:
f.write("{ invalid json }")
config = load_folder_config(tmp_path)
assert config == DEFAULT_FOLDER_CONFIG
def test_folder_config_utf8_encoding(self, tmp_path):
"""Test UTF-8 v folder configu"""
test_config = {
"ignore_patterns": ["*.čeština"],
"custom_tags": {"Štítky": ["Červená", "Žlutá"]},
"recursive": True,
}
save_folder_config(tmp_path, test_config)
loaded = load_folder_config(tmp_path)
assert loaded["ignore_patterns"] == ["*.čeština"]
assert loaded["custom_tags"]["Štítky"] == ["Červená", "Žlutá"]
def test_multiple_folders_independent_configs(self, tmp_path):
"""Test že různé složky mají nezávislé configy"""
folder1 = tmp_path / "folder1"
folder2 = tmp_path / "folder2"
folder1.mkdir()
folder2.mkdir()
config1 = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": ["*.txt"]}
config2 = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": ["*.jpg"]}
save_folder_config(folder1, config1)
save_folder_config(folder2, config2)
loaded1 = load_folder_config(folder1)
loaded2 = load_folder_config(folder2)
assert loaded1["ignore_patterns"] == ["*.txt"]
assert loaded2["ignore_patterns"] == ["*.jpg"]
class TestLegacyFunctions:
"""Testy pro zpětnou kompatibilitu"""
@@ -342,18 +201,6 @@ class TestConfigEdgeCases:
assert len(loaded["recent_folders"]) == 100
def test_folder_config_special_characters_in_patterns(self, tmp_path):
"""Test se speciálními znaky v patterns"""
test_config = {
**DEFAULT_FOLDER_CONFIG,
"ignore_patterns": ["*.tmp", "file[0-9].txt", "test?.log"]
}
save_folder_config(tmp_path, test_config)
loaded = load_folder_config(tmp_path)
assert loaded["ignore_patterns"] == test_config["ignore_patterns"]
def test_config_json_formatting(self, temp_global_config):
"""Test že config je uložen ve správném JSON formátu s indentací"""
test_config = {**DEFAULT_GLOBAL_CONFIG}
@@ -391,24 +238,3 @@ class TestConfigEdgeCases:
loaded = load_global_config()
assert loaded["last_folder"] == "/path2"
def test_folder_config_recursive_false(self, tmp_path):
"""Test nastavení recursive na False"""
test_config = {**DEFAULT_FOLDER_CONFIG, "recursive": False}
save_folder_config(tmp_path, test_config)
loaded = load_folder_config(tmp_path)
assert loaded["recursive"] is False
def test_empty_folder_config(self, tmp_path):
"""Test prázdného folder configu"""
config_path = get_folder_config_path(tmp_path)
with open(config_path, "w", encoding="utf-8") as f:
json.dump({}, f)
loaded = load_folder_config(tmp_path)
# Mělo by doplnit všechny defaulty
assert loaded["ignore_patterns"] == []
assert loaded["custom_tags"] == {}
assert loaded["recursive"] is True
+97 -151
View File
@@ -1,191 +1,154 @@
import pytest
import json
from pathlib import Path
from src.core.file import File
from src.core.pool_index import PoolIndex
from src.core.tag import Tag
from src.core.tag_manager import TagManager
class TestFile:
"""Testy pro třídu File"""
@pytest.fixture
def temp_dir(self, tmp_path):
"""Fixture pro dočasný adresář"""
return tmp_path
"""Testy pro třídu File (metadata žijí v pool indexu)"""
@pytest.fixture
def tag_manager(self):
"""Fixture pro TagManager"""
return TagManager()
@pytest.fixture
def test_file(self, temp_dir):
"""Fixture pro testovací soubor"""
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
return test_file
def index(self, tmp_path):
"""Pool index that backs the File's metadata."""
return PoolIndex(tmp_path)
def test_file_creation(self, test_file, tag_manager):
"""Test vytvoření File objektu"""
file_obj = File(test_file, tag_manager)
@pytest.fixture
def test_file(self, tmp_path):
f = tmp_path / "test.txt"
f.write_text("test content")
return f
def test_file_creation(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
assert file_obj.file_path == test_file
assert file_obj.filename == "test.txt"
assert file_obj.new == True
assert file_obj.new is True
def test_file_metadata_filename(self, test_file, tag_manager):
"""Test názvu metadata souboru"""
file_obj = File(test_file, tag_manager)
expected = test_file.parent / ".test.txt.!tag"
assert file_obj.metadata_filename == expected
def test_file_initial_tags(self, test_file, tag_manager):
"""Test že nový soubor nemá žádné automatické tagy (Stav/Nové odstraněn)"""
file_obj = File(test_file, tag_manager)
def test_file_initial_tags(self, test_file, tag_manager, index):
"""Nový soubor nemá žádné automatické tagy (Stav/Nové odstraněn)."""
file_obj = File(test_file, tag_manager, index=index)
assert file_obj.tags == []
def test_file_metadata_saved(self, test_file, tag_manager):
"""Test že metadata jsou uložena při vytvoření"""
file_obj = File(test_file, tag_manager)
assert file_obj.metadata_filename.exists()
def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
"""Metadata jsou zapsána do indexu už při vytvoření."""
file_obj = File(test_file, tag_manager, index=index)
assert index.get(file_obj.file_path) is not None
def test_file_save_metadata(self, test_file, tag_manager):
"""Test uložení metadat"""
def test_file_no_sidecar_written(self, test_file, tag_manager, index):
"""Žádný .!tag sidecar už nevzniká."""
File(test_file, tag_manager, index=index)
assert not (test_file.parent / ".test.txt.!tag").exists()
def test_index_less_file_is_in_memory_only(self, test_file, tag_manager):
"""Bez indexu je File jen v paměti — nic se nezapíše na disk."""
file_obj = File(test_file, tag_manager)
file_obj.add_tag("Video/HD")
file_obj.save_metadata() # no-op
assert not (test_file.parent / ".test.txt.!tag").exists()
def test_file_save_metadata(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.new = False
file_obj.ignored = True
file_obj.save_metadata()
# Načtení a kontrola
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f:
data = json.load(f)
data = index.get(file_obj.file_path)
assert data["new"] is False
assert data["ignored"] is True
assert data["new"] == False
assert data["ignored"] == True
def test_file_load_metadata(self, test_file, tag_manager):
"""Test načtení metadat"""
# Vytvoření a uložení metadat
file_obj = File(test_file, tag_manager)
def test_file_load_metadata(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
tag = tag_manager.add_tag("Video", "HD")
file_obj.tags.append(tag)
file_obj.date = "2025-01-15"
file_obj.save_metadata()
# Vytvoření nového objektu - měl by načíst metadata
file_obj2 = File(test_file, tag_manager)
# A fresh File over the same index reloads the metadata
file_obj2 = File(test_file, tag_manager, index=index)
assert len(file_obj2.tags) == 1 # Video/HD
assert file_obj2.date == "2025-01-15"
assert "Video/HD" in {t.full_path for t in file_obj2.tags}
# Kontrola že tagy obsahují správné hodnoty
tag_paths = {tag.full_path for tag in file_obj2.tags}
assert "Video/HD" in tag_paths
def test_file_set_date(self, test_file, tag_manager):
"""Test nastavení data"""
file_obj = File(test_file, tag_manager)
def test_file_set_date(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.set_date("2025-12-25")
assert file_obj.date == "2025-12-25"
assert index.get(file_obj.file_path)["date"] == "2025-12-25"
# Kontrola že bylo uloženo
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f:
data = json.load(f)
assert data["date"] == "2025-12-25"
def test_file_set_date_to_none(self, test_file, tag_manager):
"""Test smazání data"""
file_obj = File(test_file, tag_manager)
def test_file_set_date_to_none(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.set_date("2025-12-25")
file_obj.set_date(None)
assert file_obj.date is None
def test_file_set_date_empty_string(self, test_file, tag_manager):
"""Test nastavení prázdného řetězce jako datum"""
file_obj = File(test_file, tag_manager)
def test_file_set_date_empty_string(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.set_date("2025-12-25")
file_obj.set_date("")
assert file_obj.date is None
def test_file_add_tag_object(self, test_file, tag_manager):
"""Test přidání Tag objektu"""
file_obj = File(test_file, tag_manager)
def test_file_add_tag_object(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
tag = Tag("Video", "4K")
file_obj.add_tag(tag)
assert tag in file_obj.tags
assert len(file_obj.tags) == 1 # Video/4K
assert len(file_obj.tags) == 1
def test_file_add_tag_string(self, test_file, tag_manager):
"""Test přidání tagu jako string"""
file_obj = File(test_file, tag_manager)
def test_file_add_tag_string(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("Audio/MP3")
assert "Audio/MP3" in {t.full_path for t in file_obj.tags}
tag_paths = {tag.full_path for tag in file_obj.tags}
assert "Audio/MP3" in tag_paths
def test_file_add_tag_string_without_category(self, test_file, tag_manager):
"""Test přidání tagu bez kategorie (použije 'default')"""
file_obj = File(test_file, tag_manager)
def test_file_add_tag_string_without_category(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("SimpleTag")
assert "default/SimpleTag" in {t.full_path for t in file_obj.tags}
tag_paths = {tag.full_path for tag in file_obj.tags}
assert "default/SimpleTag" in tag_paths
def test_file_add_duplicate_tag(self, test_file, tag_manager):
"""Test že duplicitní tag není přidán"""
file_obj = File(test_file, tag_manager)
def test_file_add_duplicate_tag(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
tag = Tag("Video", "HD")
file_obj.add_tag(tag)
file_obj.add_tag(tag)
assert sum(1 for t in file_obj.tags if t == tag) == 1
# Spočítáme kolikrát se tag vyskytuje
count = sum(1 for t in file_obj.tags if t == tag)
assert count == 1
def test_file_remove_tag_object(self, test_file, tag_manager):
"""Test odstranění Tag objektu"""
file_obj = File(test_file, tag_manager)
def test_file_remove_tag_object(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
tag = Tag("Video", "HD")
file_obj.add_tag(tag)
file_obj.remove_tag(tag)
assert tag not in file_obj.tags
def test_file_remove_tag_string(self, test_file, tag_manager):
"""Test odstranění tagu jako string"""
file_obj = File(test_file, tag_manager)
def test_file_remove_tag_string(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("Video/HD")
file_obj.remove_tag("Video/HD")
assert "Video/HD" not in {t.full_path for t in file_obj.tags}
tag_paths = {tag.full_path for tag in file_obj.tags}
assert "Video/HD" not in tag_paths
def test_file_remove_tag_string_without_category(self, test_file, tag_manager):
"""Test odstranění tagu bez kategorie"""
file_obj = File(test_file, tag_manager)
def test_file_remove_tag_string_without_category(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("SimpleTag")
file_obj.remove_tag("SimpleTag")
assert "default/SimpleTag" not in {t.full_path for t in file_obj.tags}
tag_paths = {tag.full_path for tag in file_obj.tags}
assert "default/SimpleTag" not in tag_paths
def test_file_remove_nonexistent_tag(self, test_file, tag_manager):
"""Test odstranění neexistujícího tagu (nemělo by vyhodit výjimku)"""
file_obj = File(test_file, tag_manager)
def test_file_remove_nonexistent_tag(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
initial_count = len(file_obj.tags)
file_obj.remove_tag("Nonexistent/Tag")
assert len(file_obj.tags) == initial_count
def test_file_without_tagmanager(self, test_file):
"""Test File bez TagManager"""
file_obj = File(test_file, tagmanager=None)
assert file_obj.tagmanager is None
assert len(file_obj.tags) == 0 # nový soubor nemá žádné automatické tagy
assert len(file_obj.tags) == 0
def test_file_metadata_persistence(self, test_file, tag_manager):
"""Test že metadata přežijí reload"""
# Vytvoření a úprava souboru
file_obj1 = File(test_file, tag_manager)
def test_file_metadata_persistence(self, test_file, tag_manager, index):
file_obj1 = File(test_file, tag_manager, index=index)
file_obj1.add_tag("Video/HD")
file_obj1.add_tag("Audio/Stereo")
file_obj1.set_date("2025-01-01")
@@ -193,73 +156,54 @@ class TestFile:
file_obj1.ignored = True
file_obj1.save_metadata()
# Načtení nového objektu
file_obj2 = File(test_file, tag_manager)
# Kontrola
assert file_obj2.new == False
assert file_obj2.ignored == True
file_obj2 = File(test_file, tag_manager, index=index)
assert file_obj2.new is False
assert file_obj2.ignored is True
assert file_obj2.date == "2025-01-01"
tag_paths = {tag.full_path for tag in file_obj2.tags}
tag_paths = {t.full_path for t in file_obj2.tags}
assert "Video/HD" in tag_paths
assert "Audio/Stereo" in tag_paths
def test_file_metadata_json_format(self, test_file, tag_manager):
"""Test formátu JSON metadat"""
file_obj = File(test_file, tag_manager)
def test_file_metadata_record_shape(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("Test/Tag")
file_obj.set_date("2025-06-15")
# Kontrola obsahu JSON
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f:
data = json.load(f)
data = index.get(file_obj.file_path)
assert "new" in data
assert "ignored" in data
assert "tags" in data
assert "date" in data
assert isinstance(data["tags"], list)
def test_file_unicode_handling(self, temp_dir, tag_manager):
"""Test správného zacházení s unicode znaky"""
test_file = temp_dir / "český_soubor.txt"
def test_file_unicode_handling(self, tmp_path, tag_manager, index):
test_file = tmp_path / "český_soubor.txt"
test_file.write_text("obsah")
file_obj = File(test_file, tag_manager)
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("Kategorie/Český tag")
file_obj.save_metadata()
# Reload a kontrola
file_obj2 = File(test_file, tag_manager)
tag_paths = {tag.full_path for tag in file_obj2.tags}
assert "Kategorie/Český tag" in tag_paths
file_obj2 = File(test_file, tag_manager, index=index)
assert "Kategorie/Český tag" in {t.full_path for t in file_obj2.tags}
def test_file_complex_scenario(self, test_file, tag_manager):
"""Test komplexního scénáře použití"""
file_obj = File(test_file, tag_manager)
# Přidání více tagů
def test_file_complex_scenario(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("Video/HD")
file_obj.add_tag("Video/Stereo")
file_obj.add_tag("Stav/Zkontrolováno")
file_obj.set_date("2025-01-01")
# Odstranění tagu
file_obj.remove_tag("Stav/Nové")
# Kontrola stavu
tag_paths = {tag.full_path for tag in file_obj.tags}
tag_paths = {t.full_path for t in file_obj.tags}
assert "Video/HD" in tag_paths
assert "Video/Stereo" in tag_paths
assert "Stav/Zkontrolováno" in tag_paths
assert "Stav/Nové" not in tag_paths
assert file_obj.date == "2025-01-01"
# Reload a kontrola persistence
file_obj2 = File(test_file, tag_manager)
tag_paths2 = {tag.full_path for tag in file_obj2.tags}
assert tag_paths == tag_paths2
file_obj2 = File(test_file, tag_manager, index=index)
assert {t.full_path for t in file_obj2.tags} == tag_paths
assert file_obj2.date == "2025-01-01"
@@ -271,10 +215,14 @@ class TestApplyCsfdTags:
return TagManager()
@pytest.fixture
def movie_file(self, tmp_path, tag_manager):
def index(self, tmp_path):
return PoolIndex(tmp_path)
@pytest.fixture
def movie_file(self, tmp_path, tag_manager, index):
path = tmp_path / "Matrix.mkv"
path.write_text("x")
f = File(path, tag_manager)
f = File(path, tag_manager, index=index)
f.set_csfd_link("https://www.csfd.cz/film/9499-matrix/")
return f
@@ -314,7 +262,6 @@ class TestApplyCsfdTags:
paths = {t.full_path for t in movie_file.tags}
assert not any(p.startswith("Režie/") for p in paths)
assert not any(p.startswith("Herec/") for p in paths)
# …but the data is kept in the cache
cached = movie_file.get_cached_movie()
assert cached.directors == ["Lana Wachowski"]
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"]
@@ -392,7 +339,6 @@ class TestApplyCsfdTags:
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()
+110 -465
View File
@@ -1,294 +1,55 @@
import pytest
from src.core.file import File
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
from src.core.tag import Tag
class TestFileManager:
"""Testy pro třídu FileManager"""
@pytest.fixture
def tag_manager(self):
"""Fixture pro TagManager"""
return TagManager()
@pytest.fixture
def file_manager(self, tag_manager, temp_global_config):
"""Fixture pro FileManager"""
return FileManager(tag_manager)
@pytest.fixture
def temp_dir(self, tmp_path):
"""Fixture pro dočasný adresář s testovacími soubory"""
# Vytvoření struktury souborů
(tmp_path / "file1.txt").write_text("content1")
(tmp_path / "file2.txt").write_text("content2")
(tmp_path / "file3.jpg").write_text("image")
# Podsložka
subdir = tmp_path / "subdir"
subdir.mkdir()
(subdir / "file4.txt").write_text("content4")
return tmp_path
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
"""Fixture pro dočasný global config soubor"""
def temp_global_config(tmp_path, monkeypatch):
"""Point the global config file at a throwaway path for each test."""
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
@pytest.fixture
def tag_manager():
return TagManager()
@pytest.fixture
def file_manager(tag_manager, temp_global_config):
return FileManager(tag_manager)
def _in_memory_files(tmp_path, tag_manager, *names):
"""Create real files and wrap them as index-less (in-memory) File objects."""
files = []
for name in names:
path = tmp_path / name
path.write_text("content")
files.append(File(path, tag_manager))
return files
class TestFileManager:
"""Basic FileManager state."""
def test_file_manager_creation(self, file_manager, tag_manager):
"""Test vytvoření FileManager"""
assert file_manager.filelist == []
assert file_manager.folders == []
assert file_manager.tagmanager == tag_manager
assert file_manager.global_config is not None
assert file_manager.folder_configs == {}
assert file_manager.current_folder is None
def test_file_manager_append_folder(self, file_manager, temp_dir):
"""Test přidání složky"""
file_manager.append(temp_dir)
assert temp_dir in file_manager.folders
assert len(file_manager.filelist) > 0
assert file_manager.current_folder == temp_dir
def test_file_manager_append_folder_finds_all_files(self, file_manager, temp_dir):
"""Test že append najde všechny soubory včetně podsložek"""
file_manager.append(temp_dir)
# Měli bychom najít file1.txt, file2.txt, file3.jpg, subdir/file4.txt
# (ne .!tag soubory)
filenames = {f.filename for f in file_manager.filelist}
assert "file1.txt" in filenames
assert "file2.txt" in filenames
assert "file3.jpg" in filenames
assert "file4.txt" in filenames
def test_file_manager_ignores_tag_files(self, file_manager, temp_dir):
"""Test že .!tag soubory jsou ignorovány"""
# Vytvoření .!tag souboru
(temp_dir / ".file1.txt.!tag").write_text('{"tags": []}')
file_manager.append(temp_dir)
filenames = {f.filename for f in file_manager.filelist}
assert ".file1.txt.!tag" not in filenames
def test_file_manager_ignores_curator_config_files(self, file_manager, temp_dir):
"""Test že Curator config soubory jsou ignorovány"""
(temp_dir / ".Curator.!ftag").write_text('{}') # Folder config
(temp_dir / ".Curator.!gtag").write_text('{}') # Global config
file_manager.append(temp_dir)
filenames = {f.filename for f in file_manager.filelist}
assert ".Curator.!ftag" not in filenames
assert ".Curator.!gtag" not in filenames
def test_file_manager_updates_last_folder(self, file_manager, temp_dir):
"""Test aktualizace last_folder v global configu"""
file_manager.append(temp_dir)
assert file_manager.global_config["last_folder"] == str(temp_dir)
def test_file_manager_updates_recent_folders(self, file_manager, temp_dir):
"""Test aktualizace recent_folders"""
file_manager.append(temp_dir)
assert str(temp_dir) in file_manager.global_config["recent_folders"]
assert file_manager.global_config["recent_folders"][0] == str(temp_dir)
def test_file_manager_recent_folders_max_10(self, file_manager, tmp_path):
"""Test že recent_folders má max 10 položek"""
for i in range(15):
folder = tmp_path / f"folder{i}"
folder.mkdir()
(folder / "file.txt").write_text("content")
file_manager.append(folder)
assert len(file_manager.global_config["recent_folders"]) <= 10
def test_file_manager_loads_folder_config(self, file_manager, temp_dir):
"""Test že se načte folder config při append"""
file_manager.append(temp_dir)
assert temp_dir in file_manager.folder_configs
assert "ignore_patterns" in file_manager.folder_configs[temp_dir]
class TestFileManagerIgnorePatterns:
"""Testy pro ignore patterns"""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
return config_path
@pytest.fixture
def file_manager(self, tag_manager, temp_global_config):
return FileManager(tag_manager)
@pytest.fixture
def temp_dir(self, tmp_path):
(tmp_path / "file1.txt").write_text("content1")
(tmp_path / "file2.txt").write_text("content2")
(tmp_path / "file3.jpg").write_text("image")
subdir = tmp_path / "subdir"
subdir.mkdir()
(subdir / "file4.txt").write_text("content4")
return tmp_path
def test_ignore_patterns_by_extension(self, file_manager, temp_dir):
"""Test ignorování souborů podle přípony"""
from src.core.config import save_folder_config
save_folder_config(temp_dir, {"ignore_patterns": ["*.jpg"], "custom_tags": {}, "recursive": True})
file_manager.append(temp_dir)
filenames = {f.filename for f in file_manager.filelist}
assert "file3.jpg" not in filenames
assert "file1.txt" in filenames
def test_ignore_patterns_path(self, file_manager, temp_dir):
"""Test ignorování podle celé cesty"""
from src.core.config import save_folder_config
save_folder_config(temp_dir, {"ignore_patterns": ["*/subdir/*"], "custom_tags": {}, "recursive": True})
file_manager.append(temp_dir)
filenames = {f.filename for f in file_manager.filelist}
assert "file4.txt" not in filenames
assert "file1.txt" in filenames
def test_multiple_ignore_patterns(self, file_manager, temp_dir):
"""Test více ignore patternů najednou"""
from src.core.config import save_folder_config
save_folder_config(temp_dir, {"ignore_patterns": ["*.jpg", "*/subdir/*"], "custom_tags": {}, "recursive": True})
file_manager.append(temp_dir)
filenames = {f.filename for f in file_manager.filelist}
assert "file3.jpg" not in filenames
assert "file4.txt" not in filenames
assert "file1.txt" in filenames
assert "file2.txt" in filenames
def test_set_ignore_patterns(self, file_manager, temp_dir):
"""Test nastavení ignore patterns přes metodu"""
file_manager.append(temp_dir)
file_manager.set_ignore_patterns(["*.tmp", "*.log"])
patterns = file_manager.get_ignore_patterns()
assert patterns == ["*.tmp", "*.log"]
def test_get_ignore_patterns_empty(self, file_manager, temp_dir):
"""Test získání prázdných ignore patterns"""
file_manager.append(temp_dir)
patterns = file_manager.get_ignore_patterns()
assert patterns == []
class TestFileManagerFolderConfig:
"""Testy pro folder config management"""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
return config_path
@pytest.fixture
def file_manager(self, tag_manager, temp_global_config):
return FileManager(tag_manager)
@pytest.fixture
def temp_dir(self, tmp_path):
(tmp_path / "file1.txt").write_text("content")
return tmp_path
def test_get_folder_config_current(self, file_manager, temp_dir):
"""Test získání configu pro aktuální složku"""
file_manager.append(temp_dir)
config = file_manager.get_folder_config()
assert "ignore_patterns" in config
def test_get_folder_config_specific(self, file_manager, temp_dir, tmp_path):
"""Test získání configu pro specifickou složku"""
folder2 = tmp_path / "folder2"
folder2.mkdir()
(folder2 / "file.txt").write_text("content")
file_manager.append(temp_dir)
file_manager.append(folder2)
config = file_manager.get_folder_config(temp_dir)
assert config is not None
def test_get_folder_config_no_current(self, file_manager):
"""Test získání configu když není current folder"""
config = file_manager.get_folder_config()
assert config == {}
def test_save_folder_config(self, file_manager, temp_dir):
"""Test uložení folder configu"""
file_manager.append(temp_dir)
new_config = {"ignore_patterns": ["*.test"], "custom_tags": {}, "recursive": False}
file_manager.save_folder_config(config=new_config)
loaded = file_manager.get_folder_config()
assert loaded["ignore_patterns"] == ["*.test"]
assert loaded["recursive"] is False
assert file_manager.index is None
class TestFileManagerTagOperations:
"""Testy pro operace s tagy"""
"""assign/remove tag operations over the loaded file list."""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
return config_path
@pytest.fixture
def file_manager(self, tag_manager, temp_global_config):
return FileManager(tag_manager)
@pytest.fixture
def temp_dir(self, tmp_path):
(tmp_path / "file1.txt").write_text("content1")
(tmp_path / "file2.txt").write_text("content2")
(tmp_path / "file3.txt").write_text("content3")
return tmp_path
def test_assign_tag_to_file_objects_tag_object(self, file_manager, temp_dir):
"""Test přiřazení Tag objektu k souborům"""
file_manager.append(temp_dir)
files = file_manager.filelist[:2]
def test_assign_tag_to_file_objects_tag_object(self, file_manager, tmp_path, tag_manager):
files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt")
file_manager.filelist = files
tag = Tag("Video", "HD")
file_manager.assign_tag_to_file_objects(files, tag)
@@ -296,30 +57,27 @@ class TestFileManagerTagOperations:
for f in files:
assert tag in f.tags
def test_assign_tag_string_with_category(self, file_manager, temp_dir):
"""Test přiřazení tagu jako string s kategorií"""
file_manager.append(temp_dir)
files = file_manager.filelist[:1]
def test_assign_tag_string_with_category(self, file_manager, tmp_path, tag_manager):
files = _in_memory_files(tmp_path, tag_manager, "f1.txt")
file_manager.filelist = files
file_manager.assign_tag_to_file_objects(files, "Video/4K")
tag_paths = {tag.full_path for tag in files[0].tags}
assert "Video/4K" in tag_paths
def test_assign_tag_string_without_category(self, file_manager, temp_dir):
"""Test přiřazení tagu bez kategorie (default)"""
file_manager.append(temp_dir)
files = file_manager.filelist[:1]
def test_assign_tag_string_without_category(self, file_manager, tmp_path, tag_manager):
files = _in_memory_files(tmp_path, tag_manager, "f1.txt")
file_manager.filelist = files
file_manager.assign_tag_to_file_objects(files, "SimpleTag")
tag_paths = {tag.full_path for tag in files[0].tags}
assert "default/SimpleTag" in tag_paths
def test_assign_tag_no_duplicate(self, file_manager, temp_dir):
"""Test že tag není přidán dvakrát"""
file_manager.append(temp_dir)
files = file_manager.filelist[:1]
def test_assign_tag_no_duplicate(self, file_manager, tmp_path, tag_manager):
files = _in_memory_files(tmp_path, tag_manager, "f1.txt")
file_manager.filelist = files
tag = Tag("Video", "HD")
file_manager.assign_tag_to_file_objects(files, tag)
@@ -328,10 +86,9 @@ class TestFileManagerTagOperations:
count = sum(1 for t in files[0].tags if t == tag)
assert count == 1
def test_remove_tag_from_file_objects(self, file_manager, temp_dir):
"""Test odstranění tagu ze souborů"""
file_manager.append(temp_dir)
files = file_manager.filelist[:2]
def test_remove_tag_from_file_objects(self, file_manager, tmp_path, tag_manager):
files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt")
file_manager.filelist = files
tag = Tag("Video", "HD")
file_manager.assign_tag_to_file_objects(files, tag)
@@ -340,10 +97,9 @@ class TestFileManagerTagOperations:
for f in files:
assert tag not in f.tags
def test_remove_tag_string(self, file_manager, temp_dir):
"""Test odstranění tagu jako string"""
file_manager.append(temp_dir)
files = file_manager.filelist[:1]
def test_remove_tag_string(self, file_manager, tmp_path, tag_manager):
files = _in_memory_files(tmp_path, tag_manager, "f1.txt")
file_manager.filelist = files
file_manager.assign_tag_to_file_objects(files, "Video/HD")
file_manager.remove_tag_from_file_objects(files, "Video/HD")
@@ -351,210 +107,73 @@ class TestFileManagerTagOperations:
tag_paths = {tag.full_path for tag in files[0].tags}
assert "Video/HD" not in tag_paths
def test_callback_on_tag_change(self, file_manager, temp_dir):
"""Test callback při změně tagů"""
file_manager.append(temp_dir)
def test_callback_on_tag_change(self, file_manager, tmp_path, tag_manager):
files = _in_memory_files(tmp_path, tag_manager, "f1.txt")
file_manager.filelist = files
callback_calls = []
file_manager.on_files_changed = lambda filelist: callback_calls.append(len(filelist))
def callback(filelist):
callback_calls.append(len(filelist))
file_manager.on_files_changed = callback
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], Tag("Test", "Tag"))
file_manager.assign_tag_to_file_objects([files[0]], Tag("Test", "Tag"))
assert len(callback_calls) == 1
class TestFileManagerFiltering:
"""Testy pro filtrování souborů"""
"""filter_files_by_tags over the loaded file list."""
@pytest.fixture
def tag_manager(self):
return TagManager()
def loaded(self, file_manager, tmp_path, tag_manager):
files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt", "f3.txt")
file_manager.filelist = files
return file_manager
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
return config_path
def test_filter_empty_tags_returns_all(self, loaded):
assert len(loaded.filter_files_by_tags([])) == len(loaded.filelist)
@pytest.fixture
def file_manager(self, tag_manager, temp_global_config):
return FileManager(tag_manager)
def test_filter_none_returns_all(self, loaded):
assert len(loaded.filter_files_by_tags(None)) == len(loaded.filelist)
@pytest.fixture
def temp_dir(self, tmp_path):
(tmp_path / "file1.txt").write_text("content1")
(tmp_path / "file2.txt").write_text("content2")
(tmp_path / "file3.txt").write_text("content3")
return tmp_path
def test_filter_empty_tags_returns_all(self, file_manager, temp_dir):
"""Test filtrace bez tagů vrací všechny soubory"""
file_manager.append(temp_dir)
filtered = file_manager.filter_files_by_tags([])
assert len(filtered) == len(file_manager.filelist)
def test_filter_none_returns_all(self, file_manager, temp_dir):
"""Test filtrace s None vrací všechny soubory"""
file_manager.append(temp_dir)
filtered = file_manager.filter_files_by_tags(None)
assert len(filtered) == len(file_manager.filelist)
def test_filter_by_single_tag(self, file_manager, temp_dir):
"""Test filtrace podle jednoho tagu"""
file_manager.append(temp_dir)
def test_filter_by_single_tag(self, loaded):
tag = Tag("Video", "HD")
files_to_tag = file_manager.filelist[:2]
file_manager.assign_tag_to_file_objects(files_to_tag, tag)
loaded.assign_tag_to_file_objects(loaded.filelist[:2], tag)
filtered = file_manager.filter_files_by_tags([tag])
filtered = loaded.filter_files_by_tags([tag])
assert len(filtered) == 2
for f in filtered:
assert tag in f.tags
def test_filter_by_multiple_tags_and_logic(self, file_manager, temp_dir):
"""Test filtrace podle více tagů (AND logika)"""
file_manager.append(temp_dir)
def test_filter_by_multiple_tags_and_logic(self, loaded):
tag1 = Tag("Video", "HD")
tag2 = Tag("Audio", "Stereo")
loaded.assign_tag_to_file_objects([loaded.filelist[0]], tag1)
loaded.assign_tag_to_file_objects([loaded.filelist[0]], tag2)
loaded.assign_tag_to_file_objects([loaded.filelist[1]], tag1)
# První soubor má oba tagy
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag1)
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag2)
# Druhý soubor má jen první tag
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], tag1)
filtered = file_manager.filter_files_by_tags([tag1, tag2])
filtered = loaded.filter_files_by_tags([tag1, tag2])
assert len(filtered) == 1
assert filtered[0] == file_manager.filelist[0]
assert filtered[0] == loaded.filelist[0]
def test_filter_by_tag_strings(self, file_manager, temp_dir):
"""Test filtrace podle tagů jako stringy"""
file_manager.append(temp_dir)
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD")
def test_filter_by_tag_strings(self, loaded):
loaded.assign_tag_to_file_objects([loaded.filelist[0]], "Video/HD")
assert len(loaded.filter_files_by_tags(["Video/HD"])) == 1
filtered = file_manager.filter_files_by_tags(["Video/HD"])
assert len(filtered) == 1
def test_filter_no_match(self, file_manager, temp_dir):
"""Test filtrace když nic neodpovídá"""
file_manager.append(temp_dir)
filtered = file_manager.filter_files_by_tags([Tag("NonExistent", "Tag")])
assert len(filtered) == 0
class TestFileManagerLegacy:
"""Testy pro zpětnou kompatibilitu"""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
return config_path
@pytest.fixture
def file_manager(self, tag_manager, temp_global_config):
return FileManager(tag_manager)
def test_config_property_returns_global(self, file_manager):
"""Test že property config vrací global_config"""
assert file_manager.config is file_manager.global_config
def test_config_property_modifiable(self, file_manager):
"""Test že změny přes config property se projeví"""
file_manager.config["test_key"] = "test_value"
assert file_manager.global_config["test_key"] == "test_value"
def test_filter_no_match(self, loaded):
assert len(loaded.filter_files_by_tags([Tag("NonExistent", "Tag")])) == 0
class TestFileManagerEdgeCases:
"""Testy pro edge cases"""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
return config_path
@pytest.fixture
def file_manager(self, tag_manager, temp_global_config):
return FileManager(tag_manager)
def test_empty_filelist_operations(self, file_manager):
"""Test operací s prázdným filelistem"""
filtered = file_manager.filter_files_by_tags([Tag("Video", "HD")])
assert filtered == []
# Přiřazení tagů na prázdný seznam
assert file_manager.filter_files_by_tags([Tag("Video", "HD")]) == []
file_manager.assign_tag_to_file_objects([], Tag("Video", "HD"))
assert len(file_manager.filelist) == 0
def test_assign_tag_to_empty_list(self, file_manager):
"""Test přiřazení tagu prázdnému seznamu souborů"""
file_manager.assign_tag_to_file_objects([], Tag("Test", "Tag"))
# Nemělo by vyhodit výjimku
file_manager.assign_tag_to_file_objects([], Tag("Test", "Tag")) # no exception
def test_remove_nonexistent_tag(self, file_manager, tmp_path):
"""Test odstranění neexistujícího tagu"""
(tmp_path / "file.txt").write_text("content")
file_manager.append(tmp_path)
# Nemělo by vyhodit výjimku
file_manager.remove_tag_from_file_objects(file_manager.filelist, Tag("NonExistent", "Tag"))
def test_multiple_folders(self, file_manager, tmp_path):
"""Test práce s více složkami"""
folder1 = tmp_path / "folder1"
folder2 = tmp_path / "folder2"
folder1.mkdir()
folder2.mkdir()
(folder1 / "file1.txt").write_text("content1")
(folder2 / "file2.txt").write_text("content2")
file_manager.append(folder1)
file_manager.append(folder2)
assert len(file_manager.folders) == 2
filenames = {f.filename for f in file_manager.filelist}
assert "file1.txt" in filenames
assert "file2.txt" in filenames
def test_folder_with_special_characters(self, file_manager, tmp_path):
"""Test složky se speciálními znaky v názvu"""
special_folder = tmp_path / "složka s českou diakritikou"
special_folder.mkdir()
(special_folder / "soubor.txt").write_text("obsah")
file_manager.append(special_folder)
filenames = {f.filename for f in file_manager.filelist}
assert "soubor.txt" in filenames
def test_file_with_special_characters(self, file_manager, tmp_path):
"""Test souboru se speciálními znaky v názvu"""
(tmp_path / "soubor s mezerami.txt").write_text("content")
(tmp_path / "čeština.txt").write_text("obsah")
file_manager.append(tmp_path)
filenames = {f.filename for f in file_manager.filelist}
assert "soubor s mezerami.txt" in filenames
assert "čeština.txt" in filenames
def test_remove_nonexistent_tag(self, file_manager, tmp_path, tag_manager):
file_manager.filelist = _in_memory_files(tmp_path, tag_manager, "file.txt")
file_manager.remove_tag_from_file_objects(
file_manager.filelist, Tag("NonExistent", "Tag")) # no exception
class TestPoolManagement:
@@ -669,7 +288,8 @@ class TestPoolManagement:
assert movie.file_path.exists()
assert not source.exists() # moved, not copied
def test_filmoteka_category_roots_from_schema(self, file_manager):
def test_filmoteka_category_roots_from_schema(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
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"},
@@ -802,8 +422,33 @@ class TestPoolManagement:
assert len(reloaded.filelist) == 1
assert reloaded.filelist[0].title == "Matrix"
def test_copyasis_folders_default_and_set(self, file_manager):
def test_copyasis_folders_default_and_set(self, file_manager, tmp_path):
assert file_manager.copyasis_folders == ["Seriály"]
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_copyasis_folders(["Seriály", " Dokumenty ", ""])
assert file_manager.copyasis_folders == ["Seriály", "Dokumenty"]
def test_library_settings_stored_in_index_not_global(self, file_manager, tmp_path):
"""tag_schema / copyasis_folders persist in the pool index, not .!gtag."""
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_copyasis_folders(["Seriály", "Dokumenty"])
assert "copyasis_folders" not in file_manager.global_config
assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Dokumenty"]
# a fresh manager over the same pool reads the stored settings back
reloaded = FileManager(TagManager())
reloaded.set_pool_dir(tmp_path / "pool")
assert reloaded.copyasis_folders == ["Seriály", "Dokumenty"]
def test_migrates_legacy_global_settings_into_index(self, file_manager, tmp_path):
"""Old settings left in the global config move into the index on open."""
file_manager.set_pool_dir(tmp_path / "pool")
# simulate a pre-migration global config carrying library settings
file_manager.global_config["copyasis_folders"] = ["Seriály", "Filmy jinak"]
file_manager.index = None # force a fresh open + migration
assert file_manager.copyasis_folders == ["Seriály", "Filmy jinak"]
assert "copyasis_folders" not in file_manager.global_config
assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Filmy jinak"]
+1 -1
View File
@@ -61,7 +61,7 @@ class TestFileWithIndex:
f = File(movie, TagManager(), index=index)
assert not f.metadata_filename.exists() # no sidecar
assert not (movie.parent / ".Matrix.mkv.!tag").exists() # no sidecar
assert index.get(movie) is not None # record created in index
assert f.tags == [] # no automatic tags