Last active
December 9, 2020 22:09
-
-
Save cpeel/e18d3e1d6f9881039b6a2e024fb5dce1 to your computer and use it in GitHub Desktop.
Reconstruct your playlists from a Google Play Music Takeout export
This file contains 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 | |
r""" | |
playlist-reconstructor.py will rebuild your Google Play Music (GPM) playlists | |
into a JSON file from a Google Takeout export. | |
To use this script: | |
1. Export your Google Play Music from https://takeout.google.com/settings/takeout | |
Yes, you have to download the whole thing. Google puts each playlist track into | |
its own individual .csv file and these are spread through the whole download | |
so you can't even shortcut and just download the first or last one. | |
2. Extract each zip file from your Takeout using `unzip`. | |
This will extract the ZIP file contents into a single Takeouts directory. | |
3. Run this script and point it to the /Takeout/Google Play Music/Playlists directory: | |
python3 playlist-reconstructor.py \ | |
--base-dir ~/Downloads/Takeout/Google\ Play\ Music/Playlists \ | |
--output-file ~/Downloads/playlists.json | |
The output format will look like: | |
[ | |
{ | |
"Deleted": "", | |
"Description": "The absolute best decade for music.", | |
"Owner": "Casey Peel", | |
"Shared": "", | |
"Title": "'80s Music", | |
"Tracks": [ | |
{ | |
"Album": "Tiffany", | |
"Artist": "Tiffany", | |
"Duration (ms)": "228856", | |
"Play Count": "44", | |
"Playlist Index": "0", | |
"Rating": "0", | |
"Removed": "", | |
"Title": "I Think We're Alone Now" | |
}, | |
... | |
}, | |
... | |
] | |
""" | |
import argparse | |
import csv | |
import html | |
import json | |
import logging | |
import os | |
import sys | |
class PlaylistParseException(Exception): | |
"""Exception raised upon playlist parse error""" | |
def reconstruct_playlist(playlist_dir): | |
""" | |
Given a playlist directory, reconstruct the playlist and return a dict. | |
""" | |
# Attempt to load the playlist metadata if available | |
metadata_path = os.path.join(playlist_dir, "Metadata.csv") | |
if os.path.isfile(metadata_path): | |
with open(metadata_path) as csvfile: | |
reader = csv.DictReader(csvfile) | |
for row in reader: | |
playlist = dict(row) | |
else: | |
playlist = {"Title": os.path.basename(playlist_dir)} | |
# Unescape HTML values | |
playlist = unescape_values(playlist) | |
# The "Thumbs Up" playlist doesn't have a Tracks subdirectory | |
if playlist["Title"] == "Thumbs Up": | |
tracks_dir = playlist_dir | |
else: | |
tracks_dir = os.path.join(playlist_dir, "Tracks") | |
if not os.path.isdir(tracks_dir): | |
raise PlaylistParseException( | |
f"Unable to find tracks for {playlist_dir}" | |
) | |
tracks = [] | |
for filename in os.listdir(tracks_dir): | |
if not filename.endswith(".csv"): | |
continue | |
with open(os.path.join(tracks_dir, filename)) as csvfile: | |
reader = csv.DictReader(csvfile) | |
for track in reader: | |
tracks.append(unescape_values(track)) | |
# sort the playlist by the track number | |
tracks.sort(key=lambda info: int(info["Playlist Index"])) | |
playlist["Tracks"] = tracks | |
return playlist | |
def unescape_values(dictionary): | |
""" | |
Unescape each value of the dictionary. | |
""" | |
return_dict = {} | |
for key, value in dictionary.items(): | |
return_dict[key] = html.unescape(value) | |
return return_dict | |
def main(): | |
parser = argparse.ArgumentParser() | |
parser.add_argument( | |
"--base-dir", | |
type=str, | |
help="Directory containing multiple playlist directories", | |
) | |
parser.add_argument( | |
"--playlist-dir", | |
nargs="?", | |
type=str, | |
help="Individual playlist directory", | |
) | |
parser.add_argument( | |
"--output-file", | |
type=str, | |
required=True, | |
help="Filename of the output JSON file", | |
) | |
args = parser.parse_args() | |
if not args.base_dir and not args.playlist_dir: | |
logging.error("One of --base-dir or --playlist-dir required") | |
sys.exit(1) | |
# populate the list of playlist directories | |
playlist_dirs = [] | |
if args.playlist_dir: | |
playlist_dirs.append(args.playlist_dir) | |
if args.base_dir: | |
for entry in os.listdir(args.base_dir): | |
path = os.path.join(args.base_dir, entry) | |
if os.path.isdir(path): | |
playlist_dirs.append(path) | |
playlists = [] | |
for playlist_dir in playlist_dirs: | |
try: | |
playlist = reconstruct_playlist(playlist_dir) | |
except PlaylistParseException as exception: | |
logging.error(exception) | |
playlists.append(playlist) | |
# sort playlists by name | |
playlists.sort(key=lambda playlist: playlist["Title"]) | |
with open(args.output_file, "w") as outfile: | |
json.dump(playlists, outfile, sort_keys=True, indent=4) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment