diff --git a/README.md b/README.md index 4a1769c..8e6ec6d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ All configuration must be set as environment variables. Default values are added - `SPOTIFY_CLIENT_ID`: _Required_, Spotify App Client ID - `SPOTIFY_CLIENT_SECRET`: _Required_, Spotify App Client Secret - `SPOTIFY_FETCH_INTERVAL_SEC`: **60**: Interval for fetching recently listened tracks from Spotify. +- `SPOTIFY_UPDATE_INTERVAL_SEC`: **60**: Interval for updating previously imported music library entities (artist, album, track). Raise this number if you often hit the Spotify API Ratelimit. - `SPOTIFY_WEB_API_URL`: **https://api.spotify.com/**: Spotify WEB API Endpoint. - `SPOTIFY_AUTH_API_URL`: **https://accounts.spotify.com/**: Spotify Authentication API Endpoint. - `SPOTIFY_USER_FILTER`: **""**: If set, only allow Spotify users with these ids to access the app. If empty, allow all users to access the app. Seperate ids with `,` eg.: `231421323123,other_id`. diff --git a/frontend/src/hooks/use-auth.tsx b/frontend/src/hooks/use-auth.tsx index c2a7f3c..60dc9d5 100644 --- a/frontend/src/hooks/use-auth.tsx +++ b/frontend/src/hooks/use-auth.tsx @@ -46,7 +46,7 @@ function useProvideAuth(): AuthContext { setAccessToken(""); setUser(null); setIsLoaded(true); - setError(err); + setError(err as Error); throw err; } diff --git a/src/config/config.module.ts b/src/config/config.module.ts index d3e2cca..69a9b40 100644 --- a/src/config/config.module.ts +++ b/src/config/config.module.ts @@ -24,6 +24,7 @@ import { ConfigModule as NestConfigModule } from "@nestjs/config"; SPOTIFY_CLIENT_ID: Joi.string().required(), SPOTIFY_CLIENT_SECRET: Joi.string().required(), SPOTIFY_FETCH_INTERVAL_SEC: Joi.number().default(60), + SPOTIFY_UPDATE_INTERVAL_SEC: Joi.number().default(60), SPOTIFY_WEB_API_URL: Joi.string().default("https://api.spotify.com/"), SPOTIFY_AUTH_API_URL: Joi.string().default( "https://accounts.spotify.com/" diff --git a/src/database/migrations/06-AddUpdatedAtColumns.ts b/src/database/migrations/06-AddUpdatedAtColumns.ts new file mode 100644 index 0000000..b6d9a7e --- /dev/null +++ b/src/database/migrations/06-AddUpdatedAtColumns.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; + +export class AddUpdatedAtColumnes0000000000006 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + "artist", + new TableColumn({ + name: "updatedAt", + type: "timestamp", + default: "NOW()", + }) + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("artist", "updatedAt"); + } +} diff --git a/src/music-library/artist.entity.ts b/src/music-library/artist.entity.ts index 19d4f06..acb2d89 100644 --- a/src/music-library/artist.entity.ts +++ b/src/music-library/artist.entity.ts @@ -4,6 +4,7 @@ import { JoinTable, ManyToMany, PrimaryGeneratedColumn, + UpdateDateColumn, } from "typeorm"; import { SpotifyLibraryDetails } from "../sources/spotify/spotify-library-details.entity"; import { Album } from "./album.entity"; @@ -26,4 +27,7 @@ export class Artist { @Column(() => SpotifyLibraryDetails) spotify: SpotifyLibraryDetails; + + @UpdateDateColumn({ type: "timestamp" }) + updatedAt: Date; } diff --git a/src/music-library/dto/update-artist.dto.ts b/src/music-library/dto/update-artist.dto.ts new file mode 100644 index 0000000..c4bdf65 --- /dev/null +++ b/src/music-library/dto/update-artist.dto.ts @@ -0,0 +1,10 @@ +import { Artist } from "../artist.entity"; +import { Genre } from "../genre.entity"; + +export class UpdateArtistDto { + artist: Artist; + updatedFields: { + name: string; + genres: Genre[]; + }; +} diff --git a/src/music-library/music-library.service.spec.ts b/src/music-library/music-library.service.spec.ts index 0fdea3e..b0a14c7 100644 --- a/src/music-library/music-library.service.spec.ts +++ b/src/music-library/music-library.service.spec.ts @@ -8,6 +8,7 @@ import { CreateAlbumDto } from "./dto/create-album.dto"; import { CreateArtistDto } from "./dto/create-artist.dto"; import { CreateGenreDto } from "./dto/create-genre.dto"; import { CreateTrackDto } from "./dto/create-track.dto"; +import { UpdateArtistDto } from "./dto/update-artist.dto"; import { Genre } from "./genre.entity"; import { GenreRepository } from "./genre.repository"; import { MusicLibraryService } from "./music-library.service"; @@ -133,6 +134,59 @@ describe("MusicLibraryService", () => { ); }); }); + + describe("updateArtist", () => { + let updateArtistDto: UpdateArtistDto; + let artist: Artist; + + beforeEach(() => { + artist = { + id: "ARTIST", + name: "Foo", + genres: [{ id: "GENRE_POP", name: "Baz Pop" }], + spotify: { + id: "SPOTIFY_ID", + }, + } as Artist; + + updateArtistDto = { + artist, + updatedFields: { + name: "Bar", + genres: [ + { id: "GENRE_METAL", name: "Foo Metal" }, + { id: "GENRE_ROCK", name: "Bar Rock" }, + ], + }, + } as UpdateArtistDto; + + artistRepository.save = jest + .fn() + .mockImplementation(async (_artist) => _artist); + }); + + it("updates the entity", async () => { + await expect(service.updateArtist(updateArtistDto)).resolves.toEqual( + artist + ); + + expect(artistRepository.save).toHaveBeenCalledTimes(1); + expect(artistRepository.save).toHaveBeenCalledWith(artist); + + expect(artist).toHaveProperty("name", "Bar"); + expect(artist).toHaveProperty("genres", expect.arrayContaining([])); + }); + + it("throws on generic errors", async () => { + artistRepository.save = jest + .fn() + .mockRejectedValue(new Error("Generic Error")); + + await expect(service.updateArtist(updateArtistDto)).rejects.toThrow( + "Generic Error" + ); + }); + }); }); describe("Album", () => { diff --git a/src/music-library/music-library.service.ts b/src/music-library/music-library.service.ts index 25ddb18..44498a7 100644 --- a/src/music-library/music-library.service.ts +++ b/src/music-library/music-library.service.ts @@ -12,6 +12,7 @@ import { FindAlbumDto } from "./dto/find-album.dto"; import { FindArtistDto } from "./dto/find-artist.dto"; import { FindGenreDto } from "./dto/find-genre.dto"; import { FindTrackDto } from "./dto/find-track.dto"; +import { UpdateArtistDto } from "./dto/update-artist.dto"; import { Genre } from "./genre.entity"; import { GenreRepository } from "./genre.repository"; import { Track } from "./track.entity"; @@ -32,6 +33,17 @@ export class MusicLibraryService { }); } + async getArtistWithOldestUpdate(): Promise { + const [oldestArtist] = await this.artistRepository.find({ + take: 1, + order: { + updatedAt: "ASC", + }, + }); + + return oldestArtist; + } + async createArtist(data: CreateArtistDto): Promise { const artist = this.artistRepository.create(); @@ -57,6 +69,17 @@ export class MusicLibraryService { return artist; } + async updateArtist({ + artist, + updatedFields, + }: UpdateArtistDto): Promise { + artist.name = updatedFields.name; + artist.genres = updatedFields.genres; + artist.updatedAt = new Date(); + + return this.artistRepository.save(artist); + } + async findAlbum(query: FindAlbumDto): Promise { return this.albumRepository.findOne({ where: { spotify: { id: query.spotify.id } }, diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index 9905316..ad11aee 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -106,6 +106,7 @@ export class ReportsService { artist: { id: data.id, name: data.name, + updatedAt: data.updatedAt, spotify: { id: data.spotifyId, uri: data.spotifyUri, @@ -238,6 +239,7 @@ export class ReportsService { artist: { id: artistsData.id, name: artistsData.name, + updatedAt: artistsData.updatedAt, spotify: { id: artistsData.spotifyId, uri: artistsData.spotifyUri, diff --git a/src/sources/scheduler.service.ts b/src/sources/scheduler.service.ts index 37e7c09..df65c6f 100644 --- a/src/sources/scheduler.service.ts +++ b/src/sources/scheduler.service.ts @@ -18,6 +18,7 @@ export class SchedulerService implements OnApplicationBootstrap { onApplicationBootstrap() { this.setupSpotifyCrawler(); + this.setupSpotifyMusicLibraryUpdater(); } private setupSpotifyCrawler() { @@ -33,4 +34,16 @@ export class SchedulerService implements OnApplicationBootstrap { this.registry.addInterval("crawler_spotify", interval); } + + private setupSpotifyMusicLibraryUpdater() { + const callback = () => { + this.spotifyService.runUpdaterForAllEntities(); + }; + const timeoutMs = + this.config.get("SPOTIFY_UPDATE_INTERVAL_SEC") * 1000; + + const interval = setInterval(callback, timeoutMs); + + this.registry.addInterval("updater_spotify", interval); + } } diff --git a/src/sources/spotify/spotify.service.ts b/src/sources/spotify/spotify.service.ts index 7aea066..1e54350 100644 --- a/src/sources/spotify/spotify.service.ts +++ b/src/sources/spotify/spotify.service.ts @@ -126,6 +126,17 @@ export class SpotifyService { }); } + async runUpdaterForAllEntities(): Promise { + this.logger.debug("Starting Spotify updater loop"); + + const oldestArtist = + await this.musicLibraryService.getArtistWithOldestUpdate(); + + if (oldestArtist) { + await this.updateArtist(oldestArtist.spotify.id); + } + } + async importTrack( spotifyID: string, retryOnExpiredToken: boolean = true @@ -266,6 +277,44 @@ export class SpotifyService { }); } + async updateArtist( + spotifyID: string, + retryOnExpiredToken: boolean = true + ): Promise { + const artist = await this.importArtist(spotifyID, retryOnExpiredToken); + + let spotifyArtist: ArtistObject; + + try { + spotifyArtist = await this.spotifyApi.getArtist( + this.appAccessToken, + 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( + spotifyArtist.genres.map((genreName) => this.importGenre(genreName)) + ); + + this.musicLibraryService.updateArtist({ + artist, + updatedFields: { + name: spotifyArtist.name, + genres, + }, + }); + + return artist; + } + async importGenre(name: string): Promise { const genre = await this.musicLibraryService.findGenre({ name,