11 Commits

Author SHA1 Message Date
6872056901 working on 3d cards 2025-08-06 16:26:26 +02:00
7c24d9a7c5 fix mobile redirect 2025-08-04 17:24:39 +02:00
deb43a47f7 add mobile scanner 2025-08-04 17:15:39 +02:00
a71428dc57 add mobile scanner 2025-08-04 17:14:34 +02:00
b954b93dd6 add small player widget 2025-08-04 16:43:46 +02:00
b6e90be86f v0.2.3 2025-07-21 22:06:09 +02:00
0ab0a00a77 fix rate limit 2025-07-21 21:48:01 +02:00
9bb48dcc47 increase limit 2025-07-21 21:29:27 +02:00
8a3b87d242 bump version 2025-07-21 21:15:26 +02:00
ee078d9da9 magic dithering 2025-07-21 21:14:40 +02:00
b4420e334d small improvements 2025-07-21 20:44:30 +02:00
5 changed files with 820 additions and 112 deletions

View File

@ -12,6 +12,21 @@ 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):
@ -42,16 +57,15 @@ class HipsterfyPlaylistItem(object):
return img_html
def generate_hipsterfy_front_card(self, include_preview:bool = False, print_mode:bool = False):
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 ""
if print_mode:
# Minimalistisches Layout für Druck: kein Hintergrund, kein Schatten, kein Rahmen
card_style = """
width: 300px; height: 300px;
border: 1.5px;
border: 1px dashed #bbb; /* sehr dezente, gestrichelte Linie */
border-radius: 18px;
display: flex;
align-items: center;
@ -61,19 +75,7 @@ class HipsterfyPlaylistItem(object):
box-shadow: none;
transition: none;
"""
else:
card_style = """
width: 300px; height: 300px;
border: 1.5px solid #222;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f9f9f9 60%, #e0e0e0 100%);
margin: 18px;
box-shadow: 0 6px 24px rgba(0,0,0,0.13), 0 1.5px 4px rgba(0,0,0,0.07);
transition: box-shadow 0.2s;
"""
return f"""
<div style="{card_style}">
<div style="text-align: center;">
@ -89,28 +91,106 @@ class HipsterfyPlaylistItem(object):
</div>
"""
def generate_hipsterfy_back_card(self, primary_property, secondary_property, additional_properties:List[str]=None, album_cover_gb:bool = True, print_mode:bool = False):
# generates the back card with primary and secondary property and additional properties
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"<div style='margin: 4px 0; color:#fff;'><strong style='color:#fff;'>{prop.replace('_', ' ').title()}:</strong> <span style='color:#fff;'>{value}</span></div>"
additional_html += f"<div style='margin: 4px 0; color:#e0e0e0;'><strong style='color:#fff;'>{prop.replace('_', ' ').title()}:</strong> <span style='color:#fff;'>{value}</span></div>"
# Albumcover als Hintergrund
album_bg = ""
if album_cover_gb and self.album_images and len(self.album_images) > 0:
album_url = self.album_images[0]['url']
album_bg = f"""
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;
# 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;
"""
else:
album_bg = "background: #f9f9f9;"
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"""
<div style="
<div style="{outer_style}">
<div style="{inner_box_style}">
<p style="margin: 0 0 8px 0; font-size: 1.1em; color: {secondary_color};">{getattr(self, secondary_property)}</p>
<h3 style="margin: 0 0 8px 0; font-size: 1.25em; color: {primary_color};">{getattr(self, primary_property)}</h3>
{additional_html}
</div>
</div>
"""
# 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;
@ -118,22 +198,212 @@ class HipsterfyPlaylistItem(object):
flex-direction: column;
justify-content: center;
align-items: center;
{album_bg}
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;
">
<div style="text-align: center; padding: 24px 18px 18px 18px; background: rgba(0,0,0,0.35); border-radius: 12px;">
<p style="margin: 0 0 12px 0; font-size: 1.1em; color: #e0e0e0; text-shadow: 0 1px 4px #000;">{getattr(self, secondary_property)}</p>
<h3 style="margin: 0 0 8px 0; font-size: 1.35em; color: #fff; text-shadow: 0 2px 8px #000;">{getattr(self, primary_property)}</h3>
"""
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"""
<div style="{outer_style}">
<div style="{inner_box_style}">
<p style="margin: 0 0 12px 0; font-size: 1.1em; color: {secondary_color}; text-shadow: none;">{getattr(self, secondary_property)}</p>
<h3 style="margin: 0 0 8px 0; font-size: 1.35em; color: {primary_color}; text-shadow: none;">{getattr(self, primary_property)}</h3>
{additional_html}
</div>
</div>
"""
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'''<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{svg_width}px" height="{svg_height}px" viewBox="0 0 {svg_width} {svg_height}">
<text x="0" y="{font_size}" font-family="{font_family}" font-size="{font_size}px">{text}</text>
</svg>'''
# 2. Use CairoSVG to convert to SVG with <path> 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]:
"""
@ -259,6 +529,32 @@ class HipsterfyPlaylistItem(object):
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'<img src="data:image/png;base64,{b64}" style="width:{MAX_ALBUM_IMG_SIZE}px;height:{MAX_ALBUM_IMG_SIZE}px;border-radius:12px;box-shadow:0 2px 8px #aaa;margin-bottom:8px;" alt="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):
@ -270,9 +566,20 @@ class HipsterfyPlaylist(object):
"""Load the playlist data from Spotify and extract track information.
"""
playlist_id = self._playlist_uri.split("/")[-1].split("?")[0]
results = self._hipsterfy.sp.playlist_items(playlist_id, additional_types=['track'])
self._tracks_data = [HipsterfyPlaylistItem(item['track']) for item in results['items'] if item['track']]
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

View File

@ -1,5 +1,7 @@
import importlib.resources
import panel as pn
import argparse
import os
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist
from hipsterfy.panel_page import create_panel_page
@ -9,22 +11,25 @@ def parse_args():
parser.add_argument('--spotify_client_id', type=str, required=True, help='Spotify Client ID')
parser.add_argument('--spotify_client_secret', type=str, required=True, help='Spotify Client Secret')
parser.add_argument('--port', type=int, default=5006, help='Port to run the Panel app on')
parser.add_argument('--root_url', type=str, default=None, help='Root URL for the Panel app (optional)')
return parser.parse_args()
def panel_main():
args = parse_args()
hipsterfy = Hipsterfy(args.spotify_client_id, args.spotify_client_secret)
playlist_uri = 'https://open.spotify.com/playlist/5grJs3PKyLE0cL5NYmkGIF'
app = lambda: create_panel_page(hipsterfy, playlist_uri, args.root_url)
# Create a Panel app
pn.extension()
# Example usage of Hipsterfy
playlist_uri = 'https://open.spotify.com/playlist/5grJs3PKyLE0cL5NYmkGIF' # Replace with your playlist URI
app = lambda: create_panel_page(hipsterfy, playlist_uri)
# Serve the Panel app
pn.serve(app, port=args.port, websocket_origin='*', show=False)
static_dir = os.path.join(os.path.dirname(__file__), "static")
pn.serve(
{
"/": app,
},
static_dirs={"qr": static_dir},
port=args.port,
websocket_origin='*',
show=False,
)
if __name__ == "__main__":
panel_main()

View File

@ -1,43 +1,77 @@
import panel as pn
import base64
from io import BytesIO
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem, CardStyle
import qrcode
import traceback
import subprocess
pn.extension("filedownload", "notifications", "location")
def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Template:
def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None, root_url = None) -> pn.Template:
# create widgets
playlist_uri = pn.widgets.TextInput(name='Playlist URI', value=playlist_uri or '', placeholder='Enter Spotify Playlist URI')
primary_item = pn.widgets.Select(name='Primary Item', options=HipsterfyPlaylistItem.get_available_properties(), value="release_year")
secondary_item = pn.widgets.Select(name='Secondary Item', options=HipsterfyPlaylistItem.get_available_properties(), value="title")
additional_items = pn.widgets.MultiChoice(name='Additional Items', options=HipsterfyPlaylistItem.get_available_properties(), value=["artists", "album", "popularity"])
enable_album_art = pn.widgets.Checkbox(name='Enable Album Art', value=True)
print_mode_in_preview = pn.widgets.Checkbox(name='Print Mode in preview', value=True)
cards_per_row_widget = pn.widgets.IntSlider(name="Cards per Row in print", start=1, end=6, value=4, step=1)
card_style = pn.widgets.Select(name='Card Style', options=[style.value for style in CardStyle], value=CardStyle.FULL_ALBUM_COVER.value)
cards_per_row_widget = pn.widgets.IntSlider(name="Cards per Row in print", start=1, end=8, value=4, step=1)
mode_selector = pn.widgets.RadioButtonGroup(
name="Card Generation Mode (2D for printing or 3D to produce stl files for 3D printing)",
options=["2D", "3D"],
value="2D",
button_type="primary",
button_style="outline",
sizing_mode='stretch_width'
)
# add a callback that hides the card style widget if 3D mode is selected
def toggle_card_style_visibility(event):
if mode_selector.value == "3D":
card_style.visible = False
else:
card_style.visible = True
mode_selector.param.watch(toggle_card_style_visibility, 'value')
pn.state.location.sync(playlist_uri, {"value": "playlist_uri"})
pn.state.location.sync(primary_item, {"value": "primary_item"})
pn.state.location.sync(secondary_item, {"value": "secondary_item"})
pn.state.location.sync(additional_items, {"value": "additional_items"})
pn.state.location.sync(enable_album_art, {"value": "enable_album_art"})
pn.state.location.sync(card_style, {"value": "card_style"})
pn.state.location.sync(cards_per_row_widget, {"value": "cards_per_row"})
pn.state.location.sync(mode_selector, {"value": "mode_selector"})
create_preview_button = pn.widgets.Button(name='Create Preview', button_type='primary')
create_preview_button = pn.widgets.Button(name='Parse Playlist and Generate Cards', button_type='primary', sizing_mode='stretch_width')
front_cards_column = pn.Column(sizing_mode='stretch_width')
back_cards_column = pn.Column(sizing_mode='stretch_width')
# FileDownload-Buttons (werden erst nach Preview-Generierung angezeigt)
download_front_html_button = pn.widgets.FileDownload(
label="Download Front Cards (HTML)", button_type="success", visible=False, filename="hipsterfy_front_cards.html"
label="Download Front Cards (HTML)", button_type="success", visible=False, filename="hipsterfy_front_cards.html", sizing_mode='stretch_width'
)
download_back_html_button = pn.widgets.FileDownload(
label="Download Back Cards (HTML)", button_type="success", visible=False, filename="hipsterfy_back_cards.html"
label="Download Back Cards (HTML)", button_type="success", visible=False, filename="hipsterfy_back_cards.html", sizing_mode='stretch_width'
)
playlist_instructions = pn.pane.HTML(
"""
<div>
<h3>Playlist Instructions</h3>
<p>Select any Spotify playlist and click the "Parse Playlist and Generate Cards" button to generate the cards.</p>
<p><b>Note:</b> The playlist must be public accessible. Also dynamically individualized playlists (unfortunately a lot of spotify official playlists) Will not work.</p>
</div>
""",
sizing_mode='stretch_width'
)
print_instructions = pn.pane.HTML(
"""
<div style="background-color: #f0f0f0; padding: 12px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div>
<h3>Print Instructions</h3>
<p>To print the cards:</p>
<ol>
@ -95,53 +129,140 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
download_back_html_button.callback = lambda: BytesIO(generate_html(cards_back, row_reverse=True))
download_back_html_button.visible = True
download_front_html_button.filename = f"hipsterfy_front_cards.html"
download_back_html_button.filename = f"hipsterfy_back_cards.html"
print_instructions.visible = True # Show print instructions after generating preview
pn.state.notifications.success("Download buttons updated successfully!. Download and print them from the sidebar")
def create_preview(event):
try:
pn.state.notifications.info("Generating preview for playlist...")
# clear previous cards
front_cards_column.clear()
back_cards_column.clear()
front_cards_column.loading = True
back_cards_column.loading = True
front_cards_column.append(pn.pane.HTML("<h2>Front Cards</h2>", sizing_mode='stretch_width'))
back_cards_column.append(pn.pane.HTML("<h2>Back Cards</h2>", sizing_mode='stretch_width'))
# get playlist URI
uri = playlist_uri.value.strip()
if not uri:
pn.state.notifications.error("Please enter a valid Spotify Playlist URI.")
return
# create HipsterfyPlaylist instance
hipsterfy_playlist = HipsterfyPlaylist(uri, hipsterfy)
new_back_cards = []
new_front_cards = []
# generate front and back cards for each item in the playlist
if mode_selector.value == "2D":
# 2D-HTML wie gehabt
for item in hipsterfy_playlist.get_tracks_data():
front_card = item.generate_hipsterfy_front_card(include_preview=False, print_mode=print_mode_in_preview.value)
front_card = item.generate_hipsterfy_front_card(include_preview=False, card_style=CardStyle(card_style.value))
back_card = item.generate_hipsterfy_back_card(
primary_property=primary_item.value,
secondary_property=secondary_item.value,
additional_properties=additional_items.value,
album_cover_gb=enable_album_art.value,
print_mode=print_mode_in_preview.value
card_style=CardStyle(card_style.value)
)
front_cards_column.append(pn.pane.HTML(front_card, sizing_mode='stretch_width'))
back_cards_column.append(pn.pane.HTML(back_card, sizing_mode='stretch_width'))
pn.state.notifications.success("Preview generated successfully!")
pn.state.notifications.info("Prepare to download the cards as HTML files...")
new_front_cards.append(pn.pane.HTML(front_card, sizing_mode='stretch_width'))
new_back_cards.append(pn.pane.HTML(back_card, sizing_mode='stretch_width'))
for card in new_front_cards:
front_cards_column.append(card)
for card in new_back_cards:
back_cards_column.append(card)
update_download_buttons()
pn.state.notifications.success("2D preview generated successfully!")
else:
stl_buffers = []
for idx, item in enumerate(hipsterfy_playlist.get_tracks_data()):
stl_bytes = item.generate_3d_qr_card(
primary_property=primary_item.value,
secondary_property=secondary_item.value,
additional_properties=additional_items.value,
)
stl_filename = f"hipsterfy_card_{idx+1}.stl"
stl_buffers.append((stl_filename, stl_bytes))
front_cards_column.append(
pn.pane.Markdown(f"**3D Card {idx+1}:** `{stl_filename}` generated.", sizing_mode='stretch_width')
)
import zipfile
def zip_stls():
try:
buffer = BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
for fname, stl_bytes in stl_buffers:
zf.writestr(fname, stl_bytes)
buffer.seek(0)
return buffer
except Exception as e:
print("Error creating ZIP file:", e)
traceback.print_exc()
pn.state.notifications.error(f"Error creating ZIP file: {str(e)}", duration=10000)
return None
download_front_html_button.label = "Download 3D Cards (ZIP)"
download_front_html_button.callback = zip_stls
download_front_html_button.visible = True
download_back_html_button.visible = False
download_front_html_button.filename = "hipsterfy_3d_cards.zip"
pn.state.notifications.success("3D STL files generated! Download the ZIP from the sidebar.")
print_instructions.visible = mode_selector.value == "2d"
except Exception as e:
print("Error:", e)
traceback.print_exc()
pn.state.notifications.error(f"Error generating preview: {str(e)}", duration=10000)
finally:
front_cards_column.loading = False
back_cards_column.loading = False
create_preview_button.on_click(create_preview)
# Dynamischer QR-Code für die Player-App
def generate_qr_code_base64(url):
qr = qrcode.QRCode(box_size=4, border=2)
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()
return f'<div style="text-align:center;"><b>Mobile QR-Scanner:</b><br><img src="data:image/png;base64,{img_str}" alt="QR Code" style="margin-top:8px;max-width:200px;"/><br><a href="{url}" target="_blank" style="color:#1DB954">{url}</a></div>'
# Dynamisch: QR-Code aktualisiert sich, wenn die Seite unter einer anderen URL läuft
def player_qr_html():
# pn.state.location.href ist erst nach dem ersten Request gesetzt
if root_url is None:
base_url = pn.state.location.href
# Basis-URL ohne evtl. Pfad/Query
from urllib.parse import urlparse
parsed = urlparse(base_url)
base = f"{parsed.scheme}://{parsed.netloc}"
else:
base = root_url
url = base + "/qr/scan.html"
return generate_qr_code_base64(url)
player_qr_pane = pn.bind(lambda: pn.pane.HTML(player_qr_html(), sizing_mode='stretch_width'))
template = pn.template.FastListTemplate(
title='Hipsterfy Playlist Manager',
title='Hipsterfy',
sidebar=[
playlist_uri, primary_item, secondary_item, additional_items, enable_album_art,
player_qr_pane,
pn.pane.HTML("<h3> Select card generation mode:</h3>", sizing_mode='stretch_width'),
pn.pane.HTML(
"""
<p>In 2D mode, you can print the cards directly from your browser. In 3D mode, STL files will be generated for 3d printing will be generated.</p>
""",
sizing_mode='stretch_width'
),
mode_selector,
pn.layout.Divider(),
playlist_instructions, playlist_uri, primary_item, secondary_item, additional_items, card_style,
cards_per_row_widget,
create_preview_button, print_instructions, download_front_html_button, download_back_html_button
create_preview_button, download_front_html_button, download_back_html_button, print_instructions
],
main=[pn.Row(front_cards_column, back_cards_column, sizing_mode='stretch_width')],
accent_base_color='indigo',

260
hipsterfy/static/scan.html Normal file
View File

@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hipsterfy QR-Scanner</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body {
margin: 0; padding: 0;
font-family: 'Segoe UI', Arial, sans-serif;
background: #181818;
color: #fff;
min-height: 100vh;
width: 100vw;
}
#app {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 100vh;
padding: 1.5em 0.5em 2em 0.5em;
box-sizing: border-box;
width: 100vw;
max-width: 100vw;
}
h2 {
margin-bottom: 0.5em;
font-size: 2em;
letter-spacing: 1px;
text-align: center;
}
#video {
width: 100%;
max-width: 420px;
aspect-ratio: 4/3;
border-radius: 18px;
margin: 1em 0 1.5em 0;
background: #222;
box-shadow: 0 4px 24px #0008;
object-fit: cover;
}
#result {
margin: 0.5em 0 1em 0;
word-break: break-all;
text-align: center;
font-size: 1.08em;
}
#backBtn {
margin: 1.2em 0 1em 0;
padding: 0.9em 2em;
border: none;
border-radius: 10px;
background: #1DB954;
color: #fff;
font-size: 1.15em;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 8px #0004;
display: none;
transition: background 0.2s;
}
#backBtn:hover {
background: #17a74a;
}
#preview {
margin-top: 1em;
width: 100%;
max-width: 420px;
outline: none;
display: none;
}
#embed {
width: 100%;
max-width: 420px;
margin: 1em 0 0 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 170px;
}
#embed iframe {
width: 100%;
min-width: 260px;
max-width: 420px;
height: 170px;
border: none;
border-radius: 16px;
background: #222;
box-shadow: 0 2px 12px #0005;
transition: box-shadow 0.2s;
}
a {
color: #1DB954;
word-break: break-all;
}
@media (max-width: 600px) {
#app {
padding: 0.5em 0.1em 1em 0.1em;
}
#video, #embed, #preview {
max-width: 98vw;
}
#embed iframe {
min-width: 180px;
max-width: 98vw;
}
}
</style>
</head>
<body>
<div id="app">
<h2>Hipsterfy QR-Scanner</h2>
<video id="video" autoplay playsinline></video>
<div id="result"></div>
<button id="backBtn">Back to scanner</button>
<audio id="preview" controls autoplay></audio>
<div id="embed"></div>
</div>
<script src="https://unpkg.com/jsqr/dist/jsQR.js"></script>
<script>
const video = document.getElementById('video');
const resultDiv = document.getElementById('result');
const embedDiv = document.getElementById('embed');
const backBtn = document.getElementById('backBtn');
const previewAudio = document.getElementById('preview');
let scanning = true;
let stream = null;
let animationId = null;
function extractPreviewUrl(url) {
try {
if (url.endsWith('.mp3')) return url;
const u = new URL(url);
if (u.searchParams.has('preview_url')) {
return u.searchParams.get('preview_url');
}
} catch {}
return null;
}
function extractSpotifyTrackId(url) {
try {
const match = url.match(/(?:embed\/)?track\/([a-zA-Z0-9]+)/);
if (match) {
return match[1];
}
} catch {}
return null;
}
function isSpotifyUrl(url) {
try {
const u = new URL(url);
return u.hostname.endsWith('spotify.com');
} catch {
return false;
}
}
function getSpotifyEmbed(url) {
const trackId = extractSpotifyTrackId(url);
if (trackId) {
return `https://open.spotify.com/embed/track/${trackId}`;
}
return null;
}
async function startScanner() {
scanning = true;
embedDiv.innerHTML = '';
resultDiv.textContent = '';
backBtn.style.display = 'none';
previewAudio.style.display = 'none';
previewAudio.pause();
previewAudio.src = '';
if (!stream) {
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
video.srcObject = stream;
} catch (e) {
resultDiv.textContent = "Could not start camera.";
return;
}
}
video.play();
requestAnimationFrame(tick);
}
function stopScanner() {
scanning = false;
if (animationId) cancelAnimationFrame(animationId);
video.pause();
}
function tick() {
if (!scanning) return;
if (video.readyState === video.HAVE_ENOUGH_DATA) {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code && code.data) {
handleResult(code.data);
return;
}
}
animationId = requestAnimationFrame(tick);
}
function handleResult(data) {
stopScanner();
const previewUrl = extractPreviewUrl(data);
if (previewUrl) {
resultDiv.innerHTML = `<span>Spotify preview found and playing.</span>`;
previewAudio.src = previewUrl;
previewAudio.style.display = 'block';
previewAudio.play();
embedDiv.innerHTML = '';
} else if (isSpotifyUrl(data)) {
const embedUrl = getSpotifyEmbed(data);
if (embedUrl) {
resultDiv.innerHTML = `<span>Spotify link found and loaded below.</span>`;
embedDiv.innerHTML = `<iframe src="${embedUrl}" allow="encrypted-media"></iframe>`;
} else {
resultDiv.textContent = "Spotify link found, but not a supported type.";
embedDiv.innerHTML = '';
}
previewAudio.style.display = 'none';
previewAudio.pause();
previewAudio.src = '';
} else {
resultDiv.textContent = "No valid Spotify link found.";
embedDiv.innerHTML = '';
previewAudio.style.display = 'none';
previewAudio.pause();
previewAudio.src = '';
}
backBtn.style.display = 'inline-block';
}
backBtn.onclick = () => {
startScanner();
};
window.onload = startScanner;
window.onpagehide = () => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
stream = null;
}
previewAudio.pause();
previewAudio.src = '';
};
</script>
</body>
</html>

View File

@ -1,6 +1,6 @@
[project]
name = "hipsterfy"
version = "0.1.0"
version = "0.3.2"
description = ""
authors = [
{name="Jonas Weinz"}
@ -13,15 +13,30 @@ dependencies = [
"pillow",
"qrcode",
"panel",
"numpy",
"opencv-python",
"trimesh",
"manifold3d",
"lxml",
"svg.path",
"shapely",
"cairosvg",
"networkx",
"rtree",
"scikit-learn",
]
[tool.poetry]
name = "hipsterfy"
version = "0.1.0"
version = "0.2.3"
description = ""
authors = [
"Jonas Weinz"
]
readme = "README.md"
include = [
"hipsterfy/static/*"
]
[build-system]