Compare commits

...

4 Commits

6 changed files with 555 additions and 223 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"):
""" """
@ -41,15 +57,13 @@ def get_ffmpeg_codecs(codec_type="S"):
""" """
try: try:
# Run ffmpeg -codecs # Run ffmpeg -codecs
result = subprocess.run(["ffmpeg", "-codecs"], result = subprocess.run(
capture_output=True, ["ffmpeg", "-codecs"], capture_output=True, text=True, check=True
text=True, )
check=True)
output = result.stdout output = result.stdout
# Extract codecs using regex # Extract codecs using regex
codec_pattern = re.compile(rf"^[ D.E]+{codec_type}[ A-Z.]+ (\S+)", codec_pattern = re.compile(rf"^[ D.E]+{codec_type}[ A-Z.]+ (\S+)", re.MULTILINE)
re.MULTILINE)
codecs = codec_pattern.findall(output) codecs = codec_pattern.findall(output)
return set(codecs) # Return as a set for easy lookup return set(codecs) # Return as a set for easy lookup
@ -59,50 +73,29 @@ def get_ffmpeg_codecs(codec_type="S"):
return set() return set()
def findfreename(filepath, attempt=0): def user_confirm(message, color="yellow"):
''' """
Given a filepath, it will try to find a free filename by appending a number to the name. Prompt the user for a yes/no confirmation.
First, it tries using the filepath passed in the argument, then adds a number to the end. If all fail, it appends (#).
Args: Args:
filepath (str): A string containing the full filepath. message (str): The confirmation message to display.
attempt (int): The number of attempts already made to find a free filename. color (str): Color for the prompt ('yellow', 'red', 'green'). Default is 'yellow'.
Returns: Returns:
str: The first free filepath found. bool: True if the user confirms with 'Y', False otherwise.
''' """
attempt += 1 color_map = {
filename = str(filepath)[:str(filepath).rindex(".")] "yellow": cyellow,
extension = str(filepath)[str(filepath).rindex("."):] "red": cred,
copynumpath = filename + f"({attempt})" + extension "green": cgreen,
}
if not os.path.exists(filepath) and attempt <= 2: c = color_map.get(color, cyellow)
return filepath response = input(f"{c}{message}{creset} ").strip().upper()
elif not os.path.exists(copynumpath): return response == "Y"
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 ensure_output_path(output_path): def ensure_output_path(output_path):
''' """
Ensure that the output directory exists. If it doesn't, attempt to create it. Ensure that the output directory exists. If it doesn't, attempt to create it.
Args: Args:
@ -110,15 +103,14 @@ def ensure_output_path(output_path):
Returns: Returns:
None None
''' """
try: try:
os.makedirs(output_path, exist_ok=True) os.makedirs(output_path, exist_ok=True)
# Attempts to create the output directory if it doesn't exist. # Attempts to create the output directory if it doesn't exist.
# exist_ok=True ensures that an OSError is not raised if the directory already exists. # exist_ok=True ensures that an OSError is not raised if the directory already exists.
except OSError as e: except OSError as e:
# If an OSError occurs during directory creation, print an error message and exit the program. # If an OSError occurs during directory creation, print an error message and exit the program.
print(f"{cred}Failed to create output directory:{creset} {e}", print(f"{cred}Failed to create output directory:{creset} {e}", file=sys.stderr)
file=sys.stderr)
sys.exit(1) sys.exit(1)
@ -140,13 +132,23 @@ def has_supported_subs(video_file, supported_subtitle_codecs, debug=False):
False otherwise or in case of an error. False otherwise or in case of an error.
""" """
try: try:
result = subprocess.run([ result = subprocess.run(
'ffprobe', '-v', 'error', '-select_streams', 's', '-show_entries', [
'stream=index,codec_name', '-of', 'json', video_file "ffprobe",
"-v",
"error",
"-select_streams",
"s",
"-show_entries",
"stream=index,codec_name",
"-of",
"json",
video_file,
], ],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True) text=True,
)
if result.returncode != 0: if result.returncode != 0:
if debug: if debug:
@ -161,8 +163,8 @@ def has_supported_subs(video_file, supported_subtitle_codecs, debug=False):
return False # Assume no supported subtitles if JSON is malformed return False # Assume no supported subtitles if JSON is malformed
return any( return any(
stream.get("codec_name") in supported_subtitle_codecs stream.get("codec_name") in supported_subtitle_codecs for stream in streams
for stream in streams) )
except Exception as e: except Exception as e:
if debug: if debug:
@ -171,7 +173,7 @@ def has_supported_subs(video_file, supported_subtitle_codecs, debug=False):
def find_videos_to_convert(input_path, max_height=720, debug=False): def find_videos_to_convert(input_path, max_height=720, debug=False):
''' """
Find video files in the specified directory tree that meet conversion criteria. Find video files in the specified directory tree that meet conversion criteria.
Args: Args:
@ -181,10 +183,9 @@ def find_videos_to_convert(input_path, max_height=720, debug=False):
Returns: Returns:
list: A list of paths to video files that meet the conversion criteria. list: A list of paths to video files that meet the conversion criteria.
''' """
# Print a message indicating the start of the scanning process # Print a message indicating the start of the scanning process
print( print(f"{cgreen}Scanning {input_path} for video files to convert.{creset}\n")
f"{cgreen}Scanning {input_path} for video files to convert.{creset}\n")
# Initialize lists to store all video files and valid video files # Initialize lists to store all video files and valid video files
video_files = [] video_files = []
@ -195,13 +196,11 @@ 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
ENCODINGS_TO_TRY = ['utf-8', 'latin-1', 'iso-8859-1'] ENCODINGS_TO_TRY = ["utf-8", "latin-1", "iso-8859-1"]
# Iterate through each video file # Iterate through each video file
for video_file in video_files: for video_file in video_files:
@ -209,38 +208,58 @@ def find_videos_to_convert(input_path, max_height=720, debug=False):
# Run ffprobe command to get video resolution # Run ffprobe command to get video resolution
resolution_output = subprocess.check_output( resolution_output = subprocess.check_output(
[ [
'ffprobe', '-v', 'error', '-select_streams', 'v:0', "ffprobe",
'-show_entries', 'stream=width,height', '-of', 'csv=p=0', "-v",
video_file "error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0",
video_file,
], ],
stderr=subprocess.STDOUT) stderr=subprocess.STDOUT,
)
# Attempt to decode the output using different encodings # Attempt to decode the output using different encodings
dimensions_str = None dimensions_str = None
width = height = None
for encoding in ENCODINGS_TO_TRY: for encoding in ENCODINGS_TO_TRY:
try: try:
dimensions_str = resolution_output.decode( dimensions_str = (
encoding).strip().rstrip(',') resolution_output.decode(encoding).strip().rstrip(",")
)
dimensions = [ dimensions = [
int(d) for d in dimensions_str.split(',') int(d) for d in dimensions_str.split(",") if d.strip().isdigit()
if d.strip().isdigit()
] ]
if len(dimensions) >= 2: if len(dimensions) >= 2:
width, height = dimensions[:2] width, height = dimensions[:2]
else: else:
raise ValueError( raise ValueError(
f"Unexpected dimensions format: {dimensions_str}") f"Unexpected dimensions format: {dimensions_str}"
)
break # Stop trying encodings once successful decoding and conversion break # Stop trying encodings once successful decoding and conversion
except (UnicodeDecodeError, ValueError): except (UnicodeDecodeError, ValueError):
continue # Try the next encoding continue # Try the next encoding
# If decoding was unsuccessful with all encodings, skip this video
if width is None or height is None:
if debug:
print(
f"{cred}Error processing {video_file}: Unable to decode dimensions from ffprobe output.{creset}",
file=sys.stderr,
)
continue
# If decoding was unsuccessful with all encodings, skip this video # If decoding was unsuccessful with all encodings, skip this video
if dimensions_str is None: if dimensions_str is None:
if debug: if debug:
print( print(
f"{cred}Error processing {video_file}: Unable to decode output from ffprobe using any of the specified encodings.{creset}", f"{cred}Error processing {video_file}: Unable to decode output from ffprobe using any of the specified encodings.{creset}",
file=sys.stderr) file=sys.stderr,
)
continue # Skip this video and continue with the next one continue # Skip this video and continue with the next one
# Ignore vertical videos # Ignore vertical videos
@ -258,36 +277,49 @@ def find_videos_to_convert(input_path, max_height=720, debug=False):
decoded_error = e.output.decode("utf-8") decoded_error = e.output.decode("utf-8")
print( print(
f"{cred}Error processing {video_file}: {decoded_error}{creset}\n", f"{cred}Error processing {video_file}: {decoded_error}{creset}\n",
file=sys.stderr) file=sys.stderr,
)
else: else:
print( print(
f"{cred}Error processing {video_file}: No output from subprocess{creset}\n", f"{cred}Error processing {video_file}: No output from subprocess{creset}\n",
file=sys.stderr) file=sys.stderr,
)
except ValueError as e: except ValueError as e:
if debug: if debug:
# Print error message if value error occurs during decoding # Print error message if value error occurs during decoding
print( print(
f"{cred}Error processing {video_file}: ValueError decoding the file{creset}\n", f"{cred}Error processing {video_file}: ValueError decoding the file{creset}\n",
file=sys.stderr) file=sys.stderr,
)
valid_videos_files.sort() valid_videos_files.sort()
return valid_videos_files return valid_videos_files
def convert_videos(input_path, output_path, max_height=720, debug=False): def convert_videos(
''' 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.
Args: Args:
input_path (str): The path of the directory containing input video files. input_path (str): The path of the directory containing input video files.
output_path (str): The path of the directory where converted video files will be saved. output_path (str, optional): The path of the directory where converted video files will be saved.
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.
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:
bool: True if conversion succeeds, False otherwise. bool: True if conversion succeeds, False otherwise.
''' """
# Get supported subtitle codecs # Get supported subtitle codecs
supported_subtitle_codecs = get_ffmpeg_codecs("S") supported_subtitle_codecs = get_ffmpeg_codecs("S")
@ -301,17 +333,21 @@ def convert_videos(input_path, output_path, max_height=720, debug=False):
print(f"No video files to convert found.\n") print(f"No video files to convert found.\n")
return False return False
# Ensure the output directory exists # Ensure the fixed output directory exists (only when output_path is set)
if output_path:
ensure_output_path(output_path) ensure_output_path(output_path)
# Print a message indicating the start of the conversion process # Print a message indicating the start of the conversion process
print( print(
f"Converting {len(video_files)} videos taller than {max_height} pixels to 720p resolution with x265 encoding and MKV container...\n" f"Converting {len(video_files)} videos taller than {max_height} pixels to {max_height}p resolution with x265 encoding and MKV container...\n"
) )
# 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
@ -323,25 +359,45 @@ def convert_videos(input_path, output_path, max_height=720, debug=False):
print(f"{ccyan}Original file:{creset}") print(f"{ccyan}Original file:{creset}")
print(video_file) print(video_file)
# Generate output file path and name # Determine output directory: fixed path, or same dir as the source file
effective_output_path = (
output_path if output_path else os.path.dirname(video_file)
)
# 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")
output_path, )
os.path.splitext(os.path.basename(video_file))[0] + '.mkv'))
try: try:
# Get original video width and height # Get original video width and height
video_info = subprocess.run([ video_info = subprocess.run(
'ffprobe', '-v', 'error', '-select_streams', 'v:0', [
'-show_entries', 'stream=width,height', '-of', 'csv=p=0', "ffprobe",
video_file "-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0",
video_file,
], ],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True) text=True,
width, height = map( )
int, width, height = map(int, video_info.stdout.strip().rstrip(",").split(","))
video_info.stdout.strip().rstrip(',').split(','))
# Calculate the scaled width maintaining aspect ratio # Calculate the scaled width maintaining aspect ratio
scaled_width = int(max_height * (width / height)) scaled_width = int(max_height * (width / height))
@ -353,38 +409,55 @@ def convert_videos(input_path, output_path, max_height=720, debug=False):
current_file = output_file current_file = output_file
# Check if the video has supported subtitles # Check if the video has supported subtitles
include_subs = has_supported_subs(video_file, include_subs = has_supported_subs(
supported_subtitle_codecs, debug) video_file, supported_subtitle_codecs, debug
)
# Construct the FFmpeg command dynamically # Construct the FFmpeg command dynamically
ffmpeg_command = [ ffmpeg_command = [
'ffmpeg', '-n', '-i', video_file, '-map', '0:v', '-c:v', "ffmpeg",
'libx265', '-vf', f'scale={scaled_width}:{max_height}', "-n",
'-preset', 'medium', '-crf', '28', '-map', '0:a?', '-c:a', "-i",
'copy' video_file,
"-map",
"0:v",
"-c:v",
"libx265",
"-vf",
f"scale={scaled_width}:{max_height}",
"-preset",
"medium",
"-crf",
"28",
"-map",
"0:a?",
"-c:a",
"copy",
] ]
# Only include subtitles if they are supported # Only include subtitles if they are supported
if include_subs: if include_subs:
ffmpeg_command.extend(['-map', '0:s?', '-c:s', 'copy']) ffmpeg_command.extend(["-map", "0:s?", "-c:s", "copy"])
ffmpeg_command.append(output_file) ffmpeg_command.append(output_file)
# Print the FFmpeg command as a single string # Print the FFmpeg command as a single string
if (debug): if debug:
print("FFmpeg command: " + ' '.join(ffmpeg_command)) print("FFmpeg command: " + " ".join(ffmpeg_command))
# Run the FFmpeg command # Run the FFmpeg command
process = subprocess.run(ffmpeg_command, process = subprocess.run(
ffmpeg_command,
check=True, check=True,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT) stderr=subprocess.STDOUT,
)
# Check if ffmpeg process returned successfully # Check if ffmpeg process returned successfully
if process.returncode != 0: if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, raise subprocess.CalledProcessError(
process.args, process.returncode, process.args, output=process.stderr
output=process.stderr) )
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
# If we have a partial video converted, delete it due to the failure # If we have a partial video converted, delete it due to the failure
@ -397,11 +470,13 @@ def convert_videos(input_path, output_path, max_height=720, debug=False):
decoded_error = e.output.decode("utf-8") decoded_error = e.output.decode("utf-8")
print( print(
f"{cred}Error processing {video_file}: {decoded_error}{creset}\n", f"{cred}Error processing {video_file}: {decoded_error}{creset}\n",
file=sys.stderr) file=sys.stderr,
)
else: else:
print( print(
f"{cred}Error processing {video_file}: No output from subprocess{creset}\n", f"{cred}Error processing {video_file}: No output from subprocess{creset}\n",
file=sys.stderr) file=sys.stderr,
)
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"{cyellow}Conversions cancelled, cleaning up...{creset}") print(f"{cyellow}Conversions cancelled, cleaning up...{creset}")
@ -422,13 +497,12 @@ def convert_videos(input_path, output_path, max_height=720, debug=False):
if debug: if debug:
print( print(
f"{cred}Error processing {video_file}: {str(e)}{creset}\n", f"{cred}Error processing {video_file}: {str(e)}{creset}\n",
file=sys.stderr) file=sys.stderr,
)
else: else:
# Print success message if conversion is successful # Print success message if conversion is successful
print( print(f"{ccyan}Successfully converted video to output file:{creset}")
f"{ccyan}Successfully converted video to output file:{creset}")
print(f"{output_file}\n") print(f"{output_file}\n")
try: try:
@ -436,56 +510,151 @@ def convert_videos(input_path, output_path, max_height=720, debug=False):
except PermissionError: except PermissionError:
print(f"{cred}PermissionError on: '{output_file}'{creset}") print(f"{cred}PermissionError on: '{output_file}'{creset}")
# Remove the now succefully converted filepath # Delete the original file if requested
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 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
def main(): def parse_args():
''' """
Main function to parse command line arguments and initiate video conversion. Parse command line arguments.
'''
Returns:
argparse.Namespace: Parsed arguments.
"""
# Create argument parser
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description= description="Convert videos taller than a certain height to 720p resolution with x265 encoding and MKV container."
'Convert videos taller than a certain height to 720p resolution with x265 encoding and MKV container.'
) )
# Define command line arguments
parser.add_argument( parser.add_argument(
'input_path', "input_path",
nargs='?', nargs="?",
default=os.getcwd(), default=os.getcwd(),
help= help="directory path to search for video files (default: current directory)",
'directory path to search for video files (default: current directory)'
) )
parser.add_argument( parser.add_argument(
'-o', "-o",
'--output-path', "--output-path",
default=os.path.join(os.getcwd(), 'converted'), default=None,
help='directory path to save converted videos (default: ./converted)') help="directory path to save converted videos (default: ./converted, or alongside originals with --delete)",
)
parser.add_argument( parser.add_argument(
'-mh', "-mh",
'--max-height', "--max-height",
type=int, type=int,
default=720, default=720,
help='maximum height of videos to be converted (default: 720)') help="maximum height of videos to be converted (default: 720)",
)
parser.add_argument( parser.add_argument(
'--debug', "-del",
action='store_true', "--delete",
help='enable debug mode for printing additional error messages') action="store_true",
help="delete original files after successful conversion",
)
parser.add_argument(
"-y",
"--yes",
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",
help="enable debug mode for printing additional error messages",
)
# Enable autocomplete for argparse # Enable autocomplete for argparse
argcomplete.autocomplete(parser) argcomplete.autocomplete(parser)
# Parse command line arguments
args = parser.parse_args() args = parser.parse_args()
if args.output_path is None and not args.delete:
args.output_path = os.path.join(os.getcwd(), "converted")
# Warn and confirm if --delete is active
if args.delete:
print(f"{cyellow}WARNING: Delete option selected!{creset}")
if not args.yes and not user_confirm(
"Are you sure you wish to delete original files after successful conversion? [Y/N] ?",
color="yellow",
):
print(f"{cgreen}Exiting!{creset}")
exit()
return args
def main():
"""
Main function to parse command line arguments and initiate video conversion.
"""
args = parse_args()
# Convert videos # Convert videos
convert_videos(args.input_path, args.output_path, args.max_height, convert_videos(
args.debug) args.input_path,
args.output_path,
args.max_height,
args.delete,
args.rename,
args.debug,
)
if __name__ == "__main__": if __name__ == "__main__":

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()
@ -17,7 +26,7 @@ cred = colorama.Fore.RED
def autorename(input_path, max_height=720, debug=False): def autorename(input_path, max_height=720, debug=False):
''' """
Rename video files and folders in the specified directory tree that contain a resolution Rename video files and folders in the specified directory tree that contain a resolution
in their name to the specified max_height resolution. in their name to the specified max_height resolution.
@ -25,13 +34,7 @@ def autorename(input_path, max_height=720, debug=False):
input_path (str): The path of the directory to search for video files and folders. input_path (str): The path of the directory to search for video files and folders.
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)',
}
files_to_rename = [] files_to_rename = []
dirs_to_rename = [] dirs_to_rename = []
@ -39,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:
@ -76,7 +63,7 @@ def autorename(input_path, max_height=720, debug=False):
continue continue
os.rename(old_file, new_file) os.rename(old_file, new_file)
if debug: if debug:
print(f'Renamed file: {old_file} -> {new_file}') print(f"Renamed file: {old_file} -> {new_file}")
# Rename directories after # Rename directories after
for old_dir, new_dir in sorted(dirs_to_rename, key=lambda x: -len(x[0])): for old_dir, new_dir in sorted(dirs_to_rename, key=lambda x: -len(x[0])):
@ -85,38 +72,38 @@ def autorename(input_path, max_height=720, debug=False):
continue continue
os.rename(old_dir, new_dir) os.rename(old_dir, new_dir)
if debug: if debug:
print(f'Renamed directory: {old_dir} -> {new_dir}') print(f"Renamed directory: {old_dir} -> {new_dir}")
def main(): def main():
''' """
Main function to parse command line arguments and initiate file renamings. Main function to parse command line arguments and initiate file renamings.
''' """
# Create argument parser # Create argument parser
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description= description="Rename video files containing resolutions in their filenames to a specified max_height resolution."
'Rename video files containing resolutions in their filenames to a specified max_height resolution.'
) )
# Define command line arguments # Define command line arguments
parser.add_argument( parser.add_argument(
'input_path', "input_path",
nargs='?', nargs="?",
default=os.getcwd(), default=os.getcwd(),
help= help="directory path to search for video files (default: current directory)",
'directory path to search for video files (default: current directory)'
) )
parser.add_argument( parser.add_argument(
'-mh', "-mh",
'--max-height', "--max-height",
type=int, type=int,
default=720, default=720,
help='maximum height of videos to be converted (default: 720)') help="maximum height of videos to be converted (default: 720)",
)
parser.add_argument( parser.add_argument(
'--debug', "--debug",
action='store_true', action="store_true",
help='enable debug mode for printing additional messages') help="enable debug mode for printing additional messages",
)
# Enable autocomplete for argparse # Enable autocomplete for argparse
argcomplete.autocomplete(parser) argcomplete.autocomplete(parser)

View File

@ -32,20 +32,48 @@ def process_subtitles(file_path, track, command):
if command == "remove": if command == "remove":
# Remove only the specified subtitle track while keeping all other streams # Remove only the specified subtitle track while keeping all other streams
ffmpeg_command = [ ffmpeg_command = [
"ffmpeg", "-i", file_path, "-map", "0", "-map", f"-0:s:{track}", "ffmpeg",
"-c", "copy", output_file "-i",
file_path,
"-map",
"0",
"-map",
f"-0:s:{track}",
"-c",
"copy",
output_file,
] ]
elif command == "keep": elif command == "keep":
# Keep only the specified subtitle track while preserving video, audio, and metadata # Keep only the specified subtitle track while preserving video, audio, and metadata
ffmpeg_command = [ ffmpeg_command = [
"ffmpeg", "-i", file_path, "-map", "0:v", "-map", "0:a", "-map", "ffmpeg",
f"0:s:{track}", "-map", "0:t?", "-c", "copy", output_file "-i",
file_path,
"-map",
"0:v",
"-map",
"0:a",
"-map",
f"0:s:{track}",
"-map",
"0:t?",
"-c",
"copy",
output_file,
] ]
elif command == "none": elif command == "none":
# Remove all subtitle tracks # Remove all subtitle tracks
ffmpeg_command = [ ffmpeg_command = [
"ffmpeg", "-i", file_path, "-map", "0", "-map", "-0:s", "-c", "ffmpeg",
"copy", output_file "-i",
file_path,
"-map",
"0",
"-map",
"-0:s",
"-c",
"copy",
output_file,
] ]
else: else:
print(f"{cred}Invalid command: {command}{creset}") print(f"{cred}Invalid command: {command}{creset}")
@ -53,10 +81,9 @@ def process_subtitles(file_path, track, command):
try: try:
# Execute the ffmpeg command and capture output # Execute the ffmpeg command and capture output
result = subprocess.run(ffmpeg_command, result = subprocess.run(
stdout=subprocess.PIPE, ffmpeg_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
stderr=subprocess.PIPE, )
text=True)
if result.returncode == 0: if result.returncode == 0:
print( print(
@ -91,13 +118,16 @@ def main():
# Create argument parser # Create argument parser
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Manage subtitle tracks in video files.") description="Manage subtitle tracks in video files."
)
# Define command line arguments # Define command line arguments
# Add a positional argument for the command # Add a positional argument for the command
parser.add_argument('command', parser.add_argument(
choices=['remove', 'keep', 'none'], "command",
help='Command to run (remove, keep, or none)') choices=["remove", "keep", "none"],
help="Command to run (remove, keep, or none)",
)
# Add other arguments with both short and long options, including defaults # Add other arguments with both short and long options, including defaults
parser.add_argument( parser.add_argument(
@ -105,19 +135,16 @@ def main():
"--track", "--track",
type=int, type=int,
default=0, default=0,
help= help="Subtitle track index (default is 0). Use 'none' to remove all subtitles.",
"Subtitle track index (default is 0). Use 'none' to remove all subtitles."
) )
parser.add_argument("-f", parser.add_argument("-f", "--file", type=str, help="Path to a specific video file.")
"--file",
type=str,
help="Path to a specific video file.")
parser.add_argument( parser.add_argument(
"-d", "-d",
"--dir", "--dir",
type=str, type=str,
default=os.getcwd(), default=os.getcwd(),
help="Directory to process (default is current directory).") help="Directory to process (default is current directory).",
)
# Enable autocomplete for command-line arguments # Enable autocomplete for command-line arguments
argcomplete.autocomplete(parser) argcomplete.autocomplete(parser)