From 10766bbb47c492982acf1d0cb2d238b659c8d98a Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 10 Sep 2026 19:19:59 +0200 Subject: [PATCH] Split TrackApplicationResponse into a Local/External hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same treatment as AlbumApplicationResponse, but Track is the cleaner case: OwnerUserId (local-only) and ExternalId (external-only) are both genuinely exclusive to one side, so the base carries neither and is abstract — every real value is one of the two derived types. TrackApplicationResponse (abstract, shared fields + FromEntity dispatch) LocalTrackApplicationResponse (+ OwnerUserId) ExternalTrackApplicationResponse (+ ExternalId) Hit a real gotcha along the way: the response already had an explicit Source property, which collides with the "source" discriminator STJ's polymorphism support injects ("contains property 'source' that conflicts with an existing metadata property name" — only surfaced at runtime in the Api integration tests, not at build time). Fixed by dropping the now-redundant explicit Source field entirely; the discriminator already serializes the identical "Local"/"YouTube" value under the same name, so the wire contract is unchanged. Updated the two call sites that read .Source directly (TrackSearchItemResponse and a playlist track filter that needed flatMap instead of filter().map() to keep the narrowing across the chain). --- .../ExternalTrackApplicationResponse.cs | 30 ++++++++++ .../LocalTrackApplicationResponse.cs | 30 ++++++++++ .../Responses/TrackApplicationResponse.cs | 40 +++++++------ .../Responses/TrackSearchItemResponse.cs | 2 +- .../Shared/TrackApplicationResponseTests.cs | 6 +- .../Tracks/CreateTrackCommandHandlerTests.cs | 4 +- .../Tracks/GetTrackByIdQueryHandlerTests.cs | 4 +- web-player/src/lib/api/schema.d.ts | 58 ++++++++++++++++--- .../(app)/playlists/[id]/+page.server.ts | 2 +- 9 files changed, 144 insertions(+), 32 deletions(-) create mode 100644 backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs create mode 100644 backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs diff --git a/backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs new file mode 100644 index 0000000..9e9e850 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs @@ -0,0 +1,30 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Responses +{ + public record ExternalTrackApplicationResponse( + Guid Id, + string Title, + string? Artist, + ProcessingStatus AudioStatus, + double Duration, + int ListensCount, + DateTime CreatedAt, + DateTime UpdatedAt, + string ExternalId) + : TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt) + { + public static ExternalTrackApplicationResponse FromEntity(ExternalTrack track, int listensCount) => + new( + track.Id, + track.Title, + FormatArtist(track), + track.Audio.TranscodeStatus, + track.DurationSeconds, + listensCount, + track.CreatedAt, + track.UpdatedAt, + track.ExternalId); + } +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs new file mode 100644 index 0000000..6af6f00 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs @@ -0,0 +1,30 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Responses +{ + public record LocalTrackApplicationResponse( + Guid Id, + string Title, + string? Artist, + ProcessingStatus AudioStatus, + double Duration, + int ListensCount, + DateTime CreatedAt, + DateTime UpdatedAt, + long OwnerUserId) + : TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt) + { + public static LocalTrackApplicationResponse FromEntity(LocalTrack track, int listensCount) => + new( + track.Id, + track.Title, + FormatArtist(track), + track.Audio.TranscodeStatus, + track.DurationSeconds, + listensCount, + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId); + } +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index fb7e546..a8b5a53 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -1,36 +1,42 @@ +using System.Text.Json.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks.Responses { - public record TrackApplicationResponse( + // See AlbumApplicationResponse for why [JsonPolymorphic]/[JsonDerivedType] are required + // (not optional) whenever a base type carries subtype-only data across the wire. Track is a + // cleaner case than Album: both OwnerUserId (local-only) and ExternalId (external-only) are + // genuinely exclusive to one side, so the base has no "meaningless sentinel" field at all — + // every real value is one of the two derived types, hence base is abstract. + // + // No explicit Source property here (unlike the domain entities): STJ's polymorphism support + // already injects a "source" discriminator property with the same "Local"/"YouTube" values a + // hand-written TrackSource field would have, and a real property with that exact name would + // collide with it ("contains property 'source' that conflicts with an existing metadata + // property name"). Use the discriminator (or a type check) instead of a Source field. + [JsonPolymorphic(TypeDiscriminatorPropertyName = "source")] + [JsonDerivedType(typeof(LocalTrackApplicationResponse), typeDiscriminator: "Local")] + [JsonDerivedType(typeof(ExternalTrackApplicationResponse), typeDiscriminator: "YouTube")] + public abstract record TrackApplicationResponse( Guid Id, string Title, string? Artist, - TrackSource Source, - string? ExternalId, ProcessingStatus AudioStatus, double Duration, int ListensCount, - long? OwnerUserId, DateTime CreatedAt, DateTime UpdatedAt) { public static TrackApplicationResponse FromEntity(Track track, int listensCount) => - new( - track.Id, - track.Title, - FormatArtist(track), - track is ExternalTrack external ? external.Source : TrackSource.Local, - (track as ExternalTrack)?.ExternalId, - track.Audio.TranscodeStatus, - track.DurationSeconds, - listensCount, - (track as LocalTrack)?.OwnerUserId, - track.CreatedAt, - track.UpdatedAt); + track switch + { + LocalTrack local => LocalTrackApplicationResponse.FromEntity(local, listensCount), + ExternalTrack external => ExternalTrackApplicationResponse.FromEntity(external, listensCount), + _ => throw new NotSupportedException($"Unsupported track entity type '{track.GetType().Name}'.") + }; - private static string? FormatArtist(Track track) => track switch + protected static string? FormatArtist(Track track) => track switch { LocalTrack local => local.Owner.Name, ExternalTrack { TrackArtists.Count: > 0 } external => diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs index 9599877..793fc85 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs @@ -14,7 +14,7 @@ namespace Musify.Application.Tracks.Responses YouTubeSongResult? YouTubeSong) { public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => - new(track.Source, track, null); + new(track is ExternalTrackApplicationResponse ? TrackSource.YouTube : TrackSource.Local, track, null); public static TrackSearchItemResponse FromYouTubeSong(YouTubeSongResult song) => new(TrackSource.YouTube, null, song); diff --git a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs index 5141a69..6eda042 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs @@ -1,7 +1,6 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Shared @@ -14,10 +13,9 @@ namespace Musify.Application.Tests.Shared var owner = TestEntities.User(1, "The Owner"); var track = TestEntities.LocalTrack(owner); - var response = TrackApplicationResponse.FromEntity(track, listensCount: 3); + var response = Assert.IsType(TrackApplicationResponse.FromEntity(track, listensCount: 3)); Assert.Equal("The Owner", response.Artist); - Assert.Equal(TrackSource.Local, response.Source); Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(3, response.ListensCount); } @@ -34,7 +32,7 @@ namespace Musify.Application.Tests.Shared new TrackArtist { TrackId = track.Id, ArtistId = first.Id, Position = 0, Artist = first, Track = track } ]; - var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); + var response = Assert.IsType(TrackApplicationResponse.FromEntity(track, listensCount: 0)); Assert.Equal("First Artist, Second Artist", response.Artist); Assert.Equal(track.ExternalId, response.ExternalId); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index 90cc775..a86a0d5 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -3,6 +3,7 @@ using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; +using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; @@ -47,7 +48,8 @@ namespace Musify.Application.Tests.Tracks Assert.False(result.IsError); Assert.Equal("My Song", result.Value.Title); - Assert.Equal(user.Id, result.Value.OwnerUserId); + var localResponse = Assert.IsType(result.Value); + Assert.Equal(user.Id, localResponse.OwnerUserId); await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs index 0e7cd05..3d21c08 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -1,6 +1,7 @@ using ErrorOr; using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; +using Musify.Application.Tracks.Responses; using Xunit; namespace Musify.Application.Tests.Tracks @@ -21,7 +22,8 @@ namespace Musify.Application.Tests.Tracks Assert.False(result.IsError); Assert.Equal("My Song", result.Value.Title); Assert.Equal(owner.Name, result.Value.Artist); - Assert.Equal(owner.Id, result.Value.OwnerUserId); + var localResponse = Assert.IsType(result.Value); + Assert.Equal(owner.Id, localResponse.OwnerUserId); } [Fact] diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 9f8e582..ae2c2fa 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -549,7 +549,34 @@ export interface components { source: null | components["schemas"]["TrackSource"]; externalId: null | string; }; - AlbumApplicationResponse: { + AlbumApplicationResponse: components["schemas"]["AlbumApplicationResponseAlbumApplicationResponse"] | components["schemas"]["AlbumApplicationResponseExternalAlbumApplicationResponse"]; + AlbumApplicationResponseAlbumApplicationResponse: { + /** @enum {string} */ + source?: "Local"; + /** Format: uuid */ + id: string; + title: string; + description: null | string; + /** Format: int32 */ + releaseYear: null | number | string; + /** Format: int64 */ + ownerUserId: number | string; + /** Format: int32 */ + trackCount: number | string; + smallImageKeyName: null | string; + mediumImageKeyName: null | string; + largeImageKeyName: null | string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + coverTrackIds: string[]; + }; + AlbumApplicationResponseExternalAlbumApplicationResponse: { + /** @enum {string} */ + source?: "YouTube"; + externalId: string; + thumbnailUrl: null | string; /** Format: uuid */ id: string; title: string; @@ -568,8 +595,6 @@ export interface components { /** Format: date-time */ updatedAt: string; coverTrackIds: string[]; - youTubeAlbumId?: null | string; - thumbnailUrl?: null | string; }; AlbumPictureUploadResponse: { /** Format: uuid */ @@ -766,20 +791,39 @@ export interface components { /** Format: int64 */ expectedAudioSizeBytes?: null | number | string; }; - TrackApplicationResponse: { + TrackApplicationResponse: components["schemas"]["TrackApplicationResponseLocalTrackApplicationResponse"] | components["schemas"]["TrackApplicationResponseExternalTrackApplicationResponse"]; + TrackApplicationResponseExternalTrackApplicationResponse: { + /** @enum {string} */ + source?: "YouTube"; + externalId: string; /** Format: uuid */ id: string; title: string; artist: null | string; - source: components["schemas"]["TrackSource"]; - externalId: null | string; audioStatus: components["schemas"]["ProcessingStatus"]; /** Format: double */ duration: number | string; /** Format: int32 */ listensCount: number | string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + TrackApplicationResponseLocalTrackApplicationResponse: { + /** @enum {string} */ + source?: "Local"; /** Format: int64 */ - ownerUserId: null | number | string; + ownerUserId: number | string; + /** Format: uuid */ + id: string; + title: string; + artist: null | string; + audioStatus: components["schemas"]["ProcessingStatus"]; + /** Format: double */ + duration: number | string; + /** Format: int32 */ + listensCount: number | string; /** Format: date-time */ createdAt: string; /** Format: date-time */ diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index 2b5ef71..78590e2 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -40,7 +40,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { const library = (libraryRes.data?.items ?? []).filter((t) => !inPlaylist.has(t.id)); const alreadyLinked = new Set( - tracks.filter((t) => t.source === 'YouTube' && t.externalId).map((t) => t.externalId) + tracks.flatMap((t) => (t.source === 'YouTube' && t.externalId ? [t.externalId] : [])) ); const seeds = [...new Set(tracks.map((t) => t.artist).filter((a): a is string => !!a))]; const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined;