From 661f5cc3042a74f84d4809e63781ada492fc5e36 Mon Sep 17 00:00:00 2001 From: Arthur Jamet Date: Sat, 21 Dec 2024 08:59:49 +0000 Subject: [PATCH 1/8] Server: Thumbnails are independent from release illustrations --- .../migration.sql | 53 +++++++++++++++++++ server/prisma/schema.prisma | 4 ++ .../illustration.controller.spec.ts | 17 ++++-- .../illustration/illustration.repository.ts | 19 +++++++ .../src/registration/registration.service.ts | 19 ++++--- .../track/models/track.query-parameters.ts | 10 ++-- server/src/track/models/track.response.ts | 4 +- server/src/track/track.service.ts | 9 ++++ server/src/video/video.controller.spec.ts | 2 +- server/test/expected-responses.ts | 2 +- 10 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 server/prisma/migrations/20241221080934_track_thumbnail/migration.sql diff --git a/server/prisma/migrations/20241221080934_track_thumbnail/migration.sql b/server/prisma/migrations/20241221080934_track_thumbnail/migration.sql new file mode 100644 index 000000000..27bdc7d9c --- /dev/null +++ b/server/prisma/migrations/20241221080934_track_thumbnail/migration.sql @@ -0,0 +1,53 @@ +/* + Warnings: + + - A unique constraint covering the columns `[thumbnailId]` on the table `tracks` will be added. If there are existing duplicate values, this will fail. + */ +-- AlterTable +ALTER TABLE "tracks" + ADD COLUMN "thumbnailId" INTEGER; + +-- CreateIndex +CREATE UNIQUE INDEX "tracks_thumbnailId_key" ON "tracks" ("thumbnailId"); + +-- AddForeignKey +ALTER TABLE "tracks" + ADD CONSTRAINT "tracks_thumbnailId_fkey" FOREIGN KEY ("thumbnailId") REFERENCES "illustrations" ("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Update Track Illustration View so that it returns the thumbnail first +CREATE OR REPLACE VIEW "track_illustrations_view" AS +SELECT + i.*, + t.id AS "trackId" +FROM + tracks t + JOIN releases r ON r.id = t."releaseId" + -- Left join, because release can be null or not have artwork. + -- In that case, we still want videos to have their thumbnails + LEFT JOIN LATERAL ( + SELECT + "illustrationId" + FROM + release_illustrations ri + WHERE + ri."releaseId" = r."id" + AND ( + -- Take Exact disc/track illustration + ((ri.disc IS NOT DISTINCT FROM t."discIndex") + AND (ri.track IS NOT DISTINCT FROM t."trackIndex")) + OR ( + -- Take disc illustration + (ri.disc IS NOT DISTINCT FROM t."discIndex") + AND (ri.track IS NULL)) + OR ( + -- Or the release illustration + (ri.disc IS NULL) + AND (ri.track IS NULL))) + ORDER BY + track NULLS LAST, + disc ASC NULLS LAST + LIMIT 1) AS ri ON TRUE + -- TODO: Avoid join lateral if there's a thumbnail + -- If there is a thumbnail for this track, prefer it to the artwork + JOIN illustrations i ON i.id = COALESCE(t."thumbnailId", ri."illustrationId"); + diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 60fe415e4..6760d577a 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -435,6 +435,9 @@ model Track { sourceFileId Int @unique masterOf Song? @relation("Master") illustration TrackIllustration? + thumbnailId Int? @unique + /// @description (For internal use) Thumbnail for the track (only for video tracks) + thumbnail Illustration? @relation(fields: [thumbnailId], references: [id]) @@map("tracks") } @@ -620,6 +623,7 @@ model Illustration { aspectRatio Float @default(1) type IllustrationType Provider Provider[] + Track Track? @@map("illustrations") } diff --git a/server/src/illustration/illustration.controller.spec.ts b/server/src/illustration/illustration.controller.spec.ts index ff4e37bfd..e8d77ebe9 100644 --- a/server/src/illustration/illustration.controller.spec.ts +++ b/server/src/illustration/illustration.controller.spec.ts @@ -326,23 +326,29 @@ describe("Illustration Controller", () => { }); describe("Register Illustration", () => { - const buildData = (r: request.Test) => { + const buildData = (r: request.Test, trackId: number) => { return r - .field("trackId", dummyRepository.trackC1_1.id) .field("type", "Thumbnail") + .field("trackId", trackId) .attach("file", createReadStream("test/assets/cover2.jpg")); }; describe("Error handling", () => { it("Should throw, as target track does not exist", async () => { return buildData( request(app.getHttpServer()).post(`/illustrations/file`), - ) - .expect(400) - .field("trackId", 1); + 1, + ).expect(404); + }); + it("Should throw, as target track is not a video", async () => { + return buildData( + request(app.getHttpServer()).post(`/illustrations/file`), + dummyRepository.trackA1_1.id, + ).expect(400); }); it("Should throw, as type is not valid", async () => { return buildData( request(app.getHttpServer()).post(`/illustrations/file`), + dummyRepository.trackC1_1.id, ) .expect(400) .field("type", "Avatar"); @@ -351,6 +357,7 @@ describe("Illustration Controller", () => { it("Should set the image's illustration", async () => { return buildData( request(app.getHttpServer()).post(`/illustrations/file`), + dummyRepository.trackA1_2Video.id, ) .expect(201) .expect((res) => { diff --git a/server/src/illustration/illustration.repository.ts b/server/src/illustration/illustration.repository.ts index 2ea4fa9eb..c2c598ece 100644 --- a/server/src/illustration/illustration.repository.ts +++ b/server/src/illustration/illustration.repository.ts @@ -282,6 +282,25 @@ export default class IllustrationRepository { return newIllustration; } + async saveTrackThumbnail( + buffer: Buffer, + where: TrackQueryParameters.WhereInput, + ) { + const track = await this.trackService.get(where); + const newIllustration = await this.saveIllustration( + buffer, + IllustrationType.Thumbnail, + ); + if (track.thumbnailId) { + await this.deleteIllustration(track.thumbnailId); + } + await this.trackService.update( + { thumbnailId: newIllustration.id }, + where, + ); + return newIllustration; + } + async saveReleaseIllustration( buffer: Buffer, disc: number | null, diff --git a/server/src/registration/registration.service.ts b/server/src/registration/registration.service.ts index 9b11264a5..bf8b323a7 100644 --- a/server/src/registration/registration.service.ts +++ b/server/src/registration/registration.service.ts @@ -21,7 +21,12 @@ import MetadataDto from "./models/metadata.dto"; import MetadataService from "src/registration/metadata.service"; import SettingsService from "src/settings/settings.service"; import LibraryService from "src/library/library.service"; -import { Illustration, IllustrationType, Library } from "@prisma/client"; +import { + Illustration, + IllustrationType, + Library, + TrackType, +} from "@prisma/client"; import { LibraryNotFoundException } from "src/library/library.exceptions"; import FileService from "src/file/file.service"; import * as path from "path"; @@ -157,14 +162,16 @@ export class RegistrationService { }${trackIndex === null ? "" : `, track ${trackIndex}`}).`, ); if (type == IllustrationType.Thumbnail) { - return this.illustrationRepository.saveReleaseIllustration( + if (track.type != TrackType.Video) { + throw new InvalidRequestException( + "Cannot save a thumbnail for an audio track", + ); + } + return this.illustrationRepository.saveTrackThumbnail( illustrationBytes, - track.discIndex, - track.trackIndex, { - id: track.releaseId, + id: track.id, }, - type, ); } const parentReleaseIllustrations = diff --git a/server/src/track/models/track.query-parameters.ts b/server/src/track/models/track.query-parameters.ts index 1da303b8b..7f78a1584 100644 --- a/server/src/track/models/track.query-parameters.ts +++ b/server/src/track/models/track.query-parameters.ts @@ -42,9 +42,13 @@ namespace TrackQueryParameters { | "releaseId" | "song" | "songId" - > & { sourceFile: FileQueryParameters.WhereInput } & { + | "thumbnail" + | "thumbnailId" + > & { + sourceFile: FileQueryParameters.WhereInput; release: ReleaseQueryParameters.WhereInput; - } & { song: SongQueryParameters.WhereInput }; + song: SongQueryParameters.WhereInput; + }; /** * Query parameters to find one track @@ -76,7 +80,7 @@ namespace TrackQueryParameters { /** * The input required to update a track in the database */ - export type UpdateInput = Partial; + export type UpdateInput = Partial; /** * The input to find or create a track diff --git a/server/src/track/models/track.response.ts b/server/src/track/models/track.response.ts index 1e53fd2fb..7170c342b 100644 --- a/server/src/track/models/track.response.ts +++ b/server/src/track/models/track.response.ts @@ -17,7 +17,7 @@ */ import { Inject, Injectable, forwardRef } from "@nestjs/common"; -import { IntersectionType } from "@nestjs/swagger"; +import { IntersectionType, OmitType } from "@nestjs/swagger"; import { Track, TrackWithRelations } from "src/prisma/models"; import { ReleaseResponse, @@ -34,7 +34,7 @@ import { } from "src/illustration/models/illustration.response"; export class TrackResponse extends IntersectionType( - Track, + class extends OmitType(Track, ["thumbnailId"]) {}, IllustratedResponse, class { song?: SongResponse; diff --git a/server/src/track/track.service.ts b/server/src/track/track.service.ts index 91edd59e6..9afb6a9fa 100644 --- a/server/src/track/track.service.ts +++ b/server/src/track/track.service.ts @@ -382,6 +382,10 @@ export default class TrackService { where: TrackService.formatWhereInput(where), data: { ...what, + thumbnailId: undefined, + thumbnail: what.thumbnailId + ? { connect: { id: what.thumbnailId } } + : undefined, song: what.song ? { connect: SongService.formatWhereInput( @@ -429,6 +433,11 @@ export default class TrackService { where: where, }) .then((deleted) => { + if (deleted.thumbnailId) { + this.illustrationRepository.deleteIllustration( + deleted.thumbnailId, + ); + } this.illustrationRepository .getReleaseIllustrations({ id: deleted.releaseId }) .then((relatedIllustrations) => { diff --git a/server/src/video/video.controller.spec.ts b/server/src/video/video.controller.spec.ts index b8dcf6441..85cb39c59 100644 --- a/server/src/video/video.controller.spec.ts +++ b/server/src/video/video.controller.spec.ts @@ -16,7 +16,7 @@ import VideoModule from "./video.module"; jest.setTimeout(60000); -describe("Song Controller", () => { +describe("Video Controller", () => { let dummyRepository: TestPrismaService; let app: INestApplication; diff --git a/server/test/expected-responses.ts b/server/test/expected-responses.ts index 7ba7da30c..e86b3b64b 100644 --- a/server/test/expected-responses.ts +++ b/server/test/expected-responses.ts @@ -46,7 +46,7 @@ export const expectedReleaseResponse = (release: Release) => ({ releaseDate: release.releaseDate?.toISOString() ?? null, }); -export const expectedTrackResponse = (track: Track) => ({ +export const expectedTrackResponse = ({ thumbnailId, ...track }: Track) => ({ ...track, }); From 73d8766b031b07aac3dc85a020440171019823dd Mon Sep 17 00:00:00 2001 From: Arthur Jamet Date: Sat, 21 Dec 2024 10:43:54 +0000 Subject: [PATCH 2/8] Server: Tracks can have illustration without release --- .../migration.sql | 53 +++++++++++++++ server/prisma/schema.prisma | 64 ++++++++++--------- .../track/models/track.query-parameters.ts | 6 +- server/src/track/models/track.response.ts | 5 +- server/src/track/track.service.ts | 4 ++ server/test/expected-responses.ts | 6 +- 6 files changed, 105 insertions(+), 33 deletions(-) create mode 100644 server/prisma/migrations/20241221104003_standalone_track_illustration/migration.sql diff --git a/server/prisma/migrations/20241221104003_standalone_track_illustration/migration.sql b/server/prisma/migrations/20241221104003_standalone_track_illustration/migration.sql new file mode 100644 index 000000000..52f436292 --- /dev/null +++ b/server/prisma/migrations/20241221104003_standalone_track_illustration/migration.sql @@ -0,0 +1,53 @@ +/* + Warnings: + + - A unique constraint covering the columns `[standaloneIllustrationId]` on the table `tracks` will be added. If there are existing duplicate values, this will fail. + */ +-- AlterTable +ALTER TABLE "tracks" + ADD COLUMN "standaloneIllustrationId" INTEGER; + +-- CreateIndex +CREATE UNIQUE INDEX "tracks_standaloneIllustrationId_key" ON "tracks" ("standaloneIllustrationId"); + +-- AddForeignKey +ALTER TABLE "tracks" + ADD CONSTRAINT "tracks_standaloneIllustrationId_fkey" FOREIGN KEY ("standaloneIllustrationId") REFERENCES "illustrations" ("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Update Track Illustration View so that it returns the thumbnail first, or standalone illustration +CREATE OR REPLACE VIEW "track_illustrations_view" AS +SELECT + i.*, + t.id AS "trackId" +FROM + tracks t + JOIN releases r ON r.id = t."releaseId" + -- Left join, because release can be null or not have artwork. + -- In that case, we still want videos to have their thumbnails + LEFT JOIN LATERAL ( + SELECT + "illustrationId" + FROM + release_illustrations ri + WHERE + ri."releaseId" = r."id" + AND ( + -- Take Exact disc/track illustration + ((ri.disc IS NOT DISTINCT FROM t."discIndex") + AND (ri.track IS NOT DISTINCT FROM t."trackIndex")) + OR ( + -- Take disc illustration + (ri.disc IS NOT DISTINCT FROM t."discIndex") + AND (ri.track IS NULL)) + OR ( + -- Or the release illustration + (ri.disc IS NULL) + AND (ri.track IS NULL))) + ORDER BY + track NULLS LAST, + disc ASC NULLS LAST + LIMIT 1) AS ri ON TRUE + -- TODO: Avoid join lateral if there's a thumbnail + -- If there is a thumbnail for this track, prefer it to the standalone illustration and release artwork + JOIN illustrations i ON i.id = COALESCE(t."thumbnailId", t."standaloneIllustrationId", ri."illustrationId"); + diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 6760d577a..df7b36ba5 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -392,52 +392,55 @@ enum TrackType { model Track { /// @description Unique numeric Track identifier /// @example 123 - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @description The reference song - song Song @relation(fields: [songId], references: [id]) + song Song @relation(fields: [songId], references: [id]) /// @description Unique numeric identifier of the parent song /// @example 123 - songId Int + songId Int /// @description The parent release the track can be found on - release Release @relation(fields: [releaseId], references: [id]) + release Release @relation(fields: [releaseId], references: [id]) /// @description Unique numeric identifier of the parent release /// @example 123 - releaseId Int + releaseId Int /// @description The display name of the track /// @example My Song (Album Version) - name String + name String /// @description The index of the disc the track is on /// @example 1 - discIndex Int? + discIndex Int? /// @description The index of the track on the disc /// @example 2 - trackIndex Int? + trackIndex Int? /// @description Type of track (Audio or Video) - type TrackType + type TrackType /// @description Bitrate, in kbps /// @example 320 - bitrate Int? + bitrate Int? /// @description The type of source the track is ripped from - ripSource RipSource? + ripSource RipSource? /// @description The duration in seconds of the track /// @example 120 - duration Int? + duration Int? /// @description If it is a "Bonus" track /// @example false - isBonus Boolean @default(false) + isBonus Boolean @default(false) /// @description If the track has been remastered /// @example false - isRemastered Boolean @default(false) + isRemastered Boolean @default(false) /// @description The track from the file - sourceFile File @relation(fields: [sourceFileId], references: [id]) + sourceFile File @relation(fields: [sourceFileId], references: [id]) /// @description Unique numeric identifier of the source file /// @example 123 - sourceFileId Int @unique - masterOf Song? @relation("Master") - illustration TrackIllustration? - thumbnailId Int? @unique + sourceFileId Int @unique + masterOf Song? @relation("Master") + illustration TrackIllustration? + thumbnailId Int? @unique /// @description (For internal use) Thumbnail for the track (only for video tracks) - thumbnail Illustration? @relation(fields: [thumbnailId], references: [id]) + thumbnail Illustration? @relation(fields: [thumbnailId], references: [id], name: "track_thumbnail") + // Illustration if track is not attached to a release + standaloneIllustrationId Int? @unique + standaloneIllustration Illustration? @relation(fields: [standaloneIllustrationId], references: [id], name: "track_standalone_illustration") @@map("tracks") } @@ -608,22 +611,23 @@ enum IllustrationType { model Illustration { /// @description Unique numeric identifier /// @example 123 - id Int @id @default(autoincrement()) - artist Artist? - playlist Playlist? - release ReleaseIllustration? + id Int @id @default(autoincrement()) + artist Artist? + playlist Playlist? + release ReleaseIllustration? /// @description Blurhash value of the illustration. See https://github.com/woltapp/blurhash for more info. /// @example LEHV6nWB2yk8pyo0adR*.7kCMdnj - blurhash String + blurhash String /// @description List of dominant colors in the image /// @example ['#FFFFFF', '#123123', '#F0F0F0'] - colors String[] + colors String[] /// @description Aspect Ratio of the image /// @example 0.33 - aspectRatio Float @default(1) - type IllustrationType - Provider Provider[] - Track Track? + aspectRatio Float @default(1) + type IllustrationType + Provider Provider[] + ThumbnailTrack Track? @relation("track_thumbnail") + StandaloneTrack Track? @relation("track_standalone_illustration") @@map("illustrations") } diff --git a/server/src/track/models/track.query-parameters.ts b/server/src/track/models/track.query-parameters.ts index 7f78a1584..0a262f02f 100644 --- a/server/src/track/models/track.query-parameters.ts +++ b/server/src/track/models/track.query-parameters.ts @@ -44,6 +44,8 @@ namespace TrackQueryParameters { | "songId" | "thumbnail" | "thumbnailId" + | "standaloneIllustration" + | "standaloneIllustrationId" > & { sourceFile: FileQueryParameters.WhereInput; release: ReleaseQueryParameters.WhereInput; @@ -80,7 +82,9 @@ namespace TrackQueryParameters { /** * The input required to update a track in the database */ - export type UpdateInput = Partial; + export type UpdateInput = Partial< + CreateInput & { thumbnailId: number; standaloneIllustrationId: number } + >; /** * The input to find or create a track diff --git a/server/src/track/models/track.response.ts b/server/src/track/models/track.response.ts index 7170c342b..6a0251a20 100644 --- a/server/src/track/models/track.response.ts +++ b/server/src/track/models/track.response.ts @@ -34,7 +34,10 @@ import { } from "src/illustration/models/illustration.response"; export class TrackResponse extends IntersectionType( - class extends OmitType(Track, ["thumbnailId"]) {}, + class extends OmitType(Track, [ + "thumbnailId", + "standaloneIllustrationId", + ]) {}, IllustratedResponse, class { song?: SongResponse; diff --git a/server/src/track/track.service.ts b/server/src/track/track.service.ts index 9afb6a9fa..1d5c99579 100644 --- a/server/src/track/track.service.ts +++ b/server/src/track/track.service.ts @@ -382,6 +382,10 @@ export default class TrackService { where: TrackService.formatWhereInput(where), data: { ...what, + standaloneIllustrationId: undefined, + standaloneIllustration: what.standaloneIllustrationId + ? { connect: { id: what.standaloneIllustrationId } } + : undefined, thumbnailId: undefined, thumbnail: what.thumbnailId ? { connect: { id: what.thumbnailId } } diff --git a/server/test/expected-responses.ts b/server/test/expected-responses.ts index e86b3b64b..776b5a152 100644 --- a/server/test/expected-responses.ts +++ b/server/test/expected-responses.ts @@ -46,7 +46,11 @@ export const expectedReleaseResponse = (release: Release) => ({ releaseDate: release.releaseDate?.toISOString() ?? null, }); -export const expectedTrackResponse = ({ thumbnailId, ...track }: Track) => ({ +export const expectedTrackResponse = ({ + thumbnailId, + standaloneIllustrationId, + ...track +}: Track) => ({ ...track, }); From 9184363b9f308aa7903008cb010789af70d56614 Mon Sep 17 00:00:00 2001 From: Arthur Jamet Date: Sat, 21 Dec 2024 10:57:05 +0000 Subject: [PATCH 3/8] Server: Track can have no release --- .../migration.sql | 8 +++ server/prisma/schema.prisma | 4 +- .../illustration/illustration.repository.ts | 72 ++++++------------- .../registration/models/metadata-saved.dto.ts | 2 +- .../src/registration/registration.service.ts | 25 ++++--- server/src/song/song.service.ts | 13 ++-- .../track/models/track.query-parameters.ts | 2 +- server/src/track/models/track.response.ts | 2 +- server/src/track/track.service.ts | 63 +++++++++------- 9 files changed, 95 insertions(+), 96 deletions(-) create mode 100644 server/prisma/migrations/20241221112653_standalone_track/migration.sql diff --git a/server/prisma/migrations/20241221112653_standalone_track/migration.sql b/server/prisma/migrations/20241221112653_standalone_track/migration.sql new file mode 100644 index 000000000..f5fae2e8a --- /dev/null +++ b/server/prisma/migrations/20241221112653_standalone_track/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "tracks" DROP CONSTRAINT "tracks_releaseId_fkey"; + +-- AlterTable +ALTER TABLE "tracks" ALTER COLUMN "releaseId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "tracks" ADD CONSTRAINT "tracks_releaseId_fkey" FOREIGN KEY ("releaseId") REFERENCES "releases"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index df7b36ba5..693fef5ef 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -399,10 +399,10 @@ model Track { /// @example 123 songId Int /// @description The parent release the track can be found on - release Release @relation(fields: [releaseId], references: [id]) + release Release? @relation(fields: [releaseId], references: [id]) /// @description Unique numeric identifier of the parent release /// @example 123 - releaseId Int + releaseId Int? /// @description The display name of the track /// @example My Song (Album Version) name String diff --git a/server/src/illustration/illustration.repository.ts b/server/src/illustration/illustration.repository.ts index c2c598ece..0163f8bd8 100644 --- a/server/src/illustration/illustration.repository.ts +++ b/server/src/illustration/illustration.repository.ts @@ -29,8 +29,6 @@ import TrackQueryParameters from "src/track/models/track.query-parameters"; import TrackService from "src/track/track.service"; import ReleaseQueryParameters from "src/release/models/release.query-parameters"; import ReleaseService from "src/release/release.service"; -import SongQueryParameters from "src/song/models/song.query-params"; -import AlbumQueryParameters from "src/album/models/album.query-parameters"; import PlaylistQueryParameters from "src/playlist/models/playlist.query-parameters"; import PlaylistService from "src/playlist/playlist.service"; import { @@ -122,55 +120,6 @@ export default class IllustrationRepository { }); } - async getSongIllustration(where: SongQueryParameters.WhereInput) { - return this.trackService - .getMasterTrack(where) - .then((track) => this.getTrackIllustration({ id: track.id })) - .catch(() => null); - } - - async getAlbumIllustration( - where: AlbumQueryParameters.WhereInput, - ): Promise { - return this.releaseService - .getMasterRelease(where) - .then((release) => this.getReleaseIllustration({ id: release.id })); - } - - /** - * If the track does not have a specific illustration, fallback on parent disc, then release - */ - async getTrackIllustration(where: TrackQueryParameters.WhereInput) { - const track = await this.trackService.get(where); - - return this.prismaService.illustration.findFirst({ - where: { - release: { - release: { - id: track.releaseId, - }, - OR: [ - { disc: track.discIndex, track: track.trackIndex }, - { disc: track.discIndex, track: null }, - { disc: null, track: null }, - ], - }, - }, - orderBy: [ - { - release: { - disc: { nulls: "last", sort: "asc" }, - }, - }, - { - release: { - track: { nulls: "last", sort: "asc" }, - }, - }, - ], - }); - } - async saveIllustrationFromUrl( dto: IllustrationDownloadDto, ): Promise { @@ -200,6 +149,11 @@ export default class IllustrationRepository { id: dto[resourceKey]!, }); + if (!track.releaseId) { + return this.saveTrackStandaloneIllustration(buffer, { + id: track.id, + }).then(IllustrationResponse.from); + } return this.saveReleaseIllustration( buffer, track.discIndex, @@ -301,6 +255,22 @@ export default class IllustrationRepository { return newIllustration; } + async saveTrackStandaloneIllustration( + buffer: Buffer, + where: TrackQueryParameters.WhereInput, + type: IllustrationType = IllustrationType.Cover, + ) { + const track = await this.trackService.get(where); + const newIllustration = await this.saveIllustration(buffer, type); + if (track.standaloneIllustrationId) { + await this.deleteIllustration(track.standaloneIllustrationId); + } + await this.trackService.update( + { standaloneIllustrationId: newIllustration.id }, + where, + ); + return newIllustration; + } async saveReleaseIllustration( buffer: Buffer, disc: number | null, diff --git a/server/src/registration/models/metadata-saved.dto.ts b/server/src/registration/models/metadata-saved.dto.ts index 9edc7aa82..037eedef2 100644 --- a/server/src/registration/models/metadata-saved.dto.ts +++ b/server/src/registration/models/metadata-saved.dto.ts @@ -28,5 +28,5 @@ export default class MetadataSavedResponse { @ApiProperty() sourceFileId: number; @ApiProperty() - releaseId: number; + releaseId: number | null; } diff --git a/server/src/registration/registration.service.ts b/server/src/registration/registration.service.ts index bf8b323a7..3e242c2cd 100644 --- a/server/src/registration/registration.service.ts +++ b/server/src/registration/registration.service.ts @@ -152,15 +152,6 @@ export class RegistrationService { type: IllustrationType = IllustrationType.Cover, ): Promise { const track = await this.trackService.get(where, { release: true }); - const logRegistration = ( - disc: number | null, - trackIndex: number | null, - ) => - this.logger.verbose( - `Saving Illustration for '${track.release.name}' (Disc ${ - disc ?? 1 - }${trackIndex === null ? "" : `, track ${trackIndex}`}).`, - ); if (type == IllustrationType.Thumbnail) { if (track.type != TrackType.Video) { throw new InvalidRequestException( @@ -174,6 +165,22 @@ export class RegistrationService { }, ); } + if (!track.release || !track.releaseId) { + return this.illustrationRepository.saveTrackStandaloneIllustration( + illustrationBytes, + { id: track.id }, + type, + ); + } + const logRegistration = ( + disc: number | null, + trackIndex: number | null, + ) => + this.logger.verbose( + `Saving Illustration for '${track.release!.name}' (Disc ${ + disc ?? 1 + }${trackIndex === null ? "" : `, track ${trackIndex}`}).`, + ); const parentReleaseIllustrations = await this.illustrationRepository.getReleaseIllustrations({ id: track.releaseId, diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index 89ed14208..c22a2cfc6 100644 --- a/server/src/song/song.service.ts +++ b/server/src/song/song.service.ts @@ -692,7 +692,8 @@ export default class SongService extends SearchableRepositoryService { id: { in: albumSongs .filter((s) => s.type == SongType.Original) - .flatMap((s) => s.tracks!.map((t) => t.releaseId)), + .flatMap((s) => s.tracks!.map((t) => t.releaseId)) + .filter((rid): rid is number => rid !== null), }, }, undefined, @@ -729,10 +730,12 @@ export default class SongService extends SearchableRepositoryService { // See #795 .filter( (song) => - song.tracks!.find((t) => - olderStudioReleasesWhereSongAppears.includes( - t.releaseId, - ), + song.tracks!.find( + (t) => + t.releaseId && + olderStudioReleasesWhereSongAppears.includes( + t.releaseId, + ), ) == undefined, ) .map(({ name }) => new Slug(this.getBaseSongName(name)).toString()); diff --git a/server/src/track/models/track.query-parameters.ts b/server/src/track/models/track.query-parameters.ts index 0a262f02f..72561aa13 100644 --- a/server/src/track/models/track.query-parameters.ts +++ b/server/src/track/models/track.query-parameters.ts @@ -48,7 +48,7 @@ namespace TrackQueryParameters { | "standaloneIllustrationId" > & { sourceFile: FileQueryParameters.WhereInput; - release: ReleaseQueryParameters.WhereInput; + release?: ReleaseQueryParameters.WhereInput; song: SongQueryParameters.WhereInput; }; diff --git a/server/src/track/models/track.response.ts b/server/src/track/models/track.response.ts index 6a0251a20..57ce0e97a 100644 --- a/server/src/track/models/track.response.ts +++ b/server/src/track/models/track.response.ts @@ -41,7 +41,7 @@ export class TrackResponse extends IntersectionType( IllustratedResponse, class { song?: SongResponse; - release?: ReleaseResponse; + release?: ReleaseResponse | null; }, ) {} diff --git a/server/src/track/track.service.ts b/server/src/track/track.service.ts index 1d5c99579..aed8e0a5c 100644 --- a/server/src/track/track.service.ts +++ b/server/src/track/track.service.ts @@ -112,9 +112,13 @@ export default class TrackService { song: { connect: SongService.formatWhereInput(input.song), }, - release: { - connect: ReleaseService.formatWhereInput(input.release), - }, + release: input.release + ? { + connect: ReleaseService.formatWhereInput( + input.release, + ), + } + : undefined, sourceFile: { connect: FileService.formatWhereInput(input.sourceFile), }, @@ -129,17 +133,22 @@ export default class TrackService { const parentSong = await this.songService.get(input.song, { artist: true, }); - const parentRelease = await this.releaseService.get( - input.release, - ); await this.fileService.get(input.sourceFile); - if (error.code === PrismaError.RequiredRelationViolation) { - throw new TrackAlreadyExistsException( - input.name, - new Slug(parentRelease.slug), - new Slug(parentSong.artist.slug), + if (input.release) { + const parentRelease = await this.releaseService.get( + input.release, ); + + if ( + error.code === PrismaError.RequiredRelationViolation + ) { + throw new TrackAlreadyExistsException( + input.name, + new Slug(parentRelease.slug), + new Slug(parentSong.artist.slug), + ); + } } } throw new UnhandledORMErrorException(error, input); @@ -442,22 +451,24 @@ export default class TrackService { deleted.thumbnailId, ); } - this.illustrationRepository - .getReleaseIllustrations({ id: deleted.releaseId }) - .then((relatedIllustrations) => { - const exactIllustration = relatedIllustrations.find( - (i) => - i.disc !== null && - i.track !== null && - i.disc === deleted.discIndex && - i.track === deleted.trackIndex, - ); - if (exactIllustration) { - this.illustrationRepository.deleteIllustration( - exactIllustration.id, + if (deleted.releaseId) { + this.illustrationRepository + .getReleaseIllustrations({ id: deleted.releaseId }) + .then((relatedIllustrations) => { + const exactIllustration = relatedIllustrations.find( + (i) => + i.disc !== null && + i.track !== null && + i.disc === deleted.discIndex && + i.track === deleted.trackIndex, ); - } - }); + if (exactIllustration) { + this.illustrationRepository.deleteIllustration( + exactIllustration.id, + ); + } + }); + } this.logger.warn(`Track '${deleted.name}' deleted`); return deleted; }) From 1d55d46dca52a03333b8407d4720df86b1185d3d Mon Sep 17 00:00:00 2001 From: Arthur Jamet Date: Sat, 21 Dec 2024 11:31:49 +0000 Subject: [PATCH 4/8] API: Registration: Allow Album/Release to be both null --- server/src/registration/metadata.service.ts | 100 ++++++++++-------- server/src/registration/models/metadata.ts | 12 +-- .../registration.controller.spec.ts | 43 +++++++- 3 files changed, 100 insertions(+), 55 deletions(-) diff --git a/server/src/registration/metadata.service.ts b/server/src/registration/metadata.service.ts index f4a4e1507..ea2f44a71 100644 --- a/server/src/registration/metadata.service.ts +++ b/server/src/registration/metadata.service.ts @@ -130,29 +130,37 @@ export default class MetadataService { }, { id: song.id }, ); - const album = await this.albumService.getOrCreate( - { - name: this.parserService.parseReleaseExtension(metadata.album) - .parsedName, - artist: albumArtist ? { id: albumArtist?.id } : undefined, - registeredAt: file.registerDate, - }, - { releases: true }, - ); - const parsedReleaseName = this.parserService.parseReleaseExtension( - metadata.release, - ); - const release = await this.releaseService.getOrCreate( - { - name: parsedReleaseName.parsedName, - extensions: parsedReleaseName.extensions, - releaseDate: metadata.releaseDate, - album: { id: album.id }, - registeredAt: file.registerDate, - discogsId: metadata.discogsId, - }, - { album: true }, - ); + const album = metadata.album + ? await this.albumService.getOrCreate( + { + name: this.parserService.parseReleaseExtension( + metadata.album, + ).parsedName, + artist: albumArtist + ? { id: albumArtist?.id } + : undefined, + registeredAt: file.registerDate, + }, + { releases: true }, + ) + : undefined; + const parsedReleaseName = metadata.release + ? this.parserService.parseReleaseExtension(metadata.release) + : undefined; + const release = + parsedReleaseName && album + ? await this.releaseService.getOrCreate( + { + name: parsedReleaseName.parsedName, + extensions: parsedReleaseName.extensions, + releaseDate: metadata.releaseDate, + album: { id: album.id }, + registeredAt: file.registerDate, + discogsId: metadata.discogsId, + }, + { album: true }, + ) + : undefined; const track: TrackQueryParameters.CreateInput = { name: parsedTrackName.parsedName, isBonus: parsedTrackName.bonus, @@ -170,30 +178,32 @@ export default class MetadataService { ? Math.floor(metadata.duration) : null, sourceFile: { id: file.id }, - release: { id: release.id }, + release: release ? { id: release.id } : undefined, song: { id: song.id }, }; - - if ( - albumArtist === undefined && - release.album.type == AlbumType.StudioRecording - ) { - await this.albumService.update( - { type: AlbumType.Compilation }, - { id: release.albumId }, - ); - } - if (album.masterId === null) { - this.albumService.setMasterRelease({ id: release.id }); - } - if ( - !release.releaseDate || - (metadata.releaseDate && release.releaseDate < metadata.releaseDate) - ) { - await this.releaseService.update( - { releaseDate: metadata.releaseDate }, - { id: release.id }, - ); + if (release && album) { + if ( + albumArtist === undefined && + release.album.type == AlbumType.StudioRecording + ) { + await this.albumService.update( + { type: AlbumType.Compilation }, + { id: release.albumId }, + ); + } + if (album.masterId === null) { + this.albumService.setMasterRelease({ id: release.id }); + } + if ( + !release.releaseDate || + (metadata.releaseDate && + release.releaseDate < metadata.releaseDate) + ) { + await this.releaseService.update( + { releaseDate: metadata.releaseDate }, + { id: release.id }, + ); + } } if (overwrite) { await this.trackService.delete({ sourceFileId: file.id }); diff --git a/server/src/registration/models/metadata.ts b/server/src/registration/models/metadata.ts index 390739d73..4f24f8b57 100644 --- a/server/src/registration/models/metadata.ts +++ b/server/src/registration/models/metadata.ts @@ -74,20 +74,20 @@ export default class Metadata { /** * Name of the album of the track */ - @ApiProperty() + @ApiPropertyOptional() @IsString() @IsNotEmpty() - @IsDefined() - album: string; + @IsOptional() + album?: string; /** * Name of the release of the track */ - @ApiProperty() + @ApiPropertyOptional() @IsString() @IsNotEmpty() - @IsDefined() - release: string; + @IsOptional() + release?: string; /** * Name of the track diff --git a/server/src/registration/registration.controller.spec.ts b/server/src/registration/registration.controller.spec.ts index 66746f363..efb257f94 100644 --- a/server/src/registration/registration.controller.spec.ts +++ b/server/src/registration/registration.controller.spec.ts @@ -23,9 +23,10 @@ import TrackModule from "src/track/track.module"; import PlaylistModule from "src/playlist/playlist.module"; import SettingsModule from "src/settings/settings.module"; import { File, Song } from "@prisma/client"; +import TrackService from "src/track/track.service"; const validMetadata: MetadataDto = { - path: "test/assets/Music/...Baby One More Time.m4a", + path: "test/assets/Music/Album/01 ...Baby One More Time.m4a", checksum: "azerty", registrationDate: new Date("2012-04-03"), compilation: false, @@ -45,7 +46,7 @@ const applyFormFields = (r: request.Test, object: MetadataDto) => { (value as any[]).forEach((arrayValue, index) => { r.field(`${key}[${index}]`, arrayValue); }); - } else { + } else if (value !== undefined) { r.field(key, value.toString()); } }); @@ -56,6 +57,7 @@ describe("Registration Controller", () => { let app: INestApplication; let fileService: FileService; let songService: SongService; + let trackService: TrackService; let dummyRepository: TestPrismaService; let createdFile: File; let createdSong: Song; @@ -85,6 +87,7 @@ describe("Registration Controller", () => { app = await SetupApp(module); dummyRepository = module.get(PrismaService); fileService = module.get(FileService); + trackService = module.get(TrackService); songService = module.get(SongService); await dummyRepository.onModuleInit(); }); @@ -167,7 +170,7 @@ describe("Registration Controller", () => { expect(file.checksum).toBe(validMetadata.checksum); expect(file.libraryId).toBe(createdMetadata.libraryId); expect(file.track!.id).toBe(createdMetadata.trackId); - expect(file.path).toBe("...Baby One More Time.m4a"); + expect(file.path).toBe("Album/01 ...Baby One More Time.m4a"); expect(file.fingerprint).toBe("AcoustId"); const song = await songService.get( @@ -191,6 +194,38 @@ describe("Registration Controller", () => { createdFile = file; createdSong = song; }); + + it("Should register metadata (standalone track)", async () => { + const res = await applyFormFields( + request(app.getHttpServer()).post(`/metadata`), + { + ...validMetadata, + album: undefined, + release: undefined, + path: "test/assets/Music/...Baby One More Time.m4a", + }, + ).expect(201); + const createdMetadata: MetadataSavedResponse = res.body; + expect(createdMetadata.trackId).toBeGreaterThan(0); + expect(createdMetadata.libraryId).toBeGreaterThan(0); + expect(createdMetadata.songId).toBeGreaterThan(0); + expect(createdMetadata.sourceFileId).toBeGreaterThan(0); + expect(createdMetadata.releaseId).toBeNull(); + const file = await fileService.get( + { id: createdMetadata.sourceFileId }, + { track: true }, + ); + expect(file.id).toBe(createdMetadata.sourceFileId); + expect(file.checksum).toBe(validMetadata.checksum); + expect(file.libraryId).toBe(createdMetadata.libraryId); + expect(file.track!.id).toBe(createdMetadata.trackId); + expect(file.path).toBe("...Baby One More Time.m4a"); + expect(file.fingerprint).toBe("AcoustId"); + const track = await trackService.get({ + id: createdMetadata.trackId, + }); + expect(track.releaseId).toBeNull(); + }); }); describe("Metadata Update", () => { it("Should update metadata", async () => { @@ -216,7 +251,7 @@ describe("Registration Controller", () => { expect(file.checksum).toBe("zzz"); expect(file.registerDate).toStrictEqual(new Date("2011-04-03")); expect(file.track!.id).toBe(createdMetadata.trackId); - expect(file.path).toBe("...Baby One More Time.m4a"); + expect(file.path).toBe("Album/01 ...Baby One More Time.m4a"); }); }); }); From 8d98cb1393f8915fafcb6a06af64b7e050ad28a4 Mon Sep 17 00:00:00 2001 From: Arthur Jamet Date: Sat, 21 Dec 2024 13:05:39 +0000 Subject: [PATCH 5/8] Scanner: Accept Album/Release to be null --- scanner/internal/api/api.go | 8 ++++++-- scanner/internal/metadata.go | 4 ++-- scanner/internal/parser/parser_test.go | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/scanner/internal/api/api.go b/scanner/internal/api/api.go index 8418b072d..36f558279 100644 --- a/scanner/internal/api/api.go +++ b/scanner/internal/api/api.go @@ -91,8 +91,12 @@ func SaveMetadata(config config.Config, m internal.Metadata, saveMethod SaveMeta if len(m.AlbumArtist) > 0 { mp.WriteField("albumArtist", m.AlbumArtist) } - mp.WriteField("album", m.Album) - mp.WriteField("release", m.Release) + if len(m.Album) > 0 { + mp.WriteField("album", m.Album) + } + if len(m.Release) > 0 { + mp.WriteField("release", m.Release) + } mp.WriteField("name", m.Name) mp.WriteField("releaseDate", m.ReleaseDate.Format(time.RFC3339)) if m.Index > 0 { diff --git a/scanner/internal/metadata.go b/scanner/internal/metadata.go index e62dd879c..ad2fd6e61 100644 --- a/scanner/internal/metadata.go +++ b/scanner/internal/metadata.go @@ -18,9 +18,9 @@ type Metadata struct { // Name of the artist of the parent album AlbumArtist string // Name of the album of the track - Album string `validate:"required"` + Album string // Name of the release of the track - Release string `validate:"required"` + Release string // Name of the track Name string `validate:"required"` // Release date of the track diff --git a/scanner/internal/parser/parser_test.go b/scanner/internal/parser/parser_test.go index 2e270d98f..91e1da06c 100644 --- a/scanner/internal/parser/parser_test.go +++ b/scanner/internal/parser/parser_test.go @@ -23,6 +23,7 @@ func getParserTestConfig() config.UserSettings { TrackRegex: []string{ "^([\\/\\\\]+.*)*[\\/\\\\]+(?P.+)[\\/\\\\]+(?P.+)(\\s+\\((?P\\d{4})\\))[\\/\\\\]+((?P[0-9]+)-)?(?P[0-9]+)\\s+(?P.*)\\s+\\((?P.*)\\)\\..*$", "^([\\/\\\\]+.*)*[\\/\\\\]+(?P.+)[\\/\\\\]+(?P.+)(\\s+\\((?P\\d{4})\\))[\\/\\\\]+((?P[0-9]+)-)?(?P[0-9]+)\\s+(?P.*)\\..*$", + "^([\\/\\\\]+.*)*[\\/\\\\]+(?P.+)[\\/\\\\]+Unknown Album[\\/\\\\]+(?P.*)\\..*$", }, } } @@ -93,3 +94,22 @@ func TestParserEmbedded(t *testing.T) { assert.Equal(t, internal.Inline, m.IllustrationLocation) assert.Equal(t, "../../testdata/cover.jpg", m.IllustrationPath) } + +func TestParserStandaloneTrack(t *testing.T) { + path := "/data/Lady Gaga/Unknown Album/Bad Romance.m4v" + m, err := ParseMetadata(getParserTestConfig(), path) + + assert.Len(t, err, 2) // fpcalc error and no checksum + assert.Equal(t, "Lady Gaga", m.AlbumArtist) + assert.Equal(t, "Lady Gaga", m.Artist) + assert.Equal(t, false, m.IsCompilation) + assert.Equal(t, "", m.Album) + assert.Equal(t, internal.Video, m.Type) + assert.Equal(t, int64(0), m.Bitrate) + assert.Equal(t, int64(0), m.Duration) + assert.Equal(t, "", m.Release) + assert.Equal(t, int64(0), m.DiscIndex) + assert.Equal(t, int64(0), m.Index) + assert.Empty(t, m.Genres) + assert.Equal(t, "Bad Romance", m.Name) +} From 73df171ca8f0cae9c84aa4c6fc6d9f3be8d031c6 Mon Sep 17 00:00:00 2001 From: Arthur Jamet Date: Sat, 21 Dec 2024 13:40:25 +0000 Subject: [PATCH 6/8] Migration: Fix join for standalon track illustrations --- .../20241221104003_standalone_track_illustration/migration.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/prisma/migrations/20241221104003_standalone_track_illustration/migration.sql b/server/prisma/migrations/20241221104003_standalone_track_illustration/migration.sql index 52f436292..f61776eeb 100644 --- a/server/prisma/migrations/20241221104003_standalone_track_illustration/migration.sql +++ b/server/prisma/migrations/20241221104003_standalone_track_illustration/migration.sql @@ -21,7 +21,7 @@ SELECT t.id AS "trackId" FROM tracks t - JOIN releases r ON r.id = t."releaseId" + LEFT JOIN releases r ON r.id = t."releaseId" -- Left join, because release can be null or not have artwork. -- In that case, we still want videos to have their thumbnails LEFT JOIN LATERAL ( From f14c2d762ba816363f42cae448ddeaa9ad2138d4 Mon Sep 17 00:00:00 2001 From: Arthur Jamet Date: Sat, 21 Dec 2024 14:36:38 +0000 Subject: [PATCH 7/8] Front: Update Models to support standalone tracks --- front/src/components/actions/link.tsx | 11 +++- .../contextual-menu/song-contextual-menu.tsx | 3 +- .../contextual-menu/track-contextual-menu.tsx | 18 +++--- front/src/components/list-item/song-item.tsx | 13 +++-- front/src/components/list-item/track-item.tsx | 3 +- front/src/components/player/controls.tsx | 58 ++++++++++++------- front/src/models/track.ts | 4 +- front/src/pages/artists/[slugOrId]/songs.tsx | 13 +++-- .../src/pages/playlists/[slugOrId]/index.tsx | 34 ++++++++--- 9 files changed, 104 insertions(+), 53 deletions(-) diff --git a/front/src/components/actions/link.tsx b/front/src/components/actions/link.tsx index 830ad1d78..dc07bce60 100644 --- a/front/src/components/actions/link.tsx +++ b/front/src/components/actions/link.tsx @@ -27,6 +27,7 @@ import { } from "../icons"; import { NextRouter } from "next/router"; import Action from "./action"; +import toast from "react-hot-toast"; export const GoToSongLyricsAction = ( songIdentifier: string | number, @@ -76,10 +77,16 @@ export const GoToReleaseAction = ( export const GoToReleaseAsyncAction = ( router: NextRouter, - albumIdentifier: () => PromiseLike, + albumIdentifier: () => PromiseLike, ): Action => ({ onClick: () => - albumIdentifier().then((id) => router.push(`/releases/${id}`)), + albumIdentifier().then((id) => { + if (id === null) { + toast.error("This resource is not attached to any album."); + } else { + router.push(`/releases/${id}`); + } + }), label: "goToAlbum", icon: , }); diff --git a/front/src/components/contextual-menu/song-contextual-menu.tsx b/front/src/components/contextual-menu/song-contextual-menu.tsx index 3f40de022..ce7e9e4df 100644 --- a/front/src/components/contextual-menu/song-contextual-menu.tsx +++ b/front/src/components/contextual-menu/song-contextual-menu.tsx @@ -76,7 +76,8 @@ const SongContextualMenu = (props: SongContextualMenuProps) => { GoToArtistAction(props.song.artist.slug), GoToReleaseAsyncAction( router, - async () => (await getMasterTrack()).release.slug, + async () => + (await getMasterTrack()).release?.slug ?? null, ), ], [GoToSongLyricsAction(songSlug)], diff --git a/front/src/components/contextual-menu/track-contextual-menu.tsx b/front/src/components/contextual-menu/track-contextual-menu.tsx index 915821a4f..c506ad2f3 100644 --- a/front/src/components/contextual-menu/track-contextual-menu.tsx +++ b/front/src/components/contextual-menu/track-contextual-menu.tsx @@ -43,6 +43,7 @@ import { RefreshTrackMetadataAction } from "../actions/refresh-metadata"; import ChangeSongType from "../actions/song-type"; import { useTranslation } from "react-i18next"; import { usePlayerContext } from "../../contexts/player"; +import Action from "../actions/action"; type TrackContextualMenuProps = { track: TrackWithRelations<"song" | "illustration">; @@ -60,7 +61,8 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => { const getPlayNextProps = () => Promise.all([ queryClient.fetchQuery(API.getArtist(props.track.song.artistId)), - queryClient.fetchQuery(API.getRelease(props.track.releaseId)), + props.track.releaseId && + queryClient.fetchQuery(API.getRelease(props.track.releaseId)), ]).then(([artist, release]) => ({ track: props.track, artist, @@ -81,12 +83,14 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => { a !== undefined), [GoToSongLyricsAction(props.track.song.slug)], [ PlayNextAction(getPlayNextProps, playNext), diff --git a/front/src/components/list-item/song-item.tsx b/front/src/components/list-item/song-item.tsx index b8dd21e50..856489568 100644 --- a/front/src/components/list-item/song-item.tsx +++ b/front/src/components/list-item/song-item.tsx @@ -38,7 +38,7 @@ type SongItemProps< song: SongWithRelations< "artist" | "featuring" | "master" | "illustration" >, - ) => Promise)[]; + ) => Promise)[]; }; export const SongGroupItem = < @@ -92,7 +92,7 @@ const SongItem = < const artist = song?.artist; const { playTrack } = usePlayerContext(); const queryClient = useQueryClient(); - const [subtitle, setSubtitle] = useState( + const [subtitle, setSubtitle] = useState( subtitles?.length ? ((
) as unknown as string) : song @@ -105,8 +105,13 @@ const SongItem = < Promise.allSettled(subtitles.map((s) => s(song))).then((r) => setSubtitle( r - .map((s) => (s as PromiseFulfilledResult).value) - .join(" • "), + .map( + (s) => + (s as PromiseFulfilledResult) + .value, + ) + .filter((s): s is string => s !== null) + .join(" • ") || null, ), ); } diff --git a/front/src/components/list-item/track-item.tsx b/front/src/components/list-item/track-item.tsx index 1873cc1fe..779b97893 100644 --- a/front/src/components/list-item/track-item.tsx +++ b/front/src/components/list-item/track-item.tsx @@ -53,7 +53,6 @@ const TrackItem = ({ track, onClick }: TrackItemProps) => { } onClick={ track && - release && (() => { onClick?.(); queryClient @@ -67,7 +66,7 @@ const TrackItem = ({ track, onClick }: TrackItemProps) => { }) } title={track?.name} - secondTitle={release?.name} + secondTitle={release?.name ?? null} trailing={ {props.artist && props.track ? ( - - - + + {props.track?.name} + + + + ) ) : ( )} diff --git a/front/src/models/track.ts b/front/src/models/track.ts index cfd64daef..f71eff220 100644 --- a/front/src/models/track.ts +++ b/front/src/models/track.ts @@ -37,7 +37,7 @@ const Track = Resource.concat( /** * Unique identifier of the parent release */ - releaseId: yup.number().required(), + releaseId: yup.number().required().nullable(), /** * Title of the track */ @@ -86,7 +86,7 @@ export type TrackInclude = "song" | "release" | "illustration"; const TrackRelations = yup.object({ song: yup.lazy(() => Song.required()), - release: Release.required(), + release: Release.required().nullable(), illustration: Illustration.required().nullable(), }); diff --git a/front/src/pages/artists/[slugOrId]/songs.tsx b/front/src/pages/artists/[slugOrId]/songs.tsx index f28e81278..5bc36a72e 100644 --- a/front/src/pages/artists/[slugOrId]/songs.tsx +++ b/front/src/pages/artists/[slugOrId]/songs.tsx @@ -69,8 +69,9 @@ const prepareSSR = async ( artistQuery(artistIdentifier), ...songs.pages .flatMap(({ items }) => items) + .filter(({ master }) => master.releaseId) .map(({ master }) => - API.getRelease(master.releaseId, ["album"]), + API.getRelease(master.releaseId!, ["album"]), ), ], infiniteQueries: [], @@ -83,10 +84,12 @@ const ArtistSongPage: Page> = ({ const router = useRouter(); const isRareSongs = isRareSongsPage(router); const queryClient = useQueryClient(); - const getTrackReleaseName = (track: Track) => - queryClient - .fetchQuery(API.getRelease(track.releaseId)) - .then((release) => release.name); + const getTrackReleaseName = (track: Track): Promise => + track.releaseId + ? queryClient + .fetchQuery(API.getRelease(track.releaseId)) + .then((release) => release.name) + : Promise.resolve(null); const artistIdentifier = props?.artistIdentifier ?? getSlugOrId(router.query); const artist = useQuery(artistQuery, artistIdentifier); diff --git a/front/src/pages/playlists/[slugOrId]/index.tsx b/front/src/pages/playlists/[slugOrId]/index.tsx index dd817e37f..33f3d7e03 100644 --- a/front/src/pages/playlists/[slugOrId]/index.tsx +++ b/front/src/pages/playlists/[slugOrId]/index.tsx @@ -22,6 +22,7 @@ import RelationPageHeader from "../../../components/relation-page-header/relatio import { GetPropsTypesFrom, Page } from "../../../ssr"; import getSlugOrId from "../../../utils/getSlugOrId"; import { + Query, prepareMeeloQuery, useQueries, useQuery, @@ -89,7 +90,16 @@ const prepareSSR = async ( additionalProps: { playlistIdentifier }, queries: [ playlistQuery(playlistIdentifier), - ...entries.map((entry) => API.getRelease(entry.master.releaseId)), + ...entries + .map((entry) => + entry.master.releaseId + ? API.getRelease(entry.master.releaseId) + : undefined, + ) + .filter( + (promise): promise is Query => + promise !== undefined, + ), ], }; }; @@ -219,13 +229,15 @@ const PlaylistPage: Page> = ({ const playlist = useQuery(playlistQuery, playlistIdentifier); const entriesQuery = useQuery(playlistEntriesQuery, playlistIdentifier); const masterTracksReleaseQueries = useQueries( - ...(entriesQuery.data?.map( - ({ - master, - }): Parameters< - typeof useQuery> - > => [API.getRelease, master.releaseId], - ) ?? []), + ...(entriesQuery.data + ?.filter(({ master }) => master.releaseId) + .map( + ({ + master, + }): Parameters< + typeof useQuery> + > => [API.getRelease, master.releaseId ?? undefined], + ) ?? []), ); const reorderMutation = useMutation((reorderedEntries: number[]) => { return API.reorderPlaylist(playlistIdentifier, reorderedEntries) @@ -240,7 +252,11 @@ const PlaylistPage: Page> = ({ const releases = masterTracksReleaseQueries.map((query) => query.data); const resolvedReleases = releases.filter((data) => data !== undefined); - if (resolvedReleases.length !== entriesQuery.data?.length) { + if ( + resolvedReleases.length !== + entriesQuery.data?.filter(({ master }) => master.releaseId != null) + .length + ) { return undefined; } From b41268945ceb102703cd8c35d7f58e66564d2890 Mon Sep 17 00:00:00 2001 From: Arthur Jamet Date: Sat, 21 Dec 2024 14:45:36 +0000 Subject: [PATCH 8/8] Server: Fix tests --- server/src/release/release.service.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/server/src/release/release.service.ts b/server/src/release/release.service.ts index 0aa43f064..000113505 100644 --- a/server/src/release/release.service.ts +++ b/server/src/release/release.service.ts @@ -349,6 +349,11 @@ export default class ReleaseService { * @param where Query parameters to find the release to delete */ async delete(where: ReleaseQueryParameters.DeleteInput): Promise { + const { tracks } = await this.get(where, { tracks: true }); + + if (tracks.length > 0) { + throw new ReleaseNotEmptyException(where.id); + } this.illustrationRepository .getReleaseIllustrations(where) .then((relatedIllustrations) => { @@ -369,12 +374,6 @@ export default class ReleaseService { return deleted; }) .catch((error) => { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code == PrismaError.ForeignConstraintViolation - ) { - throw new ReleaseNotEmptyException(where.id); - } throw new UnhandledORMErrorException(error, where); }); }