Last active
November 8, 2024 17:24
-
-
Save ddelange/46d5a4c8c9897abb0d3d407938d3702a to your computer and use it in GitHub Desktop.
Convert iTunes Music Library xml to m3u8 playlists
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
# 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