From 99c4bb8408016b17461b0afa5ab3f9d5e45a36e5 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sat, 18 Jul 2026 23:29:57 +0200 Subject: [PATCH] Changes --- backend/Musify.Api/AppSettings.json | 6 +- .../Configuration/PlayListConfiguration.cs | 18 - .../Musify.Application/Contracts/IDatabase.cs | 4 + .../PlayLists/CreatePlayList.cs | 20 +- .../PlayLists/GetPlayListCover.cs | 16 +- .../PlayLists/GetPlayListTracks.cs | 5 +- .../Responses/PlayListApplicationResponse.cs | 14 +- .../PlayLists/UpdatePlayList.cs | 3 +- .../Services/YouTubeTrackProvisioner.cs | 48 +- .../Musify.Application/Tracks/CreateTrack.cs | 43 +- .../Musify.Application/Tracks/DeleteTrack.cs | 3 +- .../Tracks/GetListeningHistory.cs | 4 +- .../Musify.Application/Tracks/GetTrackById.cs | 4 +- .../Musify.Application/Tracks/GetTracks.cs | 5 +- .../Tracks/GetTracksByUserId.cs | 5 +- .../Responses/TrackApplicationResponse.cs | 22 +- backend/Musify.Application/Users/SyncUser.cs | 8 - backend/Musify.Domain/Entities/Artist.cs | 9 +- .../Musify.Domain/Entities/ExternalTrack.cs | 17 + backend/Musify.Domain/Entities/LocalTrack.cs | 14 + backend/Musify.Domain/Entities/PlayList.cs | 4 +- backend/Musify.Domain/Entities/Track.cs | 21 +- backend/Musify.Domain/Entities/TrackArtist.cs | 2 +- .../ValueObjects/PlayListPictures.cs | 8 +- .../Pictures/UpdatePlayListPictureActivity.cs | 19 +- .../Logs/UpdatePlayListPictureLog.cs | 8 +- .../DeletePlayListRoutingSlipBuilder.cs | 18 +- .../Persistence/Database.cs | 46 +- ...SplitTrackIntoLocalAndExternal.Designer.cs | 903 ++++++++++++++++++ ...17172507_SplitTrackIntoLocalAndExternal.cs | 274 ++++++ ...8_MakePlayListPicturesNullable.Designer.cs | 898 +++++++++++++++++ ...0717223358_MakePlayListPicturesNullable.cs | 106 ++ .../Migrations/DatabaseModelSnapshot.cs | 119 ++- backend/Musify.Worker/AppSettings.json | 6 +- frontend/package-lock.json | 20 - frontend/package.json | 2 - frontend/src/lib/collections.ts | 15 + frontend/src/lib/components/Footer.svelte | 8 - frontend/src/lib/components/Navbar.svelte | 56 -- frontend/src/lib/components/Sidebar.svelte | 40 +- .../lib/components/TrackContextMenu.svelte | 129 +++ frontend/src/lib/components/TrackTile.svelte | 76 ++ .../lib/components/player/PlayerBar.svelte | 3 +- .../src/lib/components/player/SeekBar.svelte | 10 +- .../lib/components/player/TrackInfo.svelte | 14 +- .../player/TransportControls.svelte | 6 +- .../components/player/VolumeControl.svelte | 2 +- frontend/src/lib/components/ui/Alert.svelte | 20 + frontend/src/lib/components/ui/Badge.svelte | 23 + frontend/src/lib/components/ui/Button.svelte | 60 ++ .../src/lib/components/ui/Checkbox.svelte | 29 + .../src/lib/components/ui/EmptyState.svelte | 31 + .../lib/components/ui/ExplicitBadge.svelte | 13 + frontend/src/lib/components/ui/Field.svelte | 27 + .../src/lib/components/ui/IconButton.svelte | 37 + .../lib/components/ui/ImageDropzone.svelte | 47 + .../lib/components/ui/InfiniteScroll.svelte | 38 + frontend/src/lib/components/ui/Input.svelte | 53 + .../src/lib/components/ui/MediaGrid.svelte | 20 + frontend/src/lib/components/ui/Modal.svelte | 94 ++ frontend/src/lib/components/ui/Page.svelte | 14 + .../src/lib/components/ui/PageHeader.svelte | 24 + .../lib/components/ui/PlayPauseOverlay.svelte | 23 + .../src/lib/components/ui/PlaylistArt.svelte | 14 +- .../src/lib/components/ui/PlaylistCard.svelte | 33 + .../src/lib/components/ui/PlaylistForm.svelte | 95 ++ .../lib/components/ui/SectionHeading.svelte | 10 + frontend/src/lib/components/ui/Surface.svelte | 17 + .../src/lib/components/ui/Textarea.svelte | 28 + .../lib/components/ui/TrackIndexCell.svelte | 27 + .../src/lib/components/ui/TrackMeta.svelte | 14 + .../src/lib/components/ui/TrackRow.svelte | 24 + .../src/lib/components/ui/TrackTable.svelte | 39 + .../lib/components/ui/TrackTitleCell.svelte | 58 ++ frontend/src/lib/config.ts | 3 + frontend/src/lib/player/player.svelte.ts | 26 +- frontend/src/lib/server/api.ts | 46 + frontend/src/lib/server/image.ts | 26 + frontend/src/lib/server/playlistActions.ts | 66 ++ frontend/src/lib/server/upload.ts | 63 ++ frontend/src/lib/server/youtube.ts | 54 ++ frontend/src/lib/theme/color.ts | 19 +- frontend/src/lib/theme/palette.ts | 3 +- frontend/src/lib/theme/theme.css | 18 +- frontend/src/lib/types.ts | 10 + .../src/routes/(app)/explore/+page.server.ts | 75 +- .../src/routes/(app)/explore/+page.svelte | 581 ++++------- .../src/routes/(app)/library/+page.server.ts | 24 +- .../src/routes/(app)/library/+page.svelte | 174 ++-- .../routes/(app)/playlists/+page.server.ts | 86 +- .../src/routes/(app)/playlists/+page.svelte | 279 ++---- .../(app)/playlists/[id]/+page.server.ts | 145 ++- .../routes/(app)/playlists/[id]/+page.svelte | 481 ++-------- .../[id]/components/PlaylistHeader.svelte | 77 ++ .../[id]/components/PlaylistTrackTable.svelte | 79 ++ .../[id]/components/SuggestionsTable.svelte | 151 +++ .../src/routes/(app)/upload/+page.server.ts | 54 +- frontend/src/routes/(app)/upload/+page.svelte | 195 ++-- frontend/src/routes/+layout.svelte | 15 +- frontend/src/routes/+page.server.ts | 33 +- frontend/src/routes/+page.svelte | 344 ++++--- .../api/playlists/[id]/cover/+server.ts | 23 +- frontend/src/routes/api/tracks/+server.ts | 15 + .../routes/api/tracks/[id]/cover/+server.ts | 23 +- .../routes/api/tracks/[id]/stream/+server.ts | 18 +- .../src/routes/api/youtube/search/+server.ts | 16 +- .../tracks/[videoId]/stream/+server.ts | 18 +- frontend/src/routes/layout.css | 35 +- frontend/src/routes/login/+page.svelte | 29 +- 109 files changed, 5134 insertions(+), 2163 deletions(-) create mode 100644 backend/Musify.Domain/Entities/ExternalTrack.cs create mode 100644 backend/Musify.Domain/Entities/LocalTrack.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.Designer.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.Designer.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.cs create mode 100644 frontend/src/lib/collections.ts delete mode 100644 frontend/src/lib/components/Footer.svelte delete mode 100644 frontend/src/lib/components/Navbar.svelte create mode 100644 frontend/src/lib/components/TrackContextMenu.svelte create mode 100644 frontend/src/lib/components/TrackTile.svelte create mode 100644 frontend/src/lib/components/ui/Alert.svelte create mode 100644 frontend/src/lib/components/ui/Badge.svelte create mode 100644 frontend/src/lib/components/ui/Button.svelte create mode 100644 frontend/src/lib/components/ui/Checkbox.svelte create mode 100644 frontend/src/lib/components/ui/EmptyState.svelte create mode 100644 frontend/src/lib/components/ui/ExplicitBadge.svelte create mode 100644 frontend/src/lib/components/ui/Field.svelte create mode 100644 frontend/src/lib/components/ui/IconButton.svelte create mode 100644 frontend/src/lib/components/ui/ImageDropzone.svelte create mode 100644 frontend/src/lib/components/ui/InfiniteScroll.svelte create mode 100644 frontend/src/lib/components/ui/Input.svelte create mode 100644 frontend/src/lib/components/ui/MediaGrid.svelte create mode 100644 frontend/src/lib/components/ui/Modal.svelte create mode 100644 frontend/src/lib/components/ui/Page.svelte create mode 100644 frontend/src/lib/components/ui/PageHeader.svelte create mode 100644 frontend/src/lib/components/ui/PlayPauseOverlay.svelte create mode 100644 frontend/src/lib/components/ui/PlaylistCard.svelte create mode 100644 frontend/src/lib/components/ui/PlaylistForm.svelte create mode 100644 frontend/src/lib/components/ui/SectionHeading.svelte create mode 100644 frontend/src/lib/components/ui/Surface.svelte create mode 100644 frontend/src/lib/components/ui/Textarea.svelte create mode 100644 frontend/src/lib/components/ui/TrackIndexCell.svelte create mode 100644 frontend/src/lib/components/ui/TrackMeta.svelte create mode 100644 frontend/src/lib/components/ui/TrackRow.svelte create mode 100644 frontend/src/lib/components/ui/TrackTable.svelte create mode 100644 frontend/src/lib/components/ui/TrackTitleCell.svelte create mode 100644 frontend/src/lib/config.ts create mode 100644 frontend/src/lib/server/image.ts create mode 100644 frontend/src/lib/server/playlistActions.ts create mode 100644 frontend/src/lib/server/youtube.ts create mode 100644 frontend/src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte create mode 100644 frontend/src/routes/(app)/playlists/[id]/components/PlaylistTrackTable.svelte create mode 100644 frontend/src/routes/(app)/playlists/[id]/components/SuggestionsTable.svelte create mode 100644 frontend/src/routes/api/tracks/+server.ts diff --git a/backend/Musify.Api/AppSettings.json b/backend/Musify.Api/AppSettings.json index 811b7df..5b7fe4b 100644 --- a/backend/Musify.Api/AppSettings.json +++ b/backend/Musify.Api/AppSettings.json @@ -60,11 +60,7 @@ "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", "MediumPicturesFolder": "MediumPictures", - "LargePicturesFolder": "LargePictures", - "PresetOriginalPicture": "PresetOriginalPicture.webp", - "PresetSmallPicture": "PresetSmallPicture.webp", - "PresetMediumPicture": "PresetMediumPicture.webp", - "PresetLargePicture": "PresetLargePicture.webp" + "LargePicturesFolder": "LargePictures" }, "PicturesSizes": { "SmallPictureWidth": 128, diff --git a/backend/Musify.Application/Configuration/PlayListConfiguration.cs b/backend/Musify.Application/Configuration/PlayListConfiguration.cs index 39e0d39..cb1ea32 100644 --- a/backend/Musify.Application/Configuration/PlayListConfiguration.cs +++ b/backend/Musify.Application/Configuration/PlayListConfiguration.cs @@ -34,30 +34,12 @@ namespace Musify.Application.Configuration [Required] public string LargePicturesFolder { get; set; } = "LargePictures"; - [Required] - public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; - - [Required] - public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; - - [Required] - public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; - - [Required] - public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; - public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); - public string PresetSmallPicturePath => StorageKey.Combine(ParentFolders, PresetSmallPicture); - - public string PresetMediumPicturePath => StorageKey.Combine(ParentFolders, PresetMediumPicture); - - public string PresetLargePicturePath => StorageKey.Combine(ParentFolders, PresetLargePicture); - public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); diff --git a/backend/Musify.Application/Contracts/IDatabase.cs b/backend/Musify.Application/Contracts/IDatabase.cs index 0c7d0f9..10f8377 100644 --- a/backend/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Musify.Application/Contracts/IDatabase.cs @@ -11,6 +11,10 @@ namespace Musify.Application.Contracts DbSet Tracks { get; } + DbSet LocalTracks { get; } + + DbSet ExternalTracks { get; } + DbSet Artists { get; } DbSet TrackArtists { get; } diff --git a/backend/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Musify.Application/PlayLists/CreatePlayList.cs index a6dc445..932607b 100644 --- a/backend/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Musify.Application/PlayLists/CreatePlayList.cs @@ -42,8 +42,6 @@ namespace Musify.Application.PlayLists } UploadIntent? pictureIntent = null; - string originalPictureName; - if (request.PictureIntentId.HasValue) { var validation = await uploadIntentValidator.ValidateAndLoadAsync( @@ -53,11 +51,6 @@ namespace Musify.Application.PlayLists return validation.Errors; pictureIntent = validation.Value; - originalPictureName = pictureIntent.ObjectName; - } - else - { - originalPictureName = playListConfiguration.Routes.PresetOriginalPicture; } var playList = new PlayList @@ -66,20 +59,15 @@ namespace Musify.Application.PlayLists Name = request.Name, NormalizedName = request.Name.Trim().ToUpperInvariant(), Description = request.Description, - Pictures = new PlayListPictures - { - OriginalName = originalPictureName, - SmallName = playListConfiguration.Routes.PresetSmallPicture, - MediumName = playListConfiguration.Routes.PresetMediumPicture, - LargeName = playListConfiguration.Routes.PresetLargePicture - } + Pictures = pictureIntent == null ? null + : new PlayListPictures { OriginalName = pictureIntent.ObjectName } }; await database.PlayLists.AddAsync(playList, cancellationToken); - if (pictureIntent is not null) + if (pictureIntent != null) { - var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, originalPictureName); + var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); var publishResult = await PublishCreatePlayListEventAsync( playList.Id, pictureIntent, finalPictureKey, cancellationToken); if (publishResult.IsError) diff --git a/backend/Musify.Application/PlayLists/GetPlayListCover.cs b/backend/Musify.Application/PlayLists/GetPlayListCover.cs index 0aab5ca..577d120 100644 --- a/backend/Musify.Application/PlayLists/GetPlayListCover.cs +++ b/backend/Musify.Application/PlayLists/GetPlayListCover.cs @@ -20,23 +20,23 @@ namespace Musify.Application.PlayLists { public async ValueTask> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken) { - var playList = await database.PlayLists.AsNoTracking() + var pictures = await database.PlayLists.AsNoTracking() .Where(p => p.Id == request.PlayListId) - .Select(p => new { p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName }) + .Select(p => p.Pictures) .SingleOrDefaultAsync(cancellationToken); - if (playList == null) + if (pictures is null) return Error.NotFound(); var routes = playListConfiguration.Routes; - var (name, key, presetName) = request.Size.ToLowerInvariant() switch + var (name, key) = request.Size.ToLowerInvariant() switch { - "small" => (playList.SmallName, BuildKey(routes.BuildSmallPicturePath, playList.SmallName), routes.PresetSmallPicture), - "large" => (playList.LargeName, BuildKey(routes.BuildLargePicturePath, playList.LargeName), routes.PresetLargePicture), - _ => (playList.MediumName, BuildKey(routes.BuildMediumPicturePath, playList.MediumName), routes.PresetMediumPicture) + "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)), + "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)), + _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) }; - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key) || name == presetName) + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) return Error.NotFound(); var contentType = MimeUtility.GetMimeMapping(name); diff --git a/backend/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Musify.Application/PlayLists/GetPlayListTracks.cs index e1756cc..147700a 100644 --- a/backend/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; using X.PagedList; using X.PagedList.EF; @@ -28,9 +29,9 @@ namespace Musify.Application.PlayLists var tracksQuery = database.PlayListHasTracks .AsNoTracking() - .Include(plt => plt.Track) - .ThenInclude(track => track.TrackArtists) + .Include(plt => ((ExternalTrack)plt.Track).TrackArtists) .ThenInclude(trackArtist => trackArtist.Artist) + .Include(plt => ((LocalTrack)plt.Track).Owner) .Where(plt => plt.PlayListId == request.PlayListId); var totalCount = await tracksQuery.CountAsync(cancellationToken); diff --git a/backend/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index dc41198..1a3809e 100644 --- a/backend/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -5,10 +5,10 @@ namespace Musify.Application.PlayLists.Responses public record PlayListApplicationResponse( Guid Id, string Name, - string Description, - string SmallImageKeyName, - string MediumImageKeyName, - string LargeImageKeyName, + string? Description, + string? SmallImageKeyName, + string? MediumImageKeyName, + string? LargeImageKeyName, DateTime CreatedAt, DateTime UpdatedAt) { @@ -18,9 +18,9 @@ namespace Musify.Application.PlayLists.Responses playList.Id, playList.Name, playList.Description, - playList.Pictures.SmallName, - playList.Pictures.MediumName, - playList.Pictures.LargeName, + playList.Pictures?.SmallName, + playList.Pictures?.MediumName, + playList.Pictures?.LargeName, playList.CreatedAt, playList.UpdatedAt); } diff --git a/backend/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Musify.Application/PlayLists/UpdatePlayList.cs index b5bcb25..f83954b 100644 --- a/backend/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Musify.Application/PlayLists/UpdatePlayList.cs @@ -8,6 +8,7 @@ using Musify.Application.Events; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Musify.Application.Contracts; namespace Musify.Application.PlayLists @@ -68,7 +69,7 @@ namespace Musify.Application.PlayLists return validation.Errors; pictureIntent = validation.Value; - playListEntity.Pictures.OriginalName = pictureIntent.ObjectName; + playListEntity.Pictures = new PlayListPictures { OriginalName = pictureIntent.ObjectName }; finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); } diff --git a/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs b/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs index 4299d90..5de3697 100644 --- a/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs +++ b/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs @@ -8,7 +8,7 @@ using Musify.Domain.ValueObjects; namespace Musify.Application.Services; -public sealed record YouTubeTrackProvision(Track Track, YouTubeSongResult? Song); +public sealed record YouTubeTrackProvision(ExternalTrack Track, YouTubeSongResult? Song); public sealed class YouTubeTrackProvisioner( IDatabase database, @@ -16,8 +16,8 @@ public sealed class YouTubeTrackProvisioner( TrackConfiguration trackConfiguration, ILogger logger) { - public Task FindAsync(string videoId, CancellationToken cancellationToken) => - database.Tracks + public Task FindAsync(string videoId, CancellationToken cancellationToken) => + database.ExternalTracks .Include(track => track.TrackArtists) .ThenInclude(trackArtist => trackArtist.Artist) .SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken); @@ -44,7 +44,7 @@ public sealed class YouTubeTrackProvisioner( artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken)); var title = Truncate(song.Title, 50); - var track = new Track + var track = new ExternalTrack { Title = title, NormalizedTitle = title.ToUpperInvariant(), @@ -65,7 +65,7 @@ public sealed class YouTubeTrackProvisioner( } }; - await database.Tracks.AddAsync(track, cancellationToken); + await database.ExternalTracks.AddAsync(track, cancellationToken); for (var position = 0; position < artists.Count; position++) { @@ -85,7 +85,7 @@ public sealed class YouTubeTrackProvisioner( } catch (DbUpdateException) { - database.Tracks.Remove(track); + database.ExternalTracks.Remove(track); var refetched = await FindAsync(videoId, cancellationToken); if (refetched is null) { @@ -100,43 +100,17 @@ public sealed class YouTubeTrackProvisioner( { var name = Truncate(reference.Name, 200); var normalized = name.ToUpperInvariant(); - var byExternalId = !string.IsNullOrEmpty(reference.Id); + var externalId = reference.Id ?? $"name:{normalized}"; - var existing = byExternalId - ? await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == reference.Id, cancellationToken) - : await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken); + var existing = await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == externalId, cancellationToken); if (existing is not null) return existing; - if (byExternalId) - { - var adoptable = await database.Artists - .SingleOrDefaultAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken); - if (adoptable is not null) - { - var previousName = adoptable.Name; - adoptable.ExternalId = reference.Id; - adoptable.Name = name; - - try - { - await database.SaveChangesAsync(cancellationToken); - return adoptable; - } - catch (DbUpdateException) - { - adoptable.ExternalId = null; - adoptable.Name = previousName; - return await database.Artists.SingleAsync(a => a.ExternalId == reference.Id, cancellationToken); - } - } - } - var artist = new Artist { Name = name, NormalizedName = normalized, - ExternalId = byExternalId ? reference.Id : null + ExternalId = externalId }; await database.Artists.AddAsync(artist, cancellationToken); @@ -149,9 +123,7 @@ public sealed class YouTubeTrackProvisioner( catch (DbUpdateException) { database.Artists.Remove(artist); - return byExternalId - ? await database.Artists.SingleAsync(a => a.ExternalId == reference.Id, cancellationToken) - : await database.Artists.SingleAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken); + return await database.Artists.SingleAsync(a => a.ExternalId == externalId, cancellationToken); } } diff --git a/backend/Musify.Application/Tracks/CreateTrack.cs b/backend/Musify.Application/Tracks/CreateTrack.cs index 3add270..87c0976 100644 --- a/backend/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Musify.Application/Tracks/CreateTrack.cs @@ -32,7 +32,7 @@ namespace Musify.Application.Tracks { public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) { - var user = await database.Users.AsNoTracking() + var user = await database.Users .SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); if (user is null) { @@ -59,13 +59,12 @@ namespace Musify.Application.Tracks var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); - var artist = await GetOrCreateUserArtistAsync(user, cancellationToken); - - var trackEntity = new Track + var trackEntity = new LocalTrack { Title = request.Title, NormalizedTitle = request.Title.ToUpperInvariant(), OwnerUserId = user.Id, + Owner = user, Pictures = new TrackPictures { OriginalName = pictureIntent.ObjectName, @@ -81,14 +80,7 @@ namespace Musify.Application.Tracks } }; - await database.Tracks.AddAsync(trackEntity, cancellationToken); - - await database.TrackArtists.AddAsync(new TrackArtist - { - TrackId = trackEntity.Id, - ArtistId = artist.Id, - Position = 0 - }, cancellationToken); + await database.LocalTracks.AddAsync(trackEntity, cancellationToken); await database.UserHasTracks.AddAsync(new UserHasTrack { @@ -141,32 +133,5 @@ namespace Musify.Application.Tracks return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); } - - private async Task GetOrCreateUserArtistAsync(User user, CancellationToken cancellationToken) - { - var existing = await database.Artists.SingleOrDefaultAsync(a => a.UserId == user.Id, cancellationToken); - if (existing is not null) - return existing; - - var artist = new Artist - { - Name = user.Name, - NormalizedName = user.NormalizedName, - UserId = user.Id - }; - - await database.Artists.AddAsync(artist, cancellationToken); - - try - { - await database.SaveChangesAsync(cancellationToken); - return artist; - } - catch (DbUpdateException) - { - database.Artists.Remove(artist); - return await database.Artists.SingleAsync(a => a.UserId == user.Id, cancellationToken); - } - } } } diff --git a/backend/Musify.Application/Tracks/DeleteTrack.cs b/backend/Musify.Application/Tracks/DeleteTrack.cs index 0115b4c..8e00bda 100644 --- a/backend/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Musify.Application/Tracks/DeleteTrack.cs @@ -2,6 +2,7 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Application.Events; using Musify.Application.Contracts; @@ -28,7 +29,7 @@ namespace Musify.Application.Tracks return Error.NotFound(); } - if (track.OwnerUserId != request.UserId) + if (track is not LocalTrack localTrack || localTrack.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); return Error.Unauthorized(); diff --git a/backend/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Musify.Application/Tracks/GetListeningHistory.cs index c664334..5b660f9 100644 --- a/backend/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Musify.Application/Tracks/GetListeningHistory.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; namespace Musify.Application.Tracks { @@ -24,8 +25,9 @@ namespace Musify.Application.Tracks .ToListAsync(cancellationToken); var tracksById = await database.Tracks - .Include(t => t.TrackArtists) + .Include(t => ((ExternalTrack)t).TrackArtists) .ThenInclude(trackArtist => trackArtist.Artist) + .Include(t => ((LocalTrack)t).Owner) .Where(t => recentTrackIds.Contains(t.Id)) .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } ) .ToDictionaryAsync(t => t.Id, cancellationToken); diff --git a/backend/Musify.Application/Tracks/GetTrackById.cs b/backend/Musify.Application/Tracks/GetTrackById.cs index 68ed273..565628b 100644 --- a/backend/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Musify.Application/Tracks/GetTrackById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; namespace Musify.Application.Tracks { @@ -15,8 +16,9 @@ namespace Musify.Application.Tracks public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) { var entity = await database.Tracks.AsNoTracking() - .Include(t => t.TrackArtists) + .Include(t => ((ExternalTrack)t).TrackArtists) .ThenInclude(trackArtist => trackArtist.Artist) + .Include(t => ((LocalTrack)t).Owner) .Where(t => t.Id == request.TrackId) .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count }) .SingleOrDefaultAsync(cancellationToken); diff --git a/backend/Musify.Application/Tracks/GetTracks.cs b/backend/Musify.Application/Tracks/GetTracks.cs index 5f9529d..88e6e85 100644 --- a/backend/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Musify.Application/Tracks/GetTracks.cs @@ -6,6 +6,7 @@ using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; using Musify.Application.Contracts; +using Musify.Domain.Entities; namespace Musify.Application.Tracks { @@ -19,9 +20,9 @@ namespace Musify.Application.Tracks { var tracksQuery = database.UserHasTracks .AsNoTracking() - .Include(ut => ut.Track) - .ThenInclude(track => track.TrackArtists) + .Include(ut => ((ExternalTrack)ut.Track).TrackArtists) .ThenInclude(trackArtist => trackArtist.Artist) + .Include(ut => ((LocalTrack)ut.Track).Owner) .AsQueryable(); if (!string.IsNullOrEmpty(request.Name)) diff --git a/backend/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Musify.Application/Tracks/GetTracksByUserId.cs index 055a269..2e38b10 100644 --- a/backend/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Musify.Application/Tracks/GetTracksByUserId.cs @@ -6,6 +6,7 @@ using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; using Musify.Application.Contracts; +using Musify.Domain.Entities; namespace Musify.Application.Tracks { @@ -26,9 +27,9 @@ namespace Musify.Application.Tracks var tracksQuery = database.UserHasTracks .AsNoTracking() - .Include(ut => ut.Track) - .ThenInclude(track => track.TrackArtists) + .Include(ut => ((ExternalTrack)ut.Track).TrackArtists) .ThenInclude(trackArtist => trackArtist.Artist) + .Include(ut => ((LocalTrack)ut.Track).Owner) .Where(p => p.UserId == request.UserId); if (!string.IsNullOrEmpty(request.Name)) diff --git a/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index e121c5c..0962a3a 100644 --- a/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -22,23 +22,23 @@ namespace Musify.Application.Tracks.Responses track.Id, track.Title, FormatArtist(track), - track.Source, - track.ExternalId, + track is ExternalTrack external ? external.Source : TrackSource.Local, + (track as ExternalTrack)?.ExternalId, track.Audio.TranscodeStatus, track.DurationSeconds, listensCount, - track.OwnerUserId, + (track as LocalTrack)?.OwnerUserId, track.CreatedAt, track.UpdatedAt); - private static string? FormatArtist(Track track) + private static string? FormatArtist(Track track) => track switch { - if (track.TrackArtists.Count == 0) - return null; - - return string.Join(", ", track.TrackArtists - .OrderBy(trackArtist => trackArtist.Position) - .Select(trackArtist => trackArtist.Artist.Name)); - } + LocalTrack local => local.Owner.Name, + ExternalTrack { TrackArtists.Count: > 0 } external => + string.Join(", ", external.TrackArtists + .OrderBy(trackArtist => trackArtist.Position) + .Select(trackArtist => trackArtist.Artist.Name)), + _ => null + }; } } diff --git a/backend/Musify.Application/Users/SyncUser.cs b/backend/Musify.Application/Users/SyncUser.cs index 2b12afb..576d149 100644 --- a/backend/Musify.Application/Users/SyncUser.cs +++ b/backend/Musify.Application/Users/SyncUser.cs @@ -47,14 +47,6 @@ namespace Musify.Application.Users user.SecondName = request.SecondName; user.ProfilePictureUrl = request.ProfilePictureUrl; - var artist = await database.Artists - .SingleOrDefaultAsync(a => a.UserId == request.Id, cancellationToken); - if (artist is not null) - { - artist.Name = request.Name; - artist.NormalizedName = request.Name.ToUpperInvariant(); - } - var written = await database.SaveChangesAsync(cancellationToken); if (written > 0) logger.LogInformation("Synced profile for user {UserId}", request.Id); diff --git a/backend/Musify.Domain/Entities/Artist.cs b/backend/Musify.Domain/Entities/Artist.cs index 862d99d..bca0f98 100644 --- a/backend/Musify.Domain/Entities/Artist.cs +++ b/backend/Musify.Domain/Entities/Artist.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; namespace Musify.Domain.Entities @@ -18,10 +17,9 @@ namespace Musify.Domain.Entities [MaxLength(200)] public required string NormalizedName { get; set; } - public long? UserId { get; set; } - + [Required] [MaxLength(64)] - public string? ExternalId { get; set; } + public required string ExternalId { get; set; } [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; @@ -29,9 +27,6 @@ namespace Musify.Domain.Entities [Required] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(UserId)) ] - public User? User { get; set; } - public ICollection TrackArtists { get; set; } = new List(); } } diff --git a/backend/Musify.Domain/Entities/ExternalTrack.cs b/backend/Musify.Domain/Entities/ExternalTrack.cs new file mode 100644 index 0000000..623d05b --- /dev/null +++ b/backend/Musify.Domain/Entities/ExternalTrack.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Entities +{ + public class ExternalTrack : Track + { + [Required] + public required TrackSource Source { get; set; } + + [Required] + [MaxLength(64)] + public required string ExternalId { get; set; } + + public ICollection TrackArtists { get; set; } = new List(); + } +} diff --git a/backend/Musify.Domain/Entities/LocalTrack.cs b/backend/Musify.Domain/Entities/LocalTrack.cs new file mode 100644 index 0000000..9ae6847 --- /dev/null +++ b/backend/Musify.Domain/Entities/LocalTrack.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ + public class LocalTrack : Track + { + [Required] + public required long OwnerUserId { get; set; } + + [ForeignKey(nameof(OwnerUserId)) ] + public required User Owner { get; set; } + } +} diff --git a/backend/Musify.Domain/Entities/PlayList.cs b/backend/Musify.Domain/Entities/PlayList.cs index 846e4fa..8777e09 100644 --- a/backend/Musify.Domain/Entities/PlayList.cs +++ b/backend/Musify.Domain/Entities/PlayList.cs @@ -26,9 +26,9 @@ namespace Musify.Domain.Entities [Required] [MaxLength(256)] - public string Description { get; set; } = "No description was provided."; + public string? Description { get; set; } - public required PlayListPictures Pictures { get; set; } + public PlayListPictures? Pictures { get; set; } [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; diff --git a/backend/Musify.Domain/Entities/Track.cs b/backend/Musify.Domain/Entities/Track.cs index 3babae3..dc3eb7a 100644 --- a/backend/Musify.Domain/Entities/Track.cs +++ b/backend/Musify.Domain/Entities/Track.cs @@ -1,12 +1,11 @@ using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities { #pragma warning disable CS8618 - public class Track : IAuditable + public abstract class Track : IAuditable { [Key] public Guid Id { get; set; } = Guid.NewGuid(); @@ -22,14 +21,6 @@ namespace Musify.Domain.Entities [Required] public int DurationSeconds { get; set; } - public long? OwnerUserId { get; set; } - - [Required] - public TrackSource Source { get; set; } = TrackSource.Local; - - [MaxLength(16)] - public string? ExternalId { get; set; } - [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; @@ -43,18 +34,10 @@ namespace Musify.Domain.Entities public TrackAudio Audio { get; set; } = new(); - [ForeignKey(nameof(OwnerUserId)) ] - public User? Owner { get; set; } - public ICollection UserTracks { get; set; } = new List(); public ICollection PlayListTracks { get; set; } = new List(); public ICollection ListeningHistories { get; set; } = new List(); - - public ICollection TrackArtists { get; set; } = new List(); - - [NotMapped] - public bool IsExternal => Source != TrackSource.Local; } -} \ No newline at end of file +} diff --git a/backend/Musify.Domain/Entities/TrackArtist.cs b/backend/Musify.Domain/Entities/TrackArtist.cs index 3bc7f09..c637f84 100644 --- a/backend/Musify.Domain/Entities/TrackArtist.cs +++ b/backend/Musify.Domain/Entities/TrackArtist.cs @@ -17,7 +17,7 @@ namespace Musify.Domain.Entities public int Position { get; set; } = 0; [ForeignKey(nameof(TrackId)) ] - public Track Track { get; set; } + public ExternalTrack Track { get; set; } [ForeignKey(nameof(ArtistId)) ] public Artist Artist { get; set; } diff --git a/backend/Musify.Domain/ValueObjects/PlayListPictures.cs b/backend/Musify.Domain/ValueObjects/PlayListPictures.cs index 4248b13..26aa96a 100644 --- a/backend/Musify.Domain/ValueObjects/PlayListPictures.cs +++ b/backend/Musify.Domain/ValueObjects/PlayListPictures.cs @@ -2,12 +2,12 @@ namespace Musify.Domain.ValueObjects { public class PlayListPictures { - public required string OriginalName { get; set; } + public string? OriginalName { get; set; } - public required string SmallName { get; set; } + public string? SmallName { get; set; } - public required string MediumName { get; set; } + public string? MediumName { get; set; } - public required string LargeName { get; set; } + public string? LargeName { get; set; } } } diff --git a/backend/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs b/backend/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs index 8071ddf..6f1e99c 100644 --- a/backend/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs +++ b/backend/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs @@ -2,6 +2,7 @@ using MassTransit; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Events; +using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; @@ -37,6 +38,13 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} not found"); } + if (playList.Pictures is null) + { + logger.LogWarning("Playlist {PlayListId} has no pending picture upload", + executeContext.Arguments.PlayListId); + throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} has no pending picture upload"); + } + var log = new UpdatePlayListPictureLog( playList.Id, playList.Pictures.OriginalName, @@ -82,10 +90,13 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures return compensateContext.Compensated(); } - playList.Pictures.OriginalName = compensateContext.Log.PreviousOriginalPictureKey; - playList.Pictures.SmallName = compensateContext.Log.PreviousSmallPictureKey; - playList.Pictures.MediumName = compensateContext.Log.PreviousMediumPictureKey; - playList.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey; + playList.Pictures = new PlayListPictures + { + OriginalName = compensateContext.Log.PreviousOriginalPictureKey, + SmallName = compensateContext.Log.PreviousSmallPictureKey, + MediumName = compensateContext.Log.PreviousMediumPictureKey, + LargeName = compensateContext.Log.PreviousLargePictureKey + }; database.PlayLists.Update(playList); await database.SaveChangesAsync(compensateContext.CancellationToken); diff --git a/backend/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs b/backend/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs index 8135ec8..8b8f2dd 100644 --- a/backend/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs +++ b/backend/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs @@ -2,8 +2,8 @@ namespace Musify.Infrastructure.MassTransit.Logs { public record UpdatePlayListPictureLog( Guid PlayListId, - string PreviousOriginalPictureKey, - string PreviousSmallPictureKey, - string PreviousMediumPictureKey, - string PreviousLargePictureKey); + string? PreviousOriginalPictureKey, + string? PreviousSmallPictureKey, + string? PreviousMediumPictureKey, + string? PreviousLargePictureKey); } diff --git a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs b/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs index f67b12c..c61a037 100644 --- a/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs +++ b/backend/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs @@ -37,49 +37,49 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName), new MarkPlayListAsRemovingArguments(playListId)); - if (playList is not null) + if (playList?.Pictures is not null) { var bucket = storageConfiguration.Bucket; var routes = playListConfiguration.Routes; - if (playList.Pictures.OriginalName != routes.PresetOriginalPicture) + if (playList.Pictures.OriginalName is { } originalName) { routingSlipBuilder.AddActivity( ActivityNames.RemovePlayListOriginalPicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildOriginalPicturePath(userId, playList.Pictures.OriginalName))); + routes.BuildOriginalPicturePath(userId, originalName))); } - if (playList.Pictures.SmallName != routes.PresetSmallPicture) + if (playList.Pictures.SmallName is { } smallName) { routingSlipBuilder.AddActivity( ActivityNames.RemovePlayListSmallPicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildSmallPicturePath(playList.Pictures.SmallName))); + routes.BuildSmallPicturePath(smallName))); } - if (playList.Pictures.MediumName != routes.PresetMediumPicture) + if (playList.Pictures.MediumName is { } mediumName) { routingSlipBuilder.AddActivity( ActivityNames.RemovePlayListMediumPicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildMediumPicturePath(playList.Pictures.MediumName))); + routes.BuildMediumPicturePath(mediumName))); } - if (playList.Pictures.LargeName != routes.PresetLargePicture) + if (playList.Pictures.LargeName is { } largeName) { routingSlipBuilder.AddActivity( ActivityNames.RemovePlayListLargePicture, EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), new RemoveFileFromBucketArguments( bucket, - routes.BuildLargePicturePath(playList.Pictures.LargeName))); + routes.BuildLargePicturePath(largeName))); } } diff --git a/backend/Musify.Infrastructure/Persistence/Database.cs b/backend/Musify.Infrastructure/Persistence/Database.cs index 3c2a268..dd75848 100644 --- a/backend/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Musify.Infrastructure/Persistence/Database.cs @@ -30,21 +30,6 @@ namespace Musify.Infrastructure.Persistence trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); - modelBuilder.Entity() - .HasIndex(track => new { track.Source, track.ExternalId }) - .IsUnique(); - - modelBuilder.Entity() - .HasOne(track => track.Owner) - .WithMany() - .HasForeignKey(track => track.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .ToTable(t => t.HasCheckConstraint( - "CK_Tracks_OwnerUserId_Source", - "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)")); - modelBuilder.Entity() .OwnsOne(track => track.Pictures, pictures => { @@ -68,6 +53,22 @@ namespace Musify.Infrastructure.Persistence }); modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + modelBuilder.Entity() + .ToTable("LocalTracks"); + + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .ToTable("ExternalTracks"); + + modelBuilder.Entity() + .HasIndex(track => new { track.Source, track.ExternalId }) + .IsUnique(); + modelBuilder.Entity() .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId }); @@ -83,19 +84,10 @@ namespace Musify.Infrastructure.Persistence .HasForeignKey(trackArtist => trackArtist.ArtistId) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasIndex(artist => artist.UserId) - .IsUnique(); - modelBuilder.Entity() .HasIndex(artist => artist.ExternalId) .IsUnique(); - modelBuilder.Entity() - .HasIndex(artist => artist.NormalizedName) - .IsUnique() - .HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); - modelBuilder.Entity() .OwnsOne(playList => playList.Pictures, pictures => { @@ -104,7 +96,7 @@ namespace Musify.Infrastructure.Persistence pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); }); - modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(); + modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); var playListProcessing = modelBuilder.Entity(); playListProcessing.HasKey(state => state.CorrelationId); @@ -116,6 +108,10 @@ namespace Musify.Infrastructure.Persistence public DbSet Tracks => Set(); + public DbSet LocalTracks => Set(); + + public DbSet ExternalTracks => Set(); + public DbSet Artists => Set(); public DbSet TrackArtists => Set(); diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.Designer.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.Designer.cs new file mode 100644 index 0000000..c828de5 --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.Designer.cs @@ -0,0 +1,903 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260717172507_SplitTrackIntoLocalAndExternal")] + partial class SplitTrackIntoLocalAndExternal + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("ArtistId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("TrackId", "ArtistId"); + + b.HasIndex("ArtistId"); + + b.ToTable("TrackArtist"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalTracks", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("LocalTracks", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures") + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.HasOne("Musify.Domain.Entities.Artist", "Artist") + .WithMany("TrackArtists") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.cs new file mode 100644 index 0000000..5880c3a --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260717172507_SplitTrackIntoLocalAndExternal.cs @@ -0,0 +1,274 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class SplitTrackIntoLocalAndExternal : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TrackArtist_Tracks_TrackId", + table: "TrackArtist"); + + migrationBuilder.DropForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropIndex( + name: "IX_Tracks_Source_ExternalId", + table: "Tracks"); + + migrationBuilder.DropCheckConstraint( + name: "CK_Tracks_OwnerUserId_Source", + table: "Tracks"); + + migrationBuilder.CreateTable( + name: "ExternalTracks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Source = table.Column(type: "integer", nullable: false), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalTracks", x => x.Id); + table.ForeignKey( + name: "FK_ExternalTracks_Tracks_Id", + column: x => x.Id, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "LocalTracks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OwnerUserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LocalTracks", x => x.Id); + table.ForeignKey( + name: "FK_LocalTracks_Tracks_Id", + column: x => x.Id, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_LocalTracks_User_OwnerUserId", + column: x => x.OwnerUserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExternalTracks_Source_ExternalId", + table: "ExternalTracks", + columns: new[] { "Source", "ExternalId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LocalTracks_OwnerUserId", + table: "LocalTracks", + column: "OwnerUserId"); + + migrationBuilder.Sql( + """ + INSERT INTO "LocalTracks" ("Id", "OwnerUserId") + SELECT "Id", "OwnerUserId" FROM "Tracks" WHERE "Source" = 0; + """); + + migrationBuilder.Sql( + """ + INSERT INTO "ExternalTracks" ("Id", "Source", "ExternalId") + SELECT "Id", "Source", "ExternalId" FROM "Tracks" WHERE "Source" <> 0; + """); + + migrationBuilder.Sql( + """ + DELETE FROM "TrackArtist" tra + USING "Tracks" t + WHERE tra."TrackId" = t."Id" AND t."Source" = 0; + """); + + migrationBuilder.Sql( + """ + DELETE FROM "Artists" WHERE "UserId" IS NOT NULL; + """); + + migrationBuilder.AddForeignKey( + name: "FK_TrackArtist_ExternalTracks_TrackId", + table: "TrackArtist", + column: "TrackId", + principalTable: "ExternalTracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.DropColumn( + name: "ExternalId", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "Source", + table: "Tracks"); + + migrationBuilder.DropIndex( + name: "IX_Artists_NormalizedName", + table: "Artists"); + + migrationBuilder.DropIndex( + name: "IX_Artists_UserId", + table: "Artists"); + + migrationBuilder.DropForeignKey( + name: "FK_Artists_User_UserId", + table: "Artists"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Artists"); + + migrationBuilder.AlterColumn( + name: "ExternalId", + table: "Artists", + type: "character varying(64)", + maxLength: 64, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TrackArtist_ExternalTracks_TrackId", + table: "TrackArtist"); + + migrationBuilder.AddColumn( + name: "ExternalId", + table: "Tracks", + type: "character varying(16)", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "OwnerUserId", + table: "Tracks", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "Source", + table: "Tracks", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.Sql( + """ + UPDATE "Tracks" t SET "OwnerUserId" = lt."OwnerUserId", "Source" = 0 + FROM "LocalTracks" lt WHERE lt."Id" = t."Id"; + """); + + migrationBuilder.Sql( + """ + UPDATE "Tracks" t SET "ExternalId" = et."ExternalId", "Source" = et."Source" + FROM "ExternalTracks" et WHERE et."Id" = t."Id"; + """); + + migrationBuilder.DropTable( + name: "ExternalTracks"); + + migrationBuilder.DropTable( + name: "LocalTracks"); + + migrationBuilder.AlterColumn( + name: "ExternalId", + table: "Artists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Artists", + type: "bigint", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_Source_ExternalId", + table: "Tracks", + columns: new[] { "Source", "ExternalId" }, + unique: true); + + migrationBuilder.AddCheckConstraint( + name: "CK_Tracks_OwnerUserId_Source", + table: "Tracks", + sql: "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); + + migrationBuilder.CreateIndex( + name: "IX_Artists_NormalizedName", + table: "Artists", + column: "NormalizedName", + unique: true, + filter: "\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Artists_UserId", + table: "Artists", + column: "UserId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Artists_User_UserId", + table: "Artists", + column: "UserId", + principalTable: "User", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_TrackArtist_Tracks_TrackId", + table: "TrackArtist", + column: "TrackId", + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks", + column: "OwnerUserId", + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.Designer.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.Designer.cs new file mode 100644 index 0000000..4fe2553 --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.Designer.cs @@ -0,0 +1,898 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260717223358_MakePlayListPicturesNullable")] + partial class MakePlayListPicturesNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("ArtistId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("TrackId", "ArtistId"); + + b.HasIndex("ArtistId"); + + b.ToTable("TrackArtist"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalTracks", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("LocalTracks", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.HasOne("Musify.Domain.Entities.Artist", "Artist") + .WithMany("TrackArtists") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.cs new file mode 100644 index 0000000..c007573 --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260717223358_MakePlayListPicturesNullable.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class MakePlayListPicturesNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SmallPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "OriginalPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "MediumPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "LargePictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SmallPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "OriginalPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "MediumPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LargePictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 982025a..fb7a0b7 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -200,6 +200,7 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasColumnType("timestamp with time zone"); b.Property("ExternalId") + .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)"); @@ -216,21 +217,11 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); - b.Property("UserId") - .HasColumnType("bigint"); - b.HasKey("Id"); b.HasIndex("ExternalId") .IsUnique(); - b.HasIndex("NormalizedName") - .IsUnique() - .HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); - - b.HasIndex("UserId") - .IsUnique(); - b.ToTable("Artists"); }); @@ -337,10 +328,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Property("DurationSeconds") .HasColumnType("integer"); - b.Property("ExternalId") - .HasMaxLength(16) - .HasColumnType("character varying(16)"); - b.Property("LifeCycleStatus") .HasColumnType("integer"); @@ -349,12 +336,6 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); - b.Property("OwnerUserId") - .HasColumnType("bigint"); - - b.Property("Source") - .HasColumnType("integer"); - b.Property("Title") .IsRequired() .HasMaxLength(50) @@ -365,15 +346,9 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.HasIndex("OwnerUserId"); + b.ToTable("Tracks"); - b.HasIndex("Source", "ExternalId") - .IsUnique(); - - b.ToTable("Tracks", t => - { - t.HasCheckConstraint("CK_Tracks_OwnerUserId_Source", "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); - }); + b.UseTptMappingStrategy(); }); modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => @@ -596,6 +571,36 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("TrackProcessingState"); }); + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalTracks", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("LocalTracks", (string)null); + }); + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => { b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) @@ -608,15 +613,6 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasPrincipalKey("MessageId", "ConsumerId"); }); - modelBuilder.Entity("Musify.Domain.Entities.Artist", b => - { - b.HasOne("Musify.Domain.Entities.User", "User") - .WithMany() - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => { b.HasOne("Musify.Domain.Entities.Track", "Track") @@ -650,25 +646,21 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasColumnType("uuid"); b1.Property("LargeName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("LargePictureName"); b1.Property("MediumName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("MediumPictureName"); b1.Property("OriginalName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("OriginalPictureName"); b1.Property("SmallName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("SmallPictureName"); @@ -681,8 +673,7 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasForeignKey("PlayListId"); }); - b.Navigation("Pictures") - .IsRequired(); + b.Navigation("Pictures"); b.Navigation("User"); }); @@ -708,11 +699,6 @@ namespace Musify.Infrastructure.Persistence.Migrations modelBuilder.Entity("Musify.Domain.Entities.Track", b => { - b.HasOne("Musify.Domain.Entities.User", "Owner") - .WithMany() - .HasForeignKey("OwnerUserId") - .OnDelete(DeleteBehavior.Restrict); - b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => { b1.Property("TrackId") @@ -792,8 +778,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("Audio") .IsRequired(); - b.Navigation("Owner"); - b.Navigation("Pictures") .IsRequired(); }); @@ -806,7 +790,7 @@ namespace Musify.Infrastructure.Persistence.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Musify.Domain.Entities.Track", "Track") + b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track") .WithMany("TrackArtists") .HasForeignKey("TrackId") .OnDelete(DeleteBehavior.Cascade) @@ -847,6 +831,32 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => { b.Navigation("TrackArtists"); @@ -863,8 +873,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("PlayListTracks"); - b.Navigation("TrackArtists"); - b.Navigation("UserTracks"); }); @@ -876,6 +884,11 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("UserTracks"); }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/Musify.Worker/AppSettings.json b/backend/Musify.Worker/AppSettings.json index d0cd821..f6d308b 100644 --- a/backend/Musify.Worker/AppSettings.json +++ b/backend/Musify.Worker/AppSettings.json @@ -41,11 +41,7 @@ "OriginalPicturesFolder": "OriginalPictures", "SmallPicturesFolder": "SmallPictures", "MediumPicturesFolder": "MediumPictures", - "LargePicturesFolder": "LargePictures", - "PresetOriginalPicture": "PresetOriginalPicture.webp", - "PresetSmallPicture": "PresetSmallPicture.webp", - "PresetMediumPicture": "PresetMediumPicture.webp", - "PresetLargePicture": "PresetLargePicture.webp" + "LargePicturesFolder": "LargePictures" }, "PicturesSizes": { "SmallPictureWidth": 128, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 10ca183..a32cc7f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,8 +9,6 @@ "version": "0.0.1", "dependencies": { "@fontsource-variable/inter": "^5.2.8", - "@fontsource-variable/manrope": "^5.2.8", - "@fontsource-variable/sora": "^5.2.8", "@lucide/svelte": "^1.23.0", "@sveltejs/adapter-node": "^5.5.7", "jose": "^6.2.3", @@ -49,24 +47,6 @@ "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@fontsource-variable/manrope": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz", - "integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource-variable/sora": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.2.8.tgz", - "integrity": "sha512-sP+ILTfi5r3cqmMBntI4ooQNxFI+LyHyD5hcPbWwwL4uh4kKFAkOz0w2veCXjz18fKRdva6OG0dY+5yQ02f30w==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index 439c394..dea4e6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,8 +26,6 @@ }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", - "@fontsource-variable/manrope": "^5.2.8", - "@fontsource-variable/sora": "^5.2.8", "@lucide/svelte": "^1.23.0", "@sveltejs/adapter-node": "^5.5.7", "jose": "^6.2.3", diff --git a/frontend/src/lib/collections.ts b/frontend/src/lib/collections.ts new file mode 100644 index 0000000..8fd37f3 --- /dev/null +++ b/frontend/src/lib/collections.ts @@ -0,0 +1,15 @@ +export function appendUnique( + current: T[], + incoming: T[], + keyOf: (item: T) => string | number +): T[] { + const seen = new Set(current.map(keyOf)); + const added: T[] = []; + for (const item of incoming) { + const key = keyOf(item); + if (seen.has(key)) continue; + seen.add(key); + added.push(item); + } + return added.length > 0 ? [...current, ...added] : current; +} diff --git a/frontend/src/lib/components/Footer.svelte b/frontend/src/lib/components/Footer.svelte deleted file mode 100644 index fce1599..0000000 --- a/frontend/src/lib/components/Footer.svelte +++ /dev/null @@ -1,8 +0,0 @@ -
-
-

© {new Date().getFullYear()} Musify · Streaming de música.

-

Tu música, siempre contigo.

-
-
diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte deleted file mode 100644 index e00308c..0000000 --- a/frontend/src/lib/components/Navbar.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - -
- -
diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 78e6637..3007b09 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -3,8 +3,8 @@ import type { SessionUser } from '$lib/types'; import Home from '@lucide/svelte/icons/house'; import Search from '@lucide/svelte/icons/search'; - import Library from '@lucide/svelte/icons/library-big'; - import ListMusic from '@lucide/svelte/icons/list-music'; + import Folder from '@lucide/svelte/icons/folder'; + import Music from '@lucide/svelte/icons/music'; import Upload from '@lucide/svelte/icons/upload'; import LogOut from '@lucide/svelte/icons/log-out'; @@ -30,11 +30,11 @@ const mainLinks = [ { href: '/', label: 'Inicio', icon: Home }, { href: '/explore', label: 'Buscar', icon: Search }, - { href: '/playlists', label: 'Listas', icon: ListMusic } + { href: '/playlists', label: 'Listas', icon: Music } ]; const secondaryLinks = [ - { href: '/library', label: 'Canciones subidas', icon: Library }, + { href: '/library', label: 'Canciones subidas', icon: Folder }, { href: '/upload', label: 'Subir música', icon: Upload } ]; @@ -65,32 +65,32 @@ {link.label} {/snippet} diff --git a/frontend/src/lib/components/TrackContextMenu.svelte b/frontend/src/lib/components/TrackContextMenu.svelte new file mode 100644 index 0000000..7d66c2b --- /dev/null +++ b/frontend/src/lib/components/TrackContextMenu.svelte @@ -0,0 +1,129 @@ + + + + + + + +
+ + {#if playlists.length > 0} +
+
+ Añadir a una playlist + +
+ +
+ {/if} +
diff --git a/frontend/src/lib/components/TrackTile.svelte b/frontend/src/lib/components/TrackTile.svelte new file mode 100644 index 0000000..560f0f1 --- /dev/null +++ b/frontend/src/lib/components/TrackTile.svelte @@ -0,0 +1,76 @@ + + +
  • + +
  • diff --git a/frontend/src/lib/components/player/PlayerBar.svelte b/frontend/src/lib/components/player/PlayerBar.svelte index c5f185b..a66f122 100644 --- a/frontend/src/lib/components/player/PlayerBar.svelte +++ b/frontend/src/lib/components/player/PlayerBar.svelte @@ -6,7 +6,8 @@
    -
    - +
    + {fmtTime(player.progress)} - + {fmtTime(player.current.duration)}
    diff --git a/frontend/src/lib/components/player/TrackInfo.svelte b/frontend/src/lib/components/player/TrackInfo.svelte index c586ba9..bc1efae 100644 --- a/frontend/src/lib/components/player/TrackInfo.svelte +++ b/frontend/src/lib/components/player/TrackInfo.svelte @@ -1,6 +1,7 @@
    @@ -11,18 +12,21 @@ hue={player.current.hue} size="small" alt={player.current.title} - class="h-[52px] w-[52px] flex-shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]" + class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]" /> {:else}
    {/if}
    -
    - {player.current.title} +
    + {#if player.current.explicit} + + {/if} + {player.current.title}
    -
    {player.current.artist || '—'}
    +
    {player.current.artist || '—'}
    diff --git a/frontend/src/lib/components/player/TransportControls.svelte b/frontend/src/lib/components/player/TransportControls.svelte index d53fd7b..eb23ed1 100644 --- a/frontend/src/lib/components/player/TransportControls.svelte +++ b/frontend/src/lib/components/player/TransportControls.svelte @@ -7,7 +7,7 @@ type="button" onclick={() => player.previous()} aria-label="Anterior" - class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90" + class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90" > @@ -19,7 +19,7 @@ type="button" onclick={() => player.toggle()} aria-label={player.playing ? 'Pausar' : 'Reproducir'} - class="grid h-[36px] w-[36px] place-items-center rounded-full bg-[var(--mf-text)] text-[var(--mf-bg)] shadow-lg transition duration-100 active:scale-95" + class="grid h-[36px] w-[36px] place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95" > {#if player.playing} @@ -37,7 +37,7 @@ type="button" onclick={() => player.next()} aria-label="Siguiente" - class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90" + class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90" > diff --git a/frontend/src/lib/components/player/VolumeControl.svelte b/frontend/src/lib/components/player/VolumeControl.svelte index 5290164..4668aca 100644 --- a/frontend/src/lib/components/player/VolumeControl.svelte +++ b/frontend/src/lib/components/player/VolumeControl.svelte @@ -17,7 +17,7 @@ oninput={handleVolumeChange} aria-label="Volumen" class="mf-volume" - style="background:linear-gradient(to right, var(--mf-accent) {player.volume}%, var(--mf-track) {player.volume}%)" + style="background:linear-gradient(to right, var(--mf-accent-60) {player.volume}%, var(--mf-track) {player.volume}%)" />
    diff --git a/frontend/src/lib/components/ui/Alert.svelte b/frontend/src/lib/components/ui/Alert.svelte new file mode 100644 index 0000000..0bc485a --- /dev/null +++ b/frontend/src/lib/components/ui/Alert.svelte @@ -0,0 +1,20 @@ + + +

    + {@render children()} +

    diff --git a/frontend/src/lib/components/ui/Badge.svelte b/frontend/src/lib/components/ui/Badge.svelte new file mode 100644 index 0000000..7e35326 --- /dev/null +++ b/frontend/src/lib/components/ui/Badge.svelte @@ -0,0 +1,23 @@ + + + + {@render children()} + diff --git a/frontend/src/lib/components/ui/Button.svelte b/frontend/src/lib/components/ui/Button.svelte new file mode 100644 index 0000000..1a13cc7 --- /dev/null +++ b/frontend/src/lib/components/ui/Button.svelte @@ -0,0 +1,60 @@ + + +{#if href} + + {@render children()} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/Checkbox.svelte b/frontend/src/lib/components/ui/Checkbox.svelte new file mode 100644 index 0000000..d31eaf3 --- /dev/null +++ b/frontend/src/lib/components/ui/Checkbox.svelte @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/lib/components/ui/EmptyState.svelte b/frontend/src/lib/components/ui/EmptyState.svelte new file mode 100644 index 0000000..08a6cd8 --- /dev/null +++ b/frontend/src/lib/components/ui/EmptyState.svelte @@ -0,0 +1,31 @@ + + +
    + {#if Icon} + + + + {/if} + {#if title} +

    {title}

    + {/if} + {#if description} +

    {description}

    + {/if} + {#if actions} +
    {@render actions()}
    + {/if} +
    diff --git a/frontend/src/lib/components/ui/ExplicitBadge.svelte b/frontend/src/lib/components/ui/ExplicitBadge.svelte new file mode 100644 index 0000000..809d478 --- /dev/null +++ b/frontend/src/lib/components/ui/ExplicitBadge.svelte @@ -0,0 +1,13 @@ + + +E diff --git a/frontend/src/lib/components/ui/Field.svelte b/frontend/src/lib/components/ui/Field.svelte new file mode 100644 index 0000000..7827164 --- /dev/null +++ b/frontend/src/lib/components/ui/Field.svelte @@ -0,0 +1,27 @@ + + +
    + + {@render children()} + {#if hint} +
    + {@render hint()} +
    + {/if} +
    diff --git a/frontend/src/lib/components/ui/IconButton.svelte b/frontend/src/lib/components/ui/IconButton.svelte new file mode 100644 index 0000000..f379398 --- /dev/null +++ b/frontend/src/lib/components/ui/IconButton.svelte @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/lib/components/ui/ImageDropzone.svelte b/frontend/src/lib/components/ui/ImageDropzone.svelte new file mode 100644 index 0000000..91de8b2 --- /dev/null +++ b/frontend/src/lib/components/ui/ImageDropzone.svelte @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/lib/components/ui/InfiniteScroll.svelte b/frontend/src/lib/components/ui/InfiniteScroll.svelte new file mode 100644 index 0000000..8dc8e09 --- /dev/null +++ b/frontend/src/lib/components/ui/InfiniteScroll.svelte @@ -0,0 +1,38 @@ + + +
    + {#if loading} + + {/if} +
    diff --git a/frontend/src/lib/components/ui/Input.svelte b/frontend/src/lib/components/ui/Input.svelte new file mode 100644 index 0000000..75db04e --- /dev/null +++ b/frontend/src/lib/components/ui/Input.svelte @@ -0,0 +1,53 @@ + + +
    + {#if Icon} + + + + {/if} + +
    diff --git a/frontend/src/lib/components/ui/MediaGrid.svelte b/frontend/src/lib/components/ui/MediaGrid.svelte new file mode 100644 index 0000000..77e6d4b --- /dev/null +++ b/frontend/src/lib/components/ui/MediaGrid.svelte @@ -0,0 +1,20 @@ + + + + {@render children()} + diff --git a/frontend/src/lib/components/ui/Modal.svelte b/frontend/src/lib/components/ui/Modal.svelte new file mode 100644 index 0000000..db2eae4 --- /dev/null +++ b/frontend/src/lib/components/ui/Modal.svelte @@ -0,0 +1,94 @@ + + + + +{#if open} + +{/if} diff --git a/frontend/src/lib/components/ui/Page.svelte b/frontend/src/lib/components/ui/Page.svelte new file mode 100644 index 0000000..a42833c --- /dev/null +++ b/frontend/src/lib/components/ui/Page.svelte @@ -0,0 +1,14 @@ + + +
    + {@render children()} +
    diff --git a/frontend/src/lib/components/ui/PageHeader.svelte b/frontend/src/lib/components/ui/PageHeader.svelte new file mode 100644 index 0000000..9449160 --- /dev/null +++ b/frontend/src/lib/components/ui/PageHeader.svelte @@ -0,0 +1,24 @@ + + +
    +
    +

    {title}

    + {#if subtitle} +

    {subtitle}

    + {/if} +
    + {#if actions} +
    {@render actions()}
    + {/if} +
    diff --git a/frontend/src/lib/components/ui/PlayPauseOverlay.svelte b/frontend/src/lib/components/ui/PlayPauseOverlay.svelte new file mode 100644 index 0000000..1bf0eb5 --- /dev/null +++ b/frontend/src/lib/components/ui/PlayPauseOverlay.svelte @@ -0,0 +1,23 @@ + + + + {#if active && playing} + + {:else} + + {/if} + diff --git a/frontend/src/lib/components/ui/PlaylistArt.svelte b/frontend/src/lib/components/ui/PlaylistArt.svelte index 1e5e251..17e4f8c 100644 --- a/frontend/src/lib/components/ui/PlaylistArt.svelte +++ b/frontend/src/lib/components/ui/PlaylistArt.svelte @@ -1,23 +1,17 @@ + + + +
    {name}
    + {#if description} +
    {description}
    + {/if} +
    diff --git a/frontend/src/lib/components/ui/PlaylistForm.svelte b/frontend/src/lib/components/ui/PlaylistForm.svelte new file mode 100644 index 0000000..36eb042 --- /dev/null +++ b/frontend/src/lib/components/ui/PlaylistForm.svelte @@ -0,0 +1,95 @@ + + +
    { + submitting = true; + return async ({ update, result }) => { + await update({ reset: false }); + submitting = false; + if (result.type === 'success') onSuccess?.(); + }; + }} + class="mt-4 w-full max-w-4xl space-y-4" +> +
    + + + + +
    + + + + + + diff --git a/frontend/src/lib/components/ui/TrackIndexCell.svelte b/frontend/src/lib/components/ui/TrackIndexCell.svelte new file mode 100644 index 0000000..772c755 --- /dev/null +++ b/frontend/src/lib/components/ui/TrackIndexCell.svelte @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/lib/components/ui/TrackMeta.svelte b/frontend/src/lib/components/ui/TrackMeta.svelte new file mode 100644 index 0000000..86ee1f8 --- /dev/null +++ b/frontend/src/lib/components/ui/TrackMeta.svelte @@ -0,0 +1,14 @@ + + + + {@render children()} + diff --git a/frontend/src/lib/components/ui/TrackRow.svelte b/frontend/src/lib/components/ui/TrackRow.svelte new file mode 100644 index 0000000..ac081d4 --- /dev/null +++ b/frontend/src/lib/components/ui/TrackRow.svelte @@ -0,0 +1,24 @@ + + + +
    + {@render children()} +
    diff --git a/frontend/src/lib/components/ui/TrackTable.svelte b/frontend/src/lib/components/ui/TrackTable.svelte new file mode 100644 index 0000000..14c14c2 --- /dev/null +++ b/frontend/src/lib/components/ui/TrackTable.svelte @@ -0,0 +1,39 @@ + + + + +
    +
    + {@render headers()} +
    +
    + {@render children()} +
    +
    diff --git a/frontend/src/lib/components/ui/TrackTitleCell.svelte b/frontend/src/lib/components/ui/TrackTitleCell.svelte new file mode 100644 index 0000000..a706a1a --- /dev/null +++ b/frontend/src/lib/components/ui/TrackTitleCell.svelte @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts new file mode 100644 index 0000000..3aaf10c --- /dev/null +++ b/frontend/src/lib/config.ts @@ -0,0 +1,3 @@ +export const EXPLORE_PAGE_SIZE = 27; +export const HOME_LATEST_PAGE_SIZE = 12; +export const YOUTUBE_FILLER_LIMIT = 27; diff --git a/frontend/src/lib/player/player.svelte.ts b/frontend/src/lib/player/player.svelte.ts index 867960f..d641ab6 100644 --- a/frontend/src/lib/player/player.svelte.ts +++ b/frontend/src/lib/player/player.svelte.ts @@ -1,5 +1,5 @@ import { browser } from '$app/environment'; -import { HUES, accentForHue, gradientForHue } from '$lib/theme/color'; +import { HUES, accentForHue, gradientForHue, hueFor } from '$lib/theme/color'; import { extractAccent, type Accent } from '$lib/theme/palette'; export type TrackSourceKind = 'local' | 'youtube'; @@ -12,6 +12,7 @@ export interface PlayerTrack { hue: number; source: TrackSourceKind; coverUrl?: string; + explicit?: boolean; } export interface Playlist { @@ -26,6 +27,7 @@ export interface QueueItem { artist?: string; source?: TrackSourceKind; coverUrl?: string; + explicit?: boolean; } export interface ApiTrackLike { @@ -35,6 +37,7 @@ export interface ApiTrackLike { source?: 'Local' | 'YouTube'; externalId?: string; audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed'; + isExplicit?: boolean; } export function isYouTubeTrack(track: ApiTrackLike): boolean { @@ -65,7 +68,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] { ? isPendingYouTubeTrack(track) && track.externalId ? youTubeThumbnailUrl(track.externalId) : `/api/tracks/${track.id}/cover?size=small` - : undefined + : undefined, + explicit: track.isExplicit }; }); } @@ -74,13 +78,6 @@ const RECENT_LIMIT = 15; const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0], source: 'local' }; -function hueFor(id: string | number): number { - const text = String(id); - let hash = 0; - for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0; - return HUES[hash % HUES.length]; -} - function toTrack(item: QueueItem): PlayerTrack { return { id: item.id, @@ -88,6 +85,7 @@ function toTrack(item: QueueItem): PlayerTrack { artist: item.artist ?? '', duration: 0, hue: hueFor(item.id), + explicit: item.explicit, source: item.source ?? 'local', coverUrl: item.coverUrl }; @@ -99,7 +97,7 @@ class PlayerState { playing = $state(false); progress = $state(0); volume = $state( - browser ? Number(localStorage.getItem("player.volume") ?? 100) : 100 + browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100 ); loading = $state(false); error = $state(''); @@ -333,6 +331,12 @@ class PlayerState { this.#loadCurrent(); } + playOrToggle(list: QueueItem[], index: number) { + if (index < 0 || index >= list.length) return; + if (this.currentId === list[index].id) this.toggle(); + else this.playQueue(list, index); + } + addToQueue(item: QueueItem) { if (this.currentId === null) { this.playQueue([item], 0); @@ -404,7 +408,7 @@ class PlayerState { const audio = this.#audioEl(); if (audio) { audio.volume = this.volume / 100; - localStorage.setItem("player.volume", String(this.volume)); + localStorage.setItem('player.volume', String(this.volume)); } } } diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index e321e93..ea6c451 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -1,4 +1,5 @@ import createClient from 'openapi-fetch'; +import { error, fail, type ActionFailure } from '@sveltejs/kit'; import type { paths } from '$lib/api/schema'; import { apiConfig } from '$lib/server/config'; @@ -14,3 +15,48 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) { headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined }); } + +export function requireAccessToken(locals: App.Locals, message = 'Inicia sesión.'): string { + if (!locals.accessToken) error(401, message); + return locals.accessToken; +} + +export function requireAccessTokenAction( + locals: App.Locals, + message = 'Inicia sesión.' +): string | ActionFailure<{ message: string }> { + return locals.accessToken ?? fail(401, { message }); +} + +export function unwrapOrError( + result: { data?: T; error?: unknown; response?: Response }, + message: string, + status?: number +): T { + if (result.error || result.data === undefined) { + error(status ?? result.response?.status ?? 502, message); + } + return result.data; +} + +export function unwrapOrFail( + result: { error?: unknown }, + message: string +): ActionFailure<{ message: string }> | undefined { + return result.error ? fail(502, { message }) : undefined; +} + +export async function playlistCoverTrackIds( + api: ReturnType, + playlistIds: string[] +): Promise> { + const covers = await Promise.all( + playlistIds.map(async (playlistId) => { + const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', { + params: { path: { playlistId }, query: { pageNumber: 1, pageSize: 4 } } + }); + return [playlistId, tracks?.items.map((t) => t.id) ?? []] as const; + }) + ); + return Object.fromEntries(covers); +} diff --git a/frontend/src/lib/server/image.ts b/frontend/src/lib/server/image.ts new file mode 100644 index 0000000..c584ade --- /dev/null +++ b/frontend/src/lib/server/image.ts @@ -0,0 +1,26 @@ +import { apiConfig } from '$lib/server/config'; + +const ALLOWED_SIZES = new Set(['small', 'medium', 'large']); + +export async function proxyCoverImage( + fetch: typeof globalThis.fetch, + kind: 'playlists' | 'tracks', + id: string, + size: string | null +): Promise { + const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium'; + + const upstream = await fetch(`${apiConfig.baseUrl}/${kind}/${id}/cover?size=${safeSize}`); + + if (!upstream.ok) { + return new Response(null, { status: upstream.status }); + } + + return new Response(upstream.body, { + status: 200, + headers: { + 'Content-Type': upstream.headers.get('content-type') ?? 'image/webp', + 'Cache-Control': 'public, max-age=86400' + } + }); +} diff --git a/frontend/src/lib/server/playlistActions.ts b/frontend/src/lib/server/playlistActions.ts new file mode 100644 index 0000000..e252a75 --- /dev/null +++ b/frontend/src/lib/server/playlistActions.ts @@ -0,0 +1,66 @@ +import { fail, type RequestEvent } from '@sveltejs/kit'; +import { createApiClient, requireAccessTokenAction } from '$lib/server/api'; + +export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) { + const accessToken = requireAccessTokenAction(locals); + if (typeof accessToken !== 'string') return accessToken; + + const form = await request.formData(); + const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? ''); + const trackId = String(form.get('trackId') ?? ''); + + if (!playlistId || !trackId) { + return fail(400, { message: 'Faltan datos de la canción.' }); + } + + const api = createApiClient({ fetch, accessToken }); + const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', { + params: { path: { playlistId, trackId } } + }); + + if (err) { + if (response?.status === 409) { + return fail(409, { message: 'La canción ya está en esa playlist.', trackId }); + } + return fail(502, { message: 'No se pudo añadir la canción.', trackId }); + } + + return { added: true, trackId, playlistId }; +} + +export async function addYouTubeToPlaylistAction({ + request, + params, + locals, + fetch +}: RequestEvent) { + const accessToken = requireAccessTokenAction(locals); + if (typeof accessToken !== 'string') return accessToken; + + const form = await request.formData(); + const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? ''); + const videoId = String(form.get('videoId') ?? ''); + const title = String(form.get('title') ?? ''); + const artist = String(form.get('artist') ?? ''); + const durationSeconds = Number(form.get('durationSeconds') ?? 0); + const thumbnailUrl = String(form.get('thumbnailUrl') ?? ''); + + if (!playlistId || !videoId || !title) { + return fail(400, { message: 'Faltan datos de la canción.' }); + } + + const api = createApiClient({ fetch, accessToken }); + const { error: err, response } = await api.POST('/playlists/{playlistId}/youtube-tracks', { + params: { path: { playlistId } }, + body: { videoId, title, artist, durationSeconds, thumbnailUrl } + }); + + if (err) { + if (response?.status === 409) { + return fail(409, { message: 'La canción ya está en esa playlist.', videoId }); + } + return fail(502, { message: 'No se pudo añadir la canción.', videoId }); + } + + return { added: true, videoId, playlistId }; +} diff --git a/frontend/src/lib/server/upload.ts b/frontend/src/lib/server/upload.ts index ea15106..2e234ab 100644 --- a/frontend/src/lib/server/upload.ts +++ b/frontend/src/lib/server/upload.ts @@ -4,6 +4,33 @@ import { env } from '$env/dynamic/private'; const CONNECT_HOST = env.S3_UPLOAD_CONNECT_HOST || '127.0.0.1'; +export const IMAGE_TYPES: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp' +}; + +export const AUDIO_TYPES: Record = { + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + aac: 'audio/aac', + flac: 'audio/flac', + wav: 'audio/wav', + ogg: 'audio/ogg', + opus: 'audio/opus' +}; + +export function extOf(name: string): string { + const dot = name.lastIndexOf('.'); + return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''; +} + +export function contentTypeOf(file: File, table: Record = IMAGE_TYPES): string { + if (file.type) return file.type; + return table[extOf(file.name)] ?? 'application/octet-stream'; +} + export async function putPresigned( presignedUrl: string, body: Buffer, @@ -49,3 +76,39 @@ export async function putPresigned( request.end(); }); } + +export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string }; + +export async function uploadPresignedImage( + reserve: (args: { + fileType: string; + contentType: string; + expectedSizeBytes: number; + }) => Promise<{ + data?: { uploadUrl: string; contentType: string; intentId: string } | null; + error?: unknown; + }>, + file: File +): Promise { + const { data: reserved, error: reserveError } = await reserve({ + fileType: extOf(file.name) || 'jpg', + contentType: contentTypeOf(file), + expectedSizeBytes: file.size + }); + + if (reserveError || !reserved) { + return { failMessage: 'No se pudo reservar la subida de la portada.' }; + } + + try { + const buffer = await file.arrayBuffer(); + await putPresigned(reserved.uploadUrl, Buffer.from(buffer), reserved.contentType); + } catch (err) { + return { + failMessage: 'Falló la subida de la portada al almacenamiento.', + detail: err instanceof Error ? err.message : undefined + }; + } + + return { intentId: reserved.intentId }; +} diff --git a/frontend/src/lib/server/youtube.ts b/frontend/src/lib/server/youtube.ts new file mode 100644 index 0000000..f34b0d9 --- /dev/null +++ b/frontend/src/lib/server/youtube.ts @@ -0,0 +1,54 @@ +import type { createApiClient } from '$lib/server/api'; +import type { YouTubeSong } from '$lib/types'; + +export const YOUTUBE_FILLER_QUERIES = [ + 'pop', + 'rock', + 'reggaeton', + 'lo-fi', + 'indie', + 'salsa', + 'k-pop', + 'electrónica', + 'jazz', + 'baladas', + 'trap', + 'cumbia', + 'hip hop', + 'música clásica' +]; + +export interface YoutubeFiller { + query: string; + items: YouTubeSong[]; + continuationToken: string; +} + +export function pickRandomFillerQuery(): string { + return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)]; +} + +export function shuffle(items: T[]): T[] { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +} + +export async function fetchYoutubeFiller( + api: ReturnType, + accessToken: string | null | undefined, + limit: number, + seed?: string +): Promise { + if (!accessToken) return { query: '', items: [], continuationToken: '' }; + const query = seed?.trim() || pickRandomFillerQuery(); + const { data } = await api.GET('/youtube/search', { params: { query: { query } } }); + return { + query, + items: ((data?.items ?? []) as YouTubeSong[]).slice(0, limit), + continuationToken: data?.continuationToken ?? '' + }; +} diff --git a/frontend/src/lib/theme/color.ts b/frontend/src/lib/theme/color.ts index b7c33ac..d7c107c 100644 --- a/frontend/src/lib/theme/color.ts +++ b/frontend/src/lib/theme/color.ts @@ -1,7 +1,18 @@ export const HUES = [145, 330, 40, 152, 300, 200, 10, 120, 280]; +export const ACCENT_LIGHTNESS = 60; +export const ACCENT_CHROMA = 0.17; +export const ACCENT_HUE = 145; + +export function hueFor(id: string | number): number { + const text = String(id); + let hash = 0; + for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + return HUES[hash % HUES.length]; +} + export function accentForHue(hue: number): string { - return `oklch(72% 0.17 ${hue})`; + return `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${hue})`; } export function accentSoft(hue: number, pct: number): string { @@ -18,3 +29,9 @@ export function fmtTime(seconds: number): string { const rest = total % 60; return `${minutes}:${rest < 10 ? '0' : ''}${rest}`; } + +const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' }); + +export function fmtDate(value: string | number | Date): string { + return dateFormatter.format(new Date(value)); +} diff --git a/frontend/src/lib/theme/palette.ts b/frontend/src/lib/theme/palette.ts index 75adb81..30dfbd7 100644 --- a/frontend/src/lib/theme/palette.ts +++ b/frontend/src/lib/theme/palette.ts @@ -1,4 +1,5 @@ import { browser } from '$app/environment'; +import { ACCENT_LIGHTNESS } from './color'; export interface Accent { accent: string; @@ -107,7 +108,7 @@ export async function extractAccent(url: string): Promise { const h2 = ((hue + 25) % 360).toFixed(1); return { - accent: `oklch(72% ${c} ${h})`, + accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`, gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))` }; } diff --git a/frontend/src/lib/theme/theme.css b/frontend/src/lib/theme/theme.css index b237870..2fc92d3 100644 --- a/frontend/src/lib/theme/theme.css +++ b/frontend/src/lib/theme/theme.css @@ -1,15 +1,13 @@ -/* Paleta de Musify — cambia los colores aquí y afecta a toda la app. - El acento (--mf-accent) es dinámico: lo fija el player según la pista sonando. */ :root { - --mf-bg: #08080a; + --mf-bg: rgb(9, 9, 9); --mf-elevated: #0b0b0d; - --mf-text: #f5f4f2; - --mf-text-2: rgba(245, 244, 242, 0.7); - --mf-text-3: rgba(245, 244, 242, 0.5); - --mf-text-4: rgba(245, 244, 242, 0.4); + --mf-text: #c7c6c5; + --mf-text-2: #a5a4a3; + --mf-text-3: #8c8b8a; + --mf-text-4: #7f7e7d; - --mf-surface: rgba(255, 255, 255, 0.045); + --mf-surface: rgb(18, 18, 18); --mf-surface-2: rgba(255, 255, 255, 0.06); --mf-surface-hover: rgba(255, 255, 255, 0.09); @@ -17,6 +15,8 @@ --mf-border-strong: rgba(255, 255, 255, 0.12); --mf-track: rgba(255, 255, 255, 0.12); - --mf-accent: oklch(72% 0.17 145); + --mf-accent: oklch(60% 0.17 145); + --mf-accent-60: color-mix(in oklch, var(--mf-accent) 80%, transparent); --mf-radius: 12px; + --mf-sidebar-w: 264px; } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index bb2191e..12dc7bb 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -4,3 +4,13 @@ export interface SessionUser { email: string; picture: string; } + +export interface YouTubeSong { + videoId: string; + title: string; + artist: string; + album: string; + durationSeconds: number; + thumbnailUrl: string; + isExplicit: boolean; +} diff --git a/frontend/src/routes/(app)/explore/+page.server.ts b/frontend/src/routes/(app)/explore/+page.server.ts index a24e74b..fee8c3e 100644 --- a/frontend/src/routes/(app)/explore/+page.server.ts +++ b/frontend/src/routes/(app)/explore/+page.server.ts @@ -1,8 +1,8 @@ import type { PageServerLoad, Actions } from './$types'; -import { error, fail } from '@sveltejs/kit'; -import { createApiClient } from '$lib/server/api'; - -const PAGE_SIZE = 27; +import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { fetchYoutubeFiller } from '$lib/server/youtube'; +import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; +import { EXPLORE_PAGE_SIZE as PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config'; export const load: PageServerLoad = async ({ url, locals, fetch }) => { const query = url.searchParams.get('q')?.trim() ?? ''; @@ -31,75 +31,22 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { playlistsPromise ]); - if (tracksRes.error || !tracksRes.data) { - error(502, 'No se pudieron cargar las canciones.'); - } + const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'); return { query, - tracks: tracksRes.data, + tracks, ytResults: searchRes?.data ?? null, ytError: Boolean(searchRes?.error), + youtubeFiller: query + ? Promise.resolve(null) + : fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT), playlists: playlistsRes?.data?.items ?? [], needsAuth: !locals.user }; }; export const actions: Actions = { - addTrack: async ({ request, locals, fetch }) => { - if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); - - const form = await request.formData(); - const playlistId = String(form.get('playlistId') ?? ''); - const trackId = String(form.get('trackId') ?? ''); - - if (!playlistId || !trackId) { - return fail(400, { message: 'Faltan datos de la canción.' }); - } - - const api = createApiClient({ fetch, accessToken: locals.accessToken }); - const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', { - params: { path: { playlistId, trackId } } - }); - - if (err) { - if (response?.status === 409) { - return fail(409, { message: 'La canción ya está en esa playlist.', trackId }); - } - return fail(502, { message: 'No se pudo añadir la canción.', trackId }); - } - - return { added: true, trackId, playlistId }; - }, - - addYouTubeToPlaylist: async ({ request, locals, fetch }) => { - if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); - - const form = await request.formData(); - const playlistId = String(form.get('playlistId') ?? ''); - const videoId = String(form.get('videoId') ?? ''); - const title = String(form.get('title') ?? ''); - const artist = String(form.get('artist') ?? ''); - const durationSeconds = Number(form.get('durationSeconds') ?? 0); - const thumbnailUrl = String(form.get('thumbnailUrl') ?? ''); - - if (!playlistId || !videoId || !title) { - return fail(400, { message: 'Faltan datos de la canción.' }); - } - - const api = createApiClient({ fetch, accessToken: locals.accessToken }); - const { error: err, response } = await api.POST('/playlists/{playlistId}/youtube-tracks', { - params: { path: { playlistId } }, - body: { videoId, title, artist, durationSeconds, thumbnailUrl } - }); - - if (err) { - if (response?.status === 409) { - return fail(409, { message: 'La canción ya está en esa playlist.', videoId }); - } - return fail(502, { message: 'No se pudo añadir la canción.', videoId }); - } - - return { added: true, videoId, playlistId }; - } + addTrack: addTrackAction, + addYouTubeToPlaylist: addYouTubeToPlaylistAction }; diff --git a/frontend/src/routes/(app)/explore/+page.svelte b/frontend/src/routes/(app)/explore/+page.svelte index 3b97bfd..c13922c 100644 --- a/frontend/src/routes/(app)/explore/+page.svelte +++ b/frontend/src/routes/(app)/explore/+page.svelte @@ -1,36 +1,25 @@ @@ -172,287 +200,62 @@ -
    -

    Explorar

    -

    Busca a la vez en tu música y en YouTube Music.

    + + -
    - - - - +
    {#if form?.message} -

    - {form.message} -

    + {form.message} {/if} {#if nothingFound} -
    - -

    No hay resultados para «{data.query}».

    -
    + {/if} - {#if localVisible} - {#if localHeadingVisible} -

    Musify

    - {/if} + {#if items.length > 0} + + {#each items as item, i (itemId(item))} + togglePlay(i)} + onContextMenu={(e) => openContextMenu(e, item)} + /> + {/each} + -

    - {#if data.query} - {total} - {total === 1 ? 'resultado' : 'resultados'} para «{data.query}» - {:else} - {total} - {total === 1 ? 'canción' : 'canciones'} - {/if} -

    - - {#if tracks.items.length === 0} -
    - -

    Todavía no hay canciones. ¡Sé el primero en subir una!

    -
    - {:else} -
      - {#each tracks.items as track, i (track.id)} -
    • openContextMenu(e, { kind: 'local', track })} - > - -
    • - {/each} -
    - - {#if tracks.hasPreviousPage || tracks.hasNextPage} - - {/if} - {/if} + {/if} - {#if youtubeVisible} - {#if youtubeHeadingVisible} -

    YouTube Music

    - {/if} - - {#if data.needsAuth} -
    - -

    Inicia sesión para buscar en YouTube Music.

    -
    - {:else if data.ytError} -
    - -

    YouTube Music no está disponible ahora mismo. Inténtalo de nuevo.

    -
    - {:else} -
      - {#each ytItems as song, i (song.videoId)} -
    • openContextMenu(e, { kind: 'youtube', song })} - > - -
    • - {/each} -
    - - {#if ytContinuation} -
    - -
    - {/if} - {/if} + {#if data.needsAuth} + + {:else if data.ytError} + {/if} -
    + {#if contextMenu} -
    { - if (e.key === 'Escape') closeContextMenu(); - }} - oncontextmenu={(e) => { - e.preventDefault(); - closeContextMenu(); - }} - >
    -
    - -
    -
    - Añadir a una playlist - -
    - -
    -
    + {/if} diff --git a/frontend/src/routes/(app)/library/+page.server.ts b/frontend/src/routes/(app)/library/+page.server.ts index 39ddd42..8469f60 100644 --- a/frontend/src/routes/(app)/library/+page.server.ts +++ b/frontend/src/routes/(app)/library/+page.server.ts @@ -1,33 +1,31 @@ import type { PageServerLoad, Actions } from './$types'; -import { error, fail } from '@sveltejs/kit'; -import { createApiClient } from '$lib/server/api'; +import { fail } from '@sveltejs/kit'; +import { createApiClient, requireAccessTokenAction, unwrapOrError, unwrapOrFail } from '$lib/server/api'; const PAGE_SIZE = 50; export const load: PageServerLoad = async ({ locals, fetch }) => { - if (!locals.user) error(401, 'Inicia sesión.'); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - const { data, error: err } = await api.GET('/tracks/users/{userId}', { - params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } } + const result = await api.GET('/tracks/users/{userId}', { + params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } } }); - if (err || !data) error(502, 'No se pudo cargar tu biblioteca.'); - - return { tracks: data }; + return { tracks: unwrapOrError(result, 'No se pudo cargar tu biblioteca.') }; }; export const actions: Actions = { deleteTrack: async ({ request, locals, fetch }) => { - if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + const accessToken = requireAccessTokenAction(locals); + if (typeof accessToken !== 'string') return accessToken; const trackId = String((await request.formData()).get('trackId') ?? ''); if (!trackId) return fail(400, { message: 'Falta la canción.' }); - const api = createApiClient({ fetch, accessToken: locals.accessToken }); - const { error: err } = await api.DELETE('/tracks/{trackId}', { + const api = createApiClient({ fetch, accessToken }); + const result = await api.DELETE('/tracks/{trackId}', { params: { path: { trackId } } }); - if (err) return fail(502, { message: 'No se pudo borrar la canción.' }); + const failure = unwrapOrFail(result, 'No se pudo borrar la canción.'); + if (failure) return failure; return { deleted: true }; } }; diff --git a/frontend/src/routes/(app)/library/+page.svelte b/frontend/src/routes/(app)/library/+page.svelte index b6c124e..49da173 100644 --- a/frontend/src/routes/(app)/library/+page.svelte +++ b/frontend/src/routes/(app)/library/+page.svelte @@ -1,30 +1,28 @@ @@ -33,103 +31,69 @@ -
    -
    -
    -

    Tus canciones subidas

    -

    - {total} - {total === 1 ? 'canción' : 'canciones'} subida{total === 1 ? '' : 's'}. -

    -
    - - - Subir música - -
    + + + {#snippet actions()} + {#if items.length > 0} + + {/if} + {/snippet} + {#if items.length > 0} -
    -
    + + {#snippet headers()} # Título Subida Escuchas Duración -
    -
    - {#each items as track, i (track.id)} -
    - - -
    - {dateFormatter.format(new Date(track.createdAt))} -
    - - {Number(track.listensCount)} - - - {fmtTime(Number(track.duration))} - -
    async ({ update }) => update()}> - - -
    -
    - {/each} -
    -
    + {/snippet} + {#each items as track, i (track.id)} + + togglePlay(i)} + /> + togglePlay(i)} + /> + {fmtDate(track.createdAt)} + {Number(track.listensCount)} + {fmtTime(Number(track.duration))} +
    async ({ update }) => update()}> + + + + +
    +
    + {/each} + {:else} -
    -

    Todavía no tienes canciones

    - - - Sube la primera - -
    + {#snippet actions()} + + {/snippet} + {/if} -
    + diff --git a/frontend/src/routes/(app)/playlists/+page.server.ts b/frontend/src/routes/(app)/playlists/+page.server.ts index 465201e..4724809 100644 --- a/frontend/src/routes/(app)/playlists/+page.server.ts +++ b/frontend/src/routes/(app)/playlists/+page.server.ts @@ -1,52 +1,34 @@ import type { PageServerLoad, Actions } from './$types'; -import { error, fail, redirect } from '@sveltejs/kit'; -import { createApiClient } from '$lib/server/api'; -import { putPresigned } from '$lib/server/upload'; +import { fail, redirect } from '@sveltejs/kit'; +import { + createApiClient, + playlistCoverTrackIds, + requireAccessTokenAction, + unwrapOrError +} from '$lib/server/api'; +import { uploadPresignedImage } from '$lib/server/upload'; const MAX_NAME = 100; -const IMAGE_TYPES: Record = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - webp: 'image/webp' -}; - -function extOf(name: string): string { - const dot = name.lastIndexOf('.'); - return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''; -} - -function contentTypeOf(file: File): string { - if (file.type) return file.type; - return IMAGE_TYPES[extOf(file.name)] ?? 'application/octet-stream'; -} - export const load: PageServerLoad = async ({ locals, fetch }) => { - if (!locals.user) error(401, 'Inicia sesión.'); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - const { data, error: err } = await api.GET('/playlists/users/{userId}', { - params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } } + const result = await api.GET('/playlists/users/{userId}', { + params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: 50 } } }); - if (err || !data) error(502, 'No se pudieron cargar tus playlists.'); - - const covers = await Promise.all( - data.items.map(async (playlist) => { - const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 4 } } - }); - return [playlist.id, tracks?.items.map((t) => t.id) ?? []] as const; - }) + const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.'); + const trackIds = await playlistCoverTrackIds( + api, + data.items.map((p) => p.id) ); - return { playlists: data, trackIds: Object.fromEntries(covers) as Record }; + return { playlists: data, trackIds }; }; export const actions: Actions = { create: async ({ request, locals, fetch }) => { - if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + const accessToken = requireAccessTokenAction(locals); + if (typeof accessToken !== 'string') return accessToken; const form = await request.formData(); const name = String(form.get('name') ?? '').trim(); @@ -57,36 +39,18 @@ export const actions: Actions = { return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).', name, description }); } - const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const api = createApiClient({ fetch, accessToken }); let pictureIntentId: string | null = null; if (cover instanceof File && cover.size > 0) { - const contentType = contentTypeOf(cover); - const { data: upload, error: uploadError } = await api.POST('/playlists/upload-picture', { - body: { - fileType: extOf(cover.name) || 'jpg', - contentType, - expectedSizeBytes: cover.size - } - }); - - if (uploadError || !upload) { - return fail(502, { message: 'No se pudo reservar la subida de la portada.', name, description }); + const result = await uploadPresignedImage( + (args) => api.POST('/playlists/upload-picture', { body: args }), + cover + ); + if ('failMessage' in result) { + return fail(502, { message: result.failMessage, name, description, detail: result.detail }); } - - try { - const coverBuffer = await cover.arrayBuffer(); - await putPresigned(upload.uploadUrl, Buffer.from(coverBuffer), upload.contentType); - } catch (err) { - return fail(502, { - message: 'Falló la subida de la portada al almacenamiento.', - name, - description, - detail: err instanceof Error ? err.message : undefined - }); - } - - pictureIntentId = upload.intentId; + pictureIntentId = result.intentId; } const { data, error: err } = await api.POST('/playlists', { diff --git a/frontend/src/routes/(app)/playlists/+page.svelte b/frontend/src/routes/(app)/playlists/+page.svelte index 5b9f229..a9d89df 100644 --- a/frontend/src/routes/(app)/playlists/+page.svelte +++ b/frontend/src/routes/(app)/playlists/+page.svelte @@ -1,54 +1,44 @@ @@ -56,185 +46,54 @@ -
    -
    -
    -

    Mis listas

    -

    Crea y organiza tus colecciones.

    -
    - {#if items.length > 0} - - {/if} -
    + + + {#snippet actions()} + {#if items.length > 0} + + {/if} + {/snippet} + {#if items.length > 0} - + {:else} -
    - - - -

    Todavía no tienes listas

    -

    Crea la primera y añádele canciones de tu biblioteca.

    - -
    + {#snippet actions()} + + {/snippet} + {/if} -
    + -{#if creating} - -{/if} + (creating = false)} title="Nueva lista"> + (creating = false)} + /> + diff --git a/frontend/src/routes/(app)/playlists/[id]/+page.server.ts b/frontend/src/routes/(app)/playlists/[id]/+page.server.ts index f185d0d..362e4ce 100644 --- a/frontend/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/frontend/src/routes/(app)/playlists/[id]/+page.server.ts @@ -1,80 +1,92 @@ import type { PageServerLoad, Actions } from './$types'; -import { error, fail, redirect } from '@sveltejs/kit'; -import { createApiClient } from '$lib/server/api'; -import { putPresigned } from '$lib/server/upload'; +import { fail, redirect } from '@sveltejs/kit'; +import { + createApiClient, + requireAccessTokenAction, + unwrapOrError, + unwrapOrFail +} from '$lib/server/api'; +import { uploadPresignedImage } from '$lib/server/upload'; +import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube'; +import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; +import type { YouTubeSong } from '$lib/types'; const MAX_NAME = 100; - -const IMAGE_TYPES: Record = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - webp: 'image/webp' -}; - -function extOf(name: string): string { - const dot = name.lastIndexOf('.'); - return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''; -} - -function contentTypeOf(file: File): string { - if (file.type) return file.type; - return IMAGE_TYPES[extOf(file.name)] ?? 'application/octet-stream'; -} +const SUGGESTIONS_LIMIT = 20; export const load: PageServerLoad = async ({ params, locals, fetch }) => { - if (!locals.user) error(401, 'Inicia sesión.'); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - const [playlistRes, tracksRes, libraryRes] = await Promise.all([ + const [playlistRes, tracksRes, libraryRes, playlistsRes] = await Promise.all([ api.GET('/playlists/{id}', { params: { path: { id: params.id } } }), api.GET('/playlists/{playlistId}/tracks', { params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } } }), api.GET('/tracks/users/{userId}', { - params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } } + params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: 50 } } + }), + api.GET('/playlists/users/{userId}', { + params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: 50 } } }) ]); - if (playlistRes.error || !playlistRes.data) error(404, 'Playlist no encontrada.'); + const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404); const tracks = tracksRes.data?.items ?? []; const inPlaylist = new Set(tracks.map((t) => t.id)); const library = (libraryRes.data?.items ?? []).filter((t) => !inPlaylist.has(t.id)); - return { playlist: playlistRes.data, tracks, library }; + const alreadyLinked = new Set( + tracks.filter((t) => t.source === 'YouTube' && t.externalId).map((t) => t.externalId) + ); + const seeds = [...new Set(tracks.map((t) => t.artist).filter((a) => !!a))]; + const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined; + + const youtube = fetchYoutubeFiller( + api, + locals.accessToken, + SUGGESTIONS_LIMIT + alreadyLinked.size, + seed + ) + .then((filler) => ({ + query: filler.query, + items: (filler.items as YouTubeSong[]) + .filter((song) => !alreadyLinked.has(song.videoId)) + .slice(0, SUGGESTIONS_LIMIT) + })) + .catch(() => ({ query: '', items: [] as YouTubeSong[] })); + + return { + playlist, + tracks, + library, + youtube, + playlists: playlistsRes.data?.items ?? [] + }; }; export const actions: Actions = { - addTrack: async ({ request, params, locals, fetch }) => { - if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); - const trackId = String((await request.formData()).get('trackId') ?? ''); - if (!trackId) return fail(400, { message: 'Falta la canción.' }); - - const api = createApiClient({ fetch, accessToken: locals.accessToken }); - const { error: err } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', { - params: { path: { playlistId: params.id, trackId } } - }); - if (err) return fail(502, { message: 'No se pudo añadir la canción.' }); - return { added: true }; - }, + addTrack: addTrackAction, + addYouTubeToPlaylist: addYouTubeToPlaylistAction, removeTrack: async ({ request, params, locals, fetch }) => { - if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + const accessToken = requireAccessTokenAction(locals); + if (typeof accessToken !== 'string') return accessToken; const trackId = String((await request.formData()).get('trackId') ?? ''); if (!trackId) return fail(400, { message: 'Falta la canción.' }); - const api = createApiClient({ fetch, accessToken: locals.accessToken }); - const { error: err } = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', { + const api = createApiClient({ fetch, accessToken }); + const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', { params: { path: { playlistId: params.id, trackId } } }); - if (err) return fail(502, { message: 'No se pudo quitar la canción.' }); + const failure = unwrapOrFail(result, 'No se pudo quitar la canción.'); + if (failure) return failure; return { removed: true }; }, rename: async ({ request, params, locals, fetch }) => { - if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + const accessToken = requireAccessTokenAction(locals); + if (typeof accessToken !== 'string') return accessToken; const form = await request.formData(); const name = String(form.get('name') ?? '').trim(); const description = String(form.get('description') ?? '').trim(); @@ -83,52 +95,39 @@ export const actions: Actions = { return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' }); } - const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const api = createApiClient({ fetch, accessToken }); let newPictureIntentId: string | null = null; if (cover instanceof File && cover.size > 0) { - const contentType = contentTypeOf(cover); - const { data: upload, error: uploadError } = await api.POST('/playlists/upload-picture', { - body: { - fileType: extOf(cover.name) || 'jpg', - contentType, - expectedSizeBytes: cover.size - } - }); - - if (uploadError || !upload) { - return fail(502, { message: 'No se pudo reservar la subida de la portada.' }); + const result = await uploadPresignedImage( + (args) => api.POST('/playlists/upload-picture', { body: args }), + cover + ); + if ('failMessage' in result) { + return fail(502, { message: result.failMessage, detail: result.detail }); } - - try { - const coverBuffer = await cover.arrayBuffer(); - await putPresigned(upload.uploadUrl, Buffer.from(coverBuffer), upload.contentType); - } catch (err) { - return fail(502, { - message: 'Falló la subida de la portada al almacenamiento.', - detail: err instanceof Error ? err.message : undefined - }); - } - - newPictureIntentId = upload.intentId; + newPictureIntentId = result.intentId; } - const { error: err } = await api.PUT('/playlists/{playlistId}', { + const result = await api.PUT('/playlists/{playlistId}', { params: { path: { playlistId: params.id } }, body: { newName: name, newDescription: description, newPictureIntentId } }); - if (err) return fail(502, { message: 'No se pudo actualizar la playlist.' }); + const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.'); + if (failure) return failure; return { renamed: true }; }, delete: async ({ params, locals, fetch }) => { - if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + const accessToken = requireAccessTokenAction(locals); + if (typeof accessToken !== 'string') return accessToken; - const api = createApiClient({ fetch, accessToken: locals.accessToken }); - const { error: err } = await api.DELETE('/playlists/{playlistId}', { + const api = createApiClient({ fetch, accessToken }); + const result = await api.DELETE('/playlists/{playlistId}', { params: { path: { playlistId: params.id } } }); - if (err) return fail(502, { message: 'No se pudo eliminar la playlist.' }); + const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.'); + if (failure) return failure; redirect(303, '/playlists'); } }; diff --git a/frontend/src/routes/(app)/playlists/[id]/+page.svelte b/frontend/src/routes/(app)/playlists/[id]/+page.svelte index f6ef42b..7fae7cb 100644 --- a/frontend/src/routes/(app)/playlists/[id]/+page.svelte +++ b/frontend/src/routes/(app)/playlists/[id]/+page.svelte @@ -1,28 +1,13 @@ @@ -81,376 +40,54 @@ -
    - - - - - Volver a tus listas - - -
    - t.id)} - hue={hueFor(playlist.id)} - size="large" - class="h-44 w-44 flex-shrink-0 rounded-2xl shadow-[0_20px_45px_-15px_rgba(0,0,0,0.7)]" - /> -
    -

    Lista

    -

    {playlist.name}

    - {#if playlist.description} -

    {playlist.description}

    - {/if} -

    - {tracks.length} - {tracks.length === 1 ? 'canción' : 'canciones'} -

    -
    -
    - -
    - - - -
    + + t.id)} + updatedAt={playlist.updatedAt} + playing={isCurrentQueue && player.playing} + onPlayAll={playAll} + onEdit={() => (editing = true)} + onDelete={() => (confirmingDelete = true)} + /> {#if tracks.length > 0} -
    -
    - # - Título - Añadida - Duración - -
    -
    - {#each tracks as track, i (track.id)} -
    - - -
    - {dateFormatter.format(new Date(track.createdAt))} -
    - - {fmtTime(Number(track.duration))} - -
    async ({ update }) => update()}> - - -
    -
    - {/each} -
    -
    + {/if} - {#if library.length > 0} -
    -

    Añadir de tu biblioteca

    -
    - Título - Escuchas - Duración - -
    -
    - {#each library as track, i (track.id)} -
    - -
    - - {Number(track.listensCount)} -
    - - {fmtTime(Number(track.duration))} - -
    async ({ update }) => update()} - class="flex justify-end" - > - - -
    -
    - {/each} -
    -
    + {#if library.length > 0 || youtubeSuggestions.length > 0} + {/if} -
    + -{#if editing} -
    { - if (e.target === e.currentTarget) editing = false; - }} - onkeydown={(e) => { - if (e.key === 'Escape') editing = false; - }} - > -
    -
    -

    Editar playlist

    - -
    -
    { - savingEdit = true; - return async ({ update, result }) => { - await update({ reset: false }); - savingEdit = false; - if (result.type === 'success') editing = false; - }; - }} - class="mt-4 w-full max-w-4xl space-y-4" - > -
    -
    - - Portada (Opcional) - - -
    + (editing = false)} title="Editar playlist"> + (editing = false)} + onSuccess={() => (editing = false)} + /> + -
    -
    - - -
    - -
    - - -
    -
    -
    - - {#if form?.message} -

    - {form.message} -

    - {/if} -
    - - -
    -
    -
    + (confirmingDelete = false)} maxWidth="max-w-sm"> +

    + ¿Eliminar «{playlist.name}»? +

    +

    + Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los + dispositivos donde la tengas guardada. +

    +
    + +
    + +
    -{/if} - -{#if confirmingDelete} -
    { - if (e.target === e.currentTarget) confirmingDelete = false; - }} - onkeydown={(e) => { - if (e.key === 'Escape') confirmingDelete = false; - }} - > -
    -

    ¿Eliminar «{playlist.name}»?

    -

    - Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los dispositivos donde la tengas guardada. -

    -
    - -
    - -
    -
    -
    -
    -{/if} +
    diff --git a/frontend/src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte b/frontend/src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte new file mode 100644 index 0000000..e52fb67 --- /dev/null +++ b/frontend/src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte @@ -0,0 +1,77 @@ + + + + + + + Volver a tus listas + + +
    + +
    +

    Lista

    +

    {name}

    + {#if description} +

    {description}

    + {/if} +

    + {trackIds.length} + {trackIds.length === 1 ? 'canción' : 'canciones'} +

    +
    +
    + +
    + + + +
    diff --git a/frontend/src/routes/(app)/playlists/[id]/components/PlaylistTrackTable.svelte b/frontend/src/routes/(app)/playlists/[id]/components/PlaylistTrackTable.svelte new file mode 100644 index 0000000..34f131b --- /dev/null +++ b/frontend/src/routes/(app)/playlists/[id]/components/PlaylistTrackTable.svelte @@ -0,0 +1,79 @@ + + + + {#snippet headers()} + # + Título + Añadida + Duración + + {/snippet} + {#each tracks as track, i (track.id)} + + playFrom(i)} + /> + playFrom(i)} + > + {#snippet badge()} + {#if track.source === 'YouTube' && track.audioStatus === 'Failed'} + Error + {:else if isPendingYouTubeTrack(track)} + Descargando + {/if} + {/snippet} + + {fmtDate(track.createdAt)} + {fmtTime(Number(track.duration))} +
    async ({ update }) => update()}> + + + + +
    +
    + {/each} +
    diff --git a/frontend/src/routes/(app)/playlists/[id]/components/SuggestionsTable.svelte b/frontend/src/routes/(app)/playlists/[id]/components/SuggestionsTable.svelte new file mode 100644 index 0000000..5574677 --- /dev/null +++ b/frontend/src/routes/(app)/playlists/[id]/components/SuggestionsTable.svelte @@ -0,0 +1,151 @@ + + +
    + + + {#snippet headers()} + Título + Duración + + {/snippet} + {#each suggestions as item, i (idFor(item))} + openContextMenu(e, item)}> + playFrom(i)} + > + {#snippet overlay()} + {#if player.current.id === idFor(item)} + + {/if} + {/snippet} + + + {fmtTime( + item.kind === 'youtube' ? item.song.durationSeconds : Number(item.track.duration) + )} + + {#if item.kind === 'youtube'} +
    async ({ update }) => update()} + > + + + + + + + + +
    + {:else} +
    async ({ update }) => update()}> + + + + +
    + {/if} +
    + {/each} +
    +
    + +{#if contextMenu} + +{/if} diff --git a/frontend/src/routes/(app)/upload/+page.server.ts b/frontend/src/routes/(app)/upload/+page.server.ts index 385505b..e4ee66e 100644 --- a/frontend/src/routes/(app)/upload/+page.server.ts +++ b/frontend/src/routes/(app)/upload/+page.server.ts @@ -1,42 +1,14 @@ import type { Actions } from './$types'; import { fail } from '@sveltejs/kit'; -import { createApiClient } from '$lib/server/api'; -import { putPresigned } from '$lib/server/upload'; +import { createApiClient, requireAccessTokenAction, unwrapOrFail } from '$lib/server/api'; +import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; const MAX_TITLE = 100; -const AUDIO_TYPES: Record = { - mp3: 'audio/mpeg', - m4a: 'audio/mp4', - aac: 'audio/aac', - flac: 'audio/flac', - wav: 'audio/wav', - ogg: 'audio/ogg', - opus: 'audio/opus' -}; - -const IMAGE_TYPES: Record = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - webp: 'image/webp' -}; - -function extOf(name: string): string { - const dot = name.lastIndexOf('.'); - return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''; -} - -function contentTypeOf(file: File, table: Record): string { - if (file.type) return file.type; - return table[extOf(file.name)] ?? 'application/octet-stream'; -} - export const actions: Actions = { default: async ({ request, locals, fetch }) => { - if (!locals.accessToken) { - return fail(401, { message: 'Inicia sesión para subir música.' }); - } + const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.'); + if (typeof accessToken !== 'string') return accessToken; const form = await request.formData(); const title = String(form.get('title') ?? '').trim(); @@ -55,9 +27,9 @@ export const actions: Actions = { const audioContentType = contentTypeOf(audio, AUDIO_TYPES); const pictureContentType = contentTypeOf(cover, IMAGE_TYPES); - const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const api = createApiClient({ fetch, accessToken }); - const { data: urls, error: urlsError } = await api.POST('/tracks/upload-urls', { + const urlsResult = await api.POST('/tracks/upload-urls', { body: { pictureFileType: extOf(cover.name) || 'jpg', pictureContentType, @@ -68,9 +40,9 @@ export const actions: Actions = { } }); - if (urlsError || !urls) { - return fail(502, { message: 'No se pudieron reservar las URLs de subida.' }); - } + const urlsFailure = unwrapOrFail(urlsResult, 'No se pudieron reservar las URLs de subida.'); + if (urlsFailure) return urlsFailure; + const urls = urlsResult.data!; try { const [audioBuffer, coverBuffer] = await Promise.all([ @@ -88,7 +60,7 @@ export const actions: Actions = { }); } - const { data: track, error: createError } = await api.POST('/tracks', { + const trackResult = await api.POST('/tracks', { body: { title, pictureIntentId: urls.pictureIntentId, @@ -96,9 +68,9 @@ export const actions: Actions = { } }); - if (createError || !track) { - return fail(502, { message: 'No se pudo crear la pista tras la subida.' }); - } + const trackFailure = unwrapOrFail(trackResult, 'No se pudo crear la pista tras la subida.'); + if (trackFailure) return trackFailure; + const track = trackResult.data!; return { success: true, trackId: track.id, title: track.title }; } diff --git a/frontend/src/routes/(app)/upload/+page.svelte b/frontend/src/routes/(app)/upload/+page.svelte index cd6282f..a79f92b 100644 --- a/frontend/src/routes/(app)/upload/+page.svelte +++ b/frontend/src/routes/(app)/upload/+page.svelte @@ -4,7 +4,14 @@ import Music from '@lucide/svelte/icons/music'; import ImageIcon from '@lucide/svelte/icons/image'; import Check from '@lucide/svelte/icons/check'; - import ArrowLeft from '@lucide/svelte/icons/arrow-left'; + import Page from '$lib/components/ui/Page.svelte'; + import PageHeader from '$lib/components/ui/PageHeader.svelte'; + import Surface from '$lib/components/ui/Surface.svelte'; + import Alert from '$lib/components/ui/Alert.svelte'; + import Field from '$lib/components/ui/Field.svelte'; + import Input from '$lib/components/ui/Input.svelte'; + import Checkbox from '$lib/components/ui/Checkbox.svelte'; + import Button from '$lib/components/ui/Button.svelte'; type Status = 'idle' | 'uploading' | 'done' | 'error'; type StepState = 'idle' | 'active' | 'done'; @@ -41,15 +48,13 @@ }); const dropClass = $derived( - dragging || audioName !== '' - ? 'border-emerald-500/60' - : 'border-white/15 hover:border-emerald-500/60' + dragging || audioName !== '' ? 'border-accent/60' : 'border-line hover:border-accent/60' ); function stepClass(s: StepState) { - if (s === 'active') return 'border-emerald-500/40 bg-emerald-500/5'; - if (s === 'done') return 'border-emerald-500/40'; - return 'border-white/15'; + if (s === 'active') return 'border-accent/40 bg-accent/5'; + if (s === 'done') return 'border-accent/40'; + return ''; } function formatSize(bytes: number) { @@ -101,71 +106,51 @@ -
    - - - - - Volver a la biblioteca - -

    Subir música

    -

    - Añade una canción con su portada y título. Nosotros la procesamos para streaming. -

    + +
      {#each steps as step, i (step.key)} -
    1. -
      - - {#if stepStates[i] === 'done'} - - {:else} - {i + 1} - {/if} - - {step.label} -
      -

      {step.hint}

      +
    2. + +
      + + {#if stepStates[i] === 'done'} + + {:else} + {i + 1} + {/if} + + {step.label} +
      +

      {step.hint}

      +
    3. {/each}
    {#if status === 'done'} -
    + -

    ¡Subida!

    -

    +

    ¡Subida!

    +

    «{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en unos momentos.

    - - Ir a la biblioteca - - + +
    -
    + {:else}
    (dragging = false)} ondrop={onDrop} - class="relative flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed bg-neutral-950 p-10 text-center transition {dropClass}" + class="relative flex min-h-72 flex-col items-center justify-center rounded-panel border border-dashed bg-surface p-10 text-center transition {dropClass}" > - + {#if audioName === ''} {:else} @@ -216,21 +201,20 @@ {/if} {#if audioName === ''} -

    +

    Arrastra tu audio aquí o haz clic para elegir

    -

    MP3, FLAC, WAV…

    +

    MP3, FLAC, WAV…

    {:else} -

    {audioName}

    -

    {formatSize(audioSize)} · Listo para subir

    +

    {audioName}

    +

    {formatSize(audioSize)} · Listo para subir

    {/if}
    -
    - Portada + -
    + -
    - - + -
    + {#snippet hint()}

    Se usa como nombre público de la pista en el catálogo.

    {title.length}/{MAX_TITLE} -
    -
    + {/snippet} +
    - + + Acepto los + + términos y condiciones + + al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}. + {#if status === 'uploading'} -
    +
    - Subiendo y creando la pista… - un momento + Subiendo y creando la pista… + un momento
    -
    -
    +
    +
    -
    +
    {/if} {#if status === 'error'} -
    - {errorMsg} -
    + {errorMsg} {/if}
    - - Cancelar - - + +
    {/if} - + diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index f1ea9b6..f9270ba 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,12 +1,12 @@ @@ -88,28 +232,32 @@ -
    +
    -

    {greeting}

    -

    +

    {greeting}

    +

    Tu música. Sin límites.

    {#if recentlyPlayed.length > 0} -
    -

    Escuchado recientemente

    -
    +
    + + {#each recentlyPlayed as track, i (track.id)} {/each} -
    +
    {/if} {#if playlists.length > 0} -
    -

    Mis listas

    -
    +
    + + {#each playlists as playlist, i (playlist.id)} - - -
    - {playlist.name} -
    -
    + {/each} -
    -
    + +
    {/if} -
    -

    Novedades

    - {#if latest.length > 0} -
    - {#each latest as track, i (track.id)} - +
    + + {#if novedades.length > 0} + + {#each novedades as item, i (novedadId(item))} + playNovedad(i)} + onContextMenu={(e) => openContextMenu(e, item)} + /> {/each} -
    + + {:else} -
    - -

    Todavía no hay música

    -

    - Sube una canción - y empieza a escuchar. -

    -
    + {/if}
    + +{#if contextMenu} + +{/if} diff --git a/frontend/src/routes/api/playlists/[id]/cover/+server.ts b/frontend/src/routes/api/playlists/[id]/cover/+server.ts index bc69a85..3eaf711 100644 --- a/frontend/src/routes/api/playlists/[id]/cover/+server.ts +++ b/frontend/src/routes/api/playlists/[id]/cover/+server.ts @@ -1,25 +1,6 @@ import type { RequestHandler } from './$types'; -import { apiConfig } from '$lib/server/config'; - -const ALLOWED = new Set(['small', 'medium', 'large']); +import { proxyCoverImage } from '$lib/server/image'; export const GET: RequestHandler = async ({ params, url, fetch }) => { - const size = url.searchParams.get('size') ?? 'medium'; - const safeSize = ALLOWED.has(size) ? size : 'medium'; - - const upstream = await fetch( - `${apiConfig.baseUrl}/playlists/${params.id}/cover?size=${safeSize}` - ); - - if (!upstream.ok) { - return new Response(null, { status: upstream.status }); - } - - return new Response(upstream.body, { - status: 200, - headers: { - 'Content-Type': upstream.headers.get('content-type') ?? 'image/webp', - 'Cache-Control': 'public, max-age=86400' - } - }); + return proxyCoverImage(fetch, 'playlists', params.id, url.searchParams.get('size')); }; diff --git a/frontend/src/routes/api/tracks/+server.ts b/frontend/src/routes/api/tracks/+server.ts new file mode 100644 index 0000000..e84cff5 --- /dev/null +++ b/frontend/src/routes/api/tracks/+server.ts @@ -0,0 +1,15 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { EXPLORE_PAGE_SIZE } from '$lib/config'; + +export const GET: RequestHandler = async ({ url, locals, fetch }) => { + const name = url.searchParams.get('name')?.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 } } }); + + return json(unwrapOrError(result, 'No se pudieron cargar las canciones.')); +}; diff --git a/frontend/src/routes/api/tracks/[id]/cover/+server.ts b/frontend/src/routes/api/tracks/[id]/cover/+server.ts index 8e12c44..b19858d 100644 --- a/frontend/src/routes/api/tracks/[id]/cover/+server.ts +++ b/frontend/src/routes/api/tracks/[id]/cover/+server.ts @@ -1,25 +1,6 @@ import type { RequestHandler } from './$types'; -import { apiConfig } from '$lib/server/config'; - -const ALLOWED = new Set(['small', 'medium', 'large']); +import { proxyCoverImage } from '$lib/server/image'; export const GET: RequestHandler = async ({ params, url, fetch }) => { - const size = url.searchParams.get('size') ?? 'medium'; - const safeSize = ALLOWED.has(size) ? size : 'medium'; - - const upstream = await fetch( - `${apiConfig.baseUrl}/tracks/${params.id}/cover?size=${safeSize}` - ); - - if (!upstream.ok) { - return new Response(null, { status: upstream.status }); - } - - return new Response(upstream.body, { - status: 200, - headers: { - 'Content-Type': upstream.headers.get('content-type') ?? 'image/webp', - 'Cache-Control': 'public, max-age=86400' - } - }); + return proxyCoverImage(fetch, 'tracks', params.id, url.searchParams.get('size')); }; diff --git a/frontend/src/routes/api/tracks/[id]/stream/+server.ts b/frontend/src/routes/api/tracks/[id]/stream/+server.ts index 68618cc..fc46bda 100644 --- a/frontend/src/routes/api/tracks/[id]/stream/+server.ts +++ b/frontend/src/routes/api/tracks/[id]/stream/+server.ts @@ -1,20 +1,14 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; -import { createApiClient } from '$lib/server/api'; +import { json } from '@sveltejs/kit'; +import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api'; export const GET: RequestHandler = async ({ params, locals, fetch }) => { - if (!locals.accessToken) { - error(401, 'Inicia sesión para reproducir.'); - } + const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.'); - const api = createApiClient({ fetch, accessToken: locals.accessToken }); - const { data, error: err, response } = await api.GET('/tracks/{id}/stream', { + const api = createApiClient({ fetch, accessToken }); + const result = await api.GET('/tracks/{id}/stream', { params: { path: { id: params.id } } }); - if (err || !data) { - error(response?.status ?? 502, 'No se pudo obtener el stream de la pista.'); - } - - return json(data); + return json(unwrapOrError(result, 'No se pudo obtener el stream de la pista.')); }; diff --git a/frontend/src/routes/api/youtube/search/+server.ts b/frontend/src/routes/api/youtube/search/+server.ts index 4ed93a5..ce40165 100644 --- a/frontend/src/routes/api/youtube/search/+server.ts +++ b/frontend/src/routes/api/youtube/search/+server.ts @@ -1,11 +1,9 @@ import type { RequestHandler } from './$types'; import { json, error } from '@sveltejs/kit'; -import { createApiClient } from '$lib/server/api'; +import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api'; export const GET: RequestHandler = async ({ url, locals, fetch }) => { - if (!locals.accessToken) { - error(401, 'Inicia sesión para buscar.'); - } + const accessToken = requireAccessToken(locals, 'Inicia sesión para buscar.'); const query = url.searchParams.get('query')?.trim() ?? ''; if (!query) { @@ -14,14 +12,10 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => { const continuation = url.searchParams.get('continuation') ?? undefined; - const api = createApiClient({ fetch, accessToken: locals.accessToken }); - const { data, error: err, response } = await api.GET('/youtube/search', { + const api = createApiClient({ fetch, accessToken }); + const result = await api.GET('/youtube/search', { params: { query: { query, continuation } } }); - if (err || !data) { - error(response?.status ?? 502, 'No se pudo buscar en YouTube Music.'); - } - - return json(data); + return json(unwrapOrError(result, 'No se pudo buscar en YouTube Music.')); }; diff --git a/frontend/src/routes/api/youtube/tracks/[videoId]/stream/+server.ts b/frontend/src/routes/api/youtube/tracks/[videoId]/stream/+server.ts index 4caf97b..9085bd1 100644 --- a/frontend/src/routes/api/youtube/tracks/[videoId]/stream/+server.ts +++ b/frontend/src/routes/api/youtube/tracks/[videoId]/stream/+server.ts @@ -1,20 +1,14 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; -import { createApiClient } from '$lib/server/api'; +import { json } from '@sveltejs/kit'; +import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api'; export const GET: RequestHandler = async ({ params, locals, fetch }) => { - if (!locals.accessToken) { - error(401, 'Inicia sesión para reproducir.'); - } + const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.'); - const api = createApiClient({ fetch, accessToken: locals.accessToken }); - const { data, error: err, response } = await api.GET('/youtube/tracks/{videoId}/stream', { + const api = createApiClient({ fetch, accessToken }); + const result = await api.GET('/youtube/tracks/{videoId}/stream', { params: { path: { videoId: params.videoId } } }); - if (err || !data) { - error(response?.status ?? 502, 'No se pudo obtener el stream de YouTube.'); - } - - return json(data); + return json(unwrapOrError(result, 'No se pudo obtener el stream de YouTube.')); }; diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index c660c65..a820177 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -1,9 +1,40 @@ @import 'tailwindcss'; @theme { - --font-sans: 'Manrope Variable', ui-sans-serif, system-ui, sans-serif; - --font-display: 'Sora Variable', ui-sans-serif, system-ui, sans-serif; + --font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif; --color-neutral-950: #08080a; + + --color-bg: var(--mf-bg); + --color-elevated: var(--mf-elevated); + --color-surface: var(--mf-surface); + --color-surface-2: var(--mf-surface-2); + --color-surface-hover: var(--mf-surface-hover); + --color-hover: rgba(255, 255, 255, 0.05); + --color-line: var(--mf-border); + --color-line-strong: var(--mf-border-strong); + --color-track: var(--mf-track); + --color-fg: var(--mf-text); + --color-fg-2: var(--mf-text-2); + --color-fg-3: var(--mf-text-3); + --color-muted: var(--mf-text-4); + --color-accent: var(--mf-accent); + --color-accent-soft: var(--mf-accent-60); + --color-on-accent: #ffffff; + --color-danger: oklch(63.7% 0.237 25.331); + --color-danger-fg: oklch(88.5% 0.062 18.334); + + --radius-control: 0.625rem; + --radius-panel: 1rem; + --radius-art: 0.75rem; + --radius-art-lg: 1rem; + + --shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6); + --shadow-art-lg: 0 20px 45px -15px rgba(0, 0, 0, 0.7); + --shadow-menu: 0 12px 28px -10px rgba(0, 0, 0, 0.6); +} + +@utility page-x { + padding-inline: --spacing(8); } @utility scrollbar-stable { diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index b1ab69d..77560aa 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -1,4 +1,6 @@