itunes-export 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- itunes_export-0.1.0/PKG-INFO +77 -0
- itunes_export-0.1.0/README.md +53 -0
- itunes_export-0.1.0/pyproject.toml +39 -0
- itunes_export-0.1.0/setup.cfg +4 -0
- itunes_export-0.1.0/src/itunes_export/__init__.py +23 -0
- itunes_export-0.1.0/src/itunes_export/cli.py +108 -0
- itunes_export-0.1.0/src/itunes_export/core.py +148 -0
- itunes_export-0.1.0/src/itunes_export.egg-info/PKG-INFO +77 -0
- itunes_export-0.1.0/src/itunes_export.egg-info/SOURCES.txt +12 -0
- itunes_export-0.1.0/src/itunes_export.egg-info/dependency_links.txt +1 -0
- itunes_export-0.1.0/src/itunes_export.egg-info/entry_points.txt +2 -0
- itunes_export-0.1.0/src/itunes_export.egg-info/requires.txt +3 -0
- itunes_export-0.1.0/src/itunes_export.egg-info/top_level.txt +1 -0
- itunes_export-0.1.0/tests/test_core.py +305 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: itunes-export
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export Apple Music / iTunes playlists to CSV, XML, or JSON — no manual steps
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Gnomecromancer/itunes-export
|
|
7
|
+
Project-URL: Repository, https://github.com/Gnomecromancer/itunes-export
|
|
8
|
+
Project-URL: Issues, https://github.com/Gnomecromancer/itunes-export/issues
|
|
9
|
+
Keywords: apple-music,itunes,playlist,export,csv,music,windows
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: pywin32; sys_platform == "win32"
|
|
24
|
+
|
|
25
|
+
# itunes-export
|
|
26
|
+
|
|
27
|
+
Export Apple Music / iTunes playlists to CSV, XML, or JSON from the command line — no manual dragging, no XML library hacks. Uses the iTunes COM interface directly.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install itunes-export
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Requires Windows with Apple Music or iTunes installed.**
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
itunes-export --list
|
|
39
|
+
itunes-export --playlist "My Favorites"
|
|
40
|
+
itunes-export --playlist "Road Trip" --format xml
|
|
41
|
+
itunes-export --playlist "Road Trip" --format json
|
|
42
|
+
itunes-export --playlist "Road Trip" --output road_trip.csv
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Exported fields
|
|
46
|
+
|
|
47
|
+
Name, Artist, Album, Album Artist, Composer, Genre, Year, Track Number, Track Count, Disc Number, Disc Count, Duration, Bit Rate, Sample Rate, Rating, Play Count, Date Added, Location, BPM, Comment, Kind
|
|
48
|
+
|
|
49
|
+
## Formats
|
|
50
|
+
|
|
51
|
+
- **CSV** (default) — UTF-8 with BOM for Excel compatibility
|
|
52
|
+
- **XML** — structured `<Playlist><Track>` document
|
|
53
|
+
- **JSON** — `{ playlist, exported, count, tracks: [...] }`
|
|
54
|
+
|
|
55
|
+
## Python API
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from itunes_export import connect, get_user_playlists, find_playlist, export_csv
|
|
59
|
+
|
|
60
|
+
app = connect()
|
|
61
|
+
playlists = get_user_playlists(app)
|
|
62
|
+
matches = find_playlist(playlists, "Road Trip")
|
|
63
|
+
name, pl = matches[0]
|
|
64
|
+
|
|
65
|
+
rows = [track_row(pl.Tracks.Item(i)) for i in range(1, pl.Tracks.Count + 1)]
|
|
66
|
+
export_csv(rows, "road_trip.csv")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
- Windows
|
|
72
|
+
- Apple Music or iTunes installed
|
|
73
|
+
- Python 3.8+
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# itunes-export
|
|
2
|
+
|
|
3
|
+
Export Apple Music / iTunes playlists to CSV, XML, or JSON from the command line — no manual dragging, no XML library hacks. Uses the iTunes COM interface directly.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install itunes-export
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**Requires Windows with Apple Music or iTunes installed.**
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
itunes-export --list
|
|
15
|
+
itunes-export --playlist "My Favorites"
|
|
16
|
+
itunes-export --playlist "Road Trip" --format xml
|
|
17
|
+
itunes-export --playlist "Road Trip" --format json
|
|
18
|
+
itunes-export --playlist "Road Trip" --output road_trip.csv
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Exported fields
|
|
22
|
+
|
|
23
|
+
Name, Artist, Album, Album Artist, Composer, Genre, Year, Track Number, Track Count, Disc Number, Disc Count, Duration, Bit Rate, Sample Rate, Rating, Play Count, Date Added, Location, BPM, Comment, Kind
|
|
24
|
+
|
|
25
|
+
## Formats
|
|
26
|
+
|
|
27
|
+
- **CSV** (default) — UTF-8 with BOM for Excel compatibility
|
|
28
|
+
- **XML** — structured `<Playlist><Track>` document
|
|
29
|
+
- **JSON** — `{ playlist, exported, count, tracks: [...] }`
|
|
30
|
+
|
|
31
|
+
## Python API
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from itunes_export import connect, get_user_playlists, find_playlist, export_csv
|
|
35
|
+
|
|
36
|
+
app = connect()
|
|
37
|
+
playlists = get_user_playlists(app)
|
|
38
|
+
matches = find_playlist(playlists, "Road Trip")
|
|
39
|
+
name, pl = matches[0]
|
|
40
|
+
|
|
41
|
+
rows = [track_row(pl.Tracks.Item(i)) for i in range(1, pl.Tracks.Count + 1)]
|
|
42
|
+
export_csv(rows, "road_trip.csv")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- Windows
|
|
48
|
+
- Apple Music or iTunes installed
|
|
49
|
+
- Python 3.8+
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "itunes-export"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Export Apple Music / iTunes playlists to CSV, XML, or JSON — no manual steps"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = ["pywin32; sys_platform == 'win32'"]
|
|
13
|
+
keywords = [
|
|
14
|
+
"apple-music", "itunes", "playlist", "export", "csv", "music", "windows"
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: Microsoft :: Windows",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.8",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
itunes-export = "itunes_export.cli:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/Gnomecromancer/itunes-export"
|
|
35
|
+
Repository = "https://github.com/Gnomecromancer/itunes-export"
|
|
36
|
+
Issues = "https://github.com/Gnomecromancer/itunes-export/issues"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Apple Music / iTunes playlist exporter."""
|
|
2
|
+
|
|
3
|
+
from .core import (
|
|
4
|
+
connect,
|
|
5
|
+
get_user_playlists,
|
|
6
|
+
find_playlist,
|
|
7
|
+
list_playlists,
|
|
8
|
+
track_row,
|
|
9
|
+
export_csv,
|
|
10
|
+
export_xml,
|
|
11
|
+
export_json,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"connect",
|
|
16
|
+
"get_user_playlists",
|
|
17
|
+
"find_playlist",
|
|
18
|
+
"list_playlists",
|
|
19
|
+
"track_row",
|
|
20
|
+
"export_csv",
|
|
21
|
+
"export_xml",
|
|
22
|
+
"export_json",
|
|
23
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Command-line interface for itunes-export."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import argparse
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .core import (
|
|
8
|
+
connect,
|
|
9
|
+
get_user_playlists,
|
|
10
|
+
find_playlist,
|
|
11
|
+
list_playlists,
|
|
12
|
+
track_row,
|
|
13
|
+
export_csv,
|
|
14
|
+
export_xml,
|
|
15
|
+
export_json,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _collect_tracks(pl, name):
|
|
20
|
+
"""Pull all track rows from a playlist with a progress bar."""
|
|
21
|
+
try:
|
|
22
|
+
tracks = pl.Tracks
|
|
23
|
+
total = tracks.Count
|
|
24
|
+
except Exception as e:
|
|
25
|
+
print(f"Error accessing tracks: {e}", file=sys.stderr)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
|
|
28
|
+
print(f"\nExporting '{name}' ({total:,} tracks) ...")
|
|
29
|
+
rows = []
|
|
30
|
+
for i in range(1, total + 1):
|
|
31
|
+
rows.append(track_row(tracks.Item(i)))
|
|
32
|
+
if i % 50 == 0 or i == total:
|
|
33
|
+
pct = i / total * 100
|
|
34
|
+
bar = "#" * int(pct // 2) + "-" * (50 - int(pct // 2))
|
|
35
|
+
print(f"\r [{bar}] {i:,}/{total:,} ({pct:.0f}%)", end="", flush=True)
|
|
36
|
+
print()
|
|
37
|
+
return rows
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
parser = argparse.ArgumentParser(
|
|
42
|
+
description="Export Apple Music / iTunes playlists to CSV, XML, or JSON",
|
|
43
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
44
|
+
epilog="""
|
|
45
|
+
Examples:
|
|
46
|
+
itunes-export --list
|
|
47
|
+
itunes-export --playlist "My Favorites"
|
|
48
|
+
itunes-export --playlist "Road Trip" --format xml
|
|
49
|
+
itunes-export --playlist "Road Trip" --format json
|
|
50
|
+
itunes-export --playlist "Road Trip" --output road_trip.csv
|
|
51
|
+
""",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument("--list", "-l", action="store_true",
|
|
54
|
+
help="List all playlists with track counts")
|
|
55
|
+
parser.add_argument("--playlist", "-p", metavar="NAME",
|
|
56
|
+
help="Playlist to export (partial name match ok)")
|
|
57
|
+
parser.add_argument("--format", "-f", choices=["csv", "xml", "json"], default="csv",
|
|
58
|
+
help="Output format (default: csv)")
|
|
59
|
+
parser.add_argument("--output", "-o", metavar="FILE",
|
|
60
|
+
help="Output path (default: <playlist name>.<format>)")
|
|
61
|
+
|
|
62
|
+
args = parser.parse_args()
|
|
63
|
+
if not args.list and not args.playlist:
|
|
64
|
+
parser.print_help()
|
|
65
|
+
sys.exit(0)
|
|
66
|
+
|
|
67
|
+
print("Connecting to Apple Music / iTunes ...")
|
|
68
|
+
try:
|
|
69
|
+
app = connect()
|
|
70
|
+
except RuntimeError as e:
|
|
71
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
playlists = get_user_playlists(app)
|
|
75
|
+
|
|
76
|
+
if args.list:
|
|
77
|
+
list_playlists(playlists)
|
|
78
|
+
|
|
79
|
+
if args.playlist:
|
|
80
|
+
matches = find_playlist(playlists, args.playlist)
|
|
81
|
+
if not matches:
|
|
82
|
+
print(f"Error: Playlist '{args.playlist}' not found.", file=sys.stderr)
|
|
83
|
+
print("Use --list to see available playlists.", file=sys.stderr)
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
if len(matches) > 1:
|
|
86
|
+
print(f"Multiple playlists match '{args.playlist}':", file=sys.stderr)
|
|
87
|
+
for n, _ in matches:
|
|
88
|
+
print(f" - {n}", file=sys.stderr)
|
|
89
|
+
print("Please be more specific.", file=sys.stderr)
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
|
|
92
|
+
name, pl = matches[0]
|
|
93
|
+
rows = _collect_tracks(pl, name)
|
|
94
|
+
|
|
95
|
+
if args.output is None:
|
|
96
|
+
safe_name = "".join(c for c in name if c.isalnum() or c in " _-").strip()
|
|
97
|
+
out_path = Path(f"{safe_name}.{args.format}")
|
|
98
|
+
else:
|
|
99
|
+
out_path = Path(args.output)
|
|
100
|
+
|
|
101
|
+
if args.format == "csv":
|
|
102
|
+
export_csv(rows, out_path)
|
|
103
|
+
elif args.format == "xml":
|
|
104
|
+
export_xml(rows, name, out_path)
|
|
105
|
+
else:
|
|
106
|
+
export_json(rows, name, out_path)
|
|
107
|
+
|
|
108
|
+
print(f"Done. {len(rows):,} tracks → {out_path.resolve()}")
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Core logic: COM connection, track extraction, export formats."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TRACK_FIELDS = [
|
|
11
|
+
"Name", "Artist", "Album", "Album Artist", "Composer",
|
|
12
|
+
"Genre", "Year", "Track Number", "Track Count",
|
|
13
|
+
"Disc Number", "Disc Count", "Duration",
|
|
14
|
+
"Bit Rate", "Sample Rate", "Rating", "Play Count",
|
|
15
|
+
"Date Added", "Location", "BPM", "Comment", "Kind",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def connect():
|
|
20
|
+
"""Connect to iTunes/Apple Music via COM and return the application object."""
|
|
21
|
+
try:
|
|
22
|
+
import win32com.client # type: ignore[import-untyped]
|
|
23
|
+
return win32com.client.Dispatch("iTunes.Application")
|
|
24
|
+
except ImportError as e:
|
|
25
|
+
raise RuntimeError(
|
|
26
|
+
"pywin32 is required on Windows. Run: pip install pywin32"
|
|
27
|
+
) from e
|
|
28
|
+
except Exception as e:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
f"Could not connect to iTunes/Apple Music COM: {e}\n"
|
|
31
|
+
"Make sure Apple Music or iTunes is installed."
|
|
32
|
+
) from e
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_user_playlists(app):
|
|
36
|
+
"""Return list of (name, playlist_com_object) for all user playlists."""
|
|
37
|
+
results = []
|
|
38
|
+
for source in app.Sources:
|
|
39
|
+
if source.Kind == 1: # kITSourceLibrary
|
|
40
|
+
for pl in source.Playlists:
|
|
41
|
+
results.append((pl.Name, pl))
|
|
42
|
+
return results
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _safe(obj, attr):
|
|
46
|
+
try:
|
|
47
|
+
v = getattr(obj, attr)
|
|
48
|
+
return v if v is not None else ""
|
|
49
|
+
except Exception:
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _format_duration(seconds):
|
|
54
|
+
if not seconds:
|
|
55
|
+
return ""
|
|
56
|
+
s = int(seconds)
|
|
57
|
+
return f"{s // 60}:{s % 60:02d}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def track_row(track):
|
|
61
|
+
"""Extract a dict of metadata fields from a COM track object."""
|
|
62
|
+
try:
|
|
63
|
+
raw_date = track.DateAdded
|
|
64
|
+
date_added = raw_date.strftime("%Y-%m-%d %H:%M:%S") if raw_date else ""
|
|
65
|
+
except Exception:
|
|
66
|
+
date_added = ""
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"Name": _safe(track, "Name"),
|
|
70
|
+
"Artist": _safe(track, "Artist"),
|
|
71
|
+
"Album": _safe(track, "Album"),
|
|
72
|
+
"Album Artist": _safe(track, "AlbumArtist"),
|
|
73
|
+
"Composer": _safe(track, "Composer"),
|
|
74
|
+
"Genre": _safe(track, "Genre"),
|
|
75
|
+
"Year": _safe(track, "Year"),
|
|
76
|
+
"Track Number": _safe(track, "TrackNumber"),
|
|
77
|
+
"Track Count": _safe(track, "TrackCount"),
|
|
78
|
+
"Disc Number": _safe(track, "DiscNumber"),
|
|
79
|
+
"Disc Count": _safe(track, "DiscCount"),
|
|
80
|
+
"Duration": _format_duration(_safe(track, "Duration")),
|
|
81
|
+
"Bit Rate": _safe(track, "BitRate"),
|
|
82
|
+
"Sample Rate": _safe(track, "SampleRate"),
|
|
83
|
+
"Rating": _safe(track, "Rating"),
|
|
84
|
+
"Play Count": _safe(track, "PlayedCount"),
|
|
85
|
+
"Date Added": date_added,
|
|
86
|
+
"Location": _safe(track, "Location"),
|
|
87
|
+
"BPM": _safe(track, "BPM"),
|
|
88
|
+
"Comment": _safe(track, "Comment"),
|
|
89
|
+
"Kind": _safe(track, "KindAsString"),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def list_playlists(playlists):
|
|
94
|
+
"""Print playlist names and track counts to stdout."""
|
|
95
|
+
print(f"\nAvailable playlists ({len(playlists)}):")
|
|
96
|
+
for i, (name, pl) in enumerate(playlists, 1):
|
|
97
|
+
try:
|
|
98
|
+
count = pl.Tracks.Count
|
|
99
|
+
except Exception:
|
|
100
|
+
count = "?"
|
|
101
|
+
if isinstance(count, int):
|
|
102
|
+
print(f" {i:3}. {name} ({count:,} tracks)")
|
|
103
|
+
else:
|
|
104
|
+
print(f" {i:3}. {name} ({count} tracks)")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def find_playlist(playlists, name):
|
|
108
|
+
"""Return matching (name, playlist) pairs; exact match first, then partial."""
|
|
109
|
+
exact = [(n, pl) for n, pl in playlists if n.lower() == name.lower()]
|
|
110
|
+
if exact:
|
|
111
|
+
return exact
|
|
112
|
+
return [(n, pl) for n, pl in playlists if name.lower() in n.lower()]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def export_csv(rows, path):
|
|
116
|
+
"""Write track rows to a UTF-8 CSV file."""
|
|
117
|
+
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
|
118
|
+
writer = csv.DictWriter(f, fieldnames=TRACK_FIELDS)
|
|
119
|
+
writer.writeheader()
|
|
120
|
+
writer.writerows(rows)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def export_xml(rows, playlist_name, path):
|
|
124
|
+
"""Write track rows to a UTF-8 XML file."""
|
|
125
|
+
root = ET.Element("Playlist")
|
|
126
|
+
root.set("name", playlist_name)
|
|
127
|
+
root.set("exported", datetime.now().isoformat())
|
|
128
|
+
root.set("count", str(len(rows)))
|
|
129
|
+
for row in rows:
|
|
130
|
+
el = ET.SubElement(root, "Track")
|
|
131
|
+
for field, value in row.items():
|
|
132
|
+
child = ET.SubElement(el, field.replace(" ", ""))
|
|
133
|
+
child.text = str(value)
|
|
134
|
+
tree = ET.ElementTree(root)
|
|
135
|
+
ET.indent(tree, space=" ")
|
|
136
|
+
tree.write(path, encoding="utf-8", xml_declaration=True)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def export_json(rows, playlist_name, path):
|
|
140
|
+
"""Write track rows to a UTF-8 JSON file."""
|
|
141
|
+
payload = {
|
|
142
|
+
"playlist": playlist_name,
|
|
143
|
+
"exported": datetime.now().isoformat(),
|
|
144
|
+
"count": len(rows),
|
|
145
|
+
"tracks": rows,
|
|
146
|
+
}
|
|
147
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
148
|
+
json.dump(payload, f, indent=2, ensure_ascii=False)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: itunes-export
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export Apple Music / iTunes playlists to CSV, XML, or JSON — no manual steps
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Gnomecromancer/itunes-export
|
|
7
|
+
Project-URL: Repository, https://github.com/Gnomecromancer/itunes-export
|
|
8
|
+
Project-URL: Issues, https://github.com/Gnomecromancer/itunes-export/issues
|
|
9
|
+
Keywords: apple-music,itunes,playlist,export,csv,music,windows
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: pywin32; sys_platform == "win32"
|
|
24
|
+
|
|
25
|
+
# itunes-export
|
|
26
|
+
|
|
27
|
+
Export Apple Music / iTunes playlists to CSV, XML, or JSON from the command line — no manual dragging, no XML library hacks. Uses the iTunes COM interface directly.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install itunes-export
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Requires Windows with Apple Music or iTunes installed.**
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
itunes-export --list
|
|
39
|
+
itunes-export --playlist "My Favorites"
|
|
40
|
+
itunes-export --playlist "Road Trip" --format xml
|
|
41
|
+
itunes-export --playlist "Road Trip" --format json
|
|
42
|
+
itunes-export --playlist "Road Trip" --output road_trip.csv
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Exported fields
|
|
46
|
+
|
|
47
|
+
Name, Artist, Album, Album Artist, Composer, Genre, Year, Track Number, Track Count, Disc Number, Disc Count, Duration, Bit Rate, Sample Rate, Rating, Play Count, Date Added, Location, BPM, Comment, Kind
|
|
48
|
+
|
|
49
|
+
## Formats
|
|
50
|
+
|
|
51
|
+
- **CSV** (default) — UTF-8 with BOM for Excel compatibility
|
|
52
|
+
- **XML** — structured `<Playlist><Track>` document
|
|
53
|
+
- **JSON** — `{ playlist, exported, count, tracks: [...] }`
|
|
54
|
+
|
|
55
|
+
## Python API
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from itunes_export import connect, get_user_playlists, find_playlist, export_csv
|
|
59
|
+
|
|
60
|
+
app = connect()
|
|
61
|
+
playlists = get_user_playlists(app)
|
|
62
|
+
matches = find_playlist(playlists, "Road Trip")
|
|
63
|
+
name, pl = matches[0]
|
|
64
|
+
|
|
65
|
+
rows = [track_row(pl.Tracks.Item(i)) for i in range(1, pl.Tracks.Count + 1)]
|
|
66
|
+
export_csv(rows, "road_trip.csv")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
- Windows
|
|
72
|
+
- Apple Music or iTunes installed
|
|
73
|
+
- Python 3.8+
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/itunes_export/__init__.py
|
|
4
|
+
src/itunes_export/cli.py
|
|
5
|
+
src/itunes_export/core.py
|
|
6
|
+
src/itunes_export.egg-info/PKG-INFO
|
|
7
|
+
src/itunes_export.egg-info/SOURCES.txt
|
|
8
|
+
src/itunes_export.egg-info/dependency_links.txt
|
|
9
|
+
src/itunes_export.egg-info/entry_points.txt
|
|
10
|
+
src/itunes_export.egg-info/requires.txt
|
|
11
|
+
src/itunes_export.egg-info/top_level.txt
|
|
12
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
itunes_export
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Tests for itunes_export core logic — uses mocked COM objects."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import MagicMock, patch, PropertyMock
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from itunes_export.core import (
|
|
10
|
+
_safe,
|
|
11
|
+
_format_duration,
|
|
12
|
+
track_row,
|
|
13
|
+
find_playlist,
|
|
14
|
+
list_playlists,
|
|
15
|
+
export_csv,
|
|
16
|
+
export_xml,
|
|
17
|
+
export_json,
|
|
18
|
+
TRACK_FIELDS,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Helpers
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def make_track(name="Song", artist="Artist", album="Album", **kwargs):
|
|
27
|
+
"""Build a mock COM track object."""
|
|
28
|
+
t = MagicMock()
|
|
29
|
+
t.Name = name
|
|
30
|
+
t.Artist = artist
|
|
31
|
+
t.Album = album
|
|
32
|
+
t.AlbumArtist = kwargs.get("album_artist", "")
|
|
33
|
+
t.Composer = kwargs.get("composer", "")
|
|
34
|
+
t.Genre = kwargs.get("genre", "Rock")
|
|
35
|
+
t.Year = kwargs.get("year", 2020)
|
|
36
|
+
t.TrackNumber = kwargs.get("track_number", 1)
|
|
37
|
+
t.TrackCount = kwargs.get("track_count", 10)
|
|
38
|
+
t.DiscNumber = kwargs.get("disc_number", 1)
|
|
39
|
+
t.DiscCount = kwargs.get("disc_count", 1)
|
|
40
|
+
t.Duration = kwargs.get("duration", 180)
|
|
41
|
+
t.BitRate = kwargs.get("bit_rate", 256)
|
|
42
|
+
t.SampleRate = kwargs.get("sample_rate", 44100)
|
|
43
|
+
t.Rating = kwargs.get("rating", 80)
|
|
44
|
+
t.PlayedCount = kwargs.get("play_count", 5)
|
|
45
|
+
t.DateAdded = kwargs.get("date_added", None)
|
|
46
|
+
t.Location = kwargs.get("location", "C:\\Music\\song.mp3")
|
|
47
|
+
t.BPM = kwargs.get("bpm", 120)
|
|
48
|
+
t.Comment = kwargs.get("comment", "")
|
|
49
|
+
t.KindAsString = kwargs.get("kind", "MPEG audio file")
|
|
50
|
+
return t
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def make_playlist(name, tracks):
|
|
54
|
+
"""Build a mock COM playlist with a list of track objects."""
|
|
55
|
+
pl = MagicMock()
|
|
56
|
+
pl.Name = name
|
|
57
|
+
pl.Tracks.Count = len(tracks)
|
|
58
|
+
pl.Tracks.Item = lambda i: tracks[i - 1]
|
|
59
|
+
return pl
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# _safe
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
class TestSafe:
|
|
67
|
+
def test_returns_attribute(self):
|
|
68
|
+
obj = MagicMock()
|
|
69
|
+
obj.Foo = "bar"
|
|
70
|
+
assert _safe(obj, "Foo") == "bar"
|
|
71
|
+
|
|
72
|
+
def test_returns_empty_on_exception(self):
|
|
73
|
+
obj = MagicMock()
|
|
74
|
+
type(obj).Broken = PropertyMock(side_effect=Exception("oops"))
|
|
75
|
+
assert _safe(obj, "Broken") == ""
|
|
76
|
+
|
|
77
|
+
def test_returns_empty_for_none(self):
|
|
78
|
+
obj = MagicMock()
|
|
79
|
+
obj.Val = None
|
|
80
|
+
assert _safe(obj, "Val") == ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# _format_duration
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
class TestFormatDuration:
|
|
88
|
+
def test_zero(self):
|
|
89
|
+
assert _format_duration(0) == ""
|
|
90
|
+
|
|
91
|
+
def test_empty(self):
|
|
92
|
+
assert _format_duration("") == ""
|
|
93
|
+
|
|
94
|
+
def test_seconds(self):
|
|
95
|
+
assert _format_duration(90) == "1:30"
|
|
96
|
+
|
|
97
|
+
def test_exact_minute(self):
|
|
98
|
+
assert _format_duration(60) == "1:00"
|
|
99
|
+
|
|
100
|
+
def test_long(self):
|
|
101
|
+
assert _format_duration(3661) == "61:01"
|
|
102
|
+
|
|
103
|
+
def test_pads_seconds(self):
|
|
104
|
+
assert _format_duration(65) == "1:05"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# track_row
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
class TestTrackRow:
|
|
112
|
+
def test_basic_fields(self):
|
|
113
|
+
t = make_track(name="Hello", artist="World", genre="Pop")
|
|
114
|
+
row = track_row(t)
|
|
115
|
+
assert row["Name"] == "Hello"
|
|
116
|
+
assert row["Artist"] == "World"
|
|
117
|
+
assert row["Genre"] == "Pop"
|
|
118
|
+
|
|
119
|
+
def test_all_fields_present(self):
|
|
120
|
+
row = track_row(make_track())
|
|
121
|
+
for field in TRACK_FIELDS:
|
|
122
|
+
assert field in row
|
|
123
|
+
|
|
124
|
+
def test_duration_formatted(self):
|
|
125
|
+
t = make_track(duration=125)
|
|
126
|
+
row = track_row(t)
|
|
127
|
+
assert row["Duration"] == "2:05"
|
|
128
|
+
|
|
129
|
+
def test_date_added_none(self):
|
|
130
|
+
t = make_track(date_added=None)
|
|
131
|
+
row = track_row(t)
|
|
132
|
+
assert row["Date Added"] == ""
|
|
133
|
+
|
|
134
|
+
def test_date_added_formatted(self):
|
|
135
|
+
from datetime import datetime
|
|
136
|
+
t = make_track(date_added=datetime(2023, 6, 15, 10, 30, 0))
|
|
137
|
+
row = track_row(t)
|
|
138
|
+
assert row["Date Added"] == "2023-06-15 10:30:00"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# find_playlist
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
class TestFindPlaylist:
|
|
146
|
+
def setup_method(self):
|
|
147
|
+
self.pl_a = MagicMock()
|
|
148
|
+
self.pl_b = MagicMock()
|
|
149
|
+
self.pl_c = MagicMock()
|
|
150
|
+
self.playlists = [
|
|
151
|
+
("My Favorites", self.pl_a),
|
|
152
|
+
("Road Trip", self.pl_b),
|
|
153
|
+
("Jazz Classics", self.pl_c),
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
def test_exact_match(self):
|
|
157
|
+
result = find_playlist(self.playlists, "Road Trip")
|
|
158
|
+
assert len(result) == 1
|
|
159
|
+
assert result[0][0] == "Road Trip"
|
|
160
|
+
|
|
161
|
+
def test_case_insensitive_exact(self):
|
|
162
|
+
result = find_playlist(self.playlists, "road trip")
|
|
163
|
+
assert len(result) == 1
|
|
164
|
+
|
|
165
|
+
def test_partial_match(self):
|
|
166
|
+
result = find_playlist(self.playlists, "Jazz")
|
|
167
|
+
assert len(result) == 1
|
|
168
|
+
assert result[0][0] == "Jazz Classics"
|
|
169
|
+
|
|
170
|
+
def test_no_match(self):
|
|
171
|
+
result = find_playlist(self.playlists, "Nonexistent")
|
|
172
|
+
assert result == []
|
|
173
|
+
|
|
174
|
+
def test_multiple_partial_matches(self):
|
|
175
|
+
playlists = [("Rock Hits", MagicMock()), ("Rock Classics", MagicMock())]
|
|
176
|
+
result = find_playlist(playlists, "Rock")
|
|
177
|
+
assert len(result) == 2
|
|
178
|
+
|
|
179
|
+
def test_exact_wins_over_partial(self):
|
|
180
|
+
playlists = [("Jazz", MagicMock()), ("Jazz Classics", MagicMock())]
|
|
181
|
+
result = find_playlist(playlists, "Jazz")
|
|
182
|
+
assert len(result) == 1
|
|
183
|
+
assert result[0][0] == "Jazz"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# list_playlists
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
class TestListPlaylists:
|
|
191
|
+
def test_prints_without_error(self, capsys):
|
|
192
|
+
pl = MagicMock()
|
|
193
|
+
pl.Tracks.Count = 42
|
|
194
|
+
list_playlists([("My Mix", pl)])
|
|
195
|
+
out = capsys.readouterr().out
|
|
196
|
+
assert "My Mix" in out
|
|
197
|
+
assert "42" in out
|
|
198
|
+
|
|
199
|
+
def test_handles_count_exception(self, capsys):
|
|
200
|
+
pl = MagicMock()
|
|
201
|
+
type(pl.Tracks).Count = PropertyMock(side_effect=Exception)
|
|
202
|
+
list_playlists([("Broken", pl)])
|
|
203
|
+
out = capsys.readouterr().out
|
|
204
|
+
assert "Broken" in out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# export_csv
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
class TestExportCsv:
|
|
212
|
+
def test_creates_file(self):
|
|
213
|
+
rows = [track_row(make_track(name="A")), track_row(make_track(name="B"))]
|
|
214
|
+
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
|
|
215
|
+
path = Path(f.name)
|
|
216
|
+
export_csv(rows, path)
|
|
217
|
+
assert path.exists()
|
|
218
|
+
content = path.read_text(encoding="utf-8-sig")
|
|
219
|
+
assert "Name" in content
|
|
220
|
+
assert "Artist" in content
|
|
221
|
+
|
|
222
|
+
def test_row_count(self):
|
|
223
|
+
rows = [track_row(make_track()) for _ in range(5)]
|
|
224
|
+
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
|
|
225
|
+
path = Path(f.name)
|
|
226
|
+
export_csv(rows, path)
|
|
227
|
+
lines = path.read_text(encoding="utf-8-sig").strip().splitlines()
|
|
228
|
+
assert len(lines) == 6 # header + 5 rows
|
|
229
|
+
|
|
230
|
+
def test_track_name_in_output(self):
|
|
231
|
+
rows = [track_row(make_track(name="Stairway To Heaven"))]
|
|
232
|
+
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
|
|
233
|
+
path = Path(f.name)
|
|
234
|
+
export_csv(rows, path)
|
|
235
|
+
assert "Stairway To Heaven" in path.read_text(encoding="utf-8-sig")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# export_xml
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
class TestExportXml:
|
|
243
|
+
def test_creates_valid_xml(self):
|
|
244
|
+
rows = [track_row(make_track(name="TestSong"))]
|
|
245
|
+
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as f:
|
|
246
|
+
path = Path(f.name)
|
|
247
|
+
export_xml(rows, "Test Playlist", path)
|
|
248
|
+
import xml.etree.ElementTree as ET
|
|
249
|
+
tree = ET.parse(path)
|
|
250
|
+
root = tree.getroot()
|
|
251
|
+
assert root.tag == "Playlist"
|
|
252
|
+
assert root.get("name") == "Test Playlist"
|
|
253
|
+
assert root.get("count") == "1"
|
|
254
|
+
|
|
255
|
+
def test_track_name_present(self):
|
|
256
|
+
rows = [track_row(make_track(name="MyTrack"))]
|
|
257
|
+
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as f:
|
|
258
|
+
path = Path(f.name)
|
|
259
|
+
export_xml(rows, "PL", path)
|
|
260
|
+
content = path.read_text(encoding="utf-8")
|
|
261
|
+
assert "MyTrack" in content
|
|
262
|
+
|
|
263
|
+
def test_multiple_tracks(self):
|
|
264
|
+
rows = [track_row(make_track()) for _ in range(3)]
|
|
265
|
+
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as f:
|
|
266
|
+
path = Path(f.name)
|
|
267
|
+
export_xml(rows, "Multi", path)
|
|
268
|
+
import xml.etree.ElementTree as ET
|
|
269
|
+
root = ET.parse(path).getroot()
|
|
270
|
+
assert len(list(root)) == 3
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# export_json
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
class TestExportJson:
|
|
278
|
+
def test_creates_valid_json(self):
|
|
279
|
+
rows = [track_row(make_track(name="JsonSong"))]
|
|
280
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
281
|
+
path = Path(f.name)
|
|
282
|
+
export_json(rows, "JSON Playlist", path)
|
|
283
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
284
|
+
assert data["playlist"] == "JSON Playlist"
|
|
285
|
+
assert data["count"] == 1
|
|
286
|
+
assert len(data["tracks"]) == 1
|
|
287
|
+
assert data["tracks"][0]["Name"] == "JsonSong"
|
|
288
|
+
|
|
289
|
+
def test_multiple_tracks(self):
|
|
290
|
+
rows = [track_row(make_track(name=f"Track {i}")) for i in range(5)]
|
|
291
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
292
|
+
path = Path(f.name)
|
|
293
|
+
export_json(rows, "Five", path)
|
|
294
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
295
|
+
assert data["count"] == 5
|
|
296
|
+
assert len(data["tracks"]) == 5
|
|
297
|
+
|
|
298
|
+
def test_has_exported_timestamp(self):
|
|
299
|
+
rows = [track_row(make_track())]
|
|
300
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
301
|
+
path = Path(f.name)
|
|
302
|
+
export_json(rows, "PL", path)
|
|
303
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
304
|
+
assert "exported" in data
|
|
305
|
+
assert len(data["exported"]) > 0
|