Adopt "Title (YYYY)" pool naming and clean Filmotéka link names
This commit is contained in:
+7
-1
@@ -139,12 +139,18 @@ class File:
|
||||
if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit():
|
||||
year = t.name
|
||||
break
|
||||
if year is None:
|
||||
# Last resort: recover it from a canonical "Title (YYYY)" filename.
|
||||
from .naming import parse_pool_stem
|
||||
parsed = parse_pool_stem(self.file_path.stem)
|
||||
if parsed is not None:
|
||||
year = parsed[1]
|
||||
rating = cache.get("rating")
|
||||
# user attributes first; core fields take precedence over same-named keys
|
||||
context = dict(self.attributes)
|
||||
context.update({
|
||||
"title": self.title or self.file_path.stem,
|
||||
"year": "" if year is None else year,
|
||||
"year": "" if year is None else str(year),
|
||||
"rating": "" if rating is None else rating,
|
||||
"ext": self.file_path.suffix,
|
||||
"stem": self.file_path.stem,
|
||||
|
||||
+121
-4
@@ -7,6 +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 typing import Callable, Iterable
|
||||
from src.core.config import (
|
||||
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
||||
@@ -326,8 +327,14 @@ class FileManager:
|
||||
def import_movie(
|
||||
self, source: Path, title: str, csfd_link: str | None = None,
|
||||
move: bool = False, on_conflict: str = "suffix",
|
||||
year: int | str | None = 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``
|
||||
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)
|
||||
and import the new one under the plain name.
|
||||
- ``"skip"``: do not import; return ``None``.
|
||||
|
||||
Raises:
|
||||
ValueError: a ``year`` was given but is not a four-digit number.
|
||||
"""
|
||||
movies = self.movies_dir
|
||||
pool = self.pool_dir
|
||||
@@ -351,9 +361,11 @@ class FileManager:
|
||||
|
||||
source = Path(source)
|
||||
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()
|
||||
|
||||
if conflict and on_conflict == "skip":
|
||||
@@ -367,7 +379,7 @@ class FileManager:
|
||||
# "suffix": never clobber an existing exact filename
|
||||
counter = 1
|
||||
while target.exists():
|
||||
target = movies / f"{safe_title}_{counter}{source.suffix}"
|
||||
target = movies / f"{stem}_{counter}{source.suffix}"
|
||||
counter += 1
|
||||
|
||||
if move:
|
||||
@@ -420,6 +432,111 @@ class FileManager:
|
||||
self.on_files_changed(self.filelist)
|
||||
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):
|
||||
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
||||
for f in files_objs:
|
||||
|
||||
+133
-79
@@ -19,9 +19,11 @@ Example:
|
||||
"""
|
||||
import os
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional, Dict, Set
|
||||
from .file import File
|
||||
from .naming import is_valid_year, parse_pool_stem
|
||||
|
||||
|
||||
class _SafeDict(dict):
|
||||
@@ -69,24 +71,102 @@ class HardlinkManager:
|
||||
return {cat: cat for cat in categories}
|
||||
return None
|
||||
|
||||
def _link_name(
|
||||
self, file_obj: File, tag, templates: Optional[Dict[str, str]]
|
||||
) -> str:
|
||||
"""Hardlink filename for a tag — a per-category template or the pool name.
|
||||
def _clean_title(self, file_obj: File) -> str:
|
||||
"""The movie's clean title (no trailing ``(YYYY)``), path-safe."""
|
||||
ctx = file_obj.name_context()
|
||||
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
|
||||
the file's ``name_context``; path separators are flattened. Any failure or
|
||||
empty result falls back to the pool filename.
|
||||
"""
|
||||
template = templates.get(tag.category) if templates else None
|
||||
if not template:
|
||||
return file_obj.filename
|
||||
def _default_link_name(self, file_obj: File) -> str:
|
||||
"""Filmotéka hardlink name: the clean title + extension (no year)."""
|
||||
return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}"
|
||||
|
||||
def _year_link_name(self, file_obj: File) -> Optional[str]:
|
||||
"""``Title (YYYY).ext`` used to disambiguate a name collision, or None."""
|
||||
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:
|
||||
rendered = template.format_map(_SafeDict(file_obj.name_context()))
|
||||
except (ValueError, KeyError, IndexError, AttributeError):
|
||||
return file_obj.filename
|
||||
return self._default_link_name(file_obj)
|
||||
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:
|
||||
"""Folder name for a tag — its value run through the category transform.
|
||||
@@ -185,41 +265,28 @@ class HardlinkManager:
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for file_obj in files:
|
||||
if not file_obj.tags:
|
||||
continue
|
||||
plan = self._plan_links(
|
||||
files, roots, category_transforms, category_filename_templates)
|
||||
for file_obj, target_file in plan:
|
||||
try:
|
||||
if not dry_run:
|
||||
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target_file.exists():
|
||||
# Already the right link → nothing to do (not re-counted).
|
||||
if self._is_same_file(file_obj.file_path, target_file):
|
||||
continue
|
||||
# A wrong occupant sits on the planned path; the plan is
|
||||
# authoritative, so replace it.
|
||||
target_file.unlink()
|
||||
|
||||
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)
|
||||
os.link(file_obj.file_path, target_file)
|
||||
|
||||
try:
|
||||
if not dry_run:
|
||||
# Create directory structure
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.created_links.append(target_file)
|
||||
success_count += 1
|
||||
|
||||
# Skip if link already exists
|
||||
if target_file.exists():
|
||||
# Check if it's already a hardlink to the same file
|
||||
if self._is_same_file(file_obj.file_path, target_file):
|
||||
continue
|
||||
else:
|
||||
# Different file exists, add suffix
|
||||
target_file = self._get_unique_name(target_file)
|
||||
|
||||
# Create hardlink
|
||||
os.link(file_obj.file_path, target_file)
|
||||
|
||||
self.created_links.append(target_file)
|
||||
success_count += 1
|
||||
|
||||
except OSError as e:
|
||||
self.errors.append((file_obj.file_path, str(e)))
|
||||
fail_count += 1
|
||||
except OSError as e:
|
||||
self.errors.append((file_obj.file_path, str(e)))
|
||||
fail_count += 1
|
||||
|
||||
return success_count, fail_count
|
||||
|
||||
@@ -291,7 +358,7 @@ class HardlinkManager:
|
||||
created = 0
|
||||
fail = 0
|
||||
for file_obj in chosen:
|
||||
target = base / file_obj.filename
|
||||
target = base / self._default_link_name(file_obj)
|
||||
try:
|
||||
if not dry_run:
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
@@ -299,7 +366,13 @@ class HardlinkManager:
|
||||
if self._is_same_file(file_obj.file_path, target):
|
||||
created += 1
|
||||
continue
|
||||
target = self._get_unique_name(target)
|
||||
# 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)
|
||||
os.link(file_obj.file_path, target)
|
||||
self.created_links.append(target)
|
||||
created += 1
|
||||
@@ -418,22 +491,9 @@ class HardlinkManager:
|
||||
List of tuples (source_path, target_path)
|
||||
"""
|
||||
roots = self._resolve_roots(categories, category_roots)
|
||||
preview = []
|
||||
|
||||
for file_obj in files:
|
||||
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
|
||||
plan = self._plan_links(
|
||||
files, roots, category_transforms, category_filename_templates)
|
||||
return [(file_obj.file_path, target_file) for file_obj, target_file in plan]
|
||||
|
||||
def find_obsolete_links(
|
||||
self,
|
||||
@@ -479,26 +539,20 @@ class HardlinkManager:
|
||||
continue
|
||||
|
||||
# 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
|
||||
# name in the same folder), so the value is a *set* of valid inodes.
|
||||
# Matching by inode too — not just the path — is what catches an **orphan
|
||||
# that squats the expected name**: a link whose name is right but which
|
||||
# points to an old inode (movie re-imported/replaced) is still obsolete,
|
||||
# while the correctly re-created link (forced to a ``_1`` suffix because
|
||||
# the orphan holds the base name) is *not* wrongly swept.
|
||||
# point at — taken from the very same plan that creates the links, so the
|
||||
# two never disagree on a name. Matching by inode too — not just the path
|
||||
# — is what catches an **orphan that squats the expected name**: a link
|
||||
# whose name is right but which points to an old inode (movie
|
||||
# re-imported/replaced) is still obsolete, while the correctly created
|
||||
# link is *not* wrongly swept.
|
||||
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:
|
||||
file_inode = file_obj.file_path.stat().st_ino
|
||||
expected.setdefault(path, set()).add(file_obj.file_path.stat().st_ino)
|
||||
except OSError:
|
||||
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).
|
||||
# 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
|
||||
Reference in New Issue
Block a user