Split AlbumApplicationResponse into a Local/External hierarchy

AlbumApplicationResponse used to carry YouTubeAlbumId/ThumbnailUrl as
two always-present-but-usually-null fields for the sake of a single
flat shape. Since one of these fields IS genuinely exclusive to
external albums, an inheritance split reads better than the flattened
shape: AlbumApplicationResponse now holds only the fields shared by
every album, and ExternalAlbumApplicationResponse adds ExternalId
(renamed from YouTubeAlbumId, matching the source-generalized naming
used everywhere else) and ThumbnailUrl.

[JsonPolymorphic]/[JsonDerivedType] are required here, not optional:
without them System.Text.Json serializes strictly by the declared CLR
type at each call site (both the bare return type and nested inside
AlbumSearchItemResponse.Album), so an ExternalAlbumApplicationResponse
instance would silently lose its extra fields on the wire. This also
gives every response a "source" discriminator field for free,
consistent with AlbumSearchItemResponse.Source, and the generated
OpenAPI schema now models it as a proper oneOf/discriminator instead
of nullable fields.
This commit is contained in:
2026-09-10 19:19:31 +02:00
parent 478b90d75c
commit 7ae6c0a7a2
4 changed files with 71 additions and 24 deletions
@@ -1,7 +1,17 @@
using System.Text.Json.Serialization;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses
{ {
// [JsonPolymorphic]/[JsonDerivedType] are required here, not optional: without them
// System.Text.Json serializes strictly by the declared CLR type at each call site
// (AlbumApplicationResponse, both as a bare return type and nested inside
// AlbumSearchItemResponse.Album), so an ExternalAlbumApplicationResponse instance would
// silently lose ExternalId/ThumbnailUrl on the wire. This also gives every response a
// "source" discriminator field for free, matching AlbumSearchItemResponse.Source.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "source")]
[JsonDerivedType(typeof(AlbumApplicationResponse), typeDiscriminator: "Local")]
[JsonDerivedType(typeof(ExternalAlbumApplicationResponse), typeDiscriminator: "YouTube")]
public record AlbumApplicationResponse( public record AlbumApplicationResponse(
Guid Id, Guid Id,
string Title, string Title,
@@ -14,9 +24,7 @@ namespace Musify.Application.Albums.Responses
string? LargeImageKeyName, string? LargeImageKeyName,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds, IReadOnlyList<Guid> CoverTrackIds)
string? YouTubeAlbumId = null,
string? ThumbnailUrl = null)
{ {
public const int CoverTrackCount = 4; public const int CoverTrackCount = 4;
@@ -45,22 +53,12 @@ namespace Musify.Application.Albums.Responses
int trackCount, int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null) IReadOnlyList<Guid>? coverTrackIds = null)
{ {
var externalAlbum = album as ExternalAlbum; return album switch
return new AlbumApplicationResponse( {
album.Id, ExternalAlbum externalAlbum => ExternalAlbumApplicationResponse.FromEntity(externalAlbum, trackCount, coverTrackIds),
album.Title, UserAlbum userAlbum => FromEntity(userAlbum, trackCount, coverTrackIds),
album.Description, _ => throw new NotSupportedException($"Unsupported album entity type '{album.GetType().Name}'.")
album.ReleaseYear, };
album is UserAlbum userAlbum ? userAlbum.OwnerUserId : 0,
trackCount,
album.Pictures?.SmallName,
album.Pictures?.MediumName,
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
coverTrackIds ?? [],
externalAlbum?.ExternalId,
externalAlbum?.ThumbnailUrl);
} }
} }
} }
@@ -14,7 +14,7 @@ namespace Musify.Application.Albums.Responses
YouTubeAlbumResult? YouTubeAlbum) YouTubeAlbumResult? YouTubeAlbum)
{ {
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) =>
new(album.YouTubeAlbumId is not null ? TrackSource.YouTube : TrackSource.Local, album, null); new(album is ExternalAlbumApplicationResponse ? TrackSource.YouTube : TrackSource.Local, album, null);
public static AlbumSearchItemResponse FromYouTubeAlbum(YouTubeAlbumResult album) => public static AlbumSearchItemResponse FromYouTubeAlbum(YouTubeAlbumResult album) =>
new(TrackSource.YouTube, null, album); new(TrackSource.YouTube, null, album);
@@ -0,0 +1,47 @@
using Musify.Domain.Entities;
namespace Musify.Application.Albums.Responses
{
public record ExternalAlbumApplicationResponse(
Guid Id,
string Title,
string? Description,
int? ReleaseYear,
long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds,
string ExternalId,
string? ThumbnailUrl)
: AlbumApplicationResponse(
Id, Title, Description, ReleaseYear, OwnerUserId, TrackCount,
SmallImageKeyName, MediumImageKeyName, LargeImageKeyName,
CreatedAt, UpdatedAt, CoverTrackIds)
{
public static ExternalAlbumApplicationResponse FromEntity(
ExternalAlbum album,
int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new ExternalAlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
0,
trackCount,
album.Pictures?.SmallName,
album.Pictures?.MediumName,
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
coverTrackIds ?? [],
album.ExternalId,
album.ThumbnailUrl);
}
}
}
+6 -4
View File
@@ -155,7 +155,9 @@
></div> ></div>
<div class="animate-enter relative flex flex-col gap-2 sm:gap-2.5"> <div class="animate-enter relative flex flex-col gap-2 sm:gap-2.5">
<p class="text-sm tracking-[0.14em] text-fg-2 uppercase sm:text-base">{data.greeting}</p> <p class="text-sm tracking-[0.14em] text-fg-2 uppercase sm:text-base">{data.greeting}</p>
<h1 class="font-display text-3xl leading-[1.05] font-semibold tracking-tight text-fg sm:text-6xl"> <h1
class="font-display text-3xl leading-[1.05] font-semibold tracking-tight text-fg sm:text-6xl"
>
Tu música. Sin límites. Tu música. Sin límites.
</h1> </h1>
</div> </div>
@@ -214,15 +216,15 @@
<Rail> <Rail>
{#each albums as album, i (album.id)} {#each albums as album, i (album.id)}
<a <a
href={album.youTubeAlbumId href={album.source === 'YouTube'
? `/albums/external/youtube/${album.youTubeAlbumId}` ? `/albums/external/youtube/${album.externalId}`
: `/albums/${album.id}`} : `/albums/${album.id}`}
class="group/card animate-enter block w-42 shrink-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none" class="group/card animate-enter block w-42 shrink-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
style="animation-delay:{Math.min(i, 10) * 45}ms" style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={(e) => openAlbumMenu(e, album.id)} oncontextmenu={(e) => openAlbumMenu(e, album.id)}
> >
<div class="relative"> <div class="relative">
{#if album.thumbnailUrl} {#if album.source === 'YouTube' && album.thumbnailUrl}
<Cover <Cover
trackId={album.id} trackId={album.id}
src={album.thumbnailUrl} src={album.thumbnailUrl}