diff --git a/scripts/library/__init__.py b/scripts/library/__init__.py index 4cff4b1..1171594 100644 --- a/scripts/library/__init__.py +++ b/scripts/library/__init__.py @@ -1,5 +1,20 @@ # 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 -__all__ = ["parse_verbose", "run_in_venv"] +__all__ = [ + "apply_resolution_rename", + "deletefile", + "findfreename", + "get_intermediate_dirs", + "get_spacer", + "parse_verbose", + "run_in_venv", +] diff --git a/scripts/library/tools.py b/scripts/library/tools.py new file mode 100644 index 0000000..e844faf --- /dev/null +++ b/scripts/library/tools.py @@ -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"(? 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 "_" diff --git a/scripts/video_autoreduce.py b/scripts/video_autoreduce.py index 8997eb1..f7b0727 100755 --- a/scripts/video_autoreduce.py +++ b/scripts/video_autoreduce.py @@ -9,6 +9,20 @@ import re import subprocess import sys 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() @@ -18,6 +32,8 @@ cyellow = colorama.Fore.YELLOW cgreen = colorama.Fore.GREEN cred = colorama.Fore.RED +VIDEO_EXTENSIONS = (".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".divx") + def get_ffmpeg_codecs(codec_type="S"): """ @@ -57,48 +73,6 @@ def get_ffmpeg_codecs(codec_type="S"): 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"): """ 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: fullpath = os.path.join(root, file) # Check if the file is a video file with one of the specified extensions - if file.lower().endswith( - (".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".divx") - ) and os.path.isfile(fullpath): + if file.lower().endswith(VIDEO_EXTENSIONS) and os.path.isfile(fullpath): video_files.append(fullpath) # 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( - 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. @@ -337,6 +314,7 @@ def convert_videos( 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. 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. Returns: @@ -367,6 +345,9 @@ def convert_videos( # Variable to keep a track of the current_file in case of failure current_file = None + # Track successfully converted original paths (used for folder rename pass) + converted_files = set() + # Iterate through each video file for conversion for video_file in video_files: counter += 1 @@ -383,12 +364,18 @@ def convert_videos( 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( - os.path.join( - effective_output_path, - os.path.splitext(os.path.basename(video_file))[0] + ".mkv", - ) + os.path.join(effective_output_path, base_name + ".mkv") ) try: @@ -527,9 +514,52 @@ def convert_videos( if delete: 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 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 @@ -576,6 +606,12 @@ def parse_args(): action="store_true", 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( "--debug", action="store_true", @@ -612,7 +648,12 @@ def main(): # 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, ) diff --git a/scripts/video_autoreduce_rename.py b/scripts/video_autoreduce_rename.py index 958fef8..5c4eb5f 100755 --- a/scripts/video_autoreduce_rename.py +++ b/scripts/video_autoreduce_rename.py @@ -5,7 +5,16 @@ import argparse import argcomplete import colorama 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() @@ -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. 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"(? max_height and re.search( - pattern, filename, re.IGNORECASE - ): - 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) - if old_file != new_file: - files_to_rename.append((old_file, new_file)) - break + new_filename = apply_resolution_rename(filename, max_height) + if new_filename != filename: + old_file = os.path.join(dirpath, filename) + new_file = os.path.join(dirpath, new_filename) + files_to_rename.append((old_file, new_file)) # Collect directories to rename for dirname in dirnames: - for resolution, pattern in resolution_patterns.items(): - # Only process resolutions greater than max_height - if resolution > max_height and re.search( - pattern, dirname, re.IGNORECASE - ): - 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) - if old_dir != new_dir: - dirs_to_rename.append((old_dir, new_dir)) - break + new_dirname = apply_resolution_rename(dirname, max_height) + if new_dirname != dirname: + old_dir = os.path.join(dirpath, dirname) + new_dir = os.path.join(dirpath, new_dirname) + dirs_to_rename.append((old_dir, new_dir)) # Rename files first for old_file, new_file in files_to_rename: