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
from solid import *
from solid.utils import *
import numpy as np
import trimesh
import cairosvg
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'
'
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}
"""
def create_text_mesh_svg(self, text, font_size=100, extrude_height=1.0, font_family="Arial"):
# Define a rough bounding box size (oversized is OK, it will crop to path later)
svg_width = font_size * len(text) * 0.6 # heuristic width
svg_height = font_size * 1.5 # little padding below
# 1. Create SVG with size attributes!
svg_template = f'''
'''
# 2. Use CairoSVG to convert to SVG with outlines (in-memory)
outlined_svg = BytesIO()
cairosvg.svg2svg(bytestring=svg_template.encode('utf-8'), write_to=outlined_svg)
outlined_svg.seek(0)
# 3. Load the SVG Path into Trimesh
path = trimesh.load_path(outlined_svg, "svg")
# 4. Get closed polygons
polygons = path.polygons_full
if len(polygons) == 0:
raise ValueError("No polygons found in SVG. Make sure the font is available.")
# 5. Extrude into 3D meshes
meshes = []
for p in polygons:
mesh = trimesh.creation.extrude_polygon(p, height=extrude_height)
# Fix potential non-watertight edges
mesh = mesh.copy().process(validate=True)
if not mesh.is_watertight:
print("Warning: Mesh is not watertight. Trying to fix...")
mesh = mesh.convex_hull # Hack: force watertight by convex hull
meshes.append(mesh)
text_mesh = trimesh.util.concatenate(meshes)
text_mesh.apply_translation(-text_mesh.centroid)
# Final safety net
if not text_mesh.is_watertight:
print("Final Warning: Text mesh still not watertight after fix.")
return text_mesh
def generate_3d_qr_card(
self,
primary_property,
secondary_property,
additional_properties: List[str] = None,
card_size=60,
card_thickness=3,
qr_relief_depth=0.5, # Tiefe der Vertiefung
qr_margin=8
):
"""
Generates a 3D card (STL as bytes) with a sunken QR code (engraved).
Returns: STL file as bytes.
"""
qr = qrcode.QRCode(box_size=1, border=0)
qr.add_data(self.link)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white").convert("1")
arr = np.array(img)
h, w = arr.shape
pixel_size = (card_size - 2 * qr_margin) / w
# Grundplatte
base = trimesh.creation.box(extents=(card_size, card_size, card_thickness))
base.apply_translation((card_size/2, card_size/2, card_thickness/2))
# QR-Vertiefung (nur die weißen Felder bleiben stehen, schwarze werden "eingesunken")
cutouts_xa_ya = []
cutouts_xa_yb = []
cutouts_xb_ya = []
cutouts_xb_yb = []
for y in range(h):
for x in range(w):
if arr[y, x] == 0: # Schwarzes Pixel → wird vertieft
px = qr_margin + x * pixel_size + pixel_size/2
py = qr_margin + (h - y - 1) * pixel_size + pixel_size/2
cube = trimesh.creation.box(extents=(pixel_size * 1.00001, pixel_size * 1.00001, qr_relief_depth))
cube.apply_translation((px, py, card_thickness - qr_relief_depth/2))
if y % 2 == 0:
if x % 2 == 0:
cutouts_xa_ya.append(cube)
else:
cutouts_xa_yb.append(cube)
else:
if x % 2 == 0:
cutouts_xb_ya.append(cube)
else:
cutouts_xb_yb.append(cube)
card = base
if cutouts_xa_ya:
cutout_xa_ya = trimesh.util.concatenate(cutouts_xa_ya)
card = card.difference(cutout_xa_ya)
if cutouts_xa_yb:
cutout_xa_yb = trimesh.util.concatenate(cutouts_xa_yb)
card = card.difference(cutout_xa_yb)
if cutouts_xb_ya:
cutout_xb_ya = trimesh.util.concatenate(cutouts_xb_ya)
card = card.difference(cutout_xb_ya)
if cutouts_xb_yb:
cutout_xb_yb = trimesh.util.concatenate(cutouts_xb_yb)
card = card.difference(cutout_xb_yb)
# engrave the text to the lower card side
lines = []
if primary_property:
lines.append(str(getattr(self, primary_property, "")))
if secondary_property:
lines.append(str(getattr(self, secondary_property, "")))
if additional_properties:
for prop in additional_properties:
value = getattr(self, prop, "")
if value:
lines.append(f"{prop.replace('_', ' ').title()}: {value}")
text_depth = qr_relief_depth
text_size = card_size * 0.09 # Schriftgröße relativ zur Karte
# Combine into one mesh
text_mesh = self.create_text_mesh_svg("\n".join(lines), font_size=text_size, extrude_height=text_depth, font_family="Arial")
# Center the text mesh
text_mesh.apply_translation((-text_mesh.centroid[0], -text_mesh.centroid[1], 0))
# Position the text mesh on the card
text_mesh.apply_translation((card_size / 2, card_size / 2, card_thickness - text_depth / 2))
# Text von der Karte abziehen (gravieren)
card = card.difference(text_mesh, check_volume=True)
## round corners
radius = 5
corners = [
(radius, radius, 0),
(card_size - radius, radius, 0),
(card_size - radius, card_size - radius, 0),
(radius, card_size - radius, 0)
]
plate_offsets = [
(radius / 2, radius / 2, 0),
(card_size - radius / 2, radius / 2, 0),
(card_size - radius / 2, card_size - radius / 2, 0),
(radius / 2, card_size - radius / 2, 0)
]
for i, corner in enumerate(corners ):
plate = trimesh.creation.box(extents=(radius, radius, card_thickness))
plate.apply_translation((plate_offsets[i][0], plate_offsets[i][1], card_thickness / 2))
cylinder = trimesh.creation.cylinder(radius=radius, height=card_thickness, sections=64)
cylinder.apply_translation((corner[0], corner[1], corner[2] + card_thickness / 2))
inverse_corner = plate.difference(cylinder)
card = card.difference(inverse_corner, check_volume=True)
return card.export(file_type='stl')
@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'
'
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