Skip to content

Commit 967221e

Browse files
authored
Merge pull request #2 from ekinnee/copilot/fix-ba007979-02e6-4cca-b749-7b14ec114cfc
Add 'albums' subcommand for Apple Music XML to Lidarr album import with release-group MBIDs
2 parents be66d05 + bb5c31d commit 967221e

2 files changed

Lines changed: 183 additions & 19 deletions

File tree

AppleMusicXmlToLidarr.py

Lines changed: 142 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,32 @@ def search_musicbrainz_recording(artist: str, title: str, album: str = None) ->
3737
logging.warning(f"MusicBrainz lookup failed for '{artist} - {title}' ({album}): {e}")
3838
return ""
3939

40+
def search_musicbrainz_release_group(artist: str, album: str) -> str:
41+
"""
42+
Query MusicBrainz for the release-group MBID given artist and album.
43+
Returns the MBID string, or empty string if not found.
44+
"""
45+
base_url = "https://musicbrainz.org/ws/2/release-group/"
46+
query = f'release:"{album}" AND artist:"{artist}"'
47+
params = {
48+
"query": query,
49+
"fmt": "json",
50+
"limit": 1
51+
}
52+
url = base_url + "?" + urllib.parse.urlencode(params)
53+
headers = {
54+
"User-Agent": "AppleMusicXmlToLidarr/1.0 (ekinnee)"
55+
}
56+
req = urllib.request.Request(url, headers=headers)
57+
try:
58+
with urllib.request.urlopen(req, timeout=10) as response:
59+
data = json.load(response)
60+
if "release-groups" in data and data["release-groups"]:
61+
return data["release-groups"][0].get("id", "")
62+
except Exception as e:
63+
logging.warning(f"MusicBrainz release-group lookup failed for '{artist} - {album}': {e}")
64+
return ""
65+
4066
def parse_apple_music_xml(xml_path: str) -> List[Dict]:
4167
"""
4268
Parse the Apple Music Library XML file and extract tracks with artist and title.
@@ -54,6 +80,31 @@ def parse_apple_music_xml(xml_path: str) -> List[Dict]:
5480
song_list.append({"artist": artist, "title": title, "album": album})
5581
return song_list
5682

83+
def extract_unique_albums(xml_path: str) -> List[Dict]:
84+
"""
85+
Parse the Apple Music Library XML file and extract unique (artist, album) pairs.
86+
Returns a list of dicts: {artist, album}
87+
"""
88+
with open(xml_path, "rb") as f:
89+
plist = plistlib.load(f)
90+
tracks = plist.get("Tracks", {})
91+
92+
# Use a set to track unique combinations
93+
unique_albums = set()
94+
album_list = []
95+
96+
for track in tracks.values():
97+
artist = track.get("Artist")
98+
album = track.get("Album")
99+
if artist and album:
100+
# Create a unique key for this artist-album combination
101+
unique_key = (artist, album)
102+
if unique_key not in unique_albums:
103+
unique_albums.add(unique_key)
104+
album_list.append({"artist": artist, "album": album})
105+
106+
return album_list
107+
57108
def build_lidarr_json(songs: List[Dict]) -> (List[Dict], List[Dict]):
58109
"""
59110
For each song, lookup the MusicBrainzId and build the Lidarr-compatible dict.
@@ -71,6 +122,23 @@ def build_lidarr_json(songs: List[Dict]) -> (List[Dict], List[Dict]):
71122
time.sleep(1) # MusicBrainz rate limit for anonymous requests
72123
return found, not_found
73124

125+
def build_albums_json(albums: List[Dict]) -> (List[Dict], List[Dict]):
126+
"""
127+
For each album, lookup the release-group MusicBrainzId.
128+
Returns two lists: found (with MBID) and not_found (for later processing).
129+
"""
130+
found = []
131+
not_found = []
132+
for idx, album in enumerate(albums, 1):
133+
mbid = search_musicbrainz_release_group(album["artist"], album["album"])
134+
if mbid:
135+
found.append({"MusicBrainzId": mbid})
136+
else:
137+
not_found.append(album)
138+
logging.info(f"[{idx}/{len(albums)}] {album['artist']} - {album['album']} => MBID: {mbid if mbid else 'NOT FOUND'}")
139+
time.sleep(1) # MusicBrainz rate limit for anonymous requests
140+
return found, not_found
141+
74142
def recheck_not_found(output_json: str, not_found_json: str):
75143
"""
76144
Recheck items from not_found_json, append newly found MBIDs to output_json,
@@ -151,21 +219,81 @@ def main(xml_path: str, output_json: str, not_found_json: str):
151219
json.dump(not_found, nf, ensure_ascii=False, indent=2)
152220
logging.info(f"Exported {len(not_found)} unmatched tracks to {not_found_json}")
153221

222+
def albums_main(xml_path: str, output_json: str, not_found_json: str):
223+
"""
224+
Parse XML, extract unique albums, get release-group MBIDs, write found and not found to separate files.
225+
"""
226+
logging.info(f"Parsing Apple Music library for albums: {xml_path}")
227+
albums = extract_unique_albums(xml_path)
228+
logging.info(f"Found {len(albums)} unique albums.")
229+
230+
found, not_found = build_albums_json(albums)
231+
232+
# Write found MBIDs to output JSON (for Lidarr import)
233+
with open(output_json, "w", encoding="utf-8") as f:
234+
json.dump(found, f, ensure_ascii=False, indent=2)
235+
logging.info(f"Exported {len(found)} release-group MusicBrainzIds to {output_json}")
236+
237+
# Write not found items to a separate JSON file for later processing
238+
with open(not_found_json, "w", encoding="utf-8") as nf:
239+
json.dump(not_found, nf, ensure_ascii=False, indent=2)
240+
logging.info(f"Exported {len(not_found)} unmatched albums to {not_found_json}")
241+
154242
if __name__ == "__main__":
155243
import argparse
156-
parser = argparse.ArgumentParser(description="Convert Apple Music Library.xml to Lidarr JSON import format with not-found items exported.")
157-
parser.add_argument("--recheck", action="store_true",
158-
help="Recheck mode: process items from not_found_json instead of parsing XML")
159-
parser.add_argument("xml_file", nargs="?", help="Path to Apple Music Library.xml (not needed in recheck mode)")
160-
parser.add_argument("output_json", help="Output JSON file path for found items")
161-
parser.add_argument("not_found_json", help="Output JSON file path for not found items")
162-
args = parser.parse_args()
244+
import sys
245+
246+
# Check if the first argument is a subcommand
247+
has_subcommand = len(sys.argv) > 1 and sys.argv[1] in ['tracks', 'albums']
163248

164-
if args.recheck:
165-
if args.xml_file:
166-
logging.warning("XML file argument ignored in recheck mode")
167-
recheck_not_found(args.output_json, args.not_found_json)
249+
if has_subcommand:
250+
# Use subcommands
251+
parser = argparse.ArgumentParser(description="Convert Apple Music Library.xml to Lidarr JSON import format with not-found items exported.")
252+
subparsers = parser.add_subparsers(dest='command', help='Available commands')
253+
254+
# Tracks subcommand
255+
tracks_parser = subparsers.add_parser('tracks', help='Process individual tracks (default)')
256+
tracks_parser.add_argument("--recheck", action="store_true",
257+
help="Recheck mode: process items from not_found_json instead of parsing XML")
258+
tracks_parser.add_argument("xml_file", nargs="?", help="Path to Apple Music Library.xml (not needed in recheck mode)")
259+
tracks_parser.add_argument("output_json", help="Output JSON file path for found items")
260+
tracks_parser.add_argument("not_found_json", help="Output JSON file path for not found items")
261+
262+
# Albums subcommand
263+
albums_parser = subparsers.add_parser('albums', help='Process unique albums')
264+
albums_parser.add_argument("xml_file", help="Path to Apple Music Library.xml")
265+
albums_parser.add_argument("output_json", help="Output JSON file path for found albums (default: albums.json)")
266+
albums_parser.add_argument("not_found_json", help="Output JSON file path for not found albums (default: albums_notfound.json)")
267+
268+
args = parser.parse_args()
269+
270+
# Handle subcommands
271+
if args.command == 'albums':
272+
albums_main(args.xml_file, args.output_json, args.not_found_json)
273+
elif args.command == 'tracks':
274+
if args.recheck:
275+
if args.xml_file:
276+
logging.warning("XML file argument ignored in recheck mode")
277+
recheck_not_found(args.output_json, args.not_found_json)
278+
else:
279+
if not args.xml_file:
280+
tracks_parser.error("xml_file is required when not in recheck mode")
281+
main(args.xml_file, args.output_json, args.not_found_json)
168282
else:
169-
if not args.xml_file:
170-
parser.error("xml_file is required when not in recheck mode")
171-
main(args.xml_file, args.output_json, args.not_found_json)
283+
# Backward compatibility: use original argument structure
284+
parser = argparse.ArgumentParser(description="Convert Apple Music Library.xml to Lidarr JSON import format with not-found items exported.")
285+
parser.add_argument("--recheck", action="store_true",
286+
help="Recheck mode: process items from not_found_json instead of parsing XML")
287+
parser.add_argument("xml_file", nargs="?", help="Path to Apple Music Library.xml (not needed in recheck mode)")
288+
parser.add_argument("output_json", help="Output JSON file path for found items")
289+
parser.add_argument("not_found_json", help="Output JSON file path for not found items")
290+
args = parser.parse_args()
291+
292+
if args.recheck:
293+
if args.xml_file:
294+
logging.warning("XML file argument ignored in recheck mode")
295+
recheck_not_found(args.output_json, args.not_found_json)
296+
else:
297+
if not args.xml_file:
298+
parser.error("xml_file is required when not in recheck mode")
299+
main(args.xml_file, args.output_json, args.not_found_json)

README.md

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ Export your Apple Music Library to the default xml and feed it to this. It looks
44

55
## Usage
66

7-
### Initial Processing
7+
### Processing Individual Tracks (Default)
8+
9+
#### Initial Processing
810
Convert Apple Music Library XML to Lidarr JSON format:
911

1012
```bash
@@ -17,7 +19,7 @@ This will:
1719
- Save found MBIDs to `found_tracks.json` for Lidarr import
1820
- Save unmatched tracks to `not_found_tracks.json` for later processing
1921

20-
### Recheck Mode
22+
#### Recheck Mode
2123
Reprocess tracks that were not found initially:
2224

2325
```bash
@@ -30,21 +32,55 @@ This will:
3032
- Append any newly found MBIDs to the existing `found_tracks.json`
3133
- Update `not_found_tracks.json` by removing successfully matched tracks
3234

35+
### Processing Albums
36+
37+
Extract unique albums and get release-group MBIDs:
38+
39+
```bash
40+
python3 AppleMusicXmlToLidarr.py albums Library.xml albums.json albums_notfound.json
41+
```
42+
43+
This will:
44+
- Parse your Apple Music Library.xml file
45+
- Extract unique (artist, album) pairs
46+
- Look up MusicBrainz release-group IDs for each album
47+
- Save found release-group MBIDs to `albums.json` for Lidarr import
48+
- Save unmatched albums to `albums_notfound.json` for later processing
49+
3350
### Workflow
3451

52+
#### For Individual Tracks
3553
1. Run initial processing to generate both found and not-found files
3654
2. Import the found tracks into Lidarr
3755
3. Periodically run recheck mode to find MBIDs for previously unmatched tracks
3856
4. Import any newly found tracks into Lidarr
3957

40-
The recheck mode is useful because:
58+
#### For Albums
59+
1. Run album processing to generate release-group MBIDs
60+
2. Import the found albums into Lidarr as release-groups
61+
3. Use this approach when you want to import entire albums rather than individual tracks
62+
63+
The recheck mode is useful for tracks because:
4164
- MusicBrainz database is constantly updated with new entries
4265
- Network issues may have caused temporary lookup failures
4366
- You can refine your approach or wait for better data coverage
4467

68+
### Best Practices
69+
70+
- **Use albums subcommand** when importing entire albums to Lidarr - this provides release-group MBIDs which are more appropriate for album-based imports
71+
- **Use default track processing** when you need fine-grained control over individual songs
72+
- **Always save both found and not-found files** to enable reprocessing later
73+
- **Be mindful of MusicBrainz rate limits** - the tool includes 1-second delays between requests
74+
- **Consider the file naming convention**: use descriptive names like `albums.json`/`albums_notfound.json` for album processing
75+
4576
## Output Files
4677

47-
- **found_tracks.json**: Contains MusicBrainz IDs in Lidarr-compatible format
78+
### For Track Processing
79+
- **found_tracks.json**: Contains MusicBrainz recording IDs in Lidarr-compatible format
4880
- **not_found_tracks.json**: Contains track metadata for songs that couldn't be matched
4981

50-
Both files use UTF-8 encoding and pretty-printed JSON for readability.
82+
### For Album Processing
83+
- **albums.json**: Contains MusicBrainz release-group IDs in Lidarr-compatible format
84+
- **albums_notfound.json**: Contains album metadata (artist, album) for albums that couldn't be matched
85+
86+
All files use UTF-8 encoding and pretty-printed JSON for readability.

0 commit comments

Comments
 (0)