Last active
April 6, 2026 03:11
-
-
Save yarnairb/d905903a550da571d9346aff3a6d3fdb to your computer and use it in GitHub Desktop.
Script to sync my MusicBee m3u playslists with Plex so I can use PlexAmp as my own Spotify
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 python3 | |
| import sys | |
| import signal | |
| import os | |
| import json | |
| import re | |
| import argparse | |
| import configparser | |
| import pathlib | |
| from plexapi.myplex import MyPlexAccount | |
| from plexapi.library import Library | |
| from pprint import pprint | |
| import hashlib | |
| BLOCK_SIZE = 65536 | |
| script_dir = pathlib.Path(__file__).resolve().parent | |
| def signal_handler(signal_caught, frame): | |
| print('\nOperations interupted. Exiting.') | |
| sys.exit(0) | |
| signal.signal(signal.SIGINT, signal_handler) | |
| def PreparePlexAccess(): | |
| global plex, plex_music_library, plex_playlists, plex_playlist_library | |
| print("Establishing a connection to the Plex Server") | |
| print("Please wait.....") | |
| config = configparser.ConfigParser() | |
| config.read('plex_account.ini') | |
| login = config['PLEX']['login'] | |
| password = config['PLEX']['password'] | |
| server = config['PLEX']['server'] | |
| account = MyPlexAccount(login, password) | |
| plex = account.resource(server).connect() # returns a PlexServer instance | |
| # for brute force searching | |
| print("Building a library of all tracks for brute force searching") | |
| print("Please wait.....") | |
| plex_music_library = plex.library.section('Music').all(libtype='track') | |
| plex_playlist_library = plex.playlists(playlistType='audio') | |
| # load all playlist names from Plex | |
| plex_playlists = [] | |
| for playlist in plex_playlist_library: | |
| plex_playlists.append(playlist.title) | |
| #pprint(vars(playlist)) | |
| #pprint(playlist.leafCount) | |
| def DeletePlaylist(playlist_to_delete): | |
| for playlist in plex_playlist_library: | |
| if playlist.title == playlist_to_delete: | |
| print(f"Deleting Plex playlist: {playlist.title}") | |
| playlist.delete() | |
| def get_fingerprint(filepath): | |
| hash_method = hashlib.md5() | |
| with open(filepath, 'rb') as input_file: | |
| buf = input_file.read(BLOCK_SIZE) | |
| while len(buf) > 0: | |
| hash_method.update(buf) | |
| buf = input_file.read(BLOCK_SIZE) | |
| return hash_method.hexdigest() | |
| def has_changed(filepath, key_path): | |
| update_json=True | |
| json_path = script_dir / "status_db" | |
| fingerprint = get_fingerprint(filepath) | |
| json_dict = None | |
| if not os.path.exists(json_path): | |
| if update_json: | |
| json_dict = {key_path: fingerprint} | |
| _update_json_file(json_dict=json_dict, json_path=json_path) | |
| return True | |
| else: | |
| with open(json_path, 'r') as json_file: | |
| json_dict = json.loads(json_file.read()) | |
| if key_path not in json_dict: | |
| if update_json: | |
| json_dict[key_path] = fingerprint | |
| _update_json_file(json_dict=json_dict, json_path=json_path) | |
| return True | |
| if not json_dict[key_path] == fingerprint: | |
| if update_json: | |
| json_dict[key_path] = fingerprint | |
| _update_json_file(json_dict=json_dict, json_path=json_path) | |
| return True | |
| return False | |
| def _update_json_file(json_dict, json_path): | |
| with open(json_path, 'w') as json_file: | |
| json_file.write(json.dumps(json_dict, sort_keys=True, indent=4)) | |
| def Process_m3u(infile): | |
| try: | |
| assert(type(infile) == '_io.TextIOWrapper') | |
| except AssertionError: | |
| m3ufile = open(infile,'r', errors='replace') | |
| lines = m3ufile.readlines() | |
| tracks_in_m3u = [] | |
| tracks_found = [] | |
| print(f"\nProcessing tracks in m3u playlist: {infile}") | |
| if CLI.verbose: print("# means brute force was required") | |
| for line_num, line in enumerate(lines): | |
| line = line.strip() | |
| if line.startswith('#EXTINF:'): | |
| # just in case it's a missing track from MusicBee | |
| artist = "missing" | |
| title = "missing" | |
| length, title = line.split('#EXTINF:')[1].split(',', 1) | |
| path = lines[line_num + 1].strip() | |
| tracks_in_m3u.append(path) | |
| try: | |
| print('.', end='', flush=True) | |
| artist, title = title.split(' - ', 1) | |
| track = PlexTitleSearch(title, artist, path) | |
| if track: | |
| tracks_found.append(track) | |
| except ValueError: | |
| print(f"\nSkipping track: '{artist} - {title}'") | |
| pass | |
| if CLI.verbose: | |
| print(f"\nMatched {len(tracks_found)} Plex tracks from {len(tracks_in_m3u)} tracks in {m3ufile.name}") | |
| return tracks_found | |
| def CreatePlaylist(playlist_name, tracks): | |
| music_lib_section = 'Music' | |
| # Just in case we have an empty playlist | |
| if len(tracks) > 0: | |
| plex.createPlaylist(title=playlist_name, section=music_lib_section, items=tracks) | |
| print(f"{playlist_name} created in Plex") | |
| else: | |
| print(f"{playlist_name} is empty and not created") | |
| def PlexTitleSearch(m3uTitle, m3uArtist, m3uPath): | |
| # don't worry about ~@~ chars below. View in VSCode or Notepad | |
| # to see that they are curly quotes. Vim does not like them ;^) | |
| normalized_paths = [ | |
| m3uPath.lower(), | |
| m3uPath.lower().replace("ΓÇÖ", "'"), | |
| m3uPath.lower().replace("'", "ΓÇÖ"), | |
| m3uPath.lower().replace("ΓÇ£", '"'), | |
| m3uPath.lower().replace('"', "ΓÇ£"), | |
| m3uPath.lower().replace("&", "and"), | |
| m3uPath.lower().replace("and", "&") | |
| ] | |
| # for brute force comparison if needed | |
| m3uStripped = re.sub(r'\W+', '', m3uPath.lower()), | |
| # this is a "contains" string search of the track title | |
| # So we are getting a list of all tracks that match | |
| tracks = plex.library.search(title=m3uTitle, libtype="track") | |
| for track in tracks: | |
| # extract the path to the file on the system that Plex has stored | |
| PlexPath = track.media[0].parts[0].file | |
| # If the path in Plex matches the path from the m3u file we are good | |
| if PlexPath == m3uPath: | |
| return track | |
| # Now try some brute force matching | |
| if PlexPath.lower() in normalized_paths: | |
| return track | |
| PlexStripped = re.sub(r'\W+', '', PlexPath.lower()) | |
| if PlexStripped == m3uStripped: | |
| if CLI.verbose: print(m3uStripped," == ",PlexStripped) | |
| return track | |
| if track.title.lower() == m3uTitle.lower(): | |
| if track.originalTitle and (track.originalTitle.lower() == m3uArtist.lower()): | |
| return track | |
| elif track.grandparentTitle.lower() == m3uArtist.lower(): | |
| return track | |
| # Now for the really hard core | |
| print('#', end='', flush=True) | |
| track = BruteForceMatch(m3uPath) | |
| if track: | |
| return track | |
| else: | |
| print(f"No match for {m3uPath}") | |
| return None | |
| # Plex definitely has bugs because many of the missing can be found by artist search | |
| # but not by song title. Even via tha UI | |
| def BruteForceMatch(m3uPath): | |
| m3uStripped = re.sub(r'\W+', '', m3uPath.lower()) | |
| for track in plex_music_library: | |
| PlexPath = track.media[0].parts[0].file | |
| PlexStripped = re.sub(r'\W+', '', PlexPath.lower()) | |
| if m3uStripped == PlexStripped: | |
| if CLI.verbose: print(f"\nFound by brute force:\n\t{PlexPath}") | |
| return track | |
| return None | |
| def FindAllm3uFiles(path): | |
| FileList = [] | |
| for filename in os.scandir(path): | |
| if filename.is_file(): | |
| extension = pathlib.Path(filename).suffix | |
| if extension == '.m3u': | |
| if CLI.verbose: print(filename.path) | |
| FileList.append(filename.path) | |
| return FileList | |
| def cli_parser(): | |
| """Parse command line options""" | |
| global CLI | |
| parser = argparse.ArgumentParser(description='Convert m3u playlists to Plex playlists') | |
| # argparse automatically uses the --long option as the dest name. cool. | |
| # https://docs.python.org/3/library/argparse.html#dest | |
| parser.add_argument('-d', '--debug', action='store_true', help='Debugging output') | |
| parser.add_argument('-v', '--verbose', action='store_true', help='Let it spew!') | |
| parser.add_argument('-r', '--replace', action='store_true', help='If m3u is changed Plex playlist is deleted and recreated') | |
| parser.add_argument('-p', '--pretend', action='store_false', dest='create_playlists', default=True, help='Do not create playlists') | |
| parser.add_argument('path_to_m3u_files', default=None, type=pathlib.Path, | |
| metavar="/path/to/m3u/playlists[/playslist.m3u]", help="Single m3u file or directory containing m3u files") | |
| CLI = parser.parse_args() | |
| if CLI.debug: print("cli_arg contains:\n", CLI) | |
| return CLI | |
| def main(): | |
| cli_parser() | |
| # Handle the case where a single m3u file is specified | |
| if os.path.isfile(CLI.path_to_m3u_files): | |
| file_path = pathlib.Path(CLI.path_to_m3u_files) | |
| PlaylistName = file_path.stem | |
| PreparePlexAccess() | |
| if not has_changed(file_path, PlaylistName) and PlaylistName in plex_playlists: | |
| print(f"Skipping playlist: \"{PlaylistName}\" is unchanged") | |
| else: | |
| tracks_found = Process_m3u(file_path) | |
| if CLI.create_playlists: CreatePlaylist(PlaylistName, tracks_found) | |
| exit() | |
| if not os.path.isdir(CLI.path_to_m3u_files): | |
| print(f"{CLI.path_to_m3u_files} is not a directory or file") | |
| exit() | |
| m3uPlaylists = FindAllm3uFiles(CLI.path_to_m3u_files) | |
| if len(m3uPlaylists) > 0: | |
| PreparePlexAccess() | |
| else: | |
| print(f"Did not find any m3u files in {CLI.path_to_m3u_files}!") | |
| exit() | |
| for playlist_file in m3uPlaylists: | |
| file_path = pathlib.Path(playlist_file) | |
| PlaylistName = file_path.stem | |
| if not has_changed(playlist_file, PlaylistName) and PlaylistName in plex_playlists: | |
| print(f"Skipping playlist: \"{PlaylistName}\" is unchanged") | |
| continue | |
| if PlaylistName in plex_playlists: | |
| if CLI.replace: | |
| DeletePlaylist(PlaylistName) | |
| else: | |
| print(f"Skipping playlist: \"{PlaylistName}\" already in Plex") | |
| continue | |
| tracks_found = Process_m3u(playlist_file) | |
| if CLI.create_playlists: CreatePlaylist(PlaylistName, tracks_found) | |
| if __name__ == '__main__': | |
| main() |
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
| [PLEX] | |
| # You have to have a plexpass to use plexamp so that's the kind of login I use | |
| login = <your login> | |
| password = <your password> | |
| server = <your server> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hey, I came across this while trying to achieve the same goal. But it didn't work for me; there were a couple of errors when I ran it. So, I used Claude to do a revision:
Bare m3u support — The original script only handled extended m3u format (files with
#EXTINF:header lines). It silently processed zero tracks when given a bare m3u (plain file paths only, no headers), which is one of the export formats MusicBee produces. Added a fallback branch that detects bare m3u files and extracts artist and title from the filename using the ## - Artist - Title naming convention.Mojibake fix for Unicode filenames — Artists with non-ASCII characters in their names (e.g. µ-Ziq, Björk) failed to match because the m3u file was being opened with errors='replace', silently corrupting multi-byte UTF-8 characters. Fixed by opening the file with
encoding='latin-1'and then round-tripping the path strings viapath.encode('latin-1').decode('utf-8')to correctly reconstruct the original UTF-8 characters before comparison.Trailing comma bug in
PlexTitleSearch()— The line m3uStripped =re.sub(r'\W+', '', m3uPath.lower()), had a trailing comma which silently made m3uStripped a tuple instead of a string. This meant the stripped path comparison ifPlexStripped == m3uStrippedcould never match. Removed the trailing comma.Unicode normalization in path comparison — Added
unicodedata.normalize('NFC', ...)to the primary path comparison to handle cases where the same character is encoded differently (e.g. NFC vs NFD) between the m3u file and Plex's stored path.Reverse export script — Wrote a companion script that exports Plex audio playlists back to bare m3u files, using the same plex_account.ini credentials. Supports exporting a single named playlist or all playlists at once, with an -l flag to list available playlists and -o to specify output directory.
I'd like to do a fork, just reaching out to see if you're receptive. Let me know.