Last active
January 8, 2026 13:21
-
-
Save JoeBlakeB/bd6910aa53bfb8180b36a731a19c7ef2 to your computer and use it in GitHub Desktop.
Manage playlists for a music library with a CSV file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python | |
| """ | |
| musicPlaylistGenerator.py | |
| Copyright (C) 2025 Joe Baker (JoeBlakeB) | |
| This program is free software under the GPLv3 license. | |
| Generate and Update a CSV file based on the contents of a folder containing | |
| music files, then generate M3U playlist files containing those songs. | |
| Example Usage, to generate the CSV file: | |
| $ musicPlaylistGenerator.py | |
| Will generate the file: music.csv | |
| Artist, Album, Title, Playlist 1, Playlist 2 | |
| Sia This is Acting Cheap Thrills x x | |
| Clean Bandit New Eyes Rather Be x | |
| Chappel Roan The Subway The Subway x x | |
| Years & Years Communion Shine x | |
| Then generate M3U files using: | |
| $ musicPlaylistGenerator.py | |
| Which will output two files: | |
| Playlist 1.m3u: | |
| Sia - Cheap Thrills.mp3 | |
| Clean Bandit - Rather Be.mp3 | |
| Chappel Roan - The Subway.mp3 | |
| Playlist 2.m3u: | |
| Sia - Cheap Thrills.mp3 | |
| Chappel Roan - The Subway.mp3 | |
| Years & Years - Shine.mp3 | |
| Notes: | |
| - The file structure and format can be in any format, but the music files | |
| must contain embedded metadata for each file to be distinct. | |
| - File paths and their metadata can both be changed, meaning you can restructure | |
| your directory however you want, and change metadata, but only one of those two | |
| at the same time. If you change the filename and metadata at the same time, | |
| the track will get lost and its playlists cleared. Output playlists will contain | |
| the full path of the file, relative to the specified directory. | |
| - The CSV file is updated if it already exists, removing songs that have | |
| been deleted, and adding new ones that were added. | |
| - Running the script updates the CSV, then if the CSV existed before, | |
| generates new playlist files, overwriting the old ones. | |
| - The intended workflow is: | |
| - Mange music files in your music directory | |
| - Run this script to generate the CSV | |
| - Edit the CSV to manage your playlists | |
| - Run this script again to generate the M3Us | |
| - The status column has two values other than blank: | |
| - New: the file has been added to the CSV in the previous run | |
| - Deleted: the file has been lose in the previous run, and will be deleted from the CSV in the next run | |
| Command line args (this section might be moved to only show up when doing --help but not be in this block) | |
| --musicDirectory -d (or the -1 argument?) | |
| --csvPath -c (including filename, same as folder name in the folder if not specified) | |
| --playlistOutputDirectory -p | |
| --skipCsvUpdate | |
| --skipPlaylistCreation | |
| $ musicPlaylistGenerator.py | |
| musicDirectory: . | |
| csvPath: music.csv | |
| playlists: playlist.m3u | |
| $ musicPlaylistGenerator.py Music | |
| musicDirectory: Music | |
| csvPath: Music/music.csv | |
| playlists: Music/playlist.m3u | |
| $ musicPlaylistGenerator.py MinecraftOST | |
| musicDirectory: MinecraftOST | |
| csvPath: MinecraftOST/MinecraftOST.csv | |
| playlists: MinecraftOST/playlist.m3u | |
| """ | |
| import csv | |
| import argparse | |
| import os | |
| from pathlib import Path | |
| from dataclasses import dataclass | |
| from mutagen import File as MutagenFile | |
| MUSIC_FILETYPES = ["mp3", "flac", "m4a", "aac", "ogg", "opus", "wma"] | |
| def parseArgs(argv: list[str] | None = None) -> argparse.Namespace: | |
| """Parse and normalize command line arguments.""" | |
| parser = argparse.ArgumentParser( | |
| description="Generate or update playlist CSVs and M3U files.", | |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter, | |
| ) | |
| parser.add_argument( | |
| "musicDirectory", | |
| nargs="?", | |
| default=Path("."), | |
| type=Path, | |
| help="Root directory containing music files", | |
| ) | |
| parser.add_argument( | |
| "-c", | |
| "--csvPath", | |
| type=Path, | |
| default=None, | |
| help="CSV path (defaults to <musicDirectory>/music.csv)", | |
| ) | |
| parser.add_argument( | |
| "-p", | |
| "--playlistOutputDirectory", | |
| type=Path, | |
| default=None, | |
| help="Directory to write playlist files (defaults to musicDirectory)", | |
| ) | |
| parser.add_argument( | |
| "--skipCsvUpdate", | |
| action="store_true", | |
| help="Skip scanning music files and updating the CSV", | |
| ) | |
| parser.add_argument( | |
| "--skipPlaylistCreation", | |
| action="store_true", | |
| help="Skip generating M3U playlist files", | |
| ) | |
| parser.add_argument( | |
| "--debug", | |
| action="store_true", | |
| help="Enable debug output", | |
| ) | |
| parser.add_argument( | |
| "--justifyCSV", | |
| action="store_true", | |
| default=True, | |
| help="Align CSV columns for readability (default: True)", | |
| ) | |
| parser.add_argument( | |
| "--no-justifyCSV", | |
| dest="justifyCSV", | |
| action="store_false", | |
| help="Disable CSV column alignment", | |
| ) | |
| parser.add_argument( | |
| "--changesAtTop", | |
| action="store_true", | |
| help="Place new tracks at the top of the CSV", | |
| ) | |
| args = parser.parse_args(argv) | |
| music_dir = args.musicDirectory | |
| default_csv_name = "music.csv" if music_dir.name in {"", "."} else f"{music_dir.name}.csv" | |
| args.csvPath = args.csvPath or music_dir / default_csv_name | |
| args.playlistOutputDirectory = ( | |
| args.playlistOutputDirectory or args.musicDirectory | |
| ) | |
| return args | |
| @dataclass | |
| class Track: | |
| """Represents a music track with metadata.""" | |
| path: Path | |
| title: str | |
| artist: str | |
| album: str | None | |
| playlists: dict[str, str] = None | |
| new: bool = True | |
| deleted: bool = False | |
| @dataclass | |
| class Playlist: | |
| """Represents a playlist containing multiple tracks.""" | |
| name: str | |
| tracks: list[Track] | |
| def normalize(s: str) -> str: | |
| return ''.join(c.lower() for c in s if c.isalnum()) | |
| def equalsIgnoreSpecials(str1: str, str2: str) -> bool: | |
| """Compare two strings ignoring case and special characters.""" | |
| return normalize(str1) == normalize(str2) | |
| def removefiletype(filename: str) -> str: | |
| """Remove the file extension from a filename.""" | |
| return os.path.splitext(filename)[0] | |
| def extractMetadata(filepath: Path) -> Track: | |
| """Extract title, artist, and album metadata from a music file to create a Track object""" | |
| try: | |
| audio = MutagenFile(filepath, easy=True) | |
| if audio is not None: | |
| title = None | |
| artist = None | |
| album = None | |
| if "title" in audio and audio["title"]: | |
| title = audio["title"][0] | |
| if "artist" in audio and audio["artist"]: | |
| artist = audio["artist"][0] | |
| elif "albumartist" in audio and audio["albumartist"]: | |
| artist = audio["albumartist"][0] | |
| if "album" in audio and audio["album"]: | |
| album = audio["album"][0] | |
| if title and artist: | |
| return Track(path=filepath, title=title, artist=artist, album=album) | |
| except Exception as e: | |
| print(f"Error: Could not read metadata from {filepath}: {e}") | |
| print(f"Warning: Could not read metadata from {filepath}") | |
| if " - " in filepath.stem: | |
| parts = filepath.stem.split(" - ", 1) | |
| title, artist = parts[1], parts[0] | |
| else: | |
| title, artist = filepath.stem, "Unknown Artist" | |
| return Track(path=filepath, title=title, artist=artist, album=None) | |
| def getAllTracks(musicDirectory: Path) -> list[Track]: | |
| """Recursively get all music files in the specified directory.""" | |
| allTracks = [] | |
| for filetype in MUSIC_FILETYPES: | |
| for filepath in musicDirectory.rglob(f"*.{filetype}"): | |
| allTracks.append(extractMetadata(filepath)) | |
| return allTracks | |
| def getPlaylistsFromOldCSV(csvPath: Path, allTracks: list[Track]) -> list[Playlist]: | |
| """Read existing CSV file and extract playlists.""" | |
| playlists = [] | |
| with csvPath.open("r", encoding="utf-8") as csvFile: | |
| header = csvFile.readline().strip().split(",") | |
| filenameColumn = header[0].strip() == "FileID" | |
| statusColumn = header[3+filenameColumn].strip() == "Status" | |
| playlistsStartAt = 3 + filenameColumn + statusColumn | |
| playlistNames = header[playlistsStartAt:] | |
| for name in playlistNames: | |
| playlists.append(Playlist(name=name.strip(), tracks=[])) | |
| for line in csvFile: | |
| columns = line.strip().split(",") | |
| filename = columns[0].strip() if filenameColumn else "" | |
| artist = columns[0+filenameColumn].strip() | |
| album = columns[1+filenameColumn].strip() | |
| title = columns[2+filenameColumn].strip() | |
| wasDeletedLastTime = statusColumn and columns[3+filenameColumn].strip().lower() == "deleted" | |
| trackPlaylists = columns[playlistsStartAt:] | |
| matchingTracks = [ | |
| track for track in allTracks | |
| if ((equalsIgnoreSpecials(track.title, title) and | |
| equalsIgnoreSpecials(track.artist, artist) and | |
| (equalsIgnoreSpecials(track.album, album) or album == "")) | |
| or (equalsIgnoreSpecials(filename, removefiletype(track.path)) if filenameColumn else False)) | |
| ] | |
| if not wasDeletedLastTime: | |
| for track in matchingTracks: | |
| track.new = False | |
| if not matchingTracks: | |
| if wasDeletedLastTime: | |
| continue | |
| deletedTrack = Track(path=Path(""), title=title, artist=artist, album=album, new=False, deleted=True) | |
| allTracks.append(deletedTrack) | |
| matchingTracks.append(deletedTrack) | |
| for i, inPlaylist in enumerate(trackPlaylists): | |
| if inPlaylist.strip(): | |
| if matchingTracks: | |
| playlists[i].tracks.append(matchingTracks[0]) | |
| for track in matchingTracks: | |
| if track.playlists is None: | |
| track.playlists = {} | |
| track.playlists[playlists[i].name] = inPlaylist.strip() | |
| return playlists | |
| def createNewCSV(csvPath: Path, playlists: list[Playlist], allTracks: list[Track], justify: bool = True) -> None: | |
| """Create or update the CSV file with the current tracks.""" | |
| playlistNames = [pl.name for pl in playlists] | |
| rows = [] | |
| header = ["FileID", "Artist", "Album", "Title", "Status"] + playlistNames | |
| rows.append(header) | |
| for track in allTracks: | |
| row = [ | |
| normalize(removefiletype(removefiletype(track.path))), | |
| track.artist.replace(",", " "), | |
| (track.album or "").replace(",", " "), | |
| track.title.replace(",", " "), | |
| "New" if track.new else "Deleted" if track.deleted else "" | |
| ] | |
| for plName in playlistNames: | |
| row.append(track.playlists.get(plName, "") if track.playlists else "") | |
| rows.append(row) | |
| if justify: | |
| colWidths = [max(len(row[i]) if i < len(row) else 0 for row in rows) | |
| for i in range(len(header))] | |
| colWidths[-1] = 0 | |
| with csvPath.open("w", encoding="utf-8") as csvFile: | |
| for row in rows: | |
| if justify: | |
| padded = [cell.ljust(colWidths[i]) for i, cell in enumerate(row)] | |
| csvFile.write(",".join(padded) + "\n") | |
| else: | |
| csvFile.write(",".join(row) + "\n") | |
| def createPlaylists(outputDirectory: Path, playlists: list[Playlist]) -> None: | |
| """Create M3U playlist files from the playlists.""" | |
| outputDirectory.mkdir(parents=True, exist_ok=True) | |
| for playlist in playlists: | |
| playlistPath = outputDirectory / f"{playlist.name}.m3u" | |
| with playlistPath.open("w", encoding="utf-8") as m3uFile: | |
| for track in playlist.tracks: | |
| relativePath = track.path.relative_to(args.musicDirectory) | |
| m3uFile.write(f"{relativePath.as_posix()}\n") | |
| if __name__ == "__main__": | |
| args = parseArgs() | |
| debug = print if args.debug else (lambda *a, **k: None) | |
| debug(f"musicDirectory: {args.musicDirectory}") | |
| debug(f"csvPath: {args.csvPath}") | |
| debug(f"playlistOutputDirectory: {args.playlistOutputDirectory}") | |
| debug(f"skipCsvUpdate: {args.skipCsvUpdate}") | |
| debug(f"skipPlaylistCreation: {args.skipPlaylistCreation}") | |
| allTracks = getAllTracks(args.musicDirectory) | |
| print(f"Found {len(allTracks)} tracks.") | |
| commonPath = os.path.commonpath([track.path.parent for track in allTracks]) if allTracks else None | |
| if commonPath and Path(commonPath).resolve() != args.musicDirectory.resolve(): | |
| print(f"Warning: All tracks are located under '{commonPath}'. Did you forget to specify the music directory?") | |
| playlists = getPlaylistsFromOldCSV(args.csvPath, allTracks) if args.csvPath.exists() else [] | |
| print(f"New Tracks: {sum(1 for track in allTracks if track.new)}") | |
| print(f"Deleted Tracks: {sum(1 for track in allTracks if track.deleted)}") | |
| if args.changesAtTop: | |
| allTracks.sort(key=lambda t: (not t.deleted, not t.new, t.artist.lower(), t.album.lower() if t.album else "", t.title.lower())) | |
| else: | |
| allTracks.sort(key=lambda t: (t.artist.lower(), t.album.lower() if t.album else "", t.title.lower())) | |
| if not args.skipCsvUpdate: | |
| createNewCSV(args.csvPath, playlists, allTracks, justify=args.justifyCSV) | |
| if not args.skipPlaylistCreation: | |
| createPlaylists(args.playlistOutputDirectory, playlists) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment