feat: Add initial spotify widget
All checks were successful
Build Docker / BuildImage (push) Successful in 57s
All checks were successful
Build Docker / BuildImage (push) Successful in 57s
This commit is contained in:
122
blueprints/spotify.py
Normal file
122
blueprints/spotify.py
Normal file
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
|
||||
2
templates/assets/css/brand-reveal.min.css
vendored
2
templates/assets/css/brand-reveal.min.css
vendored
@@ -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}}
|
||||
.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}
|
||||
BIN
templates/assets/img/external/spotify.png
vendored
Normal file
BIN
templates/assets/img/external/spotify.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
@@ -292,7 +292,161 @@ Check them out here!</blockquote><img class="img-fluid" src="/assets/img/pfront.
|
||||
<div class="d-none d-print-none d-sm-none d-md-block d-lg-block d-xl-block d-xxl-block clock" style="padding: 1em;background: #10101039;border-top-right-radius: 10px;"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 20 20" fill="none" class="fs-2">
|
||||
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18ZM11 6C11 5.44772 10.5523 5 10 5C9.44771 5 9 5.44772 9 6V10C9 10.2652 9.10536 10.5196 9.29289 10.7071L12.1213 13.5355C12.5118 13.9261 13.145 13.9261 13.5355 13.5355C13.9261 13.145 13.9261 12.5118 13.5355 12.1213L11 9.58579V6Z" fill="currentColor"></path>
|
||||
</svg><span style="margin-left: 10px;font-family: 'Anonymous Pro', monospace;">{{time|safe}}</span></div>
|
||||
</svg><span style="margin-left: 10px;font-family: 'Anonymous Pro', monospace;">{{time|safe}}</span></div><!-- Pop-out button for mobile -->
|
||||
<button id="spotify-toggle" style="
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
">
|
||||
<img src="/assets/img/external/spotify.png" alt="Spotify" style="
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
"></img>
|
||||
</button>
|
||||
|
||||
<div id="spotify-widget" style="
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #121212;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
font-family: sans-serif;
|
||||
max-width: 300px;
|
||||
z-index: 9999;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
opacity: 0.9;
|
||||
transform: translateX(120%); /* start hidden off-screen */
|
||||
">
|
||||
<img id="spotify-album-art" src="" alt="Album Art" style="
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 6px;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
">
|
||||
<div style="flex: 1; overflow: hidden;">
|
||||
<div id="spotify-song" style="
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
"></div>
|
||||
<div id="spotify-artist" style="
|
||||
font-size: 0.85rem;
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
"></div>
|
||||
<div id="spotify-album" style="
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const widget = document.getElementById('spotify-widget');
|
||||
const toggleBtn = document.getElementById('spotify-toggle');
|
||||
|
||||
function isMobile() {
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
|
||||
function updateVisibility() {
|
||||
if(isMobile()){
|
||||
widget.style.transform = 'translateX(120%)'; // hidden off-screen
|
||||
toggleBtn.style.display = 'block';
|
||||
} else {
|
||||
widget.style.transform = 'translateX(0)'; // visible
|
||||
toggleBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle widget slide in/out on mobile
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
widget.style.transform = 'translateX(0)'; // slide in
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Close widget when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if(isMobile()){
|
||||
if(!widget.contains(e.target) && e.target !== toggleBtn){
|
||||
widget.style.transform = 'translateX(120%)'; // slide out
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent clicks inside widget from closing it
|
||||
widget.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// --- Spotify fetch ---
|
||||
async function updateSpotifyWidget() {
|
||||
try {
|
||||
const res = await fetch('/spotify/');
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
// Check if data contains an error or message indicating nothing is playing
|
||||
if (data.error || data.message) {
|
||||
widget.style.opacity = 0.5;
|
||||
|
||||
// If existing data
|
||||
if (document.getElementById('spotify-song').textContent) {
|
||||
return false;
|
||||
}
|
||||
// Alternate text when nothing is playing
|
||||
document.getElementById('spotify-album-art').src = '/assets/img/external/spotify.png';
|
||||
document.getElementById('spotify-song').textContent = 'Not Playing';
|
||||
document.getElementById('spotify-artist').textContent = '';
|
||||
document.getElementById('spotify-album').textContent = '';
|
||||
return false;
|
||||
}
|
||||
|
||||
const track = data.spotify;
|
||||
|
||||
document.getElementById('spotify-album-art').src = track.album_art;
|
||||
document.getElementById('spotify-song').textContent = track.song_name;
|
||||
document.getElementById('spotify-artist').textContent = track.artist;
|
||||
document.getElementById('spotify-album').textContent = track.album_name;
|
||||
|
||||
widget.style.opacity = track.is_playing ? 0.9 : 0.5;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Spotify data', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for Spotify API to have responded before initial display
|
||||
updateSpotifyWidget().then(success => {
|
||||
if(success) updateVisibility();
|
||||
});
|
||||
|
||||
window.addEventListener('resize', updateVisibility);
|
||||
setInterval(updateSpotifyWidget, 15000);
|
||||
</script>
|
||||
|
||||
<script src="/assets/bootstrap/js/bootstrap.min.js"></script>
|
||||
<script src="/assets/js/script.min.js"></script>
|
||||
<script src="/assets/js/grayscale.min.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user