feat: extract resolution rename to library/tools.py, add -r/--rename to video_autoreduce

This commit is contained in:
Fabrice Quenneville 2026-05-31 11:26:43 -04:00
parent df8bdd903d
commit 29446a718d
4 changed files with 263 additions and 92 deletions

View File

@ -1,5 +1,20 @@
# scripts/library/__init__.py # scripts/library/__init__.py
from .tools import (
apply_resolution_rename,
deletefile,
findfreename,
get_intermediate_dirs,
get_spacer,
)
from .venv_utils import parse_verbose, run_in_venv from .venv_utils import parse_verbose, run_in_venv
__all__ = ["parse_verbose", "run_in_venv"] __all__ = [
"apply_resolution_rename",
"deletefile",
"findfreename",
"get_intermediate_dirs",
"get_spacer",
"parse_verbose",
"run_in_venv",
]

134
scripts/library/tools.py Normal file
View File

@ -0,0 +1,134 @@
#!/usr/bin/env python3
import colorama
import os
import re
colorama.init()
creset = colorama.Fore.RESET
cgreen = colorama.Fore.GREEN
cred = colorama.Fore.RED
# Define patterns to search for various resolutions
resolution_patterns = {
2160: r"(4096x2160|3840x2160|2880p|2160p|2160|1440p|4k)",
1080: r"(1920x1080|1080p|1080)",
720: r"(1280x720|720p|720)",
}
# Wrap each pattern to only match when isolated by non-alphanumeric chars or string boundaries
resolution_patterns = {
res: rf"(?<![a-zA-Z0-9]){pat}(?![a-zA-Z0-9])"
for res, pat in resolution_patterns.items()
}
def apply_resolution_rename(name, max_height):
"""
Apply resolution substitution to a filename or directory name if an isolated
resolution pattern greater than max_height is found.
Args:
name (str): The filename or directory name to process (without path).
max_height (int): The target resolution height to substitute to.
Returns:
str: The renamed string if a match was found, or the original string unchanged.
"""
for resolution, pattern in resolution_patterns.items():
if resolution > max_height and re.search(pattern, name, re.IGNORECASE):
return re.sub(pattern, f"{max_height}p", name, flags=re.IGNORECASE)
return name
def deletefile(filepath):
"""Delete a file, Returns a boolean
Args:
filepath : A string containing the full filepath
Returns:
Bool : The success of the operation
"""
try:
os.remove(filepath)
except OSError:
print(f"{cred}Error deleting {filepath}{creset}")
return False
print(f"{cgreen}Successfully deleted {filepath}{creset}")
return True
def get_intermediate_dirs(input_path, file_path):
"""
Return the list of directory paths between input_path (exclusive) and
the directory containing file_path (inclusive), ordered deepest first.
Args:
input_path (str): The root path (exclusive upper bound).
file_path (str): The full path to a file.
Returns:
list: List of directory paths, deepest first.
Raises:
ValueError: If file_path not under input_path.
"""
input_path = os.path.realpath(input_path)
current = os.path.realpath(os.path.dirname(file_path))
if current != input_path and not current.startswith(input_path + os.sep):
raise ValueError(
f"file_path '{file_path}' is not under input_path '{input_path}'"
)
dirs = []
while current != input_path and current != os.path.dirname(current):
dirs.append(current)
current = os.path.dirname(current)
return dirs
def findfreename(filepath, attempt=0):
"""
Given a filepath, it will try to find a free filename by appending a number to the name.
First, it tries using the filepath passed in the argument, then adds a number to the end. If all fail, it appends (#).
Args:
filepath (str): A string containing the full filepath.
attempt (int): The number of attempts already made to find a free filename.
Returns:
str: The first free filepath found.
"""
attempt += 1
filename = str(filepath)[: str(filepath).rindex(".")]
extension = str(filepath)[str(filepath).rindex(".") :]
copynumpath = filename + f"({attempt})" + extension
if not os.path.exists(filepath) and attempt <= 2:
return filepath
elif not os.path.exists(copynumpath):
return copynumpath
return findfreename(filepath, attempt)
def get_spacer(name: str) -> str:
"""
Determine the spacer character ('-' or '_') to use for naming a virtual environment
based on the given project name. Default to underscore.
Args:
name (str): The project name to analyze.
Returns:
str: The detected spacer ('-' or '_'), or '_' if none found.
"""
if "-" in name:
return "-"
elif "_" in name:
return "_"
return "_"

View File

@ -9,6 +9,20 @@ import re
import subprocess import subprocess
import sys import sys
import json import json
from pathlib import Path
# Allow importing from scripts/library even when run directly
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.append(project_root)
# === Local import ===
from scripts.library import (
deletefile,
findfreename,
apply_resolution_rename,
get_intermediate_dirs,
)
colorama.init() colorama.init()
@ -18,6 +32,8 @@ cyellow = colorama.Fore.YELLOW
cgreen = colorama.Fore.GREEN cgreen = colorama.Fore.GREEN
cred = colorama.Fore.RED cred = colorama.Fore.RED
VIDEO_EXTENSIONS = (".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".divx")
def get_ffmpeg_codecs(codec_type="S"): def get_ffmpeg_codecs(codec_type="S"):
""" """
@ -57,48 +73,6 @@ def get_ffmpeg_codecs(codec_type="S"):
return set() return set()
def findfreename(filepath, attempt=0):
"""
Given a filepath, it will try to find a free filename by appending a number to the name.
First, it tries using the filepath passed in the argument, then adds a number to the end. If all fail, it appends (#).
Args:
filepath (str): A string containing the full filepath.
attempt (int): The number of attempts already made to find a free filename.
Returns:
str: The first free filepath found.
"""
attempt += 1
filename = str(filepath)[: str(filepath).rindex(".")]
extension = str(filepath)[str(filepath).rindex(".") :]
copynumpath = filename + f"({attempt})" + extension
if not os.path.exists(filepath) and attempt <= 2:
return filepath
elif not os.path.exists(copynumpath):
return copynumpath
return findfreename(filepath, attempt)
def deletefile(filepath):
"""Delete a file, Returns a boolean
Args:
filepath : A string containing the full filepath
Returns:
Bool : The success of the operation
"""
try:
os.remove(filepath)
except OSError:
print(f"{cred}Error deleting {filepath}{creset}")
return False
print(f"{cgreen}Successfully deleted {filepath}{creset}")
return True
def user_confirm(message, color="yellow"): def user_confirm(message, color="yellow"):
""" """
Prompt the user for a yes/no confirmation. Prompt the user for a yes/no confirmation.
@ -222,9 +196,7 @@ def find_videos_to_convert(input_path, max_height=720, debug=False):
for file in files: for file in files:
fullpath = os.path.join(root, file) fullpath = os.path.join(root, file)
# Check if the file is a video file with one of the specified extensions # Check if the file is a video file with one of the specified extensions
if file.lower().endswith( if file.lower().endswith(VIDEO_EXTENSIONS) and os.path.isfile(fullpath):
(".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".divx")
) and os.path.isfile(fullpath):
video_files.append(fullpath) video_files.append(fullpath)
# List of encodings to try for decoding output from ffprobe # List of encodings to try for decoding output from ffprobe
@ -326,7 +298,12 @@ def find_videos_to_convert(input_path, max_height=720, debug=False):
def convert_videos( def convert_videos(
input_path, output_path=None, max_height=720, delete=False, debug=False input_path,
output_path=None,
max_height=720,
delete=False,
rename=False,
debug=False,
): ):
""" """
Convert videos taller than a specified height to 720p resolution with x265 encoding and MKV container. Convert videos taller than a specified height to 720p resolution with x265 encoding and MKV container.
@ -337,6 +314,7 @@ def convert_videos(
If None, output files are written alongside the originals (in-place). If None, output files are written alongside the originals (in-place).
max_height (int, optional): The maximum height (in pixels) of the video to consider for conversion. Default is 720. max_height (int, optional): The maximum height (in pixels) of the video to consider for conversion. Default is 720.
delete (bool, optional): If True, delete the original file after successful conversion. Default is False. delete (bool, optional): If True, delete the original file after successful conversion. Default is False.
rename (bool, optional): If True, rename files and intermediate folders to reflect the target resolution. Default is False.
debug (bool, optional): If True, print debug messages. Default is False. debug (bool, optional): If True, print debug messages. Default is False.
Returns: Returns:
@ -367,6 +345,9 @@ def convert_videos(
# Variable to keep a track of the current_file in case of failure # Variable to keep a track of the current_file in case of failure
current_file = None current_file = None
# Track successfully converted original paths (used for folder rename pass)
converted_files = set()
# Iterate through each video file for conversion # Iterate through each video file for conversion
for video_file in video_files: for video_file in video_files:
counter += 1 counter += 1
@ -383,12 +364,18 @@ def convert_videos(
output_path if output_path else os.path.dirname(video_file) output_path if output_path else os.path.dirname(video_file)
) )
# Generate output file path and name # Compute base output filename, applying resolution rename if requested
base_name = os.path.splitext(os.path.basename(video_file))[0]
if rename:
renamed_base = apply_resolution_rename(base_name, max_height)
if renamed_base == base_name and f"{max_height}p" not in renamed_base:
# No resolution found in filename: append .{max_height}p before extension
renamed_base = f"{base_name}.{max_height}p"
base_name = renamed_base
# Generate output file path, using findfreename to avoid conflicts
output_file = findfreename( output_file = findfreename(
os.path.join( os.path.join(effective_output_path, base_name + ".mkv")
effective_output_path,
os.path.splitext(os.path.basename(video_file))[0] + ".mkv",
)
) )
try: try:
@ -527,9 +514,52 @@ def convert_videos(
if delete: if delete:
deletefile(video_file) deletefile(video_file)
# Track this original and its converted output as successfully converted
converted_files.add(os.path.realpath(video_file))
converted_files.add(os.path.realpath(output_file))
# Remove the now successfully converted filepath # Remove the now successfully converted filepath
current_file = None current_file = None
# Folder rename second pass
if rename and converted_files:
# Collect all intermediate dirs touched by successful conversions, deepest first
dirs_to_consider = {}
for original_file in converted_files:
for d in get_intermediate_dirs(input_path, original_file):
dirs_to_consider[d] = True
# For each candidate dir, check that ALL video files under it were converted
dirs_to_rename = []
for d in sorted(dirs_to_consider, key=len, reverse=True):
all_converted = True
for root, _, files in os.walk(d):
for f in files:
if f.lower().endswith(VIDEO_EXTENSIONS):
fpath = os.path.realpath(os.path.join(root, f))
if fpath not in converted_files:
all_converted = False
break
if not all_converted:
break
if all_converted:
new_dirname = apply_resolution_rename(os.path.basename(d), max_height)
if new_dirname != os.path.basename(d):
new_dir = os.path.join(os.path.dirname(d), new_dirname)
dirs_to_rename.append((d, new_dir))
# Rename dirs deepest first to avoid breaking paths
for old_dir, new_dir in dirs_to_rename:
if os.path.exists(new_dir):
print(
f"{cred}Error: Target directory {new_dir} already exists, skipping rename.{creset}"
)
continue
os.rename(old_dir, new_dir)
if debug:
print(f"Renamed directory: {old_dir} -> {new_dir}")
return True return True
@ -576,6 +606,12 @@ def parse_args():
action="store_true", action="store_true",
help="skip confirmation prompts", help="skip confirmation prompts",
) )
parser.add_argument(
"-r",
"--rename",
action="store_true",
help="rename converted files and intermediate folders to reflect the target resolution",
)
parser.add_argument( parser.add_argument(
"--debug", "--debug",
action="store_true", action="store_true",
@ -612,7 +648,12 @@ def main():
# Convert videos # Convert videos
convert_videos( convert_videos(
args.input_path, args.output_path, args.max_height, args.delete, args.debug args.input_path,
args.output_path,
args.max_height,
args.delete,
args.rename,
args.debug,
) )

View File

@ -5,7 +5,16 @@ import argparse
import argcomplete import argcomplete
import colorama import colorama
import os import os
import re import sys
from pathlib import Path
# Allow importing from scripts/library even when run directly
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.append(project_root)
# === Local import ===
from scripts.library import apply_resolution_rename
colorama.init() colorama.init()
@ -26,18 +35,6 @@ def autorename(input_path, max_height=720, debug=False):
max_height (int, optional): The maximum height (in pixels) of the video to consider for conversion. Default is 720. max_height (int, optional): The maximum height (in pixels) of the video to consider for conversion. Default is 720.
debug (bool, optional): If True, print debug messages. Default is False. debug (bool, optional): If True, print debug messages. Default is False.
""" """
# Define patterns to search for various resolutions
resolution_patterns = {
2160: r"(4096x2160|3840x2160|2880p|2160p|2160|1440p|4k)",
1080: r"(1920x1080|1080p|1080)",
720: r"(1280x720|720p|720)",
}
# Wrap each pattern to only match when isolated by non-alphanumeric chars or string boundaries
resolution_patterns = {
res: rf"(?<![a-zA-Z0-9]){pat}(?![a-zA-Z0-9])"
for res, pat in resolution_patterns.items()
}
files_to_rename = [] files_to_rename = []
dirs_to_rename = [] dirs_to_rename = []
@ -45,35 +42,19 @@ def autorename(input_path, max_height=720, debug=False):
for dirpath, dirnames, filenames in os.walk(input_path, topdown=True): for dirpath, dirnames, filenames in os.walk(input_path, topdown=True):
# Collect files to rename # Collect files to rename
for filename in filenames: for filename in filenames:
for resolution, pattern in resolution_patterns.items(): new_filename = apply_resolution_rename(filename, max_height)
# Only process resolutions greater than max_height if new_filename != filename:
if resolution > max_height and re.search(
pattern, filename, re.IGNORECASE
):
old_file = os.path.join(dirpath, filename) old_file = os.path.join(dirpath, filename)
new_filename = re.sub(
pattern, f"{max_height}p", filename, flags=re.IGNORECASE
)
new_file = os.path.join(dirpath, new_filename) new_file = os.path.join(dirpath, new_filename)
if old_file != new_file:
files_to_rename.append((old_file, new_file)) files_to_rename.append((old_file, new_file))
break
# Collect directories to rename # Collect directories to rename
for dirname in dirnames: for dirname in dirnames:
for resolution, pattern in resolution_patterns.items(): new_dirname = apply_resolution_rename(dirname, max_height)
# Only process resolutions greater than max_height if new_dirname != dirname:
if resolution > max_height and re.search(
pattern, dirname, re.IGNORECASE
):
old_dir = os.path.join(dirpath, dirname) old_dir = os.path.join(dirpath, dirname)
new_dirname = re.sub(
pattern, f"{max_height}p", dirname, flags=re.IGNORECASE
)
new_dir = os.path.join(dirpath, new_dirname) new_dir = os.path.join(dirpath, new_dirname)
if old_dir != new_dir:
dirs_to_rename.append((old_dir, new_dir)) dirs_to_rename.append((old_dir, new_dir))
break
# Rename files first # Rename files first
for old_file, new_file in files_to_rename: for old_file, new_file in files_to_rename: