diff --git a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs new file mode 100644 index 0000000..32c2563 --- /dev/null +++ b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs @@ -0,0 +1,26 @@ +using Mediator; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Genres +{ + public record GetAvailableGenresQuery : IQuery>; + + public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); + + public class GetAvailableGenresQueryHandler + : IQueryHandler> + { + public ValueTask> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) + { + var all = Enum.GetValues(); + + IReadOnlyList result = all + .Select(genre => new AvailableGenreResponse( + genre, + all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList())) + .ToList(); + + return ValueTask.FromResult(result); + } + } +} diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs new file mode 100644 index 0000000..1dece8f --- /dev/null +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -0,0 +1,31 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Genres +{ + public record GetGenresQuery : IQuery>; + + public record GenreResponse(Genre Genre, int TrackCount); + + public class GetGenresQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetGenresQuery request, CancellationToken cancellationToken) + { + var rows = await database.TrackTags + .AsNoTracking() + .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId)) + .GroupBy(tag => tag.Tag) + .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) + .ToListAsync(cancellationToken); + + return rows + .OrderByDescending(row => row.TrackCount) + .ThenBy(row => row.Genre) + .Select(row => new GenreResponse(row.Genre, row.TrackCount)) + .ToList(); + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index b188fa2..8174418 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -5,10 +5,11 @@ using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { - public record GetTracksQuery(string? Name, int PageNumber, int PageSize) + public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) : IQuery>; public class GetTracksQueryHandler(IDatabase database) @@ -28,6 +29,9 @@ namespace Musify.Application.Tracks tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); } + if (request.Genre is { } genre) + tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre)); + var totalCount = await tracksQuery.CountAsync(cancellationToken); var pagedEntities = await tracksQuery diff --git a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs new file mode 100644 index 0000000..da31c5b --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs @@ -0,0 +1,39 @@ +using Mediator; +using Musify.Application.Genres; + +namespace Musify.Api.Endpoints; + +public static class GenreEndpoints +{ + public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/genres") + .WithTags("Genres"); + + group.MapGet("/", GetGenres) + .WithName("GetGenres") + .WithSummary("Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously.") + .Produces>(); + + group.MapGet("/available", GetAvailableGenres) + .WithName("GetAvailableGenres") + .WithSummary("Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously.") + .Produces>(); + + return app; + } + + private static async Task> GetAvailableGenres( + IMediator mediator, + CancellationToken cancellationToken) + { + return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken); + } + + private static async Task> GetGenres( + IMediator mediator, + CancellationToken cancellationToken) + { + return await mediator.Send(new GetGenresQuery(), cancellationToken); + } +} diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index ff1ea37..1e8728c 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -1,4 +1,5 @@ using Mediator; +using Musify.Domain.ValueObjects; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; @@ -19,8 +20,9 @@ public static class TrackEndpoints group.MapGet("/", GetTracks) .WithName("GetTracks") - .WithSummary("Get Paginated Tracks, Optionally Filtered By Name.") - .Produces(); + .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.") + .Produces() + .ProducesValidationProblem(); group.MapGet("/{id}", GetTrackById) .WithName("GetTrackById") @@ -92,10 +94,25 @@ public static class TrackEndpoints IMediator mediator, CancellationToken cancellationToken, string? name, + string? genre, int pageNumber = 1, int pageSize = 10) { - var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken); + Genre? parsedGenre = null; + if (!string.IsNullOrWhiteSpace(genre)) + { + if (!Enum.TryParse(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value)) + { + return Results.ValidationProblem(new Dictionary + { + [nameof(genre)] = [$"'{genre}' is not a valid genre."] + }); + } + + parsedGenre = value; + } + + var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index e31b5e0..7e8772b 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -40,6 +40,7 @@ app.UseAuthorization(); app .MapAlbumEndpoints() .MapConfigEndpoints() + .MapGenreEndpoints() .MapLikeEndpoints() .MapMixEndpoints() .MapPlayListEndpoints() diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs new file mode 100644 index 0000000..539cd97 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Musify.Api.Tests.TestSupport; +using Musify.Application.Genres; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Xunit; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class GenreEndpointsTests(ApiTestFixture fixture) + { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private async Task SeedTrackAsync(Genre genre) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" }; + var title = $"Genre track {Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + OwnerUserId = owner.Id, + Owner = owner, + Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }] + }; + foreach (var tag in track.Tags) + tag.TrackId = track.Id; + database.Users.Add(owner); + database.Tracks.Add(track); + database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return title; + } + + [Fact] + public async Task GetGenres_Anonymous_ListsGenresInUse() + { + await SeedTrackAsync(Genre.Funk); + + var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1); + } + + [Fact] + public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(Enum.GetValues().Length, body!.Count); + Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith); + } + + [Theory] + [InlineData("Nope")] + [InlineData("999")] + public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre) + { + var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetTracks_GenreQuery_IsCaseInsensitive() + { + var title = await SeedTrackAsync(Genre.Blues); + + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Track.Title == title); + } + + [Fact] + public async Task GetTracks_GenreQuery_FiltersByTag() + { + var title = await SeedTrackAsync(Genre.Ambient); + await SeedTrackAsync(Genre.Techno); + + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Track.Title == title); + Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags)); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs new file mode 100644 index 0000000..0f8d698 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs @@ -0,0 +1,32 @@ +using Musify.Application.Genres; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Genres +{ + public sealed class GetAvailableGenresQueryHandlerTests + { + private static async Task> RunAsync() => + await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken); + + [Fact] + public async Task Handle_ReturnsEveryGenre() + { + var result = await RunAsync(); + + Assert.Equal(Enum.GetValues(), result.Select(r => r.Genre)); + } + + [Fact] + public async Task Handle_ListsIncompatibleGenresSymmetrically() + { + var result = await RunAsync(); + + var metal = result.Single(r => r.Genre == Genre.Metal); + var lofi = result.Single(r => r.Genre == Genre.Lofi); + Assert.Contains(Genre.Lofi, metal.IncompatibleWith); + Assert.Contains(Genre.Metal, lofi.IncompatibleWith); + Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs new file mode 100644 index 0000000..cb4d6e9 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs @@ -0,0 +1,69 @@ +using Musify.Application.Genres; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Genres +{ + public sealed class GetGenresQueryHandlerTests : HandlerTestBase + { + private GetGenresQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoTracks_ReturnsEmpty() + { + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst() + { + var owner = TestEntities.User(); + var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]); + var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]); + var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]); + await SeedAsync( + owner, rockA, rockB, jazz, + new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id }, + new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id }, + new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); + + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Equal( + [(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)], + result.Select(r => (r.Genre, r.TrackCount))); + } + + [Fact] + public async Task Handle_TrackInSeveralLibraries_IsCountedOnce() + { + var owner = TestEntities.User(); + var other = TestEntities.User(id: 2, name: "other-user"); + var track = TestEntities.Track(owner, tags: [Genre.Pop]); + await SeedAsync( + owner, other, track, + new UserHasTrack { UserId = owner.Id, TrackId = track.Id }, + new UserHasTrack { UserId = other.Id, TrackId = track.Id }); + + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Equal(1, Assert.Single(result).TrackCount); + } + + [Fact] + public async Task Handle_TrackNotInAnyLibrary_IsIgnored() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, tags: [Genre.Metal]); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index a22dd53..90f828e 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -1,5 +1,6 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Tracks @@ -43,5 +44,23 @@ namespace Musify.Application.Tests.Tracks var item = Assert.Single(result.Value.Items); Assert.Equal("Bohemian Rhapsody", item.Track.Title); } + + [Fact] + public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag() + { + var owner = TestEntities.User(); + var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]); + var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]); + await SeedAsync( + owner, rock, jazz, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id }, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); + + var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.Equal("Loud", item.Track.Title); + } } } diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 76d3c82..b4cb8c8 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -1,4 +1,7 @@ - +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ export interface paths { "/albums": { @@ -8,10 +11,10 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Albums, Optionally Filtered By Title. */ get: operations["GetAlbums"]; put?: never; - + /** Create A New Album. */ post: operations["CreateAlbum"]; delete?: never; options?: never; @@ -26,7 +29,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get An Album By Id. */ get: operations["GetAlbumById"]; put?: never; post?: never; @@ -43,7 +46,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Albums For A User. */ get: operations["GetAlbumsByUserId"]; put?: never; post?: never; @@ -60,7 +63,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get The Albums The Current User Listened To Most Recently. */ get: operations["GetRecentlyListenedAlbums"]; put?: never; post?: never; @@ -77,7 +80,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get The Tracks Of An Album Ordered By Track Number. */ get: operations["GetAlbumTracks"]; put?: never; post?: never; @@ -94,7 +97,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get An Album Cover Image. */ get: operations["GetAlbumCover"]; put?: never; post?: never; @@ -113,7 +116,7 @@ export interface paths { }; get?: never; put?: never; - + /** Request A Pre-Signed URL To Upload An Album Picture. */ post: operations["RequestAlbumPictureUpload"]; delete?: never; options?: never; @@ -129,10 +132,10 @@ export interface paths { cookie?: never; }; get?: never; - + /** Update An Album. */ put: operations["UpdateAlbum"]; post?: never; - + /** Delete An Album. */ delete: operations["DeleteAlbum"]; options?: never; head?: never; @@ -148,9 +151,9 @@ export interface paths { }; get?: never; put?: never; - + /** Add One Of Your Uploaded Tracks To An Album. */ post: operations["AddTrackToAlbum"]; - + /** Remove A Track From An Album. */ delete: operations["RemoveTrackFromAlbum"]; options?: never; head?: never; @@ -164,7 +167,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously. */ get: operations["GetPlaybackPublicConfig"]; put?: never; post?: never; @@ -174,6 +177,40 @@ export interface paths { patch?: never; trace?: never; }; + "/genres": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously. */ + get: operations["GetGenres"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/genres/available": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously. */ + get: operations["GetAvailableGenres"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/likes": { parameters: { query?: never; @@ -181,7 +218,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get The Current User'S Liked Tracks. */ get: operations["GetLikedTracks"]; put?: never; post?: never; @@ -200,7 +237,7 @@ export interface paths { }; get?: never; put?: never; - + /** Like Or Unlike A Track By Id. */ post: operations["ToggleTrackLike"]; delete?: never; options?: never; @@ -215,7 +252,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get The Server-Generated Mixes Of The Current User. */ get: operations["GetMixes"]; put?: never; post?: never; @@ -232,7 +269,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A Mix With All Of Its Songs. */ get: operations["GetMixById"]; put?: never; post?: never; @@ -249,10 +286,10 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated PlayLists. */ get: operations["GetPlayLists"]; put?: never; - + /** Create A New PlayList. */ post: operations["CreatePlayList"]; delete?: never; options?: never; @@ -267,7 +304,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A PlayList By Id. */ get: operations["GetPlayListById"]; put?: never; post?: never; @@ -284,7 +321,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated PlayLists For A User. */ get: operations["GetPlayListsByUserId"]; put?: never; post?: never; @@ -301,10 +338,10 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Tracks Of A PlayList. */ get: operations["GetPlayListTracks"]; put?: never; - + /** Add One Of Your Tracks To A PlayList. */ post: operations["AddPlayListTrack"]; delete?: never; options?: never; @@ -319,7 +356,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A PlayList Cover Image. */ get: operations["GetPlayListCover"]; put?: never; post?: never; @@ -337,10 +374,10 @@ export interface paths { cookie?: never; }; get?: never; - + /** Update An Existing PlayList. */ put: operations["UpdatePlayList"]; post?: never; - + /** Delete A PlayList. */ delete: operations["DeletePlayList"]; options?: never; head?: never; @@ -356,7 +393,7 @@ export interface paths { }; get?: never; put?: never; - + /** Request A Pre-Signed URL To Upload A PlayList Picture. */ post: operations["RequestPlayListPictureUpload"]; delete?: never; options?: never; @@ -374,7 +411,7 @@ export interface paths { get?: never; put?: never; post?: never; - + /** Remove A Track From A PlayList. */ delete: operations["RemoveTrackFromPlayList"]; options?: never; head?: never; @@ -388,10 +425,10 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Tracks, Optionally Filtered By Name And Genre. */ get: operations["GetTracks"]; put?: never; - + /** Create A New Track. */ post: operations["CreateTrack"]; delete?: never; options?: never; @@ -406,7 +443,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A Track By Id. */ get: operations["GetTrackById"]; put?: never; post?: never; @@ -423,7 +460,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A Track Cover Image. */ get: operations["GetTrackCover"]; put?: never; post?: never; @@ -440,7 +477,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It. */ get: operations["GetTrackStream"]; put?: never; post?: never; @@ -458,6 +495,7 @@ export interface paths { cookie?: never; }; get?: never; + /** Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint. */ put: operations["RecordListeningProgress"]; post?: never; delete?: never; @@ -473,7 +511,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Tracks For A User. */ get: operations["GetTracksByUserId"]; put?: never; post?: never; @@ -493,7 +531,7 @@ export interface paths { get?: never; put?: never; post?: never; - + /** Delete A Track. */ delete: operations["DeleteTrack"]; options?: never; head?: never; @@ -509,7 +547,7 @@ export interface paths { }; get?: never; put?: never; - + /** Request Pre-Signed URLs To Upload Track Picture And Audio. */ post: operations["RequestTrackUploadUrls"]; delete?: never; options?: never; @@ -524,10 +562,10 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Users. */ get: operations["GetUsers"]; put?: never; - + /** Create A New User. */ post: operations["CreateUser"]; delete?: never; options?: never; @@ -542,7 +580,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A User By Id. */ get: operations["GetUserById"]; put?: never; post?: never; @@ -559,7 +597,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A User'S Listening History. */ get: operations["GetListeningHistory"]; put?: never; post?: never; @@ -569,23 +607,6 @@ export interface paths { patch?: never; trace?: never; }; - "/users/{id}/last-listened-track": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - - get: operations["GetLastTrackListenedByUserId"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/users/{id}/listening-stats": { parameters: { query?: never; @@ -593,7 +614,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A User'S Listening Stats For The Current Week And Streak. */ get: operations["GetListeningStats"]; put?: never; post?: never; @@ -603,6 +624,23 @@ export interface paths { patch?: never; trace?: never; }; + "/users/{id}/last-listened-track": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get The Last Track A User Listened To. */ + get: operations["GetLastTrackListenedByUserId"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/{id}/profile": { parameters: { query?: never; @@ -610,7 +648,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A User'S Public Profile, Including Follower Counts. */ get: operations["GetUserProfile"]; put?: never; post?: never; @@ -627,7 +665,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Check Whether The Current User Follows Another User. */ get: operations["IsFollowingUser"]; put?: never; post?: never; @@ -646,9 +684,9 @@ export interface paths { }; get?: never; put?: never; - + /** Follow A User. */ post: operations["FollowUser"]; - + /** Unfollow A User. */ delete: operations["UnfollowUser"]; options?: never; head?: never; @@ -660,36 +698,36 @@ export type webhooks = Record; export interface components { schemas: { AddPlayListTrackRequest: { - + /** Format: uuid */ trackId: string; }; AlbumApplicationResponse: { - + /** Format: uuid */ id: string; title: string; description: null | string; - + /** Format: int32 */ releaseYear: null | number | string; ownerUserId: string; - + /** Format: int32 */ trackCount: number | string; smallImageKeyName: null | string; mediumImageKeyName: null | string; largeImageKeyName: null | string; - + /** Format: date-time */ createdAt: string; - + /** Format: date-time */ updatedAt: string; coverTrackIds: string[]; }; AlbumPictureUploadResponse: { - + /** Format: uuid */ intentId: string; bucket: string; key: string; pictureName: string; contentType: string; - + /** Format: int32 */ expiresInSeconds: number | string; uploadUrl: string; }; @@ -698,52 +736,62 @@ export interface components { }; AlbumsSearchResponse: { items: components["schemas"]["AlbumSearchItemResponse"][]; - + /** Format: int32 */ pageNumber: number | string; - + /** Format: int32 */ pageSize: number | string; - + /** Format: int32 */ pageCount: number | string; - + /** Format: int32 */ totalItemCount: number | string; hasPreviousPage: boolean; hasNextPage: boolean; }; + AvailableGenreResponse: { + genre: components["schemas"]["Genre"]; + incompatibleWith: components["schemas"]["Genre"][]; + }; CreateAlbumRequest: { title: string; description: null | string; - + /** Format: int32 */ releaseYear: null | number | string; - + /** Format: uuid */ pictureIntentId: string; }; CreatePlayListRequest: { name: string; description: null | string; - + /** Format: uuid */ pictureIntentId: null | string; visibility?: components["schemas"]["PlaylistVisibility"]; }; CreateTrackRequest: { title: string; - + /** Format: uuid */ pictureIntentId: string; - + /** Format: uuid */ audioIntentId: string; tags: components["schemas"]["Genre"][]; }; - Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi"; CreateUserRequest: { - + /** Format: int64 */ id: number | string; name: string; firstName: null | string; secondName: null | string; }; + /** @enum {unknown} */ + Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi"; + GenreResponse: { + genre: components["schemas"]["Genre"]; + /** Format: int32 */ + trackCount: number | string; + }; HttpValidationProblemDetails: { type?: null | string; title?: null | string; - + /** Format: int32 */ status?: null | number | string; detail?: null | string; instance?: null | string; @@ -752,91 +800,91 @@ export interface components { }; }; ListeningStatsResponse: { - + /** Format: int32 */ tracksThisWeek: number | string; - + /** Format: int32 */ secondsThisWeek: number | string; - + /** Format: int32 */ streakDays: number | string; }; MixApplicationResponse: { - + /** Format: uuid */ id: string; title: string; subtitle: null | string; - + /** Format: int32 */ itemCount: number | string; items: components["schemas"]["MixItemApplicationResponse"][]; }; MixItemApplicationResponse: { - + /** Format: uuid */ trackId: string; title: string; artist: null | string; - + /** Format: double */ durationSeconds: number | string; - + /** Format: int32 */ listensCount: number | string; }; PaginatedResponseOfAlbumApplicationResponse: { items: components["schemas"]["AlbumApplicationResponse"][]; - + /** Format: int32 */ pageNumber: number | string; - + /** Format: int32 */ pageSize: number | string; - + /** Format: int32 */ pageCount: number | string; - + /** Format: int32 */ totalItemCount: number | string; hasNextPage: boolean; hasPreviousPage: boolean; }; PaginatedResponseOfPlayListApplicationResponse: { items: components["schemas"]["PlayListApplicationResponse"][]; - + /** Format: int32 */ pageNumber: number | string; - + /** Format: int32 */ pageSize: number | string; - + /** Format: int32 */ pageCount: number | string; - + /** Format: int32 */ totalItemCount: number | string; hasNextPage: boolean; hasPreviousPage: boolean; }; PaginatedResponseOfTrackApplicationResponse: { items: components["schemas"]["TrackApplicationResponse"][]; - + /** Format: int32 */ pageNumber: number | string; - + /** Format: int32 */ pageSize: number | string; - + /** Format: int32 */ pageCount: number | string; - + /** Format: int32 */ totalItemCount: number | string; hasNextPage: boolean; hasPreviousPage: boolean; }; PaginatedResponseOfUserApplicationResponse: { items: components["schemas"]["UserApplicationResponse"][]; - + /** Format: int32 */ pageNumber: number | string; - + /** Format: int32 */ pageSize: number | string; - + /** Format: int32 */ pageCount: number | string; - + /** Format: int32 */ totalItemCount: number | string; hasNextPage: boolean; hasPreviousPage: boolean; }; PlaybackPublicConfigResponse: { allowAnonymousListening: boolean; - + /** Format: int32 */ anonymousFragmentSeconds: number | string; }; PlayListApplicationResponse: { - + /** Format: uuid */ id: string; name: string; description: null | string; @@ -844,84 +892,86 @@ export interface components { mediumImageKeyName: null | string; largeImageKeyName: null | string; visibility: components["schemas"]["PlaylistVisibility"]; - + /** Format: date-time */ createdAt: string; - + /** Format: date-time */ updatedAt: string; coverTrackIds: string[]; }; PlayListPictureUploadResponse: { - + /** Format: uuid */ intentId: string; bucket: string; key: string; pictureName: string; contentType: string; - + /** Format: int32 */ expiresInSeconds: number | string; uploadUrl: string; }; - + /** @enum {unknown} */ PlaylistVisibility: "Private" | "Public"; - + /** @enum {unknown} */ ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; + RecordListeningProgressRequest: { + /** Format: double */ + playedSeconds: number | string; + }; RequestAlbumPictureUploadRequest: { fileType: string; contentType: string; - + /** Format: int64 */ expectedSizeBytes?: null | number | string; }; RequestPlayListPictureUploadRequest: { fileType: string; contentType: string; - + /** Format: int64 */ expectedSizeBytes?: null | number | string; }; - RecordListeningProgressRequest: { - playedSeconds: number | string; - }; RequestTrackUploadUrlsRequest: { pictureFileType: string; pictureContentType: string; audioFileType: string; audioContentType: string; - + /** Format: int64 */ expectedPictureSizeBytes?: null | number | string; - + /** Format: int64 */ expectedAudioSizeBytes?: null | number | string; }; ToggleLikeRequest: { - + /** Format: uuid */ trackId: string; }; TrackApplicationResponse: { - + /** Format: uuid */ id: string; title: string; artist: null | string; audioStatus: components["schemas"]["ProcessingStatus"]; - + /** Format: double */ duration: number | string; - + /** Format: int32 */ listensCount: number | string; - + /** Format: date-time */ createdAt: string; - + /** Format: date-time */ updatedAt: string; ownerUserId: string; + tags: components["schemas"]["Genre"][]; }; TrackSearchItemResponse: { track: components["schemas"]["TrackApplicationResponse"]; }; 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; hasNextPage: boolean; @@ -929,15 +979,15 @@ export interface components { TrackStreamResponse: { manifestUrl: string; ticket: string; - + /** Format: int32 */ expiresInSeconds: number | string; /** Format: uuid */ - listenId?: string | null; + listenId?: null | string; }; TrackUploadUrlsResponse: { - + /** Format: uuid */ pictureIntentId: string; - + /** Format: uuid */ audioIntentId: string; bucket: string; pictureKey: string; @@ -948,21 +998,21 @@ export interface components { audioName: string; audioContentType: string; audioUploadUrl: string; - + /** Format: int32 */ expiresInSeconds: number | string; }; UpdateAlbumRequest: { newTitle: string; newDescription: null | string; - + /** Format: int32 */ newReleaseYear: null | number | string; - + /** Format: uuid */ newPictureIntentId: null | string; }; UpdatePlayListRequest: { newName: null | string; newDescription: null | string; - + /** Format: uuid */ newPictureIntentId: null | string; newVisibility?: null | components["schemas"]["PlaylistVisibility"]; }; @@ -972,9 +1022,9 @@ export interface components { firstName: null | string; secondName: null | string; profilePictureUrl: null | string; - + /** Format: date-time */ createdAt: string; - + /** Format: date-time */ updatedAt: string; }; UserProfileResponse: { @@ -983,11 +1033,11 @@ export interface components { firstName: null | string; secondName: null | string; profilePictureUrl: null | string; - + /** Format: date-time */ createdAt: string; - + /** Format: int32 */ followersCount: number | string; - + /** Format: int32 */ followingCount: number | string; }; }; @@ -1012,7 +1062,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1036,7 +1086,7 @@ export interface operations { }; }; responses: { - + /** @description Created */ 201: { headers: { [name: string]: unknown; @@ -1045,7 +1095,7 @@ export interface operations { "application/json": components["schemas"]["AlbumApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1054,14 +1104,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1081,7 +1131,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1090,7 +1140,7 @@ export interface operations { "application/json": components["schemas"]["AlbumApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1113,7 +1163,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1122,7 +1172,7 @@ export interface operations { "application/json": components["schemas"]["PaginatedResponseOfAlbumApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1142,7 +1192,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1151,7 +1201,7 @@ export interface operations { "application/json": components["schemas"]["AlbumApplicationResponse"][]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; @@ -1174,7 +1224,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1183,7 +1233,7 @@ export interface operations { "application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1205,14 +1255,14 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1234,7 +1284,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1243,7 +1293,7 @@ export interface operations { "application/json": components["schemas"]["AlbumPictureUploadResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1252,14 +1302,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1283,7 +1333,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1292,7 +1342,7 @@ export interface operations { "application/json": components["schemas"]["AlbumApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1301,14 +1351,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1328,21 +1378,21 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description No Content */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1363,28 +1413,28 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description No Content */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -1405,21 +1455,21 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description No Content */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1437,7 +1487,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1448,6 +1498,46 @@ export interface operations { }; }; }; + GetGenres: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GenreResponse"][]; + }; + }; + }; + }; + GetAvailableGenres: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailableGenreResponse"][]; + }; + }; + }; + }; GetLikedTracks: { parameters: { query?: { @@ -1460,7 +1550,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1469,7 +1559,7 @@ export interface operations { "application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; @@ -1491,7 +1581,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1500,14 +1590,14 @@ export interface operations { "application/json": boolean; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1525,7 +1615,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1534,7 +1624,7 @@ export interface operations { "application/json": components["schemas"]["MixApplicationResponse"][]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; @@ -1554,7 +1644,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1563,14 +1653,14 @@ export interface operations { "application/json": components["schemas"]["MixApplicationResponse"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1591,7 +1681,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1615,7 +1705,7 @@ export interface operations { }; }; responses: { - + /** @description Created */ 201: { headers: { [name: string]: unknown; @@ -1624,7 +1714,7 @@ export interface operations { "application/json": components["schemas"]["PlayListApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1633,14 +1723,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1660,7 +1750,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1669,7 +1759,7 @@ export interface operations { "application/json": components["schemas"]["PlayListApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1694,7 +1784,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1719,7 +1809,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1728,7 +1818,7 @@ export interface operations { "application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1752,7 +1842,7 @@ export interface operations { }; }; responses: { - + /** @description Created */ 201: { headers: { [name: string]: unknown; @@ -1761,21 +1851,21 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -1797,14 +1887,14 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1828,7 +1918,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1837,7 +1927,7 @@ export interface operations { "application/json": components["schemas"]["PlayListApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1846,14 +1936,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1873,21 +1963,21 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description No Content */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1909,7 +1999,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1918,7 +2008,7 @@ export interface operations { "application/json": components["schemas"]["PlayListPictureUploadResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1927,14 +2017,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1955,21 +2045,21 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description No Content */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1982,6 +2072,7 @@ export interface operations { parameters: { query?: { name?: string; + genre?: string; pageNumber?: number | string; pageSize?: number | string; }; @@ -1991,7 +2082,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2000,6 +2091,15 @@ export interface operations { "application/json": components["schemas"]["TracksSearchResponse"]; }; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; }; }; CreateTrack: { @@ -2015,7 +2115,7 @@ export interface operations { }; }; responses: { - + /** @description Created */ 201: { headers: { [name: string]: unknown; @@ -2024,7 +2124,7 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -2033,21 +2133,21 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -2067,7 +2167,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2076,7 +2176,7 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2098,14 +2198,14 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2125,7 +2225,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2134,14 +2234,52 @@ export interface operations { "application/json": components["schemas"]["TrackStreamResponse"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + RecordListeningProgress: { + parameters: { + query?: never; + header?: never; + path: { + listenId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecordListeningProgressRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2165,7 +2303,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2187,28 +2325,28 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description No Content */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -2217,41 +2355,6 @@ export interface operations { }; }; }; - RecordListeningProgress: { - parameters: { - query?: never; - header?: never; - path: { - listenId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["RecordListeningProgressRequest"]; - }; - }; - responses: { - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; RequestTrackUploadUrls: { parameters: { query?: never; @@ -2265,7 +2368,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2274,7 +2377,7 @@ export interface operations { "application/json": components["schemas"]["TrackUploadUrlsResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -2283,21 +2386,21 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -2319,7 +2422,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2343,7 +2446,7 @@ export interface operations { }; }; responses: { - + /** @description Created */ 201: { headers: { [name: string]: unknown; @@ -2352,7 +2455,7 @@ export interface operations { "application/json": components["schemas"]["UserApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -2361,7 +2464,7 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -2381,7 +2484,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2390,7 +2493,7 @@ export interface operations { "application/json": components["schemas"]["UserApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2410,7 +2513,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2419,7 +2522,7 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"][]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2439,7 +2542,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2461,7 +2564,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2470,7 +2573,7 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2490,7 +2593,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2499,7 +2602,7 @@ export interface operations { "application/json": components["schemas"]["UserProfileResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2519,7 +2622,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2528,7 +2631,7 @@ export interface operations { "application/json": boolean; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; @@ -2548,14 +2651,14 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description No Content */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -2564,14 +2667,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2591,14 +2694,14 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description No Content */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; diff --git a/web-player/src/lib/data/genres.ts b/web-player/src/lib/data/genres.ts index 8dbc9f2..a7b1c64 100644 --- a/web-player/src/lib/data/genres.ts +++ b/web-player/src/lib/data/genres.ts @@ -1,22 +1,75 @@ -export const GENRE_INFO: Record = { - pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, - rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, - reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' }, - 'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' }, - indie: { label: 'Indie', tagline: 'Voces fuera del radar' }, - salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' }, - 'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' }, - electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' }, - jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' }, - baladas: { label: 'Baladas', tagline: 'Para sentir con calma' }, - trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' }, - cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' }, - 'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' }, - 'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' } +const GENRE_INFO: Record = { + Pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, + Rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, + HipHop: { label: 'Hip hop', tagline: 'Rimas con actitud' }, + RnB: { label: 'R&B', tagline: 'Ritmo suave y voces con alma' }, + Jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' }, + Blues: { label: 'Blues', tagline: 'Sentimiento en cada nota' }, + Classical: { label: 'Clásica', tagline: 'Siglos de composición' }, + Electronic: { label: 'Electrónica', tagline: 'Beats para perderte' }, + House: { label: 'House', tagline: 'Pista de baile sin fin' }, + Techno: { label: 'Techno', tagline: 'Pulso hipnótico' }, + Trance: { label: 'Trance', tagline: 'Melodías que te elevan' }, + Dubstep: { label: 'Dubstep', tagline: 'Bajos que hacen temblar' }, + DrumAndBass: { label: 'Drum and bass', tagline: 'Velocidad y graves' }, + Metal: { label: 'Metal', tagline: 'Riffs pesados' }, + Punk: { label: 'Punk', tagline: 'Rápido, crudo y directo' }, + Reggae: { label: 'Reggae', tagline: 'Buenas vibraciones' }, + Reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' }, + Country: { label: 'Country', tagline: 'Historias con guitarra' }, + Folk: { label: 'Folk', tagline: 'Raíces y acústico' }, + Indie: { label: 'Indie', tagline: 'Voces fuera del radar' }, + KPop: { label: 'K-pop', tagline: 'Coreografías pegajosas' }, + Latin: { label: 'Latina', tagline: 'Sabor y ritmo' }, + Soul: { label: 'Soul', tagline: 'Emoción en estado puro' }, + Funk: { label: 'Funk', tagline: 'Groove imposible de quedarse quieto' }, + Ambient: { label: 'Ambient', tagline: 'Paisajes sonoros' }, + Lofi: { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' } }; -export const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({ - query, - hue: Math.round((i * 360) / all.length), - ...info -})); +const KNOWN_GENRES = Object.keys(GENRE_INFO); + +function humanize(name: string): string { + return name.replace(/([a-z])([A-Z])/g, '$1 $2'); +} + +export function genreInfo(genre: string) { + return GENRE_INFO[genre] ?? { label: humanize(genre), tagline: '' }; +} + +export function genreHue(genre: string): number { + const known = KNOWN_GENRES.indexOf(genre); + if (known >= 0) return Math.round((known * 360) / KNOWN_GENRES.length); + let hash = 0; + for (const char of genre) hash = (hash * 31 + char.charCodeAt(0)) % 360; + return hash; +} + +export function genreTiles(genres: { genre: string }[]) { + return genres.map(({ genre }) => ({ + genre, + hue: genreHue(genre), + ...genreInfo(genre) + })); +} + +export function genreOptions(genres: { genre: string; incompatibleWith: string[] }[]) { + return genres.map(({ genre, incompatibleWith }) => ({ + genre, + incompatibleWith, + label: genreInfo(genre).label + })); +} + +export function findConflict( + tags: string[], + options: { genre: string; incompatibleWith: string[] }[] +): [string, string] | undefined { + const byGenre = new Map(options.map((option) => [option.genre, option.incompatibleWith])); + for (let i = 0; i < tags.length; i++) { + for (let j = i + 1; j < tags.length; j++) { + if (byGenre.get(tags[i])?.includes(tags[j])) return [tags[i], tags[j]]; + } + } + return undefined; +} diff --git a/web-player/src/lib/data/trackGenres.ts b/web-player/src/lib/data/trackGenres.ts deleted file mode 100644 index 9e64458..0000000 --- a/web-player/src/lib/data/trackGenres.ts +++ /dev/null @@ -1,56 +0,0 @@ -export const TRACK_GENRES = [ - { value: 'Pop', label: 'Pop' }, - { value: 'Rock', label: 'Rock' }, - { value: 'HipHop', label: 'Hip-hop' }, - { value: 'RnB', label: 'R&B' }, - { value: 'Jazz', label: 'Jazz' }, - { value: 'Blues', label: 'Blues' }, - { value: 'Classical', label: 'Clásica' }, - { value: 'Electronic', label: 'Electrónica' }, - { value: 'House', label: 'House' }, - { value: 'Techno', label: 'Techno' }, - { value: 'Trance', label: 'Trance' }, - { value: 'Dubstep', label: 'Dubstep' }, - { value: 'DrumAndBass', label: 'Drum and bass' }, - { value: 'Metal', label: 'Metal' }, - { value: 'Punk', label: 'Punk' }, - { value: 'Reggae', label: 'Reggae' }, - { value: 'Reggaeton', label: 'Reggaetón' }, - { value: 'Country', label: 'Country' }, - { value: 'Folk', label: 'Folk' }, - { value: 'Indie', label: 'Indie' }, - { value: 'KPop', label: 'K-pop' }, - { value: 'Latin', label: 'Latina' }, - { value: 'Soul', label: 'Soul' }, - { value: 'Funk', label: 'Funk' }, - { value: 'Ambient', label: 'Ambient' }, - { value: 'Lofi', label: 'Lo-fi' } -] as const; - -export type TrackGenre = (typeof TRACK_GENRES)[number]['value']; - -const GENRE_VALUES: ReadonlySet = new Set(TRACK_GENRES.map((genre) => genre.value)); - -const INTENSE: ReadonlySet = new Set(['Metal', 'Punk', 'Dubstep', 'DrumAndBass']); -const MELLOW: ReadonlySet = new Set(['Classical', 'Ambient', 'Lofi']); - -export function isTrackGenre(value: string): value is TrackGenre { - return GENRE_VALUES.has(value); -} - -export function areCompatible(a: TrackGenre, b: TrackGenre): boolean { - return !((INTENSE.has(a) && MELLOW.has(b)) || (INTENSE.has(b) && MELLOW.has(a))); -} - -export function genreLabel(value: TrackGenre): string { - return TRACK_GENRES.find((genre) => genre.value === value)?.label ?? value; -} - -export function findConflict(tags: readonly TrackGenre[]): [TrackGenre, TrackGenre] | undefined { - for (let i = 0; i < tags.length; i++) { - for (let j = i + 1; j < tags.length; j++) { - if (!areCompatible(tags[i], tags[j])) return [tags[i], tags[j]]; - } - } - return undefined; -} diff --git a/web-player/src/lib/state/navigation.svelte.ts b/web-player/src/lib/state/navigation.svelte.ts index 0d32598..8b1414d 100644 --- a/web-player/src/lib/state/navigation.svelte.ts +++ b/web-player/src/lib/state/navigation.svelte.ts @@ -68,6 +68,10 @@ export function searchHref(query: string): string { return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore'; } +export function genreHref(genre: string): string { + return `/explore?genre=${encodeURIComponent(genre)}`; +} + export interface AppNavLink { href: string; label: string; diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 51f7f37..7068513 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -9,13 +9,25 @@ import { export const load: PageServerLoad = async ({ url, locals, fetch }) => { const query = url.searchParams.get('q')?.trim() ?? ''; + const genreParam = url.searchParams.get('genre')?.trim() || null; const page = Math.max(1, Number(url.searchParams.get('page')) || 1); const user = locals.user; const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const genresRes = await api.GET('/genres'); + const genres = genresRes.data ?? []; + const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null; + const tracksPromise = api.GET('/tracks', { - params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } } + params: { + query: { + name: query || undefined, + genre: genre ?? undefined, + pageNumber: page, + pageSize: PAGE_SIZE + } + } }); const playlistsPromise = user @@ -47,6 +59,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { return { query, + genre, + genres, tracks, albums: (albumsRes?.data?.items ?? []).map((item) => item.album), users: usersRes?.data?.items ?? [], diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index 4d90242..2045a9d 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -16,13 +16,13 @@ import Chip from '$lib/components/ui/primitives/Chip.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import { EXPLORE_PAGE_SIZE } from '$lib/config'; - import { searchHref } from '$lib/state/navigation.svelte'; + import { genreHref } from '$lib/state/navigation.svelte'; import { createTrackMenu } from '$lib/state/menus.svelte'; import ContextMenu, { contextMenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte'; import ListPlus from '@lucide/svelte/icons/list-plus'; import ListEnd from '@lucide/svelte/icons/list-end'; import { appendUnique } from '$lib/data/collections'; - import { genreTiles } from '$lib/data/genres'; + import { genreInfo, genreTiles } from '$lib/data/genres'; import { type SearchFilter, showGroup, @@ -58,7 +58,7 @@ $effect(() => { trackMenu.close(); albumMenu = null; - sfilter = 'Todo'; + sfilter = data.genre ? 'Canciones' : 'Todo'; localItems = data.tracks.items.map((item) => item.track); localPage = Number(data.tracks.pageNumber); hasMore = data.tracks.hasNextPage; @@ -73,6 +73,9 @@ const items = $derived(songRows.map((row) => row.track)); + const tiles = $derived(genreTiles(data.genres)); + const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null); + const albums = $derived(data.albums); const users = $derived(data.users); @@ -169,6 +172,7 @@ pageSize: String(EXPLORE_PAGE_SIZE) }); if (data.query) params.set('name', data.query); + if (data.genre) params.set('genre', data.genre); 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; @@ -188,7 +192,9 @@ - {data.query ? `Buscar «${data.query}»` : 'Descubrir'} + {activeGenre ? activeGenre.label : data.query ? `Buscar «${data.query}»` : 'Descubrir'} - {#if !data.query} + {#if activeGenre} + + {:else if !data.query}
- {#each genreTiles.slice(0, 4) as genre (genre.query)} - {genre.label} + {#each tiles.slice(0, 4) as tile (tile.genre)} + {tile.label} {/each}
{/if} - {#if !data.query} + {#if !data.query && !data.genre}
- {#each genreTiles as genre, i (genre.query)} + {#each tiles as tile, i (tile.genre)}
- {genre.label} + {tile.label}
-
{genre.tagline}
+
{tile.tagline}
{/each}
+ {#if tiles.length === 0} + + {/if} {/if} - {#if data.query && !nothingFound} + {#if (data.query || data.genre) && !nothingFound}
{#if showGroup(sfilter, 'Canciones')}
@@ -364,7 +375,9 @@ {:else} {/if}
diff --git a/web-player/src/routes/(app)/upload/+page.server.ts b/web-player/src/routes/(app)/upload/+page.server.ts index 53a7bdd..a8adcec 100644 --- a/web-player/src/routes/(app)/upload/+page.server.ts +++ b/web-player/src/routes/(app)/upload/+page.server.ts @@ -7,12 +7,18 @@ import { unwrapOrFail } from '$lib/server/api'; import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; -import { findConflict, genreLabel, isTrackGenre, type TrackGenre } from '$lib/data/trackGenres'; +import { findConflict, genreInfo } from '$lib/data/genres'; +import type { components } from '$lib/api/schema'; + +type Genre = components['schemas']['Genre']; const MAX_TITLE = 100; -export const load: PageServerLoad = ({ locals, url }) => { +export const load: PageServerLoad = async ({ locals, url, fetch }) => { requireUser(locals, url); + const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const { data } = await api.GET('/genres/available'); + return { genres: data ?? [] }; }; export const actions: Actions = { @@ -39,20 +45,23 @@ export const actions: Actions = { if (rawTags.length === 0) { return fail(400, { message: 'Elige al menos un género.' }); } - if (!rawTags.every(isTrackGenre)) { + const api = createApiClient({ fetch, accessToken }); + const { data: available } = await api.GET('/genres/available'); + const options = available ?? []; + const known = new Set(options.map((option) => option.genre)); + if (!rawTags.every((tag) => known.has(tag))) { return fail(400, { message: 'Alguno de los géneros no es válido.' }); } - const tags: TrackGenre[] = [...new Set(rawTags)]; - const conflict = findConflict(tags); + const tags = [...new Set(rawTags)] as Genre[]; + const conflict = findConflict(tags, options); if (conflict) { return fail(400, { - message: `Los géneros ${genreLabel(conflict[0])} y ${genreLabel(conflict[1])} no se pueden combinar.` + message: `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.` }); } const audioContentType = contentTypeOf(audio, AUDIO_TYPES); const pictureContentType = contentTypeOf(cover, IMAGE_TYPES); - const api = createApiClient({ fetch, accessToken }); const urlsResult = await api.POST('/tracks/upload-urls', { body: { diff --git a/web-player/src/routes/(app)/upload/+page.svelte b/web-player/src/routes/(app)/upload/+page.svelte index 28e907d..c3d3100 100644 --- a/web-player/src/routes/(app)/upload/+page.svelte +++ b/web-player/src/routes/(app)/upload/+page.svelte @@ -14,13 +14,15 @@ import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte'; import Chip from '$lib/components/ui/primitives/Chip.svelte'; - import { TRACK_GENRES, areCompatible, type TrackGenre } from '$lib/data/trackGenres'; + import { genreOptions } from '$lib/data/genres'; type Status = 'idle' | 'uploading' | 'done' | 'error'; type StepState = 'idle' | 'active' | 'done'; const MAX_TITLE = 100; + let { data } = $props(); + let title = $state(''); let audioName = $state(''); let audioSize = $state(0); @@ -31,7 +33,7 @@ let acceptedTerms = $state(false); let errorMsg = $state(''); let errorDetail = $state(''); - let tags = $state([]); + let tags = $state([]); let publishedTitle = $state(''); let audioInput = $state(); @@ -41,6 +43,15 @@ { key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' } ]; + const options = $derived(genreOptions(data.genres)); + const blocked = $derived( + new Set( + options + .filter((option) => tags.includes(option.genre)) + .flatMap((option) => option.incompatibleWith) + ) + ); + const busy = $derived(status === 'uploading'); const canPublish = $derived( @@ -85,14 +96,10 @@ pickAudio(file); } - function toggleTag(tag: TrackGenre) { + function toggleTag(tag: string) { tags = tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag]; } - function tagBlocked(tag: TrackGenre) { - return !tags.includes(tag) && tags.some((current) => !areCompatible(current, tag)); - } - function reset() { title = ''; audioName = ''; @@ -254,13 +261,13 @@
- {#each TRACK_GENRES as genre (genre.value)} + {#each options as option (option.genre)} toggleTag(genre.value)} + selected={tags.includes(option.genre)} + disabled={blocked.has(option.genre)} + onclick={() => toggleTag(option.genre)} > - {genre.label} + {option.label} {/each}
diff --git a/web-player/src/routes/api/tracks/+server.ts b/web-player/src/routes/api/tracks/+server.ts index 4823c8c..a460898 100644 --- a/web-player/src/routes/api/tracks/+server.ts +++ b/web-player/src/routes/api/tracks/+server.ts @@ -5,12 +5,13 @@ import { EXPLORE_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ url, locals, fetch }) => { const name = url.searchParams.get('name')?.trim() || undefined; + const genre = url.searchParams.get('genre')?.trim() || undefined; const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const result = await api.GET('/tracks', { - params: { query: { name, pageNumber, pageSize } } + params: { query: { name, genre, pageNumber, pageSize } } }); return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));