Store library settings in the pool index and drop legacy sidecars
This commit is contained in:
+18
-58
@@ -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
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user