Last active
October 20, 2025 07:27
-
-
Save Inobtenio/9e1840cbe3eb82a02c7e7c0ff9d57867 to your computer and use it in GitHub Desktop.
updater gets what's playing on Spotify, looks at the album cover and extracts the most dominant/eye-catching color as well as the track data, server serves said data alongside a preformatted album cover for its use in the Vobot Mini Dock. More info at https://inobtenio.com/posts/vobot-mini-dock-spotify/.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import json | |
| from flask import Flask, send_from_directory | |
| from flask_restful import Resource, Api | |
| app = Flask(__name__) | |
| api = Api(app) | |
| class CurrentSong(Resource): | |
| def get(self): | |
| with open('data.json') as f: | |
| d = json.load(f) | |
| return d | |
| class CurrentSongCover(Resource): | |
| def get(self): | |
| return send_from_directory('', 'cover.jpg') | |
| class CurrentSongFullCover(Resource): | |
| def get(self): | |
| return send_from_directory('', 'full_cover.jpg') | |
| api.add_resource(CurrentSong, '/current') | |
| api.add_resource(CurrentSongCover, '/cover') | |
| api.add_resource(CurrentSongFullCover, '/full-cover') | |
| if __name__ == '__main__': | |
| app.run(debug=True, host='0.0.0.0', port=4444) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import PIL | |
| import json | |
| import asyncio | |
| import requests | |
| import binascii | |
| from PIL import Image, ImageDraw | |
| from io import BytesIO | |
| import math | |
| import colorsys | |
| import numpy | |
| import numpy as np | |
| from sklearn.cluster import KMeans | |
| qC = 4.9226 | |
| qD = 1.4060 | |
| qR = 0.7932 | |
| def patch_asscalar(a): | |
| return a.item() | |
| setattr(numpy, "asscalar", patch_asscalar) | |
| client_id = "..." | |
| client_secret = "..." | |
| refresh_token = "..." | |
| access_token = None | |
| current_track_data = {} | |
| image = None | |
| top_tracks = None | |
| def is_color_light(rgb): | |
| r, g, b = rgb | |
| brightness = 0.299 * r + 0.587 * g + 0.114 * b | |
| return brightness > 135 | |
| def calculate_dark_colorfulness(rgb): | |
| red, green, blue = [x / 255.0 for x in rgb] | |
| red_greenness = red - green # or the a component | |
| yellow_blueness = (red + green)/2 - blue # or the b component. red + green output yellow in additive color (light) | |
| chroma = math.sqrt(red_greenness ** 2 + yellow_blueness ** 2) | |
| luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue # Relative luminance | |
| darkness = 1 - luminance | |
| return (chroma, darkness) | |
| def increase_saturation(rgb, amount=0.2): | |
| r, g, b = [x / 255.0 for x in rgb] | |
| h, l, s = colorsys.rgb_to_hls(r, g, b) | |
| s = min(1.0, s + amount) # Cap at 1.0 | |
| r_new, g_new, b_new = colorsys.hls_to_rgb(h, l, s) | |
| return tuple(int(x * 255) for x in (r_new, g_new, b_new)) | |
| def improve_white_contrast(rgb, brightness_factor=0.85, saturation_boost=1.1): | |
| """ | |
| Improve contrast of a color against white by darkening and boosting saturation. | |
| Args: | |
| rgb (tuple): (R, G, B) tuple in 0–255. | |
| brightness_factor (float): < 1 to darken. | |
| saturation_boost (float): > 1 to increase saturation. | |
| Returns: | |
| tuple: Adjusted (R, G, B) in 0–255. | |
| """ | |
| r, g, b = [x / 255 for x in rgb] | |
| h, s, v = colorsys.rgb_to_hsv(r, g, b) | |
| s = min(s * saturation_boost, 1.0) | |
| v = max(v * brightness_factor, 0.0) # darken | |
| r, g, b = colorsys.hsv_to_rgb(h, s, v) | |
| return tuple(int(x * 255) for x in (r, g, b)) | |
| def extract_color_clusters(num_clusters): | |
| image = Image.open("./full_cover.jpg").convert("RGB") | |
| w, h = image.size | |
| try: | |
| pixels = np.array(image).reshape(-1, 3) # Turn a RGB matrix into an RGB 2D array | |
| # Cluster colors using K-Means | |
| kmeans = KMeans(n_clusters=num_clusters, random_state=0, n_init="auto") | |
| kmeans.fit(pixels) | |
| labels = kmeans.labels_ | |
| colors = [] | |
| # Group colors by cluster and calculate average for each | |
| clusters = [[] for _ in range(num_clusters)] | |
| for i, label in enumerate(labels): | |
| clusters[label].append(pixels[i]) | |
| for group in clusters: | |
| color = np.mean(group, axis=0) | |
| chroma, darkness = calculate_dark_colorfulness(color) | |
| dominance = len(group)/(w*h) | |
| score = chroma * qC + darkness * qD + dominance * qR | |
| colors.append({ | |
| 'color': tuple(int(c) for c in color), | |
| 'chroma': chroma, | |
| 'darkness': darkness, | |
| 'dominance': dominance, | |
| 'score': score#, | |
| }) | |
| return colors | |
| except Exception as e: | |
| return [{ | |
| 'color': (128,128,128), | |
| 'chroma': 0, | |
| 'darkness': 0.75, | |
| 'dominance': 1.0, | |
| 'score': 1.0 | |
| }] | |
| def get_best_color(): | |
| return max(extract_color_clusters(20), key=lambda c: c['score']) | |
| def get_basic_auth_header(client_id, client_secret): | |
| creds = f"{client_id}:{client_secret}" | |
| return binascii.b2a_base64(creds.encode()).strip().decode() | |
| def get_access_token(): | |
| auth_header = get_basic_auth_header(client_id, client_secret) | |
| headers = { | |
| "Authorization": f"Basic {auth_header}", | |
| "Content-Type": "application/x-www-form-urlencoded" | |
| } | |
| payload = "grant_type=refresh_token&refresh_token=" + refresh_token | |
| response = requests.post( | |
| "https://accounts.spotify.com/api/token", | |
| data=payload, | |
| headers=headers | |
| ) | |
| data = response.json() | |
| return data["access_token"] | |
| def get_currently_playing(access_token): | |
| headers = { | |
| "Authorization": f"Bearer {access_token}" | |
| } | |
| response = requests.get( | |
| "https://api.spotify.com/v1/me/player/currently-playing?additional_types=episode", | |
| headers=headers | |
| ) | |
| if response.status_code == 200 and response.content: | |
| data = response.json() | |
| track = data["item"] | |
| current_track_data["id"] = track["id"] | |
| current_track_data["song_name"] = track["name"] | |
| if data["currently_playing_type"] == "episode": | |
| current_track_data["artist_names"] = track["show"]["publisher"] | |
| get_currently_playing_cover(track["images"][0]["url"]) | |
| else: | |
| current_track_data["artist_names"] = ', '.join(artist["name"] for artist in track["artists"]) | |
| get_currently_playing_cover(track["album"]["images"][0]["url"]) | |
| highlighted_color = improve_white_contrast(get_best_color()["color"], 0.80, 1.7) | |
| print(highlighted_color) | |
| if isinstance(highlighted_color, int): | |
| highlighted_color = (highlighted_color, highlighted_color, highlighted_color) | |
| current_track_data["album_cover_main_color"] = '#%02x%02x%02x' % highlighted_color | |
| current_track_data["album_cover_main_color_is_light"] = is_color_light(highlighted_color) | |
| current_track_data["progress"] = data["progress_ms"] / 1000 | |
| current_track_data["duration"] = track["duration_ms"] / 1000 | |
| current_track_data["is_playing"] = data["is_playing"] | |
| print(f'{current_track_data["song_name"]} - {current_track_data["artist_names"]}') | |
| with open("data.json", 'w') as file: | |
| #json.dump(track_response.json(), file, indent=2) | |
| json.dump(current_track_data, file, indent=2) | |
| elif response.status_code == 401: | |
| access_token = get_access_token() | |
| else: | |
| print(response.content) | |
| print("Nothing is currently playing.") | |
| def get_currently_playing_cover(url): | |
| response = requests.get(url) | |
| if response.status_code == 200 and response.content: | |
| image = Image.open(BytesIO(response.content)) | |
| new_image = image.resize((160, 160), PIL.Image.BILINEAR) | |
| #new_image = new_image.convert("RGBA") | |
| image.save("full_cover.jpg", format="JPEG") | |
| new_image.save("cover.jpg", format="JPEG", quality=40, optimize=True) | |
| return image | |
| else: | |
| print("Nothing is currently playing.") | |
| async def run_every_n_seconds(): | |
| while True: | |
| await asyncio.to_thread(get_currently_playing, access_token) | |
| await asyncio.sleep(2) | |
| if __name__ == '__main__': | |
| access_token = get_access_token() | |
| asyncio.run(run_every_n_seconds()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment