mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
Works fine with clientCredentialGrant flow of spotify
This commit is contained in:
parent
b97a8edc5b
commit
114bd0838a
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,5 +1,10 @@
|
||||
node_modules
|
||||
dist/
|
||||
*.log
|
||||
# user specific
|
||||
cache/
|
||||
local/
|
||||
# deply build binaries
|
||||
deploy/linux/build
|
||||
deploy/win32/build
|
||||
deploy/darwin/build
|
2763
package-lock.json
generated
2763
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -6,7 +6,7 @@
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack -p",
|
||||
"build": "webpack --mode=production",
|
||||
"dev": "TSC_WATCHFILE=UseFsEvents webpack --mode=development",
|
||||
"start": "qode ./dist/index.js",
|
||||
"debug": "qode --inspect ./dist/index.js"
|
||||
@ -15,14 +15,18 @@
|
||||
"@nodegui/nodegui": "^0.27.0",
|
||||
"@nodegui/react-nodegui": "^0.10.0",
|
||||
"axios": "^0.21.1",
|
||||
"base64url": "^3.0.1",
|
||||
"dotenv": "^8.2.0",
|
||||
"du": "^1.0.0",
|
||||
"express": "^4.17.1",
|
||||
"is-url": "^1.2.4",
|
||||
"jimp": "^0.16.1",
|
||||
"node-localstorage": "^2.1.6",
|
||||
"node-mpv": "^2.0.0-beta.1",
|
||||
"open": "^7.4.1",
|
||||
"react": "^16.14.0",
|
||||
"react-router": "^5.2.0",
|
||||
"scrape-yt": "^1.4.7",
|
||||
"sharp": "^0.27.1",
|
||||
"spotify-web-api-node": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -32,12 +36,12 @@
|
||||
"@babel/preset-typescript": "^7.10.4",
|
||||
"@nodegui/packer": "^1.4.1",
|
||||
"@types/du": "^1.0.0",
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/is-url": "^1.2.28",
|
||||
"@types/node": "^14.11.1",
|
||||
"@types/node-localstorage": "^1.3.0",
|
||||
"@types/react": "^16.9.49",
|
||||
"@types/react-router": "^5.1.11",
|
||||
"@types/sharp": "^0.27.1",
|
||||
"@types/spotify-web-api-node": "^5.0.0",
|
||||
"@types/webpack-env": "^1.15.3",
|
||||
"babel-loader": "^8.1.0",
|
||||
|
17
src/app.tsx
17
src/app.tsx
@ -9,9 +9,11 @@ import { LocalStorage } from "node-localstorage";
|
||||
import authContext from "./context/authContext";
|
||||
import playerContext, { CurrentPlaylist, CurrentTrack } from "./context/playerContext";
|
||||
import Player, { audioPlayer } from "./components/Player";
|
||||
import { redirectURI } from "./conf";
|
||||
|
||||
export enum CredentialKeys {
|
||||
credentials = "credentials",
|
||||
refresh_token = "refresh_token"
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
@ -26,16 +28,16 @@ global.localStorage = new LocalStorage("./local");
|
||||
function RootApp() {
|
||||
const windowRef = useRef<QMainWindow>();
|
||||
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 [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
|
||||
const [currentTrack, setCurrentTrack] = useState<CurrentTrack>();
|
||||
|
||||
const spotifyApi = new SpotifyWebApi({ ...spotifyAuth });
|
||||
const credentialStr = localStorage.getItem(CredentialKeys.credentials);
|
||||
const spotifyApi = new SpotifyWebApi({ redirectUri: redirectURI, ...credentials });
|
||||
const cachedCredentials = localStorage.getItem(CredentialKeys.credentials);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoggedIn(!!credentialStr);
|
||||
setIsLoggedIn(!!cachedCredentials);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -62,12 +64,9 @@ function RootApp() {
|
||||
console.error("Spotify Client Credential not granted for: ", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (!credentialStr) {
|
||||
return;
|
||||
if (cachedCredentials) {
|
||||
setCredentials(JSON.parse(cachedCredentials));
|
||||
}
|
||||
const credentials = JSON.parse(credentialStr) as Credentials;
|
||||
setSpotifyAuth(credentials);
|
||||
}, [isLoggedIn]);
|
||||
|
||||
return (
|
||||
|
@ -7,7 +7,7 @@ import CachedImage from "./shared/CachedImage";
|
||||
import { CursorShape } from "@nodegui/nodegui";
|
||||
|
||||
function Home() {
|
||||
const { spotifyApi } = useContext(playerContext);
|
||||
const { spotifyApi, currentPlaylist } = useContext(playerContext);
|
||||
const { isLoggedIn, access_token } = useContext(authContext);
|
||||
const [categories, setCategories] = useState<SpotifyApi.CategoryObject[]>([]);
|
||||
|
||||
@ -28,7 +28,7 @@ function Home() {
|
||||
return (
|
||||
<ScrollArea style={`flex-grow: 1; border: none; 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 &&
|
||||
categories.map(({ id, name }, index) => {
|
||||
return <CategoryCard key={((index * Date.now()) / Math.random()) * 100} id={id} name={name} />;
|
||||
@ -54,20 +54,16 @@ function CategoryCard({ id, name }: CategoryCardProps) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (access_token) {
|
||||
if (id === "current") {
|
||||
} else {
|
||||
if (id !== "current") {
|
||||
spotifyApi.setAccessToken(access_token);
|
||||
|
||||
const playlistsRes = await spotifyApi.getPlaylistsForCategory(id, { limit: 5 });
|
||||
const playlistsRes = await spotifyApi.getPlaylistsForCategory(id, { limit: 4 });
|
||||
setPlaylists(playlistsRes.body.playlists.items);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get playlists of category ${name} for: `, error);
|
||||
}
|
||||
})();
|
||||
}, [access_token]);
|
||||
}, []);
|
||||
|
||||
function goToGenre() {
|
||||
history.push(`/genre/playlists/${id}`, { name });
|
||||
@ -102,11 +98,11 @@ function CategoryCard({ id, name }: CategoryCardProps) {
|
||||
}
|
||||
`;
|
||||
|
||||
if (playlists.length > 0 && id!=="current") {
|
||||
return (
|
||||
<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">
|
||||
{id === "current" && currentPlaylist && <PlaylistCard key={(Date.now() / Math.random()) * 100} {...currentPlaylist} />}
|
||||
{isLoggedIn &&
|
||||
playlists.map((playlist, index) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
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 {
|
||||
|
@ -1,8 +1,7 @@
|
||||
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 { CredentialKeys, Credentials } from "../app";
|
||||
import { Direction } from "@nodegui/nodegui";
|
||||
|
||||
function Login() {
|
||||
const { setIsLoggedIn } = useContext(authContext);
|
||||
@ -29,7 +28,7 @@ function Login() {
|
||||
}
|
||||
|
||||
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><p>Don't worry any of the credentials won't be collected or used for abuses</p></center>`}</Text>
|
||||
<LineEdit
|
||||
@ -67,7 +66,7 @@ function Login() {
|
||||
}}
|
||||
text="Add"
|
||||
/>
|
||||
</BoxView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,7 @@ import { getYoutubeTrack } from "../helpers/getYoutubeTrack";
|
||||
import PlayerProgressBar from "./PlayerProgressBar";
|
||||
import { random as shuffleIcon, play, pause, backward, forward, stop, heartRegular } from "../icons";
|
||||
import IconButton from "./shared/IconButton";
|
||||
import authContext from "../context/authContext";
|
||||
|
||||
export const audioPlayer = new NodeMpv(
|
||||
{
|
||||
@ -22,8 +23,10 @@ export const audioPlayer = new NodeMpv(
|
||||
);
|
||||
|
||||
function Player(): ReactElement {
|
||||
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } = useContext(playerContext);
|
||||
const [volume, setVolume] = useState<number>(parseFloat(localStorage.getItem("volume") ?? "55"));
|
||||
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist, spotifyApi } = useContext(playerContext);
|
||||
const { access_token } = useContext(authContext);
|
||||
const initVolume = parseFloat(localStorage.getItem("volume") ?? "55");
|
||||
const [volume, setVolume] = useState<number>(initVolume);
|
||||
const [totalDuration, setTotalDuration] = useState(0);
|
||||
const [shuffle, setShuffle] = useState<boolean>(false);
|
||||
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
|
||||
@ -36,7 +39,7 @@ function Player(): ReactElement {
|
||||
},
|
||||
sliderReleased: () => {
|
||||
localStorage.setItem("volume", volume.toString());
|
||||
}
|
||||
},
|
||||
},
|
||||
[]
|
||||
);
|
||||
@ -49,6 +52,7 @@ function Player(): ReactElement {
|
||||
try {
|
||||
if (!playerRunning) {
|
||||
await audioPlayer.start();
|
||||
await audioPlayer.volume(initVolume);
|
||||
}
|
||||
} catch (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
|
||||
useEffect(() => {
|
||||
// titleRef.current?.setAlignment(AlignmentFlag.AlignLeft);
|
||||
(async () => {
|
||||
try {
|
||||
if (currentTrack && playerRunning) {
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Direction } from "@nodegui/nodegui";
|
||||
import { BoxView, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||
import { ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useLocation, useParams } from "react-router";
|
||||
import authContext from "../context/authContext";
|
||||
@ -29,10 +28,15 @@ function PlaylistGenreView() {
|
||||
}, [access_token]);
|
||||
|
||||
const playlistGenreViewStylesheet = `
|
||||
#genre-container{
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
#heading {
|
||||
padding: 10px;
|
||||
}
|
||||
#scroll-view{
|
||||
flex: 1;
|
||||
flex-grow: 1;
|
||||
border: none;
|
||||
}
|
||||
@ -46,7 +50,7 @@ function PlaylistGenreView() {
|
||||
`;
|
||||
|
||||
return (
|
||||
<BoxView direction={Direction.TopToBottom} styleSheet={playlistGenreViewStylesheet}>
|
||||
<View id="genre-container" styleSheet={playlistGenreViewStylesheet}>
|
||||
<BackButton />
|
||||
<Text id="heading">{`<h2>${location.state.name}</h2>`}</Text>
|
||||
<ScrollArea id="scroll-view">
|
||||
@ -57,7 +61,7 @@ function PlaylistGenreView() {
|
||||
})}
|
||||
</View>
|
||||
</ScrollArea>
|
||||
</BoxView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
6
src/conf.ts
Normal file
6
src/conf.ts
Normal 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/"
|
@ -1,16 +1,29 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
|
||||
|
||||
export interface AuthContext {
|
||||
isLoggedIn: 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>({
|
||||
isLoggedIn: false,
|
||||
setIsLoggedIn() {},
|
||||
access_token: ""
|
||||
access_token: "",
|
||||
expires_in: 0,
|
||||
setExpiresIn() {},
|
||||
setAccess_token() {},
|
||||
});
|
||||
|
||||
export default authContext;
|
||||
|
35
src/helpers/authorizationCodePKCEGrant.ts
Normal file
35
src/helpers/authorizationCodePKCEGrant.ts
Normal 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;
|
13
src/helpers/generateCodeChallenge.ts
Normal file
13
src/helpers/generateCodeChallenge.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -4,9 +4,10 @@ import * as fs from "fs"
|
||||
import axios from "axios";
|
||||
import { Stream } from "stream";
|
||||
import { streamToBuffer } from "./streamToBuffer";
|
||||
import sharp from "sharp";
|
||||
import Jimp from "jimp";
|
||||
import du from "du";
|
||||
|
||||
|
||||
interface ImageDimensions {
|
||||
height: number;
|
||||
width: number;
|
||||
@ -28,7 +29,7 @@ export async function getCachedImageBuffer(name: string, dims?: ImageDimensions)
|
||||
fs.rmSync(cacheFolder, { recursive: true, force: true });
|
||||
}
|
||||
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
|
||||
// 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> {
|
||||
// caching the images by resizing if the max/fixed (Width/Height)
|
||||
// is available in the args
|
||||
const resizedImg = await sharp(img).resize(dims.width, dims.height).toBuffer();
|
||||
await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImg);
|
||||
return resizedImg;
|
||||
const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height)
|
||||
const resizedImgBuffer = await resizedImg.getBufferAsync(resizedImg._originalMime);
|
||||
await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImgBuffer);
|
||||
return resizedImgBuffer;
|
||||
}
|
||||
|
30
src/hooks/useAuth.ts
Normal file
30
src/hooks/useAuth.ts
Normal 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 };
|
||||
};
|
@ -8,9 +8,6 @@ module.exports = (env, argv) => {
|
||||
mode: "production",
|
||||
entry: ["./src/index.tsx"],
|
||||
target: "node",
|
||||
externals: {
|
||||
'sharp': 'commonjs sharp',
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
filename: "index.js",
|
||||
|
Loading…
Reference in New Issue
Block a user