Backend: - Expose GET /config/playback so the frontend can detect whether anonymous listening is enabled without requiring auth - Wire anonymous access through track streaming and YouTube resolve flows Frontend: - Allow browsing/listening without a session when the backend enables it - Redesign Inicio, Sidebar and top bar with a new dark theme, typography, and layout (top bar with live quick-search, sticky/blurred on scroll) - Populate Top música, Popular and Recomendados with real Musify + YouTube Music data instead of placeholders - Various shape, spacing, color and animation refinements across the redesigned components
133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
import type { PageServerLoad, Actions } from './$types';
|
|
import { fail, redirect } from '@sveltejs/kit';
|
|
import {
|
|
createApiClient,
|
|
optionalUser,
|
|
requireAccessTokenAction,
|
|
unwrapOrError,
|
|
unwrapOrFail
|
|
} from '$lib/server/api';
|
|
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
|
import { parseAlbumForm } from '$lib/server/albumForm';
|
|
import { uploadPresignedImage } from '$lib/server/upload';
|
|
|
|
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
|
const { allowAnonymousListening } = await parent();
|
|
const user = optionalUser(locals, url, allowAnonymousListening);
|
|
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 }
|
|
}
|
|
}),
|
|
user
|
|
? api.GET('/tracks/users/{userId}', {
|
|
params: {
|
|
path: { userId: user.sub },
|
|
query: { pageNumber: 1, pageSize: LIBRARY_PICKER_PAGE_SIZE }
|
|
}
|
|
})
|
|
: Promise.resolve(null)
|
|
]);
|
|
|
|
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)
|
|
);
|
|
|
|
const isOwner = user !== null && Number(album.ownerUserId) === Number(user.sub);
|
|
|
|
return { album, tracks, library, isOwner, section: isOwner ? '/albums' : null };
|
|
};
|
|
|
|
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 form = await request.formData();
|
|
const parsed = parseAlbumForm(form);
|
|
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
|
|
|
const api = createApiClient({ fetch, accessToken });
|
|
|
|
let newPictureIntentId: string | null = null;
|
|
const cover = form.get('cover');
|
|
if (cover instanceof File && cover.size > 0) {
|
|
const uploaded = await uploadPresignedImage(
|
|
(args) => api.POST('/albums/upload-picture', { body: args }),
|
|
cover
|
|
);
|
|
if ('failMessage' in uploaded) {
|
|
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
|
|
}
|
|
newPictureIntentId = uploaded.intentId;
|
|
}
|
|
|
|
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,
|
|
newPictureIntentId
|
|
}
|
|
});
|
|
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');
|
|
}
|
|
};
|