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

662 lines
18 KiB
TypeScript
Raw Normal View History

import { Injectable, Logger } from "@nestjs/common";
import { chunk, uniq } from "lodash";
import { Span } from "nestjs-otel";
import type { Job } from "pg-boss";
2020-02-02 19:21:58 +01:00
import { ListensService } from "../../listens/listens.service";
import { Album } from "../../music-library/album.entity";
import { Artist } from "../../music-library/artist.entity";
import { Genre } from "../../music-library/genre.entity";
2020-02-02 19:21:58 +01:00
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 {
IImportSpotifyJob,
ImportSpotifyJob,
UpdateSpotifyLibraryJob,
} from "../jobs";
2020-02-02 19:21:58 +01:00
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
/** Number of IDs that can be passed to Spotify Web API "Get Several Artist/Track" calls. */
const SPOTIFY_BULK_MAX_IDS = 50;
/** Number of IDs that can be passed to Spotify Web API "Get Several Album" calls. */
const SPOTIFY_BULK_ALBUMS_MAX_IDS = 20;
2020-02-01 16:11:48 +01:00
@Injectable()
export class SpotifyService {
private readonly logger = new Logger(this.constructor.name);
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,
2023-09-16 13:02:19 +02:00
private readonly spotifyAuth: SpotifyAuthService,
) {}
2020-02-02 19:21:58 +01:00
@Span()
async getCrawlableUserInfo(): Promise<{ user: User; lastListen: Date }[]> {
// All of this is kinda inefficient, we do two db queries and join in code,
// i can't be bothered to do this properly in the db for now.
// Should be refactored if listory gets hundreds of users (lol).
const [users, listens] = await Promise.all([
this.usersService.findAll(),
this.listensService.getMostRecentListenPerUser(),
]);
return users.map((user) => {
const lastListen = listens.find((listen) => listen.user.id === user.id);
return {
user,
// Return 1970 if no listen exists
lastListen: lastListen ? lastListen.playedAt : new Date(0),
};
});
return;
}
@ImportSpotifyJob.Handle()
async importSpotifyJobHandler({
data: { userID },
}: Job<IImportSpotifyJob>): Promise<void> {
const user = await this.usersService.findById(userID);
if (!user) {
this.logger.warn("User for import job not found", { userID });
return;
2020-02-02 19:21:58 +01:00
}
await this.crawlListensForUser(user);
2020-02-02 19:21:58 +01:00
}
@Span()
async crawlListensForUser(
2020-02-02 19:21:58 +01:00
user: User,
2023-09-16 13:02:19 +02:00
retryOnExpiredToken: boolean = true,
2020-02-02 19:21:58 +01:00
): Promise<void> {
this.logger.debug({ userId: user.id }, `Crawling recently played tracks`);
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(
2023-09-16 13:02:19 +02:00
user.spotify,
);
await this.usersService.updateSpotifyConnection(user, {
...user.spotify,
accessToken,
});
await this.crawlListensForUser(user, false);
} catch (errFromAuth) {
this.logger.error(
{ userId: user.id },
2023-09-16 13:02:19 +02:00
`Refreshing access token failed for user "${user.id}": ${errFromAuth}`,
);
throw errFromAuth;
}
return;
2020-02-02 19:21:58 +01:00
}
this.logger.error(
2023-09-16 13:02:19 +02:00
`Unexpected error while fetching recently played tracks: ${err}`,
);
throw err;
2020-02-02 19:21:58 +01:00
}
if (playHistory.length === 0) {
return;
}
const tracks = await this.importTracks(
2023-09-16 13:02:19 +02:00
uniq(playHistory.map((history) => history.track.id)),
);
2020-02-02 19:21:58 +01:00
const listenData = playHistory.map((history) => ({
user,
track: tracks.find((track) => history.track.id === track.spotify.id),
playedAt: new Date(history.played_at),
}));
const results = await this.listensService.createListens(listenData);
results.forEach((listen) =>
this.logger.debug(
{ userId: user.id },
`New listen found! ${user.id} listened to "${
listen.track.name
}" by ${listen.track.artists
?.map((artist) => `"${artist.name}"`)
2023-09-16 13:02:19 +02:00
.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()
2023-09-16 13:02:19 +02:00
.pop(),
2020-02-02 19:21:58 +01:00
);
/**
* 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
});
}
@Span()
@UpdateSpotifyLibraryJob.Handle()
async runUpdaterForAllEntities(): Promise<void> {
this.logger.debug("Starting Spotify updater loop");
const oldestArtist =
await this.musicLibraryService.getArtistWithOldestUpdate();
if (oldestArtist) {
await this.updateArtist(oldestArtist.spotify.id);
}
}
@Span()
2020-02-02 19:21:58 +01:00
async importTrack(
spotifyID: string,
2023-09-16 13:02:19 +02:00
retryOnExpiredToken: boolean = true,
2020-02-02 19:21:58 +01:00
): 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,
2023-09-16 13:02:19 +02:00
spotifyID,
2020-02-02 19:21:58 +01:00
);
} 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 }) =>
2023-09-16 13:02:19 +02:00
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
});
}
@Span()
async importTracks(
spotifyIDs: string[],
2023-09-16 13:02:19 +02:00
retryOnExpiredToken: boolean = true,
): Promise<Track[]> {
const tracks = await this.musicLibraryService.findTracks(
2023-09-16 13:02:19 +02:00
spotifyIDs.map((id) => ({ spotify: { id } })),
);
// Get missing ids
const missingIDs = spotifyIDs.filter(
2023-09-16 13:02:19 +02:00
(id) => !tracks.some((track) => track.spotify.id === id),
);
// No need to make spotify api request if all data is available locally
if (missingIDs.length === 0) {
return tracks;
}
let spotifyTracks: TrackObject[] = [];
// Split the import requests so we stay within the spotify api limits
try {
await Promise.all(
chunk(missingIDs, SPOTIFY_BULK_MAX_IDS).map(async (ids) => {
const batchTracks = await this.spotifyApi.getTracks(
this.appAccessToken,
2023-09-16 13:02:19 +02:00
ids,
);
spotifyTracks.push(...batchTracks);
2023-09-16 13:02:19 +02:00
}),
);
} catch (err) {
if (err.response && err.response.status === 401 && retryOnExpiredToken) {
await this.refreshAppAccessToken();
return this.importTracks(spotifyIDs, false);
}
throw err;
}
// We import albums & artist in series because the album import also
// triggers an artist import. In the best case, all artists will already be
// imported by the importArtists() call, and the album call can get them
// from the database.
const artists = await this.importArtists(
uniq(
spotifyTracks.flatMap((track) =>
2023-09-16 13:02:19 +02:00
track.artists.map((artist) => artist.id),
),
),
);
const albums = await this.importAlbums(
2023-09-16 13:02:19 +02:00
uniq(spotifyTracks.map((track) => track.album.id)),
);
// Find the right albums & artists for each spotify track & create db entry
const newTracks = await this.musicLibraryService.createTracks(
spotifyTracks.map((spotifyTrack) => {
const trackAlbum = albums.find(
2023-09-16 13:02:19 +02:00
(album) => spotifyTrack.album.id === album.spotify.id,
);
const trackArtists = spotifyTrack.artists.map((trackArtist) =>
2023-09-16 13:02:19 +02:00
artists.find((artist) => trackArtist.id == artist.spotify.id),
);
return {
name: spotifyTrack.name,
album: trackAlbum,
artists: trackArtists,
spotify: {
id: spotifyTrack.id,
uri: spotifyTrack.uri,
type: spotifyTrack.type,
href: spotifyTrack.href,
},
};
2023-09-16 13:02:19 +02:00
}),
);
// Return new & existing tracks
return [...tracks, ...newTracks];
}
@Span()
2020-02-02 19:21:58 +01:00
async importAlbum(
spotifyID: string,
2023-09-16 13:02:19 +02:00
retryOnExpiredToken: boolean = true,
2020-02-02 19:21:58 +01:00
): 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,
2023-09-16 13:02:19 +02:00
spotifyID,
2020-02-02 19:21:58 +01:00
);
} 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 }) =>
2023-09-16 13:02:19 +02:00
this.importArtist(artistID),
),
2020-02-02 19:21:58 +01:00
);
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
});
}
@Span()
async importAlbums(
spotifyIDs: string[],
2023-09-16 13:02:19 +02:00
retryOnExpiredToken: boolean = true,
): Promise<Album[]> {
const albums = await this.musicLibraryService.findAlbums(
2023-09-16 13:02:19 +02:00
spotifyIDs.map((id) => ({ spotify: { id } })),
);
// Get missing ids
const missingIDs = spotifyIDs.filter(
2023-09-16 13:02:19 +02:00
(id) => !albums.some((album) => album.spotify.id === id),
);
// No need to make spotify api request if all data is available locally
if (missingIDs.length === 0) {
return albums;
}
let spotifyAlbums: AlbumObject[] = [];
// Split the import requests so we stay within the spotify api limits
try {
await Promise.all(
chunk(missingIDs, SPOTIFY_BULK_ALBUMS_MAX_IDS).map(async (ids) => {
const batchAlbums = await this.spotifyApi.getAlbums(
this.appAccessToken,
2023-09-16 13:02:19 +02:00
ids,
);
spotifyAlbums.push(...batchAlbums);
2023-09-16 13:02:19 +02:00
}),
);
} catch (err) {
if (err.response && err.response.status === 401 && retryOnExpiredToken) {
await this.refreshAppAccessToken();
return this.importAlbums(spotifyIDs, false);
}
throw err;
}
const artists = await this.importArtists(
uniq(
spotifyAlbums.flatMap((album) =>
2023-09-16 13:02:19 +02:00
album.artists.map((artist) => artist.id),
),
),
);
// Find the right albums & artists for each spotify track & create db entry
const newAlbums = await this.musicLibraryService.createAlbums(
spotifyAlbums.map((spotifyAlbum) => {
const albumArtists = spotifyAlbum.artists.map((albumArtist) =>
2023-09-16 13:02:19 +02:00
artists.find((artist) => albumArtist.id == artist.spotify.id),
);
return {
name: spotifyAlbum.name,
artists: albumArtists,
spotify: {
id: spotifyAlbum.id,
uri: spotifyAlbum.uri,
type: spotifyAlbum.type,
href: spotifyAlbum.href,
},
};
2023-09-16 13:02:19 +02:00
}),
);
return [...albums, ...newAlbums];
}
@Span()
2020-02-02 19:21:58 +01:00
async importArtist(
spotifyID: string,
2023-09-16 13:02:19 +02:00
retryOnExpiredToken: boolean = true,
2020-02-02 19:21:58 +01:00
): 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,
2023-09-16 13:02:19 +02:00
spotifyID,
2020-02-02 19:21:58 +01:00
);
} 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;
}
const genres = await Promise.all(
2023-09-16 13:02:19 +02:00
spotifyArtist.genres.map((genreName) => this.importGenre(genreName)),
);
2020-02-02 19:21:58 +01:00
return this.musicLibraryService.createArtist({
name: spotifyArtist.name,
genres,
2020-02-02 19:21:58 +01:00
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
});
}
@Span()
async importArtists(
spotifyIDs: string[],
2023-09-16 13:02:19 +02:00
retryOnExpiredToken: boolean = true,
): Promise<Artist[]> {
const artists = await this.musicLibraryService.findArtists(
2023-09-16 13:02:19 +02:00
spotifyIDs.map((id) => ({ spotify: { id } })),
);
// Get missing ids
const missingIDs = spotifyIDs.filter(
2023-09-16 13:02:19 +02:00
(id) => !artists.some((artist) => artist.spotify.id === id),
);
// No need to make spotify api request if all data is available locally
if (missingIDs.length === 0) {
return artists;
}
let spotifyArtists: ArtistObject[] = [];
// Split the import requests so we stay within the spotify api limits
try {
await Promise.all(
chunk(missingIDs, SPOTIFY_BULK_MAX_IDS).map(async (ids) => {
const batchArtists = await this.spotifyApi.getArtists(
this.appAccessToken,
2023-09-16 13:02:19 +02:00
ids,
);
spotifyArtists.push(...batchArtists);
2023-09-16 13:02:19 +02:00
}),
);
} catch (err) {
if (err.response && err.response.status === 401 && retryOnExpiredToken) {
await this.refreshAppAccessToken();
return this.importArtists(spotifyIDs, false);
}
throw err;
}
const genres = await this.importGenres(
2023-09-16 13:02:19 +02:00
uniq(spotifyArtists.flatMap((artist) => artist.genres)),
);
// Find the right genres for each spotify artist & create db entry
const newArtists = await this.musicLibraryService.createArtists(
spotifyArtists.map((spotifyArtist) => {
const artistGenres = spotifyArtist.genres.map((artistGenre) =>
2023-09-16 13:02:19 +02:00
genres.find((genre) => artistGenre == genre.name),
);
return {
name: spotifyArtist.name,
genres: artistGenres,
spotify: {
id: spotifyArtist.id,
uri: spotifyArtist.uri,
type: spotifyArtist.type,
href: spotifyArtist.href,
},
};
2023-09-16 13:02:19 +02:00
}),
);
return [...artists, ...newArtists];
}
@Span()
async updateArtist(
spotifyID: string,
2023-09-16 13:02:19 +02:00
retryOnExpiredToken: boolean = true,
): Promise<Artist> {
const artist = await this.importArtist(spotifyID, retryOnExpiredToken);
let spotifyArtist: ArtistObject;
try {
spotifyArtist = await this.spotifyApi.getArtist(
this.appAccessToken,
2023-09-16 13:02:19 +02:00
spotifyID,
);
} catch (err) {
if (err.response && err.response.status === 401 && retryOnExpiredToken) {
await this.refreshAppAccessToken();
return this.updateArtist(spotifyID, false);
}
throw err;
}
const genres = await Promise.all(
2023-09-16 13:02:19 +02:00
spotifyArtist.genres.map((genreName) => this.importGenre(genreName)),
);
2022-06-19 20:35:44 +02:00
await this.musicLibraryService.updateArtist({
artist,
updatedFields: {
name: spotifyArtist.name,
genres,
},
});
return artist;
}
@Span()
async importGenre(name: string): Promise<Genre> {
const genre = await this.musicLibraryService.findGenre({
name,
});
if (genre) {
return genre;
}
return this.musicLibraryService.createGenre({
name,
});
}
@Span()
async importGenres(names: string[]): Promise<Genre[]> {
const genres = await this.musicLibraryService.findGenres(
2023-09-16 13:02:19 +02:00
names.map((name) => ({ name })),
);
// Get missing genres
const missingGenres = names.filter(
2023-09-16 13:02:19 +02:00
(name) => !genres.some((genre) => genre.name === name),
);
// No need to create genres if all data is available locally
if (missingGenres.length === 0) {
return genres;
}
const newGenres = await this.musicLibraryService.createGenres(
2023-09-16 13:02:19 +02:00
missingGenres.map((name) => ({ name })),
);
return [...genres, ...newGenres];
}
@Span()
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(
2023-09-16 13:02:19 +02:00
`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
}