Files
Curator/src/core/file_manager.py
T

469 lines
17 KiB
Python

from pathlib import Path
from datetime import datetime
from collections import Counter
import shutil
from .file import File
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
)
# Top-level folders inside the managed pool
POOL_MOVIES = "Filmy"
POOL_SERIES = "Seriály"
# Curator metadata files that must never be treated as content
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
# ------------------------------------------------------------------
# Pool (single source of truth) and Filmotéka (generated output)
# ------------------------------------------------------------------
@property
def pool_dir(self) -> Path | None:
value = self.global_config.get("pool_dir")
return Path(value) if value else None
@property
def movies_dir(self) -> Path | None:
pool = self.pool_dir
return pool / POOL_MOVIES if pool else None
@property
def series_dir(self) -> Path | None:
pool = self.pool_dir
return pool / POOL_SERIES if pool else None
@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])
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)
@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)
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)
def filmoteka_category_roots(self) -> dict[str, str]:
"""Category → output root-folder map derived from the tag schema.
Categories with ``filmoteka_root`` set to None are filterable but get no
folders in the generated tree.
"""
return {
e["category"]: e["filmoteka_root"]
for e in self.tag_schema
if e.get("filmoteka_root") is not None
}
def filmoteka_category_transforms(self) -> dict[str, str]:
"""Category → folder transform-name map (for grouping, e.g. rating bands).
Tags keep their exact value; this transform only shapes the folder name
in the generated tree. Categories without a transform are omitted.
"""
return {
e["category"]: e["transform"]
for e in self.tag_schema
if e.get("filmoteka_root") is not None and e.get("transform")
}
def filmoteka_category_filename_templates(self) -> dict[str, str]:
"""Category → hardlink-name template map (applied inside that category)."""
return {
e["category"]: e["filename_template"]
for e in self.tag_schema
if e.get("filmoteka_root") is not None and e.get("filename_template")
}
@property
def filmoteka_dir(self) -> Path | None:
value = self.global_config.get("filmoteka_dir")
return Path(value) if value else None
def set_pool_dir(self, pool_dir: Path) -> None:
"""Set the managed pool root and create its top-level folders."""
pool_dir = Path(pool_dir)
(pool_dir / POOL_MOVIES).mkdir(parents=True, exist_ok=True)
(pool_dir / POOL_SERIES).mkdir(parents=True, exist_ok=True)
self.global_config["pool_dir"] = str(pool_dir)
save_global_config(self.global_config)
def set_filmoteka_dir(self, filmoteka_dir: Path) -> None:
"""Set the Filmotéka output folder (generated hardlink tree)."""
self.global_config["filmoteka_dir"] = str(Path(filmoteka_dir))
save_global_config(self.global_config)
def load_pool_movies(self) -> None:
"""Reload the movie list from pool/Filmy using the unified pool index."""
self.filelist = []
movies = self.movies_dir
pool = self.pool_dir
if not (movies and movies.is_dir() and pool):
return
self.index = PoolIndex(pool)
for each in list_files(movies):
if each.name.endswith(METADATA_SUFFIXES):
continue
file_obj = File(each, self.tagmanager, index=self.index)
self.filelist.append(file_obj)
def check_data_consistency(self) -> dict:
"""Compare the pool index against the actual files in pool/Filmy.
Detects drift such as a movie file deleted directly (metadata left behind)
or a file dropped into the pool without importing (no metadata).
Returns a dict with:
ok — False if the pool isn't set (with 'error')
missing — index keys whose file no longer exists on disk
untracked — pool files (pool-relative) with no index entry
index_count / disk_count — totals for the summary
"""
pool = self.pool_dir
movies = self.movies_dir
if not (pool and movies and movies.is_dir()):
return {"ok": False, "error": "Pool není nastaven.",
"missing": [], "untracked": []}
index = self.index or PoolIndex(pool)
index_keys = set(index.records.keys())
disk_keys = set()
for f in list_files(movies):
if f.name.endswith(METADATA_SUFFIXES):
continue
disk_keys.add(f.relative_to(pool).as_posix())
missing = sorted(k for k in index_keys if not (pool / k).is_file())
untracked = sorted(disk_keys - index_keys)
return {
"ok": True,
"missing": missing,
"untracked": untracked,
"index_count": len(index_keys),
"disk_count": len(disk_keys),
}
def statistics(self) -> dict:
"""Aggregate stats over the loaded pool movies for the stats view.
Returns a dict with totals and, per tag category, the tag counts sorted
by frequency (descending).
"""
files = self.filelist
total_size = 0
with_csfd = 0
untagged = 0
ratings: list[int] = []
categories: dict[str, Counter] = {}
for f in files:
try:
total_size += f.file_path.stat().st_size
except OSError:
pass
if f.csfd_link:
with_csfd += 1
if not f.tags:
untagged += 1
for tag in f.tags:
categories.setdefault(tag.category, Counter())[tag.name] += 1
rating = (f.csfd_cache or {}).get("rating")
if isinstance(rating, (int, float)):
ratings.append(rating)
return {
"count": len(files),
"total_size": total_size,
"with_csfd": with_csfd,
"without_csfd": len(files) - with_csfd,
"untagged": untagged,
"avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None,
"categories": {
cat: counter.most_common() for cat, counter in categories.items()
},
}
def pooled_with_stem(self, title: str) -> list[File]:
"""Pooled movies whose filename stem matches ``title`` (case-insensitive)."""
stem = title.strip().lower()
return [f for f in self.filelist if Path(f.filename).stem.lower() == stem]
def _evict(self, file_obj: File) -> None:
"""Delete a pooled movie: its metadata, the file, and the list entry."""
file_obj.delete_metadata()
if file_obj.file_path.exists():
file_obj.file_path.unlink()
if file_obj in self.filelist:
self.filelist.remove(file_obj)
def import_movie(
self, source: Path, title: str, csfd_link: str | None = None,
move: bool = False, on_conflict: str = "suffix",
) -> File | None:
"""Bring a video file into pool/Filmy as 'Title.ext' and index its metadata.
By default the original is **copied** (non-destructive). With ``move=True``
the source file is moved into the pool instead, leaving nothing behind.
``on_conflict`` decides what happens when a pooled movie of the same name
already exists:
- ``"suffix"`` (default): keep both, the new file gets a ``_N`` suffix.
- ``"replace"``: evict the existing same-named movie(s) (file + metadata)
and import the new one under the plain name.
- ``"skip"``: do not import; return ``None``.
"""
movies = self.movies_dir
pool = self.pool_dir
if movies is None or pool is None:
raise RuntimeError("Pool není nastaven.")
movies.mkdir(parents=True, exist_ok=True)
if self.index is None:
self.index = PoolIndex(pool)
source = Path(source)
safe_title = title.strip() or source.stem
target = movies / f"{safe_title}{source.suffix}"
existing = self.pooled_with_stem(safe_title)
conflict = bool(existing) or target.exists()
if conflict and on_conflict == "skip":
return None
if conflict and on_conflict == "replace":
for f in existing:
self._evict(f)
if target.exists():
target.unlink()
else:
# "suffix": never clobber an existing exact filename
counter = 1
while target.exists():
target = movies / f"{safe_title}_{counter}{source.suffix}"
counter += 1
if move:
shutil.move(str(source), str(target))
else:
shutil.copy2(source, target)
file_obj = File(target, self.tagmanager, index=self.index)
file_obj.title = safe_title
file_obj.csfd_link = csfd_link or None
file_obj.added = datetime.now().isoformat(timespec="seconds")
file_obj.save_metadata()
self.filelist.append(file_obj)
if self.on_files_changed:
self.on_files_changed(self.filelist)
return file_obj
def rename_movie(self, file_obj: File, new_title: str) -> File:
"""Rename a pooled movie's file to ``<new_title>.<ext>`` and reindex it.
Renames the physical file in pool/Filmy (keeping its extension), moves
the metadata to the new key, and syncs ``title``/``filename``. The
extension is preserved; ``new_title`` is the bare name without it.
Raises:
ValueError: empty name or a name containing a path separator.
FileExistsError: another pooled file already uses that name.
"""
new_title = new_title.strip()
if not new_title:
raise ValueError("Název nesmí být prázdný.")
if "/" in new_title or "\\" in new_title:
raise ValueError("Název nesmí obsahovat lomítka.")
old_path = file_obj.file_path
new_path = old_path.with_name(f"{new_title}{old_path.suffix}")
if new_path == old_path:
return file_obj # no change
if new_path.exists():
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
old_path.rename(new_path)
file_obj.relocate(new_path)
file_obj.title = new_title
file_obj.save_metadata()
if self.on_files_changed:
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:
if isinstance(tag, str):
if "/" in tag:
category, name = tag.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
else:
tag_obj = self.tagmanager.add_tag("default", tag)
else:
tag_obj = tag
if tag_obj not in f.tags:
f.tags.append(tag_obj)
f.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)
def remove_tag_from_file_objects(self, files_objs: list[File], tag):
"""Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů."""
for f in files_objs:
if isinstance(tag, str):
if "/" in tag:
category, name = tag.split("/", 1)
from .tag import Tag as TagClass
tag_obj = TagClass(category, name)
else:
from .tag import Tag as TagClass
tag_obj = TagClass("default", tag)
else:
tag_obj = tag
if tag_obj in f.tags:
f.tags.remove(tag_obj)
f.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)
def filter_files_by_tags(self, tags: Iterable):
"""
Vrátí jen soubory, které obsahují všechny zadané tagy.
'tags' může být iterace Tag objektů nebo stringů 'category/name'.
"""
tags_list = list(tags) if tags is not None else []
if not tags_list:
return self.filelist
target_full_paths = set()
from .tag import Tag as TagClass
for t in tags_list:
if isinstance(t, TagClass):
target_full_paths.add(t.full_path)
elif isinstance(t, str):
target_full_paths.add(t)
else:
continue
filtered = []
for f in self.filelist:
file_tags = {t.full_path for t in f.tags}
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