diff --git a/deezspot/libutils/librespot.py b/deezspot/libutils/librespot.py index 40f7fbb..91366a2 100644 --- a/deezspot/libutils/librespot.py +++ b/deezspot/libutils/librespot.py @@ -278,6 +278,23 @@ class LibrespotClient: return None return f"https://i.scdn.co/image/{util.bytes_to_hex(file_id)}" + def _get_playlist_picture_url(self, attrs: Any) -> Optional[str]: + pic = getattr(attrs, "picture", b"") if attrs else b"" + try: + if not pic: + return None + data = bytes(pic) + image_id: Optional[bytes] = None + if len(data) >= 26 and data[0] == 0xAB and data[1:4] == b"gpl" and data[4:6] == b"\x00\x00": + image_id = data[6:26] + elif len(data) >= 20: + image_id = data[:20] + if image_id: + return f"https://i.scdn.co/image/{util.bytes_to_hex(image_id)}" + except Exception: + pass + return None + @staticmethod def _split_countries(countries: str) -> List[str]: if not countries: @@ -531,10 +548,12 @@ class LibrespotClient: picture_bytes = getattr(attrs, "picture", b"") if attrs else b"" images: List[Dict[str, Any]] = [] - if isinstance(picture_bytes, (bytes, bytearray)) and len(picture_bytes) in (16, 20): - url = self._image_url_from_file_id(picture_bytes) - if url: - images.append({"url": url, "width": 0, "height": 0}) + picture_url: Optional[str] = None + # Derive picture URL from attributes.picture with header-aware parsing + pic_url = self._get_playlist_picture_url(attrs) + if pic_url: + picture_url = pic_url + images.append({"url": pic_url, "width": 0, "height": 0}) owner_username = getattr(p, "owner_username", "") or "" @@ -542,15 +561,16 @@ class LibrespotClient: contents = getattr(p, "contents", None) fetched_tracks: Dict[str, Optional[Dict[str, Any]]] = {} - if include_track_objects and self._session is not None and contents is not None: - to_fetch: List[str] = [] + # Collect all track ids to fetch durations for length computation and, if requested, for expansion + to_fetch: List[str] = [] + if contents is not None: for it in getattr(contents, "items", []): uri = getattr(it, "uri", "") or "" if uri.startswith("spotify:track:"): b62 = uri.split(":")[-1] to_fetch.append(b62) - if to_fetch: - fetched_tracks = self._fetch_track_objects(to_fetch) + if to_fetch and self._session is not None: + fetched_tracks = self._fetch_track_objects(to_fetch) if contents is not None: for it in getattr(contents, "items", []): @@ -609,6 +629,22 @@ class LibrespotClient: "items": items, }) + # Compute playlist length (in seconds) by summing track durations + length_seconds: Optional[int] = None + try: + if to_fetch and fetched_tracks: + total_ms = 0 + for b62 in to_fetch: + obj = fetched_tracks.get(b62) + if obj is None: + continue + dur = obj.get("duration_ms") + if isinstance(dur, int) and dur > 0: + total_ms += dur + length_seconds = (total_ms // 1000) if total_ms > 0 else 0 + except Exception: + length_seconds = None + rev_bytes = getattr(p, "revision", b"") if hasattr(p, "revision") else b"" snapshot_b64 = base64.b64encode(rev_bytes).decode("ascii") if rev_bytes else None @@ -616,7 +652,7 @@ class LibrespotClient: "name": name or None, "description": description or None, "collaborative": collaborative or None, - "images": images or None, + "picture": picture_url or None, "owner": self._prune_empty({ "id": owner_username, "type": "user", @@ -625,6 +661,7 @@ class LibrespotClient: "display_name": owner_username or None, }), "snapshot_id": snapshot_b64, + "length": length_seconds, "tracks": tracks_obj, "type": "playlist", } diff --git a/deezspot/models/librespot/playlist.py b/deezspot/models/librespot/playlist.py index 6e8751d..a8175be 100644 --- a/deezspot/models/librespot/playlist.py +++ b/deezspot/models/librespot/playlist.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from typing import Optional, Dict, Any, List, Union -from .types import ExternalUrls, Image, _str, _int +from .types import ExternalUrls, _str, _int from .track import Track as TrackModel @@ -167,9 +167,10 @@ class Playlist: name: Optional[str] = None description: Optional[str] = None collaborative: Optional[bool] = None - images: Optional[List[Image]] = None + picture: Optional[str] = None owner: Owner = field(default_factory=Owner) snapshot_id: Optional[str] = None + length: Optional[int] = None tracks: TracksPage = field(default_factory=lambda: TracksPage(items=[])) type: str = "playlist" @@ -177,18 +178,14 @@ class Playlist: def from_dict(obj: Any) -> "Playlist": if not isinstance(obj, dict): return Playlist(tracks=TracksPage(items=[])) - imgs: List[Image] = [] - for im in obj.get("images", []) or []: - im_obj = Image.from_dict(im) - if im_obj: - imgs.append(im_obj) return Playlist( name=_str(obj.get("name")), description=_str(obj.get("description")), collaborative=bool(obj.get("collaborative")) if obj.get("collaborative") is not None else None, - images=imgs or None, + picture=_str(obj.get("picture")), owner=Owner.from_dict(obj.get("owner", {})), snapshot_id=_str(obj.get("snapshot_id")), + length=_int(obj.get("length")), tracks=TracksPage.from_dict(obj.get("tracks", {})), type=_str(obj.get("type")) or "playlist", ) @@ -198,9 +195,10 @@ class Playlist: "name": self.name, "description": self.description, "collaborative": self.collaborative, - "images": [im.to_dict() for im in (self.images or [])], + "picture": self.picture, "owner": self.owner.to_dict(), "snapshot_id": self.snapshot_id, + "length": self.length, "tracks": self.tracks.to_dict(), "type": self.type, }