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:
Vendored
+24
-62
@@ -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: {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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.'));
|
||||
};
|
||||
Reference in New Issue
Block a user