diff --git a/Makefile b/Makefile index 058ef58..e1e193e 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,8 @@ # Makefile for space_saver.py -# Variables VENV_NAME?=venv PYTHON=${VENV_NAME}/bin/python3 -# Targets - -.PHONY: help setup run clean install test lint format docs help: @echo "make clean" @@ -16,7 +12,7 @@ help: clean: rm -rf $(VENV_NAME) - find -iname "*.pyc" -delete + find . -name "*.pyc" -delete install: python3 -m venv $(VENV_NAME) diff --git a/README.md b/README.md index 908e5f3..e91ea33 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # Space Saver -This project includes a script (`space_saver.py`) that converts video files to the H.265 codec, which provides more efficient encoding and results in smaller file sizes. +`space_saver.py` converts video files to .mp4 format with H.265 codec. +It walks through a directory and its subdirectories looking for files with a given +extension (or all files if no extension is given), and converts them to .mp4 with +H.265 codec. + +The original file is deleted if the conversion is successful, +and the new file is given permissions of 755. +The script also calculates the space saved by the conversion and prints it at the end. ## Setup diff --git a/requirements.txt b/requirements.txt index 0c0a563..cfbf779 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ ffmpeg-python==0.2.0 -termcolor==1.1.0 diff --git a/ruff.toml b/ruff.toml index 01bfe08..68bb40d 100644 --- a/ruff.toml +++ b/ruff.toml @@ -34,7 +34,6 @@ known-third-party = [] section-order = [ "future", "standard-library", - "django", "third-party", "first-party", "local-folder", @@ -43,5 +42,3 @@ combine-as-imports = true split-on-trailing-comma = false lines-between-types = 1 -# [isort.sections] -# "django" = ["django"] diff --git a/space_saver.py b/space_saver.py index 716efb0..eedd8d4 100644 --- a/space_saver.py +++ b/space_saver.py @@ -1,177 +1,50 @@ -""" -This script converts video files to .mp4 format with H.265 codec. -It walks through a directory and its subdirectories looking for files with a given -extension (or all files if no extension is given), and converts them to .mp4 with -H.265 codec. - -The original file is deleted if the conversion is successful, -and the new file is given permissions of 755. -The script also calculates the space saved by the conversion and prints it at the end. -""" - import argparse -import math -import os -import sys -import time - -import ffmpeg -from termcolor import colored - - -def convert_size(size_bytes): - """ - Converts the given size in bytes to a human-readable format. +from utils.logger import configure_logger, logger +from utils.video import convert_to_H265_codec - Args: - size_bytes (int): The size in bytes. - Returns: - str: The size in a human-readable format. +def examples(): + return """ + Examples: + python space_saver.py -p /home/user/videos -e mp4 mkv + python space_saver.py -p /home/user/videos -d + python space_saver.py -p /home/user/videos -q + python space_saver.py -p /home/user/videos -q -crf 28 """ - if size_bytes == 0: - return "0B" - size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") - i = int(math.floor(math.log(size_bytes, 1024))) - p = math.pow(1024, i) - s = round(size_bytes / p, 2) - return f"{s} {size_name[i]}" - - -def convert_single_file(input_file_path, mp4_file_path, quiet, crf, extension): - """ - Converts a single video file to .mp4 format with H.265 codec. - - Args: - input_file_path (str): The path to the input file. - mp4_file_path (str): The path to the output .mp4 file. - quiet (bool): Whether to keep the ffmpeg output to a minimum while converting. - crf (int): The Constant Rate Factor (CRF) value for the ffmpeg command. - extension (str): The extension of the input file. - - Returns: - int: The space saved by the conversion, in bytes. - """ - original_size = os.path.getsize(input_file_path) - probe = ffmpeg.probe(input_file_path) - video_stream = next( - (stream for stream in probe["streams"] if stream["codec_type"] == "video"), None - ) - if video_stream is None: - print(f"No video stream found in {colored(input_file_path, 'red')}") - return - if video_stream["codec_name"] == "hevc": - print(f"{input_file_path} is already using H.265 codec, skipping conversion") - return - try: - print( - f"Converting {colored(input_file_path, 'yellow')} to mp4 with H.265 codec" - ) - start_time = time.time() - ffmpeg.input(input_file_path).output( - mp4_file_path, - vcodec="libx265", - crf=str(crf), - acodec="aac", - strict="experimental", - ).overwrite_output().run(quiet=quiet) - end_time = time.time() - except ffmpeg.Error as err: - print( - f"Error occurred while converting {colored(input_file_path, 'yellow')} to " - f"mp4: {colored(err, 'red')}" - ) - sys.exit() - else: - # If the conversion was successful, delete the original file and change - # permissions of the new file - if os.path.isfile(input_file_path): - os.remove(input_file_path) - new_size = os.path.getsize(mp4_file_path) - os.chmod(mp4_file_path, 0o755) - space_saved = original_size - new_size - print_file_sizes(original_size, new_size, extension) - hours, rem = divmod(end_time - start_time, 3600) - minutes, seconds = divmod(rem, 60) - formatted_time = "{:0>2}:{:0>2}:{:05.2f}".format( - int(hours), int(minutes), seconds - ) - print(f"Time elapsed: {colored(formatted_time, 'white')}") - return space_saved - - -def print_file_sizes(original_size, new_size, extension): - """ - Prints the original and new file sizes, and the space saved by the conversion. - - Args: - original_size (int): The size of the original file, in bytes. - new_size (int): The size of the new file, in bytes. - extension (str): The extension of the original file. - """ - size_reduction = ((original_size - new_size) / original_size) * 100 - print( - f"Original {extension} file size: " - f"{colored(convert_size(original_size), 'magenta')}\n" - f"New mp4 file size: {colored(convert_size(new_size), 'cyan')}\n" - f"Size reduction: {colored(int(size_reduction), 'cyan')}%" - ) - - -def convert_to_H265_codec(path, extension, dry_run, crf, quiet=False): - """ - Converts all video files in the given directory and its subdirectories to .mp4 format - with H.265 codec. - - Args: - path (str): The path to the directory containing the video files to convert. - extension (str): The extension of the video files to convert, or None to convert - all files. - dry_run (bool): Whether to perform a dry run without actually converting files. - crf (int): The Constant Rate Factor (CRF) value for the ffmpeg command. - quiet (bool): Whether to keep the ffmpeg output to a minimum while converting. - """ - total_space_saved = 0 - for root, _, files in os.walk(path): - for file in files: - print("Looking at file: ", file) - if ( - extension is None - or file.endswith(f".{extension}") - or file.endswith(f".{extension.upper()}") - ): - file_path = os.path.join(root, file) - base_file_path = os.path.splitext(file_path)[0] - mp4_file_path = f"{base_file_path}_H265.mp4" - if dry_run: - print(f"Would convert {file_path} to {mp4_file_path}") - else: - space_saved = convert_single_file( - file_path, mp4_file_path, quiet, crf, extension - ) - total_space_saved += space_saved if space_saved else 0 - print(colored(f"Total space saved: {convert_size(total_space_saved)}", "yellow")) def parse_arguments(): parser = argparse.ArgumentParser( - description="Convert files to .mp4 with H.265 codec" + prog="space_saver.py", + description="Convert files to .mp4 with H.265 codec", + epilog=examples(), + formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "-p", "--path", required=True, help=( - r"Path to the directory containing the .\{format\} files to convert into " + "Path to the directory containing the files to convert into " ".mp4 files with H.265 codec" ), ) parser.add_argument( - "-f", - "--format", + "-e", + "--extensions", required=False, - help="File formats to convert, if not given all files will be checked", + default=["mp4", "mkv", "avi", "mpg", "mpeg", "mov", "wmv", "flv", "webm"], + nargs="*", + type=str, + help=r"Extensions to filter, default: %(default)s", + ) + parser.add_argument( + "-c", + "--crf", + type=int, + default=23, + help="Constant Rate Factor for the H.265 codec, scale is 0-51 (default: 23)", ) parser.add_argument( "-d", @@ -186,14 +59,24 @@ def parse_arguments(): help="Keep the ffmpeg output to a minimum while converting", ) parser.add_argument( - "-c", - "--crf", - type=int, - default=23, + "-l", + "--log", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Set the logging level", ) return parser.parse_args() if __name__ == "__main__": args = parse_arguments() - convert_to_H265_codec(args.path, args.format, args.dry_run, args.crf, args.quiet) + configure_logger(args.log) + logger.info(f"Starting conversion process. {'(DRY RUN)' if args.dry_run else ''}") + try: + convert_to_H265_codec( + args.path, args.extensions, args.dry_run, args.crf, args.quiet + ) + except Exception as err: + logger.error(f"An error occurred during conversion: {err}") + else: + logger.info("Conversion process finished.") diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..1326777 --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,16 @@ +import logging + + +def configure_logger(log_level): + numeric_level = getattr(logging, log_level.upper(), None) + if not isinstance(numeric_level, int): + raise ValueError(f"Invalid log level: {log_level}") + logging.basicConfig( + level=numeric_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[logging.StreamHandler()], + ) + + +logger = logging.getLogger("space_saver") diff --git a/utils/misc.py b/utils/misc.py new file mode 100644 index 0000000..1f5bf36 --- /dev/null +++ b/utils/misc.py @@ -0,0 +1,53 @@ +import math +import os + +from pathlib import Path + +from utils.logger import logger + + +def convert_size(size_bytes): + if size_bytes == 0: + return "0B" + size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + i = int(math.floor(math.log(size_bytes, 1024))) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return f"{s} {size_name[i]}" + + +def print_file_sizes(original_size, new_size): + size_reduction = ((original_size - new_size) / original_size) * 100 + logger.info( + f"Original file size: {convert_size(original_size)}\n" + f"New mp4 file size: {convert_size(new_size)}\n" + f"Size reduction: {int(size_reduction)}%" + ) + + +def check_file_type(file, extensions): + file_extension = Path(file).suffix.lower()[1:] + if file_extension not in extensions: + logger.warning( + f'Skipping file "{Path(file).name}", it doesn\'t have a valid extension' + ) + return False + return True + + +def after_conversion_clean_up( + input_file_path, original_size, output_file_path, start_time, end_time +): + if os.path.isfile(input_file_path): + os.remove(input_file_path) + new_size = os.path.getsize(output_file_path) + os.chmod(output_file_path, 0o755) + space_saved = original_size - new_size + print_file_sizes(original_size, new_size) + hours, rem = divmod(end_time - start_time, 3600) + minutes, seconds = divmod(rem, 60) + formatted_time = "{:0>2}:{:0>2}:{:05.2f}".format( + int(hours), int(minutes), seconds + ) + logger.info(f"Time elapsed: {formatted_time}") + return space_saved diff --git a/utils/video.py b/utils/video.py new file mode 100644 index 0000000..4df227e --- /dev/null +++ b/utils/video.py @@ -0,0 +1,94 @@ +import os +import time + +from pathlib import Path + +import ffmpeg + +from utils.logger import logger +from utils.misc import after_conversion_clean_up, check_file_type, convert_size + + +def convert_to_H265_codec(path, extensions, dry_run, crf, quiet=False): + total_space_saved = 0 + for root, _, files in os.walk(path): + for file in files: + logger.info(f'Looking at file: "{file}"') + if check_file_type(file, extensions): + input_file_path = os.path.join(root, file) + base_file_path = os.path.splitext(input_file_path)[0] + output_file_path = f"{base_file_path}_H265.mp4" + space_saved = convert_single_file( + input_file_path, output_file_path, quiet, crf, dry_run + ) + total_space_saved += space_saved if space_saved else 0 + logger.info(f"Total space saved: {convert_size(total_space_saved)}") + + +def convert_single_file(input_file_path, output_file_path, quiet, crf, dry_run): + original_size = os.path.getsize(input_file_path) + + if check_video_stream(input_file_path): + if dry_run: + logger.info(f"Would convert {input_file_path} to {output_file_path}") + return 0 + try: + start_time, end_time = ffmpeg_convert( + input_file_path, output_file_path, quiet, crf + ) + except ffmpeg.Error as err: + logger.error( + f"Error occurred while converting {input_file_path} to mp4: {err}" + ) + raise + else: + return after_conversion_clean_up( + input_file_path, + original_size, + output_file_path, + start_time, + end_time, + ) + + +def ffmpeg_convert(input_file_path, mp4_file_path, quiet, crf): + start_time = time.time() + logger.info(f"Converting {input_file_path} to mp4 with H.265 codec") + ffmpeg.input(input_file_path).output( + mp4_file_path, + vcodec="libx265", + crf=str(crf), + acodec="aac", + strict="experimental", + ).overwrite_output().run(quiet=quiet) + end_time = time.time() + + return start_time, end_time + + +def check_video_stream(input_file_path): + try: + probe = ffmpeg.probe(input_file_path) + video_stream = next( + (stream for stream in probe["streams"] if stream["codec_type"] == "video"), + None, + ) + + if video_stream is None: + logger.warning(f"No video stream found in {input_file_path}") + return False + + if video_stream["codec_name"] == "hevc": + logger.warning( + f'File "{Path(input_file_path).name}" already using H.265 codec, ' + "skipping conversion." + ) + return False + + return True + + except ffmpeg.Error as err: + logger.error( + f"Error occurred while checking video stream of {input_file_path}: {err}" + ) + raise