Merge pull request 'Dynamic genres' (#20) from dynamic-genres into master
CI/CD / BackEnd (push) Successful in 1m23s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m38s

Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
2026-09-23 22:24:22 +00:00
19 changed files with 906 additions and 414 deletions
@@ -0,0 +1,26 @@
using Mediator;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
}
}
}
@@ -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<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> 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();
}
}
}
@@ -5,10 +5,11 @@ using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
public record GetTracksQuery(string? Name, int PageNumber, int PageSize) public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
: IQuery<ErrorOr<TracksSearchResponse>>; : IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database) public class GetTracksQueryHandler(IDatabase database)
@@ -28,6 +29,9 @@ namespace Musify.Application.Tracks
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); 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 totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
@@ -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<IReadOnlyList<GenreResponse>>();
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<IReadOnlyList<AvailableGenreResponse>>();
return app;
}
private static async Task<IReadOnlyList<AvailableGenreResponse>> GetAvailableGenres(
IMediator mediator,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken);
}
private static async Task<IReadOnlyList<GenreResponse>> GetGenres(
IMediator mediator,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetGenresQuery(), cancellationToken);
}
}
@@ -1,4 +1,5 @@
using Mediator; using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
@@ -19,8 +20,9 @@ public static class TrackEndpoints
group.MapGet("/", GetTracks) group.MapGet("/", GetTracks)
.WithName("GetTracks") .WithName("GetTracks")
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name.") .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
.Produces<TracksSearchResponse>(); .Produces<TracksSearchResponse>()
.ProducesValidationProblem();
group.MapGet("/{id}", GetTrackById) group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById") .WithName("GetTrackById")
@@ -92,10 +94,25 @@ public static class TrackEndpoints
IMediator mediator, IMediator mediator,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? name, string? name,
string? genre,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 10) 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>(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[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(); return result.ToHttpResult();
} }
+1
View File
@@ -40,6 +40,7 @@ app.UseAuthorization();
app app
.MapAlbumEndpoints() .MapAlbumEndpoints()
.MapConfigEndpoints() .MapConfigEndpoints()
.MapGenreEndpoints()
.MapLikeEndpoints() .MapLikeEndpoints()
.MapMixEndpoints() .MapMixEndpoints()
.MapPlayListEndpoints() .MapPlayListEndpoints()
@@ -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<string> SeedTrackAsync(Genre genre)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
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<List<GenreResponse>>(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<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Equal(Enum.GetValues<Genre>().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<TracksSearchResponse>(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<TracksSearchResponse>(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));
}
}
}
@@ -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<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ReturnsEveryGenre()
{
var result = await RunAsync();
Assert.Equal(Enum.GetValues<Genre>(), 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);
}
}
}
@@ -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);
}
}
}
@@ -1,5 +1,6 @@
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks namespace Musify.Application.Tests.Tracks
@@ -43,5 +44,23 @@ namespace Musify.Application.Tests.Tracks
var item = Assert.Single(result.Value.Items); var item = Assert.Single(result.Value.Items);
Assert.Equal("Bohemian Rhapsody", item.Track.Title); 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);
}
} }
} }
+401 -298
View File
File diff suppressed because it is too large Load Diff
+72 -19
View File
@@ -1,22 +1,75 @@
export const GENRE_INFO: Record<string, { label: string; tagline: string }> = { const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, Pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, Rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' }, HipHop: { label: 'Hip hop', tagline: 'Rimas con actitud' },
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' }, RnB: { label: 'R&B', tagline: 'Ritmo suave y voces con alma' },
indie: { label: 'Indie', tagline: 'Voces fuera del radar' }, Jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' }, Blues: { label: 'Blues', tagline: 'Sentimiento en cada nota' },
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' }, Classical: { label: 'Clásica', tagline: 'Siglos de composición' },
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' }, Electronic: { label: 'Electrónica', tagline: 'Beats para perderte' },
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' }, House: { label: 'House', tagline: 'Pista de baile sin fin' },
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' }, Techno: { label: 'Techno', tagline: 'Pulso hipnótico' },
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' }, Trance: { label: 'Trance', tagline: 'Melodías que te elevan' },
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' }, Dubstep: { label: 'Dubstep', tagline: 'Bajos que hacen temblar' },
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' }, DrumAndBass: { label: 'Drum and bass', tagline: 'Velocidad y graves' },
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' } 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) => ({ const KNOWN_GENRES = Object.keys(GENRE_INFO);
query,
hue: Math.round((i * 360) / all.length), function humanize(name: string): string {
...info 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;
}
-56
View File
@@ -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<string> = new Set(TRACK_GENRES.map((genre) => genre.value));
const INTENSE: ReadonlySet<TrackGenre> = new Set(['Metal', 'Punk', 'Dubstep', 'DrumAndBass']);
const MELLOW: ReadonlySet<TrackGenre> = 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;
}
@@ -68,6 +68,10 @@ export function searchHref(query: string): string {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore'; return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
} }
export function genreHref(genre: string): string {
return `/explore?genre=${encodeURIComponent(genre)}`;
}
export interface AppNavLink { export interface AppNavLink {
href: string; href: string;
label: string; label: string;
@@ -9,13 +9,25 @@ import {
export const load: PageServerLoad = async ({ url, locals, fetch }) => { export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const query = url.searchParams.get('q')?.trim() ?? ''; 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 page = Math.max(1, Number(url.searchParams.get('page')) || 1);
const user = locals.user; const user = locals.user;
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); 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', { 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 const playlistsPromise = user
@@ -47,6 +59,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
return { return {
query, query,
genre,
genres,
tracks, tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => item.album), albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
users: usersRes?.data?.items ?? [], users: usersRes?.data?.items ?? [],
@@ -16,13 +16,13 @@
import Chip from '$lib/components/ui/primitives/Chip.svelte'; import Chip from '$lib/components/ui/primitives/Chip.svelte';
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE } from '$lib/config'; 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 { createTrackMenu } from '$lib/state/menus.svelte';
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte'; import ContextMenu, { contextMenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import ListEnd from '@lucide/svelte/icons/list-end'; import ListEnd from '@lucide/svelte/icons/list-end';
import { appendUnique } from '$lib/data/collections'; import { appendUnique } from '$lib/data/collections';
import { genreTiles } from '$lib/data/genres'; import { genreInfo, genreTiles } from '$lib/data/genres';
import { import {
type SearchFilter, type SearchFilter,
showGroup, showGroup,
@@ -58,7 +58,7 @@
$effect(() => { $effect(() => {
trackMenu.close(); trackMenu.close();
albumMenu = null; albumMenu = null;
sfilter = 'Todo'; sfilter = data.genre ? 'Canciones' : 'Todo';
localItems = data.tracks.items.map((item) => item.track); localItems = data.tracks.items.map((item) => item.track);
localPage = Number(data.tracks.pageNumber); localPage = Number(data.tracks.pageNumber);
hasMore = data.tracks.hasNextPage; hasMore = data.tracks.hasNextPage;
@@ -73,6 +73,9 @@
const items = $derived(songRows.map((row) => row.track)); 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 albums = $derived(data.albums);
const users = $derived(data.users); const users = $derived(data.users);
@@ -169,6 +172,7 @@
pageSize: String(EXPLORE_PAGE_SIZE) pageSize: String(EXPLORE_PAGE_SIZE)
}); });
if (data.query) params.set('name', data.query); if (data.query) params.set('name', data.query);
if (data.genre) params.set('genre', data.genre);
const res = await fetch(`/api/tracks?${params}`); const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks; const next = (await res.json()) as typeof data.tracks;
@@ -188,7 +192,9 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title> <title
>{activeGenre ? activeGenre.label : data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title
>
<meta <meta
name="description" name="description"
content="Busca canciones, álbumes y personas, o descubre por género en Musify." content="Busca canciones, álbumes y personas, o descubre por género en Musify."
@@ -196,7 +202,9 @@
</svelte:head> </svelte:head>
<Page> <Page>
{#if !data.query} {#if activeGenre}
<PageHeader eyebrow="Género" title={activeGenre.label} description={activeGenre.tagline} />
{:else if !data.query}
<PageHeader <PageHeader
title="Descubrir" title="Descubrir"
description="Encuentra tu próxima canción favorita explorando por género." description="Encuentra tu próxima canción favorita explorando por género."
@@ -300,31 +308,34 @@
por playlist. por playlist.
</p> </p>
<div class="mt-5.5 flex flex-wrap justify-center gap-2"> <div class="mt-5.5 flex flex-wrap justify-center gap-2">
{#each genreTiles.slice(0, 4) as genre (genre.query)} {#each tiles.slice(0, 4) as tile (tile.genre)}
<Chip href={searchHref(genre.query)}>{genre.label}</Chip> <Chip href={genreHref(tile.genre)}>{tile.label}</Chip>
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
{#if !data.query} {#if !data.query && !data.genre}
<div class="grid-wide"> <div class="grid-wide">
{#each genreTiles as genre, i (genre.query)} {#each tiles as tile, i (tile.genre)}
<a <a
href={searchHref(genre.query)} href={genreHref(tile.genre)}
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4 transition-[filter] genre-tile hover:brightness-110" class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4 transition-[filter] genre-tile hover:brightness-110"
style="--i:{i}; --tile-hue:{genre.hue}" style="--i:{i}; --tile-hue:{tile.hue}"
> >
<div class="text-on-art"> <div class="text-on-art">
{genre.label} {tile.label}
</div> </div>
<div class="text-xs text-on-art-2">{genre.tagline}</div> <div class="text-xs text-on-art-2">{tile.tagline}</div>
</a> </a>
{/each} {/each}
</div> </div>
{#if tiles.length === 0}
<EmptyState icon={Music} description="Todavía no hay géneros. Sube música para empezar." />
{/if}
{/if} {/if}
{#if data.query && !nothingFound} {#if (data.query || data.genre) && !nothingFound}
<div class="mt-9 flex flex-col gap-9"> <div class="mt-9 flex flex-col gap-9">
{#if showGroup(sfilter, 'Canciones')} {#if showGroup(sfilter, 'Canciones')}
<div> <div>
@@ -364,7 +375,9 @@
{:else} {:else}
<EmptyState <EmptyState
icon={Music} icon={Music}
description="No hay canciones que coincidan con «{data.query}»." description={activeGenre
? 'Todavía no hay canciones de este género.'
: `No hay canciones que coincidan con «${data.query}».`}
/> />
{/if} {/if}
</div> </div>
@@ -7,12 +7,18 @@ import {
unwrapOrFail unwrapOrFail
} from '$lib/server/api'; } from '$lib/server/api';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; 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; const MAX_TITLE = 100;
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url); 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 = { export const actions: Actions = {
@@ -39,20 +45,23 @@ export const actions: Actions = {
if (rawTags.length === 0) { if (rawTags.length === 0) {
return fail(400, { message: 'Elige al menos un género.' }); 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<string>(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.' }); return fail(400, { message: 'Alguno de los géneros no es válido.' });
} }
const tags: TrackGenre[] = [...new Set(rawTags)]; const tags = [...new Set(rawTags)] as Genre[];
const conflict = findConflict(tags); const conflict = findConflict(tags, options);
if (conflict) { if (conflict) {
return fail(400, { 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 audioContentType = contentTypeOf(audio, AUDIO_TYPES);
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES); const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
const api = createApiClient({ fetch, accessToken });
const urlsResult = await api.POST('/tracks/upload-urls', { const urlsResult = await api.POST('/tracks/upload-urls', {
body: { body: {
+19 -12
View File
@@ -14,13 +14,15 @@
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte'; import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import Chip from '$lib/components/ui/primitives/Chip.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 Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done'; type StepState = 'idle' | 'active' | 'done';
const MAX_TITLE = 100; const MAX_TITLE = 100;
let { data } = $props();
let title = $state(''); let title = $state('');
let audioName = $state(''); let audioName = $state('');
let audioSize = $state(0); let audioSize = $state(0);
@@ -31,7 +33,7 @@
let acceptedTerms = $state(false); let acceptedTerms = $state(false);
let errorMsg = $state(''); let errorMsg = $state('');
let errorDetail = $state(''); let errorDetail = $state('');
let tags = $state<TrackGenre[]>([]); let tags = $state<string[]>([]);
let publishedTitle = $state(''); let publishedTitle = $state('');
let audioInput = $state<HTMLInputElement>(); let audioInput = $state<HTMLInputElement>();
@@ -41,6 +43,15 @@
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' } { 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 busy = $derived(status === 'uploading');
const canPublish = $derived( const canPublish = $derived(
@@ -85,14 +96,10 @@
pickAudio(file); pickAudio(file);
} }
function toggleTag(tag: TrackGenre) { function toggleTag(tag: string) {
tags = tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag]; 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() { function reset() {
title = ''; title = '';
audioName = ''; audioName = '';
@@ -254,13 +261,13 @@
<Field label="Géneros"> <Field label="Géneros">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each TRACK_GENRES as genre (genre.value)} {#each options as option (option.genre)}
<Chip <Chip
selected={tags.includes(genre.value)} selected={tags.includes(option.genre)}
disabled={tagBlocked(genre.value)} disabled={blocked.has(option.genre)}
onclick={() => toggleTag(genre.value)} onclick={() => toggleTag(option.genre)}
> >
{genre.label} {option.label}
</Chip> </Chip>
{/each} {/each}
</div> </div>
+2 -1
View File
@@ -5,12 +5,13 @@ import { EXPLORE_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ url, locals, fetch }) => { export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const name = url.searchParams.get('name')?.trim() || undefined; const name = url.searchParams.get('name')?.trim() || undefined;
const genre = url.searchParams.get('genre')?.trim() || undefined;
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE); const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/tracks', { 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.')); return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));