1 Commits
Author SHA1 Message Date
Bulk RenamerandCursor 28ec7746b8 Release v1.0.7: unique AppImage sizes for Gear Lever updates.
Forgejo compares release asset size only; v1.0.5 and v1.0.6 were identical at 62933496 bytes.

Co-authored-by: Cursor <[email protected]>
2026-07-03 17:36:58 -05:00
13 changed files with 65 additions and 518 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.11 1.0.7
+2 -6
View File
@@ -39,12 +39,8 @@ if [[ -f "packaging/release-stamp-${VERSION}.txt" ]]; then
fi fi
# Gear Lever (Forgejo) treats same-size AppImages as "no update". Version-scoped # Gear Lever (Forgejo) treats same-size AppImages as "no update". Version-scoped
# padding keeps each release a unique download size without affecting the app. # padding keeps each release a unique download size without affecting the app.
VER_MAJOR="${VERSION%%.*}" IFS=. read -r VER_MAJOR VER_MINOR VER_PATCH <<< "${VERSION}.0.0"
VER_REST="${VERSION#*.}" PAD_KB=$((300 + VER_MAJOR * 100 + VER_MINOR * 20 + VER_PATCH * 10))
VER_MINOR="${VER_REST%%.*}"
VER_PATCH="${VER_REST#*.}"
VER_PATCH="${VER_PATCH%%.*}"
PAD_KB=$((300 + 10#${VER_MAJOR:-0} * 100 + 10#${VER_MINOR:-0} * 20 + 10#${VER_PATCH:-0} * 10))
dd if=/dev/urandom of="$APPDIR/usr/share/hsrename/.release-pad" bs=1024 count="$PAD_KB" status=none dd if=/dev/urandom of="$APPDIR/usr/share/hsrename/.release-pad" bs=1024 count="$PAD_KB" status=none
echo "Release padding: ${PAD_KB} KiB (Gear Lever update detection)" echo "Release padding: ${PAD_KB} KiB (Gear Lever update detection)"
+27 -243
View File
@@ -6,14 +6,13 @@ from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from difflib import SequenceMatcher from difflib import SequenceMatcher
from pathlib import Path
from typing import Optional from typing import Optional
from .tvdb_client import TvdbEpisode from .tvdb_client import TvdbEpisode
# S01E05 - Title or S01E05-E06 - Title # S01E05 - Title or S01E05-E06 - Title
PATTERN_SXXEXX = re.compile( PATTERN_SXXEXX = re.compile(
r"^(.*?)([Ss])(\d+)([Ee])(\d+)(-[Ee](\d+))?(.*)$", r"^(.*?[Ss])(\d+)([Ee])(\d+)(-[Ee](\d+))?(.*)$",
) )
# Show Name 04x01 Title, 4x01 - Title, etc. # Show Name 04x01 Title, 4x01 - Title, etc.
PATTERN_NXNN = re.compile( PATTERN_NXNN = re.compile(
@@ -27,27 +26,6 @@ DEFAULT_EPISODE_PATTERN = PATTERN_SXXEXX.pattern
class EpisodeTarget: class EpisodeTarget:
season: int season: int
episode: int episode: int
episode_end: Optional[int] = None
@property
def span(self) -> int:
if self.episode_end is not None and self.episode_end > self.episode:
return self.episode_end - self.episode + 1
return 1
def format_code(self, padding: int = 2) -> str:
pad = max(1, padding)
s = str(self.season).zfill(pad)
e1 = str(self.episode).zfill(pad)
if self.episode_end is not None and self.episode_end > self.episode:
e2 = str(self.episode_end).zfill(pad)
return f"S{s}E{e1}-E{e2}"
return f"S{s}E{e1}"
def split_combined_title(name: str) -> list[str]:
"""Split a combined-order episode title like 'Ep A/Ep B' into parts."""
return [p.strip() for p in name.replace(" / ", "/").split("/") if p.strip()]
def normalize_title(title: str) -> str: def normalize_title(title: str) -> str:
@@ -69,13 +47,6 @@ def _clean_title(rest: str) -> str:
title = title[1:].strip() title = title[1:].strip()
if title.startswith("_"): if title.startswith("_"):
title = title[1:].strip() title = title[1:].strip()
title = re.sub(
r"\(\s*(?:2160p|1080p|720p|480p|4k|uhd|hd|sd|web[- ]?dl|bluray|dvdrip)\s*\)",
"",
title,
flags=re.IGNORECASE,
)
title = re.sub(r"\s+", " ", title).strip()
return title return title
@@ -88,7 +59,7 @@ def parse_episode_stem(stem: str, pattern: str = DEFAULT_EPISODE_PATTERN) -> Opt
m = PATTERN_SXXEXX.match(stem) m = PATTERN_SXXEXX.match(stem)
if m: if m:
prefix, s_letter, season_s, e_letter, ep_s, _range_block, ep2_s, rest = m.groups() prefix_before_s, season_s, _e, ep_s, _dash, ep2_s, rest = m.groups()
try: try:
season = int(season_s) season = int(season_s)
old_first = int(ep_s) old_first = int(ep_s)
@@ -106,9 +77,7 @@ def parse_episode_stem(stem: str, pattern: str = DEFAULT_EPISODE_PATTERN) -> Opt
title = _clean_title(rest) title = _clean_title(rest)
return { return {
"format": "sxxexx", "format": "sxxexx",
"prefix": prefix, "prefix_before_s": prefix_before_s,
"s_letter": s_letter,
"e_letter": e_letter,
"season": season, "season": season,
"season_pad": len(season_s), "season_pad": len(season_s),
"episode_pad": len(ep_s), "episode_pad": len(ep_s),
@@ -159,9 +128,7 @@ def rewrite_episode_stem(
pad = max(1, padding) pad = max(1, padding)
new_season = target.season new_season = target.season
new_ep = target.episode new_ep = target.episode
if target.episode_end is not None and target.episode_end > target.episode: title = parsed["title"]
span = target.episode_end - target.episode + 1
else:
span = parsed["span"] span = parsed["span"]
if parsed["format"] == "nxnn": if parsed["format"] == "nxnn":
@@ -175,14 +142,11 @@ def rewrite_episode_stem(
s_pad = max(parsed["season_pad"], len(str(new_season))) s_pad = max(parsed["season_pad"], len(str(new_season)))
e_pad = max(parsed["episode_pad"], pad, len(str(new_ep))) e_pad = max(parsed["episode_pad"], pad, len(str(new_ep)))
e1 = str(new_ep).zfill(e_pad) e1 = str(new_ep).zfill(e_pad)
head = ( prefix = f"{parsed['prefix_before_s']}S{new_season:0{s_pad}d}E"
f"{parsed['prefix']}{parsed['s_letter']}{new_season:0{s_pad}d}{parsed['e_letter']}"
)
if span <= 1: if span <= 1:
return f"{head}{e1}{parsed['suffix']}" return f"{prefix}{e1}{parsed['suffix']}"
e2 = str(new_ep + span - 1).zfill(e_pad) e2 = str(new_ep + span - 1).zfill(e_pad)
range_prefix = f"-{parsed['e_letter']}" return f"{prefix}{e1}-E{e2}{parsed['suffix']}"
return f"{head}{e1}{range_prefix}{e2}{parsed['suffix']}"
def rewrite_episode_number( def rewrite_episode_number(
@@ -211,173 +175,40 @@ def _similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio() return SequenceMatcher(None, a, b).ratio()
def _coerce_target( def _coerce_target(value: EpisodeTarget | tuple[int, int] | int, parsed: dict) -> EpisodeTarget:
value: EpisodeTarget | tuple[int, ...] | int,
parsed: dict,
) -> EpisodeTarget:
if isinstance(value, EpisodeTarget): if isinstance(value, EpisodeTarget):
return value return value
if isinstance(value, tuple): if isinstance(value, tuple):
if len(value) >= 3:
return EpisodeTarget(
season=int(value[0]),
episode=int(value[1]),
episode_end=int(value[2]) if value[2] is not None else None,
)
if len(value) >= 2:
return EpisodeTarget(season=int(value[0]), episode=int(value[1])) return EpisodeTarget(season=int(value[0]), episode=int(value[1]))
return EpisodeTarget(season=parsed["season"], episode=int(value[0]))
return EpisodeTarget(season=parsed["season"], episode=int(value)) return EpisodeTarget(season=parsed["season"], episode=int(value))
def resolve_combined_to_official(
combined_ep: TvdbEpisode,
official_episodes: list[TvdbEpisode],
) -> Optional[EpisodeTarget]:
"""Map a combined-order episode to official aired SxxExx(-Exx) numbers."""
parts = split_combined_title(combined_ep.name)
if not parts:
return None
matched: list[int] = []
season = combined_ep.season_number
season_official = [ep for ep in official_episodes if ep.season_number == season]
for part in parts:
pn = normalize_title(part)
best_num: Optional[int] = None
best_score = 0.0
for ep in season_official:
score = _similarity(pn, normalize_title(ep.name))
if score > best_score:
best_score = score
best_num = ep.number
if best_num is not None and best_score >= 0.72:
matched.append(best_num)
if not matched:
return None
start, end = min(matched), max(matched)
return EpisodeTarget(
season=season,
episode=start,
episode_end=end if end > start else None,
)
def _combined_title_variants(name: str) -> list[str]:
variants = [normalize_title(name.replace("/", " ")), normalize_title(name)]
for part in split_combined_title(name):
variants.append(normalize_title(part))
return variants
def _combined_match_score(fnorm: str, combined_name: str, file_title: str = "") -> float:
variants = _combined_title_variants(combined_name)
best = max((_similarity(fnorm, v) for v in variants), default=0.0)
if file_title and _dual_title_matches_combined(file_title, combined_name):
best = max(best, 0.96)
return best
def _dual_title_matches_combined(file_title: str, combined_name: str) -> bool:
"""True when the filename lists both stories in a combined-order pair."""
file_parts = [p.strip() for p in re.split(r"\s+-\s+", file_title) if p.strip()]
combined_parts = split_combined_title(combined_name)
if len(file_parts) < 2 or len(combined_parts) < 2:
return False
matched = 0
for fp in file_parts[: len(combined_parts)]:
fn = normalize_title(fp)
if max(_similarity(fn, normalize_title(cp)) for cp in combined_parts) >= 0.72:
matched += 1
return matched >= 2
def _combined_allowed_for_file(
parsed: dict,
target: EpisodeTarget,
combined_name: str = "",
) -> bool:
"""Treat as multi-episode when the episode tag or dual title fits the aired range."""
if target.span <= 1:
return True
if parsed.get("span", 1) > 1:
return True
file_title = parsed.get("title") or ""
if combined_name and _dual_title_matches_combined(file_title, combined_name):
return True
file_ep = parsed.get("old_first")
if file_ep is None:
return False
end = target.episode_end if target.episode_end is not None else target.episode
return file_ep == target.episode or file_ep == end
def _range_overlaps(
season: int,
start: int,
end: int,
used_ranges: list[tuple[int, int, int]],
) -> bool:
for s, a, b in used_ranges:
if s != season:
continue
if not (end < a or start > b):
return True
return False
def _target_range(target: EpisodeTarget) -> tuple[int, int, int]:
end = target.episode_end if target.episode_end is not None else target.episode
return target.season, target.episode, end
def _apply_season_hint(score: float, target: EpisodeTarget, parsed: dict) -> float:
"""Prefer matches in the same season as the filename's episode code."""
file_season = parsed.get("season")
if file_season is None:
return score
if target.season == file_season:
return score + 0.08
return score * 0.5
def match_filenames_to_episodes( def match_filenames_to_episodes(
filenames: list[str], filenames: list[str],
episodes: list[TvdbEpisode], episodes: list[TvdbEpisode],
pattern: str = DEFAULT_EPISODE_PATTERN, pattern: str = DEFAULT_EPISODE_PATTERN,
min_score: float = 0.65, min_score: float = 0.65,
season_filter: int = 0, season_filter: int = 0,
official_episodes: Optional[list[TvdbEpisode]] = None,
combined_episodes: Optional[list[TvdbEpisode]] = None,
) -> tuple[dict[str, EpisodeTarget], list[str], list[str]]: ) -> tuple[dict[str, EpisodeTarget], list[str], list[str]]:
""" """
Match filenames to TheTVDB episodes by title. Match filenames to TheTVDB episodes by title.
season_filter: 0 = use all episodes; else only episodes from that season. season_filter: 0 = use all episodes; else only episodes from that season.
official_episodes + combined_episodes: when set, also match combined-order titles Returns mapping filename -> (season, episode), unmatched list, notes.
and map to official aired numbers as Jellyfin multi-episode ranges (S01E01-E02).
Returns mapping filename -> target, unmatched list, notes.
""" """
if season_filter > 0: if season_filter > 0:
episodes = [ep for ep in episodes if ep.season_number == season_filter] episodes = [ep for ep in episodes if ep.season_number == season_filter]
official = official_episodes or episodes
if season_filter > 0:
official = [ep for ep in official if ep.season_number == season_filter]
combined = combined_episodes
if combined and season_filter > 0:
combined = [ep for ep in combined if ep.season_number == season_filter]
file_entries: list[tuple[str, str, str, dict]] = [] file_entries: list[tuple[str, str, str, dict]] = []
for name in filenames: for name in filenames:
base = Path(name).name stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name
stem = base.rsplit(".", 1)[0] if "." in base and not base.startswith(".") else base
parsed = parse_episode_stem(stem, pattern) parsed = parse_episode_stem(stem, pattern)
if not parsed: if not parsed or not parsed["title"]:
continue continue
if season_filter > 0 and parsed["season"] != season_filter: if season_filter > 0 and parsed["season"] != season_filter:
continue continue
norm = normalize_title(parsed["title"]) if parsed["title"] else "" norm = normalize_title(parsed["title"])
if norm or parsed.get("span", 1) > 1: if norm:
file_entries.append((name, norm, parsed["title"], parsed)) file_entries.append((name, norm, parsed["title"], parsed))
ep_entries = [ ep_entries = [
@@ -385,85 +216,38 @@ def match_filenames_to_episodes(
for ep in episodes for ep in episodes
] ]
combined_entries: list[tuple[TvdbEpisode, EpisodeTarget, list[str]]] = [] pairs: list[tuple[float, str, int, int, str, str]] = []
if combined: for fname, fnorm, raw_title, _parsed in file_entries:
for cep in combined:
target = resolve_combined_to_official(cep, official)
if target is None:
continue
combined_entries.append((cep, target, _combined_title_variants(cep.name)))
pairs: list[tuple[float, str, EpisodeTarget, str, str]] = []
for fname, fnorm, raw_title, parsed in file_entries:
for season, ep_num, enorm, ep_name in ep_entries: for season, ep_num, enorm, ep_name in ep_entries:
if not fnorm: score = _similarity(fnorm, enorm)
continue pairs.append((score, fname, season, ep_num, raw_title, ep_name))
score = _apply_season_hint(
_similarity(fnorm, enorm),
EpisodeTarget(season=season, episode=ep_num),
parsed,
)
pairs.append(
(
score,
fname,
EpisodeTarget(season=season, episode=ep_num),
raw_title,
ep_name,
)
)
for _cep, target, variants in combined_entries:
if not fnorm:
continue
if not _combined_allowed_for_file(parsed, target, _cep.name):
continue
best = _combined_match_score(fnorm, _cep.name, raw_title)
best = _apply_season_hint(best, target, parsed)
pairs.append((best, fname, target, raw_title, _cep.name))
pairs.sort(key=lambda x: (-x[0], -x[2].span, x[1], x[2].season, x[2].episode)) pairs.sort(key=lambda x: (-x[0], x[1], x[2], x[3]))
mapping: dict[str, EpisodeTarget] = {} mapping: dict[str, EpisodeTarget] = {}
used_files: set[str] = set() used_files: set[str] = set()
used_ranges: list[tuple[int, int, int]] = [] used_eps: set[tuple[int, int]] = set()
notes: list[str] = [] notes: list[str] = []
multi_file_names = { for score, fname, season, ep_num, raw_title, ep_name in pairs:
fname for fname, _fnorm, _raw, parsed in file_entries if parsed.get("span", 1) > 1
}
def _assign_pairs(candidates: list[tuple[float, str, EpisodeTarget, str, str]]) -> None:
for score, fname, target, raw_title, ep_name in candidates:
if score < min_score: if score < min_score:
break break
if fname in used_files: ep_key = (season, ep_num)
if fname in used_files or ep_key in used_eps:
continue continue
season, start, end = _target_range(target) mapping[fname] = EpisodeTarget(season=season, episode=ep_num)
if _range_overlaps(season, start, end, used_ranges):
continue
mapping[fname] = target
used_files.add(fname) used_files.add(fname)
used_ranges.append((season, start, end)) used_eps.add(ep_key)
pct = min(100, int(round(score * 100))) pct = int(round(score * 100))
code = target.format_code()
notes.append( notes.append(
f"{fname}: {code} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)" f"{fname}: S{season:02d}E{ep_num:02d} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)"
) )
multi_pairs = [p for p in pairs if p[1] in multi_file_names]
other_pairs = [p for p in pairs if p[1] not in multi_file_names]
_assign_pairs(multi_pairs)
_assign_pairs(other_pairs)
unmatched_files = [ unmatched_files = [
name for name in filenames name for name in filenames
if name not in mapping if name not in mapping
and parse_episode_stem( and parse_episode_stem(
( name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name,
Path(name).name.rsplit(".", 1)[0]
if "." in Path(name).name and not Path(name).name.startswith(".")
else Path(name).name
),
pattern, pattern,
) )
] ]
-86
View File
@@ -1,86 +0,0 @@
"""Scan folders for rename candidates with optional recursion and extension filters."""
from __future__ import annotations
import os
import re
from pathlib import Path
from .pipeline import UNDO_FILENAME
FILE_TYPE_PRESETS: dict[str, str] = {
"All files": "",
"Video": ".mkv,.mp4,.avi,.webm,.m4v,.mov",
"Audio": ".mp3,.flac,.wav,.ogg,.m4a,.aac",
"Images": ".jpg,.jpeg,.png,.gif,.webp,.bmp",
}
def parse_extension_filter(spec: str) -> frozenset[str] | None:
"""
Parse a user extension filter into a set of lowercase extensions with a leading dot.
Returns None when empty (match all files).
"""
spec = spec.strip()
if not spec:
return None
exts: set[str] = set()
for part in re.split(r"[,;\s]+", spec):
part = part.strip().lower()
if not part:
continue
if part.startswith("*."):
part = part[1:]
if not part.startswith("."):
part = "." + part
exts.add(part)
return frozenset(exts) if exts else None
def file_extension(rel_path: str) -> str:
"""Return the lowercase extension (with dot) of the basename, or '' if none."""
name = Path(rel_path).name
if "." in name and not name.startswith("."):
return "." + name.rsplit(".", 1)[-1].lower()
return ""
def matches_extension(rel_path: str, extensions: frozenset[str] | None) -> bool:
if extensions is None:
return True
return file_extension(rel_path) in extensions
def list_files(
base_dir: str,
*,
recursive: bool = False,
extensions: frozenset[str] | None = None,
) -> list[str]:
"""Return sorted paths relative to base_dir."""
base = Path(base_dir)
if not base.is_dir():
return []
found: list[str] = []
def consider(path: Path) -> None:
if not path.is_file():
return
if path.name.startswith("."):
return
if path.name == UNDO_FILENAME:
return
rel = path.relative_to(base).as_posix()
if matches_extension(rel, extensions):
found.append(rel)
if recursive:
for dirpath, dirnames, filenames in os.walk(base):
dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
for fn in sorted(filenames):
consider(Path(dirpath) / fn)
else:
for entry in sorted(base.iterdir(), key=lambda p: p.name.lower()):
consider(entry)
return sorted(found, key=str.lower)
+7 -19
View File
@@ -25,24 +25,13 @@ def apply_pipeline(
index: int, index: int,
total: int, total: int,
) -> str: ) -> str:
"""Apply all enabled rules to a filename or relative path. Returns new name/path.""" """Apply all enabled rules to a single filename (no path). Returns new filename."""
rel = Path(name) stem, ext = _split_name(name)
if len(rel.parts) > 1:
parent = rel.parent
file_name = rel.name
else:
parent = None
file_name = name
stem, ext = _split_name(file_name)
for r in rules: for r in rules:
if not r.enabled: if not r.enabled:
continue continue
stem, ext = r.apply(stem, ext, index, total, original_name=name) stem, ext = r.apply(stem, ext, index, total, original_name=name)
new_name = stem + ext return stem + ext
if parent is not None:
return str(parent / new_name)
return new_name
def compute_preview( def compute_preview(
@@ -88,12 +77,11 @@ def perform_renames(
final_names = {n for _, n in step1} final_names = {n for _, n in step1}
temp_map = [] temp_map = []
for i, (old_path, new_name) in enumerate(step1): for i, (old_path, new_name) in enumerate(step1):
new_path = base / new_name temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}" while temp_name in final_names or (base / temp_name).exists():
while temp_path.name in final_names or temp_path.exists() or temp_path == new_path:
i += 1 i += 1
temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}" temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
temp_map.append((old_path, temp_path, new_path)) temp_map.append((old_path, base / temp_name, base / new_name))
if dry_run: if dry_run:
for old_p, temp_p, new_p in temp_map: for old_p, temp_p, new_p in temp_map:
+1 -1
View File
@@ -20,7 +20,7 @@ SEASON_TYPE_CHOICES: list[tuple[str, str]] = [
("Official / aired order", "official"), ("Official / aired order", "official"),
("DVD order", "dvd"), ("DVD order", "dvd"),
("Absolute order", "absolute"), ("Absolute order", "absolute"),
("Combined order (multi-episode files)", "alternate"), ("Alternate order", "alternate"),
("Regional order", "regional"), ("Regional order", "regional"),
] ]
+10 -79
View File
@@ -22,14 +22,11 @@ from PyQt6.QtWidgets import (
QHeaderView, QHeaderView,
QAbstractItemView, QAbstractItemView,
QFrame, QFrame,
QCheckBox,
QComboBox,
) )
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel, QSettings from PyQt6.QtCore import Qt, QDir, QItemSelectionModel
from PyQt6.QtGui import QFont, QColor from PyQt6.QtGui import QFont, QColor
from engine.pipeline import compute_preview, perform_renames, save_undo_log, load_undo_log, perform_undo from engine.pipeline import compute_preview, perform_renames, save_undo_log, load_undo_log, perform_undo
from engine.file_scan import FILE_TYPE_PRESETS, list_files, parse_extension_filter, file_extension
from engine.rules import Rule from engine.rules import Rule
from .rule_widgets import ( from .rule_widgets import (
ReplaceRuleWidget, ReplaceRuleWidget,
@@ -53,9 +50,7 @@ class MainWindow(QMainWindow):
self._base_dir = "" self._base_dir = ""
self._file_names: list[str] = [] self._file_names: list[str] = []
self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview
self._split: QSplitter | None = None
self._setup_ui() self._setup_ui()
self._restore_layout()
self._refresh_preview() self._refresh_preview()
def _setup_ui(self): def _setup_ui(self):
@@ -75,22 +70,6 @@ class MainWindow(QMainWindow):
dir_layout.addWidget(browse_btn) dir_layout.addWidget(browse_btn)
layout.addLayout(dir_layout) layout.addLayout(dir_layout)
filter_layout = QHBoxLayout()
self.recursive_cb = QCheckBox("Include subfolders")
self.recursive_cb.toggled.connect(self._reload_files)
filter_layout.addWidget(self.recursive_cb)
filter_layout.addWidget(QLabel("File types:"))
self.types_preset = QComboBox()
self.types_preset.addItems(list(FILE_TYPE_PRESETS.keys()) + ["Custom"])
self.types_preset.currentTextChanged.connect(self._on_types_preset_changed)
filter_layout.addWidget(self.types_preset)
self.types_edit = QLineEdit()
self.types_edit.setPlaceholderText("e.g. .mkv,.mp4 or *.avi (empty = all types)")
self.types_edit.setReadOnly(True)
self.types_edit.editingFinished.connect(self._reload_files)
filter_layout.addWidget(self.types_edit, 1)
layout.addLayout(filter_layout)
# Split: left = rules, right = file list + preview # Split: left = rules, right = file list + preview
split = QSplitter(Qt.Orientation.Horizontal) split = QSplitter(Qt.Orientation.Horizontal)
@@ -174,53 +153,13 @@ class MainWindow(QMainWindow):
split.addWidget(right) split.addWidget(right)
split.setSizes([320, 680]) split.setSizes([320, 680])
self._split = split
layout.addWidget(split, 1) layout.addWidget(split, 1)
def _settings(self) -> QSettings:
return QSettings()
def _restore_layout(self) -> None:
settings = self._settings()
geometry = settings.value("window/geometry")
if geometry is not None:
self.restoreGeometry(geometry)
if self._split is not None:
split_state = settings.value("splitter/state")
if split_state is not None:
self._split.restoreState(split_state)
header = self.preview_table.horizontalHeader()
header_state = settings.value("preview_table/header")
if header_state is not None:
header.restoreState(header_state)
def _save_layout(self) -> None:
settings = self._settings()
settings.setValue("window/geometry", self.saveGeometry())
if self._split is not None:
settings.setValue("splitter/state", self._split.saveState())
settings.setValue(
"preview_table/header",
self.preview_table.horizontalHeader().saveState(),
)
def closeEvent(self, event) -> None:
self._save_layout()
super().closeEvent(event)
def _browse(self): def _browse(self):
path = QFileDialog.getExistingDirectory(self, "Select folder") path = QFileDialog.getExistingDirectory(self, "Select folder")
if path: if path:
self.dir_edit.setText(path) self.dir_edit.setText(path)
def _on_types_preset_changed(self, preset: str):
if preset != "Custom":
self.types_edit.setText(FILE_TYPE_PRESETS.get(preset, ""))
self.types_edit.setReadOnly(True)
else:
self.types_edit.setReadOnly(False)
self._reload_files()
def _on_dir_changed(self): def _on_dir_changed(self):
path = self.dir_edit.text().strip() path = self.dir_edit.text().strip()
if not path or not os.path.isdir(path): if not path or not os.path.isdir(path):
@@ -228,18 +167,9 @@ class MainWindow(QMainWindow):
self._file_names = [] self._file_names = []
else: else:
self._base_dir = path self._base_dir = path
self._reload_files() self._file_names = sorted(
return f for f in os.listdir(path)
self._refresh_preview() if os.path.isfile(os.path.join(path, f)) and not f.startswith(".")
def _reload_files(self):
if not self._base_dir:
return
extensions = parse_extension_filter(self.types_edit.text())
self._file_names = list_files(
self._base_dir,
recursive=self.recursive_cb.isChecked(),
extensions=extensions,
) )
self._refresh_preview() self._refresh_preview()
@@ -259,7 +189,9 @@ class MainWindow(QMainWindow):
def _ext_for(self, filename: str) -> str: def _ext_for(self, filename: str) -> str:
"""Return file extension with leading dot, or empty string if none.""" """Return file extension with leading dot, or empty string if none."""
return file_extension(filename) if "." in filename and not filename.startswith("."):
return "." + filename.rsplit(".", 1)[-1].lower()
return ""
def _on_preview_selection_changed(self): def _on_preview_selection_changed(self):
"""Show preview (new name) only in selected rows; others show original.""" """Show preview (new name) only in selected rows; others show original."""
@@ -331,8 +263,7 @@ class MainWindow(QMainWindow):
self.preview_status.setText("Warning: some new names are duplicated. Fix rules to avoid overwriting.") self.preview_status.setText("Warning: some new names are duplicated. Fix rules to avoid overwriting.")
self.preview_status.setStyleSheet("color: #c00;") self.preview_status.setStyleSheet("color: #c00;")
else: else:
scope = "including subfolders" if self.recursive_cb.isChecked() else "top folder only" self.preview_status.setText(f"{len(preview)} file(s). Ready to rename.")
self.preview_status.setText(f"{len(preview)} file(s) ({scope}). Ready to rename.")
self.preview_status.setStyleSheet("") self.preview_status.setStyleSheet("")
def _apply_renames(self): def _apply_renames(self):
@@ -386,7 +317,7 @@ class MainWindow(QMainWindow):
else: else:
save_undo_log(self._base_dir, renames) save_undo_log(self._base_dir, renames)
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).") QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
self._reload_files() self._on_dir_changed()
def _undo_renames(self): def _undo_renames(self):
if not self._base_dir: if not self._base_dir:
@@ -418,4 +349,4 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "Undo errors", msg) QMessageBox.warning(self, "Undo errors", msg)
else: else:
QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).") QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).")
self._reload_files() self._on_dir_changed()
+8 -56
View File
@@ -339,7 +339,6 @@ class _TvdbMatchWorker(QThread):
season_type: str, season_type: str,
filenames: list[str], filenames: list[str],
all_seasons: bool, all_seasons: bool,
multi_episode: bool,
): ):
super().__init__() super().__init__()
self.series_id = series_id self.series_id = series_id
@@ -347,60 +346,30 @@ class _TvdbMatchWorker(QThread):
self.season_type = season_type self.season_type = season_type
self.filenames = filenames self.filenames = filenames
self.all_seasons = all_seasons self.all_seasons = all_seasons
self.multi_episode = multi_episode
def run(self): def run(self):
try: try:
client = TvdbClient(TVDB_API_KEY) client = TvdbClient(TVDB_API_KEY)
if self.all_seasons:
official = client.get_all_episodes(self.series_id, season_type="official")
season_filter = 0
else:
official = client.get_season_episodes(
self.series_id,
self.season,
season_type="official",
)
season_filter = self.season
if self.all_seasons: if self.all_seasons:
episodes = client.get_all_episodes( episodes = client.get_all_episodes(
self.series_id, self.series_id,
season_type=self.season_type, season_type=self.season_type,
) )
season_filter = 0
else: else:
episodes = client.get_season_episodes( episodes = client.get_season_episodes(
self.series_id, self.series_id,
self.season, self.season,
season_type=self.season_type, season_type=self.season_type,
) )
season_filter = self.season
combined = None if not episodes:
if self.multi_episode or self.season_type == "alternate":
if self.all_seasons:
combined = client.get_all_episodes(
self.series_id,
season_type="alternate",
)
else:
combined = client.get_season_episodes(
self.series_id,
self.season,
season_type="alternate",
)
if self.season_type == "alternate":
episodes = official
if not episodes and not combined:
label = "all seasons" if self.all_seasons else f"season {self.season}" label = "all seasons" if self.all_seasons else f"season {self.season}"
raise TvdbError(f"No episodes found for {label}") raise TvdbError(f"No episodes found for {label}")
mapping, unmatched, notes = match_filenames_to_episodes( mapping, unmatched, notes = match_filenames_to_episodes(
self.filenames, self.filenames,
episodes, episodes,
season_filter=season_filter, season_filter=season_filter,
official_episodes=official,
combined_episodes=combined,
) )
self.finished.emit(mapping, unmatched, notes) self.finished.emit(mapping, unmatched, notes)
except TvdbError as e: except TvdbError as e:
@@ -465,15 +434,6 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self.padding.valueChanged.connect(self._emit) self.padding.valueChanged.connect(self._emit)
layout.addRow("Zero-pad width:", self.padding) layout.addRow("Zero-pad width:", self.padding)
self.multi_episode_cb = QCheckBox("Multi-episode files (Jellyfin S01E01-E02)")
self.multi_episode_cb.setChecked(True)
self.multi_episode_cb.setToolTip(
"Match combined-order titles from TheTVDB and rename using aired episode ranges, "
"e.g. two episodes in one file becomes S01E01-E02."
)
self.multi_episode_cb.toggled.connect(self._emit)
layout.addRow(self.multi_episode_cb)
self.match_btn = QPushButton("Match titles from file list") self.match_btn = QPushButton("Match titles from file list")
self.match_btn.clicked.connect(self._match_titles) self.match_btn.clicked.connect(self._match_titles)
layout.addRow(self.match_btn) layout.addRow(self.match_btn)
@@ -483,8 +443,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
layout.addRow(self.status) layout.addRow(self.status)
info = QLabel( info = QLabel(
"Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), " "Reads titles from S01E05 - Episode Title or Show 04x01 Episode Title filenames. "
"or Show 04x01 Episode Title filenames. "
"Use All seasons to match across every season and fix wrong season numbers too." "Use All seasons to match across every season and fix wrong season numbers too."
) )
info.setWordWrap(True) info.setWordWrap(True)
@@ -560,7 +519,6 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self._order_value(), self._order_value(),
self._file_names, self._file_names,
all_seasons, all_seasons,
self.multi_episode_cb.isChecked(),
) )
self._match_worker.finished.connect(self._on_match_finished) self._match_worker.finished.connect(self._on_match_finished)
self._match_worker.failed.connect(self._on_match_failed) self._match_worker.failed.connect(self._on_match_failed)
@@ -584,17 +542,11 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
self.status.setText(f"Match failed: {message}") self.status.setText(f"Match failed: {message}")
def getRule(self) -> TvdbEpisodeRenumberRule: def getRule(self) -> TvdbEpisodeRenumberRule:
mapping: dict = {}
for k, v in self._episode_mapping.items():
if hasattr(v, "season"):
if getattr(v, "episode_end", None) and v.episode_end > v.episode:
mapping[k] = (v.season, v.episode, v.episode_end)
else:
mapping[k] = (v.season, v.episode)
else:
mapping[k] = v
r = TvdbEpisodeRenumberRule( r = TvdbEpisodeRenumberRule(
episode_mapping=mapping, episode_mapping={
k: (v.season, v.episode) if hasattr(v, "season") else v
for k, v in self._episode_mapping.items()
},
padding=self.padding.value(), padding=self.padding.value(),
) )
r.enabled = self.enabled_cb.isChecked() r.enabled = self.enabled_cb.isChecked()
-4
View File
@@ -21,11 +21,7 @@ def main():
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
) )
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setOrganizationName("HSRename")
app.setOrganizationDomain("hisora.dev")
app.setApplicationName("HSRename") app.setApplicationName("HSRename")
version_file = _root / "VERSION"
app.setApplicationVersion(version_file.read_text().strip() if version_file.is_file() else "")
win = MainWindow() win = MainWindow()
win.show() win.show()
sys.exit(app.exec()) sys.exit(app.exec())
-4
View File
@@ -1,4 +0,0 @@
HSRename 1.0.10
- Multi-episode files: Jellyfin S01E01-E02 naming from TheTVDB combined order
- Match combined titles (Ep A/Ep B) and map to official aired episode ranges
- Combined order option in episode order dropdown; multi-episode toggle on TheTVDB rule
-3
View File
@@ -1,3 +0,0 @@
HSRename 1.0.11
- Fix multi-episode detection when filename uses end episode (S01E06 for E05-E06)
- Detect dual titles separated by " - " (Otto 3000 - Night Prowler) as combined pairs
-4
View File
@@ -1,4 +0,0 @@
HSRename 1.0.8
- Include subfolders option for recursive file listing
- File type filter with Video, Audio, Images presets and custom extensions
- Rename rules preserve subfolder paths; nested apply/undo supported
-3
View File
@@ -1,3 +0,0 @@
HSRename 1.0.9
- Remember window size, splitter position, and preview column widths between sessions
- Fix double S in SxxExx renames (SS02E33) when show name ends before the episode code