From 92a06a3c153545c4b55d6dece37a021f91ddeceb Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 10 Sep 2026 09:55:58 +0200 Subject: [PATCH] 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. --- .../Musify.Application/Tracks/GetTracks.cs | 79 ++++++++++++++--- .../Responses/TrackSearchItemResponse.cs | 22 +++++ .../Tracks/Responses/TracksSearchResponse.cs | 22 +++++ .../YouTube/SearchYouTubeTracks.cs | 21 ----- .../Musify.Api/Endpoints/TrackEndpoints.cs | 9 +- .../Musify.Api/Endpoints/YouTubeEndpoints.cs | 21 ----- .../Tracks/GetTracksQueryHandlerTests.cs | 75 ++++++++++++++-- .../SearchYouTubeTracksQueryHandlerTests.cs | 37 -------- web-player/src/lib/api/schema.d.ts | 86 +++++------------- web-player/src/lib/server/youtube.ts | 9 +- .../src/routes/(app)/explore/+page.server.ts | 10 +-- .../src/routes/(app)/explore/+page.svelte | 87 +++++++++---------- web-player/src/routes/+page.server.ts | 2 +- web-player/src/routes/api/tracks/+server.ts | 5 +- .../src/routes/api/youtube/search/+server.ts | 21 ----- 15 files changed, 266 insertions(+), 240 deletions(-) create mode 100644 backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs create mode 100644 backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs delete mode 100644 backend/Core/Musify.Application/YouTube/SearchYouTubeTracks.cs delete mode 100644 backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs delete mode 100644 web-player/src/routes/api/youtube/search/+server.ts diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 6363ab8..3d8c2e8 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -1,22 +1,31 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; -using Musify.Application.Shared; +using Microsoft.Extensions.Logging; using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; using Musify.Application.Contracts; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { - public record GetTracksQuery(string? Name, int PageNumber, int PageSize) - : IQuery >>; + public record GetTracksQuery(string? Name, int PageNumber, int PageSize, string? YoutubeContinuationToken = null) + : IQuery>; - public class GetTracksQueryHandler(IDatabase database) - : IQueryHandler> > + public class GetTracksQueryHandler( + IDatabase database, + IYouTubeMusicService youTubeMusicService, + ILogger logger) + : IQueryHandler> { - public async ValueTask >> Handle(GetTracksQuery request, CancellationToken cancellationToken) + // Mirrors the web client's search-box debounce threshold: a query shorter than this is too + // likely to still be mid-keystroke to justify a live call to the YouTube Music API for every + // client that hits this endpoint. + private const int MinNameLengthForYouTubeSearch = 2; + + public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) { var tracksQuery = database.UserHasTracks .AsNoTracking() @@ -39,11 +48,61 @@ namespace Musify.Application.Tracks .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + var localItems = pagedEntities + .Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount))) + .ToList(); - return PaginatedResponse.FromPagedList(pagedTracks); + var knownVideoIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in pagedEntities) + { + if (entry.Track is ExternalTrack { Source: TrackSource.YouTube } external) + knownVideoIds.Add(external.ExternalId); + } + + var youtubeItems = new List(); + string? nextContinuationToken = null; + var youtubeUnavailable = false; + + if (request.Name?.Trim().Length >= MinNameLengthForYouTubeSearch) + (youtubeItems, nextContinuationToken, youtubeUnavailable) = + await SearchYouTubeAsync(request.Name, request.YoutubeContinuationToken, knownVideoIds, cancellationToken); + + return new TracksSearchResponse( + [.. localItems, .. youtubeItems], + pagedEntities.PageNumber, + pagedEntities.PageSize, + pagedEntities.PageCount, + pagedEntities.TotalItemCount, + pagedEntities.HasPreviousPage, + pagedEntities.HasNextPage, + nextContinuationToken, + youtubeUnavailable); + } + + private async Task<(List Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync( + string name, + string? continuationToken, + HashSet knownVideoIds, + CancellationToken cancellationToken) + { + var searchResult = await youTubeMusicService.SearchSongsAsync(name, continuationToken ?? string.Empty, cancellationToken); + if (searchResult.IsError) + { + logger.LogInformation( + "YouTube search unavailable while combining track results: {Error}", searchResult.FirstError.Description); + return ([], null, true); + } + + var items = searchResult.Value.Items + .Where(song => knownVideoIds.Add(song.VideoId)) + .Select(TrackSearchItemResponse.FromYouTubeSong) + .ToList(); + + var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken) + ? null + : searchResult.Value.ContinuationToken; + + return (items, nextToken, false); } } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs new file mode 100644 index 0000000..9599877 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs @@ -0,0 +1,22 @@ +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Responses +{ + /// One item of a combined local/YouTube track listing. Exactly one of + /// or is populated: for + /// anything already in the catalog (local uploads and YouTube tracks provisioned earlier), + /// for a live YouTube Music search hit that has not been + /// provisioned yet, so it has no track id. + public record TrackSearchItemResponse( + TrackSource Source, + TrackApplicationResponse? Track, + YouTubeSongResult? YouTubeSong) + { + public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => + new(track.Source, track, null); + + public static TrackSearchItemResponse FromYouTubeSong(YouTubeSongResult song) => + new(TrackSource.YouTube, null, song); + } +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs new file mode 100644 index 0000000..d09aa56 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs @@ -0,0 +1,22 @@ +namespace Musify.Application.Tracks.Responses +{ + /// Result of a combined tracks listing/search: a page of local catalog tracks + /// (uploads and previously provisioned YouTube tracks), plus, when a search term was given, + /// live YouTube Music hits appended after them. Pagination metadata (, + /// , , ) describes only + /// the local page — YouTube has no stable total, so its continuation is tracked separately via + /// . + public record TracksSearchResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasPreviousPage, + bool HasNextLocalPage, + string? NextYoutubeContinuationToken, + bool YoutubeUnavailable) + { + public bool HasNextPage => HasNextLocalPage || !string.IsNullOrEmpty(NextYoutubeContinuationToken); + } +} diff --git a/backend/Core/Musify.Application/YouTube/SearchYouTubeTracks.cs b/backend/Core/Musify.Application/YouTube/SearchYouTubeTracks.cs deleted file mode 100644 index 3d09350..0000000 --- a/backend/Core/Musify.Application/YouTube/SearchYouTubeTracks.cs +++ /dev/null @@ -1,21 +0,0 @@ -using ErrorOr; -using Mediator; -using Musify.Application.Contracts; - -namespace Musify.Application.YouTube -{ - public record SearchYouTubeTracksQuery(string Query, string ContinuationToken) - : IQuery>; - - public class SearchYouTubeTracksQueryHandler(IYouTubeMusicService youTubeMusicService) - : IQueryHandler> - { - public async ValueTask> Handle(SearchYouTubeTracksQuery request, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(request.Query)) - return Error.Validation(description: "The search query must not be empty."); - - return await youTubeMusicService.SearchSongsAsync(request.Query, request.ContinuationToken, cancellationToken); - } - } -} diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 8ebf318..25ef92e 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -19,8 +19,8 @@ public static class TrackEndpoints group.MapGet("/", GetTracks) .WithName("GetTracks") - .WithSummary("Get Paginated Tracks.") - .Produces>(); + .WithSummary("Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given.") + .Produces(); group.MapGet("/{id}", GetTrackById) .WithName("GetTrackById") @@ -86,9 +86,10 @@ public static class TrackEndpoints CancellationToken cancellationToken, string? name, int pageNumber = 1, - int pageSize = 10) + int pageSize = 10, + string? youtubeContinuationToken = null) { - var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, youtubeContinuationToken), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs index 24f4098..cf4b901 100644 --- a/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs @@ -14,14 +14,6 @@ public static class YouTubeEndpoints var group = app.MapGroup("/youtube") .WithTags("YouTube"); - group.MapGet("/search", SearchTracks) - .WithName("SearchYouTubeTracks") - .WithSummary("Search Songs On YouTube Music.") - .RequireAuthorization() - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .ProducesValidationProblem(); - group.MapGet("/albums", SearchAlbums) .WithName("SearchYouTubeAlbums") .WithSummary("Search Albums On YouTube Music.") @@ -83,17 +75,4 @@ public static class YouTubeEndpoints var result = await mediator.Send(new GetYouTubeAlbumQuery(albumId), cancellationToken); return result.ToHttpResult(); } - - private static async Task SearchTracks( - IMediator mediator, - CancellationToken cancellationToken, - string query, - string? continuation = null) - { - var result = await mediator.Send( - new SearchYouTubeTracksQuery(query, continuation ?? string.Empty), - cancellationToken); - - return result.ToHttpResult(); - } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index 4867871..df94c4f 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -1,15 +1,22 @@ +using ErrorOr; +using Musify.Application.Contracts; using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; +using Musify.Domain.ValueObjects; +using NSubstitute; using Xunit; namespace Musify.Application.Tests.Tracks { public sealed class GetTracksQueryHandlerTests : HandlerTestBase { - private GetTracksQueryHandler CreateHandler() => new(Database); + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); + + private GetTracksQueryHandler CreateHandler() => + new(Database, youTubeMusicService, NoOpLogger()); [Fact] - public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns() + public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns_AndDoesNotCallYouTube() { var owner = TestEntities.User(); var trackA = TestEntities.LocalTrack(owner, "Alpha"); @@ -23,10 +30,14 @@ namespace Musify.Application.Tests.Tracks Assert.False(result.IsError); Assert.Equal(2, result.Value.TotalItemCount); + Assert.Equal(2, result.Value.Items.Count); + Assert.All(result.Value.Items, item => Assert.NotNull(item.Track)); + await youTubeMusicService.DidNotReceiveWithAnyArgs() + .SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken); } [Fact] - public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks() + public async Task Handle_NameFilter_ReturnsMatchingLocalTracks_FollowedByYouTubeResults() { var owner = TestEntities.User(); var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody"); @@ -36,11 +47,65 @@ namespace Musify.Application.Tests.Tracks new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id }, new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id }); + var song = new YouTubeSongResult("video-1", "Bohemian Rhapsody (Live)", "Queen", "A Night at the Opera", + 354, "thumb.jpg", false, []); + youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any()) + .Returns(new YouTubeSearchResult([song], "next-token")); + var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); Assert.False(result.IsError); - var track = Assert.Single(result.Value.Items); - Assert.Equal("Bohemian Rhapsody", track.Title); + Assert.Equal(2, result.Value.Items.Count); + Assert.NotNull(result.Value.Items[0].Track); + Assert.Equal("Bohemian Rhapsody", result.Value.Items[0].Track!.Title); + Assert.Null(result.Value.Items[1].Track); + Assert.NotNull(result.Value.Items[1].YouTubeSong); + Assert.Equal("video-1", result.Value.Items[1].YouTubeSong!.VideoId); + Assert.Equal("next-token", result.Value.NextYoutubeContinuationToken); + Assert.False(result.Value.YoutubeUnavailable); + } + + [Fact] + public async Task Handle_NameFilter_SkipsYouTubeResultAlreadyProvisionedLocally() + { + var owner = TestEntities.User(); + var provisioned = TestEntities.ExternalTrack(externalId: "video-1", title: "Bohemian Rhapsody", source: TrackSource.YouTube); + await SeedAsync( + owner, provisioned, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = provisioned.Id }); + + var song = new YouTubeSongResult("video-1", "Bohemian Rhapsody", "Queen", "A Night at the Opera", + 354, "thumb.jpg", false, []); + youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any()) + .Returns(new YouTubeSearchResult([song], string.Empty)); + + var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.NotNull(item.Track); + Assert.Null(item.YouTubeSong); + } + + [Fact] + public async Task Handle_NameFilter_YouTubeUnavailable_StillReturnsLocalResults() + { + var owner = TestEntities.User(); + var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody"); + await SeedAsync( + owner, match, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id }); + + youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any()) + .Returns(Error.Forbidden(description: "The YouTube Music source is disabled.")); + + var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.NotNull(item.Track); + Assert.True(result.Value.YoutubeUnavailable); + Assert.Null(result.Value.NextYoutubeContinuationToken); } } } diff --git a/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs deleted file mode 100644 index 73c4495..0000000 --- a/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ErrorOr; -using Musify.Application.Contracts; -using Musify.Application.YouTube; -using NSubstitute; -using Xunit; - -namespace Musify.Application.Tests.YouTube -{ - public sealed class SearchYouTubeTracksQueryHandlerTests - { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private SearchYouTubeTracksQueryHandler CreateHandler() => new(youTubeMusicService); - - [Fact] - public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService() - { - var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - await youTubeMusicService.DidNotReceiveWithAnyArgs() - .SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Handle_NonEmptyQuery_DelegatesToTheService() - { - var expected = new YouTubeSearchResult([], "next-token"); - youTubeMusicService.SearchSongsAsync("query", "token", Arg.Any()).Returns(expected); - - var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery("query", "token"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Same(expected, result.Value); - } - } -} diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 6d73f48..d695371 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -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: { diff --git a/web-player/src/lib/server/youtube.ts b/web-player/src/lib/server/youtube.ts index 528d316..58c801b 100644 --- a/web-player/src/lib/server/youtube.ts +++ b/web-player/src/lib/server/youtube.ts @@ -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 => 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 ?? '' }; } diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 186173b..f80e101 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -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) diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index c7afb55..fa253a6 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -48,42 +48,43 @@ let searchTimeout: ReturnType | undefined; let ytItems = $state([]); - let ytContinuation = $state(''); - let ytSearchTerm = $state(''); + let ytUnavailable = $state(false); let contextMenu = $state(null); let albumMenu = $state(null); - type LocalTrack = (typeof data.tracks.items)[number]; + type LocalTrack = NonNullable<(typeof data.tracks.items)[number]['track']>; let localItems = $state([]); 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(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} {/if} - {:else if data.query && !data.ytError} + {:else if data.query && !ytUnavailable} {/if} - {#if data.ytError} + {#if ytUnavailable} { 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()), diff --git a/web-player/src/routes/api/tracks/+server.ts b/web-player/src/routes/api/tracks/+server.ts index e84cff5..3345cd8 100644 --- a/web-player/src/routes/api/tracks/+server.ts +++ b/web-player/src/routes/api/tracks/+server.ts @@ -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.')); }; diff --git a/web-player/src/routes/api/youtube/search/+server.ts b/web-player/src/routes/api/youtube/search/+server.ts deleted file mode 100644 index ce40165..0000000 --- a/web-player/src/routes/api/youtube/search/+server.ts +++ /dev/null @@ -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.')); -};