from qrcode.image.pil import PilImage from qrcode.image.pure import PyPNGImage import spotipy from spotipy.oauth2 import SpotifyClientCredentials from pathlib import Path import json import qrcode from io import BytesIO import base64 from PIL import Image from typing import List, Dict, Any import pandas as pd import re from enum import Enum MAX_ALBUM_IMG_SIZE = 512 class CardStyle(str, Enum): FULL_ALBUM_COVER = "full album cover" MINIMAL = "minimal" PRINT_FRIENDLY_BW = "dithered bw album cover (slow, print friendly)" class Hipsterfy(object): def __init__(self, client_id, client_secret): self.client_id = client_id self.client_secret = client_secret self.sp = self.authenticate_spotify() def authenticate_spotify(self): """ Authenticate with Spotify using Client Credentials Flow. """ credentials = SpotifyClientCredentials(client_id=self.client_id, client_secret=self.client_secret) sp = spotipy.Spotify(client_credentials_manager=credentials) return sp class HipsterfyPlaylistItem(object): def _generate_qr_code_base64(self, url): qr: qrcode.QRCode[PilImage | PyPNGImage] = qrcode.QRCode(box_size=6, border=6) qr.add_data(url) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") buffered = BytesIO() img.save(buffered, format="PNG") img_str = base64.b64encode(buffered.getvalue()).decode() img_html = f'QR Code' return img_html def generate_hipsterfy_front_card(self, include_preview:bool = False, card_style:CardStyle = CardStyle.FULL_ALBUM_COVER): # generates the square front card only with the centered qr code preview_html = self.embed_html if include_preview else "" if not self._qr_html: return "" card_style = """ width: 300px; height: 300px; border: 1px dashed #bbb; /* sehr dezente, gestrichelte Linie */ border-radius: 18px; display: flex; align-items: center; justify-content: center; background: none; margin: 18px; box-shadow: none; transition: none; """ return f"""
{self._qr_html} {preview_html}
Scan Code to play Sample
""" def generate_hipsterfy_back_card( self, primary_property, secondary_property, additional_properties: List[str] = None, card_style: CardStyle = CardStyle.FULL_ALBUM_COVER ): additional_html = "" if additional_properties: for prop in additional_properties: value = getattr(self, f"{prop}", "N/A") additional_html += f"
{prop.replace('_', ' ').title()}: {value}
" # Style für die innere Box (Textfeld) inner_box_style_full = """ width: 250px; height: 250px; margin: auto; background: rgba(0,0,0,0.35); border-radius: 18px; padding: 24px 18px 18px 18px; display: flex; flex-direction: column; justify-content: center; align-items: center; box-sizing: border-box; position: relative; z-index: 1; backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); """ inner_box_style_minimal = """ width: 250px; height: 250px; margin: auto; background: #fff; border-radius: 18px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); padding: 24px 18px 18px 18px; display: flex; flex-direction: column; justify-content: center; align-items: center; box-sizing: border-box; position: relative; z-index: 1; """ if CardStyle(card_style) == CardStyle.PRINT_FRIENDLY_BW and self.album_images and len(self.album_images) > 0: album_b64 = self.get_bw_album_img_base64() outer_style = f""" width: 300px; height: 300px; border: 2px solid #222; border-radius: 18px; display: flex; flex-direction: column; justify-content: center; align-items: center; background: url('data:image/png;base64,{album_b64}') center center/cover no-repeat; background-blend-mode: multiply; background-color: #fff; margin: 18px; box-shadow: none; color: #222; overflow: hidden; position: relative; """ inner_box_style = """ width: 250px; height: 250px; margin: auto; background: rgba(255,255,255,0.9); border-radius: 18px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); padding: 18px 12px 12px 12px; display: flex; flex-direction: column; justify-content: center; align-items: center; box-sizing: border-box; position: relative; z-index: 1; """ primary_color = "#222" secondary_color = "#444" additional_html = additional_html.replace("color:#fff", f"color:{secondary_color}") additional_html = additional_html.replace("color:#e0e0e0", f"color:{secondary_color}") return f"""

{getattr(self, secondary_property)}

{getattr(self, primary_property)}

{additional_html}
""" # FULL_ALBUM_COVER: Albumcover als Hintergrund, weiße Schrift, abgedunkelte Box if CardStyle(card_style) == CardStyle.FULL_ALBUM_COVER and self.album_images and len(self.album_images) > 0: album_url = self.album_images[0]['url'] outer_style = f""" width: 300px; height: 300px; border: 1.5px solid #222; border-radius: 18px; display: flex; flex-direction: column; justify-content: center; align-items: center; background: linear-gradient(135deg, rgba(30,30,30,0.65) 60%, rgba(0,0,0,0.85) 100%), url('{album_url}') center center/cover no-repeat; margin: 18px; box-shadow: 0 6px 24px rgba(0,0,0,0.13), 0 1.5px 4px rgba(0,0,0,0.07); color: #fff; overflow: hidden; position: relative; transition: box-shadow 0.2s; """ text_color = "#fff" primary_color = "#fff" secondary_color = "#e0e0e0" inner_box_style = inner_box_style_full # MINIMAL: Weißer Hintergrund, schwarze Schrift, nur Kontur else: outer_style = """ width: 300px; height: 300px; border: 2px solid #222; border-radius: 18px; display: flex; flex-direction: column; justify-content: center; align-items: center; background: #fff; margin: 18px; box-shadow: none; color: #222; overflow: hidden; position: relative; transition: box-shadow 0.2s; """ inner_box_style = inner_box_style_minimal additional_html = additional_html.replace("color:#fff", "color:#222") primary_color = "#222" secondary_color = "#444" return f"""

{getattr(self, secondary_property)}

{getattr(self, primary_property)}

{additional_html}
""" @staticmethod def get_available_properties() -> List[str]: """ Returns a list of all available properties for the HipsterfyPlaylistItem. This can be used to dynamically generate the back card with all properties. """ return [ 'title', 'artists', 'album', 'release_date', 'release_year', 'duration', 'popularity', 'explicit', 'link', 'track_id', 'preview_url', 'track_number', 'disc_number', 'available_markets', 'is_local', 'external_ids', 'uri', 'album_images', 'album_type', 'album_release_date_precision', 'album_total_tracks', 'album_id', 'album_uri' ] def __init__(self, playlist_item:dict): self._raw_data = playlist_item self._title = self._raw_data['name'] self._artists = ', '.join([artist['name'] for artist in self._raw_data['artists']]) self._album = self._raw_data['album']['name'] self._release_date = self._raw_data['album']['release_date'] self._duration_ms = self._raw_data['duration_ms'] self._duration_min = round(self._duration_ms / 60000, 2) self._link = self._raw_data['external_urls']['spotify'] self._track_id = self._raw_data['id'] self._popularity = self._raw_data.get('popularity') self._explicit = self._raw_data.get('explicit') self._preview_url = self._raw_data.get('preview_url') self._track_number = self._raw_data.get('track_number') self._disc_number = self._raw_data.get('disc_number') self._available_markets = self._raw_data.get('available_markets') self._is_local = self._raw_data.get('is_local') self._external_ids = self._raw_data.get('external_ids') self._uri = self._raw_data.get('uri') self._album_images = self._raw_data['album'].get('images') self._album_type = self._raw_data['album'].get('album_type') self._album_release_date_precision = self._raw_data['album'].get('release_date_precision') self._album_total_tracks = self._raw_data['album'].get('total_tracks') self._album_id = self._raw_data['album'].get('id') self._album_uri = self._raw_data['album'].get('uri') self._embed_html = "" self._qr_html = "" if self._track_id: self._embed_html = f""" """ self._qr_html = self._generate_qr_code_base64(f"https://open.spotify.com/embed/track/{self._track_id}") @property def title(self): return self._title @property def artists(self): return self._artists @property def album(self): return self._album @property def release_date(self): return self._release_date @property def release_year(self): return self._release_date.split('-')[0] if self._release_date else None @property def duration(self): return self._duration_min @property def popularity(self): return self._popularity @property def explicit(self): return self._explicit @property def link(self): return self._link @property def track_id(self): return self._track_id @property def preview_url(self): return self._preview_url @property def track_number(self): return self._track_number @property def disc_number(self): return self._disc_number @property def available_markets(self): return self._available_markets @property def is_local(self): return self._is_local @property def external_ids(self): return self._external_ids @property def uri(self): return self._uri @property def album_images(self): return self._album_images @property def album_type(self): return self._album_type @property def album_release_date_precision(self): return self._album_release_date_precision @property def album_total_tracks(self): return self._album_total_tracks @property def album_id(self): return self._album_id @property def album_uri(self): return self._album_uri @property def embed_html(self): return self._embed_html @property def qr_html(self): return self._qr_html def get_bw_album_img_html(self): b64 = self.get_bw_album_img_base64() if b64: return f'Album Art BW' return "" def get_bw_album_img_base64(self): if self.album_images and len(self.album_images) > 0: url = self.album_images[0]['url'] try: import requests from PIL import Image, ImageOps, ImageEnhance response = requests.get(url) img = Image.open(BytesIO(response.content)).convert("L") img.thumbnail((MAX_ALBUM_IMG_SIZE, MAX_ALBUM_IMG_SIZE)) img = ImageOps.autocontrast(img) img = ImageEnhance.Contrast(img).enhance(2.0) # Kontrast erhöhen img = img.convert("1", dither=Image.FLOYDSTEINBERG, colors=12) # Stärkeres Dithering buffer = BytesIO() img.save(buffer, format="PNG") img_str = base64.b64encode(buffer.getvalue()).decode() return img_str except Exception: return "" return "" class HipsterfyPlaylist(object): def __init__(self, playlist_uri, hipsterfy:Hipsterfy): self._playlist_uri = playlist_uri self._hipsterfy = hipsterfy self._tracks_data = [] self._load_playlist() def _load_playlist(self): """Load the playlist data from Spotify and extract track information. """ playlist_id = self._playlist_uri.split("/")[-1].split("?")[0] results = [] last_page_reached = False offset = 0 while not last_page_reached: page_results = self._hipsterfy.sp.playlist_items(playlist_id, additional_types=['track'], limit=100, offset=offset) tracks = [item['track'] for item in page_results['items'] if item['track']] if not tracks: last_page_reached = True else: results.extend(page_results['items']) offset += len(page_results['items']) self._tracks_data = [HipsterfyPlaylistItem(item['track']) for item in results] def get_tracks_data(self) -> List[HipsterfyPlaylistItem]: """Returns the list of HipsterfyPlaylistItem objects representing the tracks in the playlist. """ return self._tracks_data