#193 Add --set/--season overrides for batch episode renaming
--season auto-numbers episodes (E01, E02, ...) across a directory; --set TYPE=VALUE overrides any parsed chunk, e.g. forcing name when release text buries it in noise. Requires splitting SxxExx tokens into separate season/episode chunks (previously one glued value) and rejoining them without a separator in the generated name. Also scopes --season numbering per directory instead of the whole tree, drops leftover unknown text when overriding name on titles with nothing else recognized, and makes extension matching case-insensitive.
This commit is contained in:
116
renamer.py
116
renamer.py
@@ -20,7 +20,8 @@ SEPARATORS = r"[() .!,_\[\]]"
|
||||
SEPARATORS_HYPHEN = r"[\-" + SEPARATORS[1:]
|
||||
LANGUAGES = r"(rus|eng|ukr|jap|ita|chi|kor|ger|fre|spa|pol)"
|
||||
PATTERNS = (
|
||||
("episode", r"s\d{1,2}(e\d{1,2})?"),
|
||||
("season", r"s\d{1,2}"),
|
||||
("episode", r"(s\d{1,2})?e\d{1,2}"),
|
||||
("year", r"(19|20)\d{2}"),
|
||||
("edition", r"((theatrical|director'*s|extended|un)[-.]?cut"
|
||||
r"|imax[-.]edition"
|
||||
@@ -43,6 +44,7 @@ PATTERNS = (
|
||||
("file_extension", r"mkv|avi"),
|
||||
("unknown", r".*")
|
||||
)
|
||||
VALID_CHUNK_TYPES = frozenset({k for k, _ in PATTERNS} | {"name", "episode_name"})
|
||||
|
||||
|
||||
# noinspection PyInterpreter
|
||||
@@ -81,12 +83,31 @@ class CliAction(enum.Enum):
|
||||
_lg = logging.getLogger("spqr.movie-renamer")
|
||||
|
||||
|
||||
def _parse_set_arg(value):
|
||||
""" Parse --set TYPE=VALUE into (chunk_type, chunk_value). """
|
||||
chunk_type, sep, chunk_value = value.partition("=")
|
||||
if not sep:
|
||||
raise argparse.ArgumentTypeError("expected TYPE=VALUE, got %r" % value)
|
||||
if chunk_type not in VALID_CHUNK_TYPES:
|
||||
raise argparse.ArgumentTypeError(
|
||||
"unknown chunk type %r, expected one of: %s"
|
||||
% (chunk_type, ", ".join(sorted(VALID_CHUNK_TYPES)))
|
||||
)
|
||||
return chunk_type, chunk_value
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Rename media files.")
|
||||
parser.add_argument("action", type=CliAction, action=EnumAction, metavar="ACTION",
|
||||
help="what to do with media file/directory (%(choices)s)")
|
||||
parser.add_argument("target", type=str, metavar="TARGET",
|
||||
help="path to the media file/directory")
|
||||
parser.add_argument("--set", dest="overrides", type=_parse_set_arg, action="append",
|
||||
default=[], metavar="TYPE=VALUE",
|
||||
help="override a parsed chunk, e.g. --set name=Foo.Bar (repeatable)")
|
||||
parser.add_argument("--season", type=int, default=None, metavar="N",
|
||||
help="season number; auto-numbers episodes (E01, E02, ...) "
|
||||
"across media files found under TARGET")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", default=False,
|
||||
help="verbose output")
|
||||
args = parser.parse_args()
|
||||
@@ -94,26 +115,57 @@ def main():
|
||||
loglevel = logging.DEBUG if args.verbose else logging.INFO
|
||||
logging.basicConfig(level=loglevel)
|
||||
|
||||
process_path(args.action, args.target)
|
||||
process_path(args.action, args.target, overrides=dict(args.overrides), season=args.season)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def process_path(action: CliAction, path):
|
||||
def _is_media_file(path):
|
||||
if os.path.isdir(path):
|
||||
return False
|
||||
ext = os.path.splitext(path)[1][1:]
|
||||
return ext.lower() in PROCESSED_FILETYPES
|
||||
|
||||
|
||||
def process_path(action: CliAction, path, overrides=None, season=None, episode=None):
|
||||
overrides = overrides or {}
|
||||
|
||||
# process only files
|
||||
if os.path.isdir(path):
|
||||
for child_path in sorted(os.listdir(path)):
|
||||
process_path(action, os.path.join(path, child_path))
|
||||
episode_num = 0
|
||||
for child_name in sorted(os.listdir(path)):
|
||||
child_path = os.path.join(path, child_name)
|
||||
child_episode = None
|
||||
if season is not None and _is_media_file(child_path):
|
||||
episode_num += 1
|
||||
child_episode = episode_num
|
||||
process_path(action, child_path, overrides=overrides, season=season,
|
||||
episode=child_episode)
|
||||
return
|
||||
|
||||
if not _is_media_file(path):
|
||||
_lg.debug("Extension is not supported: %s", path)
|
||||
return
|
||||
|
||||
file_overrides = overrides
|
||||
if season is not None:
|
||||
file_overrides = dict(overrides)
|
||||
file_overrides["season"] = "S%02d" % season
|
||||
file_overrides["episode"] = "E%02d" % (episode if episode is not None else 1)
|
||||
|
||||
# split filepath to dir path, title, and extension
|
||||
dir_path, fname = os.path.split(path)
|
||||
title, ext = os.path.splitext(fname)
|
||||
ext = ext[1:]
|
||||
if ext not in PROCESSED_FILETYPES:
|
||||
_lg.debug("Extension is not supported: %s", path)
|
||||
return
|
||||
|
||||
parsed_title = parse_title(title)
|
||||
if "name" in file_overrides and "name" not in parsed_title:
|
||||
# nothing else in the title was recognized, so the whole thing
|
||||
# landed in "unknown" instead of "name" -- the override replaces it
|
||||
parsed_title.pop("unknown", None)
|
||||
for chunk_type, chunk_value in file_overrides.items():
|
||||
parsed_title[chunk_type] = [chunk_value]
|
||||
|
||||
if action == CliAction.parse:
|
||||
print_parsed_title(title, parsed_title)
|
||||
return
|
||||
@@ -123,6 +175,7 @@ def process_path(action: CliAction, path):
|
||||
pretty_title += ".%s" % ext
|
||||
if pretty_title != fname:
|
||||
_lg.warning("%s -> %s", fname, pretty_title)
|
||||
os.rename(path, os.path.join(dir_path, pretty_title))
|
||||
return
|
||||
|
||||
|
||||
@@ -139,12 +192,22 @@ def generate_pretty_name(parsed_title):
|
||||
chunk_order = chunk_order[:ep_idx] + ["episode_name"] + chunk_order[ep_idx:]
|
||||
|
||||
result = []
|
||||
pending_season = None
|
||||
for chunk_type in chunk_order:
|
||||
if not parsed_title.get(chunk_type, []):
|
||||
chunk_values = parsed_title.get(chunk_type, [])
|
||||
if not chunk_values:
|
||||
continue
|
||||
result.append(".".join(parsed_title[chunk_type]))
|
||||
result = ".".join(result)
|
||||
return result
|
||||
chunk_str = ".".join(chunk_values)
|
||||
if chunk_type == "season":
|
||||
pending_season = chunk_str
|
||||
continue
|
||||
if chunk_type == "episode" and pending_season is not None:
|
||||
chunk_str = pending_season + chunk_str
|
||||
pending_season = None
|
||||
result.append(chunk_str)
|
||||
if pending_season is not None:
|
||||
result.append(pending_season)
|
||||
return ".".join(result)
|
||||
|
||||
|
||||
def _get_parsed_title_dict(chunk_list, chunk_map):
|
||||
@@ -205,6 +268,7 @@ def _guess_combined(chunk_values, chunk_map):
|
||||
def parse_title(title):
|
||||
""" Split media title to components. """
|
||||
|
||||
# split title by separators
|
||||
chunk_values = filter(None, re.split(SEPARATORS, title))
|
||||
|
||||
# remove non-word chunks (like single hyphens), but leave ampersands (&)
|
||||
@@ -252,9 +316,12 @@ def parse_title(title):
|
||||
while idx < len(chunk_map) and chunk_map[idx] == "unknown":
|
||||
chunk_map[idx] = "name"
|
||||
idx += 1
|
||||
# if episode number is found, next unknown chunks are episode name
|
||||
if p_title.get("episode"):
|
||||
idx = chunk_map.index("episode") + 1
|
||||
# if season/episode number is found, next unknown chunks are episode name
|
||||
if p_title.get("season") or p_title.get("episode"):
|
||||
if p_title.get("episode"):
|
||||
idx = chunk_map.index("episode") + 1
|
||||
else:
|
||||
idx = chunk_map.index("season") + 1
|
||||
while idx < len(chunk_map) and chunk_map[idx] == "unknown":
|
||||
chunk_map[idx] = "episode_name"
|
||||
idx += 1
|
||||
@@ -271,10 +338,29 @@ def parse_title(title):
|
||||
continue
|
||||
chunk_values[idx] = chunk_value.strip("-")
|
||||
|
||||
# split glued SxxExx episode tokens (e.g. "S04E06") into separate
|
||||
# season and episode chunks
|
||||
p_title = _get_parsed_title_dict(chunk_values, chunk_map)
|
||||
if p_title.get("episode"):
|
||||
bare_episodes = []
|
||||
for ep_value in p_title["episode"]:
|
||||
if ep_value[0].lower() != "s":
|
||||
bare_episodes.append(ep_value)
|
||||
continue
|
||||
season, episode = _split_combined_episode(ep_value)
|
||||
p_title["season"].append(season)
|
||||
bare_episodes.append(episode)
|
||||
p_title["episode"] = bare_episodes
|
||||
|
||||
return dict(p_title)
|
||||
|
||||
|
||||
def _split_combined_episode(chunk_value):
|
||||
""" Split a glued SxxExx token like "S04E06" into ("S04", "E06"). """
|
||||
match = re.match(r"(s\d{1,2})(e\d{1,2})$", chunk_value, flags=re.I)
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
|
||||
def guess_part(chunk_value):
|
||||
""" Return chunk type for given chunk value. """
|
||||
for chunk_type, pattern in PATTERNS:
|
||||
|
||||
Reference in New Issue
Block a user