diff --git a/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs new file mode 100644 index 0000000..55b39bc --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs @@ -0,0 +1,86 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.PlayLists.Responses; +using Musify.Domain.Entities; + +namespace Musify.Application.PlayLists +{ + public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId) + : ICommand>; + + public class AddAlbumToPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists + .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); + if (playList == null) + { + logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); + return Error.NotFound(); + } + + if (playList.UserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); + return Error.Unauthorized(); + } + + var albumExists = await database.Albums + .AsNoTracking() + .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); + if (!albumExists) + { + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(description: $"Album {request.AlbumId} not found"); + } + + var albumTrackIds = await database.AlbumHasTracks + .AsNoTracking() + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Select(albumTrack => albumTrack.TrackId) + .ToListAsync(cancellationToken); + + var playListTracksQuery = database.PlayListHasTracks + .Where(plt => plt.PlayListId == request.PlayListId); + + var existingTrackIds = (await playListTracksQuery + .Select(plt => plt.TrackId) + .ToListAsync(cancellationToken)).ToHashSet(); + + var newTrackIds = albumTrackIds + .Where(trackId => !existingTrackIds.Contains(trackId)) + .ToList(); + + if (newTrackIds.Count == 0) + return new AddAlbumToPlayListResponse(0); + + var nextPosition = existingTrackIds.Count > 0 + ? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1 + : 0; + + await database.PlayListHasTracks.AddRangeAsync( + newTrackIds.Select((trackId, index) => new PlayListHasTrack + { + PlayListId = request.PlayListId, + TrackId = trackId, + Position = nextPosition + index + }), + cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}", + newTrackIds.Count, request.AlbumId, request.PlayListId); + + return new AddAlbumToPlayListResponse(newTrackIds.Count); + } + } +} diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index 0fc11b7..ef55024 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -21,6 +21,8 @@ namespace Musify.Application.PlayLists .Select(p => new { PlayList = p, + TrackCount = p.PlayListTracks.Count, + DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0, CoverTrackIds = p.PlayListTracks .OrderBy(plt => plt.Position) .Take(PlayListApplicationResponse.CoverTrackCount) @@ -36,7 +38,7 @@ namespace Musify.Application.PlayLists && entry.PlayList.UserId != request.RequestingUserId) return Error.NotFound(); - return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds); + return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds); } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs index 07d8874..50df7ce 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs @@ -25,6 +25,8 @@ namespace Musify.Application.PlayLists .Select(p => new { PlayList = p, + TrackCount = p.PlayListTracks.Count, + DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0, CoverTrackIds = p.PlayListTracks .OrderBy(plt => plt.Position) .Take(PlayListApplicationResponse.CoverTrackCount) @@ -34,7 +36,7 @@ namespace Musify.Application.PlayLists .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var pagedPlayLists = new StaticPagedList( - pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), + pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)), pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); return PaginatedResponse.FromPagedList(pagedPlayLists); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index f877f7e..92d24f7 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -46,6 +46,8 @@ namespace Musify.Application.PlayLists .Select(p => new { PlayList = p, + TrackCount = p.PlayListTracks.Count, + DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0, CoverTrackIds = p.PlayListTracks .OrderBy(plt => plt.Position) .Take(PlayListApplicationResponse.CoverTrackCount) @@ -55,7 +57,7 @@ namespace Musify.Application.PlayLists .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var pagedPlayLists = new StaticPagedList( - pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), + pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)), pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); return PaginatedResponse.FromPagedList(pagedPlayLists); diff --git a/backend/Core/Musify.Application/PlayLists/Responses/AddAlbumToPlayListResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/AddAlbumToPlayListResponse.cs new file mode 100644 index 0000000..2439a7d --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/Responses/AddAlbumToPlayListResponse.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.PlayLists.Responses +{ + public record AddAlbumToPlayListResponse(int AddedCount); +} diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 225c5d2..1402ab7 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; +using Musify.Application.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -10,7 +12,10 @@ namespace Musify.Application.PlayLists.Responses string? SmallImageKeyName, string? MediumImageKeyName, string? LargeImageKeyName, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, PlaylistVisibility Visibility, + int TrackCount, + double DurationSeconds, DateTime CreatedAt, DateTime UpdatedAt, IReadOnlyList CoverTrackIds) @@ -19,6 +24,8 @@ namespace Musify.Application.PlayLists.Responses public static PlayListApplicationResponse FromEntity( PlayList playList, + int trackCount = 0, + double durationSeconds = 0, IReadOnlyList? coverTrackIds = null) { return new PlayListApplicationResponse( @@ -28,7 +35,10 @@ namespace Musify.Application.PlayLists.Responses playList.Pictures?.SmallName, playList.Pictures?.MediumName, playList.Pictures?.LargeName, + playList.UserId, playList.Visibility, + trackCount, + durationSeconds, playList.CreatedAt, playList.UpdatedAt, coverTrackIds ?? []); diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 46cc87a..600f629 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -112,7 +112,22 @@ namespace Musify.Application.PlayLists .ToListAsync(cancellationToken); logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id); - return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds); + var totals = await database.PlayListHasTracks + .AsNoTracking() + .Where(plt => plt.PlayListId == playListEntity.Id) + .GroupBy(plt => plt.PlayListId) + .Select(group => new + { + TrackCount = group.Count(), + DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds) + }) + .SingleOrDefaultAsync(cancellationToken); + + return PlayListApplicationResponse.FromEntity( + playListEntity, + totals?.TrackCount ?? 0, + totals?.DurationSeconds ?? 0, + coverTrackIds); } private async Task> PublishUpdatePlayListPictureSourceEventAsync( diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 8ab6dc2..7ea110b 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -94,6 +94,14 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); + group.MapPost("/{playlistId}/albums/{albumId}", AddAlbumToPlayList) + .WithName("AddAlbumToPlayList") + .WithSummary("Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It.") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList) .WithName("RemoveTrackFromPlayList") .WithSummary("Remove A Track From A PlayList.") @@ -234,6 +242,20 @@ public static class PlayListEndpoints return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); } + private static async Task AddAlbumToPlayList( + IMediator mediator, + CurrentUser currentUser, + Guid playlistId, + Guid albumId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddAlbumToPlayListCommand(currentUser.RequiredId, playlistId, albumId), + cancellationToken); + + return result.ToHttpResult(); + } + private static async Task RemoveTrackFromPlayList( IMediator mediator, CurrentUser currentUser, diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs new file mode 100644 index 0000000..b71d492 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs @@ -0,0 +1,91 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.PlayLists; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class AddAlbumToPlayListCommandHandlerTests : HandlerTestBase + { + private AddAlbumToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_AddsAlbumTracksInAlbumOrder() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var album = TestEntities.Album(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + await SeedAsync( + owner, playList, album, first, second, + new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 }); + + var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.AddedCount); + var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal([first.Id, second.Id], links.Select(link => link.TrackId)); + Assert.Equal([0, 1], links.Select(link => link.Position)); + } + + [Fact] + public async Task Handle_SkipsTracksAlreadyInPlayList_AndAppendsAfterThem() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var album = TestEntities.Album(owner.Id); + var existing = TestEntities.Track(owner, "Existing"); + var fresh = TestEntities.Track(owner, "Fresh"); + await SeedAsync( + owner, playList, album, existing, fresh, + new AlbumHasTrack { AlbumId = album.Id, TrackId = existing.Id, TrackNumber = 1 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = fresh.Id, TrackNumber = 2 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 4 }); + + var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(1, result.Value.AddedCount); + var link = await Database.PlayListHasTracks.SingleAsync(l => l.TrackId == fresh.Id, TestContext.Current.CancellationToken); + Assert.Equal(5, link.Position); + } + + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorized() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, playList, album); + + var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(stranger.Id, playList.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/web-player/.env.example b/web-player/.env.example index 75b940b..d1f1cca 100644 --- a/web-player/.env.example +++ b/web-player/.env.example @@ -11,8 +11,9 @@ ZITADEL_CLIENT_SECRET=your-web-app-client-secret AUTH_REDIRECT_URI=http://localhost:5173/auth/callback AUTH_POST_LOGOUT_URI=http://localhost:5173/ -# Secret used to derive the session cookie encryption key (any long random string) -SESSION_SECRET=change-me-to-a-long-random-string +# Secret used to derive the session cookie encryption key. Required, at least +# 32 characters (e.g. `openssl rand -base64 48`). +SESSION_SECRET=change-me-to-a-long-random-string-of-32-chars-or-more # Backend API base URL API_BASE_URL=http://localhost:5111 diff --git a/web-player/CLAUDE.md b/web-player/CLAUDE.md index 444ea62..a61cf1b 100644 --- a/web-player/CLAUDE.md +++ b/web-player/CLAUDE.md @@ -1,6 +1,6 @@ # Musify: web-player -Musify's public web app (`web-player/` folder). For design and UI recipes, use the **`musify-web`** skill. +Musify's public web app (`web-player/` folder). Design and UI recipes live in this file and in `src/routes/layout.css`. ## Stack @@ -15,7 +15,8 @@ npm run dev # dev server (port 5173) npm run build npm run check # svelte-check, must exit 0 errors before closing a change npm run format # prettier --write . -npm run lint # prettier --check . && eslint . && npm run lint:tokens +npm run test # vitest run (tests live next to the file they test) +npm run lint # prettier --check . && eslint . && npm run lint:tokens && npm run test npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below) ``` @@ -23,6 +24,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail - **No comments** in code unless essential. - `npm run lint` and `npm run check` must exit clean before closing a change. +- **Imports:** always `$lib/...`, never `../` (ESLint enforces it). Non-null assertions are forbidden too. - **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed. - **Server only code goes under `$lib/server/`**, and it's never imported from client code. - SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop. @@ -31,26 +33,40 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail ## Folder structure -- `src/lib/state/` holds reactive rune based state (`*.svelte.ts` files that don't have a more specific home). Player state stays in `$lib/player/`, theme mode in `$lib/theme/`. -- `src/lib/data/` holds client side domain helpers: albums, collections, genres, mixes, recentlyPlayed, search. -- `src/lib/utils/` holds generic utilities with no domain knowledge, like `format.ts`. -- `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader. +- `src/lib/types.ts` holds the domain types (`Track`, `Album`, `Playlist`, `Mix`, `ListeningStats`, `Paged`). Pages and the player only ever see these, never raw API DTOs. +- `src/lib/player/` is the player: `player.svelte.ts` (state and orchestration), `queue.ts` (pure queue operations), `stream.ts`, `mediaSession.ts`, `progressClock.ts`, `listenTracker.ts`, `recentlyPlayed.ts` (merges `player.recentlyPlayed` with the server history), and `actions.ts` (`playAllOrToggle`, `playShuffled`, `isQueueCurrent`). +- `src/lib/state/` holds reactive rune state: `panels.svelte.ts` (queue panel, create playlist modal), `history.svelte.ts` (previous page for `BackLink`), `menu.svelte.ts` (`createMenu()`), `pagedList.svelte.ts` (`createPagedList`), `dialog.svelte.ts`, `liked.svelte.ts` (the user's liked tracks), `toggle.svelte.ts` (`createToggle`), and `scroll.ts` (`restoreScroll`). Nothing in `state/` imports from `components/`. +- `src/lib/theme/` holds the theme: `accent.svelte.ts` (accent hue per track and the hue fade), `mode.svelte.ts`, `palette.ts`, `tokens.ts`, and `color.ts` (`ACCENT_HUE`). The player never imports from `theme/`: `(app)/+layout.svelte` follows `player.currentId` and calls `accent.follow`. +- `src/lib/data/` holds client side domain helpers: albums, genres, search. It never imports from `player/`. +- `src/lib/utils/` holds generic utilities: `format.ts`, `collections.ts`, `hrefs.ts` (search, genre and cover URLs), `menuPosition.ts`, `storage.ts`, `transitions.ts`. +- `src/lib/actions/` holds Svelte actions: `clickOutside`, `onEscape`, `pressable`. +- `src/lib/validation.ts` holds the form limits (`LIMITS`) shared by client forms and server parsers. +- `src/lib/server/` is server only code (see below). +- `src/lib/components/shell/` holds the app shell: Sidebar, TopBar, TabsBar, MobileHeader, AccountMenu, and `navLinks.ts`. - `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls. -- `src/lib/components/ui/` holds the design system components, split into five categories: +- `src/lib/components/music/` holds the components that connect the UI to app state (the player, album data): TrackList, PlayAllButton, TrackContextMenu, AlbumContextMenu. +- `src/lib/components/ui/` holds the design system, which is presentation only. ESLint forbids importing `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `components/music`, `components/shell` and `components/player` from it; it can use `$lib/state/*`, `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` and `$lib/types`. It is split into five categories: - `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on). - - `overlay/` for anything that floats above the content (Modal, ContextMenu, AccountMenu, and so on). - - `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on). + - `overlay/` for anything that floats above the content (Modal, ContextMenu, GlassMenu, ConfirmDialog, and so on). + - `media/` for stateless music display components that receive props (Artwork, MediaCard, ListRow, PlayButton, and so on). - `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone). - - `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome. -- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`). + - `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). +- The root `+layout.svelte` only holds the globals (fonts, theme, `DialogHost`). Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`); `(app)/+layout.server.ts` guards the group and loads the shell data, and `(app)/+error.svelte` renders inside the shell. Unknown URLs hit the catch-all `(app)/[...rest]/` (it throws a 404) so they also get the shell (unknown `/api/...` URLs hit `api/[...rest]/`, a JSON 404); the root `+error.svelte` only covers errors outside the group or in the group layout load. Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`). + +## Naming conventions and glossary + +- Variant maps are module level constants in upper case (`SIZE`, `VARIANT`). +- Errors: destructure as `const { data, error } = ...` and name the caught value `err` in `catch (err)`. +- Play handlers: `playFrom(index)` and `playAll()`. Form actions: `create`, `edit`, `delete`, `addTrack`, `removeTrack`. +- UI copy: "Playlist" (never "Lista"), "Mezcla" (never "Mix"), "Modo claro". Total durations always go through `fmtDurationLong`. ## Styles The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one. - **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`. -- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl`; covers: `size-cover-xs` through `size-cover-hero`). -- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`. +- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl/2xl/3xl`; covers: `size-cover-xs` through `size-cover-hero`). +- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`. - **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`). - **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth. - **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token. @@ -59,7 +75,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili ## App shell -The shell in `src/routes/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/navigation.svelte.ts`; don't use `window.scrollTo`/`scrollY`. +The shell in `src/routes/(app)/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/scroll.ts`; don't use `window.scrollTo`/`scrollY`. ## Motion @@ -67,7 +83,7 @@ Every animation follows one scale of three durations and one easing curve, all d Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items. -The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`. +The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in the root `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`. ## Auth @@ -77,12 +93,21 @@ Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_RE ## Backend API -Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action: +Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action use the client in `$lib/server/api.ts`: ```ts -import { createApiClient } from '$lib/server/api'; -const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); -const { data, error: err } = await api.GET('/tracks'); +const api = apiFor(event); // or apiFor({ fetch, locals }); adds the bearer token when there is a session +const { data, error } = await api.GET('/tracks'); ``` -It returns `{ data, error }` (ErrorOr style) and never throws. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development). +It returns `{ data, error }` (ErrorOr style) and never throws except on network errors. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development). + +Server helpers: + +- `api.ts` holds the client and error handling: `apiFor`, `unwrapOrError`, `failOnError` (turns an API error into a `fail(...)`), `requireData` (also narrows the response data) and `apiErrorDetail`. +- `guards.ts` holds `requireUser`, `optionalUser` and `loginRedirect`. Keep them in pages and actions even though the `(app)` layout also redirects: page loads run in parallel with the layout load, and form actions skip it. +- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, `toCount`, `toListeningStats`, ...) turn DTOs into the domain types. Every load and `/api` endpoint goes through them, so `Number()` conversions on DTOs never reach routes or the client. +- `actions/` holds `authedAction(handler)` (returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler) plus the shared `follow` and `playlist` actions. +- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload. +- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting. +- Page sizes come from `$lib/config`; do not hardcode them. diff --git a/web-player/README.md b/web-player/README.md index 842f370..159bcaf 100644 --- a/web-player/README.md +++ b/web-player/README.md @@ -31,5 +31,4 @@ Built and run as a container by the production stack — see [../deploy/README.md](../deploy/README.md). There the same variables come from `deploy/.env.prod` through compose instead of from `.env`. -Conventions and the design system are documented in [CLAUDE.md](CLAUDE.md) and -in the `musify-web` skill. +Conventions and the design system are documented in [CLAUDE.md](CLAUDE.md). diff --git a/web-player/docs/frontend-audit-plan.md b/web-player/docs/frontend-audit-plan.md new file mode 100644 index 0000000..037878a --- /dev/null +++ b/web-player/docs/frontend-audit-plan.md @@ -0,0 +1,315 @@ +# Plan de refactor tras la auditoría del frontend + +> **Cerrado.** Todas las partes están hechas; el plan activo es `frontend-structure-plan.md`. + +Plan de trabajo derivado de la auditoría del `web-player` (2026-09-24). Cada parte es un PR independiente, revisable por separado, que deja `npm run check` y `npm run lint` en verde. Antes de empezar una parte, mira el **Registro** del final. + +Leyenda de tamaño: **S** (< 1 h) · **M** (medio día) · **L** (1 día o más). Las partes marcadas con 🔗 dependen de cambios en `Musify.Api`. + +## Orden y dependencias + +``` +P0 Red de seguridad + └─ P1 Seguridad ─ P2 Reproductor ─ P3 Bugs de páginas ─ P4 Código muerto + └─ P5 Modelo Track único + ├─ P6 Capa de servidor ─ P7 Formularios y validación + │ └─ P8 Rendimiento 🔗 + └─ P9 Responsabilidades ─ P10 Componentes y páginas + └─ P11 Consistencia ─ P12 Guardarraíles y docs +``` + +P1 a P4 son arreglos acotados y se pueden mergear en cualquier orden. De P5 en adelante el orden importa: cada parte simplifica la siguiente. + +--- + +## P0 · Red de seguridad (M) + +Objetivo: tener tests de las funciones puras antes de tocar lógica. + +- Añadir Vitest (`vitest`, script `npm run test`, incluido en `lint` o en CI). +- Tests para lo que ya es puro: `fmtTime`, `fmtDurationLong`, `plural`, `albumMeta` (`utils/format.ts`), `findTopResult` (`data/search.ts`), `findConflict`, `genreHue` (`data/genres.ts`), `pickGreeting` (`server/greeting.ts`), `parseAlbumForm` (`server/albumForm.ts`), `appendUnique`, `shuffle`. +- Extraer las operaciones de cola de `PlayerState` a funciones puras en `player/queue.ts` (`insertNext`, `append`, `move`, `nextIndex`, `previousIndex`) y testearlas. `PlayerState` pasa a llamarlas. Esto prepara P2. + +**Cierre:** `npm run test` en verde y cubre las funciones anteriores. + +--- + +## P1 · Seguridad (S) + +1. **Redirección abierta con `returnTo`.** + - Nueva función `safeReturnTo(value: string | null | undefined): string` en `$lib/server/auth.ts`: acepta solo rutas que empiecen por `/` y no por `//` ni `/\`; si no, devuelve `/`. + - Aplicarla en `routes/auth/+page.server.ts` (ambos usos), en `routes/auth/login/+server.ts` (antes de guardar la cookie) y en `routes/auth/callback/+server.ts` (al leer la cookie). +2. **`SESSION_SECRET` obligatorio.** En `$lib/server/config.ts`, `sessionSecret` pasa por `required()` y exige una longitud mínima de 32 caracteres. Actualizar el comentario de `.env.example`. +3. **Validar las entradas de los endpoints `/api`.** + - `api/likes/+server.ts`: no reenviar el body tal cual; leer `trackId`, comprobar que es un string no vacío y devolver 400 si no. + - `api/listens/[id]/progress/+server.ts`: envolver `request.json()` para devolver 400 con JSON inválido en vez de 500. + - `server/image.ts`: `encodeURIComponent(id)` al construir la URL de upstream. + - `api/tracks/+server.ts`: acotar `pageSize` con un máximo (`MAX_PAGE_SIZE` en `config.ts`). + +**Cierre:** `/auth?returnTo=https://example.com` acaba en `/`; arrancar sin `SESSION_SECRET` falla con un mensaje claro; tests de `safeReturnTo`. + +--- + +## P2 · Reproductor y cola (M) + +1. **Duplicados en la cola** (`player.svelte.ts` `playNext`/`appendToQueue`, `Queue.svelte`). + - `playNext` mueve la pista si ya estaba en la cola, en vez de duplicarla. + - `appendToQueue` omite las pistas que ya están pendientes. + - Con esto las claves `(track.id)` vuelven a ser únicas y `#index()` es fiable. +2. **Fin de cola.** Con repetir desactivado, al terminar la última pista el reproductor se detiene en vez de volver al principio. Si más adelante se quiere "repetir todo", `repeat` pasará a `'off' | 'all' | 'one'` (queda fuera de este plan). +3. **La cola se abre sola** (`+layout.svelte:44`). Abrirla solo la primera vez que empieza a sonar algo en la sesión (`currentId` pasa de `null` a un valor) y no volver a abrirla si el usuario la cierra. +4. **Rollback de "Me gusta"** (`liked.svelte.ts:69`). En caso de error, revertir solo la entrada de ese id, no sustituir el mapa entero. +5. **localStorage seguro.** + - Nuevo `$lib/utils/storage.ts` con `readStorage(key, fallback)` y `writeStorage(key, value)` envueltos en try/catch. + - Volumen: validar y acotar a 0–100 (hoy puede quedar `NaN`). + - Unificar claves: `musify.player.volume`, `musify.player.muted`, `musify.theme`. + - Persistir el volumen siempre, no solo cuando existe el elemento `