From 69a9ee506ca9ed5195bfbad8bb4bef9782d27d67 Mon Sep 17 00:00:00 2001
From: PatillaCode <patillacode@gmail.com>
Date: Sat, 18 Nov 2023 16:53:29 +0100
Subject: [PATCH] utils refactor, extensions, logging, tweaks

---
 Makefile          |   6 +-
 README.md         |   9 ++-
 requirements.txt  |   1 -
 ruff.toml         |   3 -
 space_saver.py    | 201 ++++++++++------------------------------------
 utils/__init__.py |   0
 utils/logger.py   |  16 ++++
 utils/misc.py     |  53 ++++++++++++
 utils/video.py    |  94 ++++++++++++++++++++++
 9 files changed, 214 insertions(+), 169 deletions(-)
 create mode 100644 utils/__init__.py
 create mode 100644 utils/logger.py
 create mode 100644 utils/misc.py
 create mode 100644 utils/video.py

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