@ -0,0 +1,4 @@ | |||||
.vercel | |||||
.env | |||||
node_modules | |||||
.DS_STORE |
@ -1 +1,3 @@ | |||||
[Spotify](https://novatorem.vercel.app/api/spotify-playing) | |||||
<a href="https://novatorem.vercel.app/now-playing?open"> | |||||
<img src="https://novatorem.vercel.app/now-playing" width="256" height="64" alt="Now Playing"> | |||||
</a> |
@ -1,11 +0,0 @@ | |||||
from http.server import BaseHTTPRequestHandler | |||||
from datetime import datetime | |||||
class handler(BaseHTTPRequestHandler): | |||||
def do_GET(self): | |||||
self.send_response(200) | |||||
self.send_header('Content-type', 'text/plain') | |||||
self.end_headers() | |||||
self.wfile.write(str(datetime.now().strftime('%Y-%m-%d %H:%M:%S')).encode()) | |||||
return |
@ -0,0 +1,44 @@ | |||||
import { NowRequest, NowResponse } from "@vercel/node"; | |||||
import { renderToString } from "react-dom/server"; | |||||
import { decode } from "querystring"; | |||||
import { Player } from "../components/NowPlaying"; | |||||
import { nowPlaying } from "../utils/spotify"; | |||||
export default async function (req: NowRequest, res: NowResponse) { | |||||
const { | |||||
item = {}, | |||||
is_playing: isPlaying = false, | |||||
progress_ms: progress = 0, | |||||
} = await nowPlaying(); | |||||
const params = decode(req.url.split("?")[1]) as any; | |||||
if (params && typeof params.open !== "undefined") { | |||||
if (item && item.external_urls) { | |||||
res.writeHead(302, { | |||||
Location: item.external_urls.spotify, | |||||
}); | |||||
return res.end(); | |||||
} | |||||
return res.status(200).end(); | |||||
} | |||||
res.setHeader("Content-Type", "image/svg+xml"); | |||||
res.setHeader("Cache-Control", "s-maxage=1, stale-while-revalidate"); | |||||
const { duration_ms: duration, name: track } = item; | |||||
const { images = [] } = item.album || {}; | |||||
const cover = images[images.length - 1]?.url; | |||||
let coverImg = null; | |||||
if (cover) { | |||||
const buff = await (await fetch(cover)).arrayBuffer(); | |||||
coverImg = `data:image/jpeg;base64,${Buffer.from(buff).toString("base64")}`; | |||||
} | |||||
const artist = (item.artists || []).map(({ name }) => name).join(", "); | |||||
const text = renderToString( | |||||
Player({ cover: coverImg, artist, track, isPlaying, progress, duration }) | |||||
); | |||||
return res.status(200).send(text); | |||||
} |
@ -1,3 +0,0 @@ | |||||
flask==1.1.2 | |||||
requests==2.24.0 | |||||
python-dotenv==0.14.0 |
@ -1,216 +0,0 @@ | |||||
from flask import Flask, Response, jsonify | |||||
from base64 import b64encode | |||||
from dotenv import load_dotenv, find_dotenv | |||||
load_dotenv(find_dotenv()) | |||||
import requests | |||||
import json | |||||
import os | |||||
import random | |||||
""" | |||||
Inspired from https://github.com/natemoo-re | |||||
""" | |||||
print("Starting Server") | |||||
SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") | |||||
SPOTIFY_SECRET_ID = os.getenv("SPOTIFY_SECRET_ID") | |||||
SPOTIFY_REFRESH_TOKEN = os.getenv("SPOTIFY_REFRESH_TOKEN") | |||||
SPOTIFY_URL_REFRESH_TOKEN = "https://accounts.spotify.com/api/token" | |||||
SPOTIFY_URL_NOW_PLAYING = "https://api.spotify.com/v1/me/player/currently-playing" | |||||
LATEST_PLAY = None | |||||
app = Flask(__name__) | |||||
def get_authorization(): | |||||
return b64encode(f"{SPOTIFY_CLIENT_ID}:{SPOTIFY_SECRET_ID}".encode()).decode("ascii") | |||||
def refresh_token(): | |||||
data = { | |||||
"grant_type": "refresh_token", | |||||
"refresh_token": SPOTIFY_REFRESH_TOKEN, | |||||
} | |||||
headers = {"Authorization": "Basic {}".format(get_authorization())} | |||||
response = requests.post(SPOTIFY_URL_REFRESH_TOKEN, data=data, headers=headers) | |||||
repsonse_json = response.json() | |||||
return repsonse_json["access_token"] | |||||
def get_now_playing(): | |||||
token = refresh_token() | |||||
headers = {"Authorization": f"Bearer {token}"} | |||||
response = requests.get(SPOTIFY_URL_NOW_PLAYING, headers=headers) | |||||
if response.status_code == 204: | |||||
return {} | |||||
repsonse_json = response.json() | |||||
return repsonse_json | |||||
def get_svg_template(): | |||||
css_bar = "" | |||||
left = 1 | |||||
for i in range(1, 76): | |||||
anim = random.randint(350, 500) | |||||
css_bar += ".bar:nth-child({}) {{{{ left: {}px; animation-duration: {}ms; }}}}".format( | |||||
i, left, anim | |||||
) | |||||
left += 4 | |||||
svg = ( | |||||
""" | |||||
<svg width="320" height="445" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | |||||
<foreignObject width="320" height="445"> | |||||
<div xmlns="http://www.w3.org/1999/xhtml" class="container"> | |||||
<style> | |||||
div {{font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;}} | |||||
.container {{background-color: #121212; border-radius: 10px; padding: 10px 10px}} | |||||
.playing {{ font-weight: bold; color: #53b14f; text-align: center; display: flex; justify-content: center; align-items: center;}} | |||||
.not-play {{color: #ff1616;}} | |||||
.artist {{ font-weight: bold; font-size: 20px; color: #fff; text-align: center; margin-top: 5px; }} | |||||
.song {{ font-size: 16px; color: #b3b3b3; text-align: center; margin-top: 5px; margin-bottom: 15px; }} | |||||
.logo {{ margin-left: 5px; margin-top: 5px; }} | |||||
.cover {{ border-radius: 5px; margin-top: 9px; }} | |||||
#bars {{ | |||||
height: 30px; | |||||
margin: -20px 0 0 0px; | |||||
position: absolute; | |||||
width: 40px; | |||||
}} | |||||
.bar {{ | |||||
background: #53b14f; | |||||
bottom: 1px; | |||||
height: 3px; | |||||
position: absolute; | |||||
width: 3px; | |||||
animation: sound 0ms -800ms linear infinite alternate; | |||||
}} | |||||
@keyframes sound {{ | |||||
0% {{ | |||||
opacity: .35; | |||||
height: 3px; | |||||
}} | |||||
100% {{ | |||||
opacity: 1; | |||||
height: 28px; | |||||
}} | |||||
}} | |||||
""" | |||||
+ css_bar | |||||
+ """ | |||||
</style> | |||||
{} | |||||
</div> | |||||
</foreignObject> | |||||
</svg> | |||||
""" | |||||
) | |||||
return svg | |||||
def load_image_b64(url): | |||||
resposne = requests.get(url) | |||||
return b64encode(resposne.content).decode("ascii") | |||||
def make_svg(data): | |||||
global LATEST_PLAY | |||||
template = get_svg_template() | |||||
text = "Now playing" | |||||
content_bar = "".join(["<div class='bar'></div>" for i in range(75)]) | |||||
if data == {} and LATEST_PLAY is not None: | |||||
data = LATEST_PLAY | |||||
text = "Latest play" | |||||
content_bar = "" | |||||
elif data == {}: | |||||
content = """ | |||||
<div class="playing not-play">Nothing playing on Spotify</div> | |||||
""" | |||||
return template.format(content) | |||||
content = """ | |||||
<div class="playing">{} on <img class="logo" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAYAAAAe2bNZAAAE5ElEQVRYR81WS08bVxQ+dzweY48fQCCBllJDmqpRSx6gKizSiAUbILZjA21KVEhF20hNlLb/IKu2m3QRKaqQogIRkAXhYQxFSJGCQqVKrVSBQtpCFqEQkrQhxnb8Yjxzp7oTmdjjsWd4LHIlLzz3PL57zvedexG8Qgu9Qlhg22Bqumr0+/L2VdAMOpBvtdgCoedBnhPvsw/YpcFLg9x2DrklMA19DVajkf4B0dQnAMDkSJgQedwfWot9fevcraBWYJrA1HXX5RUWWG8jhGq1Bk7aiVj8PfGfWOc754uq+aqCOXXT8bFOTw2oBVLb5zmhw9s6fj2XXU4wniFnF6LRF2qJtO6LPPQMN3s/zWafFcxuA0kCwILYO+IZO6sESBHMbrUmWwVwQjw70jLWK9/PAEPIuqfQFtNa+u3acU8wKyd1BhjPqPNXJdVgHkP8WRwiqxGI/RsDLsSBiEXQGXRgKDCAqcQk/RgbA4hS1QXx/WPYPVaTepg0LzJHTBZmcy5wQQ5mL89CeCUMIGqvAQFjLjeD/aQdiqqLsoKLh/nCiTMT68nIaWA8Q45riKY6k5szF2YgEU5oR5HFkn2dhUNfHQLjXmOaBRZw34jHRwaotNLANHtdG6mTdaF3AVZvrwJCCNgyFoqOFIHtgA2MxUbQGXXSdyxg4MM8RJ9EYf2vdfDf80PsaUyxkqSNtd+nzU1+yOXVZ4Ahd429pCzjTiG80MIBeVH4GA/Lk8uw/PMyEL4lV9XFKiiuLt78H/2Hy5u8OEmK8LIyjb2Nbxvz9Qu5eiIKokTcuD8OfISXTk+zNOQV5r0grk6ZuIGFAMxdnpMIf+LHE0Dpqc00XCT+nq9t6l4aGOeNpia9iR6XgyHKuXv1rjYSI5BaWN5QDqUflAJFv0ya7ZBCHLeMfuQbSgPTPnG6LcLH+uVOM+dnIBFRIHGyCDlURgh7+JvDYCo1ZS04qzd3Xm/s/0lTZQJ/B2BxYBHK6sug+Ggx6M16Ge1BahdRHSHvytQKhB6E0pITMLXfKV/4QgK3jLbIKqOFM1vROFHW/NX5zdFw7NtjwL7GZoSIB/mqifaJ+bTKtF5qZfBRTmK14hIBuOccRB5GIPIoAhv+DeDjPDBWBsxvmMFaaQVDviGjaut/rkNgMQAVpyoUwyqqiVg2e11E2pu6l7xFgDtf3gEiVS2LgNv/4X4oPV6qxVx5zhBPz5CjG9FU2vUuiiJMd05LcrRWWKHg3QKwvGkBJv/FHUQkHl4Ow9rsGhAJp86USncl2F32rKCwgG+MeHxtSYO0wVDfVW+zlbABLUfKLlUBFvsW4fEvjyWTg50HJZkrrfDT+J6pz6b8imCk6ow4f0MUen8ngKTuCiIsjS+B3WFXnOAiFueG3WNHUvNkjExHl8PElFCRnYJR83/2MGiZPj8dzgmGbLoGT7bTjC7jJaaWQOs+zwmfe1vHr8ntc7yBXd2IBsW3qtakSnYixgPDbt8Zpb2cTzL3sLOH0qGOnSRP9c0FhNipvg/dN5s6KD3ds1NA2Vqjyhl5YkJq/V40gyhUvVVQRDX+R6HjcrJuuU1yh6b+pgLGSF2hdNRpAKBzAOOxgAejfu5C6hxRO4hqm7IFaLjSYKCL8Fs6HfOO1WK1haKhYCKKF3AA30++3NSSa1bTVgPthv22K7MbyeUx/gfIiuIzZiZJFQAAAABJRU5ErkJggg==" /></div> | |||||
<div class="artist">{}</div> | |||||
<div class="song">{}</div> | |||||
<div id='bars'> | |||||
{} | |||||
</div> | |||||
<a href="{}" target="_BLANK"> | |||||
<center> | |||||
<img src="data:image/png;base64, {}" width="300" height="300" class="cover"/> | |||||
</center> | |||||
</a> | |||||
""" | |||||
item = data["item"] | |||||
""" | |||||
print(json.dumps(item)) | |||||
print(item["artists"][0]["name"]) | |||||
print(item["external_urls"]["spotify"]) | |||||
print(item["album"]["images"][0]["url"]) | |||||
""" | |||||
img = load_image_b64(item["album"]["images"][1]["url"]) | |||||
artist_name = item["artists"][0]["name"].replace("&", "&") | |||||
song_name = item["name"].replace("&", "&") | |||||
content_rendered = content.format( | |||||
text, | |||||
artist_name, | |||||
song_name, | |||||
content_bar, | |||||
item["external_urls"]["spotify"], | |||||
img, | |||||
) | |||||
return template.format(content_rendered) | |||||
@app.route("/", defaults={"path": ""}) | |||||
@app.route("/<path:path>") | |||||
def catch_all(path): | |||||
global LATEST_PLAY | |||||
# TODO: caching | |||||
data = get_now_playing() | |||||
svg = make_svg(data) | |||||
# cache lastest data | |||||
if data != {}: | |||||
LATEST_PLAY = data | |||||
resp = Response(svg, mimetype="image/svg+xml") | |||||
resp.headers["Cache-Control"] = "s-maxage=1" | |||||
return resp | |||||
if __name__ == "__main__": | |||||
app.run(debug=True) |
@ -0,0 +1,162 @@ | |||||
import React from "react"; | |||||
import ReadmeImg from "./ReadmeImg"; | |||||
import Text from "./Text"; | |||||
export interface Props { | |||||
cover?: string; | |||||
track: string; | |||||
artist: string; | |||||
progress: number; | |||||
duration: number; | |||||
isPlaying: boolean; | |||||
} | |||||
export const Player: React.FC<Props> = ({ | |||||
cover, | |||||
track, | |||||
artist, | |||||
progress, | |||||
duration, | |||||
isPlaying, | |||||
}) => { | |||||
return ( | |||||
<ReadmeImg width="256" height="64"> | |||||
<style> | |||||
{` | |||||
.paused { | |||||
animation-play-state: paused !important; | |||||
background: #e1e4e8 !important; | |||||
} | |||||
img:not([src]) { | |||||
content: url("data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="); | |||||
border-radius: 6px; | |||||
background: #FFF; | |||||
border: 1px solid #e1e4e8; | |||||
} | |||||
p { | |||||
display: block; | |||||
opacity: 0; | |||||
} | |||||
.progress-bar { | |||||
position: relative; | |||||
width: 100%; | |||||
height: 4px; | |||||
margin: -1px; | |||||
border: 1px solid #e1e4e8; | |||||
border-radius: 4px; | |||||
overflow: hidden; | |||||
padding: 2px; | |||||
z-index: 0; | |||||
} | |||||
#progress { | |||||
position: absolute; | |||||
top: -1px; | |||||
left: 0; | |||||
width: 100%; | |||||
height: 6px; | |||||
transform-origin: left center; | |||||
background-color: #24292e; | |||||
animation: progress ${duration}ms linear; | |||||
animation-delay: -${progress}ms; | |||||
} | |||||
.progress-bar, | |||||
#track, | |||||
#artist, | |||||
#cover { | |||||
opacity: 0; | |||||
animation: appear 300ms ease-out forwards; | |||||
} | |||||
#track { | |||||
animation-delay: 400ms; | |||||
} | |||||
#artist { | |||||
animation-delay: 500ms; | |||||
} | |||||
.progress-bar { | |||||
animation-delay: 550ms; | |||||
margin-top: 4px; | |||||
} | |||||
#cover { | |||||
animation-name: cover-appear; | |||||
animation-delay: 300ms; | |||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 3px 10px rgba(0,0,0,0.05); | |||||
} | |||||
#cover:not([src]) { | |||||
box-shadow: none; | |||||
} | |||||
@keyframes cover-appear { | |||||
from { | |||||
opacity: 0; | |||||
transform: scale(0.8); | |||||
} | |||||
to { | |||||
opacity: 1; | |||||
transform: scale(1); | |||||
} | |||||
} | |||||
@keyframes appear { | |||||
from { | |||||
opacity: 0; | |||||
transform: translateX(-8px); | |||||
} | |||||
to { | |||||
opacity: 1; | |||||
transform: translateX(0); | |||||
} | |||||
} | |||||
@keyframes progress { | |||||
from { | |||||
transform: scaleX(0) | |||||
} | |||||
to { | |||||
transform: scaleX(1) | |||||
} | |||||
} | |||||
`} | |||||
</style> | |||||
<div | |||||
className={isPlaying ? "disabled" : ""} | |||||
style={{ | |||||
display: "flex", | |||||
alignItems: "center", | |||||
paddingTop: 8, | |||||
paddingLeft: 4, | |||||
}} | |||||
> | |||||
<img id="cover" src={cover ?? null} width="48" height="48" /> | |||||
<div | |||||
style={{ | |||||
display: "flex", | |||||
flex: 1, | |||||
flexDirection: "column", | |||||
marginTop: -4, | |||||
marginLeft: 8, | |||||
}} | |||||
> | |||||
<Text id="track" weight="bold"> | |||||
{`${track ?? ""} `.trim()} | |||||
</Text> | |||||
<Text id="artist" color={!track ? "gray" : undefined}> | |||||
{artist || "Nothing playing..."} | |||||
</Text> | |||||
{track && ( | |||||
<div className="progress-bar"> | |||||
<div id="progress" className={!isPlaying ? "paused" : ""} /> | |||||
</div> | |||||
)} | |||||
</div> | |||||
</div> | |||||
</ReadmeImg> | |||||
); | |||||
}; |
@ -0,0 +1,27 @@ | |||||
import React from "react"; | |||||
const ReadmeImg = ({ width, height, children }) => { | |||||
return ( | |||||
<svg | |||||
fill="none" | |||||
width={width} | |||||
height={height} | |||||
viewBox={`0 0 ${width} ${height}`} | |||||
xmlns="http://www.w3.org/2000/svg" | |||||
> | |||||
<foreignObject width={width} height={height}> | |||||
<div {...{ xmlns: "http://www.w3.org/1999/xhtml" }}> | |||||
<style>{` | |||||
* { | |||||
margin: 0; | |||||
box-sizing: border-box; | |||||
} | |||||
`}</style> | |||||
{children} | |||||
</div> | |||||
</foreignObject> | |||||
</svg> | |||||
); | |||||
}; | |||||
export default ReadmeImg; |
@ -0,0 +1,51 @@ | |||||
import React from "react"; | |||||
const sizes = { | |||||
default: 14, | |||||
small: 12, | |||||
}; | |||||
const colors = { | |||||
default: "#24292e", | |||||
"gray-light": "#e1e4e8", | |||||
gray: "#586069", | |||||
"gray-dark": "#24292e", | |||||
}; | |||||
const families = { | |||||
default: | |||||
"-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji", | |||||
mono: "SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace", | |||||
}; | |||||
const weights = { | |||||
default: 400, | |||||
bold: 600, | |||||
}; | |||||
const Text: React.FC<any> = ({ | |||||
children = "", | |||||
weight = "default", | |||||
family = "default", | |||||
color = "default", | |||||
size = "default", | |||||
...props | |||||
}) => { | |||||
return ( | |||||
<p | |||||
style={{ | |||||
whiteSpace: "pre", | |||||
fontSize: `${sizes[size]}px`, | |||||
lineHeight: 1.5, | |||||
fontFamily: families[family], | |||||
color: colors[color], | |||||
fontWeight: weights[weight], | |||||
}} | |||||
{...props} | |||||
> | |||||
{children} | |||||
</p> | |||||
); | |||||
}; | |||||
export default Text; |
@ -0,0 +1,43 @@ | |||||
{ | |||||
"name": "novatorem", | |||||
"private": true, | |||||
"version": "1.0.0", | |||||
"description": "Github profile", | |||||
"main": "index.js", | |||||
"dependencies": { | |||||
"faunadb": "^2.14.2", | |||||
"isomorphic-unfetch": "^3.0.0", | |||||
"querystring": "^0.2.0", | |||||
"react": "^16.13.1", | |||||
"react-dom": "^16.13.1", | |||||
"typescript": "^3.9.6" | |||||
}, | |||||
"devDependencies": { | |||||
"@types/node": "^14.0.22", | |||||
"@types/react": "^16.9.42", | |||||
"@types/react-dom": "^16.9.8", | |||||
"@vercel/node": "^1.7.2", | |||||
"husky": "^4.2.5", | |||||
"lint-staged": "^10.2.11", | |||||
"prettier": "^2.0.5" | |||||
}, | |||||
"repository": { | |||||
"type": "git", | |||||
"url": "git+https://github.com/novatorem/novatorem.git" | |||||
}, | |||||
"keywords": [], | |||||
"author": "Nate Moore", | |||||
"license": "ISC", | |||||
"bugs": { | |||||
"url": "https://github.com/novatorem/novatorem/issues" | |||||
}, | |||||
"homepage": "https://github.com/novatorem/novatorem#readme", | |||||
"husky": { | |||||
"hooks": { | |||||
"pre-commit": "lint-staged" | |||||
} | |||||
}, | |||||
"lint-staged": { | |||||
"*.{js,css,md}": "prettier --write" | |||||
} | |||||
} |
@ -0,0 +1,11 @@ | |||||
{ | |||||
"compilerOptions": { | |||||
"module": "ES2015", | |||||
"moduleResolution": "node", | |||||
"target": "ES2017", | |||||
"jsx": "react", | |||||
"esModuleInterop": true, | |||||
"skipLibCheck": true, | |||||
"forceConsistentCasingInFileNames": true | |||||
} | |||||
} |
@ -0,0 +1,46 @@ | |||||
import fetch from "isomorphic-unfetch"; | |||||
import { stringify } from "querystring"; | |||||
const { | |||||
SPOTIFY_CLIENT_ID: client_id, | |||||
SPOTIFY_CLIENT_SECRET: client_secret, | |||||
SPOTIFY_REFRESH_TOKEN: refresh_token, | |||||
} = process.env; | |||||
const basic = Buffer.from(`${client_id}:${client_secret}`).toString("base64"); | |||||
const Authorization = `Basic ${basic}`; | |||||
async function getAuthorizationToken() { | |||||
const url = new URL("https://accounts.spotify.com/api/token"); | |||||
const body = stringify({ | |||||
grant_type: "refresh_token", | |||||
refresh_token, | |||||
}); | |||||
const response = await fetch(`${url}`, { | |||||
method: "POST", | |||||
headers: { | |||||
Authorization, | |||||
"Content-Type": "application/x-www-form-urlencoded", | |||||
}, | |||||
body, | |||||
}).then((r) => r.json()); | |||||
return `Bearer ${response.access_token}`; | |||||
} | |||||
const NOW_PLAYING_ENDPOINT = `https://api.spotify.com/v1/me/player/currently-playing`; | |||||
export async function nowPlaying() { | |||||
const Authorization = await getAuthorizationToken(); | |||||
const response = await fetch(NOW_PLAYING_ENDPOINT, { | |||||
headers: { | |||||
Authorization, | |||||
}, | |||||
}); | |||||
const { status } = response; | |||||
if (status === 204) { | |||||
return {}; | |||||
} else if (status === 200) { | |||||
const data = await response.json(); | |||||
return data; | |||||
} | |||||
} |
@ -0,0 +1,4 @@ | |||||
{ | |||||
"version": 2, | |||||
"rewrites": [{ "source": "/(.*)", "destination": "/api/$1" }] | |||||
} |