7 Commits

Author SHA1 Message Date
500a77761f allow download raw project as json 2025-12-21 19:38:40 +01: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
5 changed files with 373 additions and 21 deletions

View File

@ -58,7 +58,7 @@ class HipsterfyPlaylistItem(object):
card_style = """ card_style = """
width: 300px; height: 300px; width: 300px; height: 300px;
border: 1.5px; border: 1px dashed #bbb; /* sehr dezente, gestrichelte Linie */
border-radius: 18px; border-radius: 18px;
display: flex; display: flex;
align-items: center; align-items: center;
@ -391,7 +391,10 @@ class HipsterfyPlaylistItem(object):
class HipsterfyPlaylist(object): class HipsterfyPlaylist(object):
def __init__(self, playlist_uri, hipsterfy:Hipsterfy): def __init__(self, playlist_uri: str, hipsterfy:Hipsterfy, serialized_raw_data:List[Dict[str, Any]] = None ):
if serialized_raw_data:
self._tracks_data = [HipsterfyPlaylistItem(item) for item in serialized_raw_data]
return
self._playlist_uri = playlist_uri self._playlist_uri = playlist_uri
self._hipsterfy = hipsterfy self._hipsterfy = hipsterfy
self._tracks_data = [] self._tracks_data = []
@ -400,9 +403,24 @@ class HipsterfyPlaylist(object):
"""Load the playlist data from Spotify and extract track information. """Load the playlist data from Spotify and extract track information.
""" """
playlist_id = self._playlist_uri.split("/")[-1].split("?")[0] playlist_id = self._playlist_uri.split("/")[-1].split("?")[0]
results = self._hipsterfy.sp.playlist_items(playlist_id, additional_types=['track'], limit=1000) results = []
self._tracks_data = [HipsterfyPlaylistItem(item['track']) for item in results['items'] if item['track']] 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]: def get_tracks_data(self) -> List[HipsterfyPlaylistItem]:
"""Returns the list of HipsterfyPlaylistItem objects representing the tracks in the playlist. """Returns the list of HipsterfyPlaylistItem objects representing the tracks in the playlist.
""" """
return self._tracks_data return self._tracks_data
def get_raw_playlist_data(self) -> List[Dict[str, Any]]:
"""Returns the raw playlist data as a list of dictionaries.
"""
return [item._raw_data for item in self._tracks_data]

View File

@ -1,5 +1,7 @@
import importlib.resources
import panel as pn import panel as pn
import argparse import argparse
import os
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist
from hipsterfy.panel_page import create_panel_page 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_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('--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('--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() return parser.parse_args()
def panel_main(): def panel_main():
args = parse_args() args = parse_args()
hipsterfy = Hipsterfy(args.spotify_client_id, args.spotify_client_secret) hipsterfy = Hipsterfy(args.spotify_client_id, args.spotify_client_secret)
playlist_uri = 'https://open.spotify.com/playlist/5grJs3PKyLE0cL5NYmkGIF'
# Create a Panel app app = lambda: create_panel_page(hipsterfy, playlist_uri, args.root_url)
pn.extension()
static_dir = os.path.join(os.path.dirname(__file__), "static")
# Example usage of Hipsterfy pn.serve(
playlist_uri = 'https://open.spotify.com/playlist/5grJs3PKyLE0cL5NYmkGIF' # Replace with your playlist URI {
app = lambda: create_panel_page(hipsterfy, playlist_uri) "/": app,
},
# Serve the Panel app static_dirs={"qr": static_dir},
pn.serve(app, port=args.port, websocket_origin='*', show=False) port=args.port,
websocket_origin='*',
show=False,
)
if __name__ == "__main__": if __name__ == "__main__":
panel_main() panel_main()

View File

@ -2,22 +2,36 @@ import panel as pn
import base64 import base64
from io import BytesIO from io import BytesIO
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem, CardStyle from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem, CardStyle
import qrcode
pn.extension("filedropper")
pn.extension("filedownload", "notifications", "location") 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 # create widgets
mode = pn.widgets.RadioButtonGroup(name='Input Mode', options=['Playlist URI', 'Upload JSON'], button_type='success', value='Playlist URI', sizing_mode='stretch_width')
playlist_uri = pn.widgets.TextInput(name='Playlist URI', value=playlist_uri or '', placeholder='Enter Spotify Playlist URI') playlist_uri = pn.widgets.TextInput(name='Playlist URI', value=playlist_uri or '', placeholder='Enter Spotify Playlist URI')
json_upload = pn.widgets.FileDropper(name='Upload Playlist JSON', accepted_filetypes=['application/json'], sizing_mode='stretch_width')
primary_item = pn.widgets.Select(name='Primary Item', options=HipsterfyPlaylistItem.get_available_properties(), value="release_year") 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") 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"]) additional_items = pn.widgets.MultiChoice(name='Additional Items', options=HipsterfyPlaylistItem.get_available_properties(), value=["artists", "album", "popularity"])
card_style = pn.widgets.Select(name='Card Style', options=[style.value for style in CardStyle], value=CardStyle.FULL_ALBUM_COVER.value) 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) cards_per_row_widget = pn.widgets.IntSlider(name="Cards per Row in print", start=1, end=8, value=4, step=1)
def on_mode_change(event):
if mode.value == 'Playlist URI':
playlist_uri.visible = True
json_upload.visible = False
else:
playlist_uri.visible = False
json_upload.visible = True
mode.param.watch(on_mode_change, 'value')
on_mode_change(None) # Initial call to set visibility
pn.state.location.sync(playlist_uri, {"value": "playlist_uri"}) pn.state.location.sync(playlist_uri, {"value": "playlist_uri"})
pn.state.location.sync(primary_item, {"value": "primary_item"}) pn.state.location.sync(primary_item, {"value": "primary_item"})
pn.state.location.sync(secondary_item, {"value": "secondary_item"}) pn.state.location.sync(secondary_item, {"value": "secondary_item"})
@ -38,6 +52,10 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
label="Download Back Cards (HTML)", button_type="success", visible=False, filename="hipsterfy_back_cards.html", sizing_mode='stretch_width' label="Download Back Cards (HTML)", button_type="success", visible=False, filename="hipsterfy_back_cards.html", sizing_mode='stretch_width'
) )
download_json_data_button = pn.widgets.FileDownload(
label="Download Playlist Data (JSON)", button_type="success", visible=False, filename="hipsterfy_raw_playlist_data.json", sizing_mode='stretch_width'
)
playlist_instructions = pn.pane.HTML( playlist_instructions = pn.pane.HTML(
""" """
<div> <div>
@ -109,6 +127,16 @@ 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.callback = lambda: BytesIO(generate_html(cards_back, row_reverse=True))
download_back_html_button.visible = True download_back_html_button.visible = True
# Raw Playlist Data as JSON
def generate_playlist_json():
import json
uri = playlist_uri.value.strip()
hipsterfy_playlist = HipsterfyPlaylist(uri, hipsterfy)
data = hipsterfy_playlist.get_raw_playlist_data()
return json.dumps(data, indent=4).encode() # FileDownload erwartet Bytes
download_json_data_button.callback = lambda: BytesIO(generate_playlist_json())
download_json_data_button.visible = True
print_instructions.visible = True # Show print instructions after generating preview 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") pn.state.notifications.success("Download buttons updated successfully!. Download and print them from the sidebar")
@ -134,7 +162,15 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
return return
# create HipsterfyPlaylist instance # create HipsterfyPlaylist instance
hipsterfy_playlist = HipsterfyPlaylist(uri, hipsterfy) if mode.value == 'Upload JSON':
if len(json_upload.value) == 0:
pn.state.notifications.error("Please upload a valid JSON file containing playlist data.")
return
import json
raw_data = json.loads(list(json_upload.value.values())[0].decode())
hipsterfy_playlist = HipsterfyPlaylist(uri, hipsterfy, serialized_raw_data=raw_data)
else:
hipsterfy_playlist = HipsterfyPlaylist(uri, hipsterfy)
new_back_cards = [] new_back_cards = []
new_front_cards = [] new_front_cards = []
@ -167,12 +203,42 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
create_preview_button.on_click(create_preview) 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( template = pn.template.FastListTemplate(
title='Hipsterfy', title='Hipsterfy',
sidebar=[ sidebar=[
playlist_instructions, playlist_uri, primary_item, secondary_item, additional_items, card_style, player_qr_pane, # <-- Dynamischer QR-Code ganz oben in der Sidebar
playlist_instructions, mode, json_upload, playlist_uri, primary_item, secondary_item, additional_items, card_style,
cards_per_row_widget, cards_per_row_widget,
create_preview_button, download_front_html_button, download_back_html_button, print_instructions create_preview_button, download_front_html_button, download_back_html_button, download_json_data_button, print_instructions
], ],
main=[pn.Row(front_cards_column, back_cards_column, sizing_mode='stretch_width')], main=[pn.Row(front_cards_column, back_cards_column, sizing_mode='stretch_width')],
accent_base_color='indigo', 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] [project]
name = "hipsterfy" name = "hipsterfy"
version = "0.2.1" version = "0.3.2"
description = "" description = ""
authors = [ authors = [
{name="Jonas Weinz"} {name="Jonas Weinz"}
@ -16,12 +16,15 @@ dependencies = [
] ]
[tool.poetry] [tool.poetry]
name = "hipsterfy" name = "hipsterfy"
version = "0.2.1" version = "0.2.3"
description = "" description = ""
authors = [ authors = [
"Jonas Weinz" "Jonas Weinz"
] ]
readme = "README.md" readme = "README.md"
include = [
"hipsterfy/static/*"
]
[build-system] [build-system]