Skip to content

Instantly share code, notes, and snippets.

@ddelange
Last active February 4, 2025 15:19
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
# 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
logging.basicConfig(format="%(levelname)s: %(message)s", level=logging.INFO)
logger = logging.getLogger()
RE_PLAYLIST_CHARS = re.compile("[/\\:]")
# install if missing
try:
import click
except (ImportError, ModuleNotFoundError):
args = [sys.executable, "-m", "pip", "install", "click"]
logger.info("Running %s", " ".join(args))
subprocess.check_output(args) # noqa: S603
import click
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: tp.Optional[str],
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()
@6b6561
Copy link

6b6561 commented Dec 2, 2024

A big thank you for making this script available, it's perfect for extracting playlists in m3u8 format from iTunes xml file.

Python 3.9 on Debian 11 required the following changes:

  • Add "from typing import Optional" at the beginning
  • Change "track_path_prefix: str | None," to "track_path_prefix: Optional[str]," on line 80

Debian 12 with Python 3.11 required Python click to be added by running "apt install python3-click".

@racehd
Copy link

racehd commented Jan 27, 2025

Thank you!! Worked like a charm on Arch Linux w/ Python 3.13.1 and click8.1.8

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment