469 lines
17 KiB
Python
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
|