Add populate script and artist api

This commit is contained in:
Zoe Roux
2022-10-25 02:15:19 +09:00
committed by Bluub
parent 72be838324
commit 89dbee0b17
22 changed files with 461 additions and 104 deletions

11
back/package-lock.json generated
View File

@@ -22,6 +22,7 @@
"@types/bcryptjs": "^2.4.2",
"@types/passport": "^1.0.9",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
@@ -3099,6 +3100,11 @@
"integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==",
"dev": true
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="
},
"node_modules/class-validator": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.2.tgz",
@@ -11552,6 +11558,11 @@
"integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==",
"dev": true
},
"class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="
},
"class-validator": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.2.tgz",

View File

@@ -34,6 +34,7 @@
"@types/bcryptjs": "^2.4.2",
"@types/passport": "^1.0.9",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- Added the required column `midiPath` to the `Song` table without a default value. This is not possible if the table is not empty.
- Added the required column `musicXmlPath` to the `Song` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Song" ADD COLUMN "midiPath" TEXT NOT NULL,
ADD COLUMN "musicXmlPath" TEXT NOT NULL;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `Album` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Album_name_key" ON "Album"("name");

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `Artist` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[name]` on the table `Genre` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Artist_name_key" ON "Artist"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");

View File

@@ -1,87 +1,89 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
password String
email String
LessonHistory LessonHistory[]
id Int @id @default(autoincrement())
username String @unique
password String
email String
LessonHistory LessonHistory[]
}
model Song {
id Int @id @default(autoincrement())
name String @unique
artistId Int?
artist Artist? @relation(fields: [artistId], references: [id])
albumId Int?
album Album? @relation(fields: [albumId], references: [id])
genreId Int?
genre Genre? @relation(fields: [genreId], references: [id])
difficulties Json
id Int @id @default(autoincrement())
name String @unique
midiPath String
musicXmlPath String
artistId Int?
artist Artist? @relation(fields: [artistId], references: [id])
albumId Int?
album Album? @relation(fields: [albumId], references: [id])
genreId Int?
genre Genre? @relation(fields: [genreId], references: [id])
difficulties Json
}
model Genre {
id Int @id @default(autoincrement())
name String
id Int @id @default(autoincrement())
name String @unique
Song Song[]
Song Song[]
}
model Artist {
id Int @id @default(autoincrement())
name String
id Int @id @default(autoincrement())
name String @unique
Song Song[]
Song Song[]
}
model Album {
id Int @id @default(autoincrement())
name String
id Int @id @default(autoincrement())
name String @unique
Song Song[]
Song Song[]
}
model Lesson {
id Int @id @default(autoincrement())
name String
description String
requiredLevel Int
mainSkill Skill
LessonHistory LessonHistory[]
id Int @id @default(autoincrement())
name String
description String
requiredLevel Int
mainSkill Skill
LessonHistory LessonHistory[]
}
model LessonHistory {
lesson Lesson @relation(fields: [lessonID], references: [id])
lessonID Int
user User @relation(fields: [userID], references: [id])
userID Int
lesson Lesson @relation(fields: [lessonID], references: [id])
lessonID Int
user User @relation(fields: [userID], references: [id])
userID Int
@@id([lessonID, userID])
@@id([lessonID, userID])
}
enum Skill {
TwoHands
Rhythm
NoteCombo
Arpeggio
Distance
LeftHand
RightHand
LeadHandChange
ChordComplexity
ChordTiming
Length
PedalPoint
Precision
TwoHands
Rhythm
NoteCombo
Arpeggio
Distance
LeftHand
RightHand
LeadHandChange
ChordComplexity
ChordTiming
Length
PedalPoint
Precision
@@map("DifficultyPoint")
@@map("DifficultyPoint")
}

View File

@@ -7,10 +7,12 @@ import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { SongModule } from './song/song.module';
import { LessonModule } from './lesson/lesson.module';
import { ArtistController } from './artist/artist.controller';
import { ArtistService } from './artist/artist.service';
@Module({
imports: [UsersModule, PrismaModule, AuthModule, SongModule, LessonModule],
controllers: [AppController],
providers: [AppService, PrismaService],
controllers: [AppController, ArtistController],
providers: [AppService, PrismaService, ArtistService],
})
export class AppModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ArtistController } from './artist.controller';
describe('ArtistController', () => {
let controller: ArtistController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ArtistController],
}).compile();
controller = module.get<ArtistController>(ArtistController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,70 @@
import {
BadRequestException,
Body,
ConflictException,
Controller,
DefaultValuePipe,
Delete,
Get,
NotFoundException,
Param,
ParseIntPipe,
Post,
Query,
Req,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { CreateArtistDto } from './dto/create-artist.dto';
import { Request } from 'express';
import { ArtistService } from './artist.service';
import { Prisma, Artist } from '@prisma/client';
@Controller('artist')
export class ArtistController {
constructor(private readonly service: ArtistService) {}
@Post()
async create(@Body() dto: CreateArtistDto) {
try {
return await this.service.create(dto);
} catch {
throw new ConflictException(await this.service.get({ name: dto.name }));
}
}
@Delete(':id')
async remove(@Param('id', ParseIntPipe) id: number) {
return await this.service.delete({ id });
}
@Get()
async findAll(
@Req() req: Request,
@Query() filter: Prisma.SongWhereInput,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Artist>> {
try {
const ret = await this.service.list({
skip,
take,
where: {
...filter,
id: filter.id ? +filter.id : undefined,
},
});
return new Plage(ret, req);
} catch (e) {
console.log(e);
throw new BadRequestException(null, e?.toString());
}
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.service.get({ id });
if (res === null) throw new NotFoundException('Artist not found');
return res;
}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ArtistService } from './artist.service';
describe('ArtistService', () => {
let service: ArtistService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ArtistService],
}).compile();
service = module.get<ArtistService>(ArtistService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { Prisma, Artist } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
@Injectable()
export class ArtistService {
constructor(private prisma: PrismaService) {}
async create(data: Prisma.ArtistCreateInput): Promise<Artist> {
return this.prisma.artist.create({
data,
});
}
async get(where: Prisma.ArtistWhereUniqueInput): Promise<Artist | null> {
return this.prisma.artist.findUnique({
where,
});
}
async list(params: {
skip?: number;
take?: number;
cursor?: Prisma.ArtistWhereUniqueInput;
where?: Prisma.ArtistWhereInput;
orderBy?: Prisma.ArtistOrderByWithRelationInput;
}): Promise<Artist[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.artist.findMany({
skip,
take,
cursor,
where,
orderBy,
});
}
async delete(where: Prisma.ArtistWhereUniqueInput): Promise<Artist> {
return this.prisma.artist.delete({
where,
});
}
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
export class CreateArtistDto {
@IsNotEmpty()
@ApiProperty()
name: string;
}

View File

@@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PrismaService } from './prisma/prisma.service';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@@ -16,6 +17,7 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();

View File

@@ -9,4 +9,21 @@ export class CreateSongDto {
@IsNotEmpty()
@ApiProperty()
difficulties: object;
@IsNotEmpty()
@ApiProperty()
midiPath: string;
@IsNotEmpty()
@ApiProperty()
musicXmlPath: string;
@ApiProperty()
artist?: number;
@ApiProperty()
album?: number;
@ApiProperty()
genre?: number;
}

View File

@@ -1,6 +1,7 @@
import {
BadRequestException,
Body,
ConflictException,
Controller,
DefaultValuePipe,
Delete,
@@ -11,20 +12,57 @@ import {
Post,
Query,
Req,
StreamableFile,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { CreateSongDto } from './dto/create-song.dto';
import { SongService } from './song.service';
import { Request } from 'express';
import { Prisma, Song } from '@prisma/client';
import { createReadStream } from 'fs';
@Controller('song')
export class SongController {
constructor(private readonly songService: SongService) {}
@Get(':id/midi')
async getMidi(@Param('id', ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
const file = createReadStream(song.midiPath);
return new StreamableFile(file);
}
@Get(':id/musicXml')
async getMusicXml(@Param('id', ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
const file = createReadStream(song.midiPath);
return new StreamableFile(file);
}
@Post()
async create(@Body() createSongDto: CreateSongDto) {
return await this.songService.createSong(createSongDto);
try {
return await this.songService.createSong({
...createSongDto,
artist: createSongDto.artist
? { connect: { id: createSongDto.artist } }
: undefined,
album: createSongDto.album
? { connect: { id: createSongDto.album } }
: undefined,
genre: createSongDto.genre
? { connect: { id: createSongDto.genre } }
: undefined,
});
} catch {
throw new ConflictException(
await this.songService.song({ name: createSongDto.name }),
);
}
}
@Delete(':id')
@@ -57,7 +95,7 @@ export class SongController {
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
let res = await this.songService.song({ id });
const res = await this.songService.song({ id });
if (res === null) throw new NotFoundException('Song not found');
return res;

View File

@@ -1,84 +1,108 @@
*** Settings ***
Documentation Tests of the /song route.
... Ensures that the songs CRUD works corectly.
Resource ../rest.resource
Documentation Tests of the /song route.
... Ensures that the songs CRUD works corectly.
Resource ../rest.resource
*** Keywords ***
*** Test Cases ***
Create a song
[Documentation] Create a song
&{res}= POST /song {"name": "Mama mia", "difficulties": {}}
&{res}= POST
... /song
... {"name": "Mama mia", "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
[Teardown] DELETE /song/${res.body.id}
Integer response status 201
[Teardown] DELETE /song/${res.body.id}
Find a song
[Documentation] Create a song and find it
&{res}= POST /song {"name": "Mama mia", "difficulties": {}}
&{res}= POST
... /song
... {"name": "Mama mia", "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
&{get}= GET /song/${res.body.id}
Integer response status 201
&{get}= GET /song/${res.body.id}
Output
Integer response status 200
Should Be Equal ${res.body} ${get.body}
[Teardown] DELETE /song/${res.body.id}
Integer response status 200
Should Be Equal ${res.body} ${get.body}
[Teardown] DELETE /song/${res.body.id}
Find a song non existant
[Documentation] Find non existant song
&{get}= GET /song/9999
Integer response status 404
&{get}= GET /song/9999
Integer response status 404
Find multiples songs
[Documentation] Create two songs and find them
&{res}= POST /song {"name": "Mama mia", "difficulties": {}}
&{res}= POST
... /song
... {"name": "Mama mia", "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
&{res2}= POST /song {"name": "Here we go again", "difficulties": {}}
Output
Integer response status 201
Integer response status 201
&{res2}= POST
... /song
... {"name": "Toto", "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
&{get}= GET /song
Output
Integer response status 200
Should Contain ${get.body.data} ${res.body}
Should Contain ${get.body.data} ${res2.body}
[Teardown] Run Keywords DELETE /song/${res.body.id}
... AND DELETE /song/${res2.body.id}
Integer response status 201
&{get}= GET /song
Output
Integer response status 200
Should Contain ${get.body.data} ${res.body}
Should Contain ${get.body.data} ${res2.body}
[Teardown] Run Keywords DELETE /song/${res.body.id}
... AND DELETE /song/${res2.body.id}
Find multiples songs filtered
[Documentation] Create two songs and find them
&{res}= POST /song {"name": "Mamamia", "difficulties": {}}
&{res}= POST
... /song
... {"name": "Mamamia", "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
&{res2}= POST /song {"name": "Here we go again", "difficulties": {}}
Integer response status 201
&{res2}= POST
... /song
... {"name": "jkgnsg", "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
Integer response status 201
&{get}= GET /song?name=Mamamia
&{get}= GET /song?name=Mamamia
Output
Integer response status 200
Should Contain ${get.body.data} ${res.body}
Should Not Contain ${get.body.data} ${res2.body}
[Teardown] Run Keywords DELETE /song/${res.body.id}
... AND DELETE /song/${res2.body.id}
Integer response status 200
Should Contain ${get.body.data} ${res.body}
Should Not Contain ${get.body.data} ${res2.body}
[Teardown] Run Keywords DELETE /song/${res.body.id}
... AND DELETE /song/${res2.body.id}
Find multiples songs filtered by type
[Documentation] Create two songs and find them
&{res}= POST /song {"name": "Mamamia", "difficulties": {}}
&{res}= POST
... /song
... {"name": "Mama mia", "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
&{res2}= POST /song {"name": "Here we go again", "difficulties": {}}
Integer response status 201
&{res2}= POST
... /song
... {"name": "kldngsd", "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
Integer response status 201
&{get}= GET /song?id=${res.body.id}
&{get}= GET /song?id=${res.body.id}
Output
Integer response status 200
Should Contain ${get.body.data} ${res.body}
Should Not Contain ${get.body.data} ${res2.body}
[Teardown] Run Keywords DELETE /song/${res.body.id}
... AND DELETE /song/${res2.body.id}
Integer response status 200
Should Contain ${get.body.data} ${res.body}
Should Not Contain ${get.body.data} ${res2.body}
[Teardown] Run Keywords DELETE /song/${res.body.id}
... AND DELETE /song/${res2.body.id}
Get midi file
&{res}= POST
... /song
... {"name": "Mama mia", "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
GET /song/${res.body.id}/midi
Output
[Teardown] DELETE /song/${res.body.id}

View File

@@ -8,6 +8,7 @@ services:
- "3000:3000"
volumes:
- ./back:/app
- ./musics:/musics
depends_on:
- "db"
env_file:

View File

@@ -7,6 +7,8 @@ services:
- "db"
env_file:
- .env
volumes:
- ./musics:/musics
db:
container_name: db
image: postgres:alpine3.14

View File

@@ -0,0 +1,21 @@
[Metadata]
Name=Symphony No 9 in D Minor
Artist=Beethoven
Genre=Classical
Album=
[Difficulties]
TwoHands=0
Rhythm=4
NoteCombo=0
Arpeggio=6
Distance=0
LeftHand=2
RightHand=1
LeadHandChange=0
ChordComplexity=0
ChordTiming=0
Length=1
PedalPoint=0
Precision=10

Binary file not shown.

Binary file not shown.

49
musics/populate.py Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/env python3
import sys
import os
import requests
import glob
from configparser import ConfigParser
url = os.environ.get("API_URL")
def getOrCreateArtist(name):
res = requests.post(f"{url}/artist", json={
"name": name,
})
out = res.json()
print(out)
return out["id"]
def populateFile(path, midi, mxl):
config = ConfigParser()
config.read(path)
metadata = config["Metadata"];
dificulties = dict(config["Difficulties"])
print(f"Populating {metadata['Name']}")
res = requests.post(f"{url}/song", json={
"name": metadata["Name"],
"midiPath": midi,
"musicXmlPath": mxl,
"difficulties": dificulties,
"artist": getOrCreateArtist(metadata["Artist"]),
# "album": metadata["Album"],
# "genre": metadata["Genre"],
})
print(res.json())
def main():
global url
if url == None:
url = "http://localhost:3000"
print("Searching for files...")
for file in glob.glob("**/*.ini", recursive=True):
file = os.path.abspath(file)
print(f"File found: {file}")
path = os.path.splitext(file)[0]
populateFile(file, path + ".midi", path + ".mxl")
if __name__ == "__main__":
exit(main())