feat: extract resolution rename to library/tools.py, add -r/--rename to video_autoreduce
This commit is contained in:
parent
df8bdd903d
commit
29446a718d
@ -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
134
scripts/library/tools.py
Normal 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 "_"
|
||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user