From 172a0bac61cf3ed889ad9c81093921ea695d9745 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sat, 25 Jul 2026 01:46:59 +0200 Subject: [PATCH] Changes --- .../Albums/GetRecentlyListenedAlbums.cs | 2 +- .../Responses/AlbumApplicationResponse.cs | 27 +- .../Configuration/PlayListConfiguration.cs | 4 + .../Musify.Application/Contracts/IDatabase.cs | 2 + .../PlayLists/GetPlayListsByUserId.cs | 10 +- .../Services/YouTubeTrackProvisioner.cs | 133 ++ .../YouTube/GetYouTubeAlbum.cs | 22 +- .../Musify.Domain/Entities/ExternalAlbum.cs | 18 + backend/Musify.Domain/Entities/UserAlbum.cs | 2 +- .../Assets/PlayListDefaultCover.png | Bin 0 -> 10489 bytes .../YouTubeThumbNailHelper.cs} | 8 +- .../Musify.Infrastructure.csproj | 4 + .../Persistence/Database.cs | 9 + ...260724181012_AddExternalAlbums.Designer.cs | 1205 ++++++++++++++++ .../20260724181012_AddExternalAlbums.cs | 47 + ...2659_AddExternalAlbumThumbnail.Designer.cs | 1209 +++++++++++++++++ ...0260724182659_AddExternalAlbumThumbnail.cs | 29 + .../Migrations/DatabaseModelSnapshot.cs | 31 + .../Services/PlayListPresetSeeder.cs | 85 ++ .../Services/ServicesDependencyInjection.cs | 6 + .../Services/YouTubeMusicService.cs | 9 +- backend/Musify.Worker/AppSettings.json | 4 +- backend/Musify.Worker/Program.cs | 1 + web-player/src/app.d.ts | 4 + web-player/src/lib/api/schema.d.ts | 2 + .../lib/components/AlbumContextMenu.svelte | 2 - .../src/lib/components/MobileNav.svelte | 3 +- web-player/src/lib/components/Sidebar.svelte | 3 +- .../lib/components/TrackContextMenu.svelte | 2 - .../src/lib/components/player/SeekBar.svelte | 2 +- .../lib/components/player/TrackInfo.svelte | 4 +- .../src/lib/components/ui/AlbumCard.svelte | 12 +- .../src/lib/components/ui/ArtistAvatar.svelte | 9 +- .../src/lib/components/ui/BackLink.svelte | 33 + web-player/src/lib/components/ui/Cover.svelte | 13 +- .../src/lib/components/ui/MixArt.svelte | 8 +- .../src/lib/components/ui/MixBentoTile.svelte | 51 +- .../src/lib/components/ui/PlaylistArt.svelte | 12 +- .../src/lib/components/ui/PlaylistCard.svelte | 3 +- .../lib/components/ui/SectionHeading.svelte | 4 +- .../lib/components/ui/TrackTitleCell.svelte | 3 - web-player/src/lib/config.ts | 1 + web-player/src/lib/navigation.svelte.ts | 57 + web-player/src/lib/player/player.svelte.ts | 20 +- web-player/src/lib/theme/color.ts | 17 +- .../src/routes/(app)/albums/+page.svelte | 2 +- .../routes/(app)/albums/[id]/+page.server.ts | 4 +- .../src/routes/(app)/albums/[id]/+page.svelte | 5 +- .../albums/[id]/components/AlbumHeader.svelte | 19 +- .../[id]/components/AlbumTrackTable.svelte | 2 - .../[id]/components/LibraryPicker.svelte | 2 - .../albums/youtube/[albumId]/+page.server.ts | 2 +- .../albums/youtube/[albumId]/+page.svelte | 21 +- .../src/routes/(app)/explore/+page.svelte | 20 +- .../src/routes/(app)/library/+page.svelte | 10 +- .../src/routes/(app)/mixes/[id]/+page.svelte | 22 +- .../src/routes/(app)/playlists/+page.svelte | 2 +- .../routes/(app)/playlists/[id]/+page.svelte | 2 +- .../[id]/components/PlaylistHeader.svelte | 14 +- .../[id]/components/PlaylistTrackTable.svelte | 2 - .../[id]/components/SuggestionsTable.svelte | 2 - .../src/routes/(app)/upload/+page.svelte | 2 +- web-player/src/routes/+layout.svelte | 17 + web-player/src/routes/+page.svelte | 373 ++--- web-player/src/routes/layout.css | 4 +- web-player/src/routes/login/+page.svelte | 2 +- 66 files changed, 3245 insertions(+), 416 deletions(-) create mode 100644 backend/Musify.Domain/Entities/ExternalAlbum.cs create mode 100644 backend/Musify.Infrastructure/Assets/PlayListDefaultCover.png rename backend/Musify.Infrastructure/{Services/YouTubeThumbnail.cs => Helpers/YouTubeThumbNailHelper.cs} (89%) create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.Designer.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.Designer.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.cs create mode 100644 backend/Musify.Infrastructure/Services/PlayListPresetSeeder.cs create mode 100644 web-player/src/lib/components/ui/BackLink.svelte create mode 100644 web-player/src/lib/navigation.svelte.ts diff --git a/backend/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index a4399f2..a046349 100644 --- a/backend/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -36,7 +36,7 @@ namespace Musify.Application.Albums var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); - var albums = await database.UserAlbums + var albums = await database.Albums .AsNoTracking() .Where(album => albumIds.Contains(album.Id)) .Select(album => new diff --git a/backend/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index efc4281..c1e6948 100644 --- a/backend/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -14,7 +14,9 @@ namespace Musify.Application.Albums.Responses string? LargeImageKeyName, DateTime CreatedAt, DateTime UpdatedAt, - IReadOnlyList CoverTrackIds) + IReadOnlyList CoverTrackIds, + string? YouTubeAlbumId = null, + string? ThumbnailUrl = null) { public const int CoverTrackCount = 4; @@ -37,5 +39,28 @@ namespace Musify.Application.Albums.Responses album.UpdatedAt, coverTrackIds ?? []); } + + public static AlbumApplicationResponse FromEntity( + Album album, + int trackCount, + IReadOnlyList? coverTrackIds = null) + { + var externalAlbum = album as ExternalAlbum; + return new AlbumApplicationResponse( + album.Id, + album.Title, + album.Description, + album.ReleaseYear, + album is UserAlbum userAlbum ? userAlbum.OwnerUserId : 0, + trackCount, + album.Pictures?.SmallName, + album.Pictures?.MediumName, + album.Pictures?.LargeName, + album.CreatedAt, + album.UpdatedAt, + coverTrackIds ?? [], + externalAlbum?.ExternalId, + externalAlbum?.ThumbnailUrl); + } } } diff --git a/backend/Musify.Application/Configuration/PlayListConfiguration.cs b/backend/Musify.Application/Configuration/PlayListConfiguration.cs index 87d74b1..4e1eb7f 100644 --- a/backend/Musify.Application/Configuration/PlayListConfiguration.cs +++ b/backend/Musify.Application/Configuration/PlayListConfiguration.cs @@ -12,6 +12,10 @@ namespace Musify.Application.Configuration [Required] public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes(); + + public bool SeedPresetPictures { get; set; } = true; + + public bool OverwritePresetPictures { get; set; } = false; } public class PlayListRoutes diff --git a/backend/Musify.Application/Contracts/IDatabase.cs b/backend/Musify.Application/Contracts/IDatabase.cs index 49006fa..a278e0c 100644 --- a/backend/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Musify.Application/Contracts/IDatabase.cs @@ -13,6 +13,8 @@ namespace Musify.Application.Contracts DbSet UserAlbums { get; } + DbSet ExternalAlbums { get; } + DbSet AlbumHasTracks { get; } DbSet Tracks { get; } diff --git a/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs index 091985b..ec1aa93 100644 --- a/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -1,11 +1,12 @@ using ErrorOr; +using Mediator; using Microsoft.EntityFrameworkCore; -using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; using X.PagedList; using X.PagedList.EF; -using Mediator; -using Musify.Application.Contracts; namespace Musify.Application.PlayLists { @@ -35,6 +36,7 @@ namespace Musify.Application.PlayLists var pagedEntities = await playListsQuery .OrderBy(p => p.CreatedAt) + .Where(p => p.LifeCycleStatus == LifeCycleStatus.Active) .Select(p => new { PlayList = p, @@ -43,7 +45,7 @@ namespace Musify.Application.PlayLists .Take(PlayListApplicationResponse.CoverTrackCount) .Select(plt => plt.TrackId) .ToList() - }) + } ) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var pagedPlayLists = new StaticPagedList( diff --git a/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs b/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs index 61b4bc8..b29b09a 100644 --- a/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs +++ b/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs @@ -96,6 +96,108 @@ public sealed class YouTubeTrackProvisioner( } } + public async Task MaterializeAlbumAsync(string albumExternalId, YouTubeAlbumDetail detail, CancellationToken cancellationToken) + { + var validTracks = detail.Tracks + .Where(track => !string.IsNullOrEmpty(track.VideoId)) + .ToList(); + if (validTracks.Count == 0) + return; + + var album = await GetOrCreateExternalAlbumAsync(albumExternalId, detail.Album.Title, detail.Album.ThumbnailUrl, cancellationToken); + + var videoIds = validTracks.Select(track => track.VideoId).ToList(); + + var tracksByVideoId = await database.ExternalTracks + .Where(track => track.Source == TrackSource.YouTube && videoIds.Contains(track.ExternalId)) + .ToDictionaryAsync(track => track.ExternalId, cancellationToken); + + var linkedTrackIds = (await database.AlbumHasTracks + .Where(albumTrack => albumTrack.AlbumId == album.Id) + .Select(albumTrack => albumTrack.TrackId) + .ToListAsync(cancellationToken)) + .ToHashSet(); + + if (tracksByVideoId.Count == validTracks.Count && linkedTrackIds.Count >= validTracks.Count) + return; + + var artistRefs = detail.Album.Artists + .GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant()) + .Select(group => group.First()) + .ToList(); + + var albumArtists = new List(); + foreach (var artistRef in artistRefs) + albumArtists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken)); + + foreach (var albumTrack in validTracks) + { + if (!tracksByVideoId.TryGetValue(albumTrack.VideoId, out var track)) + { + track = BuildStubTrack(albumTrack); + await database.ExternalTracks.AddAsync(track, cancellationToken); + + for (var position = 0; position < albumArtists.Count; position++) + { + await database.TrackArtists.AddAsync(new TrackArtist + { + TrackId = track.Id, + ArtistId = albumArtists[position].Id, + Position = position + }, cancellationToken); + } + + tracksByVideoId[albumTrack.VideoId] = track; + } + + if (linkedTrackIds.Contains(track.Id)) + continue; + + await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + { + AlbumId = album.Id, + TrackId = track.Id, + TrackNumber = albumTrack.TrackNumber + }, cancellationToken); + linkedTrackIds.Add(track.Id); + } + + try + { + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Materialized YouTube album {AlbumId} ({TrackCount} tracks)", albumExternalId, validTracks.Count); + } + catch (DbUpdateException exception) + { + logger.LogWarning(exception, "Race while materializing YouTube album {AlbumId}", albumExternalId); + } + } + + private ExternalTrack BuildStubTrack(YouTubeAlbumTrack albumTrack) + { + var title = Truncate(albumTrack.Title, 200); + return new ExternalTrack + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Source = TrackSource.YouTube, + ExternalId = albumTrack.VideoId, + DurationSeconds = albumTrack.DurationSeconds, + Pictures = new TrackPictures + { + SmallName = trackConfiguration.Routes.PresetSmallPicture, + MediumName = trackConfiguration.Routes.PresetMediumPicture, + LargeName = trackConfiguration.Routes.PresetLargePicture, + ProcessingStatus = ProcessingStatus.Pending + }, + Audio = new TrackAudio + { + DownloadRequested = false, + TranscodeStatus = ProcessingStatus.Pending + } + }; + } + private async Task GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken) { var name = Truncate(reference.Name, 200); @@ -127,6 +229,37 @@ public sealed class YouTubeTrackProvisioner( } } + private async Task GetOrCreateExternalAlbumAsync(string externalId, string name, string? thumbnailUrl, CancellationToken cancellationToken) + { + var existing = await database.ExternalAlbums + .SingleOrDefaultAsync(album => album.Source == TrackSource.YouTube && album.ExternalId == externalId, cancellationToken); + if (existing is not null) + return existing; + + var title = Truncate(name, 200); + var album = new ExternalAlbum + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Source = TrackSource.YouTube, + ExternalId = externalId, + ThumbnailUrl = string.IsNullOrEmpty(thumbnailUrl) ? null : Truncate(thumbnailUrl, 512) + }; + + await database.ExternalAlbums.AddAsync(album, cancellationToken); + + try + { + await database.SaveChangesAsync(cancellationToken); + return album; + } + catch (DbUpdateException) + { + database.ExternalAlbums.Remove(album); + return await database.ExternalAlbums.SingleAsync(a => a.Source == TrackSource.YouTube && a.ExternalId == externalId, cancellationToken); + } + } + private static string Truncate(string value, int maxLength) => value.Length <= maxLength ? value : value[..maxLength]; } diff --git a/backend/Musify.Application/YouTube/GetYouTubeAlbum.cs b/backend/Musify.Application/YouTube/GetYouTubeAlbum.cs index 9f79651..26d2c42 100644 --- a/backend/Musify.Application/YouTube/GetYouTubeAlbum.cs +++ b/backend/Musify.Application/YouTube/GetYouTubeAlbum.cs @@ -1,13 +1,18 @@ using ErrorOr; using Mediator; +using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Services; namespace Musify.Application.YouTube { public record GetYouTubeAlbumQuery(string AlbumId) : IQuery>; - public class GetYouTubeAlbumQueryHandler(IYouTubeMusicService youTubeMusicService) + public class GetYouTubeAlbumQueryHandler( + IYouTubeMusicService youTubeMusicService, + YouTubeTrackProvisioner provisioner, + ILogger logger) : IQueryHandler> { public async ValueTask> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken) @@ -15,7 +20,20 @@ namespace Musify.Application.YouTube if (string.IsNullOrEmpty(request.AlbumId)) return Error.Validation(description: "The album id must not be empty."); - return await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken); + var result = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken); + if (result.IsError) + return result; + + try + { + await provisioner.MaterializeAlbumAsync(request.AlbumId, result.Value, cancellationToken); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to materialize YouTube album {AlbumId}", request.AlbumId); + } + + return result; } } } diff --git a/backend/Musify.Domain/Entities/ExternalAlbum.cs b/backend/Musify.Domain/Entities/ExternalAlbum.cs new file mode 100644 index 0000000..9609154 --- /dev/null +++ b/backend/Musify.Domain/Entities/ExternalAlbum.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Entities +{ + public class ExternalAlbum : Album + { + [Required] + public required TrackSource Source { get; set; } + + [Required] + [MaxLength(64)] + public required string ExternalId { get; set; } + + [MaxLength(512)] + public string? ThumbnailUrl { get; set; } + } +} diff --git a/backend/Musify.Domain/Entities/UserAlbum.cs b/backend/Musify.Domain/Entities/UserAlbum.cs index 4638f62..dbd5132 100644 --- a/backend/Musify.Domain/Entities/UserAlbum.cs +++ b/backend/Musify.Domain/Entities/UserAlbum.cs @@ -9,7 +9,7 @@ namespace Musify.Domain.Entities [Required] public required long OwnerUserId { get; set; } - [ForeignKey(nameof(OwnerUserId))] + [ForeignKey(nameof(OwnerUserId)) ] public User Owner { get; set; } } } diff --git a/backend/Musify.Infrastructure/Assets/PlayListDefaultCover.png b/backend/Musify.Infrastructure/Assets/PlayListDefaultCover.png new file mode 100644 index 0000000000000000000000000000000000000000..323650cbf986de79b11957015be0a2e3a865127c GIT binary patch literal 10489 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 VBeFu`T0u+b{da@63Ezo6^ url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase); @@ -38,7 +38,7 @@ namespace Musify.Infrastructure.Services if (lastSlash < 0) return path; - return $"{path[..lastSlash]}/{YtImgFileName(size)}"; + return $"{path[..lastSlash] }/{YtImgFileName(size) }"; } private static string YtImgFileName(int size) => size switch @@ -50,6 +50,6 @@ namespace Musify.Infrastructure.Services }; private static bool IsSizeSuffix(ReadOnlySpan suffix) => - suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1]); + suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1] ); } } diff --git a/backend/Musify.Infrastructure/Musify.Infrastructure.csproj b/backend/Musify.Infrastructure/Musify.Infrastructure.csproj index 48ef7ef..a221580 100644 --- a/backend/Musify.Infrastructure/Musify.Infrastructure.csproj +++ b/backend/Musify.Infrastructure/Musify.Infrastructure.csproj @@ -43,4 +43,8 @@ + + + + diff --git a/backend/Musify.Infrastructure/Persistence/Database.cs b/backend/Musify.Infrastructure/Persistence/Database.cs index 2fd4af0..f28bfa0 100644 --- a/backend/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Musify.Infrastructure/Persistence/Database.cs @@ -117,6 +117,13 @@ namespace Musify.Infrastructure.Persistence .HasForeignKey(album => album.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .ToTable("ExternalAlbums"); + + modelBuilder.Entity() + .HasIndex(album => new { album.Source, album.ExternalId }) + .IsUnique(); + modelBuilder.Entity() .HasOne(albumTrack => albumTrack.Album) .WithMany(album => album.AlbumTracks) @@ -176,6 +183,8 @@ namespace Musify.Infrastructure.Persistence public DbSet UserAlbums => Set(); + public DbSet ExternalAlbums => Set(); + public DbSet AlbumHasTracks => Set(); public DbSet UserHasTracks => Set(); diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.Designer.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.Designer.cs new file mode 100644 index 0000000..20abf17 --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.Designer.cs @@ -0,0 +1,1205 @@ +// +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("20260724181012_AddExternalAlbums")] + partial class AddExternalAlbums + { + /// + 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.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + 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.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .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("double precision"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + 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.ExternalAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("UserAlbums", (string)null); + }); + + 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.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + 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.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + 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"); + + 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.ExternalAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.UserAlbum", "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.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.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + 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/20260724181012_AddExternalAlbums.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.cs new file mode 100644 index 0000000..d35e870 --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260724181012_AddExternalAlbums.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddExternalAlbums : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExternalAlbums", + 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_ExternalAlbums", x => x.Id); + table.ForeignKey( + name: "FK_ExternalAlbums_Albums_Id", + column: x => x.Id, + principalTable: "Albums", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExternalAlbums_Source_ExternalId", + table: "ExternalAlbums", + columns: new[] { "Source", "ExternalId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ExternalAlbums"); + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.Designer.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.Designer.cs new file mode 100644 index 0000000..c002621 --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.Designer.cs @@ -0,0 +1,1209 @@ +// +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("20260724182659_AddExternalAlbumThumbnail")] + partial class AddExternalAlbumThumbnail + { + /// + 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.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + 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.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .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("double precision"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + 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.ExternalAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("UserAlbums", (string)null); + }); + + 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.Album", b => + { + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .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("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + 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.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + 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"); + + 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.ExternalAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.UserAlbum", "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.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.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + 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/20260724182659_AddExternalAlbumThumbnail.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.cs new file mode 100644 index 0000000..4d3edcb --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260724182659_AddExternalAlbumThumbnail.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddExternalAlbumThumbnail : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ThumbnailUrl", + table: "ExternalAlbums", + type: "character varying(512)", + maxLength: 512, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ThumbnailUrl", + table: "ExternalAlbums"); + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 71cbfbd..41d2ab4 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -720,6 +720,28 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("TrackProcessingState"); }); + modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalAlbums", (string)null); + }); + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => { b.HasBaseType("Musify.Domain.Entities.Album"); @@ -1082,6 +1104,15 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => { b.HasOne("Musify.Domain.Entities.Album", null) diff --git a/backend/Musify.Infrastructure/Services/PlayListPresetSeeder.cs b/backend/Musify.Infrastructure/Services/PlayListPresetSeeder.cs new file mode 100644 index 0000000..69db5eb --- /dev/null +++ b/backend/Musify.Infrastructure/Services/PlayListPresetSeeder.cs @@ -0,0 +1,85 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.Services +{ + public class PlayListPresetSeeder(IServiceScopeFactory scopeFactory, ILogger logger) + : BackgroundService + { + private const string SourceResourceName = "Musify.Infrastructure.Assets.PlayListDefaultCover.png"; + private const string WebpContentType = "image/webp"; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var scope = scopeFactory.CreateScope(); + var services = scope.ServiceProvider; + + var playListConfiguration = services.GetRequiredService(); + if (!playListConfiguration.SeedPresetPictures) + return; + + try + { + await SeedAsync(services, playListConfiguration, stoppingToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to seed playlist preset pictures"); + } + } + + private async Task SeedAsync(IServiceProvider services, PlayListConfiguration configuration, CancellationToken cancellationToken) + { + var storage = services.GetRequiredService(); + var pictures = services.GetRequiredService(); + var bucket = services.GetRequiredService().Bucket; + + var source = LoadSourceBytes(); + + var routes = configuration.Routes; + var sizes = configuration.PicturesSizes; + + var targets = new (string Key, int Width, int Height)[] + { + (routes.BuildOriginalPicturePath(routes.PresetOriginalPicture), sizes.LargePictureWidth, sizes.LargePictureHeight), + (routes.PresetSmallPicturePath, sizes.SmallPictureWidth, sizes.SmallPictureHeight), + (routes.PresetMediumPicturePath, sizes.MediumPictureWidth, sizes.MediumPictureHeight), + (routes.PresetLargePicturePath, sizes.LargePictureWidth, sizes.LargePictureHeight), + }; + + foreach (var target in targets) + { + if (!configuration.OverwritePresetPictures) + { + var existing = await storage.HeadObjectAsync(bucket, target.Key, cancellationToken); + if (existing is not null) + { + logger.LogDebug("Playlist preset picture already present at {Bucket}/{Key}", bucket, target.Key); + continue; + } + } + + using var sourceStream = new MemoryStream(source, writable: false); + await using var resized = await pictures.ResizePictureAsWebpAsync(sourceStream, target.Width, target.Height, cancellationToken); + await storage.UploadFileAsync(resized, WebpContentType, bucket, target.Key, cancellationToken); + + logger.LogInformation("Seeded playlist preset picture {Bucket}/{Key} ({Width}x{Height})", bucket, target.Key, target.Width, target.Height); + } + } + + private static byte[] LoadSourceBytes() + { + var assembly = Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream(SourceResourceName) + ?? throw new InvalidOperationException($"Embedded resource '{SourceResourceName}' was not found."); + + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } + } +} diff --git a/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 83744c4..053b0cc 100644 --- a/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -45,6 +45,12 @@ namespace Musify.Infrastructure.Services return serviceDescriptors; } + public static IServiceCollection AddPlayListPresetSeeder(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddHostedService(); + return serviceDescriptors; + } + public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); diff --git a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs index f9d3cf1..391875e 100644 --- a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs +++ b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.Helpers; using YouTubeMusicAPI.Client; using YouTubeMusicAPI.Models.Search; using YouTubeMusicAPI.Models.Streaming; @@ -260,7 +261,7 @@ namespace Musify.Infrastructure.Services videoId, info.Name, string.Join(", ", info.Artists.Select(artist => artist.Name)), - string.Empty, + info.Album?.Name ?? string.Empty, (int)info.Duration.TotalSeconds, PickThumbnail(info.Thumbnails), info.IsExplicit, @@ -278,13 +279,13 @@ namespace Musify.Infrastructure.Services } public string ResolveArtworkUrl(string thumbnailUrl) => - YouTubeThumbnail.WithSize(thumbnailUrl, configuration.ArtworkSize); + YouTubeThumbNailHelper.WithSize(thumbnailUrl, configuration.ArtworkSize); private string PickThumbnail(IEnumerable thumbnails) { var ordered = thumbnails.OrderBy(thumbnail => thumbnail.Width).ToList(); - var square = ordered.Where(thumbnail => YouTubeThumbnail.IsSquare(thumbnail.Url)).ToList(); + var square = ordered.Where(thumbnail => YouTubeThumbNailHelper.IsSquare(thumbnail.Url)).ToList(); var candidates = square.Count > 0 ? square : ordered; var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize) @@ -292,7 +293,7 @@ namespace Musify.Infrastructure.Services return chosen is null ? string.Empty - : YouTubeThumbnail.WithSize(chosen.Url, configuration.ThumbnailSize); + : YouTubeThumbNailHelper.WithSize(chosen.Url, configuration.ThumbnailSize); } private static string SearchCacheKey(string token) => $"yt-search:{token}"; diff --git a/backend/Musify.Worker/AppSettings.json b/backend/Musify.Worker/AppSettings.json index c3a6934..10d9bd3 100644 --- a/backend/Musify.Worker/AppSettings.json +++ b/backend/Musify.Worker/AppSettings.json @@ -47,7 +47,9 @@ "MediumPictureHeight": 256, "LargePictureWidth": 512, "LargePictureHeight": 512 - } + }, + "SeedPresetPictures": true, + "OverwritePresetPictures": false }, "Track": { "Routes": { diff --git a/backend/Musify.Worker/Program.cs b/backend/Musify.Worker/Program.cs index 15fe304..fbfd431 100644 --- a/backend/Musify.Worker/Program.cs +++ b/backend/Musify.Worker/Program.cs @@ -34,6 +34,7 @@ services.AddAudioTranscoder(configuration); services.AddYouTubeDownloader(configuration); services.AddHttpClient(); services.AddPictureService(configuration); +services.AddPlayListPresetSeeder(configuration); services.AddDatabase(configuration); services.AddMassTransitConsumers(configuration); services.AddUploadIntentJobs(configuration); diff --git a/web-player/src/app.d.ts b/web-player/src/app.d.ts index 3640325..bc8d183 100644 --- a/web-player/src/app.d.ts +++ b/web-player/src/app.d.ts @@ -6,6 +6,10 @@ declare global { user: SessionUser | null; accessToken: string | null; } + + interface PageData { + section?: string | null; + } } } diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 96f1764..6d73f48 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -582,6 +582,8 @@ export interface components { /** Format: date-time */ updatedAt: string; coverTrackIds: string[]; + youTubeAlbumId: null | string; + thumbnailUrl: null | string; }; CreateAlbumRequest: { title: string; diff --git a/web-player/src/lib/components/AlbumContextMenu.svelte b/web-player/src/lib/components/AlbumContextMenu.svelte index 3b7edac..5d84412 100644 --- a/web-player/src/lib/components/AlbumContextMenu.svelte +++ b/web-player/src/lib/components/AlbumContextMenu.svelte @@ -26,7 +26,6 @@ import { enhance } from '$app/forms'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; - import { hueFor } from '$lib/theme/color'; interface Props { menu: AlbumMenuState; @@ -94,7 +93,6 @@ diff --git a/web-player/src/lib/components/MobileNav.svelte b/web-player/src/lib/components/MobileNav.svelte index 4a10157..6d6e48e 100644 --- a/web-player/src/lib/components/MobileNav.svelte +++ b/web-player/src/lib/components/MobileNav.svelte @@ -1,5 +1,6 @@ diff --git a/web-player/src/lib/components/Sidebar.svelte b/web-player/src/lib/components/Sidebar.svelte index 7a29c77..17a2406 100644 --- a/web-player/src/lib/components/Sidebar.svelte +++ b/web-player/src/lib/components/Sidebar.svelte @@ -1,5 +1,6 @@ + + + + + + {target.label} + diff --git a/web-player/src/lib/components/ui/Cover.svelte b/web-player/src/lib/components/ui/Cover.svelte index 85702b1..55fad76 100644 --- a/web-player/src/lib/components/ui/Cover.svelte +++ b/web-player/src/lib/components/ui/Cover.svelte @@ -1,11 +1,9 @@ -