mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
feat(api): update existing artists in MusicLibrary
This commit is contained in:
parent
a0c28e2324
commit
a0ffe108e1
11 changed files with 176 additions and 1 deletions
|
|
@ -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_ID`: _Required_, Spotify App Client ID
|
||||||
- `SPOTIFY_CLIENT_SECRET`: _Required_, Spotify App Client Secret
|
- `SPOTIFY_CLIENT_SECRET`: _Required_, Spotify App Client Secret
|
||||||
- `SPOTIFY_FETCH_INTERVAL_SEC`: **60**: Interval for fetching recently listened tracks from Spotify.
|
- `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_WEB_API_URL`: **https://api.spotify.com/**: Spotify WEB API Endpoint.
|
||||||
- `SPOTIFY_AUTH_API_URL`: **https://accounts.spotify.com/**: Spotify Authentication 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`.
|
- `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`.
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ function useProvideAuth(): AuthContext {
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
setError(err);
|
setError(err as Error);
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { ConfigModule as NestConfigModule } from "@nestjs/config";
|
||||||
SPOTIFY_CLIENT_ID: Joi.string().required(),
|
SPOTIFY_CLIENT_ID: Joi.string().required(),
|
||||||
SPOTIFY_CLIENT_SECRET: Joi.string().required(),
|
SPOTIFY_CLIENT_SECRET: Joi.string().required(),
|
||||||
SPOTIFY_FETCH_INTERVAL_SEC: Joi.number().default(60),
|
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_WEB_API_URL: Joi.string().default("https://api.spotify.com/"),
|
||||||
SPOTIFY_AUTH_API_URL: Joi.string().default(
|
SPOTIFY_AUTH_API_URL: Joi.string().default(
|
||||||
"https://accounts.spotify.com/"
|
"https://accounts.spotify.com/"
|
||||||
|
|
|
||||||
18
src/database/migrations/06-AddUpdatedAtColumns.ts
Normal file
18
src/database/migrations/06-AddUpdatedAtColumns.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
|
||||||
|
|
||||||
|
export class AddUpdatedAtColumnes0000000000006 implements MigrationInterface {
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.addColumn(
|
||||||
|
"artist",
|
||||||
|
new TableColumn({
|
||||||
|
name: "updatedAt",
|
||||||
|
type: "timestamp",
|
||||||
|
default: "NOW()",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropColumn("artist", "updatedAt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
JoinTable,
|
JoinTable,
|
||||||
ManyToMany,
|
ManyToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
} from "typeorm";
|
} from "typeorm";
|
||||||
import { SpotifyLibraryDetails } from "../sources/spotify/spotify-library-details.entity";
|
import { SpotifyLibraryDetails } from "../sources/spotify/spotify-library-details.entity";
|
||||||
import { Album } from "./album.entity";
|
import { Album } from "./album.entity";
|
||||||
|
|
@ -26,4 +27,7 @@ export class Artist {
|
||||||
|
|
||||||
@Column(() => SpotifyLibraryDetails)
|
@Column(() => SpotifyLibraryDetails)
|
||||||
spotify: SpotifyLibraryDetails;
|
spotify: SpotifyLibraryDetails;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: "timestamp" })
|
||||||
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
src/music-library/dto/update-artist.dto.ts
Normal file
10
src/music-library/dto/update-artist.dto.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Artist } from "../artist.entity";
|
||||||
|
import { Genre } from "../genre.entity";
|
||||||
|
|
||||||
|
export class UpdateArtistDto {
|
||||||
|
artist: Artist;
|
||||||
|
updatedFields: {
|
||||||
|
name: string;
|
||||||
|
genres: Genre[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import { CreateAlbumDto } from "./dto/create-album.dto";
|
||||||
import { CreateArtistDto } from "./dto/create-artist.dto";
|
import { CreateArtistDto } from "./dto/create-artist.dto";
|
||||||
import { CreateGenreDto } from "./dto/create-genre.dto";
|
import { CreateGenreDto } from "./dto/create-genre.dto";
|
||||||
import { CreateTrackDto } from "./dto/create-track.dto";
|
import { CreateTrackDto } from "./dto/create-track.dto";
|
||||||
|
import { UpdateArtistDto } from "./dto/update-artist.dto";
|
||||||
import { Genre } from "./genre.entity";
|
import { Genre } from "./genre.entity";
|
||||||
import { GenreRepository } from "./genre.repository";
|
import { GenreRepository } from "./genre.repository";
|
||||||
import { MusicLibraryService } from "./music-library.service";
|
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", () => {
|
describe("Album", () => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { FindAlbumDto } from "./dto/find-album.dto";
|
||||||
import { FindArtistDto } from "./dto/find-artist.dto";
|
import { FindArtistDto } from "./dto/find-artist.dto";
|
||||||
import { FindGenreDto } from "./dto/find-genre.dto";
|
import { FindGenreDto } from "./dto/find-genre.dto";
|
||||||
import { FindTrackDto } from "./dto/find-track.dto";
|
import { FindTrackDto } from "./dto/find-track.dto";
|
||||||
|
import { UpdateArtistDto } from "./dto/update-artist.dto";
|
||||||
import { Genre } from "./genre.entity";
|
import { Genre } from "./genre.entity";
|
||||||
import { GenreRepository } from "./genre.repository";
|
import { GenreRepository } from "./genre.repository";
|
||||||
import { Track } from "./track.entity";
|
import { Track } from "./track.entity";
|
||||||
|
|
@ -32,6 +33,17 @@ export class MusicLibraryService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getArtistWithOldestUpdate(): Promise<Artist | undefined> {
|
||||||
|
const [oldestArtist] = await this.artistRepository.find({
|
||||||
|
take: 1,
|
||||||
|
order: {
|
||||||
|
updatedAt: "ASC",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return oldestArtist;
|
||||||
|
}
|
||||||
|
|
||||||
async createArtist(data: CreateArtistDto): Promise<Artist> {
|
async createArtist(data: CreateArtistDto): Promise<Artist> {
|
||||||
const artist = this.artistRepository.create();
|
const artist = this.artistRepository.create();
|
||||||
|
|
||||||
|
|
@ -57,6 +69,17 @@ export class MusicLibraryService {
|
||||||
return artist;
|
return artist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateArtist({
|
||||||
|
artist,
|
||||||
|
updatedFields,
|
||||||
|
}: UpdateArtistDto): Promise<Artist> {
|
||||||
|
artist.name = updatedFields.name;
|
||||||
|
artist.genres = updatedFields.genres;
|
||||||
|
artist.updatedAt = new Date();
|
||||||
|
|
||||||
|
return this.artistRepository.save(artist);
|
||||||
|
}
|
||||||
|
|
||||||
async findAlbum(query: FindAlbumDto): Promise<Album | undefined> {
|
async findAlbum(query: FindAlbumDto): Promise<Album | undefined> {
|
||||||
return this.albumRepository.findOne({
|
return this.albumRepository.findOne({
|
||||||
where: { spotify: { id: query.spotify.id } },
|
where: { spotify: { id: query.spotify.id } },
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ export class ReportsService {
|
||||||
artist: {
|
artist: {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
updatedAt: data.updatedAt,
|
||||||
spotify: {
|
spotify: {
|
||||||
id: data.spotifyId,
|
id: data.spotifyId,
|
||||||
uri: data.spotifyUri,
|
uri: data.spotifyUri,
|
||||||
|
|
@ -238,6 +239,7 @@ export class ReportsService {
|
||||||
artist: {
|
artist: {
|
||||||
id: artistsData.id,
|
id: artistsData.id,
|
||||||
name: artistsData.name,
|
name: artistsData.name,
|
||||||
|
updatedAt: artistsData.updatedAt,
|
||||||
spotify: {
|
spotify: {
|
||||||
id: artistsData.spotifyId,
|
id: artistsData.spotifyId,
|
||||||
uri: artistsData.spotifyUri,
|
uri: artistsData.spotifyUri,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export class SchedulerService implements OnApplicationBootstrap {
|
||||||
|
|
||||||
onApplicationBootstrap() {
|
onApplicationBootstrap() {
|
||||||
this.setupSpotifyCrawler();
|
this.setupSpotifyCrawler();
|
||||||
|
this.setupSpotifyMusicLibraryUpdater();
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupSpotifyCrawler() {
|
private setupSpotifyCrawler() {
|
||||||
|
|
@ -33,4 +34,16 @@ export class SchedulerService implements OnApplicationBootstrap {
|
||||||
|
|
||||||
this.registry.addInterval("crawler_spotify", interval);
|
this.registry.addInterval("crawler_spotify", interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setupSpotifyMusicLibraryUpdater() {
|
||||||
|
const callback = () => {
|
||||||
|
this.spotifyService.runUpdaterForAllEntities();
|
||||||
|
};
|
||||||
|
const timeoutMs =
|
||||||
|
this.config.get<number>("SPOTIFY_UPDATE_INTERVAL_SEC") * 1000;
|
||||||
|
|
||||||
|
const interval = setInterval(callback, timeoutMs);
|
||||||
|
|
||||||
|
this.registry.addInterval("updater_spotify", interval);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,17 @@ export class SpotifyService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async importTrack(
|
async importTrack(
|
||||||
spotifyID: string,
|
spotifyID: string,
|
||||||
retryOnExpiredToken: boolean = true
|
retryOnExpiredToken: boolean = true
|
||||||
|
|
@ -266,6 +277,44 @@ export class SpotifyService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateArtist(
|
||||||
|
spotifyID: string,
|
||||||
|
retryOnExpiredToken: boolean = true
|
||||||
|
): Promise<Artist> {
|
||||||
|
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<Genre> {
|
async importGenre(name: string): Promise<Genre> {
|
||||||
const genre = await this.musicLibraryService.findGenre({
|
const genre = await this.musicLibraryService.findGenre({
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue