|
@ -1,47 +1,53 @@ |
|
|
CONFIG_FILE: str = "libconv.ini" |
|
|
|
|
|
CUE_SHEET_EXTENSION: str = ".cue" |
|
|
CUE_SHEET_EXTENSION: str = ".cue" |
|
|
|
|
|
|
|
|
import os |
|
|
import os |
|
|
import shutil |
|
|
import shutil |
|
|
import logging |
|
|
import logging |
|
|
import subprocess |
|
|
import subprocess |
|
|
|
|
|
import sys |
|
|
|
|
|
import re |
|
|
|
|
|
|
|
|
from configparser import ConfigParser |
|
|
from configparser import ConfigParser |
|
|
from copy import deepcopy |
|
|
from copy import deepcopy |
|
|
from pathlib import Path |
|
|
from pathlib import Path |
|
|
from typing import List, Optional, Tuple |
|
|
from typing import List, Optional, Tuple |
|
|
|
|
|
from ffcuesplitter.cuesplitter import FFCueSplitter |
|
|
|
|
|
|
|
|
LOGGER = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
CONFIG_FILE: str = sys.argv[1] |
|
|
|
|
|
|
|
|
config = ConfigParser() |
|
|
config = ConfigParser() |
|
|
config.read(CONFIG_FILE) |
|
|
config.read(CONFIG_FILE) |
|
|
source_folder: str = config.get("libconv", "source_folder") |
|
|
|
|
|
destination_folder: str = config.get("libconv", "destination_folder") |
|
|
|
|
|
source_file_extensions: List[str] = config.get("libconv", "source_file_extensions").split(",") |
|
|
|
|
|
destination_extension: str = config.get("libconv", "destination_extension") |
|
|
|
|
|
ffmpeg_options: str = config.get("libconv", "ffmpeg_options") |
|
|
|
|
|
ffmpeg_location: str = config.get("libconv", "ffmpeg_location") |
|
|
|
|
|
folder_icon_extensions: List[str] = config.get("libconv", "folder_icon_extensions").split(",") |
|
|
|
|
|
overwrite: bool = config.get("libconv", "overwrite_files") == "true" |
|
|
|
|
|
dry_run: bool = config.get("libconv", "dry_run") != "false" |
|
|
|
|
|
|
|
|
SRC_FOLDER: str = config.get("source", "folder") |
|
|
|
|
|
SRC_FILE_EXTS: List[str] = config.get("source", "file_exts").split(",") |
|
|
|
|
|
SRC_ICON_EXTS: List[str] = config.get("source", "icon_exts").split(",") |
|
|
|
|
|
DST_FOLDER: str = config.get("destination", "folder") |
|
|
|
|
|
DST_FILE_EXT: str = config.get("destination", "file_ext") |
|
|
|
|
|
DST_OVERWRITE: bool = config.get("libconv", "overwrite") == "true" |
|
|
|
|
|
DRY_RUN: bool = config.get("libconv", "dry_run") != "false" |
|
|
|
|
|
DEDUCE_METADATA: bool = config.get("libconv", "deduce_metadata") == "true" |
|
|
|
|
|
FFMPEG_LOCATION: str = config.get("ffmpeg", "location") |
|
|
|
|
|
FFMPEG_OPTS: str = config.get("ffmpeg", "options") |
|
|
|
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO) |
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------- # |
|
|
# ----------------------------------------------------------------- # |
|
|
# file and folder operations |
|
|
# file and folder operations |
|
|
|
|
|
|
|
|
def copy(src: str, dst: str): |
|
|
def copy(src: str, dst: str): |
|
|
if (dry_run): |
|
|
|
|
|
LOGGER.debug(f"copy {src} to {dst}") |
|
|
|
|
|
|
|
|
if (DRY_RUN): |
|
|
|
|
|
logging.info(f"copy {src} to {dst}") |
|
|
else: |
|
|
else: |
|
|
shutil.copyfile(src, dst, follow_symlinks=True) |
|
|
shutil.copyfile(src, dst, follow_symlinks=True) |
|
|
|
|
|
|
|
|
def mkdir(path: str): |
|
|
def mkdir(path: str): |
|
|
if (dry_run): |
|
|
|
|
|
LOGGER.debug(f"mkdir -r {path}") |
|
|
|
|
|
|
|
|
if (DRY_RUN): |
|
|
|
|
|
logging.info(f"mkdir -r {path}") |
|
|
else: |
|
|
else: |
|
|
os.makedirs(path, exist_ok=True) |
|
|
os.makedirs(path, exist_ok=True) |
|
|
|
|
|
|
|
|
def execute(command: str): |
|
|
def execute(command: str): |
|
|
if (dry_run): |
|
|
|
|
|
LOGGER.debug(f"execute: {command}") |
|
|
|
|
|
|
|
|
if (DRY_RUN): |
|
|
|
|
|
logging.info(f"execute: {command}") |
|
|
else: |
|
|
else: |
|
|
subprocess.run(command) |
|
|
subprocess.run(command) |
|
|
|
|
|
|
|
@ -53,45 +59,105 @@ def cue_sheet_processor(path: str): |
|
|
sheet = get_files_with_ext(path, [CUE_SHEET_EXTENSION]) |
|
|
sheet = get_files_with_ext(path, [CUE_SHEET_EXTENSION]) |
|
|
|
|
|
|
|
|
if (len(sheet) != 1): |
|
|
if (len(sheet) != 1): |
|
|
LOGGER.debug(f"ERR: Expected exactly one but {path} contains {len(sheet)} cue sheets") |
|
|
|
|
|
exit(-1) |
|
|
|
|
|
|
|
|
logging.error(f"Expected exactly one but {path} contains {len(sheet)} cue sheets") |
|
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
|
|
data = FFCueSplitter(sheet[0], dry=DRY_RUN, outputdir=dst_folder, suffix=DST_FILE_EXT, overwrite=DST_OVERWRITE, ffmpeg_cmd=FFMPEG_LOCATION, ffmpeg_add_params=FFMPEG_OPTS) |
|
|
|
|
|
data.open_cuefile() |
|
|
|
|
|
args = data.ffmpeg_arguments()["arguments"] |
|
|
|
|
|
dst_folder = prepare_destination(path) |
|
|
|
|
|
|
|
|
|
|
|
for arg in args: |
|
|
|
|
|
arg_end = arg.find("-y") |
|
|
|
|
|
filename = arg[arg_end + 5:-1] |
|
|
|
|
|
arg = arg[:arg_end] |
|
|
|
|
|
execute(f"{arg} \"{dst_folder}{filename}\"") |
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------- # |
|
|
# ----------------------------------------------------------------- # |
|
|
# basic "folder contains audio files" processing |
|
|
# basic "folder contains audio files" processing |
|
|
|
|
|
|
|
|
|
|
|
def metadata_from_folder(path: str) -> str: |
|
|
|
|
|
# this method has to be adapted to your individual folder structure |
|
|
|
|
|
# if there is none then do nothing as by default this is not used |
|
|
|
|
|
if not DEDUCE_METADATA: |
|
|
|
|
|
return "" |
|
|
|
|
|
|
|
|
|
|
|
path = os.path.normpath(path) |
|
|
|
|
|
folders = path.split(os.sep) |
|
|
|
|
|
|
|
|
|
|
|
logging.debug(f"deducing metadata from folders: {folders}") |
|
|
|
|
|
|
|
|
|
|
|
album_name = re.sub(r'[ ]*\(.*?\)', '', folders[-1]) |
|
|
|
|
|
comment = folders[-1].replace(album_name, "").replace("(", "").replace(")", "").replace(" ","") |
|
|
|
|
|
|
|
|
|
|
|
logging.debug(f"got metadata: album={album_name} comment={comment} artist={folders[-2]} genre={folders[-3]}") |
|
|
|
|
|
|
|
|
|
|
|
return f"-metadata ALBUM=\"{album_name}\" -metadata COMMENT=\"{comment}\" -metadata ARTIST=\"{folders[-2]}\" -metadata GENRE=\"{folders[-3]}\"" |
|
|
|
|
|
|
|
|
|
|
|
def metadata_from_file(filename: str) -> str: |
|
|
|
|
|
title="" |
|
|
|
|
|
number="" |
|
|
|
|
|
|
|
|
|
|
|
if re.match(r'[0-9]+[ ]*-[ ]*[.]*', filename): |
|
|
|
|
|
logging.debug(f"file {filename} matched metadata regex #1") |
|
|
|
|
|
number = re.search(r'[0-9]+[ ]*-[ ]*', filename).group() |
|
|
|
|
|
title = filename.replace(number, "") |
|
|
|
|
|
number = re.sub(r'[ ]*-[ ]*', '', number) |
|
|
|
|
|
elif re.match(r'[0-9]+\.[ ]*[.]*', filename): |
|
|
|
|
|
logging.info(f"file {filename} matched metadata regex #2") |
|
|
|
|
|
number = re.search(r'[0-9]+\.[ ]*', filename).group() |
|
|
|
|
|
title = filename.replace(number, "") |
|
|
|
|
|
number = re.sub(r'\.[ ]*', "", number) |
|
|
|
|
|
else: |
|
|
|
|
|
logging.debug(f"file {filename} matched no metadata regex") |
|
|
|
|
|
|
|
|
|
|
|
return f"-metadata TITLE=\"{title}\" -metadata TRACK=\"{number}\"" |
|
|
|
|
|
|
|
|
def file_processor(path: str): |
|
|
def file_processor(path: str): |
|
|
dst_folder = prepare_destination(path) |
|
|
dst_folder = prepare_destination(path) |
|
|
files = get_files_with_ext(path, source_file_extensions) |
|
|
|
|
|
|
|
|
files = get_files_with_ext(path, SRC_FILE_EXTS) |
|
|
|
|
|
album_metadata = metadata_from_folder(path) |
|
|
|
|
|
|
|
|
for file in files: |
|
|
for file in files: |
|
|
filename = get_filename(file, path) |
|
|
filename = get_filename(file, path) |
|
|
execute(f"{ffmpeg_location} -i {file} {ffmpeg_options} {dst_folder + filename}.{destination_extension}") |
|
|
|
|
|
|
|
|
file_metadata = metadata_from_file(filename[1:]) |
|
|
|
|
|
execute(f"\"{FFMPEG_LOCATION}\" -i \"{file}\" {FFMPEG_OPTS} {album_metadata} {file_metadata} \"{dst_folder + filename}.{DST_FILE_EXT}\"") |
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------- # |
|
|
# ----------------------------------------------------------------- # |
|
|
# Iteration over library folders and preparation |
|
|
# Iteration over library folders and preparation |
|
|
|
|
|
|
|
|
def contains_extension(path: str, extensions: List[str]) -> bool: |
|
|
def contains_extension(path: str, extensions: List[str]) -> bool: |
|
|
|
|
|
logging.debug(f"searching for extensions {extensions} in {path}") |
|
|
for root, dirs, files in os.walk(path): |
|
|
for root, dirs, files in os.walk(path): |
|
|
for file in files: |
|
|
for file in files: |
|
|
for ext in extensions: |
|
|
for ext in extensions: |
|
|
if file.endswith(ext): |
|
|
if file.endswith(ext): |
|
|
|
|
|
logging.debug(f"file {file} matched extension '{ext}'") |
|
|
return True |
|
|
return True |
|
|
|
|
|
# needed to prevent recursive search |
|
|
|
|
|
break |
|
|
return False |
|
|
return False |
|
|
|
|
|
|
|
|
def get_files_with_ext(path: str, extensions: List[str]) -> List[str]: |
|
|
def get_files_with_ext(path: str, extensions: List[str]) -> List[str]: |
|
|
files = [] |
|
|
|
|
|
|
|
|
logging.debug(f"obtainint all files with extensions {extensions} from {path}") |
|
|
|
|
|
ret_files = [] |
|
|
for root, dirs, files in os.walk(path): |
|
|
for root, dirs, files in os.walk(path): |
|
|
for file in files: |
|
|
for file in files: |
|
|
for ext in extensions: |
|
|
for ext in extensions: |
|
|
if file.endswith(ext): |
|
|
if file.endswith(ext): |
|
|
files.append(os.path.join(root, file)) |
|
|
|
|
|
return files |
|
|
|
|
|
|
|
|
logging.debug(f"file {file} matched extension '{ext}'") |
|
|
|
|
|
ret_files.append(os.path.join(root, file)) |
|
|
|
|
|
# file is added now - no need to check the other extensions |
|
|
|
|
|
break |
|
|
|
|
|
# needed to prevent recursive search |
|
|
|
|
|
break |
|
|
|
|
|
return ret_files |
|
|
|
|
|
|
|
|
def prepare_destination(path: str) -> str: |
|
|
def prepare_destination(path: str) -> str: |
|
|
images = get_files_with_ext(path, folder_icon_extensions) |
|
|
|
|
|
|
|
|
images = get_files_with_ext(path, SRC_ICON_EXTS) |
|
|
|
|
|
|
|
|
sub_path = path.replace(source_folder, "") |
|
|
|
|
|
new_path = destination_folder + sub_path |
|
|
|
|
|
|
|
|
sub_path = path.replace(SRC_FOLDER, "") |
|
|
|
|
|
new_path = DST_FOLDER + sub_path |
|
|
mkdir(new_path) |
|
|
mkdir(new_path) |
|
|
|
|
|
|
|
|
for img in images: |
|
|
for img in images: |
|
@ -103,7 +169,7 @@ def prepare_destination(path: str) -> str: |
|
|
|
|
|
|
|
|
def get_filename(file: str, path: str) -> str: |
|
|
def get_filename(file: str, path: str) -> str: |
|
|
fname = file.replace(path, "") |
|
|
fname = file.replace(path, "") |
|
|
for ext in source_file_extensions: |
|
|
|
|
|
|
|
|
for ext in SRC_FILE_EXTS: |
|
|
fname = fname.replace("." + ext, "") |
|
|
fname = fname.replace("." + ext, "") |
|
|
return fname |
|
|
return fname |
|
|
|
|
|
|
|
@ -111,14 +177,23 @@ def scan_folder(path: str): |
|
|
for root, dirs, files in os.walk(path): |
|
|
for root, dirs, files in os.walk(path): |
|
|
for directory in dirs: |
|
|
for directory in dirs: |
|
|
process_folder(os.path.join(root, directory)) |
|
|
process_folder(os.path.join(root, directory)) |
|
|
|
|
|
# needed to prevent recursive search |
|
|
|
|
|
break |
|
|
|
|
|
|
|
|
def process_folder(path: str): |
|
|
def process_folder(path: str): |
|
|
|
|
|
logging.info(f"Visiting folder '{path}'") |
|
|
if contains_extension(path, [CUE_SHEET_EXTENSION]): |
|
|
if contains_extension(path, [CUE_SHEET_EXTENSION]): |
|
|
|
|
|
logging.info("found cuesheet") |
|
|
cue_sheet_processor(path) |
|
|
cue_sheet_processor(path) |
|
|
elif contains_extension(path, source_file_extensions): |
|
|
|
|
|
|
|
|
elif contains_extension(path, SRC_FILE_EXTS): |
|
|
|
|
|
logging.info("found audio files") |
|
|
file_processor(path) |
|
|
file_processor(path) |
|
|
else: |
|
|
else: |
|
|
|
|
|
logging.info("scanning subfolders") |
|
|
scan_folder(path) |
|
|
scan_folder(path) |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
if __name__ == "__main__": |
|
|
process_folder(source_folder) |
|
|
|
|
|
|
|
|
logging.info(f"Using settings from {CONFIG_FILE}") |
|
|
|
|
|
if DRY_RUN: |
|
|
|
|
|
logging.info("Executing as DRY RUN") |
|
|
|
|
|
process_folder(SRC_FOLDER) |