Works fine with clientCredentialGrant flow of spotify

This commit is contained in:
KRTirtho 2021-02-17 22:44:43 +06:00
parent b97a8edc5b
commit 114bd0838a
15 changed files with 2524 additions and 485 deletions

5
.gitignore vendored
View File

@ -1,5 +1,10 @@
node_modules node_modules
dist/ dist/
*.log *.log
# user specific
cache/ cache/
local/ local/
# deply build binaries
deploy/linux/build
deploy/win32/build
deploy/darwin/build

2763
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "webpack -p", "build": "webpack --mode=production",
"dev": "TSC_WATCHFILE=UseFsEvents webpack --mode=development", "dev": "TSC_WATCHFILE=UseFsEvents webpack --mode=development",
"start": "qode ./dist/index.js", "start": "qode ./dist/index.js",
"debug": "qode --inspect ./dist/index.js" "debug": "qode --inspect ./dist/index.js"
@ -15,14 +15,18 @@
"@nodegui/nodegui": "^0.27.0", "@nodegui/nodegui": "^0.27.0",
"@nodegui/react-nodegui": "^0.10.0", "@nodegui/react-nodegui": "^0.10.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"base64url": "^3.0.1",
"dotenv": "^8.2.0",
"du": "^1.0.0", "du": "^1.0.0",
"express": "^4.17.1",
"is-url": "^1.2.4", "is-url": "^1.2.4",
"jimp": "^0.16.1",
"node-localstorage": "^2.1.6", "node-localstorage": "^2.1.6",
"node-mpv": "^2.0.0-beta.1", "node-mpv": "^2.0.0-beta.1",
"open": "^7.4.1",
"react": "^16.14.0", "react": "^16.14.0",
"react-router": "^5.2.0", "react-router": "^5.2.0",
"scrape-yt": "^1.4.7", "scrape-yt": "^1.4.7",
"sharp": "^0.27.1",
"spotify-web-api-node": "^5.0.2" "spotify-web-api-node": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
@ -32,12 +36,12 @@
"@babel/preset-typescript": "^7.10.4", "@babel/preset-typescript": "^7.10.4",
"@nodegui/packer": "^1.4.1", "@nodegui/packer": "^1.4.1",
"@types/du": "^1.0.0", "@types/du": "^1.0.0",
"@types/express": "^4.17.11",
"@types/is-url": "^1.2.28", "@types/is-url": "^1.2.28",
"@types/node": "^14.11.1", "@types/node": "^14.11.1",
"@types/node-localstorage": "^1.3.0", "@types/node-localstorage": "^1.3.0",
"@types/react": "^16.9.49", "@types/react": "^16.9.49",
"@types/react-router": "^5.1.11", "@types/react-router": "^5.1.11",
"@types/sharp": "^0.27.1",
"@types/spotify-web-api-node": "^5.0.0", "@types/spotify-web-api-node": "^5.0.0",
"@types/webpack-env": "^1.15.3", "@types/webpack-env": "^1.15.3",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",

View File

@ -9,9 +9,11 @@ import { LocalStorage } from "node-localstorage";
import authContext from "./context/authContext"; import authContext from "./context/authContext";
import playerContext, { CurrentPlaylist, CurrentTrack } from "./context/playerContext"; import playerContext, { CurrentPlaylist, CurrentTrack } from "./context/playerContext";
import Player, { audioPlayer } from "./components/Player"; import Player, { audioPlayer } from "./components/Player";
import { redirectURI } from "./conf";
export enum CredentialKeys { export enum CredentialKeys {
credentials = "credentials", credentials = "credentials",
refresh_token = "refresh_token"
} }
export interface Credentials { export interface Credentials {
@ -26,16 +28,16 @@ global.localStorage = new LocalStorage("./local");
function RootApp() { function RootApp() {
const windowRef = useRef<QMainWindow>(); const windowRef = useRef<QMainWindow>();
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false); const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
const [spotifyAuth, setSpotifyAuth] = useState({ clientId: "", clientSecret: "" }); const [credentials, setCredentials] = useState<Credentials>({ clientId: "", clientSecret: "" });
const [access_token, setAccess_token] = useState<string>(""); const [access_token, setAccess_token] = useState<string>("");
const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>(); const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
const [currentTrack, setCurrentTrack] = useState<CurrentTrack>(); const [currentTrack, setCurrentTrack] = useState<CurrentTrack>();
const spotifyApi = new SpotifyWebApi({ ...spotifyAuth }); const spotifyApi = new SpotifyWebApi({ redirectUri: redirectURI, ...credentials });
const credentialStr = localStorage.getItem(CredentialKeys.credentials); const cachedCredentials = localStorage.getItem(CredentialKeys.credentials);
useEffect(() => { useEffect(() => {
setIsLoggedIn(!!credentialStr); setIsLoggedIn(!!cachedCredentials);
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -62,12 +64,9 @@ function RootApp() {
console.error("Spotify Client Credential not granted for: ", error); console.error("Spotify Client Credential not granted for: ", error);
}); });
} }
if (cachedCredentials) {
if (!credentialStr) { setCredentials(JSON.parse(cachedCredentials));
return;
} }
const credentials = JSON.parse(credentialStr) as Credentials;
setSpotifyAuth(credentials);
}, [isLoggedIn]); }, [isLoggedIn]);
return ( return (

View File

@ -7,7 +7,7 @@ import CachedImage from "./shared/CachedImage";
import { CursorShape } from "@nodegui/nodegui"; import { CursorShape } from "@nodegui/nodegui";
function Home() { function Home() {
const { spotifyApi } = useContext(playerContext); const { spotifyApi, currentPlaylist } = useContext(playerContext);
const { isLoggedIn, access_token } = useContext(authContext); const { isLoggedIn, access_token } = useContext(authContext);
const [categories, setCategories] = useState<SpotifyApi.CategoryObject[]>([]); const [categories, setCategories] = useState<SpotifyApi.CategoryObject[]>([]);
@ -28,7 +28,7 @@ function Home() {
return ( return (
<ScrollArea style={`flex-grow: 1; border: none; flex: 1;`}> <ScrollArea style={`flex-grow: 1; border: none; flex: 1;`}>
<View style={`flex-direction: 'column'; justify-content: 'center'; flex: 1;`}> <View style={`flex-direction: 'column'; justify-content: 'center'; flex: 1;`}>
<CategoryCard key={((Math.random() * Date.now()) / Math.random()) * 100} id="current" name="Currently Playing" /> {currentPlaylist && <CategoryCard key={((Math.random() * Date.now()) / Math.random()) * 100} id="current" name="Currently Playing" />}
{isLoggedIn && {isLoggedIn &&
categories.map(({ id, name }, index) => { categories.map(({ id, name }, index) => {
return <CategoryCard key={((index * Date.now()) / Math.random()) * 100} id={id} name={name} />; return <CategoryCard key={((index * Date.now()) / Math.random()) * 100} id={id} name={name} />;
@ -54,20 +54,16 @@ function CategoryCard({ id, name }: CategoryCardProps) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
if (access_token) { if (id !== "current") {
if (id === "current") {
} else {
spotifyApi.setAccessToken(access_token); spotifyApi.setAccessToken(access_token);
const playlistsRes = await spotifyApi.getPlaylistsForCategory(id, { limit: 4 });
const playlistsRes = await spotifyApi.getPlaylistsForCategory(id, { limit: 5 });
setPlaylists(playlistsRes.body.playlists.items); setPlaylists(playlistsRes.body.playlists.items);
} }
}
} catch (error) { } catch (error) {
console.error(`Failed to get playlists of category ${name} for: `, error); console.error(`Failed to get playlists of category ${name} for: `, error);
} }
})(); })();
}, [access_token]); }, []);
function goToGenre() { function goToGenre() {
history.push(`/genre/playlists/${id}`, { name }); history.push(`/genre/playlists/${id}`, { name });
@ -102,11 +98,11 @@ function CategoryCard({ id, name }: CategoryCardProps) {
} }
`; `;
if (playlists.length > 0 && id!=="current") {
return ( return (
<View id="container" styleSheet={categoryStylesheet}> <View id="container" styleSheet={categoryStylesheet}>
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} /> {(playlists.length > 0 || id === "current") && <Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />}
<View id="child-view"> <View id="child-view">
{id === "current" && currentPlaylist && <PlaylistCard key={(Date.now() / Math.random()) * 100} {...currentPlaylist} />}
{isLoggedIn && {isLoggedIn &&
playlists.map((playlist, index) => { playlists.map((playlist, index) => {
return <PlaylistCard key={((index * Date.now()) / Math.random()) * 100} id={playlist.id} name={playlist.name} thumbnail={playlist.images[0].url} />; return <PlaylistCard key={((index * Date.now()) / Math.random()) * 100} id={playlist.id} name={playlist.name} thumbnail={playlist.images[0].url} />;
@ -114,18 +110,6 @@ function CategoryCard({ id, name }: CategoryCardProps) {
</View> </View>
</View> </View>
); );
}
if (id === "current" && currentPlaylist) {
return (
<View id="container" styleSheet={categoryStylesheet}>
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />
<View id="child-view">
<PlaylistCard key={(Date.now() / Math.random()) * 100} {...currentPlaylist} />
</View>
</View>
);
}
return <></>;
} }
interface PlaylistCardProps { interface PlaylistCardProps {

View File

@ -1,8 +1,7 @@
import React, { useContext, useState } from "react"; import React, { useContext, useState } from "react";
import { LineEdit, Text, Button, BoxView } from "@nodegui/react-nodegui"; import { LineEdit, Text, Button, View } from "@nodegui/react-nodegui";
import authContext from "../context/authContext"; import authContext from "../context/authContext";
import { CredentialKeys, Credentials } from "../app"; import { CredentialKeys, Credentials } from "../app";
import { Direction } from "@nodegui/nodegui";
function Login() { function Login() {
const { setIsLoggedIn } = useContext(authContext); const { setIsLoggedIn } = useContext(authContext);
@ -29,7 +28,7 @@ function Login() {
} }
return ( return (
<BoxView direction={Direction.TopToBottom}> <View style={`flex: 1; flex-direction: 'column';`}>
<Text>{`<center><h1>Add Spotify & Youtube credentials to get started</h1></center>`}</Text> <Text>{`<center><h1>Add Spotify & Youtube credentials to get started</h1></center>`}</Text>
<Text>{`<center><p>Don't worry any of the credentials won't be collected or used for abuses</p></center>`}</Text> <Text>{`<center><p>Don't worry any of the credentials won't be collected or used for abuses</p></center>`}</Text>
<LineEdit <LineEdit
@ -67,7 +66,7 @@ function Login() {
}} }}
text="Add" text="Add"
/> />
</BoxView> </View>
); );
} }

View File

@ -8,6 +8,7 @@ import { getYoutubeTrack } from "../helpers/getYoutubeTrack";
import PlayerProgressBar from "./PlayerProgressBar"; import PlayerProgressBar from "./PlayerProgressBar";
import { random as shuffleIcon, play, pause, backward, forward, stop, heartRegular } from "../icons"; import { random as shuffleIcon, play, pause, backward, forward, stop, heartRegular } from "../icons";
import IconButton from "./shared/IconButton"; import IconButton from "./shared/IconButton";
import authContext from "../context/authContext";
export const audioPlayer = new NodeMpv( export const audioPlayer = new NodeMpv(
{ {
@ -22,8 +23,10 @@ export const audioPlayer = new NodeMpv(
); );
function Player(): ReactElement { function Player(): ReactElement {
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } = useContext(playerContext); const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist, spotifyApi } = useContext(playerContext);
const [volume, setVolume] = useState<number>(parseFloat(localStorage.getItem("volume") ?? "55")); const { access_token } = useContext(authContext);
const initVolume = parseFloat(localStorage.getItem("volume") ?? "55");
const [volume, setVolume] = useState<number>(initVolume);
const [totalDuration, setTotalDuration] = useState(0); const [totalDuration, setTotalDuration] = useState(0);
const [shuffle, setShuffle] = useState<boolean>(false); const [shuffle, setShuffle] = useState<boolean>(false);
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]); const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
@ -36,7 +39,7 @@ function Player(): ReactElement {
}, },
sliderReleased: () => { sliderReleased: () => {
localStorage.setItem("volume", volume.toString()); localStorage.setItem("volume", volume.toString());
} },
}, },
[] []
); );
@ -49,6 +52,7 @@ function Player(): ReactElement {
try { try {
if (!playerRunning) { if (!playerRunning) {
await audioPlayer.start(); await audioPlayer.start();
await audioPlayer.volume(initVolume);
} }
} catch (error) { } catch (error) {
console.error("Failed to start audio player", error); console.error("Failed to start audio player", error);
@ -62,9 +66,22 @@ function Player(): ReactElement {
}; };
}, []); }, []);
useEffect(() => {
(async () => {
try {
if (access_token) {
spotifyApi.setAccessToken(access_token);
const userSavedTrack = await spotifyApi.getMySavedTracks();
console.log("userSavedTrack:", userSavedTrack);
}
} catch (error) {
console.error("Failed to get spotify user saved tracks: ", error);
}
})();
}, [access_token]);
// track change effect // track change effect
useEffect(() => { useEffect(() => {
// titleRef.current?.setAlignment(AlignmentFlag.AlignLeft);
(async () => { (async () => {
try { try {
if (currentTrack && playerRunning) { if (currentTrack && playerRunning) {

View File

@ -1,5 +1,4 @@
import { Direction } from "@nodegui/nodegui"; import { ScrollArea, Text, View } from "@nodegui/react-nodegui";
import { BoxView, ScrollArea, Text, View } from "@nodegui/react-nodegui";
import React, { useContext, useEffect, useState } from "react"; import React, { useContext, useEffect, useState } from "react";
import { useLocation, useParams } from "react-router"; import { useLocation, useParams } from "react-router";
import authContext from "../context/authContext"; import authContext from "../context/authContext";
@ -29,10 +28,15 @@ function PlaylistGenreView() {
}, [access_token]); }, [access_token]);
const playlistGenreViewStylesheet = ` const playlistGenreViewStylesheet = `
#genre-container{
flex-direction: column;
flex: 1;
}
#heading { #heading {
padding: 10px; padding: 10px;
} }
#scroll-view{ #scroll-view{
flex: 1;
flex-grow: 1; flex-grow: 1;
border: none; border: none;
} }
@ -46,7 +50,7 @@ function PlaylistGenreView() {
`; `;
return ( return (
<BoxView direction={Direction.TopToBottom} styleSheet={playlistGenreViewStylesheet}> <View id="genre-container" styleSheet={playlistGenreViewStylesheet}>
<BackButton /> <BackButton />
<Text id="heading">{`<h2>${location.state.name}</h2>`}</Text> <Text id="heading">{`<h2>${location.state.name}</h2>`}</Text>
<ScrollArea id="scroll-view"> <ScrollArea id="scroll-view">
@ -57,7 +61,7 @@ function PlaylistGenreView() {
})} })}
</View> </View>
</ScrollArea> </ScrollArea>
</BoxView> </View>
); );
} }

6
src/conf.ts Normal file
View File

@ -0,0 +1,6 @@
import dotenv from "dotenv"
import { join } from "path";
const env = dotenv.config({path: join(process.cwd(), ".env")}).parsed as any
export const clientId = "";
export const redirectURI = "http%3A%2F%2F/localhost:4304/auth/spotify/callback/"

View File

@ -1,16 +1,29 @@
import React, { Dispatch, SetStateAction } from "react"; import React, { Dispatch, SetStateAction } from "react";
export interface AuthContext { export interface AuthContext {
isLoggedIn: boolean; isLoggedIn: boolean;
setIsLoggedIn: Dispatch<SetStateAction<boolean>>; setIsLoggedIn: Dispatch<SetStateAction<boolean>>;
access_token: string access_token: string;
/**
* the time when the current access token will expire \
* always update this with `Date.now() + expires_in`
*/
expires_in: number;
/**
* sets the time when the current access token will expire \
* always update this with `Date.now() + expires_in`
*/
setExpiresIn: Dispatch<SetStateAction<number>>;
setAccess_token: Dispatch<SetStateAction<string>>;
} }
const authContext = React.createContext<AuthContext>({ const authContext = React.createContext<AuthContext>({
isLoggedIn: false, isLoggedIn: false,
setIsLoggedIn() {}, setIsLoggedIn() {},
access_token: "" access_token: "",
expires_in: 0,
setExpiresIn() {},
setAccess_token() {},
}); });
export default authContext; export default authContext;

View File

@ -0,0 +1,35 @@
import axios, { AxiosResponse } from "axios";
import qs from "querystring";
import { redirectURI } from "../conf";
export interface AuthorizationResponse {
access_token: string;
token_type: string;
scope: string;
expires_in: number;
refresh_token: string;
}
async function authorizationCodePKCEGrant({ client_id, code, code_verifier }: { code: string; code_verifier: string; client_id: string }): Promise<AxiosResponse<AuthorizationResponse>> {
const body = {
client_id,
code,
code_verifier,
redirect_uri: redirectURI,
grant_type: "authorization_code",
};
try {
const res = await axios.post<AuthorizationResponse>("https://accounts.spotify.com/api/token", qs.stringify(body), {
headers: {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.152 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded",
},
});
return res;
} catch (error) {
throw error;
}
}
export default authorizationCodePKCEGrant;

View File

@ -0,0 +1,13 @@
import crypto from "crypto";
import base64url from "base64url";
export function generateCodeChallenge() {
try {
const code_verifier = crypto.randomBytes(64).toString("hex");
const base64Digest = crypto.createHash("sha256").update(code_verifier).digest("base64");
const code_challenge = base64url.fromBase64(base64Digest);
return {code_challenge, code_verifier};
} catch (error) {
throw error;
}
}

View File

@ -4,9 +4,10 @@ import * as fs from "fs"
import axios from "axios"; import axios from "axios";
import { Stream } from "stream"; import { Stream } from "stream";
import { streamToBuffer } from "./streamToBuffer"; import { streamToBuffer } from "./streamToBuffer";
import sharp from "sharp"; import Jimp from "jimp";
import du from "du"; import du from "du";
interface ImageDimensions { interface ImageDimensions {
height: number; height: number;
width: number; width: number;
@ -28,7 +29,7 @@ export async function getCachedImageBuffer(name: string, dims?: ImageDimensions)
fs.rmSync(cacheFolder, { recursive: true, force: true }); fs.rmSync(cacheFolder, { recursive: true, force: true });
} }
const cachedImg = await fsm.readFile(cachePath); const cachedImg = await fsm.readFile(cachePath);
const cachedImgMeta = await sharp(cachedImg).metadata(); const cachedImgMeta = (await Jimp.read(cachedImg)).bitmap;
// if the dimensions are changed then the previously cached // if the dimensions are changed then the previously cached
// images are removed and replaced with a new one // images are removed and replaced with a new one
@ -59,7 +60,8 @@ export async function getCachedImageBuffer(name: string, dims?: ImageDimensions)
async function imageResizeAndWrite(img: Buffer, { cacheFolder, cacheName, dims }: { dims: ImageDimensions; cacheFolder: string; cacheName: string }): Promise<Buffer> { async function imageResizeAndWrite(img: Buffer, { cacheFolder, cacheName, dims }: { dims: ImageDimensions; cacheFolder: string; cacheName: string }): Promise<Buffer> {
// caching the images by resizing if the max/fixed (Width/Height) // caching the images by resizing if the max/fixed (Width/Height)
// is available in the args // is available in the args
const resizedImg = await sharp(img).resize(dims.width, dims.height).toBuffer(); const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height)
await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImg); const resizedImgBuffer = await resizedImg.getBufferAsync(resizedImg._originalMime);
return resizedImg; await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImgBuffer);
return resizedImgBuffer;
} }

30
src/hooks/useAuth.ts Normal file
View File

@ -0,0 +1,30 @@
import { DependencyList, useContext, useEffect } from "react";
import authContext from "../context/authContext";
import { CredentialKeys } from "../app";
import playerContext from "../context/playerContext";
interface UseAuthResult {
access_token: string;
}
export default (deps: DependencyList = []): UseAuthResult => {
const { access_token, expires_in, isLoggedIn, setExpiresIn, setAccess_token } = useContext(authContext);
const { spotifyApi } = useContext(playerContext);
const refreshToken = localStorage.getItem(CredentialKeys.refresh_token);
useEffect(() => {
const isExpiredToken = Date.now() > expires_in;
if (isLoggedIn && isExpiredToken && refreshToken) {
spotifyApi.setRefreshToken(refreshToken);
spotifyApi
.refreshAccessToken()
.then(({ body: { access_token, expires_in } }) => {
setAccess_token(access_token);
setExpiresIn(Date.now() + expires_in);
})
.catch();
}
}, deps);
return { access_token };
};

View File

@ -8,9 +8,6 @@ module.exports = (env, argv) => {
mode: "production", mode: "production",
entry: ["./src/index.tsx"], entry: ["./src/index.tsx"],
target: "node", target: "node",
externals: {
'sharp': 'commonjs sharp',
},
output: { output: {
path: path.resolve(__dirname, "dist"), path: path.resolve(__dirname, "dist"),
filename: "index.js", filename: "index.js",