Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
15 changed files with 266 additions and 240 deletions
Showing only changes of commit 92a06a3c15 - Show all commits
@@ -1,22 +1,31 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Shared; using Microsoft.Extensions.Logging;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
public record GetTracksQuery(string? Name, int PageNumber, int PageSize) public record GetTracksQuery(string? Name, int PageNumber, int PageSize, string? YoutubeContinuationToken = null)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse> >>; : IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database) public class GetTracksQueryHandler(
: IQueryHandler<GetTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>> > IDatabase database,
IYouTubeMusicService youTubeMusicService,
ILogger<GetTracksQueryHandler> logger)
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse> >> 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<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
{ {
var tracksQuery = database.UserHasTracks var tracksQuery = database.UserHasTracks
.AsNoTracking() .AsNoTracking()
@@ -39,11 +48,61 @@ namespace Musify.Application.Tracks
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var localItems = pagedEntities
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), .Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); .ToList();
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks); var knownVideoIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in pagedEntities)
{
if (entry.Track is ExternalTrack { Source: TrackSource.YouTube } external)
knownVideoIds.Add(external.ExternalId);
}
var youtubeItems = new List<TrackSearchItemResponse>();
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<TrackSearchItemResponse> Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync(
string name,
string? continuationToken,
HashSet<string> 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);
} }
} }
} }
@@ -0,0 +1,22 @@
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses
{
/// <summary>One item of a combined local/YouTube track listing. Exactly one of
/// <see cref="Track"/> or <see cref="YouTubeSong"/> is populated: <see cref="Track"/> for
/// anything already in the catalog (local uploads and YouTube tracks provisioned earlier),
/// <see cref="YouTubeSong"/> for a live YouTube Music search hit that has not been
/// provisioned yet, so it has no track id.</summary>
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);
}
}
@@ -0,0 +1,22 @@
namespace Musify.Application.Tracks.Responses
{
/// <summary>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 (<see cref="PageNumber"/>,
/// <see cref="PageSize"/>, <see cref="PageCount"/>, <see cref="TotalItemCount"/>) describes only
/// the local page — YouTube has no stable total, so its continuation is tracked separately via
/// <see cref="NextYoutubeContinuationToken"/>.</summary>
public record TracksSearchResponse(
IReadOnlyList<TrackSearchItemResponse> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextLocalPage,
string? NextYoutubeContinuationToken,
bool YoutubeUnavailable)
{
public bool HasNextPage => HasNextLocalPage || !string.IsNullOrEmpty(NextYoutubeContinuationToken);
}
}
@@ -1,21 +0,0 @@
using ErrorOr;
using Mediator;
using Musify.Application.Contracts;
namespace Musify.Application.YouTube
{
public record SearchYouTubeTracksQuery(string Query, string ContinuationToken)
: IQuery<ErrorOr<YouTubeSearchResult>>;
public class SearchYouTubeTracksQueryHandler(IYouTubeMusicService youTubeMusicService)
: IQueryHandler<SearchYouTubeTracksQuery, ErrorOr<YouTubeSearchResult>>
{
public async ValueTask<ErrorOr<YouTubeSearchResult>> 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);
}
}
}
@@ -19,8 +19,8 @@ public static class TrackEndpoints
group.MapGet("/", GetTracks) group.MapGet("/", GetTracks)
.WithName("GetTracks") .WithName("GetTracks")
.WithSummary("Get Paginated Tracks.") .WithSummary("Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given.")
.Produces<PaginatedResponse<TrackApplicationResponse>>(); .Produces<TracksSearchResponse>();
group.MapGet("/{id}", GetTrackById) group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById") .WithName("GetTrackById")
@@ -86,9 +86,10 @@ public static class TrackEndpoints
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? name, string? name,
int pageNumber = 1, 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(); return result.ToHttpResult();
} }
@@ -14,14 +14,6 @@ public static class YouTubeEndpoints
var group = app.MapGroup("/youtube") var group = app.MapGroup("/youtube")
.WithTags("YouTube"); .WithTags("YouTube");
group.MapGet("/search", SearchTracks)
.WithName("SearchYouTubeTracks")
.WithSummary("Search Songs On YouTube Music.")
.RequireAuthorization()
.Produces<YouTubeSearchResult>()
.Produces(StatusCodes.Status401Unauthorized)
.ProducesValidationProblem();
group.MapGet("/albums", SearchAlbums) group.MapGet("/albums", SearchAlbums)
.WithName("SearchYouTubeAlbums") .WithName("SearchYouTubeAlbums")
.WithSummary("Search Albums On YouTube Music.") .WithSummary("Search Albums On YouTube Music.")
@@ -83,17 +75,4 @@ public static class YouTubeEndpoints
var result = await mediator.Send(new GetYouTubeAlbumQuery(albumId), cancellationToken); var result = await mediator.Send(new GetYouTubeAlbumQuery(albumId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> 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();
}
} }
@@ -1,15 +1,22 @@
using ErrorOr;
using Musify.Application.Contracts;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks namespace Musify.Application.Tests.Tracks
{ {
public sealed class GetTracksQueryHandlerTests : HandlerTestBase public sealed class GetTracksQueryHandlerTests : HandlerTestBase
{ {
private GetTracksQueryHandler CreateHandler() => new(Database); private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private GetTracksQueryHandler CreateHandler() =>
new(Database, youTubeMusicService, NoOpLogger<GetTracksQueryHandler>());
[Fact] [Fact]
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns() public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns_AndDoesNotCallYouTube()
{ {
var owner = TestEntities.User(); var owner = TestEntities.User();
var trackA = TestEntities.LocalTrack(owner, "Alpha"); var trackA = TestEntities.LocalTrack(owner, "Alpha");
@@ -23,10 +30,14 @@ namespace Musify.Application.Tests.Tracks
Assert.False(result.IsError); Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount); 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] [Fact]
public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks() public async Task Handle_NameFilter_ReturnsMatchingLocalTracks_FollowedByYouTubeResults()
{ {
var owner = TestEntities.User(); var owner = TestEntities.User();
var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody"); 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 = match.Id },
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.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<CancellationToken>())
.Returns(new YouTubeSearchResult([song], "next-token"));
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError); Assert.False(result.IsError);
var track = Assert.Single(result.Value.Items); Assert.Equal(2, result.Value.Items.Count);
Assert.Equal("Bohemian Rhapsody", track.Title); 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<CancellationToken>())
.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<CancellationToken>())
.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);
} }
} }
} }
@@ -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<IYouTubeMusicService>();
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<CancellationToken>()).Returns(expected);
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery("query", "token"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Same(expected, result.Value);
}
}
}
+24 -62
View File
@@ -323,7 +323,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Get Paginated Tracks. */ /** Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given. */
get: operations["GetTracks"]; get: operations["GetTracks"];
put?: never; put?: never;
/** Create A New Track. */ /** Create A New Track. */
@@ -488,23 +488,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/youtube/albums": {
parameters: { parameters: {
query?: never; query?: never;
@@ -759,6 +742,11 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
updatedAt: string; updatedAt: string;
}; };
TrackSearchItemResponse: {
source: components["schemas"]["TrackSource"];
track: components["schemas"]["TrackApplicationResponse"] | null;
youTubeSong: components["schemas"]["YouTubeSongResult"] | null;
};
/** @enum {unknown} */ /** @enum {unknown} */
TrackSource: "Local" | "YouTube"; TrackSource: "Local" | "YouTube";
TrackStreamResponse: { TrackStreamResponse: {
@@ -784,6 +772,22 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
expiresInSeconds: number | string; 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: { UpdateAlbumRequest: {
newTitle: string; newTitle: string;
newDescription: null | string; newDescription: null | string;
@@ -843,10 +847,6 @@ export interface components {
id: null | string; id: null | string;
name: string; name: string;
}; };
YouTubeSearchResult: {
items: components["schemas"]["YouTubeSongResult"][];
continuationToken: string;
};
YouTubeSongResult: { YouTubeSongResult: {
videoId: string; videoId: string;
title: string; title: string;
@@ -1749,6 +1749,7 @@ export interface operations {
name?: string; name?: string;
pageNumber?: number | string; pageNumber?: number | string;
pageSize?: number | string; pageSize?: number | string;
youtubeContinuationToken?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -1762,7 +1763,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { 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: { SearchYouTubeAlbums: {
parameters: { parameters: {
query: { query: {
+6 -3
View File
@@ -53,10 +53,13 @@ export async function fetchYoutubeFiller(
const query = const query =
seedQuery?.trim() || seedQuery?.trim() ||
(userId ? pickFillerQueryForUser(userId, new Date()) : pickRandomFillerQuery()); (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 { return {
query, query,
items: ((data?.items ?? []) as YouTubeSong[]).slice(0, limit), items: (items as YouTubeSong[]).slice(0, limit),
continuationToken: data?.continuationToken ?? '' 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 } } 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}', { const playlistsPromise = api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } } 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 } } }) ? api.GET('/youtube/albums', { params: { query: { query } } })
: Promise.resolve(null); : Promise.resolve(null);
const [tracksRes, searchRes, playlistsRes, albumsRes, youtubeAlbumsRes] = await Promise.all([ const [tracksRes, playlistsRes, albumsRes, youtubeAlbumsRes] = await Promise.all([
tracksPromise, tracksPromise,
searchPromise,
playlistsPromise, playlistsPromise,
albumsPromise, albumsPromise,
youtubeAlbumsPromise youtubeAlbumsPromise
@@ -61,8 +55,6 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
tracks, tracks,
albums: albumsRes?.data?.items ?? [], albums: albumsRes?.data?.items ?? [],
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [], youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
ytResults: searchRes?.data ?? null,
ytError: Boolean(searchRes?.error),
users: searchUsers(query), users: searchUsers(query),
youtubeFiller: query youtubeFiller: query
? Promise.resolve(null) ? Promise.resolve(null)
@@ -48,42 +48,43 @@
let searchTimeout: ReturnType<typeof setTimeout> | undefined; let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let ytItems = $state<YouTubeSong[]>([]); let ytItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state(''); let ytUnavailable = $state(false);
let ytSearchTerm = $state('');
let contextMenu = $state<ContextMenuState | null>(null); let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | 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 localItems = $state<LocalTrack[]>([]);
let localPage = $state(1); let localPage = $state(1);
let localHasNext = $state(false); let ytContinuation = $state('');
let hasMore = $state(false);
let loadingMore = $state(false); let loadingMore = $state(false);
$effect(() => { $effect(() => {
contextMenu = null; contextMenu = null;
albumMenu = null; albumMenu = null;
localItems = data.tracks.items; localItems = data.tracks.items.flatMap((item) => (item.track ? [item.track] : []));
localPage = Number(data.tracks.pageNumber); localPage = Number(data.tracks.pageNumber);
localHasNext = Boolean(data.tracks.hasNextPage); ytContinuation = data.tracks.nextYoutubeContinuationToken ?? '';
hasMore = data.tracks.hasNextPage;
if (data.query) { if (data.query) {
ytItems = appendUnique([], (data.ytResults?.items ?? []) as YouTubeSong[], (i) => i.videoId); ytItems = appendUnique(
ytContinuation = data.ytResults?.continuationToken ?? ''; [],
ytSearchTerm = data.query; data.tracks.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])),
(i) => i.videoId
);
ytUnavailable = data.tracks.youtubeUnavailable;
return; return;
} }
ytItems = []; ytItems = [];
ytContinuation = ''; ytUnavailable = false;
ytSearchTerm = '';
let cancelled = false; let cancelled = false;
data.youtubeFiller?.then((filler) => { data.youtubeFiller?.then((filler) => {
if (cancelled || !filler) return; if (cancelled || !filler) return;
ytItems = appendUnique([], filler.items, (i) => i.videoId); ytItems = appendUnique([], filler.items, (i) => i.videoId);
ytContinuation = filler.continuationToken;
ytSearchTerm = filler.query;
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@@ -103,8 +104,6 @@
const items = $derived<TrackTarget[]>(songRows.map((row) => row.target)); const items = $derived<TrackTarget[]>(songRows.map((row) => row.target));
const hasMore = $derived(localHasNext || ytContinuation !== '');
const albums = $derived(data.albums); const albums = $derived(data.albums);
const youtubeAlbums = $derived(data.youtubeAlbums); const youtubeAlbums = $derived(data.youtubeAlbums);
const users = $derived(data.users); const users = $derived(data.users);
@@ -124,7 +123,7 @@
const hasUsers = $derived(users.length > 0); const hasUsers = $derived(users.length > 0);
const nothingFound = $derived( const nothingFound = $derived(
!!data.query && !data.ytError && items.length === 0 && !hasAlbums && !hasUsers !!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers
); );
function localAlbumCaption(album: LocalAlbum) { function localAlbumCaption(album: LocalAlbum) {
@@ -188,38 +187,36 @@
contextMenu = null; 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() { async function loadMore() {
if (loadingMore) return; if (loadingMore) return;
loadingMore = true; loadingMore = true;
try { try {
if (localHasNext) await loadMoreLocal(); const params = new URLSearchParams({
else if (ytContinuation && ytSearchTerm) await loadMoreYouTube(); 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 { } catch {
if (localHasNext) localHasNext = false; hasMore = false;
else ytContinuation = '';
} finally { } finally {
loadingMore = false; loadingMore = false;
} }
@@ -377,12 +374,12 @@
{#if hasMore} {#if hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} /> <InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if} {/if}
{:else if data.query && !data.ytError} {:else if data.query && !ytUnavailable}
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." /> <EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
{/if} {/if}
</div> </div>
{#if data.ytError} {#if ytUnavailable}
<EmptyState <EmptyState
icon={Music} icon={Music}
description="YouTube Music no está disponible ahora mismo. Inténtalo de nuevo." 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 recentlyPlayedPromise
]); ]);
const playlistItems = playlists.data?.items ?? []; const playlistItems = playlists.data?.items ?? [];
const latestItems = latest.data?.items ?? []; const latestItems = (latest.data?.items ?? []).flatMap((item) => (item.track ? [item.track] : []));
return { return {
greeting: pickGreeting(new Date().getHours()), 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 name = url.searchParams.get('name')?.trim() || undefined;
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); 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 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 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.')); 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.'));
};