mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
feat(api): fetch listens from spotify
This commit is contained in:
parent
f253a66f86
commit
f2065d3f1f
54 changed files with 1180 additions and 256 deletions
|
|
@ -1,22 +1,17 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Route, Router, Switch } from "react-router-dom";
|
import { Route, Router, Switch } from "react-router-dom";
|
||||||
import NavBar from "./components/NavBar";
|
|
||||||
import PrivateRoute from "./components/PrivateRoute";
|
|
||||||
import Profile from "./views/Profile";
|
|
||||||
import ExternalApi from "./views/ExternalApi";
|
|
||||||
import history from "./utils/history";
|
import history from "./utils/history";
|
||||||
|
import { NavBar } from "./components/NavBar";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
<Router history={history}>
|
<Router history={history}>
|
||||||
<header>
|
<header>
|
||||||
<NavBar />
|
<NavBar></NavBar>
|
||||||
</header>
|
</header>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" exact />
|
<Route path="/" exact />
|
||||||
<PrivateRoute path="/profile" component={Profile} />
|
|
||||||
<PrivateRoute path="/external-api" component={ExternalApi} />
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</Router>
|
</Router>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"domain": "listory.eu.auth0.com",
|
|
||||||
"clientId": "pYSTOYOsmenpCYj6TsRNQ8dprlqoQTt3",
|
|
||||||
"audience": "https://listory.apricote.de/api/v1"
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { useAuth0 } from "../react-auth0-spa";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
const NavBar = () => {
|
|
||||||
const { isAuthenticated, loginWithRedirect, logout } = useAuth0();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<button onClick={() => loginWithRedirect({})}>Log in</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAuthenticated && <button onClick={() => logout()}>Log out</button>}
|
|
||||||
|
|
||||||
{isAuthenticated && (
|
|
||||||
<span>
|
|
||||||
<Link to="/">Home</Link>
|
|
||||||
<Link to="/profile">Profile</Link>
|
|
||||||
<Link to="/external-api">External API</Link>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavBar;
|
|
||||||
9
frontend/src/components/NavBar.jsx
Normal file
9
frontend/src/components/NavBar.jsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function NavBar() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<a href="/api/v1/auth/spotify">Login with Spotify</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { Route } from "react-router-dom";
|
|
||||||
import { useAuth0 } from "../react-auth0-spa";
|
|
||||||
|
|
||||||
const PrivateRoute = ({ component: Component, path, ...rest }) => {
|
|
||||||
const { loading, isAuthenticated, loginWithRedirect } = useAuth0();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading || isAuthenticated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fn = async () => {
|
|
||||||
await loginWithRedirect({
|
|
||||||
appState: { targetUrl: path }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
fn();
|
|
||||||
}, [loading, isAuthenticated, loginWithRedirect, path]);
|
|
||||||
|
|
||||||
const render = props =>
|
|
||||||
isAuthenticated === true ? <Component {...props} /> : null;
|
|
||||||
|
|
||||||
return <Route path={path} render={render} {...rest} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PrivateRoute;
|
|
||||||
|
|
@ -2,31 +2,7 @@ import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import * as serviceWorker from "./serviceWorker";
|
import * as serviceWorker from "./serviceWorker";
|
||||||
import { Auth0Provider } from "./react-auth0-spa";
|
|
||||||
import config from "./auth_config.json";
|
|
||||||
import history from "./utils/history";
|
|
||||||
|
|
||||||
// A function that routes the user to the right place
|
ReactDOM.render(<App />, document.getElementById("root"));
|
||||||
// after login
|
|
||||||
const onRedirectCallback = appState => {
|
|
||||||
history.push(
|
|
||||||
appState && appState.targetUrl
|
|
||||||
? appState.targetUrl
|
|
||||||
: window.location.pathname
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ReactDOM.render(
|
|
||||||
<Auth0Provider
|
|
||||||
domain={config.domain}
|
|
||||||
client_id={config.clientId}
|
|
||||||
redirect_uri={window.location.origin}
|
|
||||||
audience={config.audience}
|
|
||||||
onRedirectCallback={onRedirectCallback}
|
|
||||||
>
|
|
||||||
<App />
|
|
||||||
</Auth0Provider>,
|
|
||||||
document.getElementById("root")
|
|
||||||
);
|
|
||||||
|
|
||||||
serviceWorker.unregister();
|
serviceWorker.unregister();
|
||||||
|
|
|
||||||
89
frontend/src/react-auth0-spa.js
vendored
89
frontend/src/react-auth0-spa.js
vendored
|
|
@ -1,89 +0,0 @@
|
||||||
import React, { useState, useEffect, useContext } from "react";
|
|
||||||
import createAuth0Client from "@auth0/auth0-spa-js";
|
|
||||||
|
|
||||||
const DEFAULT_REDIRECT_CALLBACK = () =>
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
|
|
||||||
export const Auth0Context = React.createContext();
|
|
||||||
export const useAuth0 = () => useContext(Auth0Context);
|
|
||||||
export const Auth0Provider = ({
|
|
||||||
children,
|
|
||||||
onRedirectCallback = DEFAULT_REDIRECT_CALLBACK,
|
|
||||||
...initOptions
|
|
||||||
}) => {
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState();
|
|
||||||
const [user, setUser] = useState();
|
|
||||||
const [auth0Client, setAuth0] = useState();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [popupOpen, setPopupOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initAuth0 = async () => {
|
|
||||||
const auth0FromHook = await createAuth0Client(initOptions);
|
|
||||||
setAuth0(auth0FromHook);
|
|
||||||
|
|
||||||
if (
|
|
||||||
window.location.search.includes("code=") &&
|
|
||||||
window.location.search.includes("state=")
|
|
||||||
) {
|
|
||||||
const { appState } = await auth0FromHook.handleRedirectCallback();
|
|
||||||
onRedirectCallback(appState);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAuthenticated = await auth0FromHook.isAuthenticated();
|
|
||||||
|
|
||||||
setIsAuthenticated(isAuthenticated);
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
const user = await auth0FromHook.getUser();
|
|
||||||
setUser(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
initAuth0();
|
|
||||||
// eslint-disable-next-line
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loginWithPopup = async (params = {}) => {
|
|
||||||
setPopupOpen(true);
|
|
||||||
try {
|
|
||||||
await auth0Client.loginWithPopup(params);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setPopupOpen(false);
|
|
||||||
}
|
|
||||||
const user = await auth0Client.getUser();
|
|
||||||
setUser(user);
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRedirectCallback = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await auth0Client.handleRedirectCallback();
|
|
||||||
const user = await auth0Client.getUser();
|
|
||||||
setLoading(false);
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
setUser(user);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Auth0Context.Provider
|
|
||||||
value={{
|
|
||||||
isAuthenticated,
|
|
||||||
user,
|
|
||||||
loading,
|
|
||||||
popupOpen,
|
|
||||||
loginWithPopup,
|
|
||||||
handleRedirectCallback,
|
|
||||||
getIdTokenClaims: (...p) => auth0Client.getIdTokenClaims(...p),
|
|
||||||
loginWithRedirect: (...p) => auth0Client.loginWithRedirect(...p),
|
|
||||||
getTokenSilently: (...p) => auth0Client.getTokenSilently(...p),
|
|
||||||
getTokenWithPopup: (...p) => auth0Client.getTokenWithPopup(...p),
|
|
||||||
logout: (...p) => auth0Client.logout(...p)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Auth0Context.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useAuth0 } from "../react-auth0-spa";
|
|
||||||
|
|
||||||
const ExternalApi = () => {
|
|
||||||
const [showResult, setShowResult] = useState(false);
|
|
||||||
const [apiMessage, setApiMessage] = useState("");
|
|
||||||
const { getTokenSilently } = useAuth0();
|
|
||||||
|
|
||||||
const callApi = async () => {
|
|
||||||
try {
|
|
||||||
const token = await getTokenSilently();
|
|
||||||
|
|
||||||
const response = await fetch("/api/v1/connections", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
setShowResult(true);
|
|
||||||
setApiMessage(responseData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1>External API</h1>
|
|
||||||
<button onClick={callApi}>Ping API</button>
|
|
||||||
{showResult && <code>{JSON.stringify(apiMessage, null, 2)}</code>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExternalApi;
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import React, { Fragment } from "react";
|
|
||||||
import { useAuth0 } from "../react-auth0-spa";
|
|
||||||
|
|
||||||
const Profile = () => {
|
|
||||||
const { loading, user } = useAuth0();
|
|
||||||
|
|
||||||
if (loading || !user) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<img src={user.picture} alt="Profile" />
|
|
||||||
|
|
||||||
<h2>{user.name}</h2>
|
|
||||||
<p>{user.email}</p>
|
|
||||||
<code>{JSON.stringify(user, null, 2)}</code>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Profile;
|
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { DatabaseModule } from "./database/database.module";
|
import { DatabaseModule } from "./database/database.module";
|
||||||
|
import { ListensModule } from "./listens/listens.module";
|
||||||
|
import { MusicLibraryModule } from "./music-library/music-library.module";
|
||||||
import { SourcesModule } from "./sources/sources.module";
|
import { SourcesModule } from "./sources/sources.module";
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
SourcesModule
|
SourcesModule,
|
||||||
|
MusicLibraryModule,
|
||||||
|
ListensModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import { AuthService } from "./auth.service";
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpotifyStrategy extends PassportStrategy(Strategy) {
|
export class SpotifyStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly authService: AuthService,
|
||||||
private readonly authService: AuthService
|
config: ConfigService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
clientID: config.get<string>("SPOTIFY_CLIENT_ID"),
|
clientID: config.get<string>("SPOTIFY_CLIENT_ID"),
|
||||||
|
|
|
||||||
8
src/listens/dto/create-listen.dto.ts
Normal file
8
src/listens/dto/create-listen.dto.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Track } from "../../music-library/track.entity";
|
||||||
|
import { User } from "../../users/user.entity";
|
||||||
|
|
||||||
|
export class CreateListenDto {
|
||||||
|
track: Track;
|
||||||
|
user: User;
|
||||||
|
playedAt: Date;
|
||||||
|
}
|
||||||
25
src/listens/listen.entity.ts
Normal file
25
src/listens/listen.entity.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn
|
||||||
|
} from "typeorm";
|
||||||
|
import { Track } from "../music-library/track.entity";
|
||||||
|
import { User } from "../users/user.entity";
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
@Index(["track", "user", "playedAt"], { unique: true })
|
||||||
|
export class Listen {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ManyToOne(type => Track)
|
||||||
|
track: Track;
|
||||||
|
|
||||||
|
@ManyToOne(type => User)
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@Column({ type: "timestamp" })
|
||||||
|
playedAt: Date;
|
||||||
|
}
|
||||||
5
src/listens/listen.repository.ts
Normal file
5
src/listens/listen.repository.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { EntityRepository, Repository } from "typeorm";
|
||||||
|
import { Listen } from "./listen.entity";
|
||||||
|
|
||||||
|
@EntityRepository(Listen)
|
||||||
|
export class ListenRepository extends Repository<Listen> {}
|
||||||
11
src/listens/listens.module.ts
Normal file
11
src/listens/listens.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ListensService } from "./listens.service";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { ListenRepository } from "./listen.repository";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([ListenRepository])],
|
||||||
|
providers: [ListensService],
|
||||||
|
exports: [ListensService]
|
||||||
|
})
|
||||||
|
export class ListensModule {}
|
||||||
18
src/listens/listens.service.spec.ts
Normal file
18
src/listens/listens.service.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { ListensService } from "./listens.service";
|
||||||
|
|
||||||
|
describe("ListensService", () => {
|
||||||
|
let service: ListensService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [ListensService]
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<ListensService>(ListensService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
35
src/listens/listens.service.ts
Normal file
35
src/listens/listens.service.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { Listen } from "./listen.entity";
|
||||||
|
import { ListenRepository } from "./listen.repository";
|
||||||
|
import { CreateListenDto } from "./dto/create-listen.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ListensService {
|
||||||
|
constructor(private readonly listenRepository: ListenRepository) {}
|
||||||
|
|
||||||
|
async createListen({
|
||||||
|
user,
|
||||||
|
track,
|
||||||
|
playedAt
|
||||||
|
}: CreateListenDto): Promise<Listen> {
|
||||||
|
const listen = this.listenRepository.create();
|
||||||
|
|
||||||
|
listen.user = user;
|
||||||
|
listen.track = track;
|
||||||
|
listen.playedAt = playedAt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.listenRepository.save(listen);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "23505") {
|
||||||
|
return this.listenRepository.findOne({
|
||||||
|
where: { user, track, playedAt }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return listen;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/music-library/album.entity.ts
Normal file
36
src/music-library/album.entity.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToMany,
|
||||||
|
JoinTable,
|
||||||
|
OneToMany
|
||||||
|
} from "typeorm";
|
||||||
|
import { SpotifyLibraryDetails } from "src/sources/spotify/spotify-library-details.entity";
|
||||||
|
import { Artist } from "./artist.entity";
|
||||||
|
import { Track } from "./track.entity";
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Album {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ManyToMany(
|
||||||
|
type => Artist,
|
||||||
|
artist => artist.albums
|
||||||
|
)
|
||||||
|
@JoinTable()
|
||||||
|
artists: Artist[];
|
||||||
|
|
||||||
|
@OneToMany(
|
||||||
|
type => Track,
|
||||||
|
track => track.album
|
||||||
|
)
|
||||||
|
tracks: Track[];
|
||||||
|
|
||||||
|
@Column(type => SpotifyLibraryDetails)
|
||||||
|
spotify: SpotifyLibraryDetails;
|
||||||
|
}
|
||||||
5
src/music-library/album.repository.ts
Normal file
5
src/music-library/album.repository.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { EntityRepository, Repository } from "typeorm";
|
||||||
|
import { Album } from "./album.entity";
|
||||||
|
|
||||||
|
@EntityRepository(Album)
|
||||||
|
export class AlbumRepository extends Repository<Album> {}
|
||||||
21
src/music-library/artist.entity.ts
Normal file
21
src/music-library/artist.entity.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from "typeorm";
|
||||||
|
import { SpotifyLibraryDetails } from "src/sources/spotify/spotify-library-details.entity";
|
||||||
|
import { Album } from "./album.entity";
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Artist {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ManyToMany(
|
||||||
|
type => Album,
|
||||||
|
album => album.artists
|
||||||
|
)
|
||||||
|
albums: Album[];
|
||||||
|
|
||||||
|
@Column(type => SpotifyLibraryDetails)
|
||||||
|
spotify: SpotifyLibraryDetails;
|
||||||
|
}
|
||||||
5
src/music-library/artist.repository.ts
Normal file
5
src/music-library/artist.repository.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { EntityRepository, Repository } from "typeorm";
|
||||||
|
import { Artist } from "./artist.entity";
|
||||||
|
|
||||||
|
@EntityRepository(Artist)
|
||||||
|
export class ArtistRepository extends Repository<Artist> {}
|
||||||
8
src/music-library/dto/create-album.dto.ts
Normal file
8
src/music-library/dto/create-album.dto.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { SpotifyLibraryDetails } from "../../sources/spotify/spotify-library-details.entity";
|
||||||
|
import { Artist } from "../artist.entity";
|
||||||
|
|
||||||
|
export class CreateAlbumDto {
|
||||||
|
name: string;
|
||||||
|
artists: Artist[];
|
||||||
|
spotify?: SpotifyLibraryDetails;
|
||||||
|
}
|
||||||
6
src/music-library/dto/create-artist.dto.ts
Normal file
6
src/music-library/dto/create-artist.dto.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { SpotifyLibraryDetails } from "../../sources/spotify/spotify-library-details.entity";
|
||||||
|
|
||||||
|
export class CreateArtistDto {
|
||||||
|
name: string;
|
||||||
|
spotify?: SpotifyLibraryDetails;
|
||||||
|
}
|
||||||
10
src/music-library/dto/create-track.dto.ts
Normal file
10
src/music-library/dto/create-track.dto.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { SpotifyLibraryDetails } from "../../sources/spotify/spotify-library-details.entity";
|
||||||
|
import { Album } from "../album.entity";
|
||||||
|
import { Artist } from "../artist.entity";
|
||||||
|
|
||||||
|
export class CreateTrackDto {
|
||||||
|
album: Album;
|
||||||
|
artists: Artist[];
|
||||||
|
name: string;
|
||||||
|
spotify?: SpotifyLibraryDetails;
|
||||||
|
}
|
||||||
5
src/music-library/dto/find-album.dto.ts
Normal file
5
src/music-library/dto/find-album.dto.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export class FindAlbumDto {
|
||||||
|
spotify: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
5
src/music-library/dto/find-artist.dto.ts
Normal file
5
src/music-library/dto/find-artist.dto.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export class FindArtistDto {
|
||||||
|
spotify: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
5
src/music-library/dto/find-track.dto.ts
Normal file
5
src/music-library/dto/find-track.dto.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export class FindTrackDto {
|
||||||
|
spotify: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
19
src/music-library/music-library.module.ts
Normal file
19
src/music-library/music-library.module.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { AlbumRepository } from "./album.repository";
|
||||||
|
import { ArtistRepository } from "./artist.repository";
|
||||||
|
import { MusicLibraryService } from "./music-library.service";
|
||||||
|
import { TrackRepository } from "./track.repository";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
AlbumRepository,
|
||||||
|
ArtistRepository,
|
||||||
|
TrackRepository
|
||||||
|
])
|
||||||
|
],
|
||||||
|
providers: [MusicLibraryService],
|
||||||
|
exports: [MusicLibraryService]
|
||||||
|
})
|
||||||
|
export class MusicLibraryModule {}
|
||||||
18
src/music-library/music-library.service.spec.ts
Normal file
18
src/music-library/music-library.service.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { MusicLibraryService } from "./music-library.service";
|
||||||
|
|
||||||
|
describe("MusicLibraryService", () => {
|
||||||
|
let service: MusicLibraryService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [MusicLibraryService]
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<MusicLibraryService>(MusicLibraryService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
78
src/music-library/music-library.service.ts
Normal file
78
src/music-library/music-library.service.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { Album } from "./album.entity";
|
||||||
|
import { AlbumRepository } from "./album.repository";
|
||||||
|
import { Artist } from "./artist.entity";
|
||||||
|
import { ArtistRepository } from "./artist.repository";
|
||||||
|
import { CreateAlbumDto } from "./dto/create-album.dto";
|
||||||
|
import { CreateArtistDto } from "./dto/create-artist.dto";
|
||||||
|
import { CreateTrackDto } from "./dto/create-track.dto";
|
||||||
|
import { FindAlbumDto } from "./dto/find-album.dto";
|
||||||
|
import { FindArtistDto } from "./dto/find-artist.dto";
|
||||||
|
import { FindTrackDto } from "./dto/find-track.dto";
|
||||||
|
import { Track } from "./track.entity";
|
||||||
|
import { TrackRepository } from "./track.repository";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MusicLibraryService {
|
||||||
|
constructor(
|
||||||
|
private readonly albumRepository: AlbumRepository,
|
||||||
|
private readonly artistRepository: ArtistRepository,
|
||||||
|
private readonly trackRepository: TrackRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findArtist(query: FindArtistDto): Promise<Artist | undefined> {
|
||||||
|
return this.artistRepository.findOne({
|
||||||
|
where: { spotify: { id: query.spotify.id } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createArtist(data: CreateArtistDto): Promise<Artist> {
|
||||||
|
const artist = this.artistRepository.create();
|
||||||
|
|
||||||
|
artist.name = data.name;
|
||||||
|
artist.spotify = data.spotify;
|
||||||
|
|
||||||
|
console.log("createArtist", { data, artist });
|
||||||
|
|
||||||
|
await this.artistRepository.save(artist);
|
||||||
|
|
||||||
|
return artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAlbum(query: FindAlbumDto): Promise<Album | undefined> {
|
||||||
|
return this.albumRepository.findOne({
|
||||||
|
where: { spotify: { id: query.spotify.id } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAlbum(data: CreateAlbumDto): Promise<Album> {
|
||||||
|
const album = this.albumRepository.create();
|
||||||
|
|
||||||
|
album.name = data.name;
|
||||||
|
album.artists = data.artists;
|
||||||
|
album.spotify = data.spotify;
|
||||||
|
|
||||||
|
await this.albumRepository.save(album);
|
||||||
|
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findTrack(query: FindTrackDto): Promise<Track | undefined> {
|
||||||
|
return this.trackRepository.findOne({
|
||||||
|
where: { spotify: { id: query.spotify.id } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTrack(data: CreateTrackDto): Promise<Track> {
|
||||||
|
const track = this.trackRepository.create();
|
||||||
|
|
||||||
|
track.name = data.name;
|
||||||
|
track.artists = data.artists;
|
||||||
|
track.album = data.album;
|
||||||
|
track.spotify = data.spotify;
|
||||||
|
|
||||||
|
await this.trackRepository.save(track);
|
||||||
|
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/music-library/track.entity.ts
Normal file
33
src/music-library/track.entity.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { SpotifyLibraryDetails } from "src/sources/spotify/spotify-library-details.entity";
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn
|
||||||
|
} from "typeorm";
|
||||||
|
import { Album } from "./album.entity";
|
||||||
|
import { Artist } from "./artist.entity";
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Track {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ManyToOne(
|
||||||
|
type => Album,
|
||||||
|
album => album.tracks
|
||||||
|
)
|
||||||
|
album: Album;
|
||||||
|
|
||||||
|
@ManyToMany(type => Artist)
|
||||||
|
@JoinTable()
|
||||||
|
artists: Artist[];
|
||||||
|
|
||||||
|
@Column(type => SpotifyLibraryDetails)
|
||||||
|
spotify?: SpotifyLibraryDetails;
|
||||||
|
}
|
||||||
5
src/music-library/track.repository.ts
Normal file
5
src/music-library/track.repository.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { EntityRepository, Repository } from "typeorm";
|
||||||
|
import { Track } from "./track.entity";
|
||||||
|
|
||||||
|
@EntityRepository(Track)
|
||||||
|
export class TrackRepository extends Repository<Track> {}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { SpotifyModule } from './spotify/spotify.module';
|
import { SpotifyModule } from "./spotify/spotify.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SpotifyModule]
|
imports: [SpotifyModule]
|
||||||
|
|
|
||||||
67
src/sources/spotify/spotify-api/entities/album-object.ts
Normal file
67
src/sources/spotify/spotify-api/entities/album-object.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { PagingObject } from "./paging-object";
|
||||||
|
import { SimplifiedTrackObject } from "./simplified-track-object";
|
||||||
|
import { SimplifiedArtistObject } from "./simplified-artist-object";
|
||||||
|
|
||||||
|
// tslint:disable: variable-name
|
||||||
|
|
||||||
|
export class AlbumObject {
|
||||||
|
/**
|
||||||
|
* A list of the genres used to classify the album.
|
||||||
|
* For example: "Prog Rock" , "Post-Grunge".
|
||||||
|
* (If not yet classified, the array is empty.)
|
||||||
|
*/
|
||||||
|
genres: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The artists of the album.
|
||||||
|
* Each artist object includes a link in href to more detailed information about the artist.
|
||||||
|
*/
|
||||||
|
artists: SimplifiedArtistObject[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link to the Web API endpoint providing full details of the album.
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify ID for the album.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label for the album.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the album.
|
||||||
|
* In case of an album takedown, the value may be an empty string.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object type: "album".
|
||||||
|
*/
|
||||||
|
type: "album";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify URI for the album.
|
||||||
|
*/
|
||||||
|
uri: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date the album was first released, for example `1981`.
|
||||||
|
* Depending on the precision, it might be shown as `1981-12` or `1981-12-15`.
|
||||||
|
*/
|
||||||
|
release_date: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precision with which `release_date` value is known: `year` , `month` , or `day`.
|
||||||
|
*/
|
||||||
|
release_date_precision: "year" | "month" | "day";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tracks of the album.
|
||||||
|
*/
|
||||||
|
tracks: PagingObject<SimplifiedTrackObject>;
|
||||||
|
}
|
||||||
32
src/sources/spotify/spotify-api/entities/artist-object.ts
Normal file
32
src/sources/spotify/spotify-api/entities/artist-object.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
export class ArtistObject {
|
||||||
|
/**
|
||||||
|
* A list of the genres the artist is associated with.
|
||||||
|
* For example: "Prog Rock" , "Post-Grunge".
|
||||||
|
* (If not yet classified, the array is empty.)
|
||||||
|
*/
|
||||||
|
genres: string[];
|
||||||
|
/**
|
||||||
|
* A link to the Web API endpoint providing full details of the artist.
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify ID for the artist.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the artist.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object type: "artist".
|
||||||
|
*/
|
||||||
|
type: "artist";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify URI for the artist.
|
||||||
|
*/
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
25
src/sources/spotify/spotify-api/entities/context-object.ts
Normal file
25
src/sources/spotify/spotify-api/entities/context-object.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { ExternalUrlObject } from "./external-url-object";
|
||||||
|
|
||||||
|
// tslint:disable variable-name
|
||||||
|
|
||||||
|
export class ContextObject {
|
||||||
|
/**
|
||||||
|
* External URLs for this context.
|
||||||
|
*/
|
||||||
|
external_urls: ExternalUrlObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link to the Web API endpoint providing full details of the track.
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object type, e.g. "artist", "playlist", "album".
|
||||||
|
*/
|
||||||
|
type: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify URI for the context.
|
||||||
|
*/
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export class ExternalUrlObject {
|
||||||
|
// No documentation for this exists
|
||||||
|
}
|
||||||
36
src/sources/spotify/spotify-api/entities/paging-object.ts
Normal file
36
src/sources/spotify/spotify-api/entities/paging-object.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
export class PagingObject<T = any> {
|
||||||
|
/**
|
||||||
|
* A link to the Web API endpoint returning the full result of the request
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The requested data
|
||||||
|
*/
|
||||||
|
items: T[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of items in the response (as set in the query or by default).
|
||||||
|
*/
|
||||||
|
limit: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL to the next page of items. ( null if none)
|
||||||
|
*/
|
||||||
|
next: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offset of the items returned (as set in the query or by default)
|
||||||
|
*/
|
||||||
|
offset: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL to the previous page of items. ( null if none)
|
||||||
|
*/
|
||||||
|
previous: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total number of items available to return.
|
||||||
|
*/
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { ContextObject } from "./context-object";
|
||||||
|
import { SimplifiedTrackObject } from "./simplified-track-object";
|
||||||
|
|
||||||
|
// tslint:disable variable-name
|
||||||
|
|
||||||
|
export class PlayHistoryObject {
|
||||||
|
/**
|
||||||
|
* The context the track was played from.
|
||||||
|
*/
|
||||||
|
context: ContextObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date and time the track was played.
|
||||||
|
*/
|
||||||
|
played_at: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The track the user listened to.
|
||||||
|
*/
|
||||||
|
track: SimplifiedTrackObject;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
// tslint:disable: variable-name
|
||||||
|
|
||||||
|
export class SimplifiedAlbumObject {
|
||||||
|
album_type: "album" | "single" | "compilation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link to the Web API endpoint providing full details of the album.
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify ID for the album.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the album. In case of an album takedown, the value may be an empty string.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object type: "album".
|
||||||
|
*/
|
||||||
|
type: "album";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify URI for the album.
|
||||||
|
*/
|
||||||
|
uri: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date the album was first released, for example `1981`.
|
||||||
|
* Depending on the precision, it might be shown as `1981-12` or `1981-12-15`.
|
||||||
|
*/
|
||||||
|
release_date: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precision with which `release_date` value is known: `year` , `month` , or `day`.
|
||||||
|
*/
|
||||||
|
release_date_precision: "year" | "month" | "day";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
export class SimplifiedArtistObject {
|
||||||
|
/**
|
||||||
|
* A link to the Web API endpoint providing full details of the artist.
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify ID for the artist.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the artist.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object type: "artist".
|
||||||
|
*/
|
||||||
|
type: "artist";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify URI for the artist.
|
||||||
|
*/
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
export class SimplifiedTrackObject {
|
||||||
|
/**
|
||||||
|
* The Spotify ID for the track.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the track.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify URI for the track.
|
||||||
|
*/
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
46
src/sources/spotify/spotify-api/entities/track-object.ts
Normal file
46
src/sources/spotify/spotify-api/entities/track-object.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { SimplifiedAlbumObject } from "./simplified-album-object";
|
||||||
|
import { SimplifiedArtistObject } from "./simplified-artist-object";
|
||||||
|
|
||||||
|
// tslint:disable: variable-name
|
||||||
|
|
||||||
|
export class TrackObject {
|
||||||
|
/**
|
||||||
|
* The album on which the track appears. The album object includes a link in href to full information about the album.
|
||||||
|
*/
|
||||||
|
album: SimplifiedAlbumObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The album on which the track appears. The album object includes a link in href to full information about the album.
|
||||||
|
*/
|
||||||
|
artists: SimplifiedArtistObject[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The track length in milliseconds.
|
||||||
|
*/
|
||||||
|
duration_ms: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link to the Web API endpoint providing full details of the track.
|
||||||
|
*/
|
||||||
|
href: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify ID for the track.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the track.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object type: "track".
|
||||||
|
*/
|
||||||
|
type: "track";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Spotify URI for the track.
|
||||||
|
*/
|
||||||
|
uri: string;
|
||||||
|
}
|
||||||
16
src/sources/spotify/spotify-api/spotify-api.module.ts
Normal file
16
src/sources/spotify/spotify-api/spotify-api.module.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { HttpModule, Module } from "@nestjs/common";
|
||||||
|
import { SpotifyApiService } from "./spotify-api.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HttpModule.registerAsync({
|
||||||
|
useFactory: () => ({
|
||||||
|
timeout: 5000,
|
||||||
|
baseURL: "https://api.spotify.com/"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
],
|
||||||
|
providers: [SpotifyApiService],
|
||||||
|
exports: [SpotifyApiService]
|
||||||
|
})
|
||||||
|
export class SpotifyApiModule {}
|
||||||
18
src/sources/spotify/spotify-api/spotify-api.service.spec.ts
Normal file
18
src/sources/spotify/spotify-api/spotify-api.service.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { SpotifyApiService } from './spotify-api.service';
|
||||||
|
|
||||||
|
describe('SpotifyApiService', () => {
|
||||||
|
let service: SpotifyApiService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [SpotifyApiService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<SpotifyApiService>(SpotifyApiService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
81
src/sources/spotify/spotify-api/spotify-api.service.ts
Normal file
81
src/sources/spotify/spotify-api/spotify-api.service.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { HttpService, Injectable } from "@nestjs/common";
|
||||||
|
import { SpotifyConnection } from "../spotify-connection.entity";
|
||||||
|
import { AlbumObject } from "./entities/album-object";
|
||||||
|
import { ArtistObject } from "./entities/artist-object";
|
||||||
|
import { PagingObject } from "./entities/paging-object";
|
||||||
|
import { PlayHistoryObject } from "./entities/play-history-object";
|
||||||
|
import { TrackObject } from "./entities/track-object";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SpotifyApiService {
|
||||||
|
constructor(private readonly httpService: HttpService) {}
|
||||||
|
|
||||||
|
async getRecentlyPlayedTracks({
|
||||||
|
accessToken,
|
||||||
|
lastRefreshTime
|
||||||
|
}: SpotifyConnection): Promise<PlayHistoryObject[]> {
|
||||||
|
console.log("SpotifyApiService#getRecentlyPlayedTracks");
|
||||||
|
|
||||||
|
const parameters: { limit: number; after?: number } = {
|
||||||
|
limit: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lastRefreshTime) {
|
||||||
|
parameters.after = lastRefreshTime.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"getRecentlyPlayedTracks parameters",
|
||||||
|
parameters,
|
||||||
|
lastRefreshTime
|
||||||
|
);
|
||||||
|
|
||||||
|
const history = await this.httpService
|
||||||
|
.get<PagingObject<PlayHistoryObject>>(`v1/me/player/recently-played`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
params: parameters
|
||||||
|
})
|
||||||
|
.toPromise();
|
||||||
|
|
||||||
|
return history.data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getArtist(
|
||||||
|
accessToken: string,
|
||||||
|
spotifyID: string
|
||||||
|
): Promise<ArtistObject> {
|
||||||
|
console.log("SpotifyApiService#getArtist");
|
||||||
|
const artist = await this.httpService
|
||||||
|
.get<ArtistObject>(`v1/artists/${spotifyID}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
})
|
||||||
|
.toPromise();
|
||||||
|
|
||||||
|
return artist.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbum(accessToken: string, spotifyID: string): Promise<AlbumObject> {
|
||||||
|
console.log("SpotifyApiService#getAlbum");
|
||||||
|
|
||||||
|
const album = await this.httpService
|
||||||
|
.get<AlbumObject>(`v1/albums/${spotifyID}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
})
|
||||||
|
.toPromise();
|
||||||
|
|
||||||
|
console.log("getAlbum", { data: album.data });
|
||||||
|
return album.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrack(accessToken: string, spotifyID: string): Promise<TrackObject> {
|
||||||
|
console.log("SpotifyApiService#getTrack");
|
||||||
|
|
||||||
|
const track = await this.httpService
|
||||||
|
.get<TrackObject>(`v1/tracks/${spotifyID}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
})
|
||||||
|
.toPromise();
|
||||||
|
|
||||||
|
return track.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/sources/spotify/spotify-auth/spotify-auth.module.ts
Normal file
16
src/sources/spotify/spotify-auth/spotify-auth.module.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { HttpModule, Module } from "@nestjs/common";
|
||||||
|
import { SpotifyAuthService } from "./spotify-auth.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HttpModule.registerAsync({
|
||||||
|
useFactory: () => ({
|
||||||
|
timeout: 5000,
|
||||||
|
baseURL: "https://accounts.spotify.com/"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
],
|
||||||
|
providers: [SpotifyAuthService],
|
||||||
|
exports: [SpotifyAuthService]
|
||||||
|
})
|
||||||
|
export class SpotifyAuthModule {}
|
||||||
51
src/sources/spotify/spotify-auth/spotify-auth.service.ts
Normal file
51
src/sources/spotify/spotify-auth/spotify-auth.service.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { HttpService, Injectable } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { SpotifyConnection } from "../spotify-connection.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SpotifyAuthService {
|
||||||
|
private readonly clientID: string;
|
||||||
|
private readonly clientSecret: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
config: ConfigService
|
||||||
|
) {
|
||||||
|
this.clientID = config.get<string>("SPOTIFY_CLIENT_ID");
|
||||||
|
this.clientSecret = config.get<string>("SPOTIFY_CLIENT_SECRET");
|
||||||
|
}
|
||||||
|
|
||||||
|
async clientCredentialsGrant(): Promise<string> {
|
||||||
|
const response = await this.httpService
|
||||||
|
.post<{ access_token: string }>(
|
||||||
|
`api/token`,
|
||||||
|
"grant_type=client_credentials",
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
username: this.clientID,
|
||||||
|
password: this.clientSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.toPromise();
|
||||||
|
|
||||||
|
return response.data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccessToken(connection: SpotifyConnection): Promise<string> {
|
||||||
|
const response = await this.httpService
|
||||||
|
.post<any>(
|
||||||
|
`api/token`,
|
||||||
|
`grant_type=refresh_token&refresh_token=${connection.refreshToken}`,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
username: this.clientID,
|
||||||
|
password: this.clientSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.toPromise();
|
||||||
|
|
||||||
|
return response.data.access_token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,4 +9,7 @@ export class SpotifyConnection {
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
|
||||||
|
@Column({ type: "timestamp", nullable: true })
|
||||||
|
lastRefreshTime?: Date;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
src/sources/spotify/spotify-library-details.entity.ts
Normal file
15
src/sources/spotify/spotify-library-details.entity.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Column } from "typeorm";
|
||||||
|
|
||||||
|
export class SpotifyLibraryDetails {
|
||||||
|
@Column()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
uri: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
type: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { SpotifyService } from './spotify.service';
|
import { UsersModule } from "src/users/users.module";
|
||||||
import { SpotifyApiModule } from './spotify-api/spotify-api.module';
|
import { MusicLibraryModule } from "../../music-library/music-library.module";
|
||||||
|
import { SpotifyApiModule } from "./spotify-api/spotify-api.module";
|
||||||
|
import { SpotifyService } from "./spotify.service";
|
||||||
|
import { ListensModule } from "../../listens/listens.module";
|
||||||
|
import { SpotifyAuthModule } from "./spotify-auth/spotify-auth.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [SpotifyService],
|
imports: [
|
||||||
imports: [SpotifyApiModule]
|
UsersModule,
|
||||||
|
ListensModule,
|
||||||
|
MusicLibraryModule,
|
||||||
|
SpotifyApiModule,
|
||||||
|
SpotifyAuthModule
|
||||||
|
],
|
||||||
|
providers: [SpotifyService]
|
||||||
})
|
})
|
||||||
export class SpotifyModule {}
|
export class SpotifyModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { SpotifyService } from './spotify.service';
|
import { SpotifyService } from "./spotify.service";
|
||||||
|
|
||||||
describe('SpotifyService', () => {
|
describe("SpotifyService", () => {
|
||||||
let service: SpotifyService;
|
let service: SpotifyService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [SpotifyService],
|
providers: [SpotifyService]
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<SpotifyService>(SpotifyService);
|
service = module.get<SpotifyService>(SpotifyService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it("should be defined", () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,255 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from "@nestjs/common";
|
||||||
import {Interval} from "@nestjs/cron"
|
import { Interval } from "@nestjs/schedule";
|
||||||
|
import { ListensService } from "../../listens/listens.service";
|
||||||
|
import { Album } from "../../music-library/album.entity";
|
||||||
|
import { Artist } from "../../music-library/artist.entity";
|
||||||
|
import { MusicLibraryService } from "../../music-library/music-library.service";
|
||||||
|
import { Track } from "../../music-library/track.entity";
|
||||||
|
import { User } from "../../users/user.entity";
|
||||||
|
import { UsersService } from "../../users/users.service";
|
||||||
|
import { AlbumObject } from "./spotify-api/entities/album-object";
|
||||||
|
import { ArtistObject } from "./spotify-api/entities/artist-object";
|
||||||
|
import { PlayHistoryObject } from "./spotify-api/entities/play-history-object";
|
||||||
|
import { TrackObject } from "./spotify-api/entities/track-object";
|
||||||
|
import { SpotifyApiService } from "./spotify-api/spotify-api.service";
|
||||||
|
import { SpotifyAuthService } from "./spotify-auth/spotify-auth.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpotifyService {
|
export class SpotifyService {
|
||||||
@Interval
|
private appAccessToken: string | null;
|
||||||
|
private appAccessTokenInProgress: Promise<void> | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly listensService: ListensService,
|
||||||
|
private readonly musicLibraryService: MusicLibraryService,
|
||||||
|
private readonly spotifyApi: SpotifyApiService,
|
||||||
|
private readonly spotifyAuth: SpotifyAuthService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Interval(20 * 1000)
|
||||||
|
async getRecentlyPlayedTracks(): Promise<void> {
|
||||||
|
console.log("SpotifyService#getRecentlyPlayedTracks");
|
||||||
|
const users = await this.usersService.findAll();
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
await this.processUser(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processUser(
|
||||||
|
user: User,
|
||||||
|
retryOnExpiredToken: boolean = true
|
||||||
|
): Promise<void> {
|
||||||
|
let playHistory: PlayHistoryObject[];
|
||||||
|
try {
|
||||||
|
playHistory = await this.spotifyApi.getRecentlyPlayedTracks(user.spotify);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response && err.response.status === 401 && retryOnExpiredToken) {
|
||||||
|
const accessToken = await this.spotifyAuth.refreshAccessToken(
|
||||||
|
user.spotify
|
||||||
|
);
|
||||||
|
await this.usersService.updateSpotifyConnection(user, {
|
||||||
|
...user.spotify,
|
||||||
|
accessToken
|
||||||
|
});
|
||||||
|
await this.processUser(user, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playHistory.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
playHistory.map(async history => {
|
||||||
|
const track = await this.importTrack(history.track.id);
|
||||||
|
|
||||||
|
this.listensService.createListen({
|
||||||
|
user,
|
||||||
|
track,
|
||||||
|
playedAt: new Date(history.played_at)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Found Listen!: ${user.displayName} listened to "${track.name}" by "${track.artists}"`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const newestPlayTime = new Date(
|
||||||
|
playHistory
|
||||||
|
.map(history => history.played_at)
|
||||||
|
.sort()
|
||||||
|
.pop()
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("newestPlayTime", {
|
||||||
|
newestPlayTime,
|
||||||
|
times: playHistory.map(history => history.played_at).sort()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.usersService.updateSpotifyConnection(user, {
|
||||||
|
...user.spotify,
|
||||||
|
lastRefreshTime: newestPlayTime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async importTrack(
|
||||||
|
spotifyID: string,
|
||||||
|
retryOnExpiredToken: boolean = true
|
||||||
|
): Promise<Track> {
|
||||||
|
const track = await this.musicLibraryService.findTrack({
|
||||||
|
spotify: { id: spotifyID }
|
||||||
|
});
|
||||||
|
if (track) {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
let spotifyTrack: TrackObject;
|
||||||
|
|
||||||
|
try {
|
||||||
|
spotifyTrack = await this.spotifyApi.getTrack(
|
||||||
|
this.appAccessToken,
|
||||||
|
spotifyID
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response && err.response.status === 401) {
|
||||||
|
await this.refreshAppAccessToken();
|
||||||
|
|
||||||
|
return this.importTrack(spotifyID, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [album, artists] = await Promise.all([
|
||||||
|
this.importAlbum(spotifyTrack.album.id),
|
||||||
|
Promise.all(
|
||||||
|
spotifyTrack.artists.map(({ id: artistID }) =>
|
||||||
|
this.importArtist(artistID)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return this.musicLibraryService.createTrack({
|
||||||
|
name: spotifyTrack.name,
|
||||||
|
album,
|
||||||
|
artists,
|
||||||
|
spotify: {
|
||||||
|
id: spotifyTrack.id,
|
||||||
|
uri: spotifyTrack.uri,
|
||||||
|
type: spotifyTrack.type,
|
||||||
|
href: spotifyTrack.href
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async importAlbum(
|
||||||
|
spotifyID: string,
|
||||||
|
retryOnExpiredToken: boolean = true
|
||||||
|
): Promise<Album> {
|
||||||
|
const album = await this.musicLibraryService.findAlbum({
|
||||||
|
spotify: { id: spotifyID }
|
||||||
|
});
|
||||||
|
if (album) {
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
|
||||||
|
let spotifyAlbum: AlbumObject;
|
||||||
|
|
||||||
|
try {
|
||||||
|
spotifyAlbum = await this.spotifyApi.getAlbum(
|
||||||
|
this.appAccessToken,
|
||||||
|
spotifyID
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response && err.response.status === 401) {
|
||||||
|
await this.refreshAppAccessToken();
|
||||||
|
|
||||||
|
return this.importAlbum(spotifyID, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artists = await Promise.all(
|
||||||
|
spotifyAlbum.artists.map(({ id: artistID }) =>
|
||||||
|
this.importArtist(artistID)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.musicLibraryService.createAlbum({
|
||||||
|
name: spotifyAlbum.name,
|
||||||
|
artists,
|
||||||
|
spotify: {
|
||||||
|
id: spotifyAlbum.id,
|
||||||
|
uri: spotifyAlbum.uri,
|
||||||
|
type: spotifyAlbum.type,
|
||||||
|
href: spotifyAlbum.href
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async importArtist(
|
||||||
|
spotifyID: string,
|
||||||
|
retryOnExpiredToken: boolean = true
|
||||||
|
): Promise<Artist> {
|
||||||
|
const artist = await this.musicLibraryService.findArtist({
|
||||||
|
spotify: { id: spotifyID }
|
||||||
|
});
|
||||||
|
if (artist) {
|
||||||
|
return artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
let spotifyArtist: ArtistObject;
|
||||||
|
|
||||||
|
try {
|
||||||
|
spotifyArtist = await this.spotifyApi.getArtist(
|
||||||
|
this.appAccessToken,
|
||||||
|
spotifyID
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response && err.response.status === 401) {
|
||||||
|
await this.refreshAppAccessToken();
|
||||||
|
|
||||||
|
return this.importArtist(spotifyID, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.musicLibraryService.createArtist({
|
||||||
|
name: spotifyArtist.name,
|
||||||
|
spotify: {
|
||||||
|
id: spotifyArtist.id,
|
||||||
|
uri: spotifyArtist.uri,
|
||||||
|
type: spotifyArtist.type,
|
||||||
|
href: spotifyArtist.href
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshAppAccessToken(): Promise<void> {
|
||||||
|
if (!this.appAccessTokenInProgress) {
|
||||||
|
console.log("refreshAppAccessToken");
|
||||||
|
this.appAccessTokenInProgress = new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const newAccessToken = await this.spotifyAuth.clientCredentialsGrant();
|
||||||
|
this.appAccessToken = newAccessToken;
|
||||||
|
resolve();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
} finally {
|
||||||
|
this.appAccessTokenInProgress = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"refreshAppAccessToken already in progress, awaiting its result"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.appAccessTokenInProgress;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { CreateOrUpdateDto } from "./dto/create-or-update.dto";
|
import { CreateOrUpdateDto } from "./dto/create-or-update.dto";
|
||||||
import { User } from "./user.entity";
|
import { User } from "./user.entity";
|
||||||
import { UserRepository } from "./user.repository";
|
import { UserRepository } from "./user.repository";
|
||||||
|
import { SpotifyConnection } from "src/sources/spotify/spotify-connection.entity";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
|
|
@ -17,6 +18,10 @@ export class UsersService {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<User[]> {
|
||||||
|
return this.userRepository.find();
|
||||||
|
}
|
||||||
|
|
||||||
async createOrUpdate(data: CreateOrUpdateDto): Promise<User> {
|
async createOrUpdate(data: CreateOrUpdateDto): Promise<User> {
|
||||||
let user = await this.userRepository.findOne({
|
let user = await this.userRepository.findOne({
|
||||||
where: { spotify: { id: data.spotify.id } }
|
where: { spotify: { id: data.spotify.id } }
|
||||||
|
|
@ -39,4 +44,12 @@ export class UsersService {
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSpotifyConnection(
|
||||||
|
user: User,
|
||||||
|
spotify: SpotifyConnection
|
||||||
|
): Promise<void> {
|
||||||
|
user.spotify = spotify;
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue