@@ -87,9 +87,16 @@ def import_playlist(self, url):
8787 self ._status (f'Found playlist "{ playlist_name } " — { total } tracks' )
8888
8989 lib = None
90- if os .path .exists (beets_db ):
90+ try :
91+ os .makedirs (music_dir , exist_ok = True )
9192 beets_context .set_music_dir (bytestring_path (music_dir ))
9293 lib = Library (beets_db , directory = music_dir )
94+ except Exception as e :
95+ log .error (f"[skimmer] Failed to open beets library { beets_db } : { e } " )
96+ lib = None
97+
98+ if lib :
99+ self ._cleanup_orphans (lib , music_dir )
93100
94101 matched = 0
95102 to_download = []
@@ -162,7 +169,43 @@ def import_playlist(self, url):
162169 self ._status ("Import complete" )
163170 return result
164171
172+ def _cleanup_orphans (self , lib , music_dir ):
173+ """Delete audio files under music_dir that are not indexed in beets."""
174+ try :
175+ known = {
176+ os .fsdecode (i .path )
177+ for i in lib .items ()
178+ if i .path
179+ }
180+ except Exception as e :
181+ log .warning (f"[skimmer] Could not read beets items during cleanup: { e } " )
182+ return
183+
184+ removed = 0
185+ for root , _dirs , files in os .walk (music_dir ):
186+ for fname in files :
187+ if fname .startswith ("." ) and fname .endswith (".partial" ):
188+ continue
189+ if not fname .lower ().endswith ((".mp3" , ".m4a" , ".flac" , ".opus" )):
190+ continue
191+ fpath = os .path .join (root , fname )
192+ if fpath in known or os .path .realpath (fpath ) in known :
193+ continue
194+ try :
195+ os .remove (fpath )
196+ removed += 1
197+ log .info (f"[skimmer] Removed orphan: { fpath } " )
198+ except OSError as e :
199+ log .warning (f"[skimmer] Failed to remove orphan { fpath } : { e } " )
200+ if removed :
201+ log .info (f"[skimmer] Cleanup removed { removed } orphan audio file(s)" )
202+
165203 def _download_tracks (self , to_download , music_dir , lib , failed ):
204+ if lib is None :
205+ raise SpotifyImportError (
206+ "Beets library is unavailable; refusing to import so files are not orphaned."
207+ )
208+
166209 temp_dir = tempfile .mkdtemp (prefix = "skimmer-spotify-" )
167210 log .info (f"[skimmer] Download temp dir: { temp_dir } " )
168211
@@ -256,7 +299,9 @@ def _download_tracks(self, to_download, music_dir, lib, failed):
256299 os .makedirs (dest_dir , exist_ok = True )
257300
258301 dest_path = os .path .join (dest_dir , os .path .basename (found_file ))
259- shutil .copy2 (found_file , dest_path )
302+ partial_path = os .path .join (dest_dir , f".{ os .path .basename (found_file )} .partial" )
303+ shutil .copy2 (found_file , partial_path )
304+ os .replace (partial_path , dest_path )
260305 entry ["file_path" ] = dest_path
261306
262307 # Download album cover
@@ -272,6 +317,19 @@ def _download_tracks(self, to_download, music_dir, lib, failed):
272317
273318 log .info (f"[skimmer] Copied to: { dest_path } " )
274319
320+ # Index immediately so a mid-import abort never leaves an orphan file.
321+ try :
322+ self ._import_one_track (lib , music_dir , entry , dest_path )
323+ except Exception as e :
324+ log .warning (f"[skimmer] Beets index failed for { dest_path } : { e } " )
325+ try :
326+ os .remove (dest_path )
327+ except OSError :
328+ pass
329+ failed .append (f"{ artist } - { title } " )
330+ entry ["file_path" ] = ""
331+ continue
332+
275333 except SpotifyImportError :
276334 raise
277335 except Exception as e :
@@ -280,20 +338,6 @@ def _download_tracks(self, to_download, music_dir, lib, failed):
280338 log .warning (f"[skimmer] Download failed for { artist } - { title } : { e } " )
281339 failed .append (f"{ artist } - { title } " )
282340
283- # Import new files into beets
284- if lib :
285- new_tracks = [
286- (t ["file_path" ], t ["artist" ], t ["album" ])
287- for t in to_download
288- if t ["file_path" ] and t ["file_path" ].startswith (music_dir )
289- ]
290- if new_tracks :
291- self ._status ("Importing into beets library..." )
292- try :
293- self ._import_to_beets (lib , music_dir , new_tracks )
294- except Exception as e :
295- log .warning (f"[skimmer] Beets import error: { e } " , exc_info = True )
296-
297341 except SpotifyImportError :
298342 raise
299343 except Exception as e :
@@ -342,53 +386,33 @@ def _tag_file(self, fpath, entry):
342386 except Exception as e :
343387 log .warning (f"[skimmer] Failed to tag { fpath } : { e } " )
344388
345- def _import_to_beets (self , lib , music_dir , track_info_list ):
346- seen_albums = {}
347- for fpath , artist , album in track_info_list :
348- if not os .path .exists (fpath ) or os .path .getsize (fpath ) == 0 :
349- continue
350- try :
351- item = Item .from_path (fpath )
352- item .add (lib )
353- key = (artist , album )
354- if key not in seen_albums :
355- seen_albums [key ] = []
356- seen_albums [key ].append (item )
357- except Exception as e :
358- log .warning (f"[skimmer] Failed to add { fpath } : { e } " )
359-
360- for (artist , album_title ), items in seen_albums .items ():
361- if items :
362- try :
363- album_obj = lib .add_album (items )
364- album_obj .genre = "Spotify Import"
365- album_obj .store ()
366- log .info (f"[skimmer] Created album '{ album_obj .album } ' (id={ album_obj .id } )" )
367- try :
368- from beets .autotag .match import tag_album
369-
370- album_items = list (album_obj .items ())
371- _ , _ , proposal = tag_album (
372- album_items , search_artist = artist , search_name = album_title
373- )
374- if proposal and proposal .candidates :
375- match = proposal .candidates [0 ]
376- match .apply_metadata ()
377- for item in match .mapping : # pyright: ignore[reportAttributeAccessIssue]
378- item .try_write ()
379- album_obj .albumartist = match .info .artist
380- album_obj .album = match .info .album
381- album_obj .store ()
382- log .info (
383- f"[skimmer] Autotagged: { match .info .artist } - { match .info .album } "
384- )
385- except Exception as e :
386- log .warning (f"[skimmer] Autotag skipped: { e } " )
387- except Exception as e :
388- log .warning (f"[skimmer] Failed to create album { album_title } : { e } " )
389-
390- if lib :
391- try :
392- lib .store ()
393- except Exception :
394- pass
389+ def _import_one_track (self , lib , music_dir , entry , fpath ):
390+ artist = entry ["artist" ]
391+ album_title = entry ["album" ] or entry ["title" ]
392+ item = Item .from_path (fpath )
393+ item .add (lib )
394+ album_obj = lib .add_album ([item ])
395+ album_obj .genre = "Spotify Import"
396+ album_obj .store ()
397+ log .info (
398+ f"[skimmer] Indexed '{ item .title } ' -> album "
399+ f"'{ album_obj .album } ' (id={ album_obj .id } )"
400+ )
401+ try :
402+ from beets .autotag .match import tag_album
403+
404+ album_items = list (album_obj .items ())
405+ _ , _ , proposal = tag_album (
406+ album_items , search_artist = artist , search_name = album_title
407+ )
408+ if proposal and proposal .candidates :
409+ match = proposal .candidates [0 ]
410+ match .apply_metadata ()
411+ for item in match .mapping : # pyright: ignore[reportAttributeAccessIssue]
412+ item .try_write ()
413+ album_obj .albumartist = match .info .artist
414+ album_obj .album = match .info .album
415+ album_obj .store ()
416+ log .info (f"[skimmer] Autotagged: { match .info .artist } - { match .info .album } " )
417+ except Exception as e :
418+ log .warning (f"[skimmer] Autotag skipped: { e } " )
0 commit comments