From b61ff1ebec827cc1db3bbe88bd17bf8e27e99588 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 23 Sep 2026 20:05:30 +0200 Subject: [PATCH] Make Discover and upload genres dynamic Discover used a hardcoded list of genre tiles that searched track titles, and the upload form kept its own copy of the genre list and compatibility rules. Both now come from the backend. Backend: - GET /genres returns genres with at least one track and their counts. - GET /genres/available returns every assignable genre with the genres it cannot be combined with, so the rules live only in GenreCompatibility. - GET /tracks accepts ?genre= (case-insensitive, 400 on unknown values). web-player: - Discover tiles are built from /genres; each opens /explore?genre=X, a song list filtered by that tag. Unknown genres fall back to a label derived from their name. - The upload form loads /genres/available, disables incompatible genres as you pick, and the server validates before reserving upload URLs. - lib/data/trackGenres.ts is removed; its helpers merge into lib/data/genres.ts. - Regenerate the typed API client. --- .../Genres/GetAvailableGenres.cs | 26 + .../Musify.Application/Genres/GetGenres.cs | 31 + .../Musify.Application/Tracks/GetTracks.cs | 6 +- .../Musify.Api/Endpoints/GenreEndpoints.cs | 39 + .../Musify.Api/Endpoints/TrackEndpoints.cs | 23 +- backend/Hosts/Musify.Api/Program.cs | 1 + .../Endpoints/GenreEndpointsTests.cs | 106 +++ .../GetAvailableGenresQueryHandlerTests.cs | 32 + .../Genres/GetGenresQueryHandlerTests.cs | 69 ++ .../Tracks/GetTracksQueryHandlerTests.cs | 19 + web-player/src/lib/api/schema.d.ts | 699 ++++++++++-------- web-player/src/lib/data/genres.ts | 93 ++- web-player/src/lib/data/trackGenres.ts | 56 -- web-player/src/lib/state/navigation.svelte.ts | 4 + .../src/routes/(app)/explore/+page.server.ts | 16 +- .../src/routes/(app)/explore/+page.svelte | 43 +- .../src/routes/(app)/upload/+page.server.ts | 23 +- .../src/routes/(app)/upload/+page.svelte | 31 +- web-player/src/routes/api/tracks/+server.ts | 3 +- 19 files changed, 906 insertions(+), 414 deletions(-) create mode 100644 backend/Core/Musify.Application/Genres/GetAvailableGenres.cs create mode 100644 backend/Core/Musify.Application/Genres/GetGenres.cs create mode 100644 backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs create mode 100644 backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs delete mode 100644 web-player/src/lib/data/trackGenres.ts 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.'));