Make album cover pictures mandatory, unify cover handling with playlists

Albums previously had no cover concept at all: no picture upload
flow, no GET /albums/{id}/cover, and AlbumApplicationResponse's
image-key fields were plumbed but never populated. Description,
Pictures and TrackCount already lived on the shared Album base entity
for both local and external albums, so unifying the cover story only
required building the missing local-album picture pipeline.

Backend, cloning PlayList's existing async picture-processing
architecture exactly (MassTransit routing slip + saga), but making
the picture mandatory instead of optional (matching Track's pattern):

- AlbumPictures moves from all-nullable to required fields.
- New AlbumConfiguration (routes/sizes), deliberately without
  PlayList's preset-picture fallback since a picture is now required
  up front.
- CreateAlbum now takes a mandatory PictureIntentId, validates and
  consumes the upload intent, and kicks off the same
  copy-to-final -> resize -> upload -> update-entity routing slip
  PlayLists use, via a new AlbumProcessingState saga that marks the
  album Failed on a picture-processing failure.
- UpdateAlbum takes an optional NewPictureIntentId to replace the
  cover later.
- New GET /albums/{id}/cover and POST /albums/upload-picture
  endpoints, mirroring the playlist ones.
- New EF migration for the AlbumProcessingState saga table.

Frontend:

- AlbumForm gains a cover dropzone, required on create and optional
  on edit (via ImageDropzone's new `required` prop), uploaded through
  the same presigned-upload helper playlists use.
- New /api/albums/{id}/cover proxy route.
- PlaylistArt gains a generic coverUrl prop so AlbumHeader and the
  album list/detail cards render the real uploaded cover instead of
  only the track mosaic fallback.

Delete-time picture cleanup for albums is intentionally left as a
follow-up, matching the existing gap in playlist deletion.
This commit is contained in:
2026-09-10 15:04:36 +02:00
parent 29a7fed6a5
commit 478b90d75c
63 changed files with 3118 additions and 383 deletions
+353 -314
View File
@@ -11,7 +11,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Get Paginated Albums. */
/** Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given. */
get: operations["GetAlbums"];
put?: never;
/** Create A New Album. */
@@ -39,6 +39,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/albums/external/{source}/{externalId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist. */
get: operations["GetExternalAlbum"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/albums/users/{userId}": {
parameters: {
query?: never;
@@ -90,6 +107,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/albums/{id}/cover": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get An Album Cover Image. */
get: operations["GetAlbumCover"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/albums/upload-picture": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Request A Pre-Signed URL To Upload An Album Picture. */
post: operations["RequestAlbumPictureUpload"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/albums/{albumId}": {
parameters: {
query?: never;
@@ -222,7 +273,8 @@ export interface paths {
/** Get Paginated Tracks Of A PlayList. */
get: operations["GetPlayListTracks"];
put?: never;
post?: never;
/** Add A Track To A PlayList, By Id Or By External Source, Provisioning It In The Background If Needed. */
post: operations["AddPlayListTrack"];
delete?: never;
options?: never;
head?: never;
@@ -290,8 +342,7 @@ export interface paths {
};
get?: never;
put?: never;
/** Add A Track To A PlayList. */
post: operations["AddTrackToPlayList"];
post?: never;
/** Remove A Track From A PlayList. */
delete: operations["RemoveTrackFromPlayList"];
options?: never;
@@ -299,23 +350,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/playlists/{playlistId}/youtube-tracks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Add A YouTube Track To A PlayList, Downloading It In The Background If Needed. */
post: operations["AddYouTubeTrackToPlayList"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/tracks": {
parameters: {
query?: never;
@@ -375,7 +409,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Get A Streaming Manifest URL And Ticket For A Track. */
/** Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It. */
get: operations["GetTrackStream"];
put?: never;
post?: never;
@@ -385,6 +419,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/tracks/external/{source}/{externalId}/stream": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Resolve Playback For A Track From An External Source (Currently Only YouTube Music): Server Stream If Downloaded, Direct Stream From The Source Otherwise. Works Anonymously When The Playback Configuration Allows It. */
get: operations["GetExternalTrackStream"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/tracks/users/{userId}": {
parameters: {
query?: never;
@@ -488,63 +539,15 @@ export interface paths {
patch?: never;
trace?: never;
};
"/youtube/albums": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Search Albums On YouTube Music. */
get: operations["SearchYouTubeAlbums"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/youtube/albums/{albumId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get A YouTube Music Album With Its Tracklist. */
get: operations["GetYouTubeAlbum"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/youtube/tracks/{videoId}/stream": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise. */
get: operations["GetYouTubeTrackStream"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AddYouTubeTrackRequest: {
videoId: string;
AddPlayListTrackRequest: {
/** Format: uuid */
trackId: null | string;
source: null | components["schemas"]["TrackSource"];
externalId: null | string;
};
AlbumApplicationResponse: {
/** Format: uuid */
@@ -565,14 +568,48 @@ export interface components {
/** Format: date-time */
updatedAt: string;
coverTrackIds: string[];
youTubeAlbumId: null | string;
thumbnailUrl: null | string;
youTubeAlbumId?: null | string;
thumbnailUrl?: null | string;
};
AlbumPictureUploadResponse: {
/** Format: uuid */
intentId: string;
bucket: string;
key: string;
pictureName: string;
contentType: string;
/** Format: int32 */
expiresInSeconds: number | string;
uploadUrl: string;
};
AlbumSearchItemResponse: {
source: components["schemas"]["TrackSource"];
album: null | components["schemas"]["AlbumApplicationResponse"];
youTubeAlbum: null | components["schemas"]["YouTubeAlbumResult"];
};
AlbumsSearchResponse: {
items: components["schemas"]["AlbumSearchItemResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
pageSize: number | string;
/** Format: int32 */
pageCount: number | string;
/** Format: int32 */
totalItemCount: number | string;
hasPreviousPage: boolean;
hasNextLocalPage: boolean;
nextYoutubeContinuationToken: null | string;
youtubeUnavailable: boolean;
hasNextPage: boolean;
};
CreateAlbumRequest: {
title: string;
description: null | string;
/** Format: int32 */
releaseYear: null | number | string;
/** Format: uuid */
pictureIntentId: string;
};
CreatePlayListRequest: {
name: string;
@@ -622,7 +659,7 @@ export interface components {
title: string;
artist: null | string;
thumbnailUrl: null | string;
/** Format: int32 */
/** Format: double */
durationSeconds: number | string;
isExplicit: boolean;
};
@@ -707,6 +744,12 @@ export interface components {
};
/** @enum {unknown} */
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
RequestAlbumPictureUploadRequest: {
fileType: string;
contentType: string;
/** Format: int64 */
expectedSizeBytes?: null | number | string;
};
RequestPlayListPictureUploadRequest: {
fileType: string;
contentType: string;
@@ -731,7 +774,7 @@ export interface components {
source: components["schemas"]["TrackSource"];
externalId: null | string;
audioStatus: components["schemas"]["ProcessingStatus"];
/** Format: int32 */
/** Format: double */
duration: number | string;
/** Format: int32 */
listensCount: number | string;
@@ -744,11 +787,27 @@ export interface components {
};
TrackSearchItemResponse: {
source: components["schemas"]["TrackSource"];
track: components["schemas"]["TrackApplicationResponse"] | null;
youTubeSong: components["schemas"]["YouTubeSongResult"] | null;
track: null | components["schemas"]["TrackApplicationResponse"];
youTubeSong: null | components["schemas"]["YouTubeSongResult"];
};
/** @enum {unknown} */
TrackSource: "Local" | "YouTube";
TracksSearchResponse: {
items: components["schemas"]["TrackSearchItemResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
pageSize: number | string;
/** Format: int32 */
pageCount: number | string;
/** Format: int32 */
totalItemCount: number | string;
hasPreviousPage: boolean;
hasNextLocalPage: boolean;
nextYoutubeContinuationToken: null | string;
youtubeUnavailable: boolean;
hasNextPage: boolean;
};
TrackStreamResponse: {
manifestUrl: string;
ticket: string;
@@ -772,27 +831,13 @@ export interface components {
/** Format: int32 */
expiresInSeconds: number | string;
};
TracksSearchResponse: {
items: components["schemas"]["TrackSearchItemResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
pageSize: number | string;
/** Format: int32 */
pageCount: number | string;
/** Format: int32 */
totalItemCount: number | string;
hasPreviousPage: boolean;
hasNextLocalPage: boolean;
nextYoutubeContinuationToken: null | string;
youtubeUnavailable: boolean;
hasNextPage: boolean;
};
UpdateAlbumRequest: {
newTitle: string;
newDescription: null | string;
/** Format: int32 */
newReleaseYear: null | number | string;
/** Format: uuid */
newPictureIntentId: null | string;
};
UpdatePlayListRequest: {
newName: null | string;
@@ -830,10 +875,6 @@ export interface components {
isEp: boolean;
artists: components["schemas"]["YouTubeArtistRef"][];
};
YouTubeAlbumSearchResult: {
items: components["schemas"]["YouTubeAlbumResult"][];
continuationToken: string;
};
YouTubeAlbumTrack: {
videoId: string;
title: string;
@@ -861,13 +902,13 @@ export interface components {
/** @enum {unknown} */
YouTubeStreamMode: "Server" | "YouTube";
YouTubeStreamResponse: {
/** Format: uuid */
trackId: null | string;
mode: components["schemas"]["YouTubeStreamMode"];
streamUrl: string;
ticket: string;
ticket: null | string;
/** Format: int32 */
expiresInSeconds: number | string;
/** Format: uuid */
trackId: string;
};
};
responses: never;
@@ -884,6 +925,7 @@ export interface operations {
title?: string;
pageNumber?: number | string;
pageSize?: number | string;
youtubeContinuationToken?: string;
};
header?: never;
path?: never;
@@ -897,7 +939,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfAlbumApplicationResponse"];
"application/json": components["schemas"]["AlbumsSearchResponse"];
};
};
};
@@ -978,6 +1020,36 @@ export interface operations {
};
};
};
GetExternalAlbum: {
parameters: {
query?: never;
header?: never;
path: {
source: string;
externalId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeAlbumDetail"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetAlbumsByUserId: {
parameters: {
query?: {
@@ -1071,6 +1143,82 @@ export interface operations {
};
};
};
GetAlbumCover: {
parameters: {
query?: {
size?: string;
};
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RequestAlbumPictureUpload: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RequestAlbumPictureUploadRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlbumPictureUploadResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UpdateAlbum: {
parameters: {
query?: never;
@@ -1451,6 +1599,62 @@ export interface operations {
};
};
};
AddPlayListTrack: {
parameters: {
query?: never;
header?: never;
path: {
playlistId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AddPlayListTrackRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TrackApplicationResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetPlayListCover: {
parameters: {
query?: {
@@ -1610,48 +1814,6 @@ export interface operations {
};
};
};
AddTrackToPlayList: {
parameters: {
query?: never;
header?: never;
path: {
playlistId: string;
trackId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RemoveTrackFromPlayList: {
parameters: {
query?: never;
@@ -1687,62 +1849,6 @@ export interface operations {
};
};
};
AddYouTubeTrackToPlayList: {
parameters: {
query?: never;
header?: never;
path: {
playlistId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AddYouTubeTrackRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TrackApplicationResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetTracks: {
parameters: {
query?: {
@@ -1916,6 +2022,50 @@ export interface operations {
};
};
};
GetExternalTrackStream: {
parameters: {
query?: never;
header?: never;
path: {
source: string;
externalId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeStreamResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetTracksByUserId: {
parameters: {
query?: {
@@ -2159,115 +2309,4 @@ export interface operations {
};
};
};
SearchYouTubeAlbums: {
parameters: {
query: {
query: string;
continuation?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeAlbumSearchResult"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetYouTubeAlbum: {
parameters: {
query?: never;
header?: never;
path: {
albumId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeAlbumDetail"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetYouTubeTrackStream: {
parameters: {
query?: never;
header?: never;
path: {
videoId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeStreamResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
import Input from './Input.svelte';
@@ -14,6 +15,8 @@
initialDescription?: string;
initialReleaseYear?: number;
titlePlaceholder?: string;
coverFallbackUrl?: string;
requireCover?: boolean;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
@@ -27,6 +30,8 @@
initialDescription = '',
initialReleaseYear,
titlePlaceholder = '',
coverFallbackUrl,
requireCover = false,
formMessage,
submitLabel,
submittingLabel,
@@ -45,6 +50,7 @@
<form
method="POST"
{action}
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
@@ -55,28 +61,36 @@
}}
class="mt-4 w-full max-w-2xl space-y-4"
>
<Field label="Título" for="album-title">
<Input
id="album-title"
name="title"
maxlength={200}
required
bind:value={title}
placeholder={titlePlaceholder}
/>
</Field>
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field label="Portada" optional={!requireCover}>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} required={requireCover} />
</Field>
<Field label="Año de publicación" for="album-year" optional>
<Input
id="album-year"
name="releaseYear"
type="number"
min={ALBUM_EARLIEST_YEAR}
max={latestYear}
bind:value={releaseYear}
placeholder={String(new Date().getFullYear())}
/>
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Título" for="album-title">
<Input
id="album-title"
name="title"
maxlength={200}
required
bind:value={title}
placeholder={titlePlaceholder}
/>
</Field>
<Field label="Año de publicación" for="album-year" optional>
<Input
id="album-year"
name="releaseYear"
type="number"
min={ALBUM_EARLIEST_YEAR}
max={latestYear}
bind:value={releaseYear}
placeholder={String(new Date().getFullYear())}
/>
</Field>
</div>
</div>
<Field label="Descripción" for="album-description" optional>
<Textarea
@@ -4,12 +4,14 @@
interface Props {
name: string;
fallbackUrl?: string;
required?: boolean;
class?: string;
}
let {
name,
fallbackUrl,
required = false,
class:
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50'
}: Props = $props();
@@ -30,6 +32,7 @@
type="file"
{name}
accept="image/*"
{required}
onchange={onInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
@@ -3,26 +3,39 @@
interface Props {
playlistId?: string;
coverUrl?: string;
trackIds: (string | number)[];
size?: 'small' | 'medium' | 'large';
version?: string;
class?: string;
}
let { playlistId, trackIds, size = 'medium', version, class: klass = '' }: Props = $props();
let {
playlistId,
coverUrl,
trackIds,
size = 'medium',
version,
class: klass = ''
}: Props = $props();
let coverFailed = $state(false);
const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
const src = $derived(
coverUrl
? coverUrl
: playlistId
? `/api/playlists/${playlistId}/cover?size=${size}${version ? `&v=${encodeURIComponent(version)}` : ''}`
: undefined
);
</script>
{#if playlistId && !coverFailed}
{#if src && !coverFailed}
<div class="relative overflow-hidden bg-surface {klass}">
<img
src="/api/playlists/{playlistId}/cover?size={size}{version
? `&v=${encodeURIComponent(version)}`
: ''}"
{src}
alt=""
loading="lazy"
onerror={() => (coverFailed = true)}
+1 -1
View File
@@ -4,7 +4,7 @@ const ALLOWED_SIZES = new Set(['small', 'medium', 'large']);
export async function proxyCoverImage(
fetch: typeof globalThis.fetch,
kind: 'playlists' | 'tracks',
kind: 'playlists' | 'tracks' | 'albums',
id: string,
size: string | null
): Promise<Response> {