From 29a7fed6a5e9cd9a5f125f8d7bbd784ac3eab0d4 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 10 Sep 2026 15:03:36 +0200 Subject: [PATCH] Generalize YouTube-backed endpoints behind a source parameter Removes the last YouTube-specific routes (/youtube/*) and merges them into the general track/album/playlist endpoints, keyed by a `source` string validated against TrackSource. This mirrors the earlier track search merge for albums and playlist-track additions: - GetAlbums now merges local albums with live YouTube Music search results, paginated via a youtubeContinuationToken, deduped against already-materialized ExternalAlbum rows. - New GET /albums/external/{source}/{externalId} and GET /tracks/external/{source}/{externalId}/stream replace the YouTube-only equivalents. - POST /playlists/{id}/tracks replaces the separate .../youtube-tracks endpoint, accepting an optional Source/ExternalId pair alongside TrackId. - AlbumSearchItemResponse/AlbumsSearchResponse follow the same discriminated-union + trailing-HasNextPage-field pattern already used for track search, so HasNextPage stays a required field in the generated OpenAPI schema. - Frontend routes renamed accordingly (/albums/youtube/[albumId] -> /albums/external/[source]/[externalId], etc.) with a single /api/thumbnail proxy replacing /api/youtube/thumbnail. Musify.Api.Endpoints.YouTubeEndpoints and the standalone SearchYouTubeAlbums query are removed as a result. --- .../Musify.Application/Albums/GetAlbums.cs | 82 ++++++++++++++++--- .../Responses/AlbumSearchItemResponse.cs | 22 +++++ .../Albums/Responses/AlbumsSearchResponse.cs | 38 +++++++++ .../Musify.Application/Tracks/GetTracks.cs | 2 +- .../Tracks/Responses/TracksSearchResponse.cs | 20 ++++- .../YouTube/SearchYouTubeAlbums.cs | 21 ----- .../PlayLists/AddPlayListTrackRequest.cs | 8 ++ .../PlayLists/AddYouTubeTrackRequest.cs | 3 - .../Musify.Api/Endpoints/AlbumEndpoints.cs | 30 ++++++- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 52 +++++------- .../Musify.Api/Endpoints/TrackEndpoints.cs | 25 ++++++ .../Musify.Api/Endpoints/YouTubeEndpoints.cs | 78 ------------------ backend/Hosts/Musify.Api/Program.cs | 3 +- .../AddPlayListTrackRequestValidator.cs | 27 ++++++ .../AddYouTubeTrackRequestValidator.cs | 14 ---- .../Albums/GetAlbumsQueryHandlerTests.cs | 66 +++++++++++++-- .../SearchYouTubeAlbumsQueryHandlerTests.cs | 37 --------- web-player/src/lib/albums.ts | 2 +- web-player/src/lib/player/player.svelte.ts | 2 +- web-player/src/lib/server/playlistActions.ts | 34 +++----- web-player/src/lib/thumbnails.ts | 2 +- .../[source]/[externalId]}/+page.server.ts | 6 +- .../[source]/[externalId]}/+page.svelte | 0 .../src/routes/(app)/explore/+page.server.ts | 17 ++-- .../src/routes/(app)/explore/+page.svelte | 2 +- web-player/src/routes/+page.svelte | 2 +- .../[source]/[externalId]}/tracks/+server.ts | 6 +- .../api/{youtube => }/thumbnail/+server.ts | 0 .../[source]/[externalId]}/stream/+server.ts | 6 +- 29 files changed, 353 insertions(+), 254 deletions(-) create mode 100644 backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs create mode 100644 backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs delete mode 100644 backend/Core/Musify.Application/YouTube/SearchYouTubeAlbums.cs create mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs delete mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddYouTubeTrackRequest.cs delete mode 100644 backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs create mode 100644 backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs delete mode 100644 backend/Hosts/Musify.Api/Validators/PlayLists/AddYouTubeTrackRequestValidator.cs delete mode 100644 backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs rename web-player/src/routes/(app)/albums/{youtube/[albumId] => external/[source]/[externalId]}/+page.server.ts (86%) rename web-player/src/routes/(app)/albums/{youtube/[albumId] => external/[source]/[externalId]}/+page.svelte (100%) rename web-player/src/routes/api/albums/{youtube/[albumId] => external/[source]/[externalId]}/tracks/+server.ts (59%) rename web-player/src/routes/api/{youtube => }/thumbnail/+server.ts (100%) rename web-player/src/routes/api/{youtube/tracks/[videoId] => tracks/external/[source]/[externalId]}/stream/+server.ts (73%) diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index 0c0b51c..6e594c1 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -1,23 +1,32 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using X.PagedList; using X.PagedList.EF; namespace Musify.Application.Albums { - public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) - : IQuery>>; + public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize, string? YoutubeContinuationToken = null) + : IQuery>; - public class GetAlbumsQueryHandler(IDatabase database) - : IQueryHandler>> + public class GetAlbumsQueryHandler( + IDatabase database, + IYouTubeMusicService youTubeMusicService, + ILogger logger) + : IQueryHandler> { - public async ValueTask>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) + // Mirrors GetTracksQueryHandler's threshold: a title shorter than this is too likely to + // still be mid-keystroke to justify a live call to the external search API. + private const int MinTitleLengthForYouTubeSearch = 2; + + public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) { - var albumsQuery = database.UserAlbums + var albumsQuery = database.Albums .AsNoTracking() .AsQueryable(); @@ -43,11 +52,62 @@ namespace Musify.Application.Albums }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var pagedAlbums = new StaticPagedList( - pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + var localItems = pagedEntities + .Select(entry => AlbumSearchItemResponse.FromAlbum( + AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))) + .ToList(); - return PaginatedResponse.FromPagedList(pagedAlbums); + var knownExternalIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in pagedEntities) + { + if (entry.Album is ExternalAlbum { Source: TrackSource.YouTube } external) + knownExternalIds.Add(external.ExternalId); + } + + var youtubeItems = new List(); + string? nextContinuationToken = null; + var youtubeUnavailable = false; + + if (request.Title?.Trim().Length >= MinTitleLengthForYouTubeSearch) + (youtubeItems, nextContinuationToken, youtubeUnavailable) = + await SearchYouTubeAsync(request.Title, request.YoutubeContinuationToken, knownExternalIds, cancellationToken); + + return AlbumsSearchResponse.Create( + [.. 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 title, + string? continuationToken, + HashSet knownExternalIds, + CancellationToken cancellationToken) + { + var searchResult = await youTubeMusicService.SearchAlbumsAsync(title, continuationToken ?? string.Empty, cancellationToken); + if (searchResult.IsError) + { + logger.LogInformation( + "YouTube album search unavailable while combining album results: {Error}", searchResult.FirstError.Description); + return ([], null, true); + } + + var items = searchResult.Value.Items + .Where(album => knownExternalIds.Add(album.AlbumId)) + .Select(AlbumSearchItemResponse.FromYouTubeAlbum) + .ToList(); + + var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken) + ? null + : searchResult.Value.ContinuationToken; + + return (items, nextToken, false); } } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs new file mode 100644 index 0000000..5789edd --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs @@ -0,0 +1,22 @@ +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Albums.Responses +{ + /// One item of a combined local/external album listing. Exactly one of + /// or is populated: for + /// anything already in the catalog (locally created albums and external albums materialized + /// earlier), for a live external search hit that hasn't been + /// materialized yet, so it has no album id. + public record AlbumSearchItemResponse( + TrackSource Source, + AlbumApplicationResponse? Album, + YouTubeAlbumResult? YouTubeAlbum) + { + public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => + new(album.YouTubeAlbumId is not null ? TrackSource.YouTube : TrackSource.Local, album, null); + + public static AlbumSearchItemResponse FromYouTubeAlbum(YouTubeAlbumResult album) => + new(TrackSource.YouTube, null, album); + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs new file mode 100644 index 0000000..ce8f424 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs @@ -0,0 +1,38 @@ +namespace Musify.Application.Albums.Responses +{ + /// Result of a combined albums listing/search: a page of local catalog albums + /// (user-created and previously materialized external albums), plus, when a search term was + /// given, live external hits appended after them. Pagination metadata (, + /// , , ) describes only + /// the local page — the external source has no stable total, so its continuation is tracked + /// separately via . + public record AlbumsSearchResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasPreviousPage, + bool HasNextLocalPage, + string? NextYoutubeContinuationToken, + bool YoutubeUnavailable, + bool HasNextPage) + { + // HasNextPage is a real field (not a computed property) so it shows up as a required member + // of the generated OpenAPI schema, same as every other field here — a get-only property added + // outside the primary constructor gets reflected as optional instead, which is never true here. + public static AlbumsSearchResponse Create( + IReadOnlyList items, + int pageNumber, + int pageSize, + int pageCount, + int totalItemCount, + bool hasPreviousPage, + bool hasNextLocalPage, + string? nextYoutubeContinuationToken, + bool youtubeUnavailable) => + new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage, + nextYoutubeContinuationToken, youtubeUnavailable, + HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken)); + } +} diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 3d8c2e8..7add78c 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -67,7 +67,7 @@ namespace Musify.Application.Tracks (youtubeItems, nextContinuationToken, youtubeUnavailable) = await SearchYouTubeAsync(request.Name, request.YoutubeContinuationToken, knownVideoIds, cancellationToken); - return new TracksSearchResponse( + return TracksSearchResponse.Create( [.. localItems, .. youtubeItems], pagedEntities.PageNumber, pagedEntities.PageSize, diff --git a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs index d09aa56..2861bc1 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs @@ -15,8 +15,24 @@ namespace Musify.Application.Tracks.Responses bool HasPreviousPage, bool HasNextLocalPage, string? NextYoutubeContinuationToken, - bool YoutubeUnavailable) + bool YoutubeUnavailable, + bool HasNextPage) { - public bool HasNextPage => HasNextLocalPage || !string.IsNullOrEmpty(NextYoutubeContinuationToken); + // HasNextPage is a real field (not a computed property) so it shows up as a required member + // of the generated OpenAPI schema, same as every other field here — a get-only property added + // outside the primary constructor gets reflected as optional instead, which is never true here. + public static TracksSearchResponse Create( + IReadOnlyList items, + int pageNumber, + int pageSize, + int pageCount, + int totalItemCount, + bool hasPreviousPage, + bool hasNextLocalPage, + string? nextYoutubeContinuationToken, + bool youtubeUnavailable) => + new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage, + nextYoutubeContinuationToken, youtubeUnavailable, + HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken)); } } diff --git a/backend/Core/Musify.Application/YouTube/SearchYouTubeAlbums.cs b/backend/Core/Musify.Application/YouTube/SearchYouTubeAlbums.cs deleted file mode 100644 index d422f51..0000000 --- a/backend/Core/Musify.Application/YouTube/SearchYouTubeAlbums.cs +++ /dev/null @@ -1,21 +0,0 @@ -using ErrorOr; -using Mediator; -using Musify.Application.Contracts; - -namespace Musify.Application.YouTube -{ - public record SearchYouTubeAlbumsQuery(string Query, string ContinuationToken) - : IQuery>; - - public class SearchYouTubeAlbumsQueryHandler(IYouTubeMusicService youTubeMusicService) - : IQueryHandler> - { - public async ValueTask> Handle(SearchYouTubeAlbumsQuery request, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(request.Query)) - return Error.Validation(description: "The search query must not be empty."); - - return await youTubeMusicService.SearchAlbumsAsync(request.Query, request.ContinuationToken, cancellationToken); - } - } -} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs new file mode 100644 index 0000000..67b29d4 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs @@ -0,0 +1,8 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Api.DataTransferObjects.PlayLists; + +/// Add a track already in the catalog by , or one from an external +/// source by + (provisioning it in the background if +/// needed) — exactly one of the two must be given. +public record AddPlayListTrackRequest(Guid? TrackId, TrackSource? Source, string? ExternalId); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddYouTubeTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddYouTubeTrackRequest.cs deleted file mode 100644 index 4959ac0..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddYouTubeTrackRequest.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record AddYouTubeTrackRequest(string VideoId); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index e61f8bb..6fe8e1e 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -7,6 +7,8 @@ using Musify.Application.Albums; using Musify.Application.Albums.Responses; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; +using Musify.Application.YouTube; +using Musify.Domain.ValueObjects; namespace Musify.Api.Endpoints; @@ -19,8 +21,8 @@ public static class AlbumEndpoints group.MapGet("/", GetAlbums) .WithName("GetAlbums") - .WithSummary("Get Paginated Albums.") - .Produces>(); + .WithSummary("Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given.") + .Produces(); group.MapGet("/{id}", GetAlbumById) .WithName("GetAlbumById") @@ -28,6 +30,12 @@ public static class AlbumEndpoints .Produces() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/external/{source}/{externalId}", GetExternalAlbum) + .WithName("GetExternalAlbum") + .WithSummary("Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + group.MapGet("/users/{userId}", GetAlbumsByUserId) .WithName("GetAlbumsByUserId") .WithSummary("Get Paginated Albums For A User.") @@ -100,9 +108,10 @@ public static class AlbumEndpoints CancellationToken cancellationToken, string? title, int pageNumber = 1, - int pageSize = 10) + int pageSize = 10, + string? youtubeContinuationToken = null) { - var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize, youtubeContinuationToken), cancellationToken); return result.ToHttpResult(); } @@ -115,6 +124,19 @@ public static class AlbumEndpoints return result.ToHttpResult(); } + private static async Task GetExternalAlbum( + IMediator mediator, + string source, + string externalId, + CancellationToken cancellationToken) + { + if (!Enum.TryParse(source, ignoreCase: true, out var trackSource) || trackSource != TrackSource.YouTube) + return Results.NotFound($"Unknown or unsupported source '{source}'."); + + var result = await mediator.Send(new GetYouTubeAlbumQuery(externalId), cancellationToken); + return result.ToHttpResult(); + } + private static async Task GetAlbumsByUserId( IMediator mediator, long userId, diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 88971e4..a5d3c43 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -1,9 +1,11 @@ using Mediator; using Musify.Application.PlayLists; using Musify.Application.PlayLists.Responses; +using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; @@ -84,19 +86,10 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{playlistId}/tracks/{trackId}", AddTrackToPlayList) - .WithName("AddTrackToPlayList") - .WithSummary("Add A Track To A PlayList.") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); - - group.MapPost("/{playlistId}/youtube-tracks", AddYouTubeTrackToPlayList) - .WithName("AddYouTubeTrackToPlayList") - .WithSummary("Add A YouTube Track To A PlayList, Downloading It In The Background If Needed.") - .AddEndpointFilter>() + group.MapPost("/{playlistId}/tracks", AddPlayListTrack) + .WithName("AddPlayListTrack") + .WithSummary("Add A Track To A PlayList, By Id Or By External Source, Provisioning It In The Background If Needed.") + .AddEndpointFilter>() .RequireAuthorization() .Produces(StatusCodes.Status201Created) .ProducesValidationProblem() @@ -225,32 +218,29 @@ public static class PlayListEndpoints return result.ToHttpResult(); } - private static async Task AddTrackToPlayList( + private static async Task AddPlayListTrack( IMediator mediator, CurrentUser currentUser, Guid playlistId, - Guid trackId, + AddPlayListTrackRequest request, CancellationToken cancellationToken) { - var result = await mediator.Send( - new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, trackId), - cancellationToken); + if (request.TrackId is { } trackId) + { + var addResult = await mediator.Send( + new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, trackId), + cancellationToken); + if (addResult.IsError) + return addResult.ToHttpResult(); - return result.ToHttpResult(); - } + var trackResult = await mediator.Send(new GetTrackByIdQuery(trackId), cancellationToken); + return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); + } - private static async Task AddYouTubeTrackToPlayList( - IMediator mediator, - CurrentUser currentUser, - Guid playlistId, - AddYouTubeTrackRequest request, - CancellationToken cancellationToken) - { + // The validator guarantees Source/ExternalId are set whenever TrackId isn't, and that Source + // is one of the currently supported external sources (only YouTube for now). var result = await mediator.Send( - new AddYouTubeTrackToPlayListCommand( - currentUser.RequiredId, - playlistId, - request.VideoId), + new AddYouTubeTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.ExternalId!), cancellationToken); return result.ToCreatedResult(track => $"/tracks/{track.Id}"); diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index c506f75..0b356ca 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -3,6 +3,9 @@ using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; using Musify.Application.Contracts; +using Musify.Application.YouTube; +using Musify.Application.YouTube.Responses; +using Musify.Domain.ValueObjects; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; @@ -41,6 +44,14 @@ public static class TrackEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); + group.MapGet("/external/{source}/{externalId}/stream", GetExternalTrackStream) + .WithName("GetExternalTrackStream") + .WithSummary("Resolve Playback For A Track From An External Source (Currently Only YouTube Music): Server Stream If Downloaded, Direct Stream From The Source Otherwise. Works Anonymously When The Playback Configuration Allows It.") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + group.MapGet("/users/{userId}", GetTracksByUserId) .WithName("GetTracksByUserId") .WithSummary("Get Paginated Tracks For A User.") @@ -129,6 +140,20 @@ public static class TrackEndpoints return result.ToHttpResult(); } + private static async Task GetExternalTrackStream( + IMediator mediator, + CurrentUser currentUser, + string source, + string externalId, + CancellationToken cancellationToken) + { + if (!Enum.TryParse(source, ignoreCase: true, out var trackSource) || trackSource != TrackSource.YouTube) + return Results.NotFound($"Unknown or unsupported source '{source}'."); + + var result = await mediator.Send(new ResolveYouTubeTrackStreamCommand(externalId, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } + private static async Task GetTracksByUserId( IMediator mediator, long userId, diff --git a/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs deleted file mode 100644 index 1a326a2..0000000 --- a/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Mediator; -using Musify.Api.Authentication; -using Musify.Api.Extensions; -using Musify.Application.Contracts; -using Musify.Application.YouTube; -using Musify.Application.YouTube.Responses; - -namespace Musify.Api.Endpoints; - -public static class YouTubeEndpoints -{ - public static IEndpointRouteBuilder MapYouTubeEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup("/youtube") - .WithTags("YouTube"); - - group.MapGet("/albums", SearchAlbums) - .WithName("SearchYouTubeAlbums") - .WithSummary("Search Albums On YouTube Music.") - .RequireAuthorization() - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .ProducesValidationProblem(); - - group.MapGet("/albums/{albumId}", GetAlbum) - .WithName("GetYouTubeAlbum") - .WithSummary("Get A YouTube Music Album With Its Tracklist.") - .RequireAuthorization() - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); - - group.MapGet("/tracks/{videoId}/stream", GetTrackStream) - .WithName("GetYouTubeTrackStream") - .WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise. Works Anonymously When The Playback Configuration Allows It.") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); - - return app; - } - - private static async Task GetTrackStream( - IMediator mediator, - CurrentUser currentUser, - string videoId, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new ResolveYouTubeTrackStreamCommand(videoId, currentUser.Id), - cancellationToken); - - return result.ToHttpResult(); - } - - private static async Task SearchAlbums( - IMediator mediator, - CancellationToken cancellationToken, - string query, - string? continuation = null) - { - var result = await mediator.Send( - new SearchYouTubeAlbumsQuery(query, continuation ?? string.Empty), - cancellationToken); - - return result.ToHttpResult(); - } - - private static async Task GetAlbum( - IMediator mediator, - string albumId, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetYouTubeAlbumQuery(albumId), cancellationToken); - return result.ToHttpResult(); - } -} diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index 25c11d6..c892594 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -42,8 +42,7 @@ app .MapMixEndpoints() .MapPlayListEndpoints() .MapTrackEndpoints() - .MapUserEndpoints() - .MapYouTubeEndpoints(); + .MapUserEndpoints(); app.Run(); diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs new file mode 100644 index 0000000..eb5b0da --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.PlayLists; +using Musify.Domain.ValueObjects; + +namespace Musify.Api.Validators.PlayLists; + +public sealed class AddPlayListTrackRequestValidator : AbstractValidator +{ + public AddPlayListTrackRequestValidator() + { + RuleFor(x => x) + .Must(x => x.TrackId.HasValue ^ (x.Source.HasValue && !string.IsNullOrEmpty(x.ExternalId))) + .WithMessage("Provide either trackId, or source and externalId, but not both."); + + When(x => x.Source.HasValue, () => + { + RuleFor(x => x.Source) + .Must(source => source == TrackSource.YouTube) + .WithMessage("Unsupported source."); + + RuleFor(x => x.ExternalId) + .NotEmpty() + .Matches("^[A-Za-z0-9_-]{11}$") + .WithMessage("externalId must be a valid YouTube video id."); + }); + } +} diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/AddYouTubeTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/AddYouTubeTrackRequestValidator.cs deleted file mode 100644 index 53d6a17..0000000 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/AddYouTubeTrackRequestValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.PlayLists; - -namespace Musify.Api.Validators.PlayLists; - -public sealed class AddYouTubeTrackRequestValidator : AbstractValidator -{ - public AddYouTubeTrackRequestValidator() - { - RuleFor(x => x.VideoId) - .NotEmpty() - .Matches("^[A-Za-z0-9_-]{11}$"); - } -} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs index c3f5c3e..1d565c5 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -1,15 +1,22 @@ +using ErrorOr; using Musify.Application.Albums; +using Musify.Application.Contracts; using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; +using NSubstitute; using Xunit; namespace Musify.Application.Tests.Albums { public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase { - private GetAlbumsQueryHandler CreateHandler() => new(Database); + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); + + private GetAlbumsQueryHandler CreateHandler() => + new(Database, youTubeMusicService, NoOpLogger()); [Fact] - public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged() + public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged_AndDoesNotCallYouTube() { var owner = TestEntities.User(); await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Alpha"), TestEntities.UserAlbum(owner.Id, "Beta")); @@ -18,19 +25,68 @@ namespace Musify.Application.Tests.Albums Assert.False(result.IsError); Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.NotNull(item.Album)); + await youTubeMusicService.DidNotReceiveWithAnyArgs() + .SearchAlbumsAsync(default!, default!, TestContext.Current.CancellationToken); } [Fact] - public async Task Handle_TitleFilter_ReturnsOnlyMatchingAlbums() + public async Task Handle_TitleFilter_ReturnsMatchingLocalAlbums_FollowedByYouTubeResults() { var owner = TestEntities.User(); await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics"), TestEntities.UserAlbum(owner.Id, "Jazz Favorites")); + var album = new YouTubeAlbumResult("album-1", "Rock Classics Reissue", "Various Artists", "thumb.jpg", 2020, false, false, []); + youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any()) + .Returns(new YouTubeAlbumSearchResult([album], "next-token")); + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); Assert.False(result.IsError); - var album = Assert.Single(result.Value.Items); - Assert.Equal("Rock Classics", album.Title); + Assert.Equal(2, result.Value.Items.Count); + Assert.NotNull(result.Value.Items[0].Album); + Assert.Equal("Rock Classics", result.Value.Items[0].Album!.Title); + Assert.Null(result.Value.Items[1].Album); + Assert.NotNull(result.Value.Items[1].YouTubeAlbum); + Assert.Equal("album-1", result.Value.Items[1].YouTubeAlbum!.AlbumId); + Assert.Equal("next-token", result.Value.NextYoutubeContinuationToken); + Assert.False(result.Value.YoutubeUnavailable); + } + + [Fact] + public async Task Handle_TitleFilter_SkipsYouTubeResultAlreadyMaterializedLocally() + { + var externalAlbum = TestEntities.ExternalAlbum(externalId: "album-1", title: "Rock Classics", source: TrackSource.YouTube); + await SeedAsync(externalAlbum); + + var album = new YouTubeAlbumResult("album-1", "Rock Classics", "Various Artists", "thumb.jpg", 2020, false, false, []); + youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any()) + .Returns(new YouTubeAlbumSearchResult([album], string.Empty)); + + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.NotNull(item.Album); + Assert.Null(item.YouTubeAlbum); + } + + [Fact] + public async Task Handle_TitleFilter_YouTubeUnavailable_StillReturnsLocalResults() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics")); + + youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any()) + .Returns(Error.Forbidden(description: "The YouTube Music source is disabled.")); + + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.NotNull(item.Album); + Assert.True(result.Value.YoutubeUnavailable); + Assert.Null(result.Value.NextYoutubeContinuationToken); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs deleted file mode 100644 index 9532847..0000000 --- a/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.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 SearchYouTubeAlbumsQueryHandlerTests - { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private SearchYouTubeAlbumsQueryHandler CreateHandler() => new(youTubeMusicService); - - [Fact] - public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService() - { - var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - await youTubeMusicService.DidNotReceiveWithAnyArgs() - .SearchAlbumsAsync(default!, default!, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Handle_NonEmptyQuery_DelegatesToTheService() - { - var expected = new YouTubeAlbumSearchResult([], "next-token"); - youTubeMusicService.SearchAlbumsAsync("query", "token", Arg.Any()).Returns(expected); - - var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery("query", "token"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Same(expected, result.Value); - } - } -} diff --git a/web-player/src/lib/albums.ts b/web-player/src/lib/albums.ts index 51c60a1..1ce1b86 100644 --- a/web-player/src/lib/albums.ts +++ b/web-player/src/lib/albums.ts @@ -16,7 +16,7 @@ export async function fetchAlbumQueueItems( return toQueueItems(data.items ?? []); } - const res = await fetch(`/api/albums/youtube/${albumId}/tracks`); + const res = await fetch(`/api/albums/external/${kind}/${albumId}/tracks`); if (!res.ok) return []; const detail = (await res.json()) as YouTubeAlbumTracksResponse; return detail.tracks.map((track) => ({ diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index f6bd8be..e7b29fb 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -305,7 +305,7 @@ class PlayerState { } async #resolveYouTubeSrc(videoId: string): Promise { - const res = await fetch(`/api/youtube/tracks/${videoId}/stream`); + const res = await fetch(`/api/tracks/external/youtube/${videoId}/stream`); if (!res.ok) throw new Error(await readErrorMessage(res)); const { mode, streamUrl, ticket } = (await res.json()) as { mode: string; diff --git a/web-player/src/lib/server/playlistActions.ts b/web-player/src/lib/server/playlistActions.ts index 0aee63c..51822d9 100644 --- a/web-player/src/lib/server/playlistActions.ts +++ b/web-player/src/lib/server/playlistActions.ts @@ -15,8 +15,9 @@ export async function addTrackAction({ request, params, locals, fetch }: Request } const api = createApiClient({ fetch, accessToken }); - const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', { - params: { path: { playlistId, trackId } } + const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', { + params: { path: { playlistId } }, + body: { trackId, source: null, externalId: null } }); if (err) { @@ -36,19 +37,15 @@ export async function addYouTubeToPlaylistAction({ request, params, locals, fetc const form = await request.formData(); const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? ''); const videoId = String(form.get('videoId') ?? ''); - const title = String(form.get('title') ?? ''); - const artist = String(form.get('artist') ?? ''); - const durationSeconds = Number(form.get('durationSeconds') ?? 0); - const thumbnailUrl = String(form.get('thumbnailUrl') ?? ''); - if (!playlistId || !videoId || !title) { + if (!playlistId || !videoId) { return fail(400, { message: 'Faltan datos de la canción.' }); } const api = createApiClient({ fetch, accessToken }); - const { error: err, response } = await api.POST('/playlists/{playlistId}/youtube-tracks', { + const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', { params: { path: { playlistId } }, - body: { videoId, title, artist, durationSeconds, thumbnailUrl } + body: { trackId: null, source: 'YouTube', externalId: videoId } }); if (err) { @@ -84,8 +81,9 @@ export async function addAlbumToPlaylistAction({ request, params, locals, fetch let added = 0; for (const track of tracks.items) { - const { error: err } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', { - params: { path: { playlistId, trackId: String(track.id) } } + const { error: err } = await api.POST('/playlists/{playlistId}/tracks', { + params: { path: { playlistId } }, + body: { trackId: String(track.id), source: null, externalId: null } }); if (!err) added++; } @@ -111,8 +109,8 @@ export async function addYoutubeAlbumToPlaylistAction({ } const api = createApiClient({ fetch, accessToken }); - const { data: detail, error: detailErr } = await api.GET('/youtube/albums/{albumId}', { - params: { path: { albumId } } + const { data: detail, error: detailErr } = await api.GET('/albums/external/{source}/{externalId}', { + params: { path: { source: 'YouTube', externalId: albumId } } }); if (detailErr || !detail) { @@ -121,15 +119,9 @@ export async function addYoutubeAlbumToPlaylistAction({ let added = 0; for (const track of detail.tracks) { - const { error: err } = await api.POST('/playlists/{playlistId}/youtube-tracks', { + const { error: err } = await api.POST('/playlists/{playlistId}/tracks', { params: { path: { playlistId } }, - body: { - videoId: track.videoId, - title: track.title, - artist: detail.album.artist, - durationSeconds: Number(track.durationSeconds), - thumbnailUrl: detail.album.thumbnailUrl - } + body: { trackId: null, source: 'YouTube', externalId: track.videoId } }); if (!err) added++; } diff --git a/web-player/src/lib/thumbnails.ts b/web-player/src/lib/thumbnails.ts index 4f1a165..494c993 100644 --- a/web-player/src/lib/thumbnails.ts +++ b/web-player/src/lib/thumbnails.ts @@ -6,7 +6,7 @@ export const THUMBNAIL_SIZES = { export type ThumbnailSize = keyof typeof THUMBNAIL_SIZES; -const PROXY_PATH = '/api/youtube/thumbnail'; +const PROXY_PATH = '/api/thumbnail'; export function thumbnailSrc( url: string | undefined, diff --git a/web-player/src/routes/(app)/albums/youtube/[albumId]/+page.server.ts b/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.server.ts similarity index 86% rename from web-player/src/routes/(app)/albums/youtube/[albumId]/+page.server.ts rename to web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.server.ts index 1e3dd3c..c51196e 100644 --- a/web-player/src/routes/(app)/albums/youtube/[albumId]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.server.ts @@ -10,13 +10,15 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const [albumRes, playlistsRes] = await Promise.all([ - api.GET('/youtube/albums/{albumId}', { params: { path: { albumId: params.albumId } } }), + api.GET('/albums/external/{source}/{externalId}', { + params: { path: { source: params.source, externalId: params.externalId } } + }), api.GET('/playlists/users/{userId}', { params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } } }) ]); - const album = unwrapOrError(albumRes, 'No se pudo cargar el álbum de YouTube Music.', 404); + const album = unwrapOrError(albumRes, 'No se pudo cargar el álbum.', 404); return { album, playlists: playlistsRes.data?.items ?? [], section: null }; }; diff --git a/web-player/src/routes/(app)/albums/youtube/[albumId]/+page.svelte b/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte similarity index 100% rename from web-player/src/routes/(app)/albums/youtube/[albumId]/+page.svelte rename to web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index f80e101..dfdd52f 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -10,7 +10,6 @@ import { import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE as PAGE_SIZE, - SEARCH_MIN_LENGTH, YOUTUBE_FILLER_LIMIT } from '$lib/config'; import { searchUsers } from '$lib/server/userDirectory'; @@ -36,25 +35,21 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { }) : Promise.resolve(null); - const youtubeAlbumsPromise = - query.length >= SEARCH_MIN_LENGTH - ? api.GET('/youtube/albums', { params: { query: { query } } }) - : Promise.resolve(null); - - const [tracksRes, playlistsRes, albumsRes, youtubeAlbumsRes] = await Promise.all([ + const [tracksRes, playlistsRes, albumsRes] = await Promise.all([ tracksPromise, playlistsPromise, - albumsPromise, - youtubeAlbumsPromise + albumsPromise ]); const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'); + const albumItems = albumsRes?.data?.items ?? []; + return { query, tracks, - albums: albumsRes?.data?.items ?? [], - youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [], + albums: albumItems.flatMap((item) => (item.album ? [item.album] : [])), + youtubeAlbums: albumItems.flatMap((item) => (item.youTubeAlbum ? [item.youTubeAlbum] : [])), 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 fa253a6..bf82183 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -282,7 +282,7 @@ {:else} openAlbumMenu(e, 'youtube', entry.album.albumId)} diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index ad964a6..8b3cbc1 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -215,7 +215,7 @@ {#each albums as album, i (album.id)} { const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - const result = await api.GET('/youtube/albums/{albumId}', { - params: { path: { albumId: params.albumId } } + const result = await api.GET('/albums/external/{source}/{externalId}', { + params: { path: { source: params.source, externalId: params.externalId } } }); - return json(unwrapOrError(result, 'No se pudo cargar el álbum de YouTube.')); + return json(unwrapOrError(result, 'No se pudo cargar el álbum.')); }; diff --git a/web-player/src/routes/api/youtube/thumbnail/+server.ts b/web-player/src/routes/api/thumbnail/+server.ts similarity index 100% rename from web-player/src/routes/api/youtube/thumbnail/+server.ts rename to web-player/src/routes/api/thumbnail/+server.ts diff --git a/web-player/src/routes/api/youtube/tracks/[videoId]/stream/+server.ts b/web-player/src/routes/api/tracks/external/[source]/[externalId]/stream/+server.ts similarity index 73% rename from web-player/src/routes/api/youtube/tracks/[videoId]/stream/+server.ts rename to web-player/src/routes/api/tracks/external/[source]/[externalId]/stream/+server.ts index 9085bd1..d0033be 100644 --- a/web-player/src/routes/api/youtube/tracks/[videoId]/stream/+server.ts +++ b/web-player/src/routes/api/tracks/external/[source]/[externalId]/stream/+server.ts @@ -6,9 +6,9 @@ export const GET: RequestHandler = async ({ params, locals, fetch }) => { const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.'); const api = createApiClient({ fetch, accessToken }); - const result = await api.GET('/youtube/tracks/{videoId}/stream', { - params: { path: { videoId: params.videoId } } + const result = await api.GET('/tracks/external/{source}/{externalId}/stream', { + params: { path: { source: params.source, externalId: params.externalId } } }); - return json(unwrapOrError(result, 'No se pudo obtener el stream de YouTube.')); + return json(unwrapOrError(result, 'No se pudo obtener el stream.')); };