Skip to content

Instantly share code, notes, and snippets.

@ddelange
Last active November 8, 2024 17:24
Show Gist options
  • Save ddelange/46d5a4c8c9897abb0d3d407938d3702a to your computer and use it in GitHub Desktop.
Save ddelange/46d5a4c8c9897abb0d3d407938d3702a to your computer and use it in GitHub Desktop.
Convert iTunes Music Library xml to m3u8 playlists
# pip install click
# python itunes_xml_to_m3u.py --help
import logging
import plistlib
import re
import typing as tp
from pathlib import Path
from urllib.parse import unquote
import click
logging.basicConfig(format="%(levelname)s: %(message)s")
logger = logging.getLogger()
RE_PLAYLIST_CHARS = re.compile("[/\\:]")
def get_paths(
*,
music_folder_base_path: str,
track_path_prefix: str,
playlist_items: list,
tracks: dict,
) -> tp.Iterator[str]:
for track_entry in playlist_items:
track_id = str(track_entry["Track ID"])
if not (track := tracks.get(track_id)):
logging.debug("Track entry not found: %s", track)
continue
if not (location := track.get("Location")):
logging.debug("Location missing: %s", track)
continue
if track_path_prefix is None:
# absolute to the current location of the media files
location = location.replace("file://", "", 1)
else:
# relative, or absolute to the new location of the media files
location = location.replace(music_folder_base_path, track_path_prefix, 1)
yield unquote(location)
@click.command(
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 120},
help="Extract m3u8 playlists from a Library.xml from iTunes/Music",
)
@click.option(
"--library-path",
default="./Library.xml",
show_default=True,
help="Path to the input Library.xml (File -> Library -> Export Library...).",
)
@click.option(
"--output-dir",
default=".",
show_default=True,
help="Directory to write the output playlists.",
)
@click.option(
"--output-extension",
default=".m3u8",
show_default=True,
help="File extension for the output playlists. For cross-platform compatibility, m3u8 uses utf-8 encoding (whereas m3u uses the creator system's default encoding).",
)
@click.option(
"--track-path-prefix",
default=None,
show_default=True,
help="(Optional) A relative or absolute path prefix to use in the output track file paths instead of the (absolute) music library path. When shipping the playlist files in the root of the folder containing the media files, set to '' or './' to create a portable playlist (containing relative paths).",
)
@click.option(
"-v",
"--verbose",
count=True,
help="Control verbosity: -v (WARNING), -vv (INFO), -vvv (DEBUG).",
)
def main(
library_path: str,
output_dir: str,
output_extension: str,
track_path_prefix: str | None,
verbose: int,
):
if verbose == 0:
logger.setLevel(logging.ERROR)
if verbose == 1:
logger.setLevel(logging.WARNING)
if verbose == 2:
logger.setLevel(logging.INFO)
if verbose >= 3:
logger.setLevel(logging.DEBUG)
logger.info("Loading %s", library_path)
library = plistlib.loads(Path(library_path).read_bytes())
music_folder_base_path = library["Music Folder"]
tracks = library["Tracks"]
encoding = "utf-8" if output_extension == ".m3u8" else None
for playlist in library["Playlists"]:
if not (playlist_name := playlist.get("Name")):
logger.debug("Missing name: %s", playlist)
continue
if not (playlist_items := playlist.get("Playlist Items")):
logger.debug("Empty playlist: %s", playlist_name)
continue
lines = get_paths(
music_folder_base_path=music_folder_base_path,
track_path_prefix=track_path_prefix,
playlist_items=playlist_items,
tracks=tracks,
)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
playlist_name = RE_PLAYLIST_CHARS.sub("_", playlist_name)
out_path = output_dir / f"{playlist_name}{output_extension}"
if out_path.exists():
logger.warning("Overwriting: %s", out_path)
else:
logger.info("Writing: %s", out_path)
out_path.write_text("\n".join(lines), encoding=encoding)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment