From 3c6f3289f1606865d9d869528c924c15321cc9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Tue, 25 May 2021 19:23:42 +0200 Subject: [PATCH] feat(server): save genres for artists and albums This can later be used for reports --- .../migrations/05-CreateGenreTables.ts | 132 ++++++++++++++++++ src/music-library/album.entity.ts | 4 + src/music-library/artist.entity.ts | 4 + src/music-library/dto/create-album.dto.ts | 2 + src/music-library/dto/create-artist.dto.ts | 2 + src/music-library/dto/create-genre.dto.ts | 3 + src/music-library/dto/find-genre.dto.ts | 3 + src/music-library/genre.entity.ts | 10 ++ src/music-library/genre.repository.ts | 5 + src/music-library/music-library.module.ts | 2 + src/music-library/music-library.service.ts | 34 +++++ src/sources/spotify/spotify.service.ts | 24 ++++ 12 files changed, 225 insertions(+) create mode 100644 src/database/migrations/05-CreateGenreTables.ts create mode 100644 src/music-library/dto/create-genre.dto.ts create mode 100644 src/music-library/dto/find-genre.dto.ts create mode 100644 src/music-library/genre.entity.ts create mode 100644 src/music-library/genre.repository.ts diff --git a/src/database/migrations/05-CreateGenreTables.ts b/src/database/migrations/05-CreateGenreTables.ts new file mode 100644 index 0000000..7d818aa --- /dev/null +++ b/src/database/migrations/05-CreateGenreTables.ts @@ -0,0 +1,132 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, +} from "typeorm"; +import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions"; + +const primaryUUIDColumn: TableColumnOptions = { + name: "id", + type: "uuid", + isPrimary: true, + isGenerated: true, + generationStrategy: "uuid", +}; + +export class CreateGenreTables0000000000005 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "genre", + columns: [ + primaryUUIDColumn, + { + name: "name", + type: "varchar", + }, + ], + indices: [ + new TableIndex({ + name: "IDX_GENRE_NAME", + columnNames: ["name"], + }), + ], + }), + true + ); + + await queryRunner.createTable( + new Table({ + name: "artist_genres", + columns: [ + { + name: "artistId", + type: "uuid", + isPrimary: true, + }, + { + name: "genreId", + type: "uuid", + isPrimary: true, + }, + ], + indices: [ + new TableIndex({ + name: "IDX_ARTIST_GENRES_ARTIST_ID", + columnNames: ["artistId"], + }), + new TableIndex({ + name: "IDX_ARTIST_GENRES_GENRE_ID", + columnNames: ["genreId"], + }), + ], + foreignKeys: [ + new TableForeignKey({ + name: "FK_ARTIST_ID", + columnNames: ["artistId"], + referencedColumnNames: ["id"], + referencedTableName: "artist", + }), + new TableForeignKey({ + name: "FK_GENRE_ID", + columnNames: ["genreId"], + referencedColumnNames: ["id"], + referencedTableName: "genre", + }), + ], + }), + true + ); + + await queryRunner.createTable( + new Table({ + name: "artist_genres", + columns: [ + { + name: "artistId", + type: "uuid", + isPrimary: true, + }, + { + name: "genreId", + type: "uuid", + isPrimary: true, + }, + ], + indices: [ + new TableIndex({ + name: "IDX_ARTIST_GENRES_ARTIST_ID", + columnNames: ["artistId"], + }), + new TableIndex({ + name: "IDX_ARTIST_GENRES_GENRE_ID", + columnNames: ["genreId"], + }), + ], + foreignKeys: [ + new TableForeignKey({ + name: "FK_ARTIST_ID", + columnNames: ["artistId"], + referencedColumnNames: ["id"], + referencedTableName: "artist", + }), + new TableForeignKey({ + name: "FK_GENRE_ID", + columnNames: ["genreId"], + referencedColumnNames: ["id"], + referencedTableName: "genre", + }), + ], + }), + true + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("album_genres"); + await queryRunner.dropTable("artist_genres"); + await queryRunner.dropTable("genre"); + } +} diff --git a/src/music-library/album.entity.ts b/src/music-library/album.entity.ts index e4de0ac..44ca2a5 100644 --- a/src/music-library/album.entity.ts +++ b/src/music-library/album.entity.ts @@ -8,6 +8,7 @@ import { } from "typeorm"; import { SpotifyLibraryDetails } from "../sources/spotify/spotify-library-details.entity"; import { Artist } from "./artist.entity"; +import { Genre } from "./genre.entity"; import { Track } from "./track.entity"; @Entity() @@ -25,6 +26,9 @@ export class Album { @OneToMany(() => Track, (track) => track.album) tracks?: Track[]; + @ManyToMany(() => Genre) + genres?: Genre[]; + @Column(() => SpotifyLibraryDetails) spotify: SpotifyLibraryDetails; } diff --git a/src/music-library/artist.entity.ts b/src/music-library/artist.entity.ts index 285b55b..c2592e7 100644 --- a/src/music-library/artist.entity.ts +++ b/src/music-library/artist.entity.ts @@ -1,6 +1,7 @@ import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from "typeorm"; import { SpotifyLibraryDetails } from "../sources/spotify/spotify-library-details.entity"; import { Album } from "./album.entity"; +import { Genre } from "./genre.entity"; @Entity() export class Artist { @@ -13,6 +14,9 @@ export class Artist { @ManyToMany(() => Album, (album) => album.artists) albums?: Album[]; + @ManyToMany(() => Genre) + genres?: Genre[]; + @Column(() => SpotifyLibraryDetails) spotify: SpotifyLibraryDetails; } diff --git a/src/music-library/dto/create-album.dto.ts b/src/music-library/dto/create-album.dto.ts index 3605198..47cb992 100644 --- a/src/music-library/dto/create-album.dto.ts +++ b/src/music-library/dto/create-album.dto.ts @@ -1,8 +1,10 @@ import { SpotifyLibraryDetails } from "../../sources/spotify/spotify-library-details.entity"; import { Artist } from "../artist.entity"; +import { Genre } from "../genre.entity"; export class CreateAlbumDto { name: string; artists: Artist[]; + genres: Genre[]; spotify?: SpotifyLibraryDetails; } diff --git a/src/music-library/dto/create-artist.dto.ts b/src/music-library/dto/create-artist.dto.ts index acaf4ae..05445f5 100644 --- a/src/music-library/dto/create-artist.dto.ts +++ b/src/music-library/dto/create-artist.dto.ts @@ -1,6 +1,8 @@ import { SpotifyLibraryDetails } from "../../sources/spotify/spotify-library-details.entity"; +import { Genre } from "../genre.entity"; export class CreateArtistDto { name: string; + genres: Genre[]; spotify?: SpotifyLibraryDetails; } diff --git a/src/music-library/dto/create-genre.dto.ts b/src/music-library/dto/create-genre.dto.ts new file mode 100644 index 0000000..b52cbfd --- /dev/null +++ b/src/music-library/dto/create-genre.dto.ts @@ -0,0 +1,3 @@ +export class CreateGenreDto { + name: string; +} diff --git a/src/music-library/dto/find-genre.dto.ts b/src/music-library/dto/find-genre.dto.ts new file mode 100644 index 0000000..a966700 --- /dev/null +++ b/src/music-library/dto/find-genre.dto.ts @@ -0,0 +1,3 @@ +export class FindGenreDto { + name: string; +} diff --git a/src/music-library/genre.entity.ts b/src/music-library/genre.entity.ts new file mode 100644 index 0000000..e8dae8a --- /dev/null +++ b/src/music-library/genre.entity.ts @@ -0,0 +1,10 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +@Entity() +export class Genre { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ unique: true }) + name: string; +} diff --git a/src/music-library/genre.repository.ts b/src/music-library/genre.repository.ts new file mode 100644 index 0000000..b3e32d6 --- /dev/null +++ b/src/music-library/genre.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm"; +import { Genre } from "./genre.entity"; + +@EntityRepository(Genre) +export class GenreRepository extends Repository {} diff --git a/src/music-library/music-library.module.ts b/src/music-library/music-library.module.ts index d183354..8c95e63 100644 --- a/src/music-library/music-library.module.ts +++ b/src/music-library/music-library.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { AlbumRepository } from "./album.repository"; import { ArtistRepository } from "./artist.repository"; +import { GenreRepository } from "./genre.repository"; import { MusicLibraryService } from "./music-library.service"; import { TrackRepository } from "./track.repository"; @@ -10,6 +11,7 @@ import { TrackRepository } from "./track.repository"; TypeOrmModule.forFeature([ AlbumRepository, ArtistRepository, + GenreRepository, TrackRepository, ]), ], diff --git a/src/music-library/music-library.service.ts b/src/music-library/music-library.service.ts index 2f34503..e49aeb3 100644 --- a/src/music-library/music-library.service.ts +++ b/src/music-library/music-library.service.ts @@ -6,10 +6,14 @@ 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 { CreateGenreDto } from "./dto/create-genre.dto"; import { CreateTrackDto } from "./dto/create-track.dto"; 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 { Genre } from "./genre.entity"; +import { GenreRepository } from "./genre.repository"; import { Track } from "./track.entity"; import { TrackRepository } from "./track.repository"; @@ -18,6 +22,7 @@ export class MusicLibraryService { constructor( private readonly albumRepository: AlbumRepository, private readonly artistRepository: ArtistRepository, + private readonly genreRepository: GenreRepository, private readonly trackRepository: TrackRepository ) {} @@ -80,6 +85,35 @@ export class MusicLibraryService { return album; } + async findGenre(query: FindGenreDto): Promise { + return this.genreRepository.findOne({ + where: { name: query.name }, + }); + } + + async createGenre(data: CreateGenreDto): Promise { + const genre = this.genreRepository.create(); + + genre.name = data.name; + + try { + await this.genreRepository.save(genre); + } catch (err) { + if ( + err.code === PostgresErrorCodes.UNIQUE_VIOLATION && + err.constraint === "IDX_GENRE_NAME" + ) { + // Multiple simultaneous importGenre calls for the same genre were + // executed and it is now available in the database for use to retrieve + return this.findGenre({ name: data.name }); + } + + throw err; + } + + return genre; + } + async findTrack(query: FindTrackDto): Promise { return this.trackRepository.findOne({ where: { spotify: { id: query.spotify.id } }, diff --git a/src/sources/spotify/spotify.service.ts b/src/sources/spotify/spotify.service.ts index 542886e..de116c4 100644 --- a/src/sources/spotify/spotify.service.ts +++ b/src/sources/spotify/spotify.service.ts @@ -3,6 +3,7 @@ import { ListensService } from "../../listens/listens.service"; import { Logger } from "../../logger/logger.service"; import { Album } from "../../music-library/album.entity"; import { Artist } from "../../music-library/artist.entity"; +import { Genre } from "../../music-library/genre.entity"; import { MusicLibraryService } from "../../music-library/music-library.service"; import { Track } from "../../music-library/track.entity"; import { User } from "../../users/user.entity"; @@ -209,9 +210,14 @@ export class SpotifyService { ) ); + const genres = await Promise.all( + spotifyAlbum.genres.map((genreName) => this.importGenre(genreName)) + ); + return this.musicLibraryService.createAlbum({ name: spotifyAlbum.name, artists, + genres, spotify: { id: spotifyAlbum.id, uri: spotifyAlbum.uri, @@ -249,8 +255,13 @@ export class SpotifyService { throw err; } + const genres = await Promise.all( + spotifyArtist.genres.map((genreName) => this.importGenre(genreName)) + ); + return this.musicLibraryService.createArtist({ name: spotifyArtist.name, + genres, spotify: { id: spotifyArtist.id, uri: spotifyArtist.uri, @@ -260,6 +271,19 @@ export class SpotifyService { }); } + async importGenre(name: string): Promise { + const genre = await this.musicLibraryService.findGenre({ + name, + }); + if (genre) { + return genre; + } + + return this.musicLibraryService.createGenre({ + name, + }); + } + private async refreshAppAccessToken(): Promise { if (!this.appAccessTokenInProgress) { this.logger.debug("refreshing spotify app access token");