Skip to content

Commit 052dccc

Browse files
committed
feat: have playlist covers auto-generate if not set
1 parent bf64379 commit 052dccc

5 files changed

Lines changed: 227 additions & 66 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ dependencies = [
1111
"mpris-server>=0.8.1",
1212
"platformdirs>=4.0.0",
1313
"spotifyscraper>=3.9.0",
14+
"Pillow>=10.0.0",
1415
"pyobjc-framework-MediaPlayer>=10.0; platform_system == 'Darwin'",
1516
"pyobjc-framework-Cocoa>=10.0; platform_system == 'Darwin'",
1617
]

‎skimmer/playlist.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,66 @@ def resolve_cover(playlist: Playlist) -> str | None:
104104
return None
105105

106106

107+
def _find_album_cover(track: PlaylistTrack) -> str | None:
108+
if not track.file_path:
109+
return None
110+
album_dir = os.path.dirname(track.file_path)
111+
if not os.path.isdir(album_dir):
112+
return None
113+
for name in ("cover.jpg", "cover.png", "front.jpg", "folder.jpg", "Cover.jpg"):
114+
path = os.path.join(album_dir, name)
115+
if os.path.exists(path):
116+
return path
117+
return None
118+
119+
120+
def generate_playlist_cover(playlist: Playlist, music_dir: str) -> str | None:
121+
"""Generate cover from track album covers:
122+
- 1-3 tracks: first track's cover (256x256)
123+
- 4+ tracks: 2x2 grid of first 4 covers (512x512)
124+
Returns path to saved cover in COVERS_DIR/{uuid}.jpg, or None if no covers found.
125+
"""
126+
if not playlist.tracks:
127+
return None
128+
129+
covers = []
130+
for track in playlist.tracks[:4]:
131+
cover_path = _find_album_cover(track)
132+
if cover_path:
133+
covers.append(cover_path)
134+
135+
if not covers:
136+
return None
137+
138+
try:
139+
from PIL import Image
140+
except ImportError:
141+
return None
142+
143+
COVERS_DIR.mkdir(parents=True, exist_ok=True)
144+
out_path = COVERS_DIR / f"{playlist.uuid}.jpg"
145+
146+
if len(covers) == 1:
147+
img = Image.open(covers[0]).convert("RGB")
148+
img = img.resize((256, 256), Image.Resampling.LANCZOS)
149+
else:
150+
grid_size = 512
151+
tile_size = 256
152+
img = Image.new("RGB", (grid_size, grid_size), (0x22, 0x22, 0x22))
153+
for i, cp in enumerate(covers[:4]):
154+
try:
155+
tile = Image.open(cp).convert("RGB")
156+
tile = tile.resize((tile_size, tile_size), Image.Resampling.LANCZOS)
157+
except Exception:
158+
continue
159+
x = (i % 2) * tile_size
160+
y = (i // 2) * tile_size
161+
img.paste(tile, (x, y))
162+
163+
img.save(out_path, "JPEG", quality=85)
164+
return str(out_path)
165+
166+
107167
def export_m3u8(playlist: Playlist, out_path: str, paths: list[str] | None = None):
108168
"""Write an .m3u8 file.
109169

‎skimmer/playlists_ui.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Playlist,
1616
PlaylistTrack,
1717
export_m3u8,
18+
generate_playlist_cover,
1819
load_playlists,
1920
parse_m3u8,
2021
resolve_cover,
@@ -472,6 +473,7 @@ def _remove_track_at(self, idx):
472473
del pl.tracks[idx]
473474
pl.last_modified = time.time()
474475
self._save()
476+
self._maybe_regenerate_cover(pl)
475477
self._build_detail_for(self._detail_playlist_index)
476478
self._rebuild_grid()
477479

@@ -638,6 +640,20 @@ def _do_delete(self, dialog, response, idx):
638640
self.stack.set_visible_child_name("grid")
639641
self._rebuild_grid()
640642

643+
def _maybe_regenerate_cover(self, pl: Playlist):
644+
"""Regenerate playlist cover from track album covers if not manually set."""
645+
if pl.cover_path:
646+
return # Preserve manual/Spotify cover
647+
music_dir = self.config.get("music_dir", "")
648+
if not music_dir:
649+
return
650+
cover_path = generate_playlist_cover(pl, music_dir)
651+
if cover_path:
652+
pl.cover_path = cover_path
653+
self._save()
654+
self._build_detail_for(self._detail_playlist_index)
655+
self._rebuild_grid()
656+
641657
def _on_add_tracks(self, *args):
642658
if self._detail_playlist_index < 0:
643659
return
@@ -648,6 +664,7 @@ def add_tracks(tracks):
648664
pl.tracks.append(t)
649665
pl.last_modified = time.time()
650666
self._save()
667+
self._maybe_regenerate_cover(pl)
651668
self._build_detail_for(self._detail_playlist_index)
652669
self._rebuild_grid()
653670

@@ -743,9 +760,11 @@ def _add_imported(self, pl):
743760
if existing:
744761
existing[0].tracks = pl.tracks
745762
existing[0].last_modified = time.time()
763+
pl = existing[0]
746764
else:
747765
self._playlists.append(pl)
748766
self._save()
767+
self._maybe_regenerate_cover(pl)
749768
self._rebuild_grid()
750769

751770
def _on_import_spotify(self, *args):

‎skimmer/spotify_import.py‎

Lines changed: 90 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -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}")

‎tests/test_spotify_import.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import os
2+
import tempfile
3+
4+
import pytest
5+
from beets.library import Item, Library
6+
7+
from skimmer.spotify_import import SpotifyImporter, SpotifyImportError
8+
9+
10+
def _tmp_lib():
11+
tmp = tempfile.mkdtemp()
12+
db = os.path.join(tmp, "b.db")
13+
music = os.path.join(tmp, "music")
14+
os.makedirs(music, exist_ok=True)
15+
lib = Library(db, directory=music)
16+
return tmp, music, lib
17+
18+
19+
def _write_audio(path):
20+
os.makedirs(os.path.dirname(path), exist_ok=True)
21+
with open(path, "wb") as f:
22+
f.write(b"\x00" * 128)
23+
24+
25+
def test_cleanup_orphans_removes_unindexed_files():
26+
_t, music, lib = _tmp_lib()
27+
orphan = os.path.join(music, "Orphan Artist", "Orphan Album", "orphan.mp3")
28+
_write_audio(orphan)
29+
importer = SpotifyImporter({})
30+
importer._cleanup_orphans(lib, music)
31+
assert not os.path.exists(orphan)
32+
33+
34+
def test_cleanup_orphans_keeps_indexed_files():
35+
_t, music, lib = _tmp_lib()
36+
known = os.path.join(music, "Artist", "Album", "track.mp3")
37+
_write_audio(known)
38+
item = Item(path=os.fsencode(known))
39+
lib.add(item)
40+
importer = SpotifyImporter({})
41+
importer._cleanup_orphans(lib, music)
42+
assert os.path.exists(known)
43+
44+
45+
def test_cleanup_orphans_ignores_partial_files():
46+
_t, music, lib = _tmp_lib()
47+
partial = os.path.join(music, ".track.mp3.partial")
48+
_write_audio(partial)
49+
importer = SpotifyImporter({})
50+
importer._cleanup_orphans(lib, music)
51+
assert os.path.exists(partial)
52+
53+
54+
def test_download_tracks_aborts_without_lib():
55+
importer = SpotifyImporter({})
56+
with pytest.raises(SpotifyImportError):
57+
importer._download_tracks([{"artist": "A", "title": "T"}], "/tmp", None, [])

0 commit comments

Comments
 (0)