From a8b2c021647885b243a1ccdea68511f4bcd4bb7a Mon Sep 17 00:00:00 2001 From: Nathan Woodburn Date: Sun, 26 Oct 2025 20:43:48 +1100 Subject: [PATCH] feat: Add initial spotify widget --- blueprints/spotify.py | 122 +++++++++++++++++ server.py | 2 + templates/assets/css/brand-reveal.min.css | 2 +- templates/assets/img/external/spotify.png | Bin 0 -> 9904 bytes templates/index.html | 156 +++++++++++++++++++++- 5 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 blueprints/spotify.py create mode 100644 templates/assets/img/external/spotify.png diff --git a/blueprints/spotify.py b/blueprints/spotify.py new file mode 100644 index 0000000..52cf20f --- /dev/null +++ b/blueprints/spotify.py @@ -0,0 +1,122 @@ +from flask import redirect, request, Blueprint, url_for +from tools import json_response +import os +import requests +import time +import base64 + +spotify_bp = Blueprint('spotify', __name__) + +CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") +CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") +ALLOWED_SPOTIFY_USER_ID = os.getenv("SPOTIFY_USER_ID") + +SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize" +SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token" +SPOTIFY_CURRENTLY_PLAYING_URL = "https://api.spotify.com/v1/me/player/currently-playing" + +SCOPE = "user-read-currently-playing user-read-playback-state" + +ACCESS_TOKEN = None +REFRESH_TOKEN = os.getenv("SPOTIFY_REFRESH_TOKEN") +TOKEN_EXPIRES = 0 + +def refresh_access_token(): + """Refresh Spotify access token when expired.""" + global ACCESS_TOKEN, TOKEN_EXPIRES + + # If still valid, reuse it + if ACCESS_TOKEN and time.time() < TOKEN_EXPIRES - 60: + return ACCESS_TOKEN + + auth_str = f"{CLIENT_ID}:{CLIENT_SECRET}" + b64_auth = base64.b64encode(auth_str.encode()).decode() + + data = { + "grant_type": "refresh_token", + "refresh_token": REFRESH_TOKEN, + } + headers = {"Authorization": f"Basic {b64_auth}"} + + response = requests.post(SPOTIFY_TOKEN_URL, data=data, headers=headers) + if response.status_code != 200: + print("Failed to refresh token:", response.text) + return None + + token_info = response.json() + ACCESS_TOKEN = token_info["access_token"] + TOKEN_EXPIRES = time.time() + token_info.get("expires_in", 3600) + return ACCESS_TOKEN + + + +@spotify_bp.route("/login") +def login(): + auth_query = ( + f"{SPOTIFY_AUTH_URL}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={url_for("spotify.callback", _external=True)}&scope={SCOPE}" + ) + return redirect(auth_query) + +@spotify_bp.route("/callback") +def callback(): + code = request.args.get("code") + if not code: + return "Authorization failed.", 400 + + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": url_for("spotify.callback", _external=True), + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + } + response = requests.post(SPOTIFY_TOKEN_URL, data=data) + token_info = response.json() + if "access_token" not in token_info: + return json_response(request, {"error": "Failed to obtain token", "details": token_info}, 400) + + access_token = token_info["access_token"] + me = requests.get( + "https://api.spotify.com/v1/me", + headers={"Authorization": f"Bearer {access_token}"} + ).json() + + if me.get("id") != ALLOWED_SPOTIFY_USER_ID: + return json_response(request, {"error": "Unauthorized user"}, 403) + + global REFRESH_TOKEN + REFRESH_TOKEN = token_info.get("refresh_token") + print("Spotify authorization successful.") + print("Refresh Token:", REFRESH_TOKEN) + return redirect(url_for("spotify.currently_playing")) + +@spotify_bp.route("/") +@spotify_bp.route("/currently-playing") +def currently_playing(): + """Public endpoint showing your current track.""" + token = refresh_access_token() + if not token: + return json_response(request, {"error": "Failed to refresh access token"}, 500) + + headers = {"Authorization": f"Bearer {token}"} + response = requests.get(SPOTIFY_CURRENTLY_PLAYING_URL, headers=headers) + + if response.status_code == 204: + return json_response(request, {"message": "Nothing is currently playing."}, 200) + elif response.status_code != 200: + return json_response(request, {"error": "Spotify API error", "status": response.status_code}, response.status_code) + + data = response.json() + if not data.get("item"): + return json_response(request, {"message": "Nothing is currently playing."}, 200) + + + track = { + "song_name": data["item"]["name"], + "artist": ", ".join([artist["name"] for artist in data["item"]["artists"]]), + "album_name": data["item"]["album"]["name"], + "album_art": data["item"]["album"]["images"][0]["url"], + "is_playing": data["is_playing"] + } + return json_response(request, {"spotify":track}, 200) \ No newline at end of file diff --git a/server.py b/server.py index b4ce245..a581350 100644 --- a/server.py +++ b/server.py @@ -25,6 +25,7 @@ from blueprints.wellknown import wk_bp from blueprints.api import api_bp from blueprints.podcast import podcast_bp from blueprints.acme import acme_bp +from blueprints.spotify import spotify_bp from tools import isCurl, isCrawler, getAddress, getFilePath, error_response, getClientIP, json_response, getHandshakeScript, get_tools_data from curl import curl_response @@ -38,6 +39,7 @@ app.register_blueprint(wk_bp, url_prefix='/.well-known') app.register_blueprint(api_bp, url_prefix='/api/v1') app.register_blueprint(podcast_bp) app.register_blueprint(acme_bp) +app.register_blueprint(spotify_bp, url_prefix='/spotify') dotenv.load_dotenv() diff --git a/templates/assets/css/brand-reveal.min.css b/templates/assets/css/brand-reveal.min.css index 1d2261d..6fc6619 100644 --- a/templates/assets/css/brand-reveal.min.css +++ b/templates/assets/css/brand-reveal.min.css @@ -1 +1 @@ -.name-container{display:inline-flex;align-items:center;overflow:hidden;position:absolute;width:fit-content;left:50%;transform:translateX(-50%)}.slider{position:relative;left:0;animation:1s linear 1s forwards slide}@keyframes slide{0%{left:0}100%{left:calc(100%)}}.brand{mask-image:linear-gradient(to right,black 50%,transparent 50%);-webkit-mask-image:linear-gradient(to right,black 50%,transparent 50%);mask-position:100% 0;-webkit-mask-position:100% 0;mask-size:200%;-webkit-mask-size:200%;animation:1s linear 1s forwards reveal}@keyframes reveal{0%{mask-position:100% 0;-webkit-mask-position:100% 0}100%{mask-position:0 0;-webkit-mask-position:0 0}} \ No newline at end of file +.name-container{display:inline-flex;align-items:center;overflow:hidden;position:absolute;width:fit-content;left:50%;transform:translateX(-50%)}.slider{position:relative;left:0;animation:1s linear 1s forwards slide}@keyframes slide{0%{left:0}100%{left:calc(100%)}}.brand{mask-image:linear-gradient(to right,black 50%,transparent 50%);-webkit-mask-image:linear-gradient(to right,black 50%,transparent 50%);mask-position:100% 0;-webkit-mask-position:100% 0;mask-size:200%;-webkit-mask-size:200%;animation:1s linear 1s forwards reveal}@keyframes reveal{0%{mask-position:100% 0;-webkit-mask-position:100% 0}100%{mask-position:0 0;-webkit-mask-position:0 0}}.now-playing{position:fixed;bottom:0;right:0;border-top-left-radius:10px;background:#10101039;padding:1em} \ No newline at end of file diff --git a/templates/assets/img/external/spotify.png b/templates/assets/img/external/spotify.png new file mode 100644 index 0000000000000000000000000000000000000000..a296f59537539ca67cbfe26b62f1b639637c94e6 GIT binary patch literal 9904 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 QP)z)_`?qd+t_tP<0Eiag3jhEB literal 0 HcmV?d00001 diff --git a/templates/index.html b/templates/index.html index 01669e9..41d0f4d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -292,7 +292,161 @@ Check them out here! - {{time|safe}} + {{time|safe}} + + +
+ Album Art +
+
+
+
+
+
+ + +