Split TrackApplicationResponse into a Local/External hierarchy

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).
This commit is contained in:
2026-09-10 19:19:59 +02:00
parent 7ae6c0a7a2
commit 10766bbb47
9 changed files with 144 additions and 32 deletions
+51 -7
View File
@@ -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 */
@@ -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;