diff --git a/web-player/src/lib/components/Sidebar.svelte b/web-player/src/lib/components/Sidebar.svelte
index 2d9a5d4..95540db 100644
--- a/web-player/src/lib/components/Sidebar.svelte
+++ b/web-player/src/lib/components/Sidebar.svelte
@@ -4,6 +4,7 @@
import Home from '@lucide/svelte/icons/house';
import Search from '@lucide/svelte/icons/search';
import Folder from '@lucide/svelte/icons/folder';
+ import Disc from '@lucide/svelte/icons/disc-3';
import Music from '@lucide/svelte/icons/music';
import Upload from '@lucide/svelte/icons/upload';
import LogOut from '@lucide/svelte/icons/log-out';
@@ -35,6 +36,7 @@
];
const secondaryLinks = [
+ { href: '/albums', label: 'Mis álbumes', icon: Disc },
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload }
];
diff --git a/web-player/src/lib/components/ui/AlbumCard.svelte b/web-player/src/lib/components/ui/AlbumCard.svelte
new file mode 100644
index 0000000..662b4c8
--- /dev/null
+++ b/web-player/src/lib/components/ui/AlbumCard.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+ {title}
+ {meta}
+
diff --git a/web-player/src/lib/components/ui/AlbumForm.svelte b/web-player/src/lib/components/ui/AlbumForm.svelte
new file mode 100644
index 0000000..f577607
--- /dev/null
+++ b/web-player/src/lib/components/ui/AlbumForm.svelte
@@ -0,0 +1,101 @@
+
+
+
diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts
index 832125e..be98665 100644
--- a/web-player/src/lib/config.ts
+++ b/web-player/src/lib/config.ts
@@ -2,5 +2,9 @@ export const EXPLORE_PAGE_SIZE = 27;
export const SEARCH_DEBOUNCE_MS = 450;
export const SEARCH_MIN_LENGTH = 2;
export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
+export const ALBUMS_PAGE_SIZE = 50;
+export const ALBUM_TRACKS_PAGE_SIZE = 200;
+export const ALBUM_EARLIEST_YEAR = 1877;
+export const LIBRARY_PICKER_PAGE_SIZE = 50;
export const HOME_LATEST_PAGE_SIZE = 12;
export const YOUTUBE_FILLER_LIMIT = 27;
diff --git a/web-player/src/lib/server/albumForm.ts b/web-player/src/lib/server/albumForm.ts
new file mode 100644
index 0000000..bed1fa4
--- /dev/null
+++ b/web-player/src/lib/server/albumForm.ts
@@ -0,0 +1,39 @@
+import { ALBUM_EARLIEST_YEAR } from '$lib/config';
+
+interface AlbumFormBody {
+ title: string;
+ description: string | null;
+ releaseYear: number | null;
+}
+
+export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
+ const title = String(form.get('title') ?? '').trim();
+ if (title === '' || title.length > 200) {
+ return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' };
+ }
+
+ const description = String(form.get('description') ?? '').trim();
+ if (description.length > 256) {
+ return { failMessage: 'La descripción no puede pasar de 256 caracteres.' };
+ }
+
+ const rawYear = String(form.get('releaseYear') ?? '').trim();
+ const latestYear = new Date().getFullYear() + 1;
+ let releaseYear: number | null = null;
+
+ if (rawYear !== '') {
+ const parsed = Number(rawYear);
+ if (!Number.isInteger(parsed) || parsed < ALBUM_EARLIEST_YEAR || parsed > latestYear) {
+ return { failMessage: `El año debe estar entre ${ALBUM_EARLIEST_YEAR} y ${latestYear}.` };
+ }
+ releaseYear = parsed;
+ }
+
+ return {
+ body: {
+ title,
+ description: description === '' ? null : description,
+ releaseYear
+ }
+ };
+}
diff --git a/web-player/src/lib/server/api.ts b/web-player/src/lib/server/api.ts
index ee811a0..38f50b0 100644
--- a/web-player/src/lib/server/api.ts
+++ b/web-player/src/lib/server/api.ts
@@ -53,6 +53,21 @@ export function unwrapOrFail(
return result.error ? fail(502, { message }) : undefined;
}
+export async function albumCoverTrackIds(
+ api: ReturnType,
+ albumIds: string[]
+): Promise> {
+ const covers = await Promise.all(
+ albumIds.map(async (albumId) => {
+ const { data: tracks } = await api.GET('/albums/{albumId}/tracks', {
+ params: { path: { albumId }, query: { pageNumber: 1, pageSize: 4 } }
+ });
+ return [albumId, tracks?.items.map((t) => t.id) ?? []] as const;
+ })
+ );
+ return Object.fromEntries(covers);
+}
+
export async function playlistCoverTrackIds(
api: ReturnType,
playlistIds: string[]
diff --git a/web-player/src/routes/(app)/albums/+page.server.ts b/web-player/src/routes/(app)/albums/+page.server.ts
new file mode 100644
index 0000000..e4f7327
--- /dev/null
+++ b/web-player/src/routes/(app)/albums/+page.server.ts
@@ -0,0 +1,45 @@
+import type { PageServerLoad, Actions } from './$types';
+import { fail, redirect } from '@sveltejs/kit';
+import {
+ albumCoverTrackIds,
+ createApiClient,
+ requireAccessTokenAction,
+ requireUser,
+ unwrapOrError
+} from '$lib/server/api';
+import { ALBUMS_PAGE_SIZE } from '$lib/config';
+import { parseAlbumForm } from '$lib/server/albumForm';
+
+export const load: PageServerLoad = async ({ locals, url, fetch }) => {
+ const user = requireUser(locals, url);
+ const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
+
+ const result = await api.GET('/albums/users/{userId}', {
+ params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
+ });
+
+ const albums = unwrapOrError(result, 'No se pudieron cargar tus álbumes.');
+ const trackIds = await albumCoverTrackIds(
+ api,
+ albums.items.map((album) => album.id)
+ );
+
+ return { albums, trackIds };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals, fetch }) => {
+ const accessToken = requireAccessTokenAction(locals);
+ if (typeof accessToken !== 'string') return accessToken;
+
+ const parsed = parseAlbumForm(await request.formData());
+ if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
+
+ const api = createApiClient({ fetch, accessToken });
+ const { data, error: err } = await api.POST('/albums', { body: parsed.body });
+
+ if (err || !data) return fail(502, { message: 'No se pudo crear el álbum.' });
+
+ redirect(303, `/albums/${data.id}`);
+ }
+};
diff --git a/web-player/src/routes/(app)/albums/+page.svelte b/web-player/src/routes/(app)/albums/+page.svelte
new file mode 100644
index 0000000..1b2f9a9
--- /dev/null
+++ b/web-player/src/routes/(app)/albums/+page.svelte
@@ -0,0 +1,76 @@
+
+
+
+ Álbumes · Musify
+
+
+
+
+
+ {#snippet actions()}
+ {#if items.length > 0}
+
+ {/if}
+ {/snippet}
+
+
+ {#if items.length > 0}
+
+ {#each items as album, i (album.id)}
+
+ {/each}
+
+ {:else}
+
+ {#snippet actions()}
+
+ {/snippet}
+
+ {/if}
+
+
+ (creating = false)} title="Nuevo álbum">
+ (creating = false)}
+ />
+
diff --git a/web-player/src/routes/(app)/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/albums/[id]/+page.server.ts
new file mode 100644
index 0000000..76614dd
--- /dev/null
+++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts
@@ -0,0 +1,110 @@
+import type { PageServerLoad, Actions } from './$types';
+import { fail, redirect } from '@sveltejs/kit';
+import {
+ createApiClient,
+ requireAccessTokenAction,
+ requireUser,
+ unwrapOrError,
+ unwrapOrFail
+} from '$lib/server/api';
+import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
+import { parseAlbumForm } from '$lib/server/albumForm';
+
+export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
+ const user = requireUser(locals, url);
+ const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
+
+ const [albumRes, tracksRes, libraryRes] = await Promise.all([
+ api.GET('/albums/{id}', { params: { path: { id: params.id } } }),
+ api.GET('/albums/{albumId}/tracks', {
+ params: {
+ path: { albumId: params.id },
+ query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE }
+ }
+ }),
+ api.GET('/tracks/users/{userId}', {
+ params: {
+ path: { userId: user.sub },
+ query: { pageNumber: 1, pageSize: LIBRARY_PICKER_PAGE_SIZE }
+ }
+ })
+ ]);
+
+ const album = unwrapOrError(albumRes, 'Álbum no encontrado.', 404);
+
+ const tracks = tracksRes.data?.items ?? [];
+ const inAlbum = new Set(tracks.map((track) => track.id));
+ const library = (libraryRes.data?.items ?? []).filter(
+ (track) => track.source === 'Local' && !inAlbum.has(track.id)
+ );
+
+ return { album, tracks, library, isOwner: Number(album.ownerUserId) === Number(user.sub) };
+};
+
+export const actions: Actions = {
+ addTrack: async ({ request, params, locals, fetch }) => {
+ const accessToken = requireAccessTokenAction(locals);
+ if (typeof accessToken !== 'string') return accessToken;
+
+ const trackId = String((await request.formData()).get('trackId') ?? '');
+ if (!trackId) return fail(400, { message: 'Falta la canción.' });
+
+ const api = createApiClient({ fetch, accessToken });
+ const result = await api.POST('/albums/{albumId}/tracks/{trackId}', {
+ params: { path: { albumId: params.id, trackId } }
+ });
+ const failure = unwrapOrFail(result, 'No se pudo añadir la canción.');
+ if (failure) return failure;
+ return { added: true };
+ },
+
+ removeTrack: async ({ request, params, locals, fetch }) => {
+ const accessToken = requireAccessTokenAction(locals);
+ if (typeof accessToken !== 'string') return accessToken;
+
+ const trackId = String((await request.formData()).get('trackId') ?? '');
+ if (!trackId) return fail(400, { message: 'Falta la canción.' });
+
+ const api = createApiClient({ fetch, accessToken });
+ const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', {
+ params: { path: { albumId: params.id, trackId } }
+ });
+ const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
+ if (failure) return failure;
+ return { removed: true };
+ },
+
+ edit: async ({ request, params, locals, fetch }) => {
+ const accessToken = requireAccessTokenAction(locals);
+ if (typeof accessToken !== 'string') return accessToken;
+
+ const parsed = parseAlbumForm(await request.formData());
+ if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
+
+ const api = createApiClient({ fetch, accessToken });
+ const result = await api.PUT('/albums/{albumId}', {
+ params: { path: { albumId: params.id } },
+ body: {
+ newTitle: parsed.body.title,
+ newDescription: parsed.body.description,
+ newReleaseYear: parsed.body.releaseYear
+ }
+ });
+ const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.');
+ if (failure) return failure;
+ return { edited: true };
+ },
+
+ delete: async ({ params, locals, fetch }) => {
+ const accessToken = requireAccessTokenAction(locals);
+ if (typeof accessToken !== 'string') return accessToken;
+
+ const api = createApiClient({ fetch, accessToken });
+ const result = await api.DELETE('/albums/{albumId}', {
+ params: { path: { albumId: params.id } }
+ });
+ const failure = unwrapOrFail(result, 'No se pudo eliminar el álbum.');
+ if (failure) return failure;
+ redirect(303, '/albums');
+ }
+};
diff --git a/web-player/src/routes/(app)/albums/[id]/+page.svelte b/web-player/src/routes/(app)/albums/[id]/+page.svelte
new file mode 100644
index 0000000..e80795b
--- /dev/null
+++ b/web-player/src/routes/(app)/albums/[id]/+page.svelte
@@ -0,0 +1,92 @@
+
+
+
+ {album.title} · Musify
+
+
+
+
+ track.id)}
+ playing={isCurrentQueue && player.playing}
+ {isOwner}
+ onPlayAll={playAll}
+ onEdit={() => (editing = true)}
+ onDelete={() => (confirmingDelete = true)}
+ />
+
+ {#if form?.message}
+ {form.message}
+ {/if}
+
+ {#if tracks.length > 0}
+
+ {/if}
+
+ {#if isOwner && library.length > 0}
+
+ {/if}
+
+
+ (editing = false)} title="Editar álbum">
+ (editing = false)}
+ onSuccess={() => (editing = false)}
+ />
+
+
+ (confirmingDelete = false)} maxWidth="max-w-sm">
+
+ ¿Eliminar «{album.title}»?
+
+
+ Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca.
+
+
+
+
+
+
diff --git a/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte b/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte
new file mode 100644
index 0000000..34dced8
--- /dev/null
+++ b/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+ Volver a tus álbumes
+
+
+
+
+
+
Álbum
+
+ {title}
+
+ {#if description}
+
{description}
+ {/if}
+
{meta}
+
+
+
+
+
+ {#if isOwner}
+
+
+ {/if}
+
diff --git a/web-player/src/routes/(app)/albums/[id]/components/AlbumTrackTable.svelte b/web-player/src/routes/(app)/albums/[id]/components/AlbumTrackTable.svelte
new file mode 100644
index 0000000..02a970d
--- /dev/null
+++ b/web-player/src/routes/(app)/albums/[id]/components/AlbumTrackTable.svelte
@@ -0,0 +1,79 @@
+
+
+
+ {#snippet headers()}
+ #
+ Título
+ Duración
+ {#if isOwner}
+
+ {/if}
+ {/snippet}
+ {#each tracks as track, i (track.id)}
+
+ playFrom(i)}
+ />
+ playFrom(i)}
+ />
+ {fmtTime(Number(track.duration))}
+ {#if isOwner}
+
+ {/if}
+
+ {/each}
+
diff --git a/web-player/src/routes/(app)/albums/[id]/components/LibraryPicker.svelte b/web-player/src/routes/(app)/albums/[id]/components/LibraryPicker.svelte
new file mode 100644
index 0000000..5abf017
--- /dev/null
+++ b/web-player/src/routes/(app)/albums/[id]/components/LibraryPicker.svelte
@@ -0,0 +1,68 @@
+
+
+
+
+
+ {#snippet headers()}
+ Título
+ Duración
+
+ {/snippet}
+ {#each library as track, i (track.id)}
+
+ playFrom(i)}
+ >
+ {#snippet overlay()}
+ {#if player.current.id === track.id}
+
+ {/if}
+ {/snippet}
+
+ {fmtTime(Number(track.duration))}
+
+
+ {/each}
+
+