from argparse import ArgumentParser from itertools import filterfalse from pathlib import Path from logging import basicConfig, warning, WARNING from shutil import copyfile def main(music_folder: Path): # get the absolute path of the music folder from the argument music_folder = music_folder.resolve().absolute() # recursively traverse music_folder looking for .m3u files and, for each, ... for m3u in music_folder.rglob("*.m3u"): # create a copy in the same directory as a backup copyfile(m3u, m3u.with_suffix(".m3u.bak")) # read the contents of the file with m3u.open("r") as contents: lines = contents.readlines() # check that all non-comment lines refer to files in the music directory if not all( music_folder in Path(line).parents if Path(line).is_absolute() # join the parent path to the relative path in line, then resolve out the relative parts else music_folder in m3u.parent.joinpath(Path(line)).resolve().parents # ignore lines which start with # because they are comments, not paths to tracks for line in filterfalse(lambda l: l.strip().startswith("#"), lines) ): # if the file contains references to files outside of the music folder, skip the m3u file and continue to the next one warning(f"{m3u} contains references to tracks that are not in {music_folder} and will be skipped") continue with m3u.open("w") as updated_contents: for line in lines: # comment line. just write it. if line.strip().startswith("#"): updated_contents.write(line) # line contains an absolute path elif Path(line).is_absolute(): # get the path of the track file relative to the playlist file and write it in posix form (/ is path delimiter) updated_contents.write(Path(line).relative_to(m3u, walk_up=True).as_posix()) # line already contains a relative path else: # normalize as a posix path updated_contents.write(Path(line).as_posix()) if __name__ == "__main__": # setup logging to terminal basicConfig( level=WARNING, ) # setup a parser for the CLI parser = ArgumentParser( description="Recursively traverse a music folder containing tracks and playlist files that reference them, replacing absolute paths in the playlists with equivalent relative paths." ) parser.add_argument( "music_folder", required=True, type=Path ) # parse the command line for the one and only argument args = parser.parse_args() main(args.music_folder)