import re from pathlib import Path from .tag import Tag class File: def __init__(self, file_path: Path, tagmanager=None, index=None) -> None: self.file_path = file_path self.filename = file_path.name # 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 self.tags: list[Tag] = [] self.tagmanager = tagmanager # new: optional date string "YYYY-MM-DD" (assigned manually) self.date: str | None = None # movie-library fields self.title: str | None = None # Optional ČSFD film URL — a second database pointer kept alongside the # TMDb id (which lives in the filename). Stored only, not fetched. self.csfd_url: str | None = None # ISO timestamp of when the movie was added to the pool (set on import); # None for older/unknown → falls back to the file's ctime. self.added: str | None = None # Cached metadata used for folder grouping (year, rating) — filled from # the movie's NFO (which Jellyfin writes). self.meta: dict | None = None # full_paths of tags derived automatically from the NFO (vs. user-added); # only these are regenerated on refresh — user tags are preserved. self.auto_tag_paths: set[str] = set() # free-form per-movie attributes (e.g. "collection_sort"); usable in # miniDLNA filename templates via name_context. self.attributes: dict[str, str] = {} # Identity key of the movie record this file belongs to (set on load / # first save); ``None`` only for an index-less, in-memory File. self._movie_key: str | None = None self.get_metadata() def _identity_from_path(self) -> tuple[str, str, str | None, int | None, str]: """``(key, title, year, tmdb_id, label)`` implied by this file's location. A file inside its own ``Title (rok) [tmdbid]`` folder is one version of that movie — identity comes from the **folder**, and the rest of its own stem is the version label. A flat file carries both in its own name. """ from .movie import identity_key from .naming import ( is_canonical_pool_stem, parse_3d_format, parse_pool_stem, parse_tmdb_id, ) parent = self.file_path.parent.name grouped = is_canonical_pool_stem(parent) identity_stem = parent if grouped else self.file_path.stem parsed = parse_pool_stem(identity_stem) year = str(parsed[1]) if parsed else None tmdb_id = parse_tmdb_id(identity_stem) title = self.title or (parsed[0] if parsed else self.file_path.stem) label = "" part = ( re.fullmatch( rf"{re.escape(parent)}[\s._-]*(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{{1,2}})", self.file_path.stem, re.IGNORECASE, ) if grouped else None ) if part: # one part of a film split across files: "-part2" label = f"part{int(part.group(1))}" elif grouped and self.file_path.stem.startswith(f"{parent} - "): label = self.file_path.stem[len(parent) + 3 :] elif parse_3d_format(self.file_path.stem): label = parse_3d_format(self.file_path.stem) or "" return identity_key(tmdb_id, title, year), title, year, tmdb_id, label def get_metadata(self) -> None: """Load this file's metadata from the movie record that owns it. Since the pool index became identity-keyed (see :mod:`src.core.movie`), a File is a *view* on one media entry of a movie record — every version of the same film therefore reads the same title/tags/ČSFD/… A file the index doesn't know yet joins the movie of the same identity when there is one (that's how a newly added version inherits the shared metadata instead of blanking it), otherwise it starts a new record. """ if self.index is None: self._init_new_metadata() return found = self.index.find_movie_by_media(self.file_path) if found is not None: self._movie_key, record = found self._apply_record(record) return self._init_new_metadata() key, *_ = self._identity_from_path() existing = self.index.get_movie(key) if existing is not None: self._movie_key = key self._apply_record(existing) self.save_metadata() def _init_new_metadata(self) -> None: self.new = True self.ignored = False self.tags = [] self.date = None self.title = None self.csfd_url = None self.meta = None self.auto_tag_paths = set() self.attributes = {} self.added = None def _build_record(self) -> dict: data = { "new": self.new, "ignored": self.ignored, # ukládáme full_path tagů "tags": [ tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags ], # which of the tags were derived automatically (from the NFO) "auto_tags": sorted(self.auto_tag_paths), # date může být None "date": self.date, "title": self.title, "csfd_url": self.csfd_url, "attributes": self.attributes, "added": self.added, } if self.meta is not None: data["meta"] = dict(self.meta) return data def _apply_record(self, data: dict) -> None: self.new = data.get("new", True) self.ignored = data.get("ignored", False) self.tags = [] self.date = data.get("date", None) self.title = data.get("title", None) self.csfd_url = data.get("csfd_url", None) # provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", []))) self.attributes = dict(data.get("attributes", {})) self.added = data.get("added", None) raw_meta = data.get("meta") if raw_meta is None: # legacy: pull year/rating out of the old csfd cache old = data.get("csfd_cache") or {} raw_meta = ( {"year": old.get("year"), "rating": old.get("rating")} if old else None ) self.meta = dict(raw_meta) if raw_meta else None if not self.tagmanager: return for tag_str in data.get("tags", []): if "/" in tag_str: category, name = tag_str.split("/", 1) tag = self.tagmanager.add_tag(category, name) self.tags.append(tag) def save_metadata(self): """Persist metadata into this file's movie record (no-op without index). Writes the shared fields onto the movie record and makes sure the record lists this file among its media. A file the index has never seen starts a new record under its own identity key. """ if self.index is None: return from .movie import ROLE_VIDEO derived_key, title, year, tmdb_id, label = self._identity_from_path() key = self._movie_key or derived_key record = self.index.get_movie(key) or {} record.update(self._build_record()) # The file's actual location is the truth — a move must update this. record["library"] = self._library_name() record["title"] = title record["year"] = year record["tmdb_id"] = tmdb_id # Keep this file listed exactly once, with the label its name implies. rel = self.index._key(self.file_path) media = [m for m in record.get("media", []) if m.get("file") != rel] media.append({"role": ROLE_VIDEO, "label": label, "file": rel}) record["media"] = media self.index.set_movie(key, record) self._movie_key = key def _library_name(self) -> str: """Top-level pool folder this file sits in (its library), or ``""``.""" if self.index is None: return "" rel = self.index._key(self.file_path) parts = Path(rel).parts return parts[0] if len(parts) > 1 else "" def added_timestamp(self) -> float: """Epoch seconds for 'date added' — the stored ``added`` or the file mtime. Used to order the "recently added" folder (newer = larger). The fallback is **mtime**, not ctime: creating the Filmotéka hardlinks bumps the source inode's ctime, which would make ctime-based ordering meaningless, whereas mtime (preserved from the source on import) stays stable. """ if self.added: try: from datetime import datetime return datetime.fromisoformat(self.added).timestamp() except ValueError: pass try: return self.file_path.stat().st_mtime except OSError: return 0.0 def name_context(self) -> dict: """Fields for a miniDLNA filename template (see config tag schema). Provides title / year / rating / ext / stem / filename from the movie's metadata (year & rating come from the NFO cache, year falling back to a ``Rok`` tag, then the canonical filename). Missing values render empty. """ cache = self.meta or {} year = cache.get("year") if year is None: for t in self.tags: if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit(): year = t.name break if year is None: # Last resort: recover it from a canonical "Title (YYYY)" filename. from .naming import parse_pool_stem parsed = parse_pool_stem(self.file_path.stem) if parsed is not None: year = parsed[1] rating = cache.get("rating") # user attributes first; core fields take precedence over same-named keys context = dict(self.attributes) context.update( { "title": self.title or self.file_path.stem, "year": "" if year is None else str(year), "rating": "" if rating is None else rating, "ext": self.file_path.suffix, "stem": self.file_path.stem, "filename": self.filename, } ) return context def set_attribute(self, key: str, value: str | None) -> None: """Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it.""" key = key.strip() if not key: return if value: self.attributes[key] = value else: self.attributes.pop(key, None) self.save_metadata() def save_location(self) -> None: """Persist a rename/move of this file without touching shared metadata. Every version of a film holds its own in-memory copy of the movie record's shared fields (title, tags, ČSFD, …). A sibling loaded before someone changed those fields would write its stale copy back on a plain :meth:`save_metadata`. Re-reading the record first means only this file's own media entry — path and popisek — actually changes. """ if self.index is not None and self.index.find_movie_by_media(self.file_path): self.get_metadata() self.save_metadata() def delete_metadata(self) -> None: """Drop this file from its movie record; the last one takes the record. Removing one version of a multi-version movie leaves the movie (and its metadata) in place — only when its last media entry goes does the whole record go with it. """ if self.index is None: return found = self.index.find_movie_by_media(self.file_path) if found is None: return key, record = found rel = self.index._key(self.file_path) record["media"] = [m for m in record.get("media", []) if m.get("file") != rel] if record["media"]: self.index.set_movie(key, record) else: self.index.delete_movie(key) def relocate(self, new_path: Path) -> None: """Point this File at a new path, moving its media entry along. The physical file must already have been moved/renamed by the caller; this only repoints the movie record's media entry (and the File), so a rename never detaches a file from its movie. """ if self.index is not None: found = self.index.find_movie_by_media(self.file_path) if found is not None: key, record = found old_rel = self.index._key(self.file_path) new_rel = self.index._key(Path(new_path)) for media in record.get("media", []): if media.get("file") == old_rel: media["file"] = new_rel self.index.set_movie(key, record) self._movie_key = key self.file_path = Path(new_path) self.filename = self.file_path.name def set_date(self, date_str: str | None): """Nastaví datum (např. '2025-09-25') nebo None pro smazání.""" if date_str is None or date_str == "": self.date = None else: # neprvádíme složitou validaci zde; očekáváme 'YYYY-MM-DD' self.date = date_str self.save_metadata() def apply_nfo(self, nfo, schema: list[dict] | None = None) -> dict: """Regenerate the auto (NFO-derived) tags from the movie's NFO. Reads genres/year/countries/rating from ``nfo`` (``nfo.NfoData``) per the tag schema's source fields and (re)creates those tags; user-added tags are kept. Also caches year/rating (for folder grouping) and fills the title when missing. ``nfo`` of ``None`` (no NFO yet) is a no-op. Returns: dict with 'success' and 'tags_added'. """ if nfo is None: return {"success": False, "error": "NFO nenalezeno", "tags_added": []} if schema is None: from .config import DEFAULT_TAG_SCHEMA schema = DEFAULT_TAG_SCHEMA self.meta = {"year": nfo.year, "rating": nfo.rating} # Drop previous auto (NFO) tags; keep user-added ones. self.tags = [t for t in self.tags if t.full_path not in self.auto_tag_paths] self.auto_tag_paths = set() tags_added: list[str] = [] for entry in schema: field = entry.get("csfd_field") # schema key = source field name if not field: continue # user-only category category = entry["category"] for value in nfo.field_values(field): tag_obj = ( self.tagmanager.add_tag(category, value) if self.tagmanager else Tag(category, value) ) if tag_obj not in self.tags: self.tags.append(tag_obj) tags_added.append(tag_obj.full_path) self.auto_tag_paths.add(tag_obj.full_path) if nfo.title and not self.title: self.title = nfo.title self.save_metadata() return {"success": True, "tags_added": tags_added} def add_tag(self, tag): # tag může být Tag nebo string from .tag import Tag as TagClass if isinstance(tag, str): if "/" in tag and self.tagmanager: category, name = tag.split("/", 1) tag_obj = self.tagmanager.add_tag(category, name) else: tag_obj = TagClass("default", tag) elif isinstance(tag, TagClass): tag_obj = tag else: return if tag_obj not in self.tags: self.tags.append(tag_obj) self.save_metadata() def remove_tag(self, tag): # tag může být Tag nebo string (full_path) if isinstance(tag, str): if "/" in tag: category, name = tag.split("/", 1) tag_obj = Tag(category, name) else: tag_obj = Tag("default", tag) elif isinstance(tag, Tag): tag_obj = tag else: return if tag_obj in self.tags: self.tags.remove(tag_obj) self.save_metadata()