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