web-player: use playlist totals from the API, cache genres and parallelize explore requests

This commit is contained in:
2026-09-24 05:21:29 +02:00
parent b011ccdd5b
commit e89463fb83
10 changed files with 61 additions and 84 deletions
+4
View File
@@ -942,6 +942,10 @@ export interface components {
largeImageKeyName: null | string; largeImageKeyName: null | string;
ownerUserId: string; ownerUserId: string;
visibility: components["schemas"]["PlaylistVisibility"]; visibility: components["schemas"]["PlaylistVisibility"];
/** Format: int32 */
trackCount: number | string;
/** Format: double */
durationSeconds: number | string;
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
/** Format: date-time */ /** Format: date-time */
-2
View File
@@ -9,9 +9,7 @@ export const PLAYLIST_TRACKS_PAGE_SIZE = 200;
export const LIBRARY_PICKER_PAGE_SIZE = 50; export const LIBRARY_PICKER_PAGE_SIZE = 50;
export const LIBRARY_PAGE_SIZE = 50; export const LIBRARY_PAGE_SIZE = 50;
export const PLAYLISTS_PAGE_SIZE = 50; export const PLAYLISTS_PAGE_SIZE = 50;
export const PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE = 500;
export const PROFILE_PLAYLISTS_PAGE_SIZE = 50; export const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
export const COUNT_ONLY_PAGE_SIZE = 1;
export const EXPLORE_ALBUMS_PAGE_SIZE = 10; export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
export const EXPLORE_ALBUMS_LIMIT = 18; export const EXPLORE_ALBUMS_LIMIT = 18;
export const HOME_MIXES_LIMIT = 6; export const HOME_MIXES_LIMIT = 6;
+18
View File
@@ -0,0 +1,18 @@
import type { ApiClient } from '$lib/server/api';
import type { components } from '$lib/api/schema';
type GenreInfo = components['schemas']['GenreResponse'];
const CACHE_TTL_MS = 5 * 60_000;
let cached: { genres: GenreInfo[]; expiresAt: number } | null = null;
export async function getGenres(api: ApiClient): Promise<GenreInfo[]> {
if (cached && cached.expiresAt > Date.now()) return cached.genres;
const { data } = await api.GET('/genres');
if (!data) return cached?.genres ?? [];
cached = { genres: data, expiresAt: Date.now() + CACHE_TTL_MS };
return data;
}
+6 -14
View File
@@ -1,15 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { components } from '$lib/api/schema'; import type { components } from '$lib/api/schema';
import { import { toAlbum, toDatedTrack, toLikedTrack, toMix, toPage, toPlaylist, toTrack } from './mappers';
toAlbum,
toDatedTrack,
toLikedTrack,
toMix,
toPage,
toPlaylist,
toPlaylistSummary,
toTrack
} from './mappers';
const trackDto: components['schemas']['TrackApplicationResponse'] = { const trackDto: components['schemas']['TrackApplicationResponse'] = {
id: 't1', id: 't1',
@@ -107,24 +98,25 @@ describe('toPlaylist', () => {
largeImageKeyName: null, largeImageKeyName: null,
ownerUserId: '42', ownerUserId: '42',
visibility: 'Public', visibility: 'Public',
trackCount: '7',
durationSeconds: '90.5',
createdAt: '', createdAt: '',
updatedAt: 'u', updatedAt: 'u',
coverTrackIds: [] coverTrackIds: []
}; };
it('drops storage keys', () => { it('drops storage keys and normalizes totals', () => {
expect(toPlaylist(dto)).toEqual({ expect(toPlaylist(dto)).toEqual({
id: 'p1', id: 'p1',
name: 'List', name: 'List',
description: 'd', description: 'd',
visibility: 'Public', visibility: 'Public',
ownerUserId: '42', ownerUserId: '42',
trackCount: 7,
durationSeconds: 90.5,
coverTrackIds: [], coverTrackIds: [],
updatedAt: 'u' updatedAt: 'u'
}); });
}); });
it('adds the track count in summaries', () => {
expect(toPlaylistSummary(dto, 7).trackCount).toBe(7);
});
}); });
describe('toPage', () => { describe('toPage', () => {
+3 -5
View File
@@ -1,5 +1,5 @@
import type { components } from '$lib/api/schema'; import type { components } from '$lib/api/schema';
import type { Album, LikedTrack, Mix, Paged, Playlist, PlaylistSummary, Track } from '$lib/types'; import type { Album, LikedTrack, Mix, Paged, Playlist, Track } from '$lib/types';
type TrackDto = components['schemas']['TrackApplicationResponse']; type TrackDto = components['schemas']['TrackApplicationResponse'];
type AlbumDto = components['schemas']['AlbumApplicationResponse']; type AlbumDto = components['schemas']['AlbumApplicationResponse'];
@@ -75,15 +75,13 @@ export function toPlaylist(dto: PlaylistDto): Playlist {
description: dto.description, description: dto.description,
visibility: dto.visibility, visibility: dto.visibility,
ownerUserId: dto.ownerUserId, ownerUserId: dto.ownerUserId,
trackCount: Number(dto.trackCount) || 0,
durationSeconds: Number(dto.durationSeconds) || 0,
coverTrackIds: dto.coverTrackIds, coverTrackIds: dto.coverTrackIds,
updatedAt: dto.updatedAt updatedAt: dto.updatedAt
}; };
} }
export function toPlaylistSummary(dto: PlaylistDto, trackCount: number): PlaylistSummary {
return { ...toPlaylist(dto), trackCount };
}
export function toPage<D, T>(dto: PageDto<D>, map: (item: D) => T): Paged<T> { export function toPage<D, T>(dto: PageDto<D>, map: (item: D) => T): Paged<T> {
return { return {
items: dto.items.map(map), items: dto.items.map(map),
+2 -4
View File
@@ -38,14 +38,12 @@ export interface Playlist {
description: string | null; description: string | null;
visibility: 'Public' | 'Private'; visibility: 'Public' | 'Private';
ownerUserId: string; ownerUserId: string;
trackCount: number;
durationSeconds: number;
coverTrackIds: string[]; coverTrackIds: string[];
updatedAt: string; updatedAt: string;
} }
export interface PlaylistSummary extends Playlist {
trackCount: number;
}
export interface Mix { export interface Mix {
id: string; id: string;
title: string; title: string;
@@ -1,5 +1,6 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { apiFor, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
import { getGenres } from '$lib/server/genres';
import { toAlbum, toPage, toTrack } from '$lib/server/mappers'; import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions'; import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config'; import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
@@ -12,11 +13,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const user = locals.user; const user = locals.user;
const api = apiFor({ fetch, locals }); const api = apiFor({ fetch, locals });
const genresRes = await api.GET('/genres'); const tracksFor = (genre: string | null) =>
const genres = genresRes.data ?? []; api.GET('/tracks', {
const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null;
const tracksPromise = api.GET('/tracks', {
params: { params: {
query: { query: {
name: query || undefined, name: query || undefined,
@@ -39,11 +37,14 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
}) })
: Promise.resolve(null); : Promise.resolve(null);
const [tracksRes, albumsRes, usersRes] = await Promise.all([ const [genres, firstTracksRes, albumsRes, usersRes] = await Promise.all([
tracksPromise, getGenres(api),
tracksFor(genreParam),
albumsPromise, albumsPromise,
usersPromise usersPromise
]); ]);
const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null;
const tracksRes = genre === genreParam ? firstTracksRes : await tracksFor(genre);
const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) => const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) =>
toTrack(item.track) toTrack(item.track)
@@ -1,8 +1,8 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api'; import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api';
import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config'; import { PLAYLISTS_PAGE_SIZE } from '$lib/config';
import { toPlaylistSummary, toTrack } from '$lib/server/mappers'; import { toPlaylist } from '$lib/server/mappers';
import { parsePlaylistForm } from '$lib/server/forms/playlistForm'; import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
import { uploadOptionalCover } from '$lib/server/upload'; import { uploadOptionalCover } from '$lib/server/upload';
@@ -13,32 +13,17 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PLAYLISTS_PAGE_SIZE } } params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PLAYLISTS_PAGE_SIZE } }
}); });
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.'); const playlists = unwrapOrError(result, 'No se pudieron cargar tus playlists.').items.map(
toPlaylist
const tracksByPlaylist = await Promise.all(
data.items.map((playlist) =>
api
.GET('/playlists/{playlistId}/tracks', {
params: {
path: { playlistId: playlist.id },
query: { pageNumber: 1, pageSize: PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE }
}
})
.then((res) => (res.data?.items ?? []).map(toTrack))
)
);
const items = data.items.map((playlist, i) =>
toPlaylistSummary(playlist, tracksByPlaylist[i].length)
); );
const totals = { const totals = {
playlistCount: items.length, playlistCount: playlists.length,
trackCount: items.reduce((sum, playlist) => sum + playlist.trackCount, 0), trackCount: playlists.reduce((sum, playlist) => sum + playlist.trackCount, 0),
durationSeconds: tracksByPlaylist.flat().reduce((sum, track) => sum + track.duration, 0) durationSeconds: playlists.reduce((sum, playlist) => sum + playlist.durationSeconds, 0)
}; };
return { playlists: items, totals }; return { playlists, totals };
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -1,7 +1,7 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api'; import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
import { toPlaylistSummary } from '$lib/server/mappers'; import { toPlaylist } from '$lib/server/mappers';
import { COUNT_ONLY_PAGE_SIZE, PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config'; import { PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions'; import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
@@ -20,24 +20,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
]); ]);
const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404); const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404);
const playlistItems = playlistsRes.data?.items ?? []; const playlists = (playlistsRes.data?.items ?? []).map(toPlaylist);
const tracksByPlaylist = await Promise.all(
playlistItems.map((playlist) =>
api
.GET('/playlists/{playlistId}/tracks', {
params: {
path: { playlistId: playlist.id },
query: { pageNumber: 1, pageSize: COUNT_ONLY_PAGE_SIZE }
}
})
.then((res) => Number(res.data?.totalItemCount ?? 0))
)
);
const playlists = playlistItems.map((playlist, i) =>
toPlaylistSummary(playlist, tracksByPlaylist[i])
);
const isOwnProfile = viewer !== null && viewer.sub === params.id; const isOwnProfile = viewer !== null && viewer.sub === params.id;
+1 -1
View File
@@ -155,7 +155,7 @@
{@const meta = {@const meta =
totalCount === 0 totalCount === 0
? visibilityLabel ? visibilityLabel
: `${plural(totalCount, 'canción', 'canciones')} · ${visibilityLabel}`} : `${plural(totalCount, 'canción', 'canciones')} · ${fmtDurationLong(spotlight.durationSeconds)} · ${visibilityLabel}`}
<div <div
class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2" class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
> >