Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87282b0259 | ||
|
|
64d7a30328 | ||
|
|
445e24ecae | ||
|
|
fce5ad64e5 |
+53
-9
@@ -45,6 +45,13 @@ class EpisodeTarget:
|
|||||||
return f"S{s}E{e1}"
|
return f"S{s}E{e1}"
|
||||||
|
|
||||||
|
|
||||||
|
def target_to_tuple(target: EpisodeTarget) -> tuple[int, ...]:
|
||||||
|
"""Plain tuple safe to pass through Qt signals."""
|
||||||
|
if target.episode_end is not None and target.episode_end > target.episode:
|
||||||
|
return (target.season, target.episode, target.episode_end)
|
||||||
|
return (target.season, target.episode)
|
||||||
|
|
||||||
|
|
||||||
def split_combined_title(name: str) -> list[str]:
|
def split_combined_title(name: str) -> list[str]:
|
||||||
"""Split a combined-order episode title like 'Ep A/Ep B' into parts."""
|
"""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()]
|
return [p.strip() for p in name.replace(" / ", "/").split("/") if p.strip()]
|
||||||
@@ -269,21 +276,46 @@ def _combined_title_variants(name: str) -> list[str]:
|
|||||||
return variants
|
return variants
|
||||||
|
|
||||||
|
|
||||||
def _combined_match_score(fnorm: str, combined_name: str) -> float:
|
def _combined_match_score(fnorm: str, combined_name: str, file_title: str = "") -> float:
|
||||||
variants = _combined_title_variants(combined_name)
|
variants = _combined_title_variants(combined_name)
|
||||||
return max((_similarity(fnorm, v) for v in variants), default=0.0)
|
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 _combined_allowed_for_file(parsed: dict, target: EpisodeTarget) -> bool:
|
def _dual_title_matches_combined(file_title: str, combined_name: str) -> bool:
|
||||||
"""Only treat as multi-episode when the file's episode code fits the range start."""
|
"""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:
|
if target.span <= 1:
|
||||||
return True
|
return True
|
||||||
if parsed.get("span", 1) > 1:
|
if parsed.get("span", 1) > 1:
|
||||||
return True
|
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")
|
file_ep = parsed.get("old_first")
|
||||||
if file_ep is None:
|
if file_ep is None:
|
||||||
return False
|
return False
|
||||||
return file_ep == target.episode
|
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(
|
def _range_overlaps(
|
||||||
@@ -370,7 +402,19 @@ def match_filenames_to_episodes(
|
|||||||
|
|
||||||
pairs: list[tuple[float, str, EpisodeTarget, str, str]] = []
|
pairs: list[tuple[float, str, EpisodeTarget, str, str]] = []
|
||||||
for fname, fnorm, raw_title, parsed in file_entries:
|
for fname, fnorm, raw_title, parsed in file_entries:
|
||||||
for season, ep_num, enorm, ep_name in ep_entries:
|
file_season = parsed.get("season")
|
||||||
|
if season_filter == 0 and file_season is not None:
|
||||||
|
season_eps = [e for e in ep_entries if e[0] == file_season]
|
||||||
|
season_combined = [
|
||||||
|
(cep, target, variants)
|
||||||
|
for cep, target, variants in combined_entries
|
||||||
|
if target.season == file_season
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
season_eps = ep_entries
|
||||||
|
season_combined = combined_entries
|
||||||
|
|
||||||
|
for season, ep_num, enorm, ep_name in season_eps:
|
||||||
if not fnorm:
|
if not fnorm:
|
||||||
continue
|
continue
|
||||||
score = _apply_season_hint(
|
score = _apply_season_hint(
|
||||||
@@ -387,12 +431,12 @@ def match_filenames_to_episodes(
|
|||||||
ep_name,
|
ep_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for _cep, target, variants in combined_entries:
|
for _cep, target, variants in season_combined:
|
||||||
if not fnorm:
|
if not fnorm:
|
||||||
continue
|
continue
|
||||||
if not _combined_allowed_for_file(parsed, target):
|
if not _combined_allowed_for_file(parsed, target, _cep.name):
|
||||||
continue
|
continue
|
||||||
best = _combined_match_score(fnorm, _cep.name)
|
best = _combined_match_score(fnorm, _cep.name, raw_title)
|
||||||
best = _apply_season_hint(best, target, parsed)
|
best = _apply_season_hint(best, target, parsed)
|
||||||
pairs.append((best, fname, target, raw_title, _cep.name))
|
pairs.append((best, fname, target, raw_title, _cep.name))
|
||||||
|
|
||||||
|
|||||||
+84
-52
@@ -24,6 +24,12 @@ SEASON_TYPE_CHOICES: list[tuple[str, str]] = [
|
|||||||
("Regional order", "regional"),
|
("Regional order", "regional"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Language path segment for /series/{id}/episodes/{season-type}/{language}
|
||||||
|
LANGUAGE_CHOICES: list[tuple[str, str]] = [
|
||||||
|
("Default (show language)", ""),
|
||||||
|
("English", "eng"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TvdbSeries:
|
class TvdbSeries:
|
||||||
@@ -154,37 +160,74 @@ class TvdbClient:
|
|||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def get_season_episodes(
|
def _episodes_path(self, series_id: int, season_type: str, language: str = "") -> str:
|
||||||
|
base = f"/series/{series_id}/episodes/{season_type}"
|
||||||
|
if language:
|
||||||
|
return f"{base}/{language}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _parse_episode_batch(
|
||||||
self,
|
self,
|
||||||
series_id: int,
|
batch: list[dict[str, Any]],
|
||||||
season: int,
|
*,
|
||||||
season_type: str = "default",
|
season: Optional[int] = None,
|
||||||
|
skip_specials: bool = False,
|
||||||
) -> list[TvdbEpisode]:
|
) -> list[TvdbEpisode]:
|
||||||
|
episodes: list[TvdbEpisode] = []
|
||||||
|
for ep in batch:
|
||||||
|
ep_season = ep.get("seasonNumber")
|
||||||
|
number = ep.get("number")
|
||||||
|
name = ep.get("name")
|
||||||
|
if number is None or ep_season is None or not name:
|
||||||
|
continue
|
||||||
|
ep_season = int(ep_season)
|
||||||
|
if skip_specials and ep_season == 0:
|
||||||
|
continue
|
||||||
|
if season is not None and ep_season != season:
|
||||||
|
continue
|
||||||
|
episodes.append(
|
||||||
|
TvdbEpisode(
|
||||||
|
number=int(number),
|
||||||
|
season_number=ep_season,
|
||||||
|
name=str(name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
def _fetch_episodes(
|
||||||
|
self,
|
||||||
|
series_id: int,
|
||||||
|
season_type: str,
|
||||||
|
*,
|
||||||
|
season: Optional[int] = None,
|
||||||
|
language: str = "",
|
||||||
|
skip_specials: bool = False,
|
||||||
|
) -> list[TvdbEpisode]:
|
||||||
|
"""Fetch episodes; optional translated titles via language code (e.g. eng)."""
|
||||||
episodes: list[TvdbEpisode] = []
|
episodes: list[TvdbEpisode] = []
|
||||||
page = 0
|
page = 0
|
||||||
while True:
|
max_pages = 50
|
||||||
|
use_api_season = season is not None and not language
|
||||||
|
while page < max_pages:
|
||||||
|
params: dict[str, Any] = {"page": page}
|
||||||
|
if use_api_season:
|
||||||
|
params["season"] = season
|
||||||
payload = self._request(
|
payload = self._request(
|
||||||
"GET",
|
"GET",
|
||||||
f"/series/{series_id}/episodes/{season_type}",
|
self._episodes_path(series_id, season_type, language),
|
||||||
params={"page": page, "season": season},
|
params=params,
|
||||||
)
|
)
|
||||||
batch = (payload.get("data") or {}).get("episodes") or []
|
batch = (payload.get("data") or {}).get("episodes") or []
|
||||||
if not batch:
|
if not batch:
|
||||||
break
|
break
|
||||||
for ep in batch:
|
filter_season = season if language else None
|
||||||
if ep.get("seasonNumber") != season:
|
episodes.extend(
|
||||||
continue
|
self._parse_episode_batch(
|
||||||
number = ep.get("number")
|
batch,
|
||||||
name = ep.get("name")
|
season=filter_season,
|
||||||
if number is None or not name:
|
skip_specials=skip_specials,
|
||||||
continue
|
|
||||||
episodes.append(
|
|
||||||
TvdbEpisode(
|
|
||||||
number=int(number),
|
|
||||||
season_number=int(season),
|
|
||||||
name=str(name),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
links = payload.get("links") or {}
|
links = payload.get("links") or {}
|
||||||
if not links.get("next"):
|
if not links.get("next"):
|
||||||
break
|
break
|
||||||
@@ -192,41 +235,30 @@ class TvdbClient:
|
|||||||
episodes.sort(key=lambda e: (e.season_number, e.number))
|
episodes.sort(key=lambda e: (e.season_number, e.number))
|
||||||
return episodes
|
return episodes
|
||||||
|
|
||||||
|
def get_season_episodes(
|
||||||
|
self,
|
||||||
|
series_id: int,
|
||||||
|
season: int,
|
||||||
|
season_type: str = "default",
|
||||||
|
language: str = "",
|
||||||
|
) -> list[TvdbEpisode]:
|
||||||
|
return self._fetch_episodes(
|
||||||
|
series_id,
|
||||||
|
season_type,
|
||||||
|
season=season,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
|
||||||
def get_all_episodes(
|
def get_all_episodes(
|
||||||
self,
|
self,
|
||||||
series_id: int,
|
series_id: int,
|
||||||
season_type: str = "default",
|
season_type: str = "default",
|
||||||
|
language: str = "",
|
||||||
) -> list[TvdbEpisode]:
|
) -> list[TvdbEpisode]:
|
||||||
"""Fetch every episode for a series (all seasons), paginated."""
|
"""Fetch every episode for a series (all seasons), paginated."""
|
||||||
episodes: list[TvdbEpisode] = []
|
return self._fetch_episodes(
|
||||||
page = 0
|
series_id,
|
||||||
while True:
|
season_type,
|
||||||
payload = self._request(
|
language=language,
|
||||||
"GET",
|
skip_specials=True,
|
||||||
f"/series/{series_id}/episodes/{season_type}",
|
)
|
||||||
params={"page": page},
|
|
||||||
)
|
|
||||||
batch = (payload.get("data") or {}).get("episodes") or []
|
|
||||||
if not batch:
|
|
||||||
break
|
|
||||||
for ep in batch:
|
|
||||||
number = ep.get("number")
|
|
||||||
season = ep.get("seasonNumber")
|
|
||||||
name = ep.get("name")
|
|
||||||
if number is None or season is None or not name:
|
|
||||||
continue
|
|
||||||
if int(season) == 0:
|
|
||||||
continue
|
|
||||||
episodes.append(
|
|
||||||
TvdbEpisode(
|
|
||||||
number=int(number),
|
|
||||||
season_number=int(season),
|
|
||||||
name=str(name),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
links = payload.get("links") or {}
|
|
||||||
if not links.get("next"):
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
episodes.sort(key=lambda e: (e.season_number, e.number))
|
|
||||||
return episodes
|
|
||||||
|
|||||||
+16
-2
@@ -25,7 +25,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel, QSettings
|
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel, QSettings, QTimer
|
||||||
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
|
||||||
@@ -54,6 +54,7 @@ class MainWindow(QMainWindow):
|
|||||||
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._split: QSplitter | None = None
|
||||||
|
self._preview_refresh_scheduled = False
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
self._restore_layout()
|
self._restore_layout()
|
||||||
self._refresh_preview()
|
self._refresh_preview()
|
||||||
@@ -129,7 +130,7 @@ class MainWindow(QMainWindow):
|
|||||||
if hasattr(w, "set_file_names"):
|
if hasattr(w, "set_file_names"):
|
||||||
w.set_file_names(self._file_names)
|
w.set_file_names(self._file_names)
|
||||||
if hasattr(w, "matchCompleted"):
|
if hasattr(w, "matchCompleted"):
|
||||||
w.matchCompleted.connect(self._refresh_preview)
|
w.matchCompleted.connect(self._schedule_refresh_preview)
|
||||||
g = QGroupBox(title)
|
g = QGroupBox(title)
|
||||||
g_layout = QVBoxLayout(g)
|
g_layout = QVBoxLayout(g)
|
||||||
g_layout.setContentsMargins(8, 12, 8, 8)
|
g_layout.setContentsMargins(8, 12, 8, 8)
|
||||||
@@ -274,6 +275,19 @@ class MainWindow(QMainWindow):
|
|||||||
display_name = self._preview_by_orig.get(orig, orig) if row in selected_rows else orig
|
display_name = self._preview_by_orig.get(orig, orig) if row in selected_rows else orig
|
||||||
self.preview_table.setItem(row, 1, QTableWidgetItem(display_name))
|
self.preview_table.setItem(row, 1, QTableWidgetItem(display_name))
|
||||||
|
|
||||||
|
def _schedule_refresh_preview(self):
|
||||||
|
if self._preview_refresh_scheduled:
|
||||||
|
return
|
||||||
|
self._preview_refresh_scheduled = True
|
||||||
|
QTimer.singleShot(0, self._run_scheduled_refresh)
|
||||||
|
|
||||||
|
def _run_scheduled_refresh(self):
|
||||||
|
self._preview_refresh_scheduled = False
|
||||||
|
try:
|
||||||
|
self._refresh_preview()
|
||||||
|
except Exception as exc:
|
||||||
|
self.preview_status.setText(f"Preview error: {exc}")
|
||||||
|
|
||||||
def _refresh_preview(self):
|
def _refresh_preview(self):
|
||||||
rules = self._get_rules()
|
rules = self._get_rules()
|
||||||
if not self._file_names:
|
if not self._file_names:
|
||||||
|
|||||||
+119
-43
@@ -31,8 +31,8 @@ from engine.rules import (
|
|||||||
PrefixSuffixRule,
|
PrefixSuffixRule,
|
||||||
CsvMappingRule,
|
CsvMappingRule,
|
||||||
)
|
)
|
||||||
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES
|
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES, LANGUAGE_CHOICES
|
||||||
from engine.episode_match import match_filenames_to_episodes
|
from engine.episode_match import match_filenames_to_episodes, target_to_tuple
|
||||||
|
|
||||||
|
|
||||||
class ReplaceRuleWidget(QWidget):
|
class ReplaceRuleWidget(QWidget):
|
||||||
@@ -310,18 +310,19 @@ class EpisodeRenumberRuleWidget(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
class _TvdbSearchWorker(QThread):
|
class _TvdbSearchWorker(QThread):
|
||||||
finished = pyqtSignal(list)
|
search_done = pyqtSignal()
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, query: str):
|
def __init__(self, query: str):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.query = query
|
self.query = query
|
||||||
|
self.results: list = []
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
client = TvdbClient(TVDB_API_KEY)
|
client = TvdbClient(TVDB_API_KEY)
|
||||||
results = client.search_series(self.query)
|
self.results = client.search_series(self.query)
|
||||||
self.finished.emit(results)
|
self.search_done.emit()
|
||||||
except TvdbError as e:
|
except TvdbError as e:
|
||||||
self.failed.emit(str(e))
|
self.failed.emit(str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -329,7 +330,7 @@ class _TvdbSearchWorker(QThread):
|
|||||||
|
|
||||||
|
|
||||||
class _TvdbMatchWorker(QThread):
|
class _TvdbMatchWorker(QThread):
|
||||||
finished = pyqtSignal(dict, list, list)
|
match_done = pyqtSignal()
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -340,6 +341,7 @@ class _TvdbMatchWorker(QThread):
|
|||||||
filenames: list[str],
|
filenames: list[str],
|
||||||
all_seasons: bool,
|
all_seasons: bool,
|
||||||
multi_episode: bool,
|
multi_episode: bool,
|
||||||
|
language: str,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.series_id = series_id
|
self.series_id = series_id
|
||||||
@@ -348,49 +350,85 @@ class _TvdbMatchWorker(QThread):
|
|||||||
self.filenames = filenames
|
self.filenames = filenames
|
||||||
self.all_seasons = all_seasons
|
self.all_seasons = all_seasons
|
||||||
self.multi_episode = multi_episode
|
self.multi_episode = multi_episode
|
||||||
|
self.language = language
|
||||||
|
self.mapping: dict[str, tuple[int, ...]] = {}
|
||||||
|
self.unmatched: list[str] = []
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
client = TvdbClient(TVDB_API_KEY)
|
client = TvdbClient(TVDB_API_KEY)
|
||||||
if self.all_seasons:
|
lang = self.language
|
||||||
official = client.get_all_episodes(self.series_id, season_type="official")
|
season_filter = 0 if self.all_seasons else self.season
|
||||||
season_filter = 0
|
need_official = self.multi_episode or self.season_type == "alternate"
|
||||||
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,
|
||||||
|
language=lang,
|
||||||
)
|
)
|
||||||
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,
|
||||||
|
language=lang,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.season_type == "alternate":
|
||||||
|
if self.all_seasons:
|
||||||
|
official = client.get_all_episodes(
|
||||||
|
self.series_id, season_type="official", language=lang,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
official = client.get_season_episodes(
|
||||||
|
self.series_id,
|
||||||
|
self.season,
|
||||||
|
season_type="official",
|
||||||
|
language=lang,
|
||||||
|
)
|
||||||
|
episodes = official
|
||||||
|
elif need_official:
|
||||||
|
if self.season_type == "official":
|
||||||
|
official = episodes
|
||||||
|
elif self.all_seasons:
|
||||||
|
official = client.get_all_episodes(
|
||||||
|
self.series_id, season_type="official", language=lang,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
official = client.get_season_episodes(
|
||||||
|
self.series_id,
|
||||||
|
self.season,
|
||||||
|
season_type="official",
|
||||||
|
language=lang,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
official = episodes
|
||||||
|
|
||||||
combined = None
|
combined = None
|
||||||
if self.multi_episode or self.season_type == "alternate":
|
if need_official:
|
||||||
if self.all_seasons:
|
if self.all_seasons:
|
||||||
combined = client.get_all_episodes(
|
combined = client.get_all_episodes(
|
||||||
self.series_id,
|
self.series_id,
|
||||||
season_type="alternate",
|
season_type="alternate",
|
||||||
|
language=lang,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
combined = client.get_season_episodes(
|
combined = client.get_season_episodes(
|
||||||
self.series_id,
|
self.series_id,
|
||||||
self.season,
|
self.season,
|
||||||
season_type="alternate",
|
season_type="alternate",
|
||||||
|
language=lang,
|
||||||
)
|
)
|
||||||
|
if not combined and lang:
|
||||||
if self.season_type == "alternate":
|
if self.all_seasons:
|
||||||
episodes = official
|
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 not episodes and not combined:
|
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}"
|
||||||
@@ -402,11 +440,15 @@ class _TvdbMatchWorker(QThread):
|
|||||||
official_episodes=official,
|
official_episodes=official,
|
||||||
combined_episodes=combined,
|
combined_episodes=combined,
|
||||||
)
|
)
|
||||||
self.finished.emit(mapping, unmatched, notes)
|
self.mapping = {path: target_to_tuple(t) for path, t in mapping.items()}
|
||||||
|
self.unmatched = list(unmatched)
|
||||||
|
del notes
|
||||||
|
self.match_done.emit()
|
||||||
except TvdbError as e:
|
except TvdbError as e:
|
||||||
self.failed.emit(str(e))
|
self.failed.emit(str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.failed.emit(str(e))
|
import traceback
|
||||||
|
self.failed.emit(f"{e}\n\n{traceback.format_exc()}")
|
||||||
|
|
||||||
|
|
||||||
class TvdbEpisodeRenumberRuleWidget(QWidget):
|
class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||||
@@ -450,6 +492,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
self.order_type.currentIndexChanged.connect(self._emit)
|
self.order_type.currentIndexChanged.connect(self._emit)
|
||||||
layout.addRow("Episode order:", self.order_type)
|
layout.addRow("Episode order:", self.order_type)
|
||||||
|
|
||||||
|
self.title_language = QComboBox()
|
||||||
|
for label, value in LANGUAGE_CHOICES:
|
||||||
|
self.title_language.addItem(label, value)
|
||||||
|
self.title_language.setCurrentIndex(1) # English — most filenames use it
|
||||||
|
self.title_language.currentIndexChanged.connect(self._emit)
|
||||||
|
layout.addRow("Episode titles:", self.title_language)
|
||||||
|
|
||||||
self.season = QSpinBox()
|
self.season = QSpinBox()
|
||||||
self.season.setMinimum(0)
|
self.season.setMinimum(0)
|
||||||
self.season.setMaximum(99)
|
self.season.setMaximum(99)
|
||||||
@@ -485,6 +534,7 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
info = QLabel(
|
info = QLabel(
|
||||||
"Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), "
|
"Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), "
|
||||||
"or Show 04x01 Episode Title filenames. "
|
"or Show 04x01 Episode Title filenames. "
|
||||||
|
"For non-English shows, set Episode titles to English if your filenames use English. "
|
||||||
"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)
|
||||||
@@ -500,6 +550,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
value = self.order_type.currentData()
|
value = self.order_type.currentData()
|
||||||
return value if value else "default"
|
return value if value else "default"
|
||||||
|
|
||||||
|
def _language_value(self) -> str:
|
||||||
|
value = self.title_language.currentData()
|
||||||
|
return value if value else ""
|
||||||
|
|
||||||
|
def _language_label(self) -> str:
|
||||||
|
return self.title_language.currentText()
|
||||||
|
|
||||||
def _emit(self):
|
def _emit(self):
|
||||||
self.ruleChanged.emit()
|
self.ruleChanged.emit()
|
||||||
|
|
||||||
@@ -512,11 +569,12 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
self.series_combo.setEnabled(False)
|
self.series_combo.setEnabled(False)
|
||||||
self.status.setText("Searching TheTVDB…")
|
self.status.setText("Searching TheTVDB…")
|
||||||
self._search_worker = _TvdbSearchWorker(query)
|
self._search_worker = _TvdbSearchWorker(query)
|
||||||
self._search_worker.finished.connect(self._on_search_finished)
|
self._search_worker.search_done.connect(self._on_search_finished)
|
||||||
self._search_worker.failed.connect(self._on_search_failed)
|
self._search_worker.failed.connect(self._on_search_failed)
|
||||||
self._search_worker.start()
|
self._search_worker.start()
|
||||||
|
|
||||||
def _on_search_finished(self, results):
|
def _on_search_finished(self):
|
||||||
|
results = self._search_worker.results if self._search_worker else []
|
||||||
self._search_results = results
|
self._search_results = results
|
||||||
self.series_combo.clear()
|
self.series_combo.clear()
|
||||||
if not results:
|
if not results:
|
||||||
@@ -548,10 +606,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
series_id = self.series_combo.currentData()
|
series_id = self.series_combo.currentData()
|
||||||
if series_id is None:
|
if series_id is None:
|
||||||
return
|
return
|
||||||
|
if self._match_worker is not None and self._match_worker.isRunning():
|
||||||
|
self.status.setText("Matching already in progress…")
|
||||||
|
return
|
||||||
self.match_btn.setEnabled(False)
|
self.match_btn.setEnabled(False)
|
||||||
all_seasons = self.season.value() == 0
|
all_seasons = self.season.value() == 0
|
||||||
self.status.setText(
|
self.status.setText(
|
||||||
f"Fetching episodes ({self._order_label()}, "
|
f"Fetching episodes ({self._order_label()}, {self._language_label()}, "
|
||||||
f"{'all seasons' if all_seasons else f'season {self.season.value()}'}…) and matching titles…"
|
f"{'all seasons' if all_seasons else f'season {self.season.value()}'}…) and matching titles…"
|
||||||
)
|
)
|
||||||
self._match_worker = _TvdbMatchWorker(
|
self._match_worker = _TvdbMatchWorker(
|
||||||
@@ -561,23 +622,39 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
self._file_names,
|
self._file_names,
|
||||||
all_seasons,
|
all_seasons,
|
||||||
self.multi_episode_cb.isChecked(),
|
self.multi_episode_cb.isChecked(),
|
||||||
|
self._language_value(),
|
||||||
)
|
)
|
||||||
self._match_worker.finished.connect(self._on_match_finished)
|
self._match_worker.match_done.connect(self._on_match_finished)
|
||||||
self._match_worker.failed.connect(self._on_match_failed)
|
self._match_worker.failed.connect(self._on_match_failed)
|
||||||
|
self._match_worker.finished.connect(self._on_match_worker_finished)
|
||||||
self._match_worker.start()
|
self._match_worker.start()
|
||||||
|
|
||||||
def _on_match_finished(self, mapping: dict, unmatched: list, notes: list):
|
def _on_match_worker_finished(self):
|
||||||
self._episode_mapping = mapping
|
worker = self._match_worker
|
||||||
self.match_btn.setEnabled(True)
|
if worker is not None:
|
||||||
matched = len(mapping)
|
worker.deleteLater()
|
||||||
total = len(self._file_names)
|
self._match_worker = None
|
||||||
scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}"
|
|
||||||
msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()})."
|
def _on_match_finished(self):
|
||||||
if unmatched:
|
try:
|
||||||
msg += f" {len(unmatched)} file(s) unmatched."
|
worker = self._match_worker
|
||||||
self.status.setText(msg)
|
if worker is None:
|
||||||
self.matchCompleted.emit()
|
return
|
||||||
self._emit()
|
self._episode_mapping = dict(worker.mapping)
|
||||||
|
unmatched = list(worker.unmatched)
|
||||||
|
self.match_btn.setEnabled(True)
|
||||||
|
matched = len(self._episode_mapping)
|
||||||
|
total = len(self._file_names)
|
||||||
|
scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}"
|
||||||
|
msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()}, {self._language_label()})."
|
||||||
|
if unmatched:
|
||||||
|
msg += f" {len(unmatched)} file(s) unmatched."
|
||||||
|
self.status.setText(msg)
|
||||||
|
self.matchCompleted.emit()
|
||||||
|
except Exception as e:
|
||||||
|
self.match_btn.setEnabled(True)
|
||||||
|
self.status.setText(f"Match error: {e}")
|
||||||
|
QMessageBox.warning(self, "Match error", str(e))
|
||||||
|
|
||||||
def _on_match_failed(self, message: str):
|
def _on_match_failed(self, message: str):
|
||||||
self.match_btn.setEnabled(True)
|
self.match_btn.setEnabled(True)
|
||||||
@@ -586,11 +663,10 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
def getRule(self) -> TvdbEpisodeRenumberRule:
|
def getRule(self) -> TvdbEpisodeRenumberRule:
|
||||||
mapping: dict = {}
|
mapping: dict = {}
|
||||||
for k, v in self._episode_mapping.items():
|
for k, v in self._episode_mapping.items():
|
||||||
if hasattr(v, "season"):
|
if isinstance(v, tuple):
|
||||||
if getattr(v, "episode_end", None) and v.episode_end > v.episode:
|
mapping[k] = v
|
||||||
mapping[k] = (v.season, v.episode, v.episode_end)
|
elif hasattr(v, "season"):
|
||||||
else:
|
mapping[k] = target_to_tuple(v)
|
||||||
mapping[k] = (v.season, v.episode)
|
|
||||||
else:
|
else:
|
||||||
mapping[k] = v
|
mapping[k] = v
|
||||||
r = TvdbEpisodeRenumberRule(
|
r = TvdbEpisodeRenumberRule(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ HSRename - Native Linux GUI for mass renaming files.
|
|||||||
Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules.
|
Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules.
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Ensure project root is on path when run as script or module
|
# Ensure project root is on path when run as script or module
|
||||||
@@ -16,7 +17,26 @@ from PyQt6.QtCore import Qt
|
|||||||
from gui.main_window import MainWindow
|
from gui.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
def _install_exception_logger() -> None:
|
||||||
|
log_dir = Path.home() / ".config" / "HSRename"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = log_dir / "crash.log"
|
||||||
|
|
||||||
|
def _hook(exc_type, exc, tb):
|
||||||
|
try:
|
||||||
|
log_file.write_text(
|
||||||
|
"".join(traceback.format_exception(exc_type, exc, tb)),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
sys.__excepthook__(exc_type, exc, tb)
|
||||||
|
|
||||||
|
sys.excepthook = _hook
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
_install_exception_logger()
|
||||||
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
HSRename 1.0.12
|
||||||
|
- Episode titles language selector (English / show default) for TheTVDB matching
|
||||||
|
- Fixes 0 matches on anime and other shows when filenames are English but TheTVDB default is Japanese
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
HSRename 1.0.13
|
||||||
|
- Fix crash during TheTVDB match (safe thread signal payloads, worker re-entry guard)
|
||||||
|
- Faster matching by scoping episodes to each file's season
|
||||||
|
- Fewer redundant TheTVDB API calls during match
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
HSRename 1.0.14
|
||||||
|
- Fix Qt thread crashes: workers no longer pass dict/list through signals
|
||||||
|
- Defer preview refresh after match to avoid re-entrant table updates
|
||||||
|
- Log Python exceptions to ~/.config/HSRename/crash.log
|
||||||
Reference in New Issue
Block a user