Rework Curator into a Jellyfin-first library manager
This commit is contained in:
+308
-40
@@ -7,7 +7,7 @@ from .tag_manager import TagManager
|
||||
from .pool_index import PoolIndex
|
||||
from .utils import list_files
|
||||
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
|
||||
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem
|
||||
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem, parse_tmdb_id
|
||||
from typing import Callable, Iterable
|
||||
from src.core.config import (
|
||||
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
||||
@@ -21,6 +21,9 @@ POOL_SERIES = "Seriály"
|
||||
_LIBRARY_SETTINGS_DEFAULTS = {
|
||||
"tag_schema": DEFAULT_TAG_SCHEMA,
|
||||
"copyasis_folders": [POOL_SERIES],
|
||||
# Top-level pool folders that hold movies — each is a Jellyfin library
|
||||
# (physical partition). "Filmy" is the default; users add Kids/Dokumenty/…
|
||||
"movie_libraries": [POOL_MOVIES],
|
||||
}
|
||||
|
||||
# Curator metadata files that must never be treated as content. The legacy
|
||||
@@ -28,6 +31,13 @@ _LIBRARY_SETTINGS_DEFAULTS = {
|
||||
# stray ones are still skipped so they never leak into the Filmotéka.
|
||||
METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index")
|
||||
|
||||
# Sidecars Jellyfin writes next to a movie (NFO + artwork/subtitles). These are
|
||||
# not movies and must be skipped when scanning a library.
|
||||
SIDECAR_SUFFIXES = (
|
||||
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn",
|
||||
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt",
|
||||
)
|
||||
|
||||
|
||||
class FileManager:
|
||||
def __init__(self, tagmanager: TagManager):
|
||||
@@ -117,6 +127,27 @@ class FileManager:
|
||||
cleaned = [n.strip() for n in names if n.strip()]
|
||||
self._set_setting("copyasis_folders", cleaned)
|
||||
|
||||
@property
|
||||
def movie_libraries(self) -> list[str]:
|
||||
"""Top-level pool folders holding movies (each a Jellyfin library)."""
|
||||
libs = self._get_setting("movie_libraries")
|
||||
return libs if libs else [POOL_MOVIES]
|
||||
|
||||
def set_movie_libraries(self, names: list[str]) -> None:
|
||||
"""Set the movie-library list (never empty — falls back to Filmy)."""
|
||||
cleaned = [n.strip() for n in names if n.strip()]
|
||||
self._set_setting("movie_libraries", cleaned or [POOL_MOVIES])
|
||||
|
||||
def library_of(self, file_obj: File) -> str | None:
|
||||
"""Which library (top-level pool folder) a pooled movie lives in."""
|
||||
pool = self.pool_dir
|
||||
if not pool:
|
||||
return None
|
||||
try:
|
||||
return file_obj.file_path.relative_to(pool).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
@property
|
||||
def tag_schema(self) -> list[dict]:
|
||||
"""Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA)."""
|
||||
@@ -164,12 +195,13 @@ class FileManager:
|
||||
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."""
|
||||
"""Set the managed pool root and create every library's top-level folder."""
|
||||
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)
|
||||
self._open_index(pool_dir) # settings now readable through the index
|
||||
for name in self.movie_libraries + self.copyasis_folders:
|
||||
(pool_dir / name).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def set_filmoteka_dir(self, filmoteka_dir: Path) -> None:
|
||||
"""Set the Filmotéka output folder (generated hardlink tree)."""
|
||||
@@ -177,22 +209,33 @@ class FileManager:
|
||||
save_global_config(self.global_config)
|
||||
|
||||
def load_pool_movies(self) -> None:
|
||||
"""Reload the movie list from pool/Filmy using the unified pool index."""
|
||||
"""Reload the movie list from every movie library, via the pool index.
|
||||
|
||||
Scans each configured movie library (``movie_libraries``) recursively —
|
||||
a movie may sit directly in the library or inside its own per-movie
|
||||
folder. Curator's own metadata and the sidecars Jellyfin writes (NFO,
|
||||
artwork, subtitles) are skipped.
|
||||
"""
|
||||
self.filelist = []
|
||||
movies = self.movies_dir
|
||||
pool = self.pool_dir
|
||||
if not (movies and movies.is_dir() and pool):
|
||||
if not pool:
|
||||
return
|
||||
|
||||
self._open_index(pool)
|
||||
for each in list_files(movies):
|
||||
if each.name.endswith(METADATA_SUFFIXES):
|
||||
for library in self.movie_libraries:
|
||||
lib_dir = pool / library
|
||||
if not lib_dir.is_dir():
|
||||
continue
|
||||
file_obj = File(each, self.tagmanager, index=self.index)
|
||||
self.filelist.append(file_obj)
|
||||
for each in list_files(lib_dir):
|
||||
if each.name.endswith(METADATA_SUFFIXES):
|
||||
continue
|
||||
if each.suffix.lower() in SIDECAR_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.
|
||||
"""Compare the pool index against the actual files in the movie libraries.
|
||||
|
||||
Detects drift such as a movie file deleted directly (metadata left behind)
|
||||
or a file dropped into the pool without importing (no metadata).
|
||||
@@ -204,8 +247,7 @@ class FileManager:
|
||||
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()):
|
||||
if not (pool and pool.is_dir()):
|
||||
return {"ok": False, "error": "Pool není nastaven.",
|
||||
"missing": [], "untracked": []}
|
||||
|
||||
@@ -213,10 +255,16 @@ class FileManager:
|
||||
index_keys = set(index.records.keys())
|
||||
|
||||
disk_keys = set()
|
||||
for f in list_files(movies):
|
||||
if f.name.endswith(METADATA_SUFFIXES):
|
||||
for library in self.movie_libraries:
|
||||
lib_dir = pool / library
|
||||
if not lib_dir.is_dir():
|
||||
continue
|
||||
disk_keys.add(f.relative_to(pool).as_posix())
|
||||
for f in list_files(lib_dir):
|
||||
if f.name.endswith(METADATA_SUFFIXES):
|
||||
continue
|
||||
if f.suffix.lower() in SIDECAR_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)
|
||||
@@ -228,6 +276,51 @@ class FileManager:
|
||||
"disk_count": len(disk_keys),
|
||||
}
|
||||
|
||||
def verify_against_jellyfin(self, jf_movies: list) -> dict:
|
||||
"""Compare the pool against what Jellyfin currently sees.
|
||||
|
||||
Matches each pooled movie to a Jellyfin item by **TMDb id** (from the
|
||||
filename), falling back to (title, year). Reports what is out of sync.
|
||||
|
||||
``jf_movies`` is a list of ``jellyfin.JellyfinMovie``. Returns a dict:
|
||||
matched — how many pooled movies Jellyfin has
|
||||
pool_only — pooled filenames Jellyfin does not see (rescan/rematch)
|
||||
jellyfin_only — Jellyfin titles with no matching pooled movie
|
||||
pool_total / jellyfin_total — totals for the summary
|
||||
"""
|
||||
by_tmdb = {m.tmdb_id: m for m in jf_movies if m.tmdb_id}
|
||||
by_name = {(m.title.strip().lower(), m.year): m for m in jf_movies if m.title}
|
||||
|
||||
matched_ids: set = set()
|
||||
pool_only: list[str] = []
|
||||
for f in self.filelist:
|
||||
tmdb_id = parse_tmdb_id(f.file_path.stem)
|
||||
ctx = f.name_context()
|
||||
title = str(f.title or ctx.get("title") or "").strip().lower()
|
||||
year_str = str(ctx.get("year") or "")
|
||||
year = int(year_str) if year_str.isdigit() else None
|
||||
|
||||
match = None
|
||||
if tmdb_id is not None and tmdb_id in by_tmdb:
|
||||
match = by_tmdb[tmdb_id]
|
||||
elif (title, year) in by_name:
|
||||
match = by_name[(title, year)]
|
||||
|
||||
if match is not None:
|
||||
matched_ids.add(match.jellyfin_id)
|
||||
else:
|
||||
pool_only.append(f.filename)
|
||||
|
||||
jellyfin_only = sorted(
|
||||
m.title for m in jf_movies if m.jellyfin_id not in matched_ids)
|
||||
return {
|
||||
"matched": len(self.filelist) - len(pool_only),
|
||||
"pool_only": sorted(pool_only),
|
||||
"jellyfin_only": jellyfin_only,
|
||||
"pool_total": len(self.filelist),
|
||||
"jellyfin_total": len(jf_movies),
|
||||
}
|
||||
|
||||
def scan_video_integrity(
|
||||
self,
|
||||
files: list[File],
|
||||
@@ -279,7 +372,7 @@ class FileManager:
|
||||
"""
|
||||
files = self.filelist
|
||||
total_size = 0
|
||||
with_csfd = 0
|
||||
with_nfo = 0
|
||||
untagged = 0
|
||||
ratings: list[int] = []
|
||||
categories: dict[str, Counter] = {}
|
||||
@@ -289,21 +382,21 @@ class FileManager:
|
||||
total_size += f.file_path.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
if f.csfd_link:
|
||||
with_csfd += 1
|
||||
if f.meta is not None:
|
||||
with_nfo += 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")
|
||||
rating = (f.meta 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,
|
||||
"with_nfo": with_nfo,
|
||||
"without_nfo": len(files) - with_nfo,
|
||||
"untagged": untagged,
|
||||
"avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None,
|
||||
"categories": {
|
||||
@@ -325,16 +418,19 @@ class FileManager:
|
||||
self.filelist.remove(file_obj)
|
||||
|
||||
def import_movie(
|
||||
self, source: Path, title: str, csfd_link: str | None = None,
|
||||
self, source: Path, title: str,
|
||||
move: bool = False, on_conflict: str = "suffix",
|
||||
year: int | str | None = None,
|
||||
year: int | str | None = None, tmdb_id: int | str | None = None,
|
||||
library: str | None = None,
|
||||
) -> File | None:
|
||||
"""Bring a video file into pool/Filmy and index its metadata.
|
||||
"""Bring a video file into a movie library and index its metadata.
|
||||
|
||||
The pooled filename follows the naming convention ``Title (YYYY).ext``
|
||||
when a ``year`` is given (validated to four digits); without a year it
|
||||
Lands in ``pool/<library>/`` (``library`` defaults to the first movie
|
||||
library, "Filmy"). The pooled filename follows the naming convention
|
||||
``Title (YYYY) [tmdbid-N].ext`` when a ``year`` is given (and a
|
||||
``tmdb_id`` when known — the form Jellyfin matches on); without a year it
|
||||
falls back to ``Title.ext``. The GUI import always supplies a year, so a
|
||||
library built through the app is uniformly ``Title (YYYY).ext``.
|
||||
library built through the app is uniformly ``Title (YYYY) [tmdbid-N].ext``.
|
||||
|
||||
By default the original is **copied** (non-destructive). With ``move=True``
|
||||
the source file is moved into the pool instead, leaving nothing behind.
|
||||
@@ -350,10 +446,11 @@ class FileManager:
|
||||
Raises:
|
||||
ValueError: a ``year`` was given but is not a four-digit number.
|
||||
"""
|
||||
movies = self.movies_dir
|
||||
pool = self.pool_dir
|
||||
if movies is None or pool is None:
|
||||
if pool is None:
|
||||
raise RuntimeError("Pool není nastaven.")
|
||||
lib = (library or "").strip() or self.movie_libraries[0]
|
||||
movies = pool / lib
|
||||
movies.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self.index is None:
|
||||
@@ -362,7 +459,8 @@ class FileManager:
|
||||
source = Path(source)
|
||||
safe_title = title.strip() or source.stem
|
||||
has_year = year is not None and str(year).strip() != ""
|
||||
stem = canonical_pool_stem(safe_title, year) if has_year else safe_title
|
||||
stem = (canonical_pool_stem(safe_title, year, tmdb_id) if has_year
|
||||
else safe_title)
|
||||
target = movies / f"{stem}{source.suffix}"
|
||||
|
||||
existing = self.pooled_with_stem(stem)
|
||||
@@ -389,7 +487,6 @@ class FileManager:
|
||||
|
||||
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()
|
||||
|
||||
@@ -433,12 +530,13 @@ class FileManager:
|
||||
return file_obj
|
||||
|
||||
def _canonical_name_for(self, file_obj: File) -> str | None:
|
||||
"""The ``Title (YYYY)`` stem this movie should have, or ``None``.
|
||||
"""The ``Title (YYYY) [tmdbid-N]`` stem this movie should have, or ``None``.
|
||||
|
||||
Uses the clean title and the year from the movie's metadata
|
||||
(``name_context``: ČSFD cache → ``Rok`` tag → the current filename). A
|
||||
``(YYYY)`` that leaked into the title is stripped so it isn't doubled.
|
||||
Returns ``None`` when no four-digit year is known.
|
||||
(``name_context``: cache → ``Rok`` tag → the current filename). A
|
||||
``(YYYY)`` that leaked into the title is stripped so it isn't doubled, and
|
||||
an existing ``[tmdbid-N]`` on the current filename is preserved. Returns
|
||||
``None`` when no four-digit year is known.
|
||||
"""
|
||||
ctx = file_obj.name_context()
|
||||
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
|
||||
@@ -449,16 +547,19 @@ class FileManager:
|
||||
title = parsed[0]
|
||||
year = year or str(parsed[1])
|
||||
|
||||
# Keep the TMDb id already in the pool filename, if any.
|
||||
tmdb_id = parse_tmdb_id(file_obj.file_path.stem)
|
||||
|
||||
title = title.replace("/", "-").replace("\\", "-").strip()
|
||||
if not (title and is_valid_year(year)):
|
||||
return None
|
||||
return canonical_pool_stem(title, year)
|
||||
return canonical_pool_stem(title, year, tmdb_id)
|
||||
|
||||
def rename_to_canonical(self, file_obj: File) -> File | None:
|
||||
"""Rename a pooled movie to ``Title (YYYY).ext`` from its metadata.
|
||||
|
||||
The year comes from the movie's ČSFD metadata (falling back to a ``Rok``
|
||||
tag or the existing filename). ``file_obj.title`` keeps the clean title
|
||||
The year comes from the movie's metadata (NFO cache, falling back to a
|
||||
``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title
|
||||
(without the year). Does not fire ``on_files_changed`` — the caller
|
||||
refreshes once after a batch.
|
||||
|
||||
@@ -537,6 +638,173 @@ class FileManager:
|
||||
"conflicts": conflicts, "unchanged": unchanged,
|
||||
"cancelled": cancelled}
|
||||
|
||||
def move_to_library(self, file_obj: File, new_library: str) -> File:
|
||||
"""Move a pooled movie into another library (top-level pool folder).
|
||||
|
||||
Moves the file into ``pool/<new_library>/`` and re-indexes it under the
|
||||
new key. Raises:
|
||||
ValueError: the target isn't a known movie library.
|
||||
FileExistsError: a file of that name already sits in the target.
|
||||
"""
|
||||
pool = self.pool_dir
|
||||
if pool is None:
|
||||
raise RuntimeError("Pool není nastaven.")
|
||||
new_library = new_library.strip()
|
||||
if new_library not in self.movie_libraries:
|
||||
raise ValueError(f"Neznámá knihovna „{new_library}“.")
|
||||
|
||||
old_path = file_obj.file_path
|
||||
dest_dir = pool / new_library
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
new_path = dest_dir / old_path.name
|
||||
if new_path == old_path:
|
||||
return file_obj # already there
|
||||
if new_path.exists():
|
||||
raise FileExistsError(f"V knihovně „{new_library}“ už „{old_path.name}“ je.")
|
||||
|
||||
shutil.move(str(old_path), str(new_path))
|
||||
file_obj.relocate(new_path) # drops the old index key, rebinds to the new
|
||||
file_obj.save_metadata()
|
||||
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
return file_obj
|
||||
|
||||
def refresh_tags_from_nfo(self, files: list[File] | None = None) -> dict:
|
||||
"""(Re)derive each movie's auto tags from its NFO (written by Jellyfin).
|
||||
|
||||
Reads the ``.nfo`` next to each movie and regenerates the genre / year /
|
||||
country / rating tags per the tag schema (`File.apply_nfo`); user-added
|
||||
tags are preserved. Movies without an NFO yet are left untouched.
|
||||
|
||||
Returns ``{updated, without_nfo}``.
|
||||
"""
|
||||
from .nfo import read_nfo
|
||||
targets = files if files is not None else self.filelist
|
||||
schema = self.tag_schema
|
||||
updated, without_nfo = 0, 0
|
||||
for f in targets:
|
||||
nfo = read_nfo(f.file_path)
|
||||
if nfo is None:
|
||||
without_nfo += 1
|
||||
continue
|
||||
f.apply_nfo(nfo, schema)
|
||||
updated += 1
|
||||
if updated and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
return {"updated": updated, "without_nfo": without_nfo}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Series (Jellyfin TV layout: Series/Season NN/Series SxxEyy.ext)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def series_libraries(self) -> list[str]:
|
||||
"""Pool folders that hold series (the copy-as-is mirrors, e.g. Seriály)."""
|
||||
return self.copyasis_folders
|
||||
|
||||
def load_series(self) -> list:
|
||||
"""Scan every series library into a list of ``series.Series``."""
|
||||
from .series import scan_series
|
||||
pool = self.pool_dir
|
||||
if not pool:
|
||||
return []
|
||||
result: list = []
|
||||
for library in self.series_libraries:
|
||||
result.extend(scan_series(pool / library, library))
|
||||
return result
|
||||
|
||||
def import_episodes(
|
||||
self, sources: list[Path], title: str, year: int | str | None = None,
|
||||
tmdb_id: int | str | None = None, library: str | None = None,
|
||||
move: bool = False, on_conflict: str = "suffix",
|
||||
) -> dict:
|
||||
"""Import episode files into ``<library>/Series (rok) [tmdbid]/Season NN/``.
|
||||
|
||||
Each source's season/episode is parsed from its filename and it is copied
|
||||
(or moved) in as ``Series SxxEyy.ext``. Files with no recognisable
|
||||
``SxxEyy`` are skipped and reported.
|
||||
|
||||
Returns ``{imported, skipped_no_code, errors}``.
|
||||
"""
|
||||
from .series import (
|
||||
parse_episode, series_folder_stem, season_folder_name, episode_filename)
|
||||
pool = self.pool_dir
|
||||
if pool is None:
|
||||
raise RuntimeError("Pool není nastaven.")
|
||||
lib = ((library or "").strip()
|
||||
or (self.series_libraries[0] if self.series_libraries else POOL_SERIES))
|
||||
stem = series_folder_stem(title, year, tmdb_id)
|
||||
|
||||
imported = 0
|
||||
skipped_no_code: list[str] = []
|
||||
errors: list[str] = []
|
||||
for src in sources:
|
||||
src = Path(src)
|
||||
code = parse_episode(src.name)
|
||||
if code is None:
|
||||
skipped_no_code.append(src.name)
|
||||
continue
|
||||
season, ep = code
|
||||
dest_dir = pool / lib / stem / season_folder_name(season)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = dest_dir / episode_filename(title, season, ep, src.suffix)
|
||||
if target.exists():
|
||||
if on_conflict == "skip":
|
||||
continue
|
||||
if on_conflict == "replace":
|
||||
target.unlink()
|
||||
else: # suffix
|
||||
base = episode_filename(title, season, ep, "")
|
||||
counter = 1
|
||||
while target.exists():
|
||||
target = dest_dir / f"{base}_{counter}{src.suffix}"
|
||||
counter += 1
|
||||
try:
|
||||
if move:
|
||||
shutil.move(str(src), str(target))
|
||||
else:
|
||||
shutil.copy2(src, target)
|
||||
imported += 1
|
||||
except OSError as exc:
|
||||
errors.append(f"{src.name}: {exc}")
|
||||
return {"imported": imported, "skipped_no_code": skipped_no_code,
|
||||
"errors": errors}
|
||||
|
||||
def rename_episodes_to_canonical(self, series) -> dict:
|
||||
"""Rename a series' episodes to ``Series SxxEyy.ext`` in the right season.
|
||||
|
||||
``series`` is a ``series.Series``. Episodes with no recognisable code are
|
||||
skipped; a target name already taken is reported as a conflict.
|
||||
|
||||
Returns ``{renamed, skipped, conflicts}``.
|
||||
"""
|
||||
from .series import season_folder_name, episode_filename
|
||||
renamed: list[tuple[str, str]] = []
|
||||
skipped: list[str] = []
|
||||
conflicts: list[tuple[str, str]] = []
|
||||
for ep in series.episodes:
|
||||
if ep.season is None or ep.episode is None:
|
||||
skipped.append(ep.path.name)
|
||||
continue
|
||||
dest_dir = series.folder / season_folder_name(ep.season)
|
||||
new_path = dest_dir / episode_filename(
|
||||
series.title, ep.season, ep.episode, ep.path.suffix)
|
||||
if new_path == ep.path:
|
||||
continue
|
||||
if new_path.exists():
|
||||
conflicts.append((ep.path.name, f"„{new_path.name}“ už existuje"))
|
||||
continue
|
||||
try:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
old_name = ep.path.name
|
||||
ep.path.rename(new_path)
|
||||
ep.path = new_path
|
||||
renamed.append((old_name, new_path.name))
|
||||
except OSError as exc:
|
||||
conflicts.append((ep.path.name, str(exc)))
|
||||
return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts}
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user