Store library settings in the pool index and drop legacy sidecars

This commit is contained in:
2026-07-03 16:06:45 +02:00
parent f0f38d4257
commit 7ae0709f38
13 changed files with 379 additions and 2292 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually."""
__version__ = "1.7.0"
__version__ = "1.8.0"
+18 -58
View File
@@ -1,10 +1,15 @@
"""
Configuration management for Curator
Configuration for Curator.
Three levels of configuration:
1. Global config (.Curator.!gtag next to Curator.py) - app-wide settings
2. Folder config (.Curator.!ftag in project root) - folder-specific settings
3. File tags (.{filename}.!tag) - per-file metadata (handled in file.py)
Two stores, by responsibility:
1. Global config (``.Curator.!gtag`` next to Curator.py) — **application data
only**: window state, MRU folders, and the pointers to where the pool and the
Filmotéka output live (``pool_dir`` / ``filmoteka_dir``). Those pointers must
stay here because the pool index is found *through* them.
2. Pool index (``<pool>/.Curator.!index``, see ``pool_index.py``) — everything
describing a particular library: per-movie metadata **and** library-level
settings (``tag_schema``, ``copyasis_folders``). It travels with the pool.
"""
import json
from pathlib import Path
@@ -12,12 +17,9 @@ from pathlib import Path
# Global config file (next to the main script)
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
# Folder config filename
FOLDER_CONFIG_NAME = ".Curator.!ftag"
# =============================================================================
# GLOBAL CONFIG - Application settings
# TAG SCHEMA - default library settings (stored per-pool in the index)
# =============================================================================
# Tag schema: the single source of truth for which tag categories exist, how
@@ -35,6 +37,8 @@ FOLDER_CONFIG_NAME = ".Curator.!ftag"
# keep the pool filename. Pool files are never renamed by this.
# Grouping folders are prefixed with "- " so DLNA/TV browsers sort the special
# folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root.
# This lives in the code as the default; the effective schema is stored per-pool
# in the index (``PoolIndex.settings["tag_schema"]``).
DEFAULT_TAG_SCHEMA = [
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "- Dle roku"},
@@ -44,6 +48,11 @@ DEFAULT_TAG_SCHEMA = [
"filmoteka_root": "- Dle hodnocení"},
]
# =============================================================================
# GLOBAL CONFIG - Application settings only
# =============================================================================
DEFAULT_GLOBAL_CONFIG = {
"window_geometry": "1200x800",
"window_maximized": False,
@@ -52,8 +61,6 @@ DEFAULT_GLOBAL_CONFIG = {
"recent_folders": [],
"pool_dir": None, # managed pool root (single source of truth)
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
"copyasis_folders": ["Seriály"], # pool subfolders mirrored 1:1 (copy-as-is)
"tag_schema": DEFAULT_TAG_SCHEMA, # tag categories + ČSFD/Filmotéka rules
}
@@ -79,53 +86,6 @@ def save_global_config(cfg: dict):
json.dump(cfg, f, indent=2, ensure_ascii=False)
# =============================================================================
# FOLDER CONFIG - Per-folder settings
# =============================================================================
DEFAULT_FOLDER_CONFIG = {
"ignore_patterns": [],
"custom_tags": {}, # Additional tags specific to this folder
"recursive": True, # Whether to scan subfolders
"hardlink_output_dir": None, # Output directory for hardlink structure
"hardlink_categories": None, # Categories to include in hardlink (None = all)
}
def get_folder_config_path(folder: Path) -> Path:
"""Get path to folder config file"""
return folder / FOLDER_CONFIG_NAME
def load_folder_config(folder: Path) -> dict:
"""Load folder-specific config"""
config_path = get_folder_config_path(folder)
if config_path.exists():
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
# Merge with defaults for any missing keys
for key, value in DEFAULT_FOLDER_CONFIG.items():
if key not in config:
config[key] = value
return config
except Exception:
return DEFAULT_FOLDER_CONFIG.copy()
return DEFAULT_FOLDER_CONFIG.copy()
def save_folder_config(folder: Path, cfg: dict):
"""Save folder-specific config"""
config_path = get_folder_config_path(folder)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def folder_has_config(folder: Path) -> bool:
"""Check if folder has a tagger config"""
return get_folder_config_path(folder).exists()
# =============================================================================
# BACKWARDS COMPATIBILITY
# =============================================================================
+11 -34
View File
@@ -1,5 +1,4 @@
from pathlib import Path
import json
from .tag import Tag
# Bump this when the csfd_cache schema changes to force re-fetch on next open.
@@ -11,9 +10,9 @@ class File:
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
self.file_path = file_path
self.filename = file_path.name
self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag"
# Optional unified pool index; when set, metadata lives there instead of
# in the sidecar file (see PoolIndex).
# Unified pool index; when set, metadata is persisted there (see
# PoolIndex). Without an index a File is an in-memory object only — no
# metadata is written to disk (there are no per-file sidecars anymore).
self.index = index
self.new = True
self.ignored = False
@@ -38,20 +37,12 @@ class File:
self.get_metadata()
def get_metadata(self) -> None:
if self.index is not None:
record = self.index.get(self.file_path)
if record is None:
self._init_new_metadata()
self.save_metadata()
else:
self._apply_record(record)
return
if not self.metadata_filename.exists():
record = self.index.get(self.file_path) if self.index is not None else None
if record is None:
self._init_new_metadata()
self.save_metadata()
else:
self.load_metadata()
self._apply_record(record)
def _init_new_metadata(self) -> None:
self.new = True
@@ -111,17 +102,9 @@ class File:
self.tags.append(tag)
def save_metadata(self):
data = self._build_record()
"""Persist metadata to the pool index; a no-op for an index-less File."""
if self.index is not None:
self.index.set(self.file_path, data)
return
with open(self.metadata_filename, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def load_metadata(self) -> None:
with open(self.metadata_filename, "r", encoding="utf-8") as f:
data = json.load(f)
self._apply_record(data)
self.index.set(self.file_path, self._build_record())
def added_timestamp(self) -> float:
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
@@ -181,27 +164,21 @@ class File:
self.save_metadata()
def delete_metadata(self) -> None:
"""Remove this file's metadata (from the index, or its sidecar file)."""
"""Remove this file's metadata from the pool index (no-op without one)."""
if self.index is not None:
self.index.delete(self.file_path)
elif self.metadata_filename.exists():
self.metadata_filename.unlink()
def relocate(self, new_path: Path) -> None:
"""Point this File at a new path, moving its metadata along.
The physical file must already have been moved/renamed by the caller.
Drops the metadata under the old path (index key or sidecar) and rebinds
to the new path; call ``save_metadata()`` afterwards to write it back.
Drops the metadata under the old index key and rebinds to the new path;
call ``save_metadata()`` afterwards to write it back.
"""
old_metadata_filename = self.metadata_filename
if self.index is not None:
self.index.delete(self.file_path)
self.file_path = Path(new_path)
self.filename = self.file_path.name
self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag"
if self.index is None and old_metadata_filename.exists():
old_metadata_filename.rename(self.metadata_filename)
def set_date(self, date_str: str | None):
"""Nastaví datum (např. '2025-09-25') nebo None pro smazání."""
+68 -97
View File
@@ -7,30 +7,33 @@ from .tag_manager import TagManager
from .pool_index import PoolIndex
from .utils import list_files
from typing import Iterable
import fnmatch
from src.core.config import (
load_global_config, save_global_config,
load_folder_config, save_folder_config
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
)
# Top-level folders inside the managed pool
POOL_MOVIES = "Filmy"
POOL_SERIES = "Seriály"
# Curator metadata files that must never be treated as content
# Library-level settings that live in the pool index (not the global config).
_LIBRARY_SETTINGS_DEFAULTS = {
"tag_schema": DEFAULT_TAG_SCHEMA,
"copyasis_folders": [POOL_SERIES],
}
# Curator metadata files that must never be treated as content. The legacy
# per-file (.!tag) and per-folder (.!ftag) sidecars are no longer written, but
# stray ones are still skipped so they never leak into the Filmotéka.
METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index")
class FileManager:
def __init__(self, tagmanager: TagManager):
self.filelist: list[File] = []
self.folders: list[Path] = []
self.tagmanager = tagmanager
self.on_files_changed = None # callback do GUI
self.global_config = load_global_config()
self.folder_configs: dict[Path, dict] = {} # folder -> config
self.current_folder: Path | None = None
self.index: PoolIndex | None = None # unified pool metadata index
self.index: PoolIndex | None = None # unified pool metadata + settings
# ------------------------------------------------------------------
# Pool (single source of truth) and Filmotéka (generated output)
@@ -51,27 +54,75 @@ class FileManager:
pool = self.pool_dir
return pool / POOL_SERIES if pool else None
# ------------------------------------------------------------------
# Pool index + library-level settings (stored in the index, per pool)
# ------------------------------------------------------------------
def _open_index(self, pool: Path) -> PoolIndex:
"""(Re)load the pool index and migrate any legacy global settings."""
self.index = PoolIndex(pool)
self._migrate_global_settings()
return self.index
def _ensure_index(self) -> PoolIndex | None:
"""Return the pool index, opening it lazily when a pool is configured."""
if self.index is None and self.pool_dir:
self._open_index(self.pool_dir)
return self.index
def _migrate_global_settings(self) -> None:
"""Move library settings from the old global config into the index once.
Earlier versions stored ``tag_schema`` / ``copyasis_folders`` in the
global ``.!gtag``. They now belong to the pool; seed the index from the
old values (if present and not already migrated) and drop them from the
global config so it carries app data only.
"""
if self.index is None:
return
moved = False
for key in _LIBRARY_SETTINGS_DEFAULTS:
if key in self.global_config:
if key not in self.index.settings:
self.index.set_setting(key, self.global_config[key])
del self.global_config[key]
moved = True
if moved:
save_global_config(self.global_config)
def _get_setting(self, key: str):
"""Read a library setting from the index (falls back to the default)."""
default = _LIBRARY_SETTINGS_DEFAULTS[key]
idx = self._ensure_index()
if idx is None:
return default
return idx.get_setting(key, default)
def _set_setting(self, key: str, value) -> None:
"""Persist a library setting into the index (needs a configured pool)."""
idx = self._ensure_index()
if idx is None:
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
idx.set_setting(key, value)
@property
def copyasis_folders(self) -> list[str]:
"""Names of pool subfolders mirrored 1:1 (copy-as-is) into the output."""
return self.global_config.get("copyasis_folders", [POOL_SERIES])
return self._get_setting("copyasis_folders")
def set_copyasis_folders(self, names: list[str]) -> None:
"""Set the copy-as-is subfolder list and persist it."""
cleaned = [n.strip() for n in names if n.strip()]
self.global_config["copyasis_folders"] = cleaned
save_global_config(self.global_config)
self._set_setting("copyasis_folders", cleaned)
@property
def tag_schema(self) -> list[dict]:
"""Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA)."""
from src.core.config import DEFAULT_TAG_SCHEMA
return self.global_config.get("tag_schema", DEFAULT_TAG_SCHEMA)
return self._get_setting("tag_schema")
def set_tag_schema(self, schema: list[dict]) -> None:
"""Set the tag schema and persist it."""
self.global_config["tag_schema"] = schema
save_global_config(self.global_config)
self._set_setting("tag_schema", schema)
def filmoteka_category_roots(self) -> dict[str, str]:
"""Category → output root-folder map derived from the tag schema.
@@ -131,7 +182,7 @@ class FileManager:
if not (movies and movies.is_dir() and pool):
return
self.index = PoolIndex(pool)
self._open_index(pool)
for each in list_files(movies):
if each.name.endswith(METADATA_SUFFIXES):
continue
@@ -252,7 +303,7 @@ class FileManager:
movies.mkdir(parents=True, exist_ok=True)
if self.index is None:
self.index = PoolIndex(pool)
self._open_index(pool)
source = Path(source)
safe_title = title.strip() or source.stem
@@ -325,80 +376,6 @@ class FileManager:
self.on_files_changed(self.filelist)
return file_obj
def append(self, folder: Path) -> None:
"""Add a folder to scan for files"""
self.folders.append(folder)
self.current_folder = folder
# Update global config with last folder
self.global_config["last_folder"] = str(folder)
# Update recent folders list
recent = self.global_config.get("recent_folders", [])
folder_str = str(folder)
if folder_str in recent:
recent.remove(folder_str)
recent.insert(0, folder_str)
self.global_config["recent_folders"] = recent[:10] # Keep max 10
save_global_config(self.global_config)
# Load folder-specific config
folder_config = load_folder_config(folder)
self.folder_configs[folder] = folder_config
# Get ignore patterns from folder config
ignore_patterns = folder_config.get("ignore_patterns", [])
for each in list_files(folder):
# Skip all Curator metadata files (.!tag / .!ftag / .!gtag / .!index)
if each.name.endswith(METADATA_SUFFIXES):
continue
full_path = each.as_posix()
# Check against ignore patterns
if any(
fnmatch.fnmatch(each.name, pat) or fnmatch.fnmatch(full_path, pat)
for pat in ignore_patterns
):
continue
file_obj = File(each, self.tagmanager)
self.filelist.append(file_obj)
def get_folder_config(self, folder: Path = None) -> dict:
"""Get config for a folder (or current folder if not specified)"""
if folder is None:
folder = self.current_folder
if folder is None:
return {}
if folder not in self.folder_configs:
self.folder_configs[folder] = load_folder_config(folder)
return self.folder_configs[folder]
def save_folder_config(self, folder: Path = None, config: dict = None):
"""Save config for a folder"""
if folder is None:
folder = self.current_folder
if folder is None:
return
if config is None:
config = self.folder_configs.get(folder, {})
self.folder_configs[folder] = config
save_folder_config(folder, config)
def set_ignore_patterns(self, patterns: list[str], folder: Path = None):
"""Set ignore patterns for a folder"""
config = self.get_folder_config(folder)
config["ignore_patterns"] = patterns
self.save_folder_config(folder, config)
def get_ignore_patterns(self, folder: Path = None) -> list[str]:
"""Get ignore patterns for a folder"""
config = self.get_folder_config(folder)
return config.get("ignore_patterns", [])
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
for f in files_objs:
@@ -460,9 +437,3 @@ class FileManager:
if all(tag in file_tags for tag in target_full_paths):
filtered.append(f)
return filtered
# Legacy property for backwards compatibility
@property
def config(self):
"""Legacy: returns global config"""
return self.global_config
+26 -1
View File
@@ -5,6 +5,15 @@ Instead of one sidecar file per movie, the whole pool keeps a single JSON index
at ``<pool>/.Curator.!index``. Curator owns the pool (it inserts/removes files
itself), so files never move behind its back and a central index is safe.
The index also carries **library-level settings** (``tag_schema``,
``copyasis_folders``) under a ``settings`` section, so everything describing a
particular pool travels with the pool. The global config keeps only app data
(window state, MRU) and the pointers to where the pool/output live.
On-disk shape::
{"settings": {...}, "movies": {"Filmy/Matrix.mkv": {...}, ...}}
Records are keyed by the file's path relative to the pool root (POSIX form),
e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines.
"""
@@ -21,6 +30,7 @@ class PoolIndex:
self.pool_dir = Path(pool_dir)
self.index_path = self.pool_dir / INDEX_FILENAME
self.records: dict[str, dict] = {}
self.settings: dict = {}
self.load()
def _key(self, file_path: Path) -> str:
@@ -35,19 +45,34 @@ class PoolIndex:
"""Load the index from disk (missing/corrupt index = empty)."""
if not self.index_path.exists():
self.records = {}
self.settings = {}
return
try:
with open(self.index_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.records = data.get("movies", {})
self.settings = data.get("settings", {})
except (json.JSONDecodeError, OSError):
self.records = {}
self.settings = {}
def save(self) -> None:
"""Persist the index to disk."""
self.pool_dir.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f:
json.dump({"movies": self.records}, f, indent=2, ensure_ascii=False)
json.dump(
{"settings": self.settings, "movies": self.records},
f, indent=2, ensure_ascii=False,
)
def get_setting(self, key: str, default=None):
"""Return a library-level setting stored in the index."""
return self.settings.get(key, default)
def set_setting(self, key: str, value) -> None:
"""Upsert a library-level setting and persist the index."""
self.settings[key] = value
self.save()
def get(self, file_path: Path) -> dict | None:
"""Return the record for a file, or None if it is not indexed."""
-1296
View File
File diff suppressed because it is too large Load Diff