From f2065d3f1ff56c992568bdae23bf6e2bea074cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sun, 2 Feb 2020 19:21:58 +0100 Subject: [PATCH] feat(api): fetch listens from spotify --- frontend/src/App.js | 9 +- frontend/src/auth_config.json | 5 - frontend/src/components/NavBar.js | 27 -- frontend/src/components/NavBar.jsx | 9 + frontend/src/components/PrivateRoute.js | 26 -- frontend/src/index.js | 26 +- frontend/src/react-auth0-spa.js | 89 ------ frontend/src/views/ExternalApi.js | 37 --- frontend/src/views/Profile.js | 22 -- src/app.module.ts | 8 +- src/auth/spotify.strategy.ts | 4 +- src/listens/dto/create-listen.dto.ts | 8 + src/listens/listen.entity.ts | 25 ++ src/listens/listen.repository.ts | 5 + src/listens/listens.module.ts | 11 + src/listens/listens.service.spec.ts | 18 ++ src/listens/listens.service.ts | 35 +++ src/music-library/album.entity.ts | 36 +++ src/music-library/album.repository.ts | 5 + src/music-library/artist.entity.ts | 21 ++ src/music-library/artist.repository.ts | 5 + src/music-library/dto/create-album.dto.ts | 8 + src/music-library/dto/create-artist.dto.ts | 6 + src/music-library/dto/create-track.dto.ts | 10 + src/music-library/dto/find-album.dto.ts | 5 + src/music-library/dto/find-artist.dto.ts | 5 + src/music-library/dto/find-track.dto.ts | 5 + src/music-library/music-library.module.ts | 19 ++ .../music-library.service.spec.ts | 18 ++ src/music-library/music-library.service.ts | 78 ++++++ src/music-library/track.entity.ts | 33 +++ src/music-library/track.repository.ts | 5 + src/sources/sources.module.ts | 4 +- .../spotify-api/entities/album-object.ts | 67 +++++ .../spotify-api/entities/artist-object.ts | 32 +++ .../spotify-api/entities/context-object.ts | 25 ++ .../entities/external-url-object.ts | 3 + .../spotify-api/entities/paging-object.ts | 36 +++ .../entities/play-history-object.ts | 21 ++ .../entities/simplified-album-object.ts | 41 +++ .../entities/simplified-artist-object.ts | 26 ++ .../entities/simplified-track-object.ts | 16 ++ .../spotify-api/entities/track-object.ts | 46 ++++ .../spotify/spotify-api/spotify-api.module.ts | 16 ++ .../spotify-api/spotify-api.service.spec.ts | 18 ++ .../spotify-api/spotify-api.service.ts | 81 ++++++ .../spotify-auth/spotify-auth.module.ts | 16 ++ .../spotify-auth/spotify-auth.service.ts | 51 ++++ .../spotify/spotify-connection.entity.ts | 3 + .../spotify/spotify-library-details.entity.ts | 15 ++ src/sources/spotify/spotify.module.ts | 20 +- src/sources/spotify/spotify.service.spec.ts | 10 +- src/sources/spotify/spotify.service.ts | 253 +++++++++++++++++- src/users/users.service.ts | 13 + 54 files changed, 1180 insertions(+), 256 deletions(-) delete mode 100644 frontend/src/auth_config.json delete mode 100644 frontend/src/components/NavBar.js create mode 100644 frontend/src/components/NavBar.jsx delete mode 100644 frontend/src/components/PrivateRoute.js delete mode 100644 frontend/src/react-auth0-spa.js delete mode 100644 frontend/src/views/ExternalApi.js delete mode 100644 frontend/src/views/Profile.js create mode 100644 src/listens/dto/create-listen.dto.ts create mode 100644 src/listens/listen.entity.ts create mode 100644 src/listens/listen.repository.ts create mode 100644 src/listens/listens.module.ts create mode 100644 src/listens/listens.service.spec.ts create mode 100644 src/listens/listens.service.ts create mode 100644 src/music-library/album.entity.ts create mode 100644 src/music-library/album.repository.ts create mode 100644 src/music-library/artist.entity.ts create mode 100644 src/music-library/artist.repository.ts create mode 100644 src/music-library/dto/create-album.dto.ts create mode 100644 src/music-library/dto/create-artist.dto.ts create mode 100644 src/music-library/dto/create-track.dto.ts create mode 100644 src/music-library/dto/find-album.dto.ts create mode 100644 src/music-library/dto/find-artist.dto.ts create mode 100644 src/music-library/dto/find-track.dto.ts create mode 100644 src/music-library/music-library.module.ts create mode 100644 src/music-library/music-library.service.spec.ts create mode 100644 src/music-library/music-library.service.ts create mode 100644 src/music-library/track.entity.ts create mode 100644 src/music-library/track.repository.ts create mode 100644 src/sources/spotify/spotify-api/entities/album-object.ts create mode 100644 src/sources/spotify/spotify-api/entities/artist-object.ts create mode 100644 src/sources/spotify/spotify-api/entities/context-object.ts create mode 100644 src/sources/spotify/spotify-api/entities/external-url-object.ts create mode 100644 src/sources/spotify/spotify-api/entities/paging-object.ts create mode 100644 src/sources/spotify/spotify-api/entities/play-history-object.ts create mode 100644 src/sources/spotify/spotify-api/entities/simplified-album-object.ts create mode 100644 src/sources/spotify/spotify-api/entities/simplified-artist-object.ts create mode 100644 src/sources/spotify/spotify-api/entities/simplified-track-object.ts create mode 100644 src/sources/spotify/spotify-api/entities/track-object.ts create mode 100644 src/sources/spotify/spotify-api/spotify-api.module.ts create mode 100644 src/sources/spotify/spotify-api/spotify-api.service.spec.ts create mode 100644 src/sources/spotify/spotify-api/spotify-api.service.ts create mode 100644 src/sources/spotify/spotify-auth/spotify-auth.module.ts create mode 100644 src/sources/spotify/spotify-auth/spotify-auth.service.ts create mode 100644 src/sources/spotify/spotify-library-details.entity.ts diff --git a/frontend/src/App.js b/frontend/src/App.js index 63af9ca..cf77910 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,22 +1,17 @@ import React from "react"; 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 { NavBar } from "./components/NavBar"; function App() { return (
- +
- -
diff --git a/frontend/src/auth_config.json b/frontend/src/auth_config.json deleted file mode 100644 index b8c8de1..0000000 --- a/frontend/src/auth_config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "domain": "listory.eu.auth0.com", - "clientId": "pYSTOYOsmenpCYj6TsRNQ8dprlqoQTt3", - "audience": "https://listory.apricote.de/api/v1" -} diff --git a/frontend/src/components/NavBar.js b/frontend/src/components/NavBar.js deleted file mode 100644 index b5bdd73..0000000 --- a/frontend/src/components/NavBar.js +++ /dev/null @@ -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 ( -
- {!isAuthenticated && ( - - )} - - {isAuthenticated && } - - {isAuthenticated && ( - - Home  - Profile - External API - - )} -
- ); -}; - -export default NavBar; diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx new file mode 100644 index 0000000..e5aeb42 --- /dev/null +++ b/frontend/src/components/NavBar.jsx @@ -0,0 +1,9 @@ +import React from "react"; + +export function NavBar() { + return ( +
+ Login with Spotify +
+ ); +} diff --git a/frontend/src/components/PrivateRoute.js b/frontend/src/components/PrivateRoute.js deleted file mode 100644 index b1b1123..0000000 --- a/frontend/src/components/PrivateRoute.js +++ /dev/null @@ -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 ? : null; - - return ; -}; - -export default PrivateRoute; diff --git a/frontend/src/index.js b/frontend/src/index.js index cd7338d..8780a3a 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -2,31 +2,7 @@ import React from "react"; import ReactDOM from "react-dom"; import App from "./App"; 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 -// after login -const onRedirectCallback = appState => { - history.push( - appState && appState.targetUrl - ? appState.targetUrl - : window.location.pathname - ); -}; - -ReactDOM.render( - - - , - document.getElementById("root") -); +ReactDOM.render(, document.getElementById("root")); serviceWorker.unregister(); diff --git a/frontend/src/react-auth0-spa.js b/frontend/src/react-auth0-spa.js deleted file mode 100644 index c004590..0000000 --- a/frontend/src/react-auth0-spa.js +++ /dev/null @@ -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 ( - auth0Client.getIdTokenClaims(...p), - loginWithRedirect: (...p) => auth0Client.loginWithRedirect(...p), - getTokenSilently: (...p) => auth0Client.getTokenSilently(...p), - getTokenWithPopup: (...p) => auth0Client.getTokenWithPopup(...p), - logout: (...p) => auth0Client.logout(...p) - }} - > - {children} - - ); -}; diff --git a/frontend/src/views/ExternalApi.js b/frontend/src/views/ExternalApi.js deleted file mode 100644 index fd1ddf9..0000000 --- a/frontend/src/views/ExternalApi.js +++ /dev/null @@ -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 ( - <> -

External API

- - {showResult && {JSON.stringify(apiMessage, null, 2)}} - - ); -}; - -export default ExternalApi; diff --git a/frontend/src/views/Profile.js b/frontend/src/views/Profile.js deleted file mode 100644 index 61be06a..0000000 --- a/frontend/src/views/Profile.js +++ /dev/null @@ -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
Loading...
; - } - - return ( - - Profile - -

{user.name}

-

{user.email}

- {JSON.stringify(user, null, 2)} -
- ); -}; - -export default Profile; diff --git a/src/app.module.ts b/src/app.module.ts index bb89b9a..ad223cc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,17 +1,23 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; import { AuthModule } from "./auth/auth.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 { UsersModule } from "./users/users.module"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + ScheduleModule.forRoot(), DatabaseModule, AuthModule, UsersModule, - SourcesModule + SourcesModule, + MusicLibraryModule, + ListensModule ] }) export class AppModule {} diff --git a/src/auth/spotify.strategy.ts b/src/auth/spotify.strategy.ts index e31c9b0..20af2eb 100644 --- a/src/auth/spotify.strategy.ts +++ b/src/auth/spotify.strategy.ts @@ -7,8 +7,8 @@ import { AuthService } from "./auth.service"; @Injectable() export class SpotifyStrategy extends PassportStrategy(Strategy) { constructor( - private readonly config: ConfigService, - private readonly authService: AuthService + private readonly authService: AuthService, + config: ConfigService ) { super({ clientID: config.get("SPOTIFY_CLIENT_ID"), diff --git a/src/listens/dto/create-listen.dto.ts b/src/listens/dto/create-listen.dto.ts new file mode 100644 index 0000000..ba54fb2 --- /dev/null +++ b/src/listens/dto/create-listen.dto.ts @@ -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; +} diff --git a/src/listens/listen.entity.ts b/src/listens/listen.entity.ts new file mode 100644 index 0000000..c13535e --- /dev/null +++ b/src/listens/listen.entity.ts @@ -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; +} diff --git a/src/listens/listen.repository.ts b/src/listens/listen.repository.ts new file mode 100644 index 0000000..c0a099f --- /dev/null +++ b/src/listens/listen.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm"; +import { Listen } from "./listen.entity"; + +@EntityRepository(Listen) +export class ListenRepository extends Repository {} diff --git a/src/listens/listens.module.ts b/src/listens/listens.module.ts new file mode 100644 index 0000000..190e866 --- /dev/null +++ b/src/listens/listens.module.ts @@ -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 {} diff --git a/src/listens/listens.service.spec.ts b/src/listens/listens.service.spec.ts new file mode 100644 index 0000000..c800872 --- /dev/null +++ b/src/listens/listens.service.spec.ts @@ -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); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/listens/listens.service.ts b/src/listens/listens.service.ts new file mode 100644 index 0000000..3100c84 --- /dev/null +++ b/src/listens/listens.service.ts @@ -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 { + 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; + } +} diff --git a/src/music-library/album.entity.ts b/src/music-library/album.entity.ts new file mode 100644 index 0000000..dbabf58 --- /dev/null +++ b/src/music-library/album.entity.ts @@ -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; +} diff --git a/src/music-library/album.repository.ts b/src/music-library/album.repository.ts new file mode 100644 index 0000000..805e9a9 --- /dev/null +++ b/src/music-library/album.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm"; +import { Album } from "./album.entity"; + +@EntityRepository(Album) +export class AlbumRepository extends Repository {} diff --git a/src/music-library/artist.entity.ts b/src/music-library/artist.entity.ts new file mode 100644 index 0000000..1acd58f --- /dev/null +++ b/src/music-library/artist.entity.ts @@ -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; +} diff --git a/src/music-library/artist.repository.ts b/src/music-library/artist.repository.ts new file mode 100644 index 0000000..cbe90f0 --- /dev/null +++ b/src/music-library/artist.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm"; +import { Artist } from "./artist.entity"; + +@EntityRepository(Artist) +export class ArtistRepository extends Repository {} diff --git a/src/music-library/dto/create-album.dto.ts b/src/music-library/dto/create-album.dto.ts new file mode 100644 index 0000000..3605198 --- /dev/null +++ b/src/music-library/dto/create-album.dto.ts @@ -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; +} diff --git a/src/music-library/dto/create-artist.dto.ts b/src/music-library/dto/create-artist.dto.ts new file mode 100644 index 0000000..acaf4ae --- /dev/null +++ b/src/music-library/dto/create-artist.dto.ts @@ -0,0 +1,6 @@ +import { SpotifyLibraryDetails } from "../../sources/spotify/spotify-library-details.entity"; + +export class CreateArtistDto { + name: string; + spotify?: SpotifyLibraryDetails; +} diff --git a/src/music-library/dto/create-track.dto.ts b/src/music-library/dto/create-track.dto.ts new file mode 100644 index 0000000..90a1158 --- /dev/null +++ b/src/music-library/dto/create-track.dto.ts @@ -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; +} diff --git a/src/music-library/dto/find-album.dto.ts b/src/music-library/dto/find-album.dto.ts new file mode 100644 index 0000000..d3b2cfb --- /dev/null +++ b/src/music-library/dto/find-album.dto.ts @@ -0,0 +1,5 @@ +export class FindAlbumDto { + spotify: { + id: string; + }; +} diff --git a/src/music-library/dto/find-artist.dto.ts b/src/music-library/dto/find-artist.dto.ts new file mode 100644 index 0000000..09db9f1 --- /dev/null +++ b/src/music-library/dto/find-artist.dto.ts @@ -0,0 +1,5 @@ +export class FindArtistDto { + spotify: { + id: string; + }; +} diff --git a/src/music-library/dto/find-track.dto.ts b/src/music-library/dto/find-track.dto.ts new file mode 100644 index 0000000..7ffddc5 --- /dev/null +++ b/src/music-library/dto/find-track.dto.ts @@ -0,0 +1,5 @@ +export class FindTrackDto { + spotify: { + id: string; + }; +} diff --git a/src/music-library/music-library.module.ts b/src/music-library/music-library.module.ts new file mode 100644 index 0000000..40cfc90 --- /dev/null +++ b/src/music-library/music-library.module.ts @@ -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 {} diff --git a/src/music-library/music-library.service.spec.ts b/src/music-library/music-library.service.spec.ts new file mode 100644 index 0000000..7fe79c3 --- /dev/null +++ b/src/music-library/music-library.service.spec.ts @@ -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); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/music-library/music-library.service.ts b/src/music-library/music-library.service.ts new file mode 100644 index 0000000..580ca29 --- /dev/null +++ b/src/music-library/music-library.service.ts @@ -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 { + return this.artistRepository.findOne({ + where: { spotify: { id: query.spotify.id } } + }); + } + + async createArtist(data: CreateArtistDto): Promise { + 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 { + return this.albumRepository.findOne({ + where: { spotify: { id: query.spotify.id } } + }); + } + + async createAlbum(data: CreateAlbumDto): Promise { + 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 { + return this.trackRepository.findOne({ + where: { spotify: { id: query.spotify.id } } + }); + } + + async createTrack(data: CreateTrackDto): Promise { + 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; + } +} diff --git a/src/music-library/track.entity.ts b/src/music-library/track.entity.ts new file mode 100644 index 0000000..6a0bc41 --- /dev/null +++ b/src/music-library/track.entity.ts @@ -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; +} diff --git a/src/music-library/track.repository.ts b/src/music-library/track.repository.ts new file mode 100644 index 0000000..3e37cf7 --- /dev/null +++ b/src/music-library/track.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm"; +import { Track } from "./track.entity"; + +@EntityRepository(Track) +export class TrackRepository extends Repository {} diff --git a/src/sources/sources.module.ts b/src/sources/sources.module.ts index a061706..8e7095f 100644 --- a/src/sources/sources.module.ts +++ b/src/sources/sources.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; -import { SpotifyModule } from './spotify/spotify.module'; +import { Module } from "@nestjs/common"; +import { SpotifyModule } from "./spotify/spotify.module"; @Module({ imports: [SpotifyModule] diff --git a/src/sources/spotify/spotify-api/entities/album-object.ts b/src/sources/spotify/spotify-api/entities/album-object.ts new file mode 100644 index 0000000..019cbdb --- /dev/null +++ b/src/sources/spotify/spotify-api/entities/album-object.ts @@ -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; +} diff --git a/src/sources/spotify/spotify-api/entities/artist-object.ts b/src/sources/spotify/spotify-api/entities/artist-object.ts new file mode 100644 index 0000000..69938a1 --- /dev/null +++ b/src/sources/spotify/spotify-api/entities/artist-object.ts @@ -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; +} diff --git a/src/sources/spotify/spotify-api/entities/context-object.ts b/src/sources/spotify/spotify-api/entities/context-object.ts new file mode 100644 index 0000000..ca21c34 --- /dev/null +++ b/src/sources/spotify/spotify-api/entities/context-object.ts @@ -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; +} diff --git a/src/sources/spotify/spotify-api/entities/external-url-object.ts b/src/sources/spotify/spotify-api/entities/external-url-object.ts new file mode 100644 index 0000000..71867f8 --- /dev/null +++ b/src/sources/spotify/spotify-api/entities/external-url-object.ts @@ -0,0 +1,3 @@ +export class ExternalUrlObject { + // No documentation for this exists +} diff --git a/src/sources/spotify/spotify-api/entities/paging-object.ts b/src/sources/spotify/spotify-api/entities/paging-object.ts new file mode 100644 index 0000000..733b93e --- /dev/null +++ b/src/sources/spotify/spotify-api/entities/paging-object.ts @@ -0,0 +1,36 @@ +export class PagingObject { + /** + * 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; +} diff --git a/src/sources/spotify/spotify-api/entities/play-history-object.ts b/src/sources/spotify/spotify-api/entities/play-history-object.ts new file mode 100644 index 0000000..8dc78b7 --- /dev/null +++ b/src/sources/spotify/spotify-api/entities/play-history-object.ts @@ -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; +} diff --git a/src/sources/spotify/spotify-api/entities/simplified-album-object.ts b/src/sources/spotify/spotify-api/entities/simplified-album-object.ts new file mode 100644 index 0000000..5fedb52 --- /dev/null +++ b/src/sources/spotify/spotify-api/entities/simplified-album-object.ts @@ -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"; +} diff --git a/src/sources/spotify/spotify-api/entities/simplified-artist-object.ts b/src/sources/spotify/spotify-api/entities/simplified-artist-object.ts new file mode 100644 index 0000000..9f5131d --- /dev/null +++ b/src/sources/spotify/spotify-api/entities/simplified-artist-object.ts @@ -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; +} diff --git a/src/sources/spotify/spotify-api/entities/simplified-track-object.ts b/src/sources/spotify/spotify-api/entities/simplified-track-object.ts new file mode 100644 index 0000000..9c40faf --- /dev/null +++ b/src/sources/spotify/spotify-api/entities/simplified-track-object.ts @@ -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; +} diff --git a/src/sources/spotify/spotify-api/entities/track-object.ts b/src/sources/spotify/spotify-api/entities/track-object.ts new file mode 100644 index 0000000..ae55065 --- /dev/null +++ b/src/sources/spotify/spotify-api/entities/track-object.ts @@ -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; +} diff --git a/src/sources/spotify/spotify-api/spotify-api.module.ts b/src/sources/spotify/spotify-api/spotify-api.module.ts new file mode 100644 index 0000000..3cd8367 --- /dev/null +++ b/src/sources/spotify/spotify-api/spotify-api.module.ts @@ -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 {} diff --git a/src/sources/spotify/spotify-api/spotify-api.service.spec.ts b/src/sources/spotify/spotify-api/spotify-api.service.spec.ts new file mode 100644 index 0000000..4c66cb3 --- /dev/null +++ b/src/sources/spotify/spotify-api/spotify-api.service.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/sources/spotify/spotify-api/spotify-api.service.ts b/src/sources/spotify/spotify-api/spotify-api.service.ts new file mode 100644 index 0000000..345a650 --- /dev/null +++ b/src/sources/spotify/spotify-api/spotify-api.service.ts @@ -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 { + 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>(`v1/me/player/recently-played`, { + headers: { Authorization: `Bearer ${accessToken}` }, + params: parameters + }) + .toPromise(); + + return history.data.items; + } + + async getArtist( + accessToken: string, + spotifyID: string + ): Promise { + console.log("SpotifyApiService#getArtist"); + const artist = await this.httpService + .get(`v1/artists/${spotifyID}`, { + headers: { Authorization: `Bearer ${accessToken}` } + }) + .toPromise(); + + return artist.data; + } + + async getAlbum(accessToken: string, spotifyID: string): Promise { + console.log("SpotifyApiService#getAlbum"); + + const album = await this.httpService + .get(`v1/albums/${spotifyID}`, { + headers: { Authorization: `Bearer ${accessToken}` } + }) + .toPromise(); + + console.log("getAlbum", { data: album.data }); + return album.data; + } + + async getTrack(accessToken: string, spotifyID: string): Promise { + console.log("SpotifyApiService#getTrack"); + + const track = await this.httpService + .get(`v1/tracks/${spotifyID}`, { + headers: { Authorization: `Bearer ${accessToken}` } + }) + .toPromise(); + + return track.data; + } +} diff --git a/src/sources/spotify/spotify-auth/spotify-auth.module.ts b/src/sources/spotify/spotify-auth/spotify-auth.module.ts new file mode 100644 index 0000000..623dac7 --- /dev/null +++ b/src/sources/spotify/spotify-auth/spotify-auth.module.ts @@ -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 {} diff --git a/src/sources/spotify/spotify-auth/spotify-auth.service.ts b/src/sources/spotify/spotify-auth/spotify-auth.service.ts new file mode 100644 index 0000000..ac60ca8 --- /dev/null +++ b/src/sources/spotify/spotify-auth/spotify-auth.service.ts @@ -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("SPOTIFY_CLIENT_ID"); + this.clientSecret = config.get("SPOTIFY_CLIENT_SECRET"); + } + + async clientCredentialsGrant(): Promise { + 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 { + const response = await this.httpService + .post( + `api/token`, + `grant_type=refresh_token&refresh_token=${connection.refreshToken}`, + { + auth: { + username: this.clientID, + password: this.clientSecret + } + } + ) + .toPromise(); + + return response.data.access_token; + } +} diff --git a/src/sources/spotify/spotify-connection.entity.ts b/src/sources/spotify/spotify-connection.entity.ts index f765443..266e8ce 100644 --- a/src/sources/spotify/spotify-connection.entity.ts +++ b/src/sources/spotify/spotify-connection.entity.ts @@ -9,4 +9,7 @@ export class SpotifyConnection { @Column() refreshToken: string; + + @Column({ type: "timestamp", nullable: true }) + lastRefreshTime?: Date; } diff --git a/src/sources/spotify/spotify-library-details.entity.ts b/src/sources/spotify/spotify-library-details.entity.ts new file mode 100644 index 0000000..94d0f6a --- /dev/null +++ b/src/sources/spotify/spotify-library-details.entity.ts @@ -0,0 +1,15 @@ +import { Column } from "typeorm"; + +export class SpotifyLibraryDetails { + @Column() + id: string; + + @Column() + uri: string; + + @Column() + type: string; + + @Column() + href: string; +} diff --git a/src/sources/spotify/spotify.module.ts b/src/sources/spotify/spotify.module.ts index 89d3187..393acdb 100644 --- a/src/sources/spotify/spotify.module.ts +++ b/src/sources/spotify/spotify.module.ts @@ -1,9 +1,19 @@ -import { Module } from '@nestjs/common'; -import { SpotifyService } from './spotify.service'; -import { SpotifyApiModule } from './spotify-api/spotify-api.module'; +import { Module } from "@nestjs/common"; +import { UsersModule } from "src/users/users.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({ - providers: [SpotifyService], - imports: [SpotifyApiModule] + imports: [ + UsersModule, + ListensModule, + MusicLibraryModule, + SpotifyApiModule, + SpotifyAuthModule + ], + providers: [SpotifyService] }) export class SpotifyModule {} diff --git a/src/sources/spotify/spotify.service.spec.ts b/src/sources/spotify/spotify.service.spec.ts index 5067a85..ebc9ac4 100644 --- a/src/sources/spotify/spotify.service.spec.ts +++ b/src/sources/spotify/spotify.service.spec.ts @@ -1,18 +1,18 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { SpotifyService } from './spotify.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { SpotifyService } from "./spotify.service"; -describe('SpotifyService', () => { +describe("SpotifyService", () => { let service: SpotifyService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [SpotifyService], + providers: [SpotifyService] }).compile(); service = module.get(SpotifyService); }); - it('should be defined', () => { + it("should be defined", () => { expect(service).toBeDefined(); }); }); diff --git a/src/sources/spotify/spotify.service.ts b/src/sources/spotify/spotify.service.ts index 612df07..1353609 100644 --- a/src/sources/spotify/spotify.service.ts +++ b/src/sources/spotify/spotify.service.ts @@ -1,8 +1,255 @@ -import { Injectable } from '@nestjs/common'; -import {Interval} from "@nestjs/cron" +import { Injectable } from "@nestjs/common"; +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() export class SpotifyService { - @Interval + private appAccessToken: string | null; + private appAccessTokenInProgress: Promise | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 12b4053..42feb8b 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { CreateOrUpdateDto } from "./dto/create-or-update.dto"; import { User } from "./user.entity"; import { UserRepository } from "./user.repository"; +import { SpotifyConnection } from "src/sources/spotify/spotify-connection.entity"; @Injectable() export class UsersService { @@ -17,6 +18,10 @@ export class UsersService { return user; } + async findAll(): Promise { + return this.userRepository.find(); + } + async createOrUpdate(data: CreateOrUpdateDto): Promise { let user = await this.userRepository.findOne({ where: { spotify: { id: data.spotify.id } } @@ -39,4 +44,12 @@ export class UsersService { return user; } + + async updateSpotifyConnection( + user: User, + spotify: SpotifyConnection + ): Promise { + user.spotify = spotify; + await this.userRepository.save(user); + } }