initial commit

This commit is contained in:
2025-07-20 20:17:02 +02:00
commit 62166624dc
9 changed files with 2272 additions and 0 deletions

164
.gitignore vendored Normal file
View File

@ -0,0 +1,164 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
*.cache
*.json
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# Hipsterfy
creates small track cards with Spotify embeds and QR codes to print them out. Any relation to an existing party game is purely coincidental.
## Installation
```bash
pip install -e .
```
## Usage
```bash
python -m hipsterfy.main
```

0
hipsterfy/__init__.py Normal file
View File

260
hipsterfy/hipsterfy.py Normal file
View File

@ -0,0 +1,260 @@
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
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=2, 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'<img src="data:image/png;base64,{img_str}" alt="QR Code" style="margin-top:4px;"/>'
def generate_hipsterfy_front_card(self, include_preview:bool = False):
# 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 ""
return f"""
<div 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;
">
<div style="text-align: center;">
<div style="margin-top: 8px;">
{self._qr_html}
{preview_html}
</div>
<div style="margin-top: 18px; color: #888; font-size: 0.95em;">
<span>Scan for Spotify Preview</span>
</div>
</div>
</div>
"""
def generate_hipsterfy_back_card(self, primary_property, secondary_property, additional_properties:List[str]=None, album_cover_gb:bool = True):
# generates the back card with primary and secondary property and additional properties
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>"
# 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;
"""
else:
album_bg = "background: #f9f9f9;"
return f"""
<div style="
width: 300px; height: 300px;
border: 1.5px solid #222;
border-radius: 18px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
{album_bg}
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;">
<h3 style="margin: 0 0 8px 0; font-size: 1.35em; color: #fff; text-shadow: 0 2px 8px #000;">{getattr(self, primary_property)}</h3>
<p style="margin: 0 0 12px 0; font-size: 1.1em; color: #e0e0e0; text-shadow: 0 1px 4px #000;">{getattr(self, secondary_property)}</p>
{additional_html}
</div>
</div>
"""
@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"""
<iframe src="https://open.spotify.com/embed/track/{self._track_id}" width="300" height="80" frameborder="0" allowtransparency="true" allow="encrypted-media"></iframe>
"""
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
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 = self._hipsterfy.sp.playlist_items(playlist_id, additional_types=['track'])
self._tracks_data = [HipsterfyPlaylistItem(item['track']) for item in results['items'] if item['track']]
def get_tracks_data(self) -> List[HipsterfyPlaylistItem]:
"""Returns the list of HipsterfyPlaylistItem objects representing the tracks in the playlist.
"""
return self._tracks_data

30
hipsterfy/main.py Normal file
View File

@ -0,0 +1,30 @@
import panel as pn
import argparse
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist
from hipsterfy.panel_page import create_panel_page
def parse_args():
parser = argparse.ArgumentParser(description="Hipsterfy - A Spotify Playlist Manager")
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')
return parser.parse_args()
def panel_main():
args = parse_args()
hipsterfy = Hipsterfy(args.spotify_client_id, args.spotify_client_secret)
# Create a Panel app
pn.extension()
# Example usage of Hipsterfy
playlist_uri = 'https://open.spotify.com/playlist/294v6cT4ZWxtpsKQPZyC5h' # Replace with your playlist URI
app = create_panel_page(hipsterfy, playlist_uri)
# Serve the Panel app
pn.serve(app, port=args.port, websocket_origin='*', show=False)
if __name__ == "__main__":
panel_main()

61
hipsterfy/panel_page.py Normal file
View File

@ -0,0 +1,61 @@
import panel as pn
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem
def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=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=["artist", "album"])
enable_album_art = pn.widgets.Checkbox(name='Enable Album Art', value=True)
create_preview_button = pn.widgets.Button(name='Create Preview', button_type='primary')
front_cards_column = pn.Column(sizing_mode='stretch_width')
back_cards_column = pn.Column(sizing_mode='stretch_width')
# add widgets to the sidebar of the template
template = pn.template.FastListTemplate(
title='Hipsterfy Playlist Manager',
sidebar=[playlist_uri, primary_item, secondary_item, additional_items, enable_album_art, create_preview_button],
main=[pn.Row(front_cards_column, back_cards_column, sizing_mode='stretch_width')],
accent_base_color='indigo',
header_background='indigo',
header_color='white'
)
def create_preview(event):
# clear previous cards
front_cards_column.clear()
back_cards_column.clear()
# 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)
# generate front and back cards for each item in the playlist
for item in hipsterfy_playlist.get_tracks_data():
front_card = item.generate_hipsterfy_front_card(include_preview=False)
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
)
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'))
# notify user of success
# bind the create_preview function to the button click event
create_preview_button.on_click(create_preview)
# return the template
return template

7
poetry.lock generated Normal file
View File

@ -0,0 +1,7 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
package = []
[metadata]
lock-version = "2.0"
python-versions = "*"
content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8"

29
pyproject.toml Normal file
View File

@ -0,0 +1,29 @@
[project]
name = "hipsterfy"
version = "0.1.0"
description = ""
authors = [
{name="Jonas Weinz"}
]
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"spotipy",
"pandas",
"pillow",
"qrcode",
"panel",
]
[tool.poetry]
name = "hipsterfy"
version = "0.1.0"
description = ""
authors = [
"Jonas Weinz"
]
readme = "README.md"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

1706
test_notebooks/scratch.ipynb Normal file

File diff suppressed because it is too large Load Diff