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 ``.`` 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