Combine track search with live YouTube Music results

GET /tracks now merges the local catalog page with a live YouTube Music
search (deduplicated against tracks already provisioned locally) whenever a
name filter is given, appending the YouTube hits after the local matches and
carrying a continuation token for further pages. Each item is tagged with its
source via the new TrackSearchItemResponse/TracksSearchResponse shapes,
wrapping the existing TrackApplicationResponse or a raw YouTubeSongResult so
neither type had to change.

GET /youtube/search is removed along with its now-unused handler; the
web-player's explore page, its /api/tracks proxy, and the YouTube filler
helper are updated to call the combined endpoint instead of hitting the two
separately.
This commit is contained in:
2026-09-10 09:55:58 +02:00
parent c418844178
commit 92a06a3c15
15 changed files with 266 additions and 240 deletions
+24 -62
View File
@@ -323,7 +323,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Get Paginated Tracks. */
/** Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given. */
get: operations["GetTracks"];
put?: never;
/** Create A New Track. */
@@ -488,23 +488,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/youtube/search": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Search Songs On YouTube Music. */
get: operations["SearchYouTubeTracks"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/youtube/albums": {
parameters: {
query?: never;
@@ -759,6 +742,11 @@ export interface components {
/** Format: date-time */
updatedAt: string;
};
TrackSearchItemResponse: {
source: components["schemas"]["TrackSource"];
track: components["schemas"]["TrackApplicationResponse"] | null;
youTubeSong: components["schemas"]["YouTubeSongResult"] | null;
};
/** @enum {unknown} */
TrackSource: "Local" | "YouTube";
TrackStreamResponse: {
@@ -784,6 +772,22 @@ 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;
@@ -843,10 +847,6 @@ export interface components {
id: null | string;
name: string;
};
YouTubeSearchResult: {
items: components["schemas"]["YouTubeSongResult"][];
continuationToken: string;
};
YouTubeSongResult: {
videoId: string;
title: string;
@@ -1749,6 +1749,7 @@ export interface operations {
name?: string;
pageNumber?: number | string;
pageSize?: number | string;
youtubeContinuationToken?: string;
};
header?: never;
path?: never;
@@ -1762,7 +1763,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"];
"application/json": components["schemas"]["TracksSearchResponse"];
};
};
};
@@ -2158,45 +2159,6 @@ export interface operations {
};
};
};
SearchYouTubeTracks: {
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"]["YouTubeSearchResult"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SearchYouTubeAlbums: {
parameters: {
query: {
+6 -3
View File
@@ -53,10 +53,13 @@ export async function fetchYoutubeFiller(
const query =
seedQuery?.trim() ||
(userId ? pickFillerQueryForUser(userId, new Date()) : pickRandomFillerQuery());
const { data } = await api.GET('/youtube/search', { params: { query: { query } } });
const { data } = await api.GET('/tracks', { params: { query: { name: query, pageSize: 1 } } });
const items = (data?.items ?? [])
.map((item) => item.youTubeSong)
.filter((song): song is NonNullable<typeof song> => song !== null && song !== undefined);
return {
query,
items: ((data?.items ?? []) as YouTubeSong[]).slice(0, limit),
continuationToken: data?.continuationToken ?? ''
items: (items as YouTubeSong[]).slice(0, limit),
continuationToken: data?.nextYoutubeContinuationToken ?? ''
};
}
@@ -26,11 +26,6 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } }
});
const searchPromise =
query.length >= SEARCH_MIN_LENGTH
? api.GET('/youtube/search', { params: { query: { query } } })
: Promise.resolve(null);
const playlistsPromise = api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
});
@@ -46,9 +41,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
? api.GET('/youtube/albums', { params: { query: { query } } })
: Promise.resolve(null);
const [tracksRes, searchRes, playlistsRes, albumsRes, youtubeAlbumsRes] = await Promise.all([
const [tracksRes, playlistsRes, albumsRes, youtubeAlbumsRes] = await Promise.all([
tracksPromise,
searchPromise,
playlistsPromise,
albumsPromise,
youtubeAlbumsPromise
@@ -61,8 +55,6 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
tracks,
albums: albumsRes?.data?.items ?? [],
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
ytResults: searchRes?.data ?? null,
ytError: Boolean(searchRes?.error),
users: searchUsers(query),
youtubeFiller: query
? Promise.resolve(null)
@@ -48,42 +48,43 @@
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let ytItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state('');
let ytSearchTerm = $state('');
let ytUnavailable = $state(false);
let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null);
type LocalTrack = (typeof data.tracks.items)[number];
type LocalTrack = NonNullable<(typeof data.tracks.items)[number]['track']>;
let localItems = $state<LocalTrack[]>([]);
let localPage = $state(1);
let localHasNext = $state(false);
let ytContinuation = $state('');
let hasMore = $state(false);
let loadingMore = $state(false);
$effect(() => {
contextMenu = null;
albumMenu = null;
localItems = data.tracks.items;
localItems = data.tracks.items.flatMap((item) => (item.track ? [item.track] : []));
localPage = Number(data.tracks.pageNumber);
localHasNext = Boolean(data.tracks.hasNextPage);
ytContinuation = data.tracks.nextYoutubeContinuationToken ?? '';
hasMore = data.tracks.hasNextPage;
if (data.query) {
ytItems = appendUnique([], (data.ytResults?.items ?? []) as YouTubeSong[], (i) => i.videoId);
ytContinuation = data.ytResults?.continuationToken ?? '';
ytSearchTerm = data.query;
ytItems = appendUnique(
[],
data.tracks.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])),
(i) => i.videoId
);
ytUnavailable = data.tracks.youtubeUnavailable;
return;
}
ytItems = [];
ytContinuation = '';
ytSearchTerm = '';
ytUnavailable = false;
let cancelled = false;
data.youtubeFiller?.then((filler) => {
if (cancelled || !filler) return;
ytItems = appendUnique([], filler.items, (i) => i.videoId);
ytContinuation = filler.continuationToken;
ytSearchTerm = filler.query;
});
return () => {
cancelled = true;
@@ -103,8 +104,6 @@
const items = $derived<TrackTarget[]>(songRows.map((row) => row.target));
const hasMore = $derived(localHasNext || ytContinuation !== '');
const albums = $derived(data.albums);
const youtubeAlbums = $derived(data.youtubeAlbums);
const users = $derived(data.users);
@@ -124,7 +123,7 @@
const hasUsers = $derived(users.length > 0);
const nothingFound = $derived(
!!data.query && !data.ytError && items.length === 0 && !hasAlbums && !hasUsers
!!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers
);
function localAlbumCaption(album: LocalAlbum) {
@@ -188,38 +187,36 @@
contextMenu = null;
}
async function loadMoreLocal() {
const params = new URLSearchParams({
pageNumber: String(localPage + 1),
pageSize: String(EXPLORE_PAGE_SIZE)
});
if (data.query) params.set('name', data.query);
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks;
localItems = appendUnique(localItems, next.items, (track) => track.id);
localPage = Number(next.pageNumber);
localHasNext = Boolean(next.hasNextPage);
}
async function loadMoreYouTube() {
const params = new URLSearchParams({ query: ytSearchTerm, continuation: ytContinuation });
const res = await fetch(`/api/youtube/search?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
ytItems = appendUnique(ytItems, next.items, (i) => i.videoId);
ytContinuation = next.continuationToken;
}
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
try {
if (localHasNext) await loadMoreLocal();
else if (ytContinuation && ytSearchTerm) await loadMoreYouTube();
const params = new URLSearchParams({
pageNumber: String(localPage + 1),
pageSize: String(EXPLORE_PAGE_SIZE)
});
if (data.query) {
params.set('name', data.query);
if (ytContinuation) params.set('youtubeContinuationToken', ytContinuation);
}
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks;
localItems = appendUnique(
localItems,
next.items.flatMap((item) => (item.track ? [item.track] : [])),
(track) => track.id
);
ytItems = appendUnique(
ytItems,
next.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])),
(song) => song.videoId
);
localPage = Number(next.pageNumber);
ytContinuation = next.nextYoutubeContinuationToken ?? '';
hasMore = next.hasNextPage;
} catch {
if (localHasNext) localHasNext = false;
else ytContinuation = '';
hasMore = false;
} finally {
loadingMore = false;
}
@@ -377,12 +374,12 @@
{#if hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if}
{:else if data.query && !data.ytError}
{:else if data.query && !ytUnavailable}
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
{/if}
</div>
{#if data.ytError}
{#if ytUnavailable}
<EmptyState
icon={Music}
description="YouTube Music no está disponible ahora mismo. Inténtalo de nuevo."
+1 -1
View File
@@ -44,7 +44,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
recentlyPlayedPromise
]);
const playlistItems = playlists.data?.items ?? [];
const latestItems = latest.data?.items ?? [];
const latestItems = (latest.data?.items ?? []).flatMap((item) => (item.track ? [item.track] : []));
return {
greeting: pickGreeting(new Date().getHours()),
+4 -1
View File
@@ -7,9 +7,12 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const name = url.searchParams.get('name')?.trim() || undefined;
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE);
const youtubeContinuationToken = url.searchParams.get('youtubeContinuationToken') || undefined;
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/tracks', { params: { query: { name, pageNumber, pageSize } } });
const result = await api.GET('/tracks', {
params: { query: { name, pageNumber, pageSize, youtubeContinuationToken } }
});
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));
};
@@ -1,21 +0,0 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const accessToken = requireAccessToken(locals, 'Inicia sesión para buscar.');
const query = url.searchParams.get('query')?.trim() ?? '';
if (!query) {
error(400, 'Falta el término de búsqueda.');
}
const continuation = url.searchParams.get('continuation') ?? undefined;
const api = createApiClient({ fetch, accessToken });
const result = await api.GET('/youtube/search', {
params: { query: { query, continuation } }
});
return json(unwrapOrError(result, 'No se pudo buscar en YouTube Music.'));
};