Store library settings in the pool index and drop legacy sidecars
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+11
-34
@@ -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.save_metadata()
|
|
||||||
else:
|
|
||||||
self._apply_record(record)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.metadata_filename.exists():
|
|
||||||
self._init_new_metadata()
|
self._init_new_metadata()
|
||||||
self.save_metadata()
|
self.save_metadata()
|
||||||
else:
|
else:
|
||||||
self.load_metadata()
|
self._apply_record(record)
|
||||||
|
|
||||||
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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+8
-182
@@ -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
@@ -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()
|
||||||
|
|||||||
+113
-468
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
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)
|
||||||
|
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:
|
class TestFileManager:
|
||||||
"""Testy pro třídu FileManager"""
|
"""Basic FileManager state."""
|
||||||
|
|
||||||
@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"
|
|
||||||
import src.core.config as config_module
|
|
||||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
|
||||||
return config_path
|
|
||||||
|
|
||||||
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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user