266 lines
10 KiB
Python
266 lines
10 KiB
Python
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
|
|
# 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] = {}
|
|
self.get_metadata()
|
|
|
|
def get_metadata(self) -> None:
|
|
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._apply_record(record)
|
|
|
|
def _init_new_metadata(self) -> None:
|
|
self.new = True
|
|
self.ignored = False
|
|
self.tags = []
|
|
self.date = None
|
|
self.title = 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,
|
|
"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)
|
|
# 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 to the pool index; a no-op for an index-less File."""
|
|
if self.index is not None:
|
|
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.
|
|
|
|
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 delete_metadata(self) -> None:
|
|
"""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)
|
|
|
|
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 index key and rebinds to the new path;
|
|
call ``save_metadata()`` afterwards to write it back.
|
|
"""
|
|
if self.index is not None:
|
|
self.index.delete(self.file_path)
|
|
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() |