Skip to content

Instantly share code, notes, and snippets.

@hwine
Forked from ddelange/itunes_xml_to_m3u.py
Created December 19, 2025 18:39
Show Gist options
  • Select an option

  • Save hwine/7aead05c4a3221c70b855d0224f66cc2 to your computer and use it in GitHub Desktop.

Select an option

Save hwine/7aead05c4a3221c70b855d0224f66cc2 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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment