Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
9 changed files with 144 additions and 32 deletions
Showing only changes of commit 10766bbb47 - Show all commits
@@ -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);
}
}
@@ -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);
}
}
@@ -1,36 +1,42 @@
using System.Text.Json.Serialization;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses 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, Guid Id,
string Title, string Title,
string? Artist, string? Artist,
TrackSource Source,
string? ExternalId,
ProcessingStatus AudioStatus, ProcessingStatus AudioStatus,
double Duration, double Duration,
int ListensCount, int ListensCount,
long? OwnerUserId,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt) DateTime UpdatedAt)
{ {
public static TrackApplicationResponse FromEntity(Track track, int listensCount) => public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new( track switch
track.Id, {
track.Title, LocalTrack local => LocalTrackApplicationResponse.FromEntity(local, listensCount),
FormatArtist(track), ExternalTrack external => ExternalTrackApplicationResponse.FromEntity(external, listensCount),
track is ExternalTrack external ? external.Source : TrackSource.Local, _ => throw new NotSupportedException($"Unsupported track entity type '{track.GetType().Name}'.")
(track as ExternalTrack)?.ExternalId, };
track.Audio.TranscodeStatus,
track.DurationSeconds,
listensCount,
(track as LocalTrack)?.OwnerUserId,
track.CreatedAt,
track.UpdatedAt);
private static string? FormatArtist(Track track) => track switch protected static string? FormatArtist(Track track) => track switch
{ {
LocalTrack local => local.Owner.Name, LocalTrack local => local.Owner.Name,
ExternalTrack { TrackArtists.Count: > 0 } external => ExternalTrack { TrackArtists.Count: > 0 } external =>
@@ -14,7 +14,7 @@ namespace Musify.Application.Tracks.Responses
YouTubeSongResult? YouTubeSong) YouTubeSongResult? YouTubeSong)
{ {
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => 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) => public static TrackSearchItemResponse FromYouTubeSong(YouTubeSongResult song) =>
new(TrackSource.YouTube, null, song); new(TrackSource.YouTube, null, song);
@@ -1,7 +1,6 @@
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Shared namespace Musify.Application.Tests.Shared
@@ -14,10 +13,9 @@ namespace Musify.Application.Tests.Shared
var owner = TestEntities.User(1, "The Owner"); var owner = TestEntities.User(1, "The Owner");
var track = TestEntities.LocalTrack(owner); var track = TestEntities.LocalTrack(owner);
var response = TrackApplicationResponse.FromEntity(track, listensCount: 3); var response = Assert.IsType<LocalTrackApplicationResponse>(TrackApplicationResponse.FromEntity(track, listensCount: 3));
Assert.Equal("The Owner", response.Artist); Assert.Equal("The Owner", response.Artist);
Assert.Equal(TrackSource.Local, response.Source);
Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount); 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 } 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<ExternalTrackApplicationResponse>(TrackApplicationResponse.FromEntity(track, listensCount: 0));
Assert.Equal("First Artist, Second Artist", response.Artist); Assert.Equal("First Artist, Second Artist", response.Artist);
Assert.Equal(track.ExternalId, response.ExternalId); Assert.Equal(track.ExternalId, response.ExternalId);
@@ -3,6 +3,7 @@ using Musify.Application.Contracts;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@@ -47,7 +48,8 @@ namespace Musify.Application.Tests.Tracks
Assert.False(result.IsError); Assert.False(result.IsError);
Assert.Equal("My Song", result.Value.Title); Assert.Equal("My Song", result.Value.Title);
Assert.Equal(user.Id, result.Value.OwnerUserId); var localResponse = Assert.IsType<LocalTrackApplicationResponse>(result.Value);
Assert.Equal(user.Id, localResponse.OwnerUserId);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>()); await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
@@ -1,6 +1,7 @@
using ErrorOr; using ErrorOr;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks namespace Musify.Application.Tests.Tracks
@@ -21,7 +22,8 @@ namespace Musify.Application.Tests.Tracks
Assert.False(result.IsError); Assert.False(result.IsError);
Assert.Equal("My Song", result.Value.Title); Assert.Equal("My Song", result.Value.Title);
Assert.Equal(owner.Name, result.Value.Artist); Assert.Equal(owner.Name, result.Value.Artist);
Assert.Equal(owner.Id, result.Value.OwnerUserId); var localResponse = Assert.IsType<LocalTrackApplicationResponse>(result.Value);
Assert.Equal(owner.Id, localResponse.OwnerUserId);
} }
[Fact] [Fact]
+51 -7
View File
@@ -549,7 +549,34 @@ export interface components {
source: null | components["schemas"]["TrackSource"]; source: null | components["schemas"]["TrackSource"];
externalId: null | string; 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 */ /** Format: uuid */
id: string; id: string;
title: string; title: string;
@@ -568,8 +595,6 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
updatedAt: string; updatedAt: string;
coverTrackIds: string[]; coverTrackIds: string[];
youTubeAlbumId?: null | string;
thumbnailUrl?: null | string;
}; };
AlbumPictureUploadResponse: { AlbumPictureUploadResponse: {
/** Format: uuid */ /** Format: uuid */
@@ -766,20 +791,39 @@ export interface components {
/** Format: int64 */ /** Format: int64 */
expectedAudioSizeBytes?: null | number | string; expectedAudioSizeBytes?: null | number | string;
}; };
TrackApplicationResponse: { TrackApplicationResponse: components["schemas"]["TrackApplicationResponseLocalTrackApplicationResponse"] | components["schemas"]["TrackApplicationResponseExternalTrackApplicationResponse"];
TrackApplicationResponseExternalTrackApplicationResponse: {
/** @enum {string} */
source?: "YouTube";
externalId: string;
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
title: string; title: string;
artist: null | string; artist: null | string;
source: components["schemas"]["TrackSource"];
externalId: null | string;
audioStatus: components["schemas"]["ProcessingStatus"]; audioStatus: components["schemas"]["ProcessingStatus"];
/** Format: double */ /** Format: double */
duration: number | string; duration: number | string;
/** Format: int32 */ /** Format: int32 */
listensCount: number | string; listensCount: number | string;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
};
TrackApplicationResponseLocalTrackApplicationResponse: {
/** @enum {string} */
source?: "Local";
/** Format: int64 */ /** 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 */ /** Format: date-time */
createdAt: string; createdAt: string;
/** Format: date-time */ /** Format: date-time */
@@ -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 library = (libraryRes.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
const alreadyLinked = new Set( 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 seeds = [...new Set(tracks.map((t) => t.artist).filter((a): a is string => !!a))];
const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined; const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined;