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 ## 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 ## 1.7.0 — 2026-07-02
### Added ### 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`. 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** - `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index**
(`<pool>/.Curator.!index`, see `pool_index.py`); `File` writes there when an (`<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 index is injected. Without an index a `File` is an in-memory object only — the
arbitrary (non-pool) folders. old per-file `.!tag` sidecars and the per-folder `.!ftag` config have been
removed.
### GUI decision ### 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 - **Metadata storage:** one **unified metadata file** for the whole pool (a
central index), not per-file sidecars. Justified because Curator owns the pool 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. 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 - **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 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 dialog), or auto-filled with **"Najít ČSFD odkazy"** (cleans each filename into
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "curator" name = "curator"
version = "1.7.0" version = "1.8.0"
description = "" description = ""
authors = [ authors = [
{name = "jan.doubravsky@gmail.com"} {name = "jan.doubravsky@gmail.com"}
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually.""" """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: Two stores, by responsibility:
1. Global config (.Curator.!gtag next to Curator.py) - app-wide settings
2. Folder config (.Curator.!ftag in project root) - folder-specific settings 1. Global config (``.Curator.!gtag`` next to Curator.py) — **application data
3. File tags (.{filename}.!tag) - per-file metadata (handled in file.py) 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 import json
from pathlib import Path from pathlib import Path
@@ -12,12 +17,9 @@ from pathlib import Path
# Global config file (next to the main script) # Global config file (next to the main script)
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag" 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 # 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. # keep the pool filename. Pool files are never renamed by this.
# Grouping folders are prefixed with "- " so DLNA/TV browsers sort the special # 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. # 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 = [ DEFAULT_TAG_SCHEMA = [
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""}, {"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "- Dle roku"}, {"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "- Dle roku"},
@@ -44,6 +48,11 @@ DEFAULT_TAG_SCHEMA = [
"filmoteka_root": "- Dle hodnocení"}, "filmoteka_root": "- Dle hodnocení"},
] ]
# =============================================================================
# GLOBAL CONFIG - Application settings only
# =============================================================================
DEFAULT_GLOBAL_CONFIG = { DEFAULT_GLOBAL_CONFIG = {
"window_geometry": "1200x800", "window_geometry": "1200x800",
"window_maximized": False, "window_maximized": False,
@@ -52,8 +61,6 @@ DEFAULT_GLOBAL_CONFIG = {
"recent_folders": [], "recent_folders": [],
"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)
"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) 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 # BACKWARDS COMPATIBILITY
# ============================================================================= # =============================================================================
+9 -32
View File
@@ -1,5 +1,4 @@
from pathlib import Path from pathlib import Path
import json
from .tag import Tag from .tag import Tag
# Bump this when the csfd_cache schema changes to force re-fetch on next open. # 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: def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
self.file_path = file_path self.file_path = file_path
self.filename = file_path.name self.filename = file_path.name
self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag" # Unified pool index; when set, metadata is persisted there (see
# Optional unified pool index; when set, metadata lives there instead of # PoolIndex). Without an index a File is an in-memory object only — no
# in the sidecar file (see PoolIndex). # metadata is written to disk (there are no per-file sidecars anymore).
self.index = index self.index = index
self.new = True self.new = True
self.ignored = False self.ignored = False
@@ -38,20 +37,12 @@ class File:
self.get_metadata() self.get_metadata()
def get_metadata(self) -> None: def get_metadata(self) -> None:
if self.index is not None: record = self.index.get(self.file_path) if self.index is not None else None
record = self.index.get(self.file_path)
if record is None: if record is None:
self._init_new_metadata() self._init_new_metadata()
self.save_metadata() self.save_metadata()
else: else:
self._apply_record(record) 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: def _init_new_metadata(self) -> None:
self.new = True self.new = True
@@ -111,17 +102,9 @@ class File:
self.tags.append(tag) self.tags.append(tag)
def save_metadata(self): 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: if self.index is not None:
self.index.set(self.file_path, data) self.index.set(self.file_path, self._build_record())
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)
def added_timestamp(self) -> float: def added_timestamp(self) -> float:
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime. """Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
@@ -181,27 +164,21 @@ class File:
self.save_metadata() 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 pool index (no-op without one)."""
if self.index is not None: if self.index is not None:
self.index.delete(self.file_path) self.index.delete(self.file_path)
elif self.metadata_filename.exists():
self.metadata_filename.unlink()
def relocate(self, new_path: Path) -> None: def relocate(self, new_path: Path) -> None:
"""Point this File at a new path, moving its metadata along. """Point this File at a new path, moving its metadata along.
The physical file must already have been moved/renamed by the caller. 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 Drops the metadata under the old index key and rebinds to the new path;
to the new path; call ``save_metadata()`` afterwards to write it back. call ``save_metadata()`` afterwards to write it back.
""" """
old_metadata_filename = self.metadata_filename
if self.index is not None: if self.index is not None:
self.index.delete(self.file_path) self.index.delete(self.file_path)
self.file_path = Path(new_path) self.file_path = Path(new_path)
self.filename = self.file_path.name 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): def set_date(self, date_str: str | None):
"""Nastaví datum (např. '2025-09-25') nebo None pro smazání.""" """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 .pool_index import PoolIndex
from .utils import list_files from .utils import list_files
from typing import Iterable from typing import Iterable
import fnmatch
from src.core.config import ( from src.core.config import (
load_global_config, save_global_config, load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
load_folder_config, save_folder_config
) )
# Top-level folders inside the managed pool # Top-level folders inside the managed pool
POOL_MOVIES = "Filmy" POOL_MOVIES = "Filmy"
POOL_SERIES = "Seriály" 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") METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index")
class FileManager: class FileManager:
def __init__(self, tagmanager: TagManager): def __init__(self, tagmanager: TagManager):
self.filelist: list[File] = [] self.filelist: list[File] = []
self.folders: list[Path] = []
self.tagmanager = tagmanager self.tagmanager = tagmanager
self.on_files_changed = None # callback do GUI self.on_files_changed = None # callback do GUI
self.global_config = load_global_config() self.global_config = load_global_config()
self.folder_configs: dict[Path, dict] = {} # folder -> config self.index: PoolIndex | None = None # unified pool metadata + settings
self.current_folder: Path | None = None
self.index: PoolIndex | None = None # unified pool metadata index
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Pool (single source of truth) and Filmotéka (generated output) # Pool (single source of truth) and Filmotéka (generated output)
@@ -51,27 +54,75 @@ class FileManager:
pool = self.pool_dir pool = self.pool_dir
return pool / POOL_SERIES if pool else None 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 @property
def copyasis_folders(self) -> list[str]: def copyasis_folders(self) -> list[str]:
"""Names of pool subfolders mirrored 1:1 (copy-as-is) into the output.""" """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: def set_copyasis_folders(self, names: list[str]) -> None:
"""Set the copy-as-is subfolder list and persist it.""" """Set the copy-as-is subfolder list and persist it."""
cleaned = [n.strip() for n in names if n.strip()] cleaned = [n.strip() for n in names if n.strip()]
self.global_config["copyasis_folders"] = cleaned self._set_setting("copyasis_folders", cleaned)
save_global_config(self.global_config)
@property @property
def tag_schema(self) -> list[dict]: def tag_schema(self) -> list[dict]:
"""Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA).""" """Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA)."""
from src.core.config import DEFAULT_TAG_SCHEMA return self._get_setting("tag_schema")
return self.global_config.get("tag_schema", DEFAULT_TAG_SCHEMA)
def set_tag_schema(self, schema: list[dict]) -> None: def set_tag_schema(self, schema: list[dict]) -> None:
"""Set the tag schema and persist it.""" """Set the tag schema and persist it."""
self.global_config["tag_schema"] = schema self._set_setting("tag_schema", schema)
save_global_config(self.global_config)
def filmoteka_category_roots(self) -> dict[str, str]: def filmoteka_category_roots(self) -> dict[str, str]:
"""Category → output root-folder map derived from the tag schema. """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): if not (movies and movies.is_dir() and pool):
return return
self.index = PoolIndex(pool) self._open_index(pool)
for each in list_files(movies): for each in list_files(movies):
if each.name.endswith(METADATA_SUFFIXES): if each.name.endswith(METADATA_SUFFIXES):
continue continue
@@ -252,7 +303,7 @@ class FileManager:
movies.mkdir(parents=True, exist_ok=True) movies.mkdir(parents=True, exist_ok=True)
if self.index is None: if self.index is None:
self.index = PoolIndex(pool) self._open_index(pool)
source = Path(source) source = Path(source)
safe_title = title.strip() or source.stem safe_title = title.strip() or source.stem
@@ -325,80 +376,6 @@ class FileManager:
self.on_files_changed(self.filelist) self.on_files_changed(self.filelist)
return file_obj 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): 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.""" """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
for f in files_objs: for f in files_objs:
@@ -460,9 +437,3 @@ class FileManager:
if all(tag in file_tags for tag in target_full_paths): if all(tag in file_tags for tag in target_full_paths):
filtered.append(f) filtered.append(f)
return filtered 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 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. 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), 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. e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines.
""" """
@@ -21,6 +30,7 @@ class PoolIndex:
self.pool_dir = Path(pool_dir) self.pool_dir = Path(pool_dir)
self.index_path = self.pool_dir / INDEX_FILENAME self.index_path = self.pool_dir / INDEX_FILENAME
self.records: dict[str, dict] = {} self.records: dict[str, dict] = {}
self.settings: dict = {}
self.load() self.load()
def _key(self, file_path: Path) -> str: def _key(self, file_path: Path) -> str:
@@ -35,19 +45,34 @@ class PoolIndex:
"""Load the index from disk (missing/corrupt index = empty).""" """Load the index from disk (missing/corrupt index = empty)."""
if not self.index_path.exists(): if not self.index_path.exists():
self.records = {} self.records = {}
self.settings = {}
return return
try: try:
with open(self.index_path, "r", encoding="utf-8") as f: with open(self.index_path, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
self.records = data.get("movies", {}) self.records = data.get("movies", {})
self.settings = data.get("settings", {})
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
self.records = {} self.records = {}
self.settings = {}
def save(self) -> None: def save(self) -> None:
"""Persist the index to disk.""" """Persist the index to disk."""
self.pool_dir.mkdir(parents=True, exist_ok=True) self.pool_dir.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f: 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: def get(self, file_path: Path) -> dict | None:
"""Return the record for a file, or None if it is not indexed.""" """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 import json
from src.core.config import ( from src.core.config import (
load_global_config, save_global_config, DEFAULT_GLOBAL_CONFIG, 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 load_config, save_config # Legacy functions
) )
@@ -20,16 +18,23 @@ class TestGlobalConfig:
return config_path return config_path
def test_default_global_config_structure(self): 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_geometry" in DEFAULT_GLOBAL_CONFIG
assert "window_maximized" in DEFAULT_GLOBAL_CONFIG assert "window_maximized" in DEFAULT_GLOBAL_CONFIG
assert "last_folder" in DEFAULT_GLOBAL_CONFIG assert "last_folder" in DEFAULT_GLOBAL_CONFIG
assert "sidebar_width" in DEFAULT_GLOBAL_CONFIG assert "sidebar_width" in DEFAULT_GLOBAL_CONFIG
assert "recent_folders" 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_geometry"] == "1200x800"
assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False
assert DEFAULT_GLOBAL_CONFIG["last_folder"] is None 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): def test_load_global_config_nonexistent_file(self, temp_global_config):
"""Test načtení globální konfigurace když soubor neexistuje""" """Test načtení globální konfigurace když soubor neexistuje"""
config = load_global_config() config = load_global_config()
@@ -62,8 +67,6 @@ class TestGlobalConfig:
"recent_folders": [], "recent_folders": [],
"pool_dir": None, "pool_dir": None,
"filmoteka_dir": None, "filmoteka_dir": None,
"copyasis_folders": ["Seriály"],
"tag_schema": DEFAULT_GLOBAL_CONFIG["tag_schema"],
} }
save_global_config(test_config) save_global_config(test_config)
@@ -125,150 +128,6 @@ class TestGlobalConfig:
assert len(loaded["recent_folders"]) == 3 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: class TestLegacyFunctions:
"""Testy pro zpětnou kompatibilitu""" """Testy pro zpětnou kompatibilitu"""
@@ -342,18 +201,6 @@ class TestConfigEdgeCases:
assert len(loaded["recent_folders"]) == 100 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): 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 že config je uložen ve správném JSON formátu s indentací"""
test_config = {**DEFAULT_GLOBAL_CONFIG} test_config = {**DEFAULT_GLOBAL_CONFIG}
@@ -391,24 +238,3 @@ class TestConfigEdgeCases:
loaded = load_global_config() loaded = load_global_config()
assert loaded["last_folder"] == "/path2" 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 pytest
import json
from pathlib import Path
from src.core.file import File from src.core.file import File
from src.core.pool_index import PoolIndex
from src.core.tag import Tag from src.core.tag import Tag
from src.core.tag_manager import TagManager from src.core.tag_manager import TagManager
class TestFile: class TestFile:
"""Testy pro třídu File""" """Testy pro třídu File (metadata žijí v pool indexu)"""
@pytest.fixture
def temp_dir(self, tmp_path):
"""Fixture pro dočasný adresář"""
return tmp_path
@pytest.fixture @pytest.fixture
def tag_manager(self): def tag_manager(self):
"""Fixture pro TagManager"""
return TagManager() return TagManager()
@pytest.fixture @pytest.fixture
def test_file(self, temp_dir): def index(self, tmp_path):
"""Fixture pro testovací soubor""" """Pool index that backs the File's metadata."""
test_file = temp_dir / "test.txt" return PoolIndex(tmp_path)
test_file.write_text("test content")
return test_file
def test_file_creation(self, test_file, tag_manager): @pytest.fixture
"""Test vytvoření File objektu""" def test_file(self, tmp_path):
file_obj = File(test_file, tag_manager) 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.file_path == test_file
assert file_obj.filename == "test.txt" 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): def test_file_initial_tags(self, test_file, tag_manager, index):
"""Test názvu metadata souboru""" """Nový soubor nemá žádné automatické tagy (Stav/Nové odstraněn)."""
file_obj = File(test_file, tag_manager) file_obj = File(test_file, tag_manager, index=index)
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)
assert file_obj.tags == [] assert file_obj.tags == []
def test_file_metadata_saved(self, test_file, tag_manager): def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
"""Test že metadata jsou uložena při vytvoření""" """Metadata jsou zapsána do indexu už při vytvoření."""
file_obj = File(test_file, tag_manager) file_obj = File(test_file, tag_manager, index=index)
assert file_obj.metadata_filename.exists() assert index.get(file_obj.file_path) is not None
def test_file_save_metadata(self, test_file, tag_manager): def test_file_no_sidecar_written(self, test_file, tag_manager, index):
"""Test uložení metadat""" """Žá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 = 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.new = False
file_obj.ignored = True file_obj.ignored = True
file_obj.save_metadata() file_obj.save_metadata()
# Načtení a kontrola data = index.get(file_obj.file_path)
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: assert data["new"] is False
data = json.load(f) assert data["ignored"] is True
assert data["new"] == False def test_file_load_metadata(self, test_file, tag_manager, index):
assert data["ignored"] == True file_obj = File(test_file, tag_manager, index=index)
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)
tag = tag_manager.add_tag("Video", "HD") tag = tag_manager.add_tag("Video", "HD")
file_obj.tags.append(tag) file_obj.tags.append(tag)
file_obj.date = "2025-01-15" file_obj.date = "2025-01-15"
file_obj.save_metadata() file_obj.save_metadata()
# Vytvoření nového objektu - měl by načíst metadata # A fresh File over the same index reloads the metadata
file_obj2 = File(test_file, tag_manager) file_obj2 = File(test_file, tag_manager, index=index)
assert len(file_obj2.tags) == 1 # Video/HD assert len(file_obj2.tags) == 1 # Video/HD
assert file_obj2.date == "2025-01-15" 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 def test_file_set_date(self, test_file, tag_manager, index):
tag_paths = {tag.full_path for tag in file_obj2.tags} file_obj = File(test_file, tag_manager, index=index)
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)
file_obj.set_date("2025-12-25") file_obj.set_date("2025-12-25")
assert file_obj.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 def test_file_set_date_to_none(self, test_file, tag_manager, index):
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: file_obj = File(test_file, tag_manager, index=index)
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)
file_obj.set_date("2025-12-25") file_obj.set_date("2025-12-25")
file_obj.set_date(None) file_obj.set_date(None)
assert file_obj.date is None assert file_obj.date is None
def test_file_set_date_empty_string(self, test_file, tag_manager): def test_file_set_date_empty_string(self, test_file, tag_manager, index):
"""Test nastavení prázdného řetězce jako datum""" file_obj = File(test_file, tag_manager, index=index)
file_obj = File(test_file, tag_manager)
file_obj.set_date("2025-12-25") file_obj.set_date("2025-12-25")
file_obj.set_date("") file_obj.set_date("")
assert file_obj.date is None assert file_obj.date is None
def test_file_add_tag_object(self, test_file, tag_manager): def test_file_add_tag_object(self, test_file, tag_manager, index):
"""Test přidání Tag objektu""" file_obj = File(test_file, tag_manager, index=index)
file_obj = File(test_file, tag_manager)
tag = Tag("Video", "4K") tag = Tag("Video", "4K")
file_obj.add_tag(tag) file_obj.add_tag(tag)
assert tag in file_obj.tags 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): def test_file_add_tag_string(self, test_file, tag_manager, index):
"""Test přidání tagu jako string""" file_obj = File(test_file, tag_manager, index=index)
file_obj = File(test_file, tag_manager)
file_obj.add_tag("Audio/MP3") 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} def test_file_add_tag_string_without_category(self, test_file, tag_manager, index):
assert "Audio/MP3" in tag_paths file_obj = File(test_file, tag_manager, index=index)
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)
file_obj.add_tag("SimpleTag") 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} def test_file_add_duplicate_tag(self, test_file, tag_manager, index):
assert "default/SimpleTag" in tag_paths file_obj = File(test_file, tag_manager, index=index)
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)
tag = Tag("Video", "HD") tag = Tag("Video", "HD")
file_obj.add_tag(tag) file_obj.add_tag(tag)
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 def test_file_remove_tag_object(self, test_file, tag_manager, index):
count = sum(1 for t in file_obj.tags if t == tag) file_obj = File(test_file, tag_manager, index=index)
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)
tag = Tag("Video", "HD") tag = Tag("Video", "HD")
file_obj.add_tag(tag) file_obj.add_tag(tag)
file_obj.remove_tag(tag) file_obj.remove_tag(tag)
assert tag not in file_obj.tags assert tag not in file_obj.tags
def test_file_remove_tag_string(self, test_file, tag_manager): def test_file_remove_tag_string(self, test_file, tag_manager, index):
"""Test odstranění tagu jako string""" file_obj = File(test_file, tag_manager, index=index)
file_obj = File(test_file, tag_manager)
file_obj.add_tag("Video/HD") file_obj.add_tag("Video/HD")
file_obj.remove_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} def test_file_remove_tag_string_without_category(self, test_file, tag_manager, index):
assert "Video/HD" not in tag_paths file_obj = File(test_file, tag_manager, index=index)
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)
file_obj.add_tag("SimpleTag") file_obj.add_tag("SimpleTag")
file_obj.remove_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} def test_file_remove_nonexistent_tag(self, test_file, tag_manager, index):
assert "default/SimpleTag" not in tag_paths file_obj = File(test_file, tag_manager, index=index)
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)
initial_count = len(file_obj.tags) initial_count = len(file_obj.tags)
file_obj.remove_tag("Nonexistent/Tag") file_obj.remove_tag("Nonexistent/Tag")
assert len(file_obj.tags) == initial_count assert len(file_obj.tags) == initial_count
def test_file_without_tagmanager(self, test_file): def test_file_without_tagmanager(self, test_file):
"""Test File bez TagManager"""
file_obj = File(test_file, tagmanager=None) file_obj = File(test_file, tagmanager=None)
assert file_obj.tagmanager is 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): def test_file_metadata_persistence(self, test_file, tag_manager, index):
"""Test že metadata přežijí reload""" file_obj1 = File(test_file, tag_manager, index=index)
# Vytvoření a úprava souboru
file_obj1 = File(test_file, tag_manager)
file_obj1.add_tag("Video/HD") file_obj1.add_tag("Video/HD")
file_obj1.add_tag("Audio/Stereo") file_obj1.add_tag("Audio/Stereo")
file_obj1.set_date("2025-01-01") file_obj1.set_date("2025-01-01")
@@ -193,73 +156,54 @@ class TestFile:
file_obj1.ignored = True file_obj1.ignored = True
file_obj1.save_metadata() file_obj1.save_metadata()
# Načtení nového objektu file_obj2 = File(test_file, tag_manager, index=index)
file_obj2 = File(test_file, tag_manager) assert file_obj2.new is False
assert file_obj2.ignored is True
# Kontrola
assert file_obj2.new == False
assert file_obj2.ignored == True
assert file_obj2.date == "2025-01-01" assert file_obj2.date == "2025-01-01"
tag_paths = {t.full_path for t in file_obj2.tags}
tag_paths = {tag.full_path for tag in file_obj2.tags}
assert "Video/HD" in tag_paths assert "Video/HD" in tag_paths
assert "Audio/Stereo" in tag_paths assert "Audio/Stereo" in tag_paths
def test_file_metadata_json_format(self, test_file, tag_manager): def test_file_metadata_record_shape(self, test_file, tag_manager, index):
"""Test formátu JSON metadat""" file_obj = File(test_file, tag_manager, index=index)
file_obj = File(test_file, tag_manager)
file_obj.add_tag("Test/Tag") file_obj.add_tag("Test/Tag")
file_obj.set_date("2025-06-15") file_obj.set_date("2025-06-15")
# Kontrola obsahu JSON data = index.get(file_obj.file_path)
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f:
data = json.load(f)
assert "new" in data assert "new" in data
assert "ignored" in data assert "ignored" in data
assert "tags" in data assert "tags" in data
assert "date" in data assert "date" in data
assert isinstance(data["tags"], list) assert isinstance(data["tags"], list)
def test_file_unicode_handling(self, temp_dir, tag_manager): def test_file_unicode_handling(self, tmp_path, tag_manager, index):
"""Test správného zacházení s unicode znaky""" test_file = tmp_path / "český_soubor.txt"
test_file = temp_dir / "český_soubor.txt"
test_file.write_text("obsah") 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.add_tag("Kategorie/Český tag")
file_obj.save_metadata() file_obj.save_metadata()
# Reload a kontrola file_obj2 = File(test_file, tag_manager, index=index)
file_obj2 = File(test_file, tag_manager) assert "Kategorie/Český tag" in {t.full_path for t in file_obj2.tags}
tag_paths = {tag.full_path for tag in file_obj2.tags}
assert "Kategorie/Český tag" in tag_paths
def test_file_complex_scenario(self, test_file, tag_manager): def test_file_complex_scenario(self, test_file, tag_manager, index):
"""Test komplexního scénáře použití""" file_obj = File(test_file, tag_manager, index=index)
file_obj = File(test_file, tag_manager)
# Přidání více tagů
file_obj.add_tag("Video/HD") file_obj.add_tag("Video/HD")
file_obj.add_tag("Video/Stereo") file_obj.add_tag("Video/Stereo")
file_obj.add_tag("Stav/Zkontrolováno") file_obj.add_tag("Stav/Zkontrolováno")
file_obj.set_date("2025-01-01") file_obj.set_date("2025-01-01")
# Odstranění tagu
file_obj.remove_tag("Stav/Nové") file_obj.remove_tag("Stav/Nové")
# Kontrola stavu tag_paths = {t.full_path for t in file_obj.tags}
tag_paths = {tag.full_path for tag in file_obj.tags}
assert "Video/HD" in tag_paths assert "Video/HD" in tag_paths
assert "Video/Stereo" in tag_paths assert "Video/Stereo" in tag_paths
assert "Stav/Zkontrolováno" in tag_paths assert "Stav/Zkontrolováno" in tag_paths
assert "Stav/Nové" not in tag_paths assert "Stav/Nové" not in tag_paths
assert file_obj.date == "2025-01-01" assert file_obj.date == "2025-01-01"
# Reload a kontrola persistence file_obj2 = File(test_file, tag_manager, index=index)
file_obj2 = File(test_file, tag_manager) assert {t.full_path for t in file_obj2.tags} == tag_paths
tag_paths2 = {tag.full_path for tag in file_obj2.tags}
assert tag_paths == tag_paths2
assert file_obj2.date == "2025-01-01" assert file_obj2.date == "2025-01-01"
@@ -271,10 +215,14 @@ class TestApplyCsfdTags:
return TagManager() return TagManager()
@pytest.fixture @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 = tmp_path / "Matrix.mkv"
path.write_text("x") 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/") f.set_csfd_link("https://www.csfd.cz/film/9499-matrix/")
return f return f
@@ -314,7 +262,6 @@ class TestApplyCsfdTags:
paths = {t.full_path for t in movie_file.tags} paths = {t.full_path for t in movie_file.tags}
assert not any(p.startswith("Režie/") for p in paths) assert not any(p.startswith("Režie/") for p in paths)
assert not any(p.startswith("Herec/") 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() cached = movie_file.get_cached_movie()
assert cached.directors == ["Lana Wachowski"] assert cached.directors == ["Lana Wachowski"]
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"] assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"]
@@ -392,7 +339,6 @@ class TestApplyCsfdTags:
first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"]) first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"])
with patch("src.core.csfd.fetch_movie", return_value=first): with patch("src.core.csfd.fetch_movie", return_value=first):
movie_file.apply_csfd_tags() movie_file.apply_csfd_tags()
# different movie on re-fetch
second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"]) second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"])
with patch("src.core.csfd.fetch_movie", return_value=second): with patch("src.core.csfd.fetch_movie", return_value=second):
movie_file.apply_csfd_tags() movie_file.apply_csfd_tags()
+111 -466
View File
@@ -1,294 +1,55 @@
import pytest import pytest
from src.core.file import File
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.tag import Tag from src.core.tag import Tag
class TestFileManager: @pytest.fixture
"""Testy pro třídu FileManager""" def temp_global_config(tmp_path, monkeypatch):
"""Point the global config file at a throwaway path for each test."""
@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"""
config_path = tmp_path / "test_config.json" config_path = tmp_path / "test_config.json"
import src.core.config as config_module 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 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): def test_file_manager_creation(self, file_manager, tag_manager):
"""Test vytvoření FileManager"""
assert file_manager.filelist == [] assert file_manager.filelist == []
assert file_manager.folders == []
assert file_manager.tagmanager == tag_manager assert file_manager.tagmanager == tag_manager
assert file_manager.global_config is not None assert file_manager.global_config is not None
assert file_manager.folder_configs == {} assert file_manager.index is None
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
class TestFileManagerTagOperations: class TestFileManagerTagOperations:
"""Testy pro operace s tagy""" """assign/remove tag operations over the loaded file list."""
@pytest.fixture def test_assign_tag_to_file_objects_tag_object(self, file_manager, tmp_path, tag_manager):
def tag_manager(self): files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt")
return TagManager() file_manager.filelist = files
@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]
tag = Tag("Video", "HD") tag = Tag("Video", "HD")
file_manager.assign_tag_to_file_objects(files, tag) file_manager.assign_tag_to_file_objects(files, tag)
@@ -296,30 +57,27 @@ class TestFileManagerTagOperations:
for f in files: for f in files:
assert tag in f.tags assert tag in f.tags
def test_assign_tag_string_with_category(self, file_manager, temp_dir): def test_assign_tag_string_with_category(self, file_manager, tmp_path, tag_manager):
"""Test přiřazení tagu jako string s kategorií""" files = _in_memory_files(tmp_path, tag_manager, "f1.txt")
file_manager.append(temp_dir) file_manager.filelist = files
files = file_manager.filelist[:1]
file_manager.assign_tag_to_file_objects(files, "Video/4K") file_manager.assign_tag_to_file_objects(files, "Video/4K")
tag_paths = {tag.full_path for tag in files[0].tags} tag_paths = {tag.full_path for tag in files[0].tags}
assert "Video/4K" in tag_paths assert "Video/4K" in tag_paths
def test_assign_tag_string_without_category(self, file_manager, temp_dir): def test_assign_tag_string_without_category(self, file_manager, tmp_path, tag_manager):
"""Test přiřazení tagu bez kategorie (default)""" files = _in_memory_files(tmp_path, tag_manager, "f1.txt")
file_manager.append(temp_dir) file_manager.filelist = files
files = file_manager.filelist[:1]
file_manager.assign_tag_to_file_objects(files, "SimpleTag") file_manager.assign_tag_to_file_objects(files, "SimpleTag")
tag_paths = {tag.full_path for tag in files[0].tags} tag_paths = {tag.full_path for tag in files[0].tags}
assert "default/SimpleTag" in tag_paths assert "default/SimpleTag" in tag_paths
def test_assign_tag_no_duplicate(self, file_manager, temp_dir): def test_assign_tag_no_duplicate(self, file_manager, tmp_path, tag_manager):
"""Test že tag není přidán dvakrát""" files = _in_memory_files(tmp_path, tag_manager, "f1.txt")
file_manager.append(temp_dir) file_manager.filelist = files
files = file_manager.filelist[:1]
tag = Tag("Video", "HD") tag = Tag("Video", "HD")
file_manager.assign_tag_to_file_objects(files, tag) 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) count = sum(1 for t in files[0].tags if t == tag)
assert count == 1 assert count == 1
def test_remove_tag_from_file_objects(self, file_manager, temp_dir): def test_remove_tag_from_file_objects(self, file_manager, tmp_path, tag_manager):
"""Test odstranění tagu ze souborů""" files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt")
file_manager.append(temp_dir) file_manager.filelist = files
files = file_manager.filelist[:2]
tag = Tag("Video", "HD") tag = Tag("Video", "HD")
file_manager.assign_tag_to_file_objects(files, tag) file_manager.assign_tag_to_file_objects(files, tag)
@@ -340,10 +97,9 @@ class TestFileManagerTagOperations:
for f in files: for f in files:
assert tag not in f.tags assert tag not in f.tags
def test_remove_tag_string(self, file_manager, temp_dir): def test_remove_tag_string(self, file_manager, tmp_path, tag_manager):
"""Test odstranění tagu jako string""" files = _in_memory_files(tmp_path, tag_manager, "f1.txt")
file_manager.append(temp_dir) file_manager.filelist = files
files = file_manager.filelist[:1]
file_manager.assign_tag_to_file_objects(files, "Video/HD") file_manager.assign_tag_to_file_objects(files, "Video/HD")
file_manager.remove_tag_from_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} tag_paths = {tag.full_path for tag in files[0].tags}
assert "Video/HD" not in tag_paths assert "Video/HD" not in tag_paths
def test_callback_on_tag_change(self, file_manager, temp_dir): def test_callback_on_tag_change(self, file_manager, tmp_path, tag_manager):
"""Test callback při změně tagů""" files = _in_memory_files(tmp_path, tag_manager, "f1.txt")
file_manager.append(temp_dir) file_manager.filelist = files
callback_calls = [] callback_calls = []
file_manager.on_files_changed = lambda filelist: callback_calls.append(len(filelist))
def callback(filelist): file_manager.assign_tag_to_file_objects([files[0]], Tag("Test", "Tag"))
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"))
assert len(callback_calls) == 1 assert len(callback_calls) == 1
class TestFileManagerFiltering: class TestFileManagerFiltering:
"""Testy pro filtrování souborů""" """filter_files_by_tags over the loaded file list."""
@pytest.fixture @pytest.fixture
def tag_manager(self): def loaded(self, file_manager, tmp_path, tag_manager):
return TagManager() files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt", "f3.txt")
file_manager.filelist = files
return file_manager
@pytest.fixture def test_filter_empty_tags_returns_all(self, loaded):
def temp_global_config(self, tmp_path, monkeypatch): assert len(loaded.filter_files_by_tags([])) == len(loaded.filelist)
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 test_filter_none_returns_all(self, loaded):
def file_manager(self, tag_manager, temp_global_config): assert len(loaded.filter_files_by_tags(None)) == len(loaded.filelist)
return FileManager(tag_manager)
@pytest.fixture def test_filter_by_single_tag(self, loaded):
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)
tag = Tag("Video", "HD") tag = Tag("Video", "HD")
files_to_tag = file_manager.filelist[:2] loaded.assign_tag_to_file_objects(loaded.filelist[:2], tag)
file_manager.assign_tag_to_file_objects(files_to_tag, tag)
filtered = file_manager.filter_files_by_tags([tag]) filtered = loaded.filter_files_by_tags([tag])
assert len(filtered) == 2 assert len(filtered) == 2
for f in filtered: for f in filtered:
assert tag in f.tags assert tag in f.tags
def test_filter_by_multiple_tags_and_logic(self, file_manager, temp_dir): def test_filter_by_multiple_tags_and_logic(self, loaded):
"""Test filtrace podle více tagů (AND logika)"""
file_manager.append(temp_dir)
tag1 = Tag("Video", "HD") tag1 = Tag("Video", "HD")
tag2 = Tag("Audio", "Stereo") 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 filtered = loaded.filter_files_by_tags([tag1, tag2])
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])
assert len(filtered) == 1 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): def test_filter_by_tag_strings(self, loaded):
"""Test filtrace podle tagů jako stringy""" loaded.assign_tag_to_file_objects([loaded.filelist[0]], "Video/HD")
file_manager.append(temp_dir) assert len(loaded.filter_files_by_tags(["Video/HD"])) == 1
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD")
filtered = file_manager.filter_files_by_tags(["Video/HD"]) def test_filter_no_match(self, loaded):
assert len(filtered) == 1 assert len(loaded.filter_files_by_tags([Tag("NonExistent", "Tag")])) == 0
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"
class TestFileManagerEdgeCases: 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): def test_empty_filelist_operations(self, file_manager):
"""Test operací s prázdným filelistem""" assert file_manager.filter_files_by_tags([Tag("Video", "HD")]) == []
filtered = file_manager.filter_files_by_tags([Tag("Video", "HD")])
assert filtered == []
# Přiřazení tagů na prázdný seznam
file_manager.assign_tag_to_file_objects([], Tag("Video", "HD")) file_manager.assign_tag_to_file_objects([], Tag("Video", "HD"))
assert len(file_manager.filelist) == 0 assert len(file_manager.filelist) == 0
def test_assign_tag_to_empty_list(self, file_manager): 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")) # no exception
file_manager.assign_tag_to_file_objects([], Tag("Test", "Tag"))
# Nemělo by vyhodit výjimku
def test_remove_nonexistent_tag(self, file_manager, tmp_path): def test_remove_nonexistent_tag(self, file_manager, tmp_path, tag_manager):
"""Test odstranění neexistujícího tagu""" file_manager.filelist = _in_memory_files(tmp_path, tag_manager, "file.txt")
(tmp_path / "file.txt").write_text("content") file_manager.remove_tag_from_file_objects(
file_manager.append(tmp_path) file_manager.filelist, Tag("NonExistent", "Tag")) # no exception
# 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
class TestPoolManagement: class TestPoolManagement:
@@ -669,7 +288,8 @@ 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): 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([ file_manager.set_tag_schema([
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""}, {"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku"}, {"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku"},
@@ -802,8 +422,33 @@ class TestPoolManagement:
assert len(reloaded.filelist) == 1 assert len(reloaded.filelist) == 1
assert reloaded.filelist[0].title == "Matrix" 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"] assert file_manager.copyasis_folders == ["Seriály"]
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_copyasis_folders(["Seriály", " Dokumenty ", ""]) file_manager.set_copyasis_folders(["Seriály", " Dokumenty ", ""])
assert file_manager.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) 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 index.get(movie) is not None # record created in index
assert f.tags == [] # no automatic tags assert f.tags == [] # no automatic tags