├── requirements.txt ├── folder_to_cover.py ├── no_cover.py ├── hifi_to_cdq.py ├── cleanup_orphan_dirs.py ├── non_log_flacs.py ├── flac_to_lame.py ├── plexamp_release_type.py ├── recursive_transcode.py ├── cleanup_orphan_dirs_v2.py ├── cleanup_orphan_dirs_v3.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | ffmpeg-python 2 | filetype -------------------------------------------------------------------------------- /folder_to_cover.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import filetype 3 | import os 4 | import shutil 5 | 6 | 7 | def main(): 8 | args = term_args() 9 | 10 | if args.path is None: 11 | print("Please provide directory!") 12 | exit(1) 13 | 14 | directory = args.path 15 | paths = [] 16 | 17 | # Only looking leaf directorys containing one image file 18 | for root, dirs, files in os.walk(directory): 19 | for file in files: 20 | if file.lower() == "folder.jpg": 21 | os.rename((root + "/" + file), (root + "/" + "cover.jpg")) 22 | else: 23 | print("No directories found!") 24 | 25 | 26 | def term_args(): 27 | parser = argparse.ArgumentParser() 28 | parser.add_argument("-p", "--path", type=str, help="Path to be searched.") 29 | return parser.parse_args() 30 | 31 | 32 | if __name__ == '__main__': 33 | main() 34 | -------------------------------------------------------------------------------- /no_cover.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import filetype 3 | import os 4 | import shutil 5 | 6 | 7 | def main(): 8 | args = term_args() 9 | 10 | if args.path is None: 11 | print("Please provide directory!") 12 | exit(1) 13 | 14 | directory = args.path 15 | paths = [] 16 | 17 | # Only looking leaf directorys containing one image file 18 | for root, dirs, files in os.walk(directory): 19 | cover_art_present = False 20 | for file in files: 21 | if file == "cover.jpg": 22 | cover_art_present = True 23 | break 24 | if not cover_art_present and "[" in root: 25 | print(root) 26 | else: 27 | print("No directories found!") 28 | 29 | 30 | def term_args(): 31 | parser = argparse.ArgumentParser() 32 | parser.add_argument("-p", "--path", type=str, help="Path to be searched.") 33 | return parser.parse_args() 34 | 35 | 36 | if __name__ == '__main__': 37 | main() 38 | -------------------------------------------------------------------------------- /hifi_to_cdq.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import argparse 3 | import ffmpeg 4 | import os 5 | 6 | 7 | def main(): 8 | lossless_extensions = (".mka", ".flac", ".wav") 9 | args = term_args() 10 | 11 | if args.path is None: 12 | print("Please provide directory!") 13 | exit(1) 14 | 15 | directory = args.path 16 | output_dir = directory + "/transcode" 17 | Path(output_dir).mkdir(exist_ok=True) 18 | 19 | for file in os.listdir(directory): 20 | if file.endswith(lossless_extensions): 21 | stream = ffmpeg.input(directory + "/" + file) 22 | file = file.split(".")[0] + ".flac" 23 | stream = ffmpeg.output(stream, (output_dir + "/" + file), **{'sample_fmt':'s16'}, **{'ar':44100}) 24 | ffmpeg.run(stream) 25 | 26 | 27 | def term_args(): 28 | parser = argparse.ArgumentParser() 29 | parser.add_argument("-p", "--path", type=str, help="Path to be transcoded.") 30 | return parser.parse_args() 31 | 32 | 33 | if __name__ == '__main__': 34 | main() 35 | -------------------------------------------------------------------------------- /cleanup_orphan_dirs.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import filetype 3 | import os 4 | import shutil 5 | 6 | 7 | def main(): 8 | args = term_args() 9 | 10 | if args.path is None: 11 | print("Please provide directory!") 12 | exit(1) 13 | 14 | directory = args.path 15 | paths = [] 16 | 17 | # Only looking leaf directorys containing one image file 18 | for root, dirs, files in os.walk(directory): 19 | if len(files) == 1 and filetype.is_image(os.path.join(root,files[0])) and not dirs: 20 | paths.append(root) 21 | print(root) 22 | 23 | if paths: 24 | print() 25 | print("Delete the following directories?") 26 | print("[Y]es, [N]o") 27 | choice = input("> ").lower().strip() 28 | 29 | if choice == "y": 30 | for path in paths: 31 | shutil.rmtree(path) 32 | else: 33 | print("No directories found!") 34 | 35 | 36 | def term_args(): 37 | parser = argparse.ArgumentParser() 38 | parser.add_argument("-p", "--path", type=str, help="Path to be searched.") 39 | return parser.parse_args() 40 | 41 | 42 | if __name__ == '__main__': 43 | main() 44 | -------------------------------------------------------------------------------- /non_log_flacs.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import filetype 3 | import os 4 | import shutil 5 | 6 | 7 | def main(): 8 | args = term_args() 9 | 10 | if args.path is None: 11 | print("Please provide directory!") 12 | exit(1) 13 | 14 | 15 | directory = args.path 16 | paths = [] 17 | lossless_extensions = ["flac", "alac", "wav"] 18 | 19 | for root, dirs, files in os.walk(directory, topdown=True): 20 | depth = root[len(directory) + len(os.path.sep):].count(os.path.sep) 21 | if depth == 1: 22 | log_file_present = False 23 | combined = '\t'.join(files) 24 | for ext in lossless_extensions: 25 | if ext in combined and '.log' in combined: 26 | log_file_present = True 27 | break 28 | 29 | if log_file_present == False: 30 | paths.append(root) 31 | 32 | paths = sorted(paths) 33 | for path in paths: 34 | print(path) 35 | 36 | 37 | def term_args(): 38 | parser = argparse.ArgumentParser() 39 | parser.add_argument("-p", "--path", type=str, help="Path to be searched.") 40 | return parser.parse_args() 41 | 42 | 43 | if __name__ == '__main__': 44 | main() 45 | -------------------------------------------------------------------------------- /flac_to_lame.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import argparse 3 | import os 4 | 5 | 6 | def main(): 7 | args = term_args() 8 | 9 | if args.path is None: 10 | print("Please provide directory!") 11 | exit(1) 12 | 13 | directory = args.path 14 | for option in args.transcodes: 15 | Path(directory + "/mp3_" + option).mkdir(exist_ok=True) 16 | 17 | for file in os.listdir(directory): 18 | if file.endswith(".flac"): 19 | for option in args.transcodes: 20 | input = directory + "/" + file 21 | output = directory+ "/mp3_" + option + "/" + file.split(".flac")[0] + ".mp3" 22 | transcode(input, output, option) 23 | 24 | 25 | def transcode(input, output, option): 26 | opt = { 27 | "320": "-b:a 320k", 28 | "v0": "-q:a 0" 29 | } 30 | 31 | cmd = "ffmpeg -i \"{}\" -codec:a libmp3lame {} \"{}\"".format(input, opt[option], output) 32 | os.system(cmd) 33 | 34 | 35 | def term_args(): 36 | mp3_transcodes = ["320", "v0"] 37 | parser = argparse.ArgumentParser() 38 | parser.add_argument("-p", "--path", type=str, help="Path to be transcoded.") 39 | parser.add_argument("-t", "--transcodes", type=str, nargs='+', help="Choices for transcodes", choices=mp3_transcodes) 40 | return parser.parse_args() 41 | 42 | 43 | if __name__ == '__main__': 44 | main() 45 | -------------------------------------------------------------------------------- /plexamp_release_type.py: -------------------------------------------------------------------------------- 1 | from mutagen.flac import FLAC 2 | import argparse 3 | import os 4 | 5 | plex_release_types = { 6 | "live": "album;live", 7 | "compilation": "album;compilation", 8 | "remix": "album;remix", 9 | "mixtape": "album;mixtape", 10 | "soundtrack": "album;soundtrack" 11 | } 12 | 13 | 14 | def main(): 15 | args = term_args() 16 | if args.path is None: 17 | print("Please provide directory!") 18 | exit(1) 19 | 20 | directory = args.path 21 | filetypes = (".flac") 22 | 23 | for root, dirs, files in os.walk(directory, topdown=True): 24 | depth = root[len(directory) + len(os.path.sep):].count(os.path.sep) 25 | if depth == 1: 26 | for file in files: 27 | if file.endswith(filetypes): 28 | if file.endswith(".flac"): 29 | try: 30 | audio = FLAC(root + "/" + file) 31 | rt = audio["releasetype"][0].lower().strip() 32 | p = root + "/" + file 33 | update_release_type(audio, rt, p) 34 | except: 35 | continue 36 | 37 | def update_release_type(track, rt, p): 38 | track["releasetype"] = plex_release_types[rt] 39 | print(p + " - UPDATED TO " + plex_release_types[rt]) 40 | track.save() 41 | 42 | 43 | def term_args(): 44 | parser = argparse.ArgumentParser() 45 | parser.add_argument("-p", "--path", type=str, help="Path to library.") 46 | return parser.parse_args() 47 | 48 | 49 | if __name__ == '__main__': 50 | main() -------------------------------------------------------------------------------- /recursive_transcode.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import argparse 4 | import ffmpeg 5 | 6 | lossless_extensions = (".flac", ".wav") 7 | destination_codec = 'alac' 8 | 9 | def main(): 10 | args = term_args() 11 | 12 | if args.path is None or args.output_path is None: 13 | print('input path or output path not specified') 14 | exit(1) 15 | 16 | base_dir = args.path 17 | output_dir = args.output_path 18 | print(f'Base Directory = { base_dir}') 19 | print(f'Output Directory = {output_dir}') 20 | 21 | print(f'Output Codec = {destination_codec}') 22 | 23 | for root, subs, files in os.walk(base_dir): 24 | for file in files: 25 | if file.endswith(lossless_extensions): 26 | convert(file, root, output_dir) 27 | 28 | def convert(file, path, output_dir): 29 | print(file) 30 | file_folder = os.path.basename(path) 31 | destination_folder = os.path.join(output_dir, file_folder) 32 | if (os.path.exists(destination_folder) is False): 33 | os.mkdir(destination_folder) 34 | 35 | for file_extension in lossless_extensions: 36 | if (file.endswith(file_extension)): 37 | out_file = file.split(file_extension)[0] 38 | cmd = f'ffmpeg -i "{os.path.join(path , file)}" -y -v 0 -vcodec copy -acodec {destination_codec} "{os.path.join(destination_folder,out_file)}.m4a"' 39 | 40 | os.system(cmd) 41 | 42 | def term_args(): 43 | parser = argparse.ArgumentParser() 44 | parser.add_argument("-p", "--path", type=str, help="Path to be transcoded.") 45 | parser.add_argument("-o", "--output_path", type=str, help="Output path") 46 | parser.add_argument("-c", "--output_codec", type=str, help="Output codec") 47 | return parser.parse_args() 48 | 49 | if __name__ == '__main__': 50 | main() 51 | -------------------------------------------------------------------------------- /cleanup_orphan_dirs_v2.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import filetype 3 | import os 4 | import shutil 5 | import glob,os.path 6 | 7 | 8 | def main(): 9 | args = term_args() 10 | audio_extensions = ['.mp3', '.aac', '.ogg', '.flac', '.wav', '.alac', 'aiff', '.dsd', '.pcm', '.opus', '.m4a'] 11 | 12 | if args.path is None: 13 | print("Please provide directory!") 14 | exit(1) 15 | 16 | path = os.path.normpath(args.path) 17 | paths = [] 18 | 19 | for root, dirs, files in os.walk(path, topdown=True): 20 | depth = root[len(path) + len(os.path.sep):].count(os.path.sep) 21 | if depth == 1: 22 | combined = '\t'.join(files) 23 | audio_dir = False 24 | for ext in audio_extensions: 25 | if ext in combined and '01 ' in combined: 26 | audio_dir = True 27 | break 28 | 29 | if audio_dir == False: 30 | paths.append(root) 31 | print(root) 32 | 33 | if paths: 34 | print() 35 | print("Delete the following directories?") 36 | print("[Y]es, [N]o") 37 | choice = input("> ").lower().strip() 38 | 39 | if choice == "y": 40 | for path in paths: 41 | try: 42 | shutil.rmtree(path) 43 | except Exception: 44 | print("Cannot delete: " + path) 45 | else: 46 | print("No directories found!") 47 | 48 | 49 | 50 | def term_args(): 51 | parser = argparse.ArgumentParser() 52 | parser.add_argument("-p", "--path", type=str, help="Path to be searched.") 53 | return parser.parse_args() 54 | 55 | 56 | if __name__ == '__main__': 57 | main() 58 | -------------------------------------------------------------------------------- /cleanup_orphan_dirs_v3.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import filetype 3 | import os 4 | import shutil 5 | 6 | 7 | def is_audio_file(file_path): 8 | """Check if a file is an audio file using filetype library.""" 9 | try: 10 | kind = filetype.guess(file_path) 11 | if kind is None: 12 | # Fallback to extension check if filetype can't determine 13 | audio_extensions = ['.mp3', '.aac', '.ogg', '.flac', '.wav', '.alac', 14 | '.aiff', '.dsd', '.pcm', '.opus', '.m4a', '.mp4', 15 | '.m4p', '.ape', '.wma', '.ac3', '.dts'] 16 | return any(file_path.lower().endswith(ext) for ext in audio_extensions) 17 | return kind.mime.startswith('audio/') 18 | except Exception: 19 | # Fallback to extension check on error 20 | audio_extensions = ['.mp3', '.aac', '.ogg', '.flac', '.wav', '.alac', 21 | '.aiff', '.dsd', '.pcm', '.opus', '.m4a', '.mp4', 22 | '.m4p', '.ape', '.wma', '.ac3', '.dts'] 23 | return any(file_path.lower().endswith(ext) for ext in audio_extensions) 24 | 25 | 26 | def has_music_files(directory): 27 | """Check if a directory contains any music files (recursively checks subdirectories).""" 28 | try: 29 | for root, dirs, files in os.walk(directory): 30 | for file in files: 31 | file_path = os.path.join(root, file) 32 | if is_audio_file(file_path): 33 | return True 34 | except Exception: 35 | pass 36 | return False 37 | 38 | 39 | def main(): 40 | args = term_args() 41 | 42 | if args.path is None: 43 | print("Please provide directory!") 44 | exit(1) 45 | 46 | directory = os.path.normpath(args.path) 47 | paths_to_delete = [] 48 | 49 | # Walk through the directory structure 50 | # We're looking for album directories (depth 1: library/artist/album) 51 | for root, dirs, files in os.walk(directory, topdown=True): 52 | # Calculate depth from the root directory 53 | depth = root[len(directory) + len(os.path.sep):].count(os.path.sep) 54 | 55 | # Check album directories (depth 1: library/artist/album) 56 | if depth == 1: 57 | if not has_music_files(root): 58 | paths_to_delete.append(root) 59 | 60 | if paths_to_delete: 61 | print(f"\nFound {len(paths_to_delete)} album directories without music files:\n") 62 | for path in sorted(paths_to_delete): 63 | print(path) 64 | 65 | print("\n" + "="*60) 66 | print(f"Total directories to delete: {len(paths_to_delete)}") 67 | print("="*60) 68 | print("\nDelete the following directories?") 69 | print("[Y]es, [N]o") 70 | choice = input("> ").lower().strip() 71 | 72 | if choice == "y": 73 | deleted_count = 0 74 | failed_count = 0 75 | for path in paths_to_delete: 76 | try: 77 | shutil.rmtree(path) 78 | deleted_count += 1 79 | print(f"Deleted: {path}") 80 | except Exception as e: 81 | failed_count += 1 82 | print(f"Cannot delete: {path} - {str(e)}") 83 | 84 | print(f"\nCompleted: {deleted_count} deleted, {failed_count} failed") 85 | else: 86 | print("Deletion cancelled.") 87 | else: 88 | print("No directories found without music files!") 89 | 90 | 91 | def term_args(): 92 | parser = argparse.ArgumentParser( 93 | description="Find and optionally delete album directories that don't contain music files." 94 | ) 95 | parser.add_argument("-p", "--path", type=str, help="Path to music library (library/artist/album structure).") 96 | return parser.parse_args() 97 | 98 | 99 | if __name__ == '__main__': 100 | main() 101 | 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JBOMLS 2 | 3 | **Just a Bunch Of Music Library Scripts** 4 | 5 | A collection of Python scripts for maintaining and organizing music libraries. These tools help with cleanup, transcoding, metadata management, and library maintenance tasks. 6 | 7 | ## Table of Contents 8 | 9 | - [Requirements](#requirements) 10 | - [Installation](#installation) 11 | - [Scripts](#scripts) 12 | - [Cleanup Scripts](#cleanup-scripts) 13 | - [Transcoding Scripts](#transcoding-scripts) 14 | - [Metadata Scripts](#metadata-scripts) 15 | - [Utility Scripts](#utility-scripts) 16 | 17 | ## Requirements 18 | 19 | - Python 3.6+ 20 | - ffmpeg (must be installed and available in PATH) 21 | - Dependencies listed in `requirements.txt`: 22 | - `ffmpeg-python` 23 | - `filetype` 24 | - `mutagen` (for FLAC metadata editing) 25 | 26 | ## Installation 27 | 28 | 1. Clone or download this repository 29 | 2. Install dependencies: 30 | ```bash 31 | pip install -r requirements.txt 32 | ``` 33 | 3. Ensure `ffmpeg` is installed and accessible from your command line 34 | 35 | ## Scripts 36 | 37 | ### Cleanup Scripts 38 | 39 | #### cleanup_orphan_dirs.py 40 | 41 | Removes directories that contain only a single image file (typically orphaned cover art directories). 42 | 43 | **Usage:** 44 | ```bash 45 | python cleanup_orphan_dirs.py -p /path/to/music/library 46 | ``` 47 | 48 | **Example:** 49 | The following directory would be deleted: 50 | ``` 51 | /Music/Talk Talk/[1991] Laughing Stock/ 52 | └── cover.jpg 53 | ``` 54 | 55 | The following directory would **not** be deleted (contains audio files): 56 | ``` 57 | /Music/Talk Talk/[1982] The Party's Over/ 58 | ├── cover.jpg 59 | ├── 01 Talk Talk.flac 60 | ├── 02 It's So Serious.flac 61 | └── ... 62 | ``` 63 | 64 | #### cleanup_orphan_dirs_v2.py 65 | 66 | Finds album directories (at depth 1: `library/artist/album`) that don't contain audio files. Checks for audio files with a specific pattern (looking for '01 ' in filenames). If an album directory doesn't contain matching audio files, it will offer to delete the directory. 67 | 68 | **Usage:** 69 | ```bash 70 | python cleanup_orphan_dirs_v2.py -p /path/to/music/library 71 | ``` 72 | 73 | #### cleanup_orphan_dirs_v3.py 74 | 75 | An improved version of the orphan directory cleanup script. Uses the `filetype` library to properly detect audio files and recursively checks subdirectories for any music files. If an album directory (at depth 1: `library/artist/album`) has no music files anywhere within it, it will offer to delete the directory. More reliable than v2 as it uses proper audio file detection rather than filename pattern matching. 76 | 77 | **Usage:** 78 | ```bash 79 | python cleanup_orphan_dirs_v3.py -p /path/to/music/library 80 | ``` 81 | 82 | ### Transcoding Scripts 83 | 84 | #### flac_to_lame.py 85 | 86 | Transcodes FLAC files in a directory to MP3 format. Supports both 320kbps and V0 quality settings. 87 | 88 | **Usage:** 89 | ```bash 90 | python flac_to_lame.py -p /path/to/flac/directory -t 320 v0 91 | ``` 92 | 93 | **Options:** 94 | - `-p, --path`: Path to directory containing FLAC files 95 | - `-t, --transcodes`: Transcode options - choose `320` (320kbps), `v0` (V0 quality), or both 96 | 97 | **Note:** Creates subdirectories `mp3_320/` and/or `mp3_v0/` in the source directory. 98 | 99 | #### hifi_to_cdq.py 100 | 101 | Transcodes lossless audio files (FLAC, WAV, MKA) to CD quality FLAC (16-bit, 44.1kHz). 102 | 103 | **Usage:** 104 | ```bash 105 | python hifi_to_cdq.py -p /path/to/audio/directory 106 | ``` 107 | 108 | **Note:** Creates a `transcode/` subdirectory in the source directory with the converted files. 109 | 110 | #### recursive_transcode.py 111 | 112 | Recursively transcodes lossless audio files (FLAC, WAV) throughout a directory structure and rebuilds the original folder structure at the destination. Converts to ALAC format by default. 113 | 114 | **Usage:** 115 | ```bash 116 | python recursive_transcode.py -p /path/to/source -o /path/to/destination 117 | ``` 118 | 119 | **Options:** 120 | - `-p, --path`: Source directory to transcode 121 | - `-o, --output_path`: Destination directory for transcoded files 122 | 123 | ### Metadata Scripts 124 | 125 | #### plexamp_release_type.py 126 | 127 | Updates RELEASETYPE metadata tags in FLAC files to the format required by Plex/Plexamp. Plex/Plexamp requires specific formatting for release types to properly categorize albums. 128 | 129 | **Usage:** 130 | ```bash 131 | python plexamp_release_type.py -p /path/to/music/library 132 | ``` 133 | 134 | **Example:** 135 | A file with release type "Live" will be updated to "album;live" so it appears under Plex/Plexamp's Live Albums grouping. 136 | 137 | **Supported Release Types:** 138 | 139 | | Original Release Type | Updated Release Type | 140 | |----------------------|---------------------| 141 | | live | album;live | 142 | | compilation | album;compilation | 143 | | remix | album;remix | 144 | | mixtape | album;mixtape | 145 | | soundtrack | album;soundtrack | 146 | 147 | ### Utility Scripts 148 | 149 | #### folder_to_cover.py 150 | 151 | Renames `folder.jpg` files to `cover.jpg` throughout a directory structure. Useful for standardizing cover art filenames in your music library. 152 | 153 | **Usage:** 154 | ```bash 155 | python folder_to_cover.py -p /path/to/music/library 156 | ``` 157 | 158 | #### no_cover.py 159 | 160 | Reports album directories (those with "[" in the path, indicating album naming convention) that don't have a `cover.jpg` file. Prints out the paths of directories missing cover art. 161 | 162 | **Usage:** 163 | ```bash 164 | python no_cover.py -p /path/to/music/library 165 | ``` 166 | 167 | #### non_log_flacs.py 168 | 169 | Scans a music library and reports on lossless albums (at depth 1: `library/artist/album`) that do not have a log file in the album directory. Useful for identifying albums that may be missing EAC/ripping logs. 170 | 171 | **Usage:** 172 | ```bash 173 | python non_log_flacs.py -p /path/to/music/library 174 | ``` 175 | 176 | **Example:** 177 | The following directory would show up in the report (no log file): 178 | ``` 179 | /Music/Radiohead/[1997] Karma Police (Single)/ 180 | ├── cover.jpg 181 | └── 01 Karma Police.flac 182 | ``` 183 | 184 | The following directory would **not** show up (has log file): 185 | ``` 186 | /Music/Radiohead/[1997] Karma Police (Single)/ 187 | ├── cover.jpg 188 | ├── 01 Karma Police.flac 189 | └── Karma Police.log 190 | ``` 191 | 192 | ## Contributing 193 | 194 | Contributions are welcome! Feel free to submit issues, improvements, or new scripts. 195 | --------------------------------------------------------------------------------