Adopt "Title (YYYY)" pool naming and clean Filmotéka link names
This commit is contained in:
@@ -21,6 +21,41 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 1.11.0 — 2026-07-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Pool naming convention `Title (YYYY).ext`** (`src/core/naming.py`). Pooled
|
||||||
|
movies now carry a four-digit year in parentheses, so two same-named films
|
||||||
|
coexist and the filename is self-describing.
|
||||||
|
- **Import requires a year**: the import dialog has a per-row **Rok** field
|
||||||
|
(pre-filled from the source filename), and *"Najít ČSFD odkazy"* now also
|
||||||
|
fills the year from the top ČSFD hit — a **suggested** year is tinted **blue**
|
||||||
|
(verify it), a name **colliding** with the pool stays **red**. The import is
|
||||||
|
blocked until every row has a four-digit year, and the file lands in the pool
|
||||||
|
as `Title (rok).ext`. Same title + different year no longer collide.
|
||||||
|
- **Rename to the convention**: *Filmy → "Přejmenovat dle ČSFD (rok)…"*
|
||||||
|
(`FileManager.rename_all_to_canonical`) renames selected movies (or the whole
|
||||||
|
pool) to `Title (rok).ext` using the year from their ČSFD metadata, with a
|
||||||
|
cancelable progress dialog; files with no known year are skipped and name
|
||||||
|
collisions reported. `File.title` keeps the clean title (without the year);
|
||||||
|
`File.name_context` recovers the year from a canonical filename when no
|
||||||
|
ČSFD/tag year exists.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Filmotéka hardlinks are named by the clean title (no year).** In the tag
|
||||||
|
tree a movie now links as `Title.ext` instead of the pooled `Title (YYYY).ext`.
|
||||||
|
When two *different* films would collide on that name in the **same folder**,
|
||||||
|
the colliding ones keep the year (`Title (YYYY).ext`) to tell them apart; a
|
||||||
|
residual clash (same title *and* year) gets a stable numeric suffix. Copy-as-is
|
||||||
|
mirrors (Seriály) are untouched — they stay 1:1. A per-category
|
||||||
|
`filename_template` (Tag schéma dialog) still overrides the default inside its
|
||||||
|
folders. Link creation and obsolete-detection now share one collision-resolved
|
||||||
|
plan (`HardlinkManager._plan_links`), so they can't disagree on a name — the
|
||||||
|
first generation after upgrading re-lays the affected links once, then it is
|
||||||
|
stable.
|
||||||
|
- `File.name_context`'s `year` field is now always a string (was an int when it
|
||||||
|
came straight from the ČSFD cache) — uniform for filename templates.
|
||||||
|
|
||||||
## 1.10.0 — 2026-07-07
|
## 1.10.0 — 2026-07-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+28
-7
@@ -98,8 +98,17 @@ movie table, and one-click Filmotéka generation.
|
|||||||
Filmotéka output, with the files materialized as **hardlinks** into the pool.
|
Filmotéka output, with the files materialized as **hardlinks** into the pool.
|
||||||
So `pool/Seriály/...` is cloned 1:1 into `output/Seriály/...` (same structure,
|
So `pool/Seriály/...` is cloned 1:1 into `output/Seriály/...` (same structure,
|
||||||
hardlinked files). This is how Seriály work.
|
hardlinked files). This is how Seriály work.
|
||||||
- **File naming:** imported movies are renamed to **`Title.ext`** (no year in the
|
- **File naming:** pooled movies follow the convention **`Title (YYYY).ext`** — a
|
||||||
filename; year lives in metadata/tags).
|
clean title plus a four-digit year in parentheses (`naming.py`:
|
||||||
|
`canonical_pool_stem` / `parse_pool_stem`). The year lets two same-named films
|
||||||
|
coexist and makes the filename self-describing (`File.name_context` recovers
|
||||||
|
the year from the filename when no ČSFD/tag year is present). The GUI **import
|
||||||
|
requires a four-digit year** (per-row *Rok* field, pre-filled from the source
|
||||||
|
filename and fillable from ČSFD — a ČSFD-suggested year is tinted blue to be
|
||||||
|
verified, a name colliding with the pool is tinted red). *Filmy → "Přejmenovat
|
||||||
|
dle ČSFD (rok)…"* (`FileManager.rename_all_to_canonical`) brings older
|
||||||
|
`Title.ext` files up to the convention from their ČSFD metadata; files without
|
||||||
|
a known year are skipped. `File.title` always stays the clean title (no year).
|
||||||
- **Import copy vs move:** by default the original file is **copied** into the
|
- **Import copy vs move:** by default the original file is **copied** into the
|
||||||
pool (non-destructive); the import dialog also offers a **move** option that
|
pool (non-destructive); the import dialog also offers a **move** option that
|
||||||
relocates the source into the pool instead.
|
relocates the source into the pool instead.
|
||||||
@@ -122,11 +131,20 @@ movie table, and one-click Filmotéka generation.
|
|||||||
The `transform` (e.g. `decade_band`) shapes only the **folder name** — tags keep
|
The `transform` (e.g. `decade_band`) shapes only the **folder name** — tags keep
|
||||||
the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90–100 %`);
|
the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90–100 %`);
|
||||||
it is applied at Filmotéka generation via `filmoteka_category_transforms`.
|
it is applied at Filmotéka generation via `filmoteka_category_transforms`.
|
||||||
- **Per-category filename template** (`filename_template` in a schema entry): the
|
- **Filmotéka link naming (clean title, year on collision):** the default
|
||||||
hardlink name **inside that category's folders only** is rendered from the
|
hardlink name in the tag tree is the movie's **clean title** — `Title.ext`,
|
||||||
movie's metadata (`File.name_context`: title/year/rating/ext/stem/filename plus
|
*not* the pooled `Title (YYYY).ext`. When two different films would collide on
|
||||||
any free-form attributes), e.g. a Kolekce with `"{collection_sort} - {title}{ext}"`.
|
that name **within one folder**, the colliding ones keep the disambiguating
|
||||||
Other folders and the pool file keep the plain name; applied via
|
**year** (`Title (YYYY).ext`); a residual same-title-and-year clash gets a
|
||||||
|
stable numeric suffix. `HardlinkManager._plan_links` resolves the whole tag
|
||||||
|
tree to a collision-free `(file, path)` plan that **both** link creation and
|
||||||
|
obsolete-detection consume, so they can never disagree on a name (the previous
|
||||||
|
churn source). Copy-as-is mirrors (Seriály) are unaffected — they mirror 1:1.
|
||||||
|
- **Per-category filename template** (`filename_template` in a schema entry):
|
||||||
|
overrides the default naming **inside that category's folders only**, rendered
|
||||||
|
from the movie's metadata (`File.name_context`: title/year/rating/ext/stem/
|
||||||
|
filename plus any free-form attributes), e.g. a Kolekce with
|
||||||
|
`"{collection_sort} - {title}{ext}"`. Applied via
|
||||||
`filmoteka_category_filename_templates`.
|
`filmoteka_category_filename_templates`.
|
||||||
- **Free-form per-movie attributes** (`File.attributes`, set in the GUI): arbitrary
|
- **Free-form per-movie attributes** (`File.attributes`, set in the GUI): arbitrary
|
||||||
`key → value` metadata stored in the index and merged into `name_context`, so
|
`key → value` metadata stored in the index and merged into `name_context`, so
|
||||||
@@ -152,6 +170,9 @@ movie table, and one-click Filmotéka generation.
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
|
||||||
|
import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus
|
||||||
|
"Přejmenovat dle ČSFD (rok)" to bring older files up to the convention
|
||||||
- Video integrity check (`integrity.py`, Testy → "Kontrola integrity videa"):
|
- Video integrity check (`integrity.py`, Testy → "Kontrola integrity videa"):
|
||||||
FFmpeg-based scan for corrupted/unreadable video data, deep (full decode) or
|
FFmpeg-based scan for corrupted/unreadable video data, deep (full decode) or
|
||||||
quick (ffprobe), over selected movies or the whole pool
|
quick (ffprobe), over selected movies or the whole pool
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "curator"
|
name = "curator"
|
||||||
version = "1.10.0"
|
version = "1.11.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "jan.doubravsky@gmail.com"}
|
{name = "jan.doubravsky@gmail.com"}
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
"""Auto-generated — do not edit manually."""
|
"""Auto-generated — do not edit manually."""
|
||||||
__version__ = "1.10.0"
|
__version__ = "1.11.0"
|
||||||
|
|||||||
+7
-1
@@ -139,12 +139,18 @@ class File:
|
|||||||
if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit():
|
if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit():
|
||||||
year = t.name
|
year = t.name
|
||||||
break
|
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")
|
rating = cache.get("rating")
|
||||||
# user attributes first; core fields take precedence over same-named keys
|
# user attributes first; core fields take precedence over same-named keys
|
||||||
context = dict(self.attributes)
|
context = dict(self.attributes)
|
||||||
context.update({
|
context.update({
|
||||||
"title": self.title or self.file_path.stem,
|
"title": self.title or self.file_path.stem,
|
||||||
"year": "" if year is None else year,
|
"year": "" if year is None else str(year),
|
||||||
"rating": "" if rating is None else rating,
|
"rating": "" if rating is None else rating,
|
||||||
"ext": self.file_path.suffix,
|
"ext": self.file_path.suffix,
|
||||||
"stem": self.file_path.stem,
|
"stem": self.file_path.stem,
|
||||||
|
|||||||
+121
-4
@@ -7,6 +7,7 @@ from .tag_manager import TagManager
|
|||||||
from .pool_index import PoolIndex
|
from .pool_index import PoolIndex
|
||||||
from .utils import list_files
|
from .utils import list_files
|
||||||
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
|
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
|
||||||
|
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
from src.core.config import (
|
from src.core.config import (
|
||||||
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
||||||
@@ -326,8 +327,14 @@ class FileManager:
|
|||||||
def import_movie(
|
def import_movie(
|
||||||
self, source: Path, title: str, csfd_link: str | None = None,
|
self, source: Path, title: str, csfd_link: str | None = None,
|
||||||
move: bool = False, on_conflict: str = "suffix",
|
move: bool = False, on_conflict: str = "suffix",
|
||||||
|
year: int | str | None = None,
|
||||||
) -> File | None:
|
) -> File | None:
|
||||||
"""Bring a video file into pool/Filmy as 'Title.ext' and index its metadata.
|
"""Bring a video file into pool/Filmy 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
|
||||||
|
falls back to ``Title.ext``. The GUI import always supplies a year, so a
|
||||||
|
library built through the app is uniformly ``Title (YYYY).ext``.
|
||||||
|
|
||||||
By default the original is **copied** (non-destructive). With ``move=True``
|
By default the original is **copied** (non-destructive). With ``move=True``
|
||||||
the source file is moved into the pool instead, leaving nothing behind.
|
the source file is moved into the pool instead, leaving nothing behind.
|
||||||
@@ -339,6 +346,9 @@ class FileManager:
|
|||||||
- ``"replace"``: evict the existing same-named movie(s) (file + metadata)
|
- ``"replace"``: evict the existing same-named movie(s) (file + metadata)
|
||||||
and import the new one under the plain name.
|
and import the new one under the plain name.
|
||||||
- ``"skip"``: do not import; return ``None``.
|
- ``"skip"``: do not import; return ``None``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: a ``year`` was given but is not a four-digit number.
|
||||||
"""
|
"""
|
||||||
movies = self.movies_dir
|
movies = self.movies_dir
|
||||||
pool = self.pool_dir
|
pool = self.pool_dir
|
||||||
@@ -351,9 +361,11 @@ class FileManager:
|
|||||||
|
|
||||||
source = Path(source)
|
source = Path(source)
|
||||||
safe_title = title.strip() or source.stem
|
safe_title = title.strip() or source.stem
|
||||||
target = movies / f"{safe_title}{source.suffix}"
|
has_year = year is not None and str(year).strip() != ""
|
||||||
|
stem = canonical_pool_stem(safe_title, year) if has_year else safe_title
|
||||||
|
target = movies / f"{stem}{source.suffix}"
|
||||||
|
|
||||||
existing = self.pooled_with_stem(safe_title)
|
existing = self.pooled_with_stem(stem)
|
||||||
conflict = bool(existing) or target.exists()
|
conflict = bool(existing) or target.exists()
|
||||||
|
|
||||||
if conflict and on_conflict == "skip":
|
if conflict and on_conflict == "skip":
|
||||||
@@ -367,7 +379,7 @@ class FileManager:
|
|||||||
# "suffix": never clobber an existing exact filename
|
# "suffix": never clobber an existing exact filename
|
||||||
counter = 1
|
counter = 1
|
||||||
while target.exists():
|
while target.exists():
|
||||||
target = movies / f"{safe_title}_{counter}{source.suffix}"
|
target = movies / f"{stem}_{counter}{source.suffix}"
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
if move:
|
if move:
|
||||||
@@ -420,6 +432,111 @@ class FileManager:
|
|||||||
self.on_files_changed(self.filelist)
|
self.on_files_changed(self.filelist)
|
||||||
return file_obj
|
return file_obj
|
||||||
|
|
||||||
|
def _canonical_name_for(self, file_obj: File) -> str | None:
|
||||||
|
"""The ``Title (YYYY)`` 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.
|
||||||
|
"""
|
||||||
|
ctx = file_obj.name_context()
|
||||||
|
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
|
||||||
|
year = str(ctx.get("year") or "").strip()
|
||||||
|
|
||||||
|
parsed = parse_pool_stem(title)
|
||||||
|
if parsed is not None:
|
||||||
|
title = parsed[0]
|
||||||
|
year = year or str(parsed[1])
|
||||||
|
|
||||||
|
title = title.replace("/", "-").replace("\\", "-").strip()
|
||||||
|
if not (title and is_valid_year(year)):
|
||||||
|
return None
|
||||||
|
return canonical_pool_stem(title, year)
|
||||||
|
|
||||||
|
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
|
||||||
|
(without the year). Does not fire ``on_files_changed`` — the caller
|
||||||
|
refreshes once after a batch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The file when renamed (or already canonical); ``None`` when no
|
||||||
|
four-digit year is known, so no canonical name can be built.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileExistsError: another pooled file already uses that name.
|
||||||
|
"""
|
||||||
|
new_stem = self._canonical_name_for(file_obj)
|
||||||
|
if new_stem is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_path = file_obj.file_path
|
||||||
|
new_path = old_path.with_name(f"{new_stem}{old_path.suffix}")
|
||||||
|
if new_path == old_path:
|
||||||
|
return file_obj # already canonical
|
||||||
|
if new_path.exists():
|
||||||
|
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
|
||||||
|
|
||||||
|
parsed = parse_pool_stem(new_stem)
|
||||||
|
old_path.rename(new_path)
|
||||||
|
file_obj.relocate(new_path)
|
||||||
|
if parsed is not None:
|
||||||
|
file_obj.title = parsed[0]
|
||||||
|
file_obj.save_metadata()
|
||||||
|
return file_obj
|
||||||
|
|
||||||
|
def rename_all_to_canonical(
|
||||||
|
self,
|
||||||
|
files: list[File],
|
||||||
|
on_progress: Callable[[int, int, File], None] | None = None,
|
||||||
|
should_cancel: Callable[[], bool] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Rename each movie to ``Title (YYYY).ext`` from its metadata.
|
||||||
|
|
||||||
|
Skips files already canonical and those without a known year. Reports
|
||||||
|
conflicts (target name taken) without aborting the rest.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
renamed — (old_name, new_name) pairs actually renamed
|
||||||
|
skipped_no_year — filenames with no four-digit year available
|
||||||
|
conflicts — (old_name, message) where the target name was taken
|
||||||
|
unchanged — count already in canonical form
|
||||||
|
cancelled — True if stopped early
|
||||||
|
"""
|
||||||
|
renamed: list[tuple[str, str]] = []
|
||||||
|
skipped_no_year: list[str] = []
|
||||||
|
conflicts: list[tuple[str, str]] = []
|
||||||
|
unchanged = 0
|
||||||
|
cancelled = False
|
||||||
|
total = len(files)
|
||||||
|
for i, f in enumerate(files, 1):
|
||||||
|
if should_cancel is not None and should_cancel():
|
||||||
|
cancelled = True
|
||||||
|
break
|
||||||
|
old_name = f.filename
|
||||||
|
try:
|
||||||
|
result = self.rename_to_canonical(f)
|
||||||
|
except FileExistsError as exc:
|
||||||
|
conflicts.append((old_name, str(exc)))
|
||||||
|
else:
|
||||||
|
if result is None:
|
||||||
|
skipped_no_year.append(old_name)
|
||||||
|
elif f.filename != old_name:
|
||||||
|
renamed.append((old_name, f.filename))
|
||||||
|
else:
|
||||||
|
unchanged += 1
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(i, total, f)
|
||||||
|
|
||||||
|
if renamed and self.on_files_changed:
|
||||||
|
self.on_files_changed(self.filelist)
|
||||||
|
return {"renamed": renamed, "skipped_no_year": skipped_no_year,
|
||||||
|
"conflicts": conflicts, "unchanged": unchanged,
|
||||||
|
"cancelled": cancelled}
|
||||||
|
|
||||||
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
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."""
|
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
||||||
for f in files_objs:
|
for f in files_objs:
|
||||||
|
|||||||
+121
-67
@@ -19,9 +19,11 @@ Example:
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple, Optional, Dict, Set
|
from typing import List, Tuple, Optional, Dict, Set
|
||||||
from .file import File
|
from .file import File
|
||||||
|
from .naming import is_valid_year, parse_pool_stem
|
||||||
|
|
||||||
|
|
||||||
class _SafeDict(dict):
|
class _SafeDict(dict):
|
||||||
@@ -69,24 +71,102 @@ class HardlinkManager:
|
|||||||
return {cat: cat for cat in categories}
|
return {cat: cat for cat in categories}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _link_name(
|
def _clean_title(self, file_obj: File) -> str:
|
||||||
self, file_obj: File, tag, templates: Optional[Dict[str, str]]
|
"""The movie's clean title (no trailing ``(YYYY)``), path-safe."""
|
||||||
) -> str:
|
ctx = file_obj.name_context()
|
||||||
"""Hardlink filename for a tag — a per-category template or the pool name.
|
title = str(ctx.get("title") or file_obj.file_path.stem or "").strip()
|
||||||
|
parsed = parse_pool_stem(title)
|
||||||
|
if parsed is not None:
|
||||||
|
title = parsed[0]
|
||||||
|
title = title.replace("/", "-").replace("\\", "-").strip()
|
||||||
|
return title or file_obj.file_path.stem
|
||||||
|
|
||||||
Applies ``templates[tag.category]`` (e.g. ``"{year} - {title}{ext}"``) to
|
def _default_link_name(self, file_obj: File) -> str:
|
||||||
the file's ``name_context``; path separators are flattened. Any failure or
|
"""Filmotéka hardlink name: the clean title + extension (no year)."""
|
||||||
empty result falls back to the pool filename.
|
return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}"
|
||||||
"""
|
|
||||||
template = templates.get(tag.category) if templates else None
|
def _year_link_name(self, file_obj: File) -> Optional[str]:
|
||||||
if not template:
|
"""``Title (YYYY).ext`` used to disambiguate a name collision, or None."""
|
||||||
return file_obj.filename
|
year = str(file_obj.name_context().get("year") or "").strip()
|
||||||
|
if not is_valid_year(year):
|
||||||
|
return None
|
||||||
|
return f"{self._clean_title(file_obj)} ({year}){file_obj.file_path.suffix}"
|
||||||
|
|
||||||
|
def _render_template(self, file_obj: File, template: str) -> str:
|
||||||
|
"""Render a per-category filename template; fall back to the clean name."""
|
||||||
try:
|
try:
|
||||||
rendered = template.format_map(_SafeDict(file_obj.name_context()))
|
rendered = template.format_map(_SafeDict(file_obj.name_context()))
|
||||||
except (ValueError, KeyError, IndexError, AttributeError):
|
except (ValueError, KeyError, IndexError, AttributeError):
|
||||||
return file_obj.filename
|
return self._default_link_name(file_obj)
|
||||||
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
|
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
|
||||||
return rendered or file_obj.filename
|
return rendered or self._default_link_name(file_obj)
|
||||||
|
|
||||||
|
def _plan_links(
|
||||||
|
self,
|
||||||
|
files: List[File],
|
||||||
|
roots: Optional[Dict[str, str]],
|
||||||
|
transforms: Optional[Dict[str, str]],
|
||||||
|
templates: Optional[Dict[str, str]],
|
||||||
|
) -> List[Tuple[File, Path]]:
|
||||||
|
"""Resolve the tag tree to a collision-free ``(file, link_path)`` plan.
|
||||||
|
|
||||||
|
The default hardlink name is the movie's **clean title** (no year). When
|
||||||
|
two *different* movies would land on the same name in the same folder,
|
||||||
|
the colliding ones fall back to the year-qualified ``Title (YYYY).ext`` to
|
||||||
|
tell them apart; a residual clash (same title *and* year) gets a stable
|
||||||
|
numeric suffix so every path is unique. A per-category
|
||||||
|
``filename_template`` overrides the default inside that category's folders.
|
||||||
|
|
||||||
|
Both link creation and obsolete detection consume this one plan, so they
|
||||||
|
can never disagree on a name (which is what caused hardlink churn before).
|
||||||
|
"""
|
||||||
|
# (file_obj, target_dir, name, is_templated), one per file+folder
|
||||||
|
entries: List[List] = []
|
||||||
|
seen_fd: set[tuple[str, str]] = set()
|
||||||
|
for file_obj in files:
|
||||||
|
for tag in file_obj.tags:
|
||||||
|
target_dir = self._target_dir(tag, roots, transforms)
|
||||||
|
if target_dir is None:
|
||||||
|
continue
|
||||||
|
key = (str(file_obj.file_path), str(target_dir))
|
||||||
|
if key in seen_fd: # one link per movie per folder
|
||||||
|
continue
|
||||||
|
seen_fd.add(key)
|
||||||
|
template = templates.get(tag.category) if templates else None
|
||||||
|
if template:
|
||||||
|
entries.append(
|
||||||
|
[file_obj, target_dir, self._render_template(file_obj, template), True])
|
||||||
|
else:
|
||||||
|
entries.append(
|
||||||
|
[file_obj, target_dir, self._default_link_name(file_obj), False])
|
||||||
|
|
||||||
|
# Disambiguate default-named collisions (different movies) by their year.
|
||||||
|
groups: dict[tuple[Path, str], set[str]] = defaultdict(set)
|
||||||
|
for file_obj, target_dir, name, templated in entries:
|
||||||
|
if not templated:
|
||||||
|
groups[(target_dir, name)].add(str(file_obj.file_path))
|
||||||
|
for entry in entries:
|
||||||
|
file_obj, target_dir, name, templated = entry
|
||||||
|
if not templated and len(groups[(target_dir, name)]) > 1:
|
||||||
|
year_name = self._year_link_name(file_obj)
|
||||||
|
if year_name:
|
||||||
|
entry[2] = year_name
|
||||||
|
|
||||||
|
# Final pass: guarantee unique paths (stable numeric suffix on any tie).
|
||||||
|
by_path: dict[tuple[Path, str], List[List]] = defaultdict(list)
|
||||||
|
for entry in entries:
|
||||||
|
by_path[(entry[1], entry[2])].append(entry)
|
||||||
|
plan: List[Tuple[File, Path]] = []
|
||||||
|
for (target_dir, name), group in by_path.items():
|
||||||
|
if len(group) == 1:
|
||||||
|
plan.append((group[0][0], target_dir / name))
|
||||||
|
continue
|
||||||
|
ordered = sorted(group, key=lambda e: str(e[0].file_path))
|
||||||
|
stem, suffix = Path(name).stem, Path(name).suffix
|
||||||
|
for i, entry in enumerate(ordered):
|
||||||
|
fname = name if i == 0 else f"{stem}_{i + 1}{suffix}"
|
||||||
|
plan.append((entry[0], target_dir / fname))
|
||||||
|
return plan
|
||||||
|
|
||||||
def _folder_value(self, tag, transforms: Optional[Dict[str, str]]) -> str:
|
def _folder_value(self, tag, transforms: Optional[Dict[str, str]]) -> str:
|
||||||
"""Folder name for a tag — its value run through the category transform.
|
"""Folder name for a tag — its value run through the category transform.
|
||||||
@@ -185,33 +265,20 @@ class HardlinkManager:
|
|||||||
success_count = 0
|
success_count = 0
|
||||||
fail_count = 0
|
fail_count = 0
|
||||||
|
|
||||||
for file_obj in files:
|
plan = self._plan_links(
|
||||||
if not file_obj.tags:
|
files, roots, category_transforms, category_filename_templates)
|
||||||
continue
|
for file_obj, target_file in plan:
|
||||||
|
|
||||||
for tag in file_obj.tags:
|
|
||||||
# Resolve the target dir; None means this category is excluded
|
|
||||||
target_dir = self._target_dir(tag, roots, category_transforms)
|
|
||||||
if target_dir is None:
|
|
||||||
continue
|
|
||||||
target_file = target_dir / self._link_name(
|
|
||||||
file_obj, tag, category_filename_templates)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# Create directory structure
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Skip if link already exists
|
|
||||||
if target_file.exists():
|
if target_file.exists():
|
||||||
# Check if it's already a hardlink to the same file
|
# Already the right link → nothing to do (not re-counted).
|
||||||
if self._is_same_file(file_obj.file_path, target_file):
|
if self._is_same_file(file_obj.file_path, target_file):
|
||||||
continue
|
continue
|
||||||
else:
|
# A wrong occupant sits on the planned path; the plan is
|
||||||
# Different file exists, add suffix
|
# authoritative, so replace it.
|
||||||
target_file = self._get_unique_name(target_file)
|
target_file.unlink()
|
||||||
|
|
||||||
# Create hardlink
|
|
||||||
os.link(file_obj.file_path, target_file)
|
os.link(file_obj.file_path, target_file)
|
||||||
|
|
||||||
self.created_links.append(target_file)
|
self.created_links.append(target_file)
|
||||||
@@ -291,7 +358,7 @@ class HardlinkManager:
|
|||||||
created = 0
|
created = 0
|
||||||
fail = 0
|
fail = 0
|
||||||
for file_obj in chosen:
|
for file_obj in chosen:
|
||||||
target = base / file_obj.filename
|
target = base / self._default_link_name(file_obj)
|
||||||
try:
|
try:
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
base.mkdir(parents=True, exist_ok=True)
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -299,6 +366,12 @@ class HardlinkManager:
|
|||||||
if self._is_same_file(file_obj.file_path, target):
|
if self._is_same_file(file_obj.file_path, target):
|
||||||
created += 1
|
created += 1
|
||||||
continue
|
continue
|
||||||
|
# Collision with another movie: prefer the year-qualified
|
||||||
|
# name, else a numeric suffix.
|
||||||
|
year_name = self._year_link_name(file_obj)
|
||||||
|
if year_name and not (base / year_name).exists():
|
||||||
|
target = base / year_name
|
||||||
|
else:
|
||||||
target = self._get_unique_name(target)
|
target = self._get_unique_name(target)
|
||||||
os.link(file_obj.file_path, target)
|
os.link(file_obj.file_path, target)
|
||||||
self.created_links.append(target)
|
self.created_links.append(target)
|
||||||
@@ -418,22 +491,9 @@ class HardlinkManager:
|
|||||||
List of tuples (source_path, target_path)
|
List of tuples (source_path, target_path)
|
||||||
"""
|
"""
|
||||||
roots = self._resolve_roots(categories, category_roots)
|
roots = self._resolve_roots(categories, category_roots)
|
||||||
preview = []
|
plan = self._plan_links(
|
||||||
|
files, roots, category_transforms, category_filename_templates)
|
||||||
for file_obj in files:
|
return [(file_obj.file_path, target_file) for file_obj, target_file in plan]
|
||||||
if not file_obj.tags:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for tag in file_obj.tags:
|
|
||||||
target_dir = self._target_dir(tag, roots, category_transforms)
|
|
||||||
if target_dir is None:
|
|
||||||
continue
|
|
||||||
target_file = target_dir / self._link_name(
|
|
||||||
file_obj, tag, category_filename_templates)
|
|
||||||
|
|
||||||
preview.append((file_obj.file_path, target_file))
|
|
||||||
|
|
||||||
return preview
|
|
||||||
|
|
||||||
def find_obsolete_links(
|
def find_obsolete_links(
|
||||||
self,
|
self,
|
||||||
@@ -479,26 +539,20 @@ class HardlinkManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Every link the tag tree *should* contain, mapped to the inode(s) it may
|
# Every link the tag tree *should* contain, mapped to the inode(s) it may
|
||||||
# point at. A path can be wanted by more than one movie (same rendered
|
# point at — taken from the very same plan that creates the links, so the
|
||||||
# name in the same folder), so the value is a *set* of valid inodes.
|
# two never disagree on a name. Matching by inode too — not just the path
|
||||||
# Matching by inode too — not just the path — is what catches an **orphan
|
# — is what catches an **orphan that squats the expected name**: a link
|
||||||
# that squats the expected name**: a link whose name is right but which
|
# whose name is right but which points to an old inode (movie
|
||||||
# points to an old inode (movie re-imported/replaced) is still obsolete,
|
# re-imported/replaced) is still obsolete, while the correctly created
|
||||||
# while the correctly re-created link (forced to a ``_1`` suffix because
|
# link is *not* wrongly swept.
|
||||||
# the orphan holds the base name) is *not* wrongly swept.
|
|
||||||
expected: dict[Path, set[int]] = {}
|
expected: dict[Path, set[int]] = {}
|
||||||
for file_obj in files:
|
for file_obj, path in self._plan_links(
|
||||||
|
files, roots, category_transforms, category_filename_templates
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
file_inode = file_obj.file_path.stat().st_ino
|
expected.setdefault(path, set()).add(file_obj.file_path.stat().st_ino)
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
for tag in file_obj.tags:
|
|
||||||
target_dir = self._target_dir(tag, roots, category_transforms)
|
|
||||||
if target_dir is None:
|
|
||||||
continue
|
|
||||||
path = target_dir / self._link_name(
|
|
||||||
file_obj, tag, category_filename_templates)
|
|
||||||
expected.setdefault(path, set()).add(file_inode)
|
|
||||||
|
|
||||||
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors).
|
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors).
|
||||||
# Inside them, any file that isn't an expected link is obsolete — whether
|
# Inside them, any file that isn't an expected link is obsolete — whether
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Pool file naming convention.
|
||||||
|
|
||||||
|
Every movie in the pool is named ``Title (YYYY).ext`` — a title followed by a
|
||||||
|
four-digit year in parentheses. The year lets two films of the same name live
|
||||||
|
side by side and makes the filename self-describing (the year can be recovered
|
||||||
|
without any metadata). These helpers build, validate and parse that stem (the
|
||||||
|
filename without its extension).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# A trailing four-digit year in parentheses. The title is non-greedy so a title
|
||||||
|
# that itself contains parentheses keeps them: "Já, robot (2004)" but also
|
||||||
|
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019.
|
||||||
|
_POOL_STEM_RE = re.compile(r"^(?P<title>.+?) \((?P<year>\d{4})\)$")
|
||||||
|
_FOUR_DIGITS = re.compile(r"\d{4}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_year(year: object) -> bool:
|
||||||
|
"""True when ``year`` is (or stringifies to) exactly four digits."""
|
||||||
|
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_pool_stem(title: str, year: object) -> str:
|
||||||
|
"""Build the canonical pool stem ``Title (YYYY)``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: the title is empty or the year is not four digits.
|
||||||
|
"""
|
||||||
|
title = (title or "").strip()
|
||||||
|
year_s = str(year).strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Název filmu nesmí být prázdný.")
|
||||||
|
if not re.fullmatch(r"\d{4}", year_s):
|
||||||
|
raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.")
|
||||||
|
return f"{title} ({year_s})"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pool_stem(stem: str) -> tuple[str, int] | None:
|
||||||
|
"""Split a canonical stem into ``(title, year)``; ``None`` if it isn't one."""
|
||||||
|
match = _POOL_STEM_RE.match((stem or "").strip())
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group("title").strip(), int(match.group("year"))
|
||||||
|
|
||||||
|
|
||||||
|
def is_canonical_pool_stem(stem: str) -> bool:
|
||||||
|
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
|
||||||
|
return _POOL_STEM_RE.match((stem or "").strip()) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def year_from_filename(name: str) -> str | None:
|
||||||
|
"""Best-effort four-digit year guessed from a raw (release) filename.
|
||||||
|
|
||||||
|
Prefers a parenthesised year (``… (1999) …``) and otherwise falls back to the
|
||||||
|
last standalone 19xx/20xx run. Used only to pre-fill the import dialog — the
|
||||||
|
user confirms it. Returns the year as a string, or ``None``.
|
||||||
|
"""
|
||||||
|
paren = re.search(r"\((19\d{2}|20\d{2})\)", name)
|
||||||
|
if paren:
|
||||||
|
return paren.group(1)
|
||||||
|
loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name)
|
||||||
|
return loose[-1] if loose else None
|
||||||
+182
-34
@@ -14,8 +14,8 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QTimer
|
from PySide6.QtCore import Qt, QTimer, QRegularExpression
|
||||||
from PySide6.QtGui import QAction, QKeySequence
|
from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
|
QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
|
||||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
@@ -29,6 +29,7 @@ from src.core.file import File
|
|||||||
from src.core.tag import Tag
|
from src.core.tag import Tag
|
||||||
from src.constants import APP_TITLE
|
from src.constants import APP_TITLE
|
||||||
from src.core.hardlink_manager import HardlinkManager
|
from src.core.hardlink_manager import HardlinkManager
|
||||||
|
from src.core.naming import canonical_pool_stem, is_valid_year, year_from_filename
|
||||||
|
|
||||||
# Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV
|
# Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV
|
||||||
# browsers sort them ahead of the genre folders at the output root.
|
# browsers sort them ahead of the genre folders at the output root.
|
||||||
@@ -54,21 +55,30 @@ class ImportMoviesDialog(QDialog):
|
|||||||
self.setWindowTitle("Importovat filmy do poolu")
|
self.setWindowTitle("Importovat filmy do poolu")
|
||||||
self.setMinimumSize(720, 360)
|
self.setMinimumSize(720, 360)
|
||||||
|
|
||||||
# Lower-cased names already present in the pool (for collision highlight)
|
# Lower-cased canonical stems already present in the pool (collision check)
|
||||||
self.existing_names = {n.lower() for n in (existing_names or set())}
|
self.existing_names = {n.lower() for n in (existing_names or set())}
|
||||||
# (source path, title field, ČSFD field) per row
|
# (source path, title field, year field, ČSFD field) per row
|
||||||
self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = []
|
self._rows: list[tuple[Path, QLineEdit, QLineEdit, QLineEdit]] = []
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
self.table = QTableWidget(0, 4)
|
info = QLabel(
|
||||||
self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz", ""])
|
"Filmy se do poolu uloží jako „Název (rok).ext“. "
|
||||||
|
"Rok je povinný (čtyřmístné číslo). "
|
||||||
|
"Modře = rok navržený z ČSFD (ověř ho); červeně = název už v poolu je.")
|
||||||
|
info.setWordWrap(True)
|
||||||
|
layout.addWidget(info)
|
||||||
|
|
||||||
|
self.table = QTableWidget(0, 5)
|
||||||
|
self.table.setHorizontalHeaderLabels(
|
||||||
|
["Soubor", "Název", "Rok", "ČSFD odkaz", ""])
|
||||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
header = self.table.horizontalHeader()
|
header = self.table.horizontalHeader()
|
||||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||||
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||||
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
header.setSectionResizeMode(3, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
||||||
layout.addWidget(self.table)
|
layout.addWidget(self.table)
|
||||||
|
|
||||||
add_row = QHBoxLayout()
|
add_row = QHBoxLayout()
|
||||||
@@ -99,8 +109,20 @@ class ImportMoviesDialog(QDialog):
|
|||||||
name_item = QTableWidgetItem(source.name)
|
name_item = QTableWidgetItem(source.name)
|
||||||
name_item.setFlags(Qt.ItemIsEnabled)
|
name_item.setFlags(Qt.ItemIsEnabled)
|
||||||
self.table.setItem(row, 0, name_item)
|
self.table.setItem(row, 0, name_item)
|
||||||
|
|
||||||
title_edit = QLineEdit(source.stem)
|
title_edit = QLineEdit(source.stem)
|
||||||
title_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e))
|
title_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e))
|
||||||
|
|
||||||
|
year_edit = QLineEdit(year_from_filename(source.name) or "")
|
||||||
|
year_edit.setPlaceholderText("rok")
|
||||||
|
year_edit.setMaximumWidth(64)
|
||||||
|
year_edit.setValidator(
|
||||||
|
QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), year_edit))
|
||||||
|
year_edit.textChanged.connect(
|
||||||
|
lambda _t, e=title_edit: self._mark_collision(e))
|
||||||
|
# Manual edits clear the "suggested" (blue) marker; ČSFD autofill re-adds it.
|
||||||
|
year_edit.textEdited.connect(lambda _t, e=year_edit: self._set_suggested(e, False))
|
||||||
|
|
||||||
csfd_edit = QLineEdit()
|
csfd_edit = QLineEdit()
|
||||||
csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
|
csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
|
||||||
remove_btn = QPushButton("✕")
|
remove_btn = QPushButton("✕")
|
||||||
@@ -108,24 +130,52 @@ class ImportMoviesDialog(QDialog):
|
|||||||
remove_btn.setToolTip("Odebrat tento soubor z importu")
|
remove_btn.setToolTip("Odebrat tento soubor z importu")
|
||||||
remove_btn.clicked.connect(lambda _c, e=title_edit: self._remove_row(e))
|
remove_btn.clicked.connect(lambda _c, e=title_edit: self._remove_row(e))
|
||||||
self.table.setCellWidget(row, 1, title_edit)
|
self.table.setCellWidget(row, 1, title_edit)
|
||||||
self.table.setCellWidget(row, 2, csfd_edit)
|
self.table.setCellWidget(row, 2, year_edit)
|
||||||
self.table.setCellWidget(row, 3, remove_btn)
|
self.table.setCellWidget(row, 3, csfd_edit)
|
||||||
self._rows.append((source, title_edit, csfd_edit))
|
self.table.setCellWidget(row, 4, remove_btn)
|
||||||
|
self._rows.append((source, title_edit, year_edit, csfd_edit))
|
||||||
self._mark_collision(title_edit)
|
self._mark_collision(title_edit)
|
||||||
|
|
||||||
|
def _row_of(self, title_edit: QLineEdit) -> tuple[Path, QLineEdit, QLineEdit, QLineEdit] | None:
|
||||||
|
for entry in self._rows:
|
||||||
|
if entry[1] is title_edit:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _candidate_stem(self, title: str, year: str) -> str | None:
|
||||||
|
"""The canonical 'Title (year)' stem, or None if it can't be built yet."""
|
||||||
|
if not (title.strip() and is_valid_year(year)):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return canonical_pool_stem(title, year)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
def _mark_collision(self, title_edit: QLineEdit) -> None:
|
def _mark_collision(self, title_edit: QLineEdit) -> None:
|
||||||
"""Highlight the title in red when that name already exists in the pool."""
|
"""Red title when the resulting 'Title (year)' name already exists."""
|
||||||
clashes = title_edit.text().strip().lower() in self.existing_names
|
entry = self._row_of(title_edit)
|
||||||
|
if entry is None:
|
||||||
|
return
|
||||||
|
_src, _title, year_edit, _csfd = entry
|
||||||
|
stem = self._candidate_stem(title_edit.text(), year_edit.text())
|
||||||
|
clashes = stem is not None and stem.lower() in self.existing_names
|
||||||
title_edit.setStyleSheet(
|
title_edit.setStyleSheet(
|
||||||
"QLineEdit { color: #c0392b; font-weight: bold; }" if clashes else "")
|
"QLineEdit { color: #c0392b; font-weight: bold; }" if clashes else "")
|
||||||
title_edit.setToolTip(
|
title_edit.setToolTip(
|
||||||
"V poolu už existuje film s tímto názvem — při importu dostane "
|
"V poolu už existuje film „" + stem + "“ — při importu se tě zeptám, "
|
||||||
"číselnou příponu." if clashes else "")
|
"co s ním." if clashes else "")
|
||||||
|
|
||||||
|
def _set_suggested(self, year_edit: QLineEdit, suggested: bool) -> None:
|
||||||
|
"""Blue tint marks a year proposed by ČSFD (not yet confirmed by the user)."""
|
||||||
|
year_edit.setStyleSheet(
|
||||||
|
"QLineEdit { color: #2980b9; }" if suggested else "")
|
||||||
|
year_edit.setToolTip(
|
||||||
|
"Rok navržený z ČSFD — ověř ho." if suggested else "")
|
||||||
|
|
||||||
def _remove_row(self, title_edit: QLineEdit) -> None:
|
def _remove_row(self, title_edit: QLineEdit) -> None:
|
||||||
"""Drop a single file from the import list without restarting the dialog."""
|
"""Drop a single file from the import list without restarting the dialog."""
|
||||||
for index, (_src, edit, _csfd) in enumerate(self._rows):
|
for index, entry in enumerate(self._rows):
|
||||||
if edit is title_edit:
|
if entry[1] is title_edit:
|
||||||
self.table.removeRow(index)
|
self.table.removeRow(index)
|
||||||
self._rows.pop(index)
|
self._rows.pop(index)
|
||||||
return
|
return
|
||||||
@@ -136,11 +186,15 @@ class ImportMoviesDialog(QDialog):
|
|||||||
self._append_row(Path(path))
|
self._append_row(Path(path))
|
||||||
|
|
||||||
def _autofill_csfd(self) -> None:
|
def _autofill_csfd(self) -> None:
|
||||||
"""Fill empty ČSFD fields by searching ČSFD for each file's cleaned name."""
|
"""Fill empty ČSFD links (and empty years) from a ČSFD search per row.
|
||||||
|
|
||||||
|
Uses the top search hit: its URL fills an empty link, its year fills an
|
||||||
|
empty year field and is tinted blue (a suggestion to verify).
|
||||||
|
"""
|
||||||
import requests
|
import requests
|
||||||
from src.core import csfd
|
from src.core import csfd
|
||||||
|
|
||||||
targets = [(t, c) for _, t, c in self._rows if not c.text().strip()]
|
targets = [(t, y, c) for _, t, y, c in self._rows if not c.text().strip()]
|
||||||
if not targets:
|
if not targets:
|
||||||
QMessageBox.information(self, "ČSFD", "Všechny řádky už mají odkaz.")
|
QMessageBox.information(self, "ČSFD", "Všechny řádky už mají odkaz.")
|
||||||
return
|
return
|
||||||
@@ -149,15 +203,21 @@ class ImportMoviesDialog(QDialog):
|
|||||||
QApplication.setOverrideCursor(Qt.WaitCursor)
|
QApplication.setOverrideCursor(Qt.WaitCursor)
|
||||||
try:
|
try:
|
||||||
with requests.Session() as session:
|
with requests.Session() as session:
|
||||||
for title_edit, csfd_edit in targets:
|
for title_edit, year_edit, csfd_edit in targets:
|
||||||
query = csfd.clean_filename_to_query(title_edit.text())
|
query = csfd.clean_filename_to_query(title_edit.text())
|
||||||
try:
|
try:
|
||||||
url = csfd.find_csfd_url(query, session=session)
|
hits = csfd.search_movies(query, limit=1, session=session)
|
||||||
except Exception: # noqa: BLE001 — network/parse failure for one row
|
except Exception: # noqa: BLE001 — network/parse failure for one row
|
||||||
url = None
|
hits = []
|
||||||
if url:
|
if not hits:
|
||||||
csfd_edit.setText(url)
|
continue
|
||||||
|
hit = hits[0]
|
||||||
|
if hit.url:
|
||||||
|
csfd_edit.setText(hit.url)
|
||||||
found += 1
|
found += 1
|
||||||
|
if hit.year and not year_edit.text().strip():
|
||||||
|
year_edit.setText(str(hit.year))
|
||||||
|
self._set_suggested(year_edit, True)
|
||||||
finally:
|
finally:
|
||||||
QApplication.restoreOverrideCursor()
|
QApplication.restoreOverrideCursor()
|
||||||
|
|
||||||
@@ -165,16 +225,34 @@ class ImportMoviesDialog(QDialog):
|
|||||||
self, "ČSFD", f"Vyplněno {found} z {len(targets)} hledaných odkazů."
|
self, "ČSFD", f"Vyplněno {found} z {len(targets)} hledaných odkazů."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def accept(self) -> None: # noqa: D401 — Qt override
|
||||||
|
"""Block the import until every row has a title and a four-digit year."""
|
||||||
|
bad_year: list[str] = []
|
||||||
|
for source, title_edit, year_edit, _csfd in self._rows:
|
||||||
|
title = title_edit.text().strip() or source.stem
|
||||||
|
if not is_valid_year(year_edit.text()):
|
||||||
|
bad_year.append(title)
|
||||||
|
if bad_year:
|
||||||
|
listing = "\n".join(f" • {t}" for t in bad_year[:10])
|
||||||
|
more = f"\n … a další ({len(bad_year) - 10})" if len(bad_year) > 10 else ""
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Chybí rok",
|
||||||
|
"Každý film musí mít čtyřmístný rok (uloží se jako „Název "
|
||||||
|
f"(rok).ext“):\n{listing}{more}")
|
||||||
|
return
|
||||||
|
super().accept()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def move_files(self) -> bool:
|
def move_files(self) -> bool:
|
||||||
return self.move_check.isChecked()
|
return self.move_check.isChecked()
|
||||||
|
|
||||||
def entries(self) -> list[tuple[Path, str, str]]:
|
def entries(self) -> list[tuple[Path, str, str, str]]:
|
||||||
"""Return (source, title, csfd_link) per row; title falls back to stem."""
|
"""Return (source, title, year, csfd_link) per row; title falls back to stem."""
|
||||||
result: list[tuple[Path, str, str]] = []
|
result: list[tuple[Path, str, str, str]] = []
|
||||||
for source, title_edit, csfd_edit in self._rows:
|
for source, title_edit, year_edit, csfd_edit in self._rows:
|
||||||
title = title_edit.text().strip() or source.stem
|
title = title_edit.text().strip() or source.stem
|
||||||
result.append((source, title, csfd_edit.text().strip()))
|
result.append(
|
||||||
|
(source, title, year_edit.text().strip(), csfd_edit.text().strip()))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -499,6 +577,8 @@ class QtApp(QMainWindow):
|
|||||||
movie_menu = bar.addMenu("&Filmy")
|
movie_menu = bar.addMenu("&Filmy")
|
||||||
self._add_action(movie_menu, "Importovat filmy…", self.import_movie, "Ctrl+I")
|
self._add_action(movie_menu, "Importovat filmy…", self.import_movie, "Ctrl+I")
|
||||||
self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2")
|
self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2")
|
||||||
|
self._add_action(
|
||||||
|
movie_menu, "Přejmenovat dle ČSFD (rok)…", self.rename_to_csfd_naming)
|
||||||
self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T")
|
self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T")
|
||||||
self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D")
|
self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D")
|
||||||
self._add_action(movie_menu, "Nastavit atribut…", self.set_attribute)
|
self._add_action(movie_menu, "Nastavit atribut…", self.set_attribute)
|
||||||
@@ -772,8 +852,13 @@ class QtApp(QMainWindow):
|
|||||||
return
|
return
|
||||||
move = dialog.move_files
|
move = dialog.move_files
|
||||||
|
|
||||||
# Files whose name already exists in the pool (the red-highlighted rows)
|
# Rows whose canonical "Title (year)" name already exists in the pool
|
||||||
colliding = [t for s, t, _ in entries if self.filehandler.pooled_with_stem(t)]
|
colliding = [
|
||||||
|
canonical_pool_stem(t, y)
|
||||||
|
for s, t, y, _ in entries
|
||||||
|
if is_valid_year(y)
|
||||||
|
and self.filehandler.pooled_with_stem(canonical_pool_stem(t, y))
|
||||||
|
]
|
||||||
on_conflict = "suffix"
|
on_conflict = "suffix"
|
||||||
if colliding:
|
if colliding:
|
||||||
on_conflict = self._resolve_import_conflicts(colliding)
|
on_conflict = self._resolve_import_conflicts(colliding)
|
||||||
@@ -783,10 +868,11 @@ class QtApp(QMainWindow):
|
|||||||
imported: list[File] = []
|
imported: list[File] = []
|
||||||
skipped = 0
|
skipped = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
for source, title, csfd_link in entries:
|
for source, title, year, csfd_link in entries:
|
||||||
try:
|
try:
|
||||||
movie = self.filehandler.import_movie(
|
movie = self.filehandler.import_movie(
|
||||||
source, title, csfd_link or None, move=move, on_conflict=on_conflict)
|
source, title, csfd_link or None, move=move,
|
||||||
|
on_conflict=on_conflict, year=year or None)
|
||||||
if movie is None:
|
if movie is None:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
else:
|
else:
|
||||||
@@ -931,6 +1017,68 @@ class QtApp(QMainWindow):
|
|||||||
self.refresh_table()
|
self.refresh_table()
|
||||||
self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000)
|
self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000)
|
||||||
|
|
||||||
|
def rename_to_csfd_naming(self) -> None:
|
||||||
|
"""Rename movies to the 'Title (rok).ext' convention from their metadata."""
|
||||||
|
files = self._selected_movies() or self.filehandler.filelist
|
||||||
|
if not files:
|
||||||
|
QMessageBox.information(self, "Přejmenovat dle ČSFD", "Pool je prázdný.")
|
||||||
|
return
|
||||||
|
scope = "vybrané filmy" if self._selected_movies() else "celý pool"
|
||||||
|
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self, "Přejmenovat dle ČSFD (rok)",
|
||||||
|
f"Přejmenovat {scope} ({len(files)}) na „Název (rok).ext“ podle "
|
||||||
|
"staženého roku z ČSFD?\n\nFilmy bez známého roku se přeskočí.",
|
||||||
|
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
||||||
|
if answer != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, len(files), self)
|
||||||
|
progress.setWindowTitle("Přejmenovat dle ČSFD (rok)")
|
||||||
|
progress.setWindowModality(Qt.WindowModal)
|
||||||
|
progress.setMinimumDuration(0)
|
||||||
|
|
||||||
|
def on_progress(done: int, total: int, f: File) -> None:
|
||||||
|
progress.setValue(done)
|
||||||
|
progress.setLabelText(f"[{done}/{total}] {f.filename}")
|
||||||
|
QApplication.processEvents()
|
||||||
|
|
||||||
|
result = self.filehandler.rename_all_to_canonical(
|
||||||
|
files, on_progress=on_progress, should_cancel=progress.wasCanceled)
|
||||||
|
progress.setValue(len(files))
|
||||||
|
self.refresh_table()
|
||||||
|
|
||||||
|
renamed = result["renamed"]
|
||||||
|
no_year = result["skipped_no_year"]
|
||||||
|
conflicts = result["conflicts"]
|
||||||
|
suffix = " (zrušeno)" if result["cancelled"] else ""
|
||||||
|
summary = (
|
||||||
|
f"Přejmenováno: {len(renamed)}, beze změny: {result['unchanged']}, "
|
||||||
|
f"bez roku: {len(no_year)}, kolize: {len(conflicts)}{suffix}")
|
||||||
|
|
||||||
|
if not (no_year or conflicts):
|
||||||
|
QMessageBox.information(self, "Přejmenování dokončeno", summary)
|
||||||
|
else:
|
||||||
|
detail: list[str] = []
|
||||||
|
if renamed:
|
||||||
|
detail.append(f"■ Přejmenováno ({len(renamed)}):")
|
||||||
|
detail += [f" • {old} → {new}" for old, new in renamed]
|
||||||
|
detail.append("")
|
||||||
|
if no_year:
|
||||||
|
detail.append(f"■ Bez známého roku — přeskočeno ({len(no_year)}):")
|
||||||
|
detail += [f" • {n}" for n in no_year]
|
||||||
|
detail.append("")
|
||||||
|
if conflicts:
|
||||||
|
detail.append(f"■ Kolize názvu ({len(conflicts)}):")
|
||||||
|
detail += [f" • {old} — {msg}" for old, msg in conflicts]
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Warning if conflicts else QMessageBox.Information)
|
||||||
|
box.setWindowTitle("Přejmenování dle ČSFD")
|
||||||
|
box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.")
|
||||||
|
box.setDetailedText("\n".join(detail))
|
||||||
|
box.exec()
|
||||||
|
self.status.showMessage(summary, 5000)
|
||||||
|
|
||||||
def edit_csfd(self) -> None:
|
def edit_csfd(self) -> None:
|
||||||
files = self._selected_movies()
|
files = self._selected_movies()
|
||||||
if len(files) != 1:
|
if len(files) != 1:
|
||||||
|
|||||||
+1
-1
@@ -271,7 +271,7 @@ class TestApplyCsfdTags:
|
|||||||
movie_file.csfd_cache = {"year": 1962, "rating": 75}
|
movie_file.csfd_cache = {"year": 1962, "rating": 75}
|
||||||
ctx = movie_file.name_context()
|
ctx = movie_file.name_context()
|
||||||
assert ctx["title"] == "Dr. No"
|
assert ctx["title"] == "Dr. No"
|
||||||
assert ctx["year"] == 1962
|
assert ctx["year"] == "1962" # coerced to str for uniform template fields
|
||||||
assert ctx["rating"] == 75
|
assert ctx["rating"] == 75
|
||||||
assert ctx["ext"] == movie_file.file_path.suffix
|
assert ctx["ext"] == movie_file.file_path.suffix
|
||||||
assert ctx["filename"] == movie_file.filename
|
assert ctx["filename"] == movie_file.filename
|
||||||
|
|||||||
@@ -409,6 +409,69 @@ class TestPoolManagement:
|
|||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
file_manager.rename_movie(movie, " ")
|
file_manager.rename_movie(movie, " ")
|
||||||
|
|
||||||
|
def test_import_movie_with_year_uses_convention(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
source = tmp_path / "raw.mkv"
|
||||||
|
source.write_bytes(b"x" * 10)
|
||||||
|
|
||||||
|
movie = file_manager.import_movie(source, "Matrix", year=1999)
|
||||||
|
|
||||||
|
assert movie.file_path.name == "Matrix (1999).mkv"
|
||||||
|
assert movie.title == "Matrix" # clean title, no year
|
||||||
|
|
||||||
|
def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year="99")
|
||||||
|
|
||||||
|
def test_import_same_title_different_year_coexist(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"b")
|
||||||
|
first = file_manager.import_movie(tmp_path / "a.mkv", "Solaris", year=1972)
|
||||||
|
second = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=2002)
|
||||||
|
|
||||||
|
assert first.file_path.name == "Solaris (1972).mkv"
|
||||||
|
assert second.file_path.name == "Solaris (2002).mkv" # no collision/suffix
|
||||||
|
|
||||||
|
def test_rename_to_canonical_from_csfd_year(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix") # plain
|
||||||
|
assert movie.file_path.name == "Matrix.mkv"
|
||||||
|
|
||||||
|
movie.csfd_cache = {"year": 1999}
|
||||||
|
renamed = file_manager.rename_to_canonical(movie)
|
||||||
|
|
||||||
|
assert renamed is not None
|
||||||
|
assert movie.file_path.name == "Matrix (1999).mkv"
|
||||||
|
assert movie.title == "Matrix"
|
||||||
|
|
||||||
|
def test_rename_to_canonical_skips_without_year(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Unknown")
|
||||||
|
|
||||||
|
assert file_manager.rename_to_canonical(movie) is None
|
||||||
|
assert movie.file_path.name == "Unknown.mkv" # untouched
|
||||||
|
|
||||||
|
def test_rename_all_to_canonical_reports(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
for n in ("a.mkv", "b.mkv", "c.mkv"):
|
||||||
|
(tmp_path / n).write_bytes(b"x")
|
||||||
|
with_year = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
||||||
|
with_year.csfd_cache = {"year": 1999}
|
||||||
|
already = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=1972)
|
||||||
|
_no_year = file_manager.import_movie(tmp_path / "c.mkv", "Mystery")
|
||||||
|
|
||||||
|
result = file_manager.rename_all_to_canonical(file_manager.filelist)
|
||||||
|
|
||||||
|
assert result["renamed"] == [("Matrix.mkv", "Matrix (1999).mkv")]
|
||||||
|
assert result["unchanged"] == 1 # Solaris (1972) already canonical
|
||||||
|
assert result["skipped_no_year"] == ["Mystery.mkv"]
|
||||||
|
assert already.file_path.name == "Solaris (1972).mkv"
|
||||||
|
|
||||||
def test_load_pool_movies_reads_from_index(self, file_manager, tmp_path):
|
def test_load_pool_movies_reads_from_index(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
source = tmp_path / "raw.mkv"
|
source = tmp_path / "raw.mkv"
|
||||||
|
|||||||
@@ -208,6 +208,78 @@ class TestHardlinkManager:
|
|||||||
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
|
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
|
||||||
assert (created2, removed2) == (0, 0)
|
assert (created2, removed2) == (0, 0)
|
||||||
|
|
||||||
|
def test_link_name_strips_year_by_default(
|
||||||
|
self, tmp_path, tag_manager
|
||||||
|
):
|
||||||
|
"""A pooled 'Title (YYYY).ext' hardlinks into the tag tree as 'Title.ext'."""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "Solaris (1972).mkv").write_text("x")
|
||||||
|
f = File(source / "Solaris (1972).mkv", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.title = "Solaris"
|
||||||
|
f.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.create_structure_for_files([f], category_roots={"žánr": ""})
|
||||||
|
|
||||||
|
assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped
|
||||||
|
assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
|
||||||
|
|
||||||
|
def test_link_name_keeps_year_on_collision(
|
||||||
|
self, tmp_path, tag_manager
|
||||||
|
):
|
||||||
|
"""Two same-title films in one folder keep the year to disambiguate."""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "Solaris (1972).mkv").write_text("a")
|
||||||
|
(source / "Solaris (2002).mkv").write_text("b")
|
||||||
|
f1 = File(source / "Solaris (1972).mkv", tag_manager)
|
||||||
|
f1.tags.clear()
|
||||||
|
f1.title = "Solaris"
|
||||||
|
f1.csfd_cache = {"year": 1972}
|
||||||
|
f1.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
|
f1.add_tag(Tag("rok", "1972"))
|
||||||
|
f2 = File(source / "Solaris (2002).mkv", tag_manager)
|
||||||
|
f2.tags.clear()
|
||||||
|
f2.title = "Solaris"
|
||||||
|
f2.csfd_cache = {"year": 2002}
|
||||||
|
f2.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
|
f2.add_tag(Tag("rok", "2002"))
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
roots = {"žánr": "", "rok": "Dle roku"}
|
||||||
|
manager.create_structure_for_files([f1, f2], category_roots=roots)
|
||||||
|
|
||||||
|
# Same genre folder → clash → both keep the year
|
||||||
|
assert (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
|
||||||
|
assert (output / "Sci-Fi" / "Solaris (2002).mkv").exists()
|
||||||
|
assert not (output / "Sci-Fi" / "Solaris.mkv").exists()
|
||||||
|
# Separate year folders → no clash → clean name
|
||||||
|
assert (output / "Dle roku" / "1972" / "Solaris.mkv").exists()
|
||||||
|
assert (output / "Dle roku" / "2002" / "Solaris.mkv").exists()
|
||||||
|
|
||||||
|
def test_clean_naming_cleanup_is_consistent(
|
||||||
|
self, tmp_path, tag_manager
|
||||||
|
):
|
||||||
|
"""Clean-named links are stable: a second sync removes/creates nothing."""
|
||||||
|
source = tmp_path / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "Solaris (1972).mkv").write_text("x")
|
||||||
|
f = File(source / "Solaris (1972).mkv", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.title = "Solaris"
|
||||||
|
f.add_tag(Tag("žánr", "Sci-Fi"))
|
||||||
|
roots = {"žánr": ""}
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
manager.sync_structure([f], category_roots=roots)
|
||||||
|
created, _, removed, _ = manager.sync_structure([f], category_roots=roots)
|
||||||
|
assert (created, removed) == (0, 0)
|
||||||
|
|
||||||
def test_sync_removes_root_genre_folder_when_last_movie_drops_tag(
|
def test_sync_removes_root_genre_folder_when_last_movie_drops_tag(
|
||||||
self, temp_source_dir, temp_output_dir, tag_manager
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
):
|
):
|
||||||
@@ -263,8 +335,8 @@ class TestHardlinkManager:
|
|||||||
|
|
||||||
# Templated name inside the collection folder
|
# Templated name inside the collection folder
|
||||||
assert (temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt").exists()
|
assert (temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt").exists()
|
||||||
# Other categories keep the pool filename
|
# Other categories use the default clean title (no year, not the pool name)
|
||||||
assert (temp_output_dir / "Akční" / "file1.txt").exists()
|
assert (temp_output_dir / "Akční" / "Dr. No.txt").exists()
|
||||||
|
|
||||||
def test_filename_template_cleanup_is_consistent(
|
def test_filename_template_cleanup_is_consistent(
|
||||||
self, temp_source_dir, temp_output_dir, tag_manager
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
@@ -421,30 +493,27 @@ class TestHardlinkManager:
|
|||||||
assert fail2 == 0
|
assert fail2 == 0
|
||||||
|
|
||||||
def test_unique_name_on_conflict(self, temp_source_dir, temp_output_dir, tag_manager):
|
def test_unique_name_on_conflict(self, temp_source_dir, temp_output_dir, tag_manager):
|
||||||
"""Test že při konfliktu (jiný soubor) se použije unikátní jméno"""
|
"""Two different movies with the same clean name (no year to tell them
|
||||||
# Create first file
|
apart) in one folder get distinct links via a numeric suffix."""
|
||||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
f1.tags.clear()
|
f1.tags.clear()
|
||||||
f1.add_tag(Tag("test", "tag"))
|
f1.add_tag(Tag("test", "tag"))
|
||||||
|
|
||||||
manager = HardlinkManager(temp_output_dir)
|
|
||||||
manager.create_structure_for_files([f1])
|
|
||||||
|
|
||||||
# Create different file with same name in different location
|
|
||||||
source2 = temp_source_dir / "subdir"
|
source2 = temp_source_dir / "subdir"
|
||||||
source2.mkdir()
|
source2.mkdir()
|
||||||
(source2 / "file1.txt").write_text("different content")
|
(source2 / "file1.txt").write_text("different content")
|
||||||
|
|
||||||
f2 = File(source2 / "file1.txt", tag_manager)
|
f2 = File(source2 / "file1.txt", tag_manager)
|
||||||
f2.tags.clear()
|
f2.tags.clear()
|
||||||
f2.add_tag(Tag("test", "tag"))
|
f2.add_tag(Tag("test", "tag"))
|
||||||
|
|
||||||
# Should create file1_1.txt
|
# One generation over both files → the plan disambiguates the clash.
|
||||||
manager2 = HardlinkManager(temp_output_dir)
|
manager = HardlinkManager(temp_output_dir)
|
||||||
success, fail = manager2.create_structure_for_files([f2])
|
success, fail = manager.create_structure_for_files([f1, f2])
|
||||||
|
|
||||||
assert success == 1
|
assert success == 2 and fail == 0
|
||||||
assert (temp_output_dir / "test" / "tag" / "file1_1.txt").exists()
|
folder = temp_output_dir / "test" / "tag"
|
||||||
|
assert (folder / "file1.txt").exists()
|
||||||
|
assert (folder / "file1_2.txt").exists()
|
||||||
|
|
||||||
def test_czech_characters_in_tags(self, temp_source_dir, temp_output_dir, tag_manager):
|
def test_czech_characters_in_tags(self, temp_source_dir, temp_output_dir, tag_manager):
|
||||||
"""Test českých znaků v názvech tagů"""
|
"""Test českých znaků v názvech tagů"""
|
||||||
@@ -746,9 +815,11 @@ class TestEdgeCases:
|
|||||||
"""Test souboru se speciálními znaky v názvu"""
|
"""Test souboru se speciálními znaky v názvu"""
|
||||||
source = tmp_path / "source"
|
source = tmp_path / "source"
|
||||||
source.mkdir()
|
source.mkdir()
|
||||||
(source / "file with spaces (2024).txt").write_text("content")
|
# Parentheses that are NOT a four-digit year must survive verbatim
|
||||||
|
# (a trailing "(YYYY)" would be stripped by the naming rule).
|
||||||
|
(source / "file with spaces (part 2).txt").write_text("content")
|
||||||
|
|
||||||
f = File(source / "file with spaces (2024).txt", tag_manager)
|
f = File(source / "file with spaces (part 2).txt", tag_manager)
|
||||||
f.tags.clear()
|
f.tags.clear()
|
||||||
f.add_tag(Tag("test", "tag"))
|
f.add_tag(Tag("test", "tag"))
|
||||||
|
|
||||||
@@ -759,7 +830,7 @@ class TestEdgeCases:
|
|||||||
success, fail = manager.create_structure_for_files([f])
|
success, fail = manager.create_structure_for_files([f])
|
||||||
|
|
||||||
assert success == 1
|
assert success == 1
|
||||||
assert (output / "test" / "tag" / "file with spaces (2024).txt").exists()
|
assert (output / "test" / "tag" / "file with spaces (part 2).txt").exists()
|
||||||
|
|
||||||
def test_empty_category_filter(self, tmp_path, tag_manager):
|
def test_empty_category_filter(self, tmp_path, tag_manager):
|
||||||
"""Test s prázdným seznamem kategorií"""
|
"""Test s prázdným seznamem kategorií"""
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests for the pool naming convention helpers (src.core.naming)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.naming import (
|
||||||
|
canonical_pool_stem,
|
||||||
|
is_canonical_pool_stem,
|
||||||
|
is_valid_year,
|
||||||
|
parse_pool_stem,
|
||||||
|
year_from_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("year,ok", [
|
||||||
|
("1968", True), (1968, True), ("968", False), ("19688", False),
|
||||||
|
("abcd", False), ("", False), (" 1999 ", True),
|
||||||
|
])
|
||||||
|
def test_is_valid_year(year, ok):
|
||||||
|
assert is_valid_year(year) is ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_pool_stem_builds_title_year():
|
||||||
|
assert canonical_pool_stem("Matrix", 1999) == "Matrix (1999)"
|
||||||
|
assert canonical_pool_stem(" Matrix ", "1999") == "Matrix (1999)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_pool_stem_rejects_bad_year():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
canonical_pool_stem("Matrix", "99")
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_pool_stem_rejects_empty_title():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
canonical_pool_stem(" ", "1999")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_pool_stem_roundtrip():
|
||||||
|
assert parse_pool_stem("Matrix (1999)") == ("Matrix", 1999)
|
||||||
|
# title keeps its own parentheses; only the trailing year is peeled off
|
||||||
|
assert parse_pool_stem("Já, robot (2004)") == ("Já, robot", 2004)
|
||||||
|
assert parse_pool_stem("Kurz (special) (2019)") == ("Kurz (special)", 2019)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_pool_stem_non_canonical():
|
||||||
|
assert parse_pool_stem("Matrix") is None
|
||||||
|
assert parse_pool_stem("Matrix (99)") is None
|
||||||
|
assert parse_pool_stem("Matrix 1999") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_canonical_pool_stem():
|
||||||
|
assert is_canonical_pool_stem("Matrix (1999)")
|
||||||
|
assert not is_canonical_pool_stem("Matrix")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,year", [
|
||||||
|
("Matrix.1999.1080p.BluRay.mkv", "1999"),
|
||||||
|
("Šíleně smutná princezna (1968).mkv", "1968"),
|
||||||
|
("Movie 2001 2010 remux.mkv", "2010"), # last standalone year wins
|
||||||
|
("No Year Here.mkv", None),
|
||||||
|
("Film 12345.mkv", None), # not a bare 4-digit year
|
||||||
|
])
|
||||||
|
def test_year_from_filename(name, year):
|
||||||
|
assert year_from_filename(name) == year
|
||||||
Reference in New Issue
Block a user