Listory/src/sources/spotify/spotify.service.ts

292 lines
8.1 KiB
TypeScript
Raw Normal View History

2020-02-02 19:21:58 +01:00
import { Injectable } from "@nestjs/common";
import { ListensService } from "../../listens/listens.service";
import { Logger } from "../../logger/logger.service";
2020-02-02 19:21:58 +01:00
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";
2020-02-01 16:11:48 +01:00
@Injectable()
export class SpotifyService {
2020-02-02 19:21:58 +01:00
private appAccessToken: string | null;
private appAccessTokenInProgress: Promise<void> | null;
2020-02-01 16:11:48 +01:00
2020-02-02 19:21:58 +01:00
constructor(
private readonly usersService: UsersService,
private readonly listensService: ListensService,
private readonly musicLibraryService: MusicLibraryService,
private readonly spotifyApi: SpotifyApiService,
private readonly spotifyAuth: SpotifyAuthService,
private readonly logger: Logger
) {
this.logger.setContext(this.constructor.name);
}
2020-02-02 19:21:58 +01:00
async runCrawlerForAllUsers(): Promise<void> {
this.logger.debug("Starting Spotify crawler loop");
2020-02-02 19:21:58 +01:00
const users = await this.usersService.findAll();
for (const user of users) {
2021-05-25 18:12:42 +02:00
// We want to run this sequentially to avoid rate limits
// eslint-disable-next-line no-await-in-loop
await this.crawlListensForUser(user);
2020-02-02 19:21:58 +01:00
}
}
async crawlListensForUser(
2020-02-02 19:21:58 +01:00
user: User,
retryOnExpiredToken: boolean = true
): Promise<void> {
this.logger.debug(`Crawling recently played tracks for user "${user.id}"`);
2020-02-02 19:21:58 +01:00
let playHistory: PlayHistoryObject[];
try {
playHistory = await this.spotifyApi.getRecentlyPlayedTracks(user.spotify);
} catch (err) {
if (err.response && err.response.status === 401 && retryOnExpiredToken) {
try {
const accessToken = await this.spotifyAuth.refreshAccessToken(
user.spotify
);
await this.usersService.updateSpotifyConnection(user, {
...user.spotify,
accessToken,
});
await this.crawlListensForUser(user, false);
} catch (errFromAuth) {
this.logger.error(
`Refreshing access token failed for user "${user.id}": ${errFromAuth}`
);
}
} else {
// TODO sent to sentry
this.logger.error(
`Unexpected error while fetching recently played tracks: ${err}`
);
2020-02-02 19:21:58 +01:00
}
// Makes no sense to keep processing the (inexistent) data but if we throw
// the error the fetch loop will not process other users.
return;
2020-02-02 19:21:58 +01:00
}
if (playHistory.length === 0) {
return;
}
await Promise.all(
2020-05-02 17:17:20 +02:00
playHistory.map(async (history) => {
2020-02-02 19:21:58 +01:00
const track = await this.importTrack(history.track.id);
const { isDuplicate } = await this.listensService.createListen({
2020-02-02 19:21:58 +01:00
user,
track,
2020-05-02 17:17:20 +02:00
playedAt: new Date(history.played_at),
2020-02-02 19:21:58 +01:00
});
if (!isDuplicate) {
this.logger.debug(
`New listen found! ${user.id} listened to "${
track.name
}" by ${track.artists
?.map((artist) => `"${artist.name}"`)
.join(", ")}`
);
}
2020-02-02 19:21:58 +01:00
})
);
const newestPlayTime = new Date(
playHistory
2020-05-02 17:17:20 +02:00
.map((history) => history.played_at)
2020-02-02 19:21:58 +01:00
.sort()
.pop()
);
/**
* lastRefreshTime was previously used to only get new listens from Spotify
* but the Spotify WEB Api was sometimes not adding the listens in the right
* order, causing us to miss some listens.
*
* The variable will still be set, in case we want to add the functionality
* again.
*/
await this.usersService.updateSpotifyConnection(user, {
2020-02-02 19:21:58 +01:00
...user.spotify,
2020-05-02 17:17:20 +02:00
lastRefreshTime: newestPlayTime,
2020-02-02 19:21:58 +01:00
});
}
async importTrack(
spotifyID: string,
retryOnExpiredToken: boolean = true
): Promise<Track> {
const track = await this.musicLibraryService.findTrack({
2020-05-02 17:17:20 +02:00
spotify: { id: spotifyID },
2020-02-02 19:21:58 +01:00
});
if (track) {
return track;
}
let spotifyTrack: TrackObject;
try {
spotifyTrack = await this.spotifyApi.getTrack(
this.appAccessToken,
spotifyID
);
} catch (err) {
2021-05-25 18:12:42 +02:00
if (err.response && err.response.status === 401 && retryOnExpiredToken) {
2020-02-02 19:21:58 +01:00
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)
)
2020-05-02 17:17:20 +02:00
),
2020-02-02 19:21:58 +01:00
]);
return this.musicLibraryService.createTrack({
name: spotifyTrack.name,
album,
artists,
spotify: {
id: spotifyTrack.id,
uri: spotifyTrack.uri,
type: spotifyTrack.type,
2020-05-02 17:17:20 +02:00
href: spotifyTrack.href,
},
2020-02-02 19:21:58 +01:00
});
}
async importAlbum(
spotifyID: string,
retryOnExpiredToken: boolean = true
): Promise<Album> {
const album = await this.musicLibraryService.findAlbum({
2020-05-02 17:17:20 +02:00
spotify: { id: spotifyID },
2020-02-02 19:21:58 +01:00
});
if (album) {
return album;
}
let spotifyAlbum: AlbumObject;
try {
spotifyAlbum = await this.spotifyApi.getAlbum(
this.appAccessToken,
spotifyID
);
} catch (err) {
2021-05-25 18:12:42 +02:00
if (err.response && err.response.status === 401 && retryOnExpiredToken) {
2020-02-02 19:21:58 +01:00
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,
2020-05-02 17:17:20 +02:00
href: spotifyAlbum.href,
},
2020-02-02 19:21:58 +01:00
});
}
async importArtist(
spotifyID: string,
retryOnExpiredToken: boolean = true
): Promise<Artist> {
const artist = await this.musicLibraryService.findArtist({
2020-05-02 17:17:20 +02:00
spotify: { id: spotifyID },
2020-02-02 19:21:58 +01:00
});
if (artist) {
return artist;
}
let spotifyArtist: ArtistObject;
try {
spotifyArtist = await this.spotifyApi.getArtist(
this.appAccessToken,
spotifyID
);
} catch (err) {
2021-05-25 18:12:42 +02:00
if (err.response && err.response.status === 401 && retryOnExpiredToken) {
2020-02-02 19:21:58 +01:00
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,
2020-05-02 17:17:20 +02:00
href: spotifyArtist.href,
},
2020-02-02 19:21:58 +01:00
});
}
private async refreshAppAccessToken(): Promise<void> {
if (!this.appAccessTokenInProgress) {
this.logger.debug("refreshing spotify app access token");
2021-05-25 18:12:42 +02:00
/* eslint-disable no-async-promise-executor */
2020-02-02 19:21:58 +01:00
this.appAccessTokenInProgress = new Promise(async (resolve, reject) => {
try {
2021-05-25 16:01:55 +02:00
const newAccessToken =
await this.spotifyAuth.clientCredentialsGrant();
2020-02-02 19:21:58 +01:00
this.appAccessToken = newAccessToken;
this.logger.debug("spotify app access token refreshed");
2020-02-02 19:21:58 +01:00
resolve();
} catch (err) {
this.logger.warn(
`Error while refreshing spotify app access token ${err}`
);
2020-02-02 19:21:58 +01:00
reject(err);
} finally {
this.appAccessTokenInProgress = null;
}
});
}
return this.appAccessTokenInProgress;
}
2020-02-01 16:11:48 +01:00
}