This commit is contained in:
2026-07-18 23:29:57 +02:00
parent 4fed9bd059
commit 2dc544b2c0
109 changed files with 5134 additions and 2163 deletions
+1 -5
View File
@@ -60,11 +60,7 @@
"OriginalPicturesFolder": "OriginalPictures", "OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures", "SmallPicturesFolder": "SmallPictures",
"MediumPicturesFolder": "MediumPictures", "MediumPicturesFolder": "MediumPictures",
"LargePicturesFolder": "LargePictures", "LargePicturesFolder": "LargePictures"
"PresetOriginalPicture": "PresetOriginalPicture.webp",
"PresetSmallPicture": "PresetSmallPicture.webp",
"PresetMediumPicture": "PresetMediumPicture.webp",
"PresetLargePicture": "PresetLargePicture.webp"
}, },
"PicturesSizes": { "PicturesSizes": {
"SmallPictureWidth": 128, "SmallPictureWidth": 128,
@@ -34,30 +34,12 @@ namespace Musify.Application.Configuration
[Required] [Required]
public string LargePicturesFolder { get; set; } = "LargePictures"; 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 SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); 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 OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
@@ -11,6 +11,10 @@ namespace Musify.Application.Contracts
DbSet<Track> Tracks { get; } DbSet<Track> Tracks { get; }
DbSet<LocalTrack> LocalTracks { get; }
DbSet<ExternalTrack> ExternalTracks { get; }
DbSet<Artist> Artists { get; } DbSet<Artist> Artists { get; }
DbSet<TrackArtist> TrackArtists { get; } DbSet<TrackArtist> TrackArtists { get; }
@@ -42,8 +42,6 @@ namespace Musify.Application.PlayLists
} }
UploadIntent? pictureIntent = null; UploadIntent? pictureIntent = null;
string originalPictureName;
if (request.PictureIntentId.HasValue) if (request.PictureIntentId.HasValue)
{ {
var validation = await uploadIntentValidator.ValidateAndLoadAsync( var validation = await uploadIntentValidator.ValidateAndLoadAsync(
@@ -53,11 +51,6 @@ namespace Musify.Application.PlayLists
return validation.Errors; return validation.Errors;
pictureIntent = validation.Value; pictureIntent = validation.Value;
originalPictureName = pictureIntent.ObjectName;
}
else
{
originalPictureName = playListConfiguration.Routes.PresetOriginalPicture;
} }
var playList = new PlayList var playList = new PlayList
@@ -66,20 +59,15 @@ namespace Musify.Application.PlayLists
Name = request.Name, Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(), NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = request.Description, Description = request.Description,
Pictures = new PlayListPictures Pictures = pictureIntent == null ? null
{ : new PlayListPictures { OriginalName = pictureIntent.ObjectName }
OriginalName = originalPictureName,
SmallName = playListConfiguration.Routes.PresetSmallPicture,
MediumName = playListConfiguration.Routes.PresetMediumPicture,
LargeName = playListConfiguration.Routes.PresetLargePicture
}
}; };
await database.PlayLists.AddAsync(playList, cancellationToken); 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( var publishResult = await PublishCreatePlayListEventAsync(
playList.Id, pictureIntent, finalPictureKey, cancellationToken); playList.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError) if (publishResult.IsError)
@@ -20,23 +20,23 @@ namespace Musify.Application.PlayLists
{ {
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
{ {
var playList = await database.PlayLists.AsNoTracking() var pictures = await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.PlayListId) .Where(p => p.Id == request.PlayListId)
.Select(p => new { p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName }) .Select(p => p.Pictures)
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
if (playList == null) if (pictures is null)
return Error.NotFound(); return Error.NotFound();
var routes = playListConfiguration.Routes; 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), "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (playList.LargeName, BuildKey(routes.BuildLargePicturePath, playList.LargeName), routes.PresetLargePicture), "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (playList.MediumName, BuildKey(routes.BuildMediumPicturePath, playList.MediumName), routes.PresetMediumPicture) _ => (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(); return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name); var contentType = MimeUtility.GetMimeMapping(name);
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
@@ -28,9 +29,9 @@ namespace Musify.Application.PlayLists
var tracksQuery = database.PlayListHasTracks var tracksQuery = database.PlayListHasTracks
.AsNoTracking() .AsNoTracking()
.Include(plt => plt.Track) .Include(plt => ((ExternalTrack)plt.Track).TrackArtists)
.ThenInclude(track => track.TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist) .ThenInclude(trackArtist => trackArtist.Artist)
.Include(plt => ((LocalTrack)plt.Track).Owner)
.Where(plt => plt.PlayListId == request.PlayListId); .Where(plt => plt.PlayListId == request.PlayListId);
var totalCount = await tracksQuery.CountAsync(cancellationToken); var totalCount = await tracksQuery.CountAsync(cancellationToken);
@@ -5,10 +5,10 @@ namespace Musify.Application.PlayLists.Responses
public record PlayListApplicationResponse( public record PlayListApplicationResponse(
Guid Id, Guid Id,
string Name, string Name,
string Description, string? Description,
string SmallImageKeyName, string? SmallImageKeyName,
string MediumImageKeyName, string? MediumImageKeyName,
string LargeImageKeyName, string? LargeImageKeyName,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt) DateTime UpdatedAt)
{ {
@@ -18,9 +18,9 @@ namespace Musify.Application.PlayLists.Responses
playList.Id, playList.Id,
playList.Name, playList.Name,
playList.Description, playList.Description,
playList.Pictures.SmallName, playList.Pictures?.SmallName,
playList.Pictures.MediumName, playList.Pictures?.MediumName,
playList.Pictures.LargeName, playList.Pictures?.LargeName,
playList.CreatedAt, playList.CreatedAt,
playList.UpdatedAt); playList.UpdatedAt);
} }
@@ -8,6 +8,7 @@ using Musify.Application.Events;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists
@@ -68,7 +69,7 @@ namespace Musify.Application.PlayLists
return validation.Errors; return validation.Errors;
pictureIntent = validation.Value; pictureIntent = validation.Value;
playListEntity.Pictures.OriginalName = pictureIntent.ObjectName; playListEntity.Pictures = new PlayListPictures { OriginalName = pictureIntent.ObjectName };
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
} }
@@ -8,7 +8,7 @@ using Musify.Domain.ValueObjects;
namespace Musify.Application.Services; namespace Musify.Application.Services;
public sealed record YouTubeTrackProvision(Track Track, YouTubeSongResult? Song); public sealed record YouTubeTrackProvision(ExternalTrack Track, YouTubeSongResult? Song);
public sealed class YouTubeTrackProvisioner( public sealed class YouTubeTrackProvisioner(
IDatabase database, IDatabase database,
@@ -16,8 +16,8 @@ public sealed class YouTubeTrackProvisioner(
TrackConfiguration trackConfiguration, TrackConfiguration trackConfiguration,
ILogger<YouTubeTrackProvisioner> logger) ILogger<YouTubeTrackProvisioner> logger)
{ {
public Task<Track?> FindAsync(string videoId, CancellationToken cancellationToken) => public Task<ExternalTrack?> FindAsync(string videoId, CancellationToken cancellationToken) =>
database.Tracks database.ExternalTracks
.Include(track => track.TrackArtists) .Include(track => track.TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist) .ThenInclude(trackArtist => trackArtist.Artist)
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken); .SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken);
@@ -44,7 +44,7 @@ public sealed class YouTubeTrackProvisioner(
artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken)); artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
var title = Truncate(song.Title, 50); var title = Truncate(song.Title, 50);
var track = new Track var track = new ExternalTrack
{ {
Title = title, Title = title,
NormalizedTitle = title.ToUpperInvariant(), 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++) for (var position = 0; position < artists.Count; position++)
{ {
@@ -85,7 +85,7 @@ public sealed class YouTubeTrackProvisioner(
} }
catch (DbUpdateException) catch (DbUpdateException)
{ {
database.Tracks.Remove(track); database.ExternalTracks.Remove(track);
var refetched = await FindAsync(videoId, cancellationToken); var refetched = await FindAsync(videoId, cancellationToken);
if (refetched is null) if (refetched is null)
{ {
@@ -100,43 +100,17 @@ public sealed class YouTubeTrackProvisioner(
{ {
var name = Truncate(reference.Name, 200); var name = Truncate(reference.Name, 200);
var normalized = name.ToUpperInvariant(); var normalized = name.ToUpperInvariant();
var byExternalId = !string.IsNullOrEmpty(reference.Id); var externalId = reference.Id ?? $"name:{normalized}";
var existing = byExternalId var existing = await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == externalId, cancellationToken);
? 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);
if (existing is not null) if (existing is not null)
return existing; 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 var artist = new Artist
{ {
Name = name, Name = name,
NormalizedName = normalized, NormalizedName = normalized,
ExternalId = byExternalId ? reference.Id : null ExternalId = externalId
}; };
await database.Artists.AddAsync(artist, cancellationToken); await database.Artists.AddAsync(artist, cancellationToken);
@@ -149,9 +123,7 @@ public sealed class YouTubeTrackProvisioner(
catch (DbUpdateException) catch (DbUpdateException)
{ {
database.Artists.Remove(artist); database.Artists.Remove(artist);
return byExternalId return await database.Artists.SingleAsync(a => a.ExternalId == externalId, cancellationToken);
? 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);
} }
} }
@@ -32,7 +32,7 @@ namespace Musify.Application.Tracks
{ {
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
{ {
var user = await database.Users.AsNoTracking() var user = await database.Users
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); .SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user is null) if (user is null)
{ {
@@ -59,13 +59,12 @@ namespace Musify.Application.Tracks
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
var artist = await GetOrCreateUserArtistAsync(user, cancellationToken); var trackEntity = new LocalTrack
var trackEntity = new Track
{ {
Title = request.Title, Title = request.Title,
NormalizedTitle = request.Title.ToUpperInvariant(), NormalizedTitle = request.Title.ToUpperInvariant(),
OwnerUserId = user.Id, OwnerUserId = user.Id,
Owner = user,
Pictures = new TrackPictures Pictures = new TrackPictures
{ {
OriginalName = pictureIntent.ObjectName, OriginalName = pictureIntent.ObjectName,
@@ -81,14 +80,7 @@ namespace Musify.Application.Tracks
} }
}; };
await database.Tracks.AddAsync(trackEntity, cancellationToken); await database.LocalTracks.AddAsync(trackEntity, cancellationToken);
await database.TrackArtists.AddAsync(new TrackArtist
{
TrackId = trackEntity.Id,
ArtistId = artist.Id,
Position = 0
}, cancellationToken);
await database.UserHasTracks.AddAsync(new UserHasTrack await database.UserHasTracks.AddAsync(new UserHasTrack
{ {
@@ -141,32 +133,5 @@ namespace Musify.Application.Tracks
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
} }
private async Task<Artist> 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);
}
}
} }
} }
@@ -2,6 +2,7 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Contracts; using Musify.Application.Contracts;
@@ -28,7 +29,7 @@ namespace Musify.Application.Tracks
return Error.NotFound(); 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); logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized(); return Error.Unauthorized();
@@ -2,6 +2,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
@@ -24,8 +25,9 @@ namespace Musify.Application.Tracks
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var tracksById = await database.Tracks var tracksById = await database.Tracks
.Include(t => t.TrackArtists) .Include(t => ((ExternalTrack)t).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist) .ThenInclude(trackArtist => trackArtist.Artist)
.Include(t => ((LocalTrack)t).Owner)
.Where(t => recentTrackIds.Contains(t.Id)) .Where(t => recentTrackIds.Contains(t.Id))
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } ) .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } )
.ToDictionaryAsync(t => t.Id, cancellationToken); .ToDictionaryAsync(t => t.Id, cancellationToken);
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
@@ -15,8 +16,9 @@ namespace Musify.Application.Tracks
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
{ {
var entity = await database.Tracks.AsNoTracking() var entity = await database.Tracks.AsNoTracking()
.Include(t => t.TrackArtists) .Include(t => ((ExternalTrack)t).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist) .ThenInclude(trackArtist => trackArtist.Artist)
.Include(t => ((LocalTrack)t).Owner)
.Where(t => t.Id == request.TrackId) .Where(t => t.Id == request.TrackId)
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count }) .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
@@ -6,6 +6,7 @@ using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
@@ -19,9 +20,9 @@ namespace Musify.Application.Tracks
{ {
var tracksQuery = database.UserHasTracks var tracksQuery = database.UserHasTracks
.AsNoTracking() .AsNoTracking()
.Include(ut => ut.Track) .Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
.ThenInclude(track => track.TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist) .ThenInclude(trackArtist => trackArtist.Artist)
.Include(ut => ((LocalTrack)ut.Track).Owner)
.AsQueryable(); .AsQueryable();
if (!string.IsNullOrEmpty(request.Name)) if (!string.IsNullOrEmpty(request.Name))
@@ -6,6 +6,7 @@ using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
@@ -26,9 +27,9 @@ namespace Musify.Application.Tracks
var tracksQuery = database.UserHasTracks var tracksQuery = database.UserHasTracks
.AsNoTracking() .AsNoTracking()
.Include(ut => ut.Track) .Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
.ThenInclude(track => track.TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist) .ThenInclude(trackArtist => trackArtist.Artist)
.Include(ut => ((LocalTrack)ut.Track).Owner)
.Where(p => p.UserId == request.UserId); .Where(p => p.UserId == request.UserId);
if (!string.IsNullOrEmpty(request.Name)) if (!string.IsNullOrEmpty(request.Name))
@@ -22,23 +22,23 @@ namespace Musify.Application.Tracks.Responses
track.Id, track.Id,
track.Title, track.Title,
FormatArtist(track), FormatArtist(track),
track.Source, track is ExternalTrack external ? external.Source : TrackSource.Local,
track.ExternalId, (track as ExternalTrack)?.ExternalId,
track.Audio.TranscodeStatus, track.Audio.TranscodeStatus,
track.DurationSeconds, track.DurationSeconds,
listensCount, listensCount,
track.OwnerUserId, (track as LocalTrack)?.OwnerUserId,
track.CreatedAt, track.CreatedAt,
track.UpdatedAt); track.UpdatedAt);
private static string? FormatArtist(Track track) private static string? FormatArtist(Track track) => track switch
{ {
if (track.TrackArtists.Count == 0) LocalTrack local => local.Owner.Name,
return null; ExternalTrack { TrackArtists.Count: > 0 } external =>
string.Join(", ", external.TrackArtists
return string.Join(", ", track.TrackArtists
.OrderBy(trackArtist => trackArtist.Position) .OrderBy(trackArtist => trackArtist.Position)
.Select(trackArtist => trackArtist.Artist.Name)); .Select(trackArtist => trackArtist.Artist.Name)),
} _ => null
};
} }
} }
@@ -47,14 +47,6 @@ namespace Musify.Application.Users
user.SecondName = request.SecondName; user.SecondName = request.SecondName;
user.ProfilePictureUrl = request.ProfilePictureUrl; 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); var written = await database.SaveChangesAsync(cancellationToken);
if (written > 0) if (written > 0)
logger.LogInformation("Synced profile for user {UserId}", request.Id); logger.LogInformation("Synced profile for user {UserId}", request.Id);
+2 -7
View File
@@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.Abstractions; using Musify.Domain.Abstractions;
namespace Musify.Domain.Entities namespace Musify.Domain.Entities
@@ -18,10 +17,9 @@ namespace Musify.Domain.Entities
[MaxLength(200)] [MaxLength(200)]
public required string NormalizedName { get; set; } public required string NormalizedName { get; set; }
public long? UserId { get; set; } [Required]
[MaxLength(64)] [MaxLength(64)]
public string? ExternalId { get; set; } public required string ExternalId { get; set; }
[Required] [Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -29,9 +27,6 @@ namespace Musify.Domain.Entities
[Required] [Required]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(UserId)) ]
public User? User { get; set; }
public ICollection<TrackArtist> TrackArtists { get; set; } = new List<TrackArtist>(); public ICollection<TrackArtist> TrackArtists { get; set; } = new List<TrackArtist>();
} }
} }
@@ -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<TrackArtist> TrackArtists { get; set; } = new List<TrackArtist>();
}
}
@@ -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; }
}
}
+2 -2
View File
@@ -26,9 +26,9 @@ namespace Musify.Domain.Entities
[Required] [Required]
[MaxLength(256)] [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] [Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
+1 -18
View File
@@ -1,12 +1,11 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.Abstractions; using Musify.Domain.Abstractions;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities namespace Musify.Domain.Entities
{ {
#pragma warning disable CS8618 #pragma warning disable CS8618
public class Track : IAuditable public abstract class Track : IAuditable
{ {
[Key] [Key]
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -22,14 +21,6 @@ namespace Musify.Domain.Entities
[Required] [Required]
public int DurationSeconds { get; set; } 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] [Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
@@ -43,18 +34,10 @@ namespace Musify.Domain.Entities
public TrackAudio Audio { get; set; } = new(); public TrackAudio Audio { get; set; } = new();
[ForeignKey(nameof(OwnerUserId)) ]
public User? Owner { get; set; }
public ICollection<UserHasTrack> UserTracks { get; set; } = new List<UserHasTrack>(); public ICollection<UserHasTrack> UserTracks { get; set; } = new List<UserHasTrack>();
public ICollection<PlayListHasTrack> PlayListTracks { get; set; } = new List<PlayListHasTrack>(); public ICollection<PlayListHasTrack> PlayListTracks { get; set; } = new List<PlayListHasTrack>();
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>(); public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
public ICollection<TrackArtist> TrackArtists { get; set; } = new List<TrackArtist>();
[NotMapped]
public bool IsExternal => Source != TrackSource.Local;
} }
} }
@@ -17,7 +17,7 @@ namespace Musify.Domain.Entities
public int Position { get; set; } = 0; public int Position { get; set; } = 0;
[ForeignKey(nameof(TrackId)) ] [ForeignKey(nameof(TrackId)) ]
public Track Track { get; set; } public ExternalTrack Track { get; set; }
[ForeignKey(nameof(ArtistId)) ] [ForeignKey(nameof(ArtistId)) ]
public Artist Artist { get; set; } public Artist Artist { get; set; }
@@ -2,12 +2,12 @@ namespace Musify.Domain.ValueObjects
{ {
public class PlayListPictures 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; }
} }
} }
@@ -2,6 +2,7 @@ using MassTransit;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs; 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"); 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( var log = new UpdatePlayListPictureLog(
playList.Id, playList.Id,
playList.Pictures.OriginalName, playList.Pictures.OriginalName,
@@ -82,10 +90,13 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures
return compensateContext.Compensated(); return compensateContext.Compensated();
} }
playList.Pictures.OriginalName = compensateContext.Log.PreviousOriginalPictureKey; playList.Pictures = new PlayListPictures
playList.Pictures.SmallName = compensateContext.Log.PreviousSmallPictureKey; {
playList.Pictures.MediumName = compensateContext.Log.PreviousMediumPictureKey; OriginalName = compensateContext.Log.PreviousOriginalPictureKey,
playList.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey; SmallName = compensateContext.Log.PreviousSmallPictureKey,
MediumName = compensateContext.Log.PreviousMediumPictureKey,
LargeName = compensateContext.Log.PreviousLargePictureKey
};
database.PlayLists.Update(playList); database.PlayLists.Update(playList);
await database.SaveChangesAsync(compensateContext.CancellationToken); await database.SaveChangesAsync(compensateContext.CancellationToken);
@@ -2,8 +2,8 @@ namespace Musify.Infrastructure.MassTransit.Logs
{ {
public record UpdatePlayListPictureLog( public record UpdatePlayListPictureLog(
Guid PlayListId, Guid PlayListId,
string PreviousOriginalPictureKey, string? PreviousOriginalPictureKey,
string PreviousSmallPictureKey, string? PreviousSmallPictureKey,
string PreviousMediumPictureKey, string? PreviousMediumPictureKey,
string PreviousLargePictureKey); string? PreviousLargePictureKey);
} }
@@ -37,49 +37,49 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName), EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName),
new MarkPlayListAsRemovingArguments(playListId)); new MarkPlayListAsRemovingArguments(playListId));
if (playList is not null) if (playList?.Pictures is not null)
{ {
var bucket = storageConfiguration.Bucket; var bucket = storageConfiguration.Bucket;
var routes = playListConfiguration.Routes; var routes = playListConfiguration.Routes;
if (playList.Pictures.OriginalName != routes.PresetOriginalPicture) if (playList.Pictures.OriginalName is { } originalName)
{ {
routingSlipBuilder.AddActivity( routingSlipBuilder.AddActivity(
ActivityNames.RemovePlayListOriginalPicture, ActivityNames.RemovePlayListOriginalPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments( new RemoveFileFromBucketArguments(
bucket, 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( routingSlipBuilder.AddActivity(
ActivityNames.RemovePlayListSmallPicture, ActivityNames.RemovePlayListSmallPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments( new RemoveFileFromBucketArguments(
bucket, bucket,
routes.BuildSmallPicturePath(playList.Pictures.SmallName))); routes.BuildSmallPicturePath(smallName)));
} }
if (playList.Pictures.MediumName != routes.PresetMediumPicture) if (playList.Pictures.MediumName is { } mediumName)
{ {
routingSlipBuilder.AddActivity( routingSlipBuilder.AddActivity(
ActivityNames.RemovePlayListMediumPicture, ActivityNames.RemovePlayListMediumPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments( new RemoveFileFromBucketArguments(
bucket, bucket,
routes.BuildMediumPicturePath(playList.Pictures.MediumName))); routes.BuildMediumPicturePath(mediumName)));
} }
if (playList.Pictures.LargeName != routes.PresetLargePicture) if (playList.Pictures.LargeName is { } largeName)
{ {
routingSlipBuilder.AddActivity( routingSlipBuilder.AddActivity(
ActivityNames.RemovePlayListLargePicture, ActivityNames.RemovePlayListLargePicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments( new RemoveFileFromBucketArguments(
bucket, bucket,
routes.BuildLargePicturePath(playList.Pictures.LargeName))); routes.BuildLargePicturePath(largeName)));
} }
} }
@@ -30,21 +30,6 @@ namespace Musify.Infrastructure.Persistence
trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); trackProcessing.Property(state => state.CurrentState).HasMaxLength(64);
modelBuilder.Entity<Track>()
.HasIndex(track => new { track.Source, track.ExternalId })
.IsUnique();
modelBuilder.Entity<Track>()
.HasOne(track => track.Owner)
.WithMany()
.HasForeignKey(track => track.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Track>()
.ToTable(t => t.HasCheckConstraint(
"CK_Tracks_OwnerUserId_Source",
"(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"));
modelBuilder.Entity<Track>() modelBuilder.Entity<Track>()
.OwnsOne(track => track.Pictures, pictures => .OwnsOne(track => track.Pictures, pictures =>
{ {
@@ -68,6 +53,22 @@ namespace Musify.Infrastructure.Persistence
}); });
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired(); modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
modelBuilder.Entity<LocalTrack>()
.ToTable("LocalTracks");
modelBuilder.Entity<LocalTrack>()
.HasOne(track => track.Owner)
.WithMany()
.HasForeignKey(track => track.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ExternalTrack>()
.ToTable("ExternalTracks");
modelBuilder.Entity<ExternalTrack>()
.HasIndex(track => new { track.Source, track.ExternalId })
.IsUnique();
modelBuilder.Entity<TrackArtist>() modelBuilder.Entity<TrackArtist>()
.HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId }); .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId });
@@ -83,19 +84,10 @@ namespace Musify.Infrastructure.Persistence
.HasForeignKey(trackArtist => trackArtist.ArtistId) .HasForeignKey(trackArtist => trackArtist.ArtistId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Artist>()
.HasIndex(artist => artist.UserId)
.IsUnique();
modelBuilder.Entity<Artist>() modelBuilder.Entity<Artist>()
.HasIndex(artist => artist.ExternalId) .HasIndex(artist => artist.ExternalId)
.IsUnique(); .IsUnique();
modelBuilder.Entity<Artist>()
.HasIndex(artist => artist.NormalizedName)
.IsUnique()
.HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL");
modelBuilder.Entity<PlayList>() modelBuilder.Entity<PlayList>()
.OwnsOne(playList => playList.Pictures, pictures => .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.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
}); });
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(); modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(false);
var playListProcessing = modelBuilder.Entity<PlayListProcessingState>(); var playListProcessing = modelBuilder.Entity<PlayListProcessingState>();
playListProcessing.HasKey(state => state.CorrelationId); playListProcessing.HasKey(state => state.CorrelationId);
@@ -116,6 +108,10 @@ namespace Musify.Infrastructure.Persistence
public DbSet<Track> Tracks => Set<Track>(); public DbSet<Track> Tracks => Set<Track>();
public DbSet<LocalTrack> LocalTracks => Set<LocalTrack>();
public DbSet<ExternalTrack> ExternalTracks => Set<ExternalTrack>();
public DbSet<Artist> Artists => Set<Artist>(); public DbSet<Artist> Artists => Set<Artist>();
public DbSet<TrackArtist> TrackArtists => Set<TrackArtist>(); public DbSet<TrackArtist> TrackArtists => Set<TrackArtist>();
@@ -0,0 +1,903 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("Consumed")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ConsumerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<int>("ReceiveCount")
.HasColumnType("integer");
b.Property<DateTime>("Received")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("Delivered");
b.ToTable("InboxState");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.Property<long>("SequenceNumber")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ConversationId")
.HasColumnType("uuid");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("DestinationAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("EnqueueTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("FaultAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Headers")
.HasColumnType("text");
b.Property<Guid?>("InboxConsumerId")
.HasColumnType("uuid");
b.Property<Guid?>("InboxMessageId")
.HasColumnType("uuid");
b.Property<Guid?>("InitiatorId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<string>("MessageType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("OutboxId")
.HasColumnType("uuid");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<Guid?>("RequestId")
.HasColumnType("uuid");
b.Property<string>("ResponseAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("SentTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<Guid>("OutboxId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("OutboxId");
b.HasIndex("Created");
b.ToTable("OutboxState");
});
modelBuilder.Entity("Musify.Domain.Entities.Artist", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("ListenedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("UserId");
b.ToTable("ListeningHistory");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("PlayLists");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("PlayListId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PlayListId");
b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("DurationSeconds")
.HasColumnType("integer");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("NormalizedTitle")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Tracks");
b.UseTptMappingStrategy();
});
modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b =>
{
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<Guid>("ArtistId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("TrackId", "ArtistId");
b.HasIndex("ArtistId");
b.ToTable("TrackArtist");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<int>("State")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Upload");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("ExpectedSizeBytes")
.HasColumnType("bigint");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("ObjectName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Purpose")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UploadIntents");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("ProfilePictureUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SecondName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("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<Guid>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("Bucket")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CurrentState")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("PictureKey")
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("CorrelationId");
b.ToTable("PlayListProcessingState");
});
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b =>
{
b.Property<Guid>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("AudioKey")
.HasColumnType("text");
b.Property<bool>("AudioProcessed")
.HasColumnType("boolean");
b.Property<string>("Bucket")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CurrentState")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("PictureKey")
.HasColumnType("text");
b.Property<bool>("PictureProcessed")
.HasColumnType("boolean");
b.Property<DateTime>("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<string>("ExternalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("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<long>("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<Guid>("PlayListId")
.HasColumnType("uuid");
b1.Property<string>("LargeName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("LargePictureName");
b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName");
b1.Property<string>("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<Guid>("TrackId")
.HasColumnType("uuid");
b1.Property<bool>("DownloadRequested")
.HasColumnType("boolean")
.HasColumnName("DownloadRequested");
b1.Property<string>("FolderName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("AudioFolderName");
b1.Property<DateTime?>("LastRetryAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("LastRetryAt");
b1.Property<string>("OriginalName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalAudioName");
b1.Property<int>("RetryCount")
.HasColumnType("integer")
.HasColumnName("RetryCount");
b1.Property<int>("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<Guid>("TrackId")
.HasColumnType("uuid");
b1.Property<string>("LargeName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("LargePictureName");
b1.Property<string>("MediumName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName");
b1.Property<int>("ProcessingStatus")
.HasColumnType("integer")
.HasColumnName("PicturesProcessingStatus");
b1.Property<string>("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
}
}
}
@@ -0,0 +1,274 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class SplitTrackIntoLocalAndExternal : Migration
{
/// <inheritdoc />
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<Guid>(type: "uuid", nullable: false),
Source = table.Column<int>(type: "integer", nullable: false),
ExternalId = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
OwnerUserId = table.Column<long>(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<string>(
name: "ExternalId",
table: "Artists",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_TrackArtist_ExternalTracks_TrackId",
table: "TrackArtist");
migrationBuilder.AddColumn<string>(
name: "ExternalId",
table: "Tracks",
type: "character varying(16)",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "OwnerUserId",
table: "Tracks",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<int>(
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<string>(
name: "ExternalId",
table: "Artists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AddColumn<long>(
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);
}
}
}
@@ -0,0 +1,898 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("Consumed")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ConsumerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<int>("ReceiveCount")
.HasColumnType("integer");
b.Property<DateTime>("Received")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("Delivered");
b.ToTable("InboxState");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.Property<long>("SequenceNumber")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ConversationId")
.HasColumnType("uuid");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("DestinationAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("EnqueueTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("FaultAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Headers")
.HasColumnType("text");
b.Property<Guid?>("InboxConsumerId")
.HasColumnType("uuid");
b.Property<Guid?>("InboxMessageId")
.HasColumnType("uuid");
b.Property<Guid?>("InitiatorId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<string>("MessageType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("OutboxId")
.HasColumnType("uuid");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<Guid?>("RequestId")
.HasColumnType("uuid");
b.Property<string>("ResponseAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("SentTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<Guid>("OutboxId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("OutboxId");
b.HasIndex("Created");
b.ToTable("OutboxState");
});
modelBuilder.Entity("Musify.Domain.Entities.Artist", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("ListenedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("UserId");
b.ToTable("ListeningHistory");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("PlayLists");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("PlayListId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PlayListId");
b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("DurationSeconds")
.HasColumnType("integer");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
b.Property<string>("NormalizedTitle")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Tracks");
b.UseTptMappingStrategy();
});
modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b =>
{
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<Guid>("ArtistId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("TrackId", "ArtistId");
b.HasIndex("ArtistId");
b.ToTable("TrackArtist");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<int>("State")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Upload");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("ExpectedSizeBytes")
.HasColumnType("bigint");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("ObjectName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Purpose")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UploadIntents");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("ProfilePictureUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SecondName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("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<Guid>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("Bucket")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CurrentState")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("PictureKey")
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("CorrelationId");
b.ToTable("PlayListProcessingState");
});
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b =>
{
b.Property<Guid>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("AudioKey")
.HasColumnType("text");
b.Property<bool>("AudioProcessed")
.HasColumnType("boolean");
b.Property<string>("Bucket")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CurrentState")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("PictureKey")
.HasColumnType("text");
b.Property<bool>("PictureProcessed")
.HasColumnType("boolean");
b.Property<DateTime>("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<string>("ExternalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("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<long>("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<Guid>("PlayListId")
.HasColumnType("uuid");
b1.Property<string>("LargeName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("LargePictureName");
b1.Property<string>("MediumName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName");
b1.Property<string>("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<Guid>("TrackId")
.HasColumnType("uuid");
b1.Property<bool>("DownloadRequested")
.HasColumnType("boolean")
.HasColumnName("DownloadRequested");
b1.Property<string>("FolderName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("AudioFolderName");
b1.Property<DateTime?>("LastRetryAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("LastRetryAt");
b1.Property<string>("OriginalName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalAudioName");
b1.Property<int>("RetryCount")
.HasColumnType("integer")
.HasColumnName("RetryCount");
b1.Property<int>("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<Guid>("TrackId")
.HasColumnType("uuid");
b1.Property<string>("LargeName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("LargePictureName");
b1.Property<string>("MediumName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName");
b1.Property<int>("ProcessingStatus")
.HasColumnType("integer")
.HasColumnName("PicturesProcessingStatus");
b1.Property<string>("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
}
}
}
@@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MakePlayListPicturesNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "SmallPictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<string>(
name: "OriginalPictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<string>(
name: "MediumPictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<string>(
name: "LargePictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
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<string>(
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<string>(
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<string>(
name: "LargePictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true);
}
}
}
@@ -200,6 +200,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("ExternalId") b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)"); .HasColumnType("character varying(64)");
@@ -216,21 +217,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<long?>("UserId")
.HasColumnType("bigint");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ExternalId") b.HasIndex("ExternalId")
.IsUnique(); .IsUnique();
b.HasIndex("NormalizedName")
.IsUnique()
.HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("Artists"); b.ToTable("Artists");
}); });
@@ -337,10 +328,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<int>("DurationSeconds") b.Property<int>("DurationSeconds")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("ExternalId")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<int>("LifeCycleStatus") b.Property<int>("LifeCycleStatus")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -349,12 +336,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
b.Property<long?>("OwnerUserId")
.HasColumnType("bigint");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
@@ -365,15 +346,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("OwnerUserId"); b.ToTable("Tracks");
b.HasIndex("Source", "ExternalId") b.UseTptMappingStrategy();
.IsUnique();
b.ToTable("Tracks", t =>
{
t.HasCheckConstraint("CK_Tracks_OwnerUserId_Source", "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)");
});
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b =>
@@ -596,6 +571,36 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("TrackProcessingState"); b.ToTable("TrackProcessingState");
}); });
modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b =>
{
b.HasBaseType("Musify.Domain.Entities.Track");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("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<long>("OwnerUserId")
.HasColumnType("bigint");
b.HasIndex("OwnerUserId");
b.ToTable("LocalTracks", (string)null);
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{ {
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
@@ -608,15 +613,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasPrincipalKey("MessageId", "ConsumerId"); .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 => modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b =>
{ {
b.HasOne("Musify.Domain.Entities.Track", "Track") b.HasOne("Musify.Domain.Entities.Track", "Track")
@@ -650,25 +646,21 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnType("uuid"); .HasColumnType("uuid");
b1.Property<string>("LargeName") b1.Property<string>("LargeName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("LargePictureName"); .HasColumnName("LargePictureName");
b1.Property<string>("MediumName") b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName"); .HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName") b1.Property<string>("OriginalName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName"); .HasColumnName("OriginalPictureName");
b1.Property<string>("SmallName") b1.Property<string>("SmallName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("SmallPictureName"); .HasColumnName("SmallPictureName");
@@ -681,8 +673,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasForeignKey("PlayListId"); .HasForeignKey("PlayListId");
}); });
b.Navigation("Pictures") b.Navigation("Pictures");
.IsRequired();
b.Navigation("User"); b.Navigation("User");
}); });
@@ -708,11 +699,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Musify.Domain.Entities.Track", b => 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 => b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 =>
{ {
b1.Property<Guid>("TrackId") b1.Property<Guid>("TrackId")
@@ -792,8 +778,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("Audio") b.Navigation("Audio")
.IsRequired(); .IsRequired();
b.Navigation("Owner");
b.Navigation("Pictures") b.Navigation("Pictures")
.IsRequired(); .IsRequired();
}); });
@@ -806,7 +790,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Musify.Domain.Entities.Track", "Track") b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track")
.WithMany("TrackArtists") .WithMany("TrackArtists")
.HasForeignKey("TrackId") .HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -847,6 +831,32 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("User"); 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 => modelBuilder.Entity("Musify.Domain.Entities.Artist", b =>
{ {
b.Navigation("TrackArtists"); b.Navigation("TrackArtists");
@@ -863,8 +873,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("PlayListTracks"); b.Navigation("PlayListTracks");
b.Navigation("TrackArtists");
b.Navigation("UserTracks"); b.Navigation("UserTracks");
}); });
@@ -876,6 +884,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("UserTracks"); b.Navigation("UserTracks");
}); });
modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b =>
{
b.Navigation("TrackArtists");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
+1 -5
View File
@@ -41,11 +41,7 @@
"OriginalPicturesFolder": "OriginalPictures", "OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures", "SmallPicturesFolder": "SmallPictures",
"MediumPicturesFolder": "MediumPictures", "MediumPicturesFolder": "MediumPictures",
"LargePicturesFolder": "LargePictures", "LargePicturesFolder": "LargePictures"
"PresetOriginalPicture": "PresetOriginalPicture.webp",
"PresetSmallPicture": "PresetSmallPicture.webp",
"PresetMediumPicture": "PresetMediumPicture.webp",
"PresetLargePicture": "PresetLargePicture.webp"
}, },
"PicturesSizes": { "PicturesSizes": {
"SmallPictureWidth": 128, "SmallPictureWidth": 128,
-20
View File
@@ -9,8 +9,6 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@fontsource-variable/sora": "^5.2.8",
"@lucide/svelte": "^1.23.0", "@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"jose": "^6.2.3", "jose": "^6.2.3",
@@ -49,24 +47,6 @@
"url": "https://github.com/sponsors/ayuhito" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
-2
View File
@@ -26,8 +26,6 @@
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@fontsource-variable/sora": "^5.2.8",
"@lucide/svelte": "^1.23.0", "@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"jose": "^6.2.3", "jose": "^6.2.3",
+15
View File
@@ -0,0 +1,15 @@
export function appendUnique<T>(
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;
}
@@ -1,8 +0,0 @@
<footer class="border-t border-white/10 py-8">
<div
class="flex flex-col items-center justify-between gap-4 px-8 text-sm text-neutral-500 sm:flex-row"
>
<p>© {new Date().getFullYear()} Musify · Streaming de música.</p>
<p class="text-neutral-600">Tu música, siempre contigo.</p>
</div>
</footer>
-56
View File
@@ -1,56 +0,0 @@
<script lang="ts">
import type { SessionUser } from '$lib/types';
interface Props {
user: SessionUser | null;
}
let { user }: Props = $props();
</script>
<header class="sticky top-0 z-50 border-b border-white/10 bg-neutral-950/80 backdrop-blur">
<nav class="flex items-center justify-between gap-4 px-8 py-4">
<a href="/" class="text-lg tracking-tight text-neutral-100 transition hover:text-white">
Musify
</a>
<div class="flex items-center gap-6">
{#if user}
<a href="/explore" class="flex items-center gap-2 text-sm text-neutral-200">
{#if user.picture}
<img src={user.picture} alt="" class="h-7 w-7 rounded-full object-cover" />
{:else}
<span
class="grid h-7 w-7 place-items-center rounded-full bg-neutral-700 text-xs font-semibold uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
<span class="hidden sm:inline">{user.name}</span>
</a>
<form method="POST" action="/logout">
<button
class="rounded-full border border-white/10 px-4 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
>
Salir
</button>
</form>
{:else}
<a
href="/login"
data-sveltekit-reload
class="text-sm font-medium text-neutral-300 transition hover:text-white"
>
Iniciar sesión
</a>
<a
href="/login?mode=register"
data-sveltekit-reload
class="rounded-full bg-[var(--mf-accent)] px-4 py-2 text-sm font-semibold text-neutral-950 backdrop-blur-md transition hover:brightness-110"
>
Registrarse
</a>
{/if}
</div>
</nav>
</header>
+20 -20
View File
@@ -3,8 +3,8 @@
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import Home from '@lucide/svelte/icons/house'; import Home from '@lucide/svelte/icons/house';
import Search from '@lucide/svelte/icons/search'; import Search from '@lucide/svelte/icons/search';
import Library from '@lucide/svelte/icons/library-big'; import Folder from '@lucide/svelte/icons/folder';
import ListMusic from '@lucide/svelte/icons/list-music'; import Music from '@lucide/svelte/icons/music';
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import LogOut from '@lucide/svelte/icons/log-out'; import LogOut from '@lucide/svelte/icons/log-out';
@@ -30,11 +30,11 @@
const mainLinks = [ const mainLinks = [
{ href: '/', label: 'Inicio', icon: Home }, { href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Buscar', icon: Search }, { href: '/explore', label: 'Buscar', icon: Search },
{ href: '/playlists', label: 'Listas', icon: ListMusic } { href: '/playlists', label: 'Listas', icon: Music }
]; ];
const secondaryLinks = [ const secondaryLinks = [
{ href: '/library', label: 'Canciones subidas', icon: Library }, { href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload } { href: '/upload', label: 'Subir música', icon: Upload }
]; ];
@@ -65,32 +65,32 @@
<a <a
href={link.href} href={link.href}
data-active={active} data-active={active}
class="relative flex items-center gap-3 rounded-[10px] px-3 py-2.5 font-semibold transition-colors duration-100 {active class="relative flex items-center gap-4 rounded-control px-4 py-3 transition-colors duration-100 {active
? 'text-[var(--mf-text)]' ? 'text-fg'
: 'text-[var(--mf-text-2)] hover:bg-white/5 hover:text-[var(--mf-text)]'}" : 'text-fg-3 hover:bg-hover hover:text-fg'}"
> >
<link.icon <link.icon
class="h-[18px] w-[18px] flex-shrink-0" class="h-6 w-6 shrink-0"
strokeWidth={2} style={active ? 'color:var(--mf-accent-60)' : ''}
style={active ? 'color:var(--mf-accent)' : ''}
/> />
{link.label} {link.label}
</a> </a>
{/snippet} {/snippet}
<aside <aside
class="sticky top-0 flex h-screen w-[264px] flex-shrink-0 flex-col gap-7 border-r border-[var(--mf-border)] px-[18px] py-[26px]" class="sticky top-0 flex h-screen flex-shrink-0 flex-col gap-7 border-r border-line px-[18px] py-[26px]"
style="width:var(--mf-sidebar-w)"
> >
<a href="/" class="px-3 font-display text-xl font-extrabold tracking-tight">Musify</a> <a href="/" class="px-3 text-xl font-extrabold tracking-wide">Musify</a>
<div class="relative flex flex-col gap-7" bind:this={navBox}> <div class="relative flex flex-col gap-7" bind:this={navBox}>
<div <div
class="pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-[10px] bg-white/10 {animate class="pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-control bg-surface-hover {animate
? 'transition-[top,height] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]' ? 'transition-[top,height] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]'
: ''}" : ''}"
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}" style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
> >
<span class="absolute inset-y-0 left-0 w-[3px] rounded-r-full bg-[var(--mf-accent)]"></span> <span class="absolute inset-y-0 left-0 w-[3px] rounded-r-full" style="background:var(--mf-accent-60)"></span>
</div> </div>
<nav class="relative z-10 flex flex-col gap-1"> <nav class="relative z-10 flex flex-col gap-1">
@@ -99,7 +99,7 @@
{/each} {/each}
</nav> </nav>
<div class="mx-2 border-t border-[var(--mf-border)]"></div> <div class="mx-2 border-t border-line"></div>
<nav class="relative z-10 flex flex-col gap-1"> <nav class="relative z-10 flex flex-col gap-1">
{#each secondaryLinks as link (link.href)} {#each secondaryLinks as link (link.href)}
@@ -111,12 +111,12 @@
<div class="relative mt-auto" bind:this={menuRef}> <div class="relative mt-auto" bind:this={menuRef}>
{#if menuOpen} {#if menuOpen}
<div <div
class="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-xl border border-[var(--mf-border)] bg-[var(--mf-elevated)] py-1.5 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]" class="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-panel border border-line bg-elevated py-1.5 shadow-menu"
> >
<form method="POST" action="/logout" data-sveltekit-reload> <form method="POST" action="/logout" data-sveltekit-reload>
<button <button
type="submit" type="submit"
class="flex w-full items-center gap-3 px-3 py-2.5 text-sm font-semibold text-[var(--mf-text-2)] transition hover:bg-white/5" class="flex w-full items-center gap-3 px-3 py-2.5 text-base font-semibold text-fg-2 transition hover:bg-hover"
> >
<LogOut class="h-[18px] w-[18px]" strokeWidth={2} /> <LogOut class="h-[18px] w-[18px]" strokeWidth={2} />
Salir Salir
@@ -127,18 +127,18 @@
<button <button
type="button" type="button"
onclick={() => (menuOpen = !menuOpen)} onclick={() => (menuOpen = !menuOpen)}
class="flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition hover:bg-white/5" class="flex w-full items-center gap-2.5 rounded-control px-3 py-2.5 text-left transition hover:bg-hover"
> >
{#if user.picture} {#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" /> <img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else} {:else}
<span <span
class="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full bg-[var(--mf-surface-2)] text-xs font-semibold uppercase" class="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full bg-surface-2 text-sm font-semibold uppercase"
> >
{user.name.charAt(0)} {user.name.charAt(0)}
</span> </span>
{/if} {/if}
<span class="truncate text-[13px] font-semibold leading-[1.35]">{user.name}</span> <span class="truncate text-base font-semibold leading-[1.35]">{user.name}</span>
</button> </button>
</div> </div>
</aside> </aside>
@@ -0,0 +1,129 @@
<script lang="ts" module>
import type { ApiTrackLike } from '$lib/player/player.svelte';
import type { YouTubeSong } from '$lib/types';
export type ContextMenuTarget =
| { kind: 'youtube'; song: YouTubeSong }
| { kind: 'local'; track: ApiTrackLike };
export type ContextMenuState = ContextMenuTarget & { x: number; y: number; openLeft: boolean };
const MENU_WIDTH = 208;
const SUBMENU_WIDTH = 224;
export function contextMenuStateFor(
event: MouseEvent,
target: ContextMenuTarget
): ContextMenuState {
event.preventDefault();
return {
...target,
x: Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8),
y: Math.min(event.clientY, window.innerHeight - 60),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import ListPlus from '@lucide/svelte/icons/list-plus';
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: ContextMenuState;
playlists: { id: string; name: string }[];
onClose: () => void;
onAddToQueue: () => void;
}
let { menu, playlists, onClose, onAddToQueue }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-30"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<div
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
<button
type="button"
onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
>
<ListPlus class="h-6 w-6 shrink-0" strokeWidth={2} />
<span class="text-base">Reproducir a continuación</span>
</button>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default text-base">Añadir a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 {menu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu"
>
<div class="max-h-80 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action={menu.kind === 'youtube' ? '?/addYouTubeToPlaylist' : '?/addTrack'}
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
{#if menu.kind === 'youtube'}
<input type="hidden" name="videoId" value={menu.song.videoId} />
<input type="hidden" name="title" value={menu.song.title} />
<input type="hidden" name="artist" value={menu.song.artist} />
<input type="hidden" name="durationSeconds" value={menu.song.durationSeconds} />
<input type="hidden" name="thumbnailUrl" value={menu.song.thumbnailUrl} />
{:else}
<input type="hidden" name="trackId" value={menu.track.id} />
{/if}
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
hue={hueFor(playlist.id)}
size="small"
class="h-8 w-8 flex-shrink-0 rounded-control"
/>
<span class="truncate text-base text-fg-2">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import PlayPauseOverlay from '$lib/components/ui/PlayPauseOverlay.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
interface Props {
id: string | number;
title: string;
artist?: string;
coverSrc?: string;
coverSize?: 'small' | 'medium' | 'large';
hue: number;
explicit?: boolean;
active: boolean;
playing: boolean;
index: number;
onClick: () => void;
onContextMenu?: (event: MouseEvent) => void;
badge?: Snippet;
}
let {
id,
title,
artist,
coverSrc,
coverSize = 'large',
hue,
explicit = false,
active,
playing,
index,
onClick,
onContextMenu,
badge
}: Props = $props();
</script>
<li
class="group animate-enter relative min-w-0"
style="animation-delay:{index * 40}ms"
oncontextmenu={onContextMenu}
>
<button
type="button"
onclick={onClick}
aria-label="{active && playing ? 'Pausar' : 'Reproducir'} {title}"
class="block w-full text-left"
>
<Cover
trackId={id}
src={coverSrc}
{hue}
size={coverSize}
alt={title}
class="aspect-square w-full rounded-art shadow-art"
>
<PlayPauseOverlay {active} {playing} />
</Cover>
<div class="mt-2.5 flex min-w-0 items-center gap-1.5">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg" title={title}>{title}</span>
</div>
{#if badge || artist}
<div class="mt-0.5 flex min-w-0 items-center gap-1.5">
{@render badge?.()}
{#if artist}
<span class="truncate text-sm text-fg-3" title={artist}>{artist}</span>
{/if}
</div>
{/if}
</button>
</li>
@@ -6,7 +6,8 @@
</script> </script>
<div <div
class="animate-enter fixed bottom-0 left-[264px] right-0 z-50 h-[92px] border-t border-[var(--mf-border)] bg-[var(--mf-bg)]" class="animate-enter fixed bottom-0 right-0 z-50 h-[92px] border-t border-line bg-bg"
style="left:var(--mf-sidebar-w)"
> >
<div <div
class="grid h-full items-center gap-5 px-8" class="grid h-full items-center gap-5 px-8"
@@ -8,18 +8,18 @@
} }
</script> </script>
<div class="flex w-full max-w-[900px] items-center gap-[9px]"> <div class="flex w-full max-w-172 items-center gap-3">
<span class="w-8 text-right text-[11px] tabular-nums text-[var(--mf-text-4)]"> <span class="text-right text-xs tabular-nums text-muted">
{fmtTime(player.progress)} {fmtTime(player.progress)}
</span> </span>
<button <button
type="button" type="button"
onclick={seek} onclick={seek}
aria-label="Barra de progreso" aria-label="Barra de progreso"
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-[var(--mf-track)] transition-[height] hover:h-2" class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track transition-[height] hover:h-2"
> >
<div <div
class="absolute inset-y-0 left-0 rounded-full bg-[var(--mf-accent)] group-hover:brightness-110" class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
style="width:{player.progressPercent}%" style="width:{player.progressPercent}%"
></div> ></div>
<span <span
@@ -27,7 +27,7 @@
style="left:{player.progressPercent}%" style="left:{player.progressPercent}%"
></span> ></span>
</button> </button>
<span class="w-8 text-[11px] tabular-nums text-[var(--mf-text-4)]"> <span class="text-xs tabular-nums text-(--mf-text-4)">
{fmtTime(player.current.duration)} {fmtTime(player.current.duration)}
</span> </span>
</div> </div>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import Cover from '$lib/components/ui/Cover.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
</script> </script>
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
@@ -11,18 +12,21 @@
hue={player.current.hue} hue={player.current.hue}
size="small" size="small"
alt={player.current.title} 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} {:else}
<div <div
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)]"
style="background:{player.artGradient}" style="background:{player.artGradient}"
></div> ></div>
{/if} {/if}
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-[13.5px] font-semibold text-[var(--mf-text)]"> <div class="flex min-w-0 items-center gap-1.5">
{player.current.title} {#if player.current.explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg">{player.current.title}</span>
</div> </div>
<div class="truncate text-xs text-[var(--mf-text-3)]">{player.current.artist || '—'}</div> <div class="truncate text-sm text-muted">{player.current.artist || '—'}</div>
</div> </div>
</div> </div>
@@ -7,7 +7,7 @@
type="button" type="button"
onclick={() => player.previous()} onclick={() => player.previous()}
aria-label="Anterior" 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"
> >
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor"> <svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<rect x="0" y="0" width="2.5" height="14" rx="1" /> <rect x="0" y="0" width="2.5" height="14" rx="1" />
@@ -19,7 +19,7 @@
type="button" type="button"
onclick={() => player.toggle()} onclick={() => player.toggle()}
aria-label={player.playing ? 'Pausar' : 'Reproducir'} 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} {#if player.playing}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"> <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
@@ -37,7 +37,7 @@
type="button" type="button"
onclick={() => player.next()} onclick={() => player.next()}
aria-label="Siguiente" 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"
> >
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor"> <svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<path d="M1 0 11 7 1 14z" /> <path d="M1 0 11 7 1 14z" />
@@ -17,7 +17,7 @@
oninput={handleVolumeChange} oninput={handleVolumeChange}
aria-label="Volumen" aria-label="Volumen"
class="mf-volume" 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}%)"
/> />
</div> </div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
tone?: 'neutral' | 'danger';
class?: string;
children: Snippet;
}
let { tone = 'neutral', class: klass = '', children }: Props = $props();
const tones = {
neutral: 'border-line bg-surface text-fg-2',
danger: 'border-danger/40 bg-danger/10 text-danger-fg'
};
</script>
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}">
{@render children()}
</p>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
tone?: 'neutral' | 'accent' | 'danger';
class?: string;
children: Snippet;
}
let { tone = 'neutral', class: klass = '', children }: Props = $props();
const tones = {
neutral: 'bg-surface-2 text-fg',
accent: 'bg-accent/20 text-accent',
danger: 'bg-danger/10 text-danger-fg'
};
</script>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-sm leading-none font-semibold {tones[tone]} {klass}"
>
{@render children()}
</span>
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
variant?: 'primary' | 'secondary' | 'subtle' | 'danger';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit';
href?: string;
reload?: boolean;
disabled?: boolean;
loading?: boolean;
onclick?: (event: MouseEvent) => void;
class?: string;
children: Snippet;
}
let {
variant = 'primary',
size = 'md',
type = 'button',
href,
reload = false,
disabled = false,
loading = false,
onclick,
class: klass = '',
children
}: Props = $props();
const sizes = {
sm: 'px-4 py-2 text-base',
md: 'px-5 py-2.5 text-base',
lg: 'px-8 py-3.5 text-base'
};
const variants = {
primary: 'bg-accent-soft font-semibold text-on-accent active:scale-[0.97]',
secondary: 'border border-line text-fg hover:bg-hover',
subtle: 'bg-surface-2 font-semibold text-fg hover:bg-surface-hover',
danger: 'bg-danger font-semibold text-white hover:brightness-110'
};
const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-control transition disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
);
</script>
{#if href}
<a {href} data-sveltekit-reload={reload ? '' : undefined} class={classes}>
{@render children()}
</a>
{:else}
<button {type} disabled={disabled || loading} {onclick} class={classes}>
{#if loading}
<LoaderCircle class="h-4 w-4 animate-spin" />
{/if}
{@render children()}
</button>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Check from '@lucide/svelte/icons/check';
interface Props {
checked?: boolean;
name?: string;
class?: string;
children: Snippet;
}
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
</script>
<label class="flex items-center gap-3 text-base text-fg-2 {klass}">
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<input
type="checkbox"
{name}
bind:checked
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
/>
<Check
class="pointer-events-none absolute h-3.5 w-3.5 text-on-accent opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
<span class="cursor-pointer">{@render children()}</span>
</label>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LucideIcon } from '@lucide/svelte';
interface Props {
icon?: LucideIcon;
title?: string;
description?: string;
class?: string;
actions?: Snippet;
}
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
</script>
<div class="flex flex-col items-center px-6 py-14 text-center {klass}">
{#if Icon}
<span class="grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-on-accent">
<Icon class="h-6 w-6" />
</span>
{/if}
{#if title}
<h3 class="mt-5 text-2xl font-bold tracking-tight text-fg">{title}</h3>
{/if}
{#if description}
<p class="mt-2 text-fg-2">{description}</p>
{/if}
{#if actions}
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
{/if}
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: klass = '' }: Props = $props();
</script>
<span
class="grid h-4 w-4 shrink-0 place-items-center rounded-sm bg-fg-3 text-[11px] leading-4 font-bold text-bg {klass}"
title="Contenido explícito"
aria-label="Contenido explícito"
>E</span>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
label: string;
for?: string;
optional?: boolean;
class?: string;
hint?: Snippet;
children: Snippet;
}
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props();
</script>
<div class="flex min-w-0 flex-col {klass}">
<label for={forId} class="mb-2 block text-base font-medium text-fg-2">
{label}
{#if optional}<span class="text-muted">(Opcional)</span>{/if}
</label>
{@render children()}
{#if hint}
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted">
{@render hint()}
</div>
{/if}
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
label: string;
type?: 'button' | 'submit';
size?: 'sm' | 'md';
revealOnHover?: boolean;
onclick?: (event: MouseEvent) => void;
class?: string;
children: Snippet;
}
let {
label,
type = 'button',
size = 'md',
revealOnHover = false,
onclick,
class: klass = '',
children
}: Props = $props();
const sizes = { sm: 'h-7 w-7', md: 'h-8 w-8' };
</script>
<button
{type}
{onclick}
aria-label={label}
title={label}
class="grid place-items-center rounded-control text-muted transition hover:bg-hover hover:text-fg {sizes[
size
]} {revealOnHover ? 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100' : ''} {klass}"
>
{@render children()}
</button>
@@ -0,0 +1,47 @@
<script lang="ts">
import ImageIcon from '@lucide/svelte/icons/image';
interface Props {
name: string;
fallbackUrl?: string;
class?: string;
}
let {
name,
fallbackUrl,
class: klass = 'h-52 w-52 rounded-control border border-line bg-surface hover:border-accent/50'
}: Props = $props();
let preview = $state('');
let fallbackFailed = $state(false);
function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) preview = URL.createObjectURL(file);
}
</script>
<label class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}">
<input
type="file"
{name}
accept="image/*"
onchange={onInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
/>
{#if preview !== ''}
<img src={preview} alt="Portada" class="h-full w-full object-cover" />
{:else if fallbackUrl && !fallbackFailed}
<img
src={fallbackUrl}
alt="Portada actual"
loading="lazy"
onerror={() => (fallbackFailed = true)}
class="h-full w-full object-cover"
/>
{:else}
<ImageIcon class="h-12 w-12 text-muted" />
{/if}
</label>
@@ -0,0 +1,38 @@
<script lang="ts">
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
onLoadMore: () => void;
hasMore: boolean;
loading: boolean;
rootMargin?: string;
}
let { onLoadMore, hasMore, loading, rootMargin = '600px' }: Props = $props();
let sentinel = $state<HTMLDivElement>();
let visible = $state(false);
$effect(() => {
const node = sentinel;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
visible = entries[0].isIntersecting;
},
{ rootMargin }
);
observer.observe(node);
return () => observer.disconnect();
});
$effect(() => {
if (visible && hasMore && !loading) onLoadMore();
});
</script>
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading}
<LoaderCircle class="h-5 w-5 animate-spin text-muted" />
{/if}
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { LucideIcon } from '@lucide/svelte';
interface Props {
value?: string;
id?: string;
name?: string;
type?: 'text' | 'search';
placeholder?: string;
maxlength?: number;
required?: boolean;
autocomplete?: 'on' | 'off';
icon?: LucideIcon;
oninput?: (event: Event) => void;
class?: string;
}
let {
value = $bindable(''),
id,
name,
type = 'text',
placeholder,
maxlength,
required = false,
autocomplete = 'off',
icon: Icon,
oninput,
class: klass = ''
}: Props = $props();
</script>
<div class="relative w-full">
{#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
<Icon class="h-4 w-4" />
</span>
{/if}
<input
{id}
{name}
{type}
{placeholder}
{maxlength}
{required}
{autocomplete}
{oninput}
bind:value
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
? 'pr-4 pl-11'
: 'px-4'} {klass}"
/>
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
as?: 'ul' | 'div';
min?: string;
class?: string;
children: Snippet;
}
let { as = 'div', min = '180px', class: klass = '', children }: Props = $props();
</script>
<svelte:element
this={as}
class="grid gap-4 {klass}"
style="grid-template-columns:repeat(auto-fill,minmax({min},1fr))"
>
{@render children()}
</svelte:element>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x';
import IconButton from './IconButton.svelte';
interface Props {
open: boolean;
onClose: () => void;
title?: string;
maxWidth?: string;
panelClass?: string;
children: Snippet;
}
let {
open,
onClose,
title,
maxWidth = 'max-w-2xl',
panelClass = 'border border-line bg-elevated',
children
}: Props = $props();
let panel: HTMLDivElement | undefined = $state();
$effect(() => {
if (!open) return;
const trigger = document.activeElement as HTMLElement | null;
panel?.focus();
return () => trigger?.focus();
});
function onBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) onClose();
}
function focusableElements(): HTMLElement[] {
if (!panel) return [];
return Array.from(
panel.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
}
function onWindowKeydown(event: KeyboardEvent) {
if (!open) return;
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusableElements();
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
{#if open}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="presentation"
onclick={onBackdropClick}
>
<div
bind:this={panel}
class="animate-pop w-full {maxWidth} rounded-panel p-6 shadow-2xl outline-none {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
{#if title}
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold tracking-tight text-fg">{title}</h2>
<IconButton label="Cerrar" onclick={onClose}>
<X class="h-4 w-4" />
</IconButton>
</div>
{/if}
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<section class="page-x pt-11 pb-10 {klass}">
{@render children()}
</section>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string;
class?: string;
actions?: Snippet;
}
let { title, subtitle, class: klass = '', actions }: Props = $props();
</script>
<div class="flex items-end justify-between gap-4 {klass}">
<div class="min-w-0">
<h1 class="text-3xl font-bold tracking-tight text-fg sm:text-4xl">{title}</h1>
{#if subtitle}
<p class="mt-2 text-fg-2">{subtitle}</p>
{/if}
</div>
{#if actions}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
interface Props {
active: boolean;
playing: boolean;
}
let { active, playing }: Props = $props();
</script>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
class:!opacity-100={active}
class:!translate-y-0={active}
>
{#if active && playing}
<Pause class="h-5 w-5" fill="currentColor" />
{:else}
<Play class="h-5 w-5" fill="currentColor" />
{/if}
</span>
@@ -1,23 +1,17 @@
<script lang="ts"> <script lang="ts">
import Cover from './Cover.svelte'; import Cover from './Cover.svelte';
import { gradientForHue, HUES } from '$lib/theme/color'; import { gradientForHue, hueFor } from '$lib/theme/color';
interface Props { interface Props {
playlistId?: string; playlistId?: string;
trackIds: (string | number)[]; trackIds: (string | number)[];
hue: number; hue: number;
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large';
version?: string;
class?: string; class?: string;
} }
let { playlistId, trackIds, hue, size = 'medium', class: klass = '' }: Props = $props(); let { playlistId, trackIds, hue, size = 'medium', version, class: klass = '' }: Props = $props();
function hueFor(id: string | 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];
}
let coverFailed = $state(false); let coverFailed = $state(false);
@@ -28,7 +22,7 @@
{#if playlistId && !coverFailed} {#if playlistId && !coverFailed}
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}"> <div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
<img <img
src="/api/playlists/{playlistId}/cover?size={size}" src="/api/playlists/{playlistId}/cover?size={size}{version ? `&v=${encodeURIComponent(version)}` : ''}"
alt="" alt=""
loading="lazy" loading="lazy"
onerror={() => (coverFailed = true)} onerror={() => (coverFailed = true)}
@@ -0,0 +1,33 @@
<script lang="ts">
import PlaylistArt from './PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
id: string;
name: string;
description?: string;
trackIds: (string | number)[];
updatedAt: string;
index?: number;
}
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
</script>
<a
href="/playlists/{id}"
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
>
<PlaylistArt
playlistId={id}
{trackIds}
hue={hueFor(id)}
version={updatedAt}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
<div class="mt-2.5 truncate text-fg">{name}</div>
{#if description}
<div class="mt-0.5 truncate text-sm text-fg-3">{description}</div>
{/if}
</a>
@@ -0,0 +1,95 @@
<script lang="ts">
import { enhance } from '$app/forms';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
import Input from './Input.svelte';
import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
interface Props {
action: string;
initialName?: string;
initialDescription?: string;
namePlaceholder?: string;
coverFallbackUrl?: string;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
onCancel: () => void;
onSuccess?: () => void;
}
let {
action,
initialName = '',
initialDescription = '',
namePlaceholder = '',
coverFallbackUrl,
formMessage,
submitLabel,
submittingLabel,
onCancel,
onSuccess
}: Props = $props();
let name = $state(initialName);
let description = $state(initialDescription);
let submitting = $state(false);
</script>
<form
method="POST"
{action}
enctype="multipart/form-data"
use:enhance={() => {
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"
>
<div class="flex items-stretch gap-8">
<Field label="Portada" optional>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} />
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Nombre" for="playlist-name">
<Input
id="playlist-name"
name="name"
maxlength={100}
required
bind:value={name}
placeholder={namePlaceholder}
/>
</Field>
<Field label="Descripción" for="playlist-description" optional class="min-h-0 flex-1">
<Textarea
id="playlist-description"
name="description"
maxlength={300}
bind:value={description}
placeholder="¿De qué va esta lista?"
class="flex-1"
/>
</Field>
</div>
</div>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || name.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</form>
@@ -0,0 +1,10 @@
<script lang="ts">
interface Props {
title: string;
class?: string;
}
let { title, class: klass = '' }: Props = $props();
</script>
<h2 class="mb-6 text-2xl font-bold text-fg {klass}">{title}</h2>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
padding?: 'sm' | 'md' | 'lg';
class?: string;
children: Snippet;
}
let { padding = 'md', class: klass = '', children }: Props = $props();
const paddings = { sm: 'p-4', md: 'p-6', lg: 'p-12' };
</script>
<div class="rounded-panel border border-line bg-surface transition {paddings[padding]} {klass}">
{@render children()}
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
value?: string;
id?: string;
name?: string;
placeholder?: string;
maxlength?: number;
class?: string;
}
let {
value = $bindable(''),
id,
name,
placeholder,
maxlength,
class: klass = ''
}: Props = $props();
</script>
<textarea
{id}
{name}
{placeholder}
{maxlength}
bind:value
class="w-full min-h-0 resize-none rounded-control border border-line bg-surface px-4 py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {klass}"
></textarea>
@@ -0,0 +1,27 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import NowPlaying from './NowPlaying.svelte';
interface Props {
index: number;
active: boolean;
playing: boolean;
onToggle: () => void;
}
let { index, active, playing, onToggle }: Props = $props();
</script>
<button
type="button"
onclick={onToggle}
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-muted"
>
{#if active}
<NowPlaying paused={!playing} />
{:else}
<span class="group-hover:hidden">{index + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
{/if}
</button>
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<span class="truncate text-center text-sm tabular-nums text-muted {klass}">
{@render children()}
</span>
@@ -0,0 +1,24 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Snippet } from 'svelte';
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
interface Props {
class?: string;
oncontextmenu?: (event: MouseEvent) => void;
children: Snippet;
}
let { class: klass = '', oncontextmenu, children }: Props = $props();
const table = getContext<TrackTableContext>(trackTableKey);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid items-center gap-8 rounded-control px-3 py-2.5 transition hover:bg-surface {klass}"
style="grid-template-columns:{table.columns}"
{oncontextmenu}
>
{@render children()}
</div>
@@ -0,0 +1,39 @@
<script lang="ts" module>
export const trackTableKey = Symbol('track-table');
export interface TrackTableContext {
readonly columns: string;
}
</script>
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
columns: string;
class?: string;
headers: Snippet;
children: Snippet;
}
let { columns, class: klass = '', headers, children }: Props = $props();
setContext<TrackTableContext>(trackTableKey, {
get columns() {
return columns;
}
});
</script>
<div class={klass}>
<div
class="grid items-center gap-8 border-b border-line px-3 pb-2 text-sm font-medium tracking-wide text-muted uppercase"
style="grid-template-columns:{columns}"
>
{@render headers()}
</div>
<div class="mt-1 flex flex-col">
{@render children()}
</div>
</div>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Cover from './Cover.svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
interface Props {
trackId: string | number;
title: string;
artist?: string;
coverSrc?: string;
coverSize?: string;
hue: number;
explicit?: boolean;
titleClass?: string;
onClick: () => void;
overlay?: Snippet;
badge?: Snippet;
}
let {
trackId,
title,
artist,
coverSrc,
coverSize = 'h-[42px] w-[42px]',
hue,
explicit = false,
titleClass = '',
onClick,
overlay,
badge
}: Props = $props();
</script>
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
<Cover
trackId={trackId}
src={coverSrc}
{hue}
size="small"
alt={title}
class="{coverSize} flex-shrink-0 rounded-control"
>
{@render overlay?.()}
</Cover>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="min-w-0 truncate text-fg {titleClass}">{title}</span>
{@render badge?.()}
</div>
{#if artist}
<div class="truncate text-left text-sm text-fg-3">{artist}</div>
{/if}
</div>
</button>
+3
View File
@@ -0,0 +1,3 @@
export const EXPLORE_PAGE_SIZE = 27;
export const HOME_LATEST_PAGE_SIZE = 12;
export const YOUTUBE_FILLER_LIMIT = 27;
+15 -11
View File
@@ -1,5 +1,5 @@
import { browser } from '$app/environment'; 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'; import { extractAccent, type Accent } from '$lib/theme/palette';
export type TrackSourceKind = 'local' | 'youtube'; export type TrackSourceKind = 'local' | 'youtube';
@@ -12,6 +12,7 @@ export interface PlayerTrack {
hue: number; hue: number;
source: TrackSourceKind; source: TrackSourceKind;
coverUrl?: string; coverUrl?: string;
explicit?: boolean;
} }
export interface Playlist { export interface Playlist {
@@ -26,6 +27,7 @@ export interface QueueItem {
artist?: string; artist?: string;
source?: TrackSourceKind; source?: TrackSourceKind;
coverUrl?: string; coverUrl?: string;
explicit?: boolean;
} }
export interface ApiTrackLike { export interface ApiTrackLike {
@@ -35,6 +37,7 @@ export interface ApiTrackLike {
source?: 'Local' | 'YouTube'; source?: 'Local' | 'YouTube';
externalId?: string; externalId?: string;
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed'; audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
isExplicit?: boolean;
} }
export function isYouTubeTrack(track: ApiTrackLike): boolean { export function isYouTubeTrack(track: ApiTrackLike): boolean {
@@ -65,7 +68,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
? isPendingYouTubeTrack(track) && track.externalId ? isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId) ? youTubeThumbnailUrl(track.externalId)
: `/api/tracks/${track.id}/cover?size=small` : `/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' }; 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 { function toTrack(item: QueueItem): PlayerTrack {
return { return {
id: item.id, id: item.id,
@@ -88,6 +85,7 @@ function toTrack(item: QueueItem): PlayerTrack {
artist: item.artist ?? '', artist: item.artist ?? '',
duration: 0, duration: 0,
hue: hueFor(item.id), hue: hueFor(item.id),
explicit: item.explicit,
source: item.source ?? 'local', source: item.source ?? 'local',
coverUrl: item.coverUrl coverUrl: item.coverUrl
}; };
@@ -99,7 +97,7 @@ class PlayerState {
playing = $state(false); playing = $state(false);
progress = $state(0); progress = $state(0);
volume = $state( volume = $state(
browser ? Number(localStorage.getItem("player.volume") ?? 100) : 100 browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100
); );
loading = $state(false); loading = $state(false);
error = $state(''); error = $state('');
@@ -333,6 +331,12 @@ class PlayerState {
this.#loadCurrent(); 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) { addToQueue(item: QueueItem) {
if (this.currentId === null) { if (this.currentId === null) {
this.playQueue([item], 0); this.playQueue([item], 0);
@@ -404,7 +408,7 @@ class PlayerState {
const audio = this.#audioEl(); const audio = this.#audioEl();
if (audio) { if (audio) {
audio.volume = this.volume / 100; audio.volume = this.volume / 100;
localStorage.setItem("player.volume", String(this.volume)); localStorage.setItem('player.volume', String(this.volume));
} }
} }
} }
+46
View File
@@ -1,4 +1,5 @@
import createClient from 'openapi-fetch'; import createClient from 'openapi-fetch';
import { error, fail, type ActionFailure } from '@sveltejs/kit';
import type { paths } from '$lib/api/schema'; import type { paths } from '$lib/api/schema';
import { apiConfig } from '$lib/server/config'; import { apiConfig } from '$lib/server/config';
@@ -14,3 +15,48 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined 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<T>(
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<typeof createApiClient>,
playlistIds: string[]
): Promise<Record<string, string[]>> {
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);
}
+26
View File
@@ -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<Response> {
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'
}
});
}
@@ -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 };
}
+63
View File
@@ -4,6 +4,33 @@ import { env } from '$env/dynamic/private';
const CONNECT_HOST = env.S3_UPLOAD_CONNECT_HOST || '127.0.0.1'; const CONNECT_HOST = env.S3_UPLOAD_CONNECT_HOST || '127.0.0.1';
export const IMAGE_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp'
};
export const AUDIO_TYPES: Record<string, string> = {
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<string, string> = IMAGE_TYPES): string {
if (file.type) return file.type;
return table[extOf(file.name)] ?? 'application/octet-stream';
}
export async function putPresigned( export async function putPresigned(
presignedUrl: string, presignedUrl: string,
body: Buffer, body: Buffer,
@@ -49,3 +76,39 @@ export async function putPresigned(
request.end(); 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<UploadImageResult> {
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 };
}
+54
View File
@@ -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<T>(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<typeof createApiClient>,
accessToken: string | null | undefined,
limit: number,
seed?: string
): Promise<YoutubeFiller> {
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 ?? ''
};
}
+18 -1
View File
@@ -1,7 +1,18 @@
export const HUES = [145, 330, 40, 152, 300, 200, 10, 120, 280]; 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 { 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 { export function accentSoft(hue: number, pct: number): string {
@@ -18,3 +29,9 @@ export function fmtTime(seconds: number): string {
const rest = total % 60; const rest = total % 60;
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`; 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));
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { ACCENT_LIGHTNESS } from './color';
export interface Accent { export interface Accent {
accent: string; accent: string;
@@ -107,7 +108,7 @@ export async function extractAccent(url: string): Promise<Accent | null> {
const h2 = ((hue + 25) % 360).toFixed(1); const h2 = ((hue + 25) % 360).toFixed(1);
return { return {
accent: `oklch(72% ${c} ${h})`, accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))` gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
}; };
} }
+9 -9
View File
@@ -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 { :root {
--mf-bg: #08080a; --mf-bg: rgb(9, 9, 9);
--mf-elevated: #0b0b0d; --mf-elevated: #0b0b0d;
--mf-text: #f5f4f2; --mf-text: #c7c6c5;
--mf-text-2: rgba(245, 244, 242, 0.7); --mf-text-2: #a5a4a3;
--mf-text-3: rgba(245, 244, 242, 0.5); --mf-text-3: #8c8b8a;
--mf-text-4: rgba(245, 244, 242, 0.4); --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-2: rgba(255, 255, 255, 0.06);
--mf-surface-hover: rgba(255, 255, 255, 0.09); --mf-surface-hover: rgba(255, 255, 255, 0.09);
@@ -17,6 +15,8 @@
--mf-border-strong: rgba(255, 255, 255, 0.12); --mf-border-strong: rgba(255, 255, 255, 0.12);
--mf-track: 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-radius: 12px;
--mf-sidebar-w: 264px;
} }
+10
View File
@@ -4,3 +4,13 @@ export interface SessionUser {
email: string; email: string;
picture: string; picture: string;
} }
export interface YouTubeSong {
videoId: string;
title: string;
artist: string;
album: string;
durationSeconds: number;
thumbnailUrl: string;
isExplicit: boolean;
}
@@ -1,8 +1,8 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { error, fail } from '@sveltejs/kit'; import { createApiClient, unwrapOrError } from '$lib/server/api';
import { createApiClient } from '$lib/server/api'; import { fetchYoutubeFiller } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
const PAGE_SIZE = 27; import { EXPLORE_PAGE_SIZE as PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
export const load: PageServerLoad = async ({ url, locals, fetch }) => { export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const query = url.searchParams.get('q')?.trim() ?? ''; const query = url.searchParams.get('q')?.trim() ?? '';
@@ -31,75 +31,22 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
playlistsPromise playlistsPromise
]); ]);
if (tracksRes.error || !tracksRes.data) { const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
error(502, 'No se pudieron cargar las canciones.');
}
return { return {
query, query,
tracks: tracksRes.data, tracks,
ytResults: searchRes?.data ?? null, ytResults: searchRes?.data ?? null,
ytError: Boolean(searchRes?.error), ytError: Boolean(searchRes?.error),
youtubeFiller: query
? Promise.resolve(null)
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
playlists: playlistsRes?.data?.items ?? [], playlists: playlistsRes?.data?.items ?? [],
needsAuth: !locals.user needsAuth: !locals.user
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
addTrack: async ({ request, locals, fetch }) => { addTrack: addTrackAction,
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); addYouTubeToPlaylist: addYouTubeToPlaylistAction
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 };
}
}; };
+181 -378
View File
@@ -1,36 +1,25 @@
<script lang="ts"> <script lang="ts">
import Search from '@lucide/svelte/icons/search'; import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ListPlus from '@lucide/svelte/icons/list-plus';
import ListMusic from '@lucide/svelte/icons/list-music';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { enhance } from '$app/forms'; import { player, queueIdForTrack, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
import { import Page from '$lib/components/ui/Page.svelte';
player, import PageHeader from '$lib/components/ui/PageHeader.svelte';
queueIdForTrack, import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
toQueueItems, import Alert from '$lib/components/ui/Alert.svelte';
type ApiTrackLike, import Input from '$lib/components/ui/Input.svelte';
type QueueItem import EmptyState from '$lib/components/ui/EmptyState.svelte';
} from '$lib/player/player.svelte'; import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import { EXPLORE_PAGE_SIZE } from '$lib/config';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import { hueFor } from '$lib/theme/color';
import { HUES } from '$lib/theme/color'; import type { YouTubeSong } from '$lib/types';
import TrackTile from '$lib/components/TrackTile.svelte';
interface YouTubeSong { import TrackContextMenu, {
videoId: string; contextMenuStateFor,
title: string; type ContextMenuTarget,
artist: string; type ContextMenuState
album: string; } from '$lib/components/TrackContextMenu.svelte';
durationSeconds: number; import { appendUnique } from '$lib/collections.js';
thumbnailUrl: string;
isExplicit: boolean;
}
type ContextMenuTarget =
| { kind: 'youtube'; song: YouTubeSong }
| { kind: 'local'; track: ApiTrackLike };
let { data, form } = $props(); let { data, form } = $props();
@@ -39,96 +28,78 @@
let ytItems = $state<YouTubeSong[]>([]); let ytItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state(''); let ytContinuation = $state('');
let ytLoadingMore = $state(false); let ytSearchTerm = $state('');
let contextMenu = $state<(ContextMenuTarget & { x: number; y: number; openLeft: boolean }) | null>( let contextMenu = $state<ContextMenuState | null>(null);
null
); type LocalTrack = (typeof data.tracks.items)[number];
let localItems = $state<LocalTrack[]>([]);
let localPage = $state(1);
let localHasNext = $state(false);
let loadingMore = $state(false);
$effect(() => { $effect(() => {
ytItems = (data.ytResults?.items ?? []) as YouTubeSong[];
ytContinuation = data.ytResults?.continuationToken ?? '';
contextMenu = null; contextMenu = null;
}); localItems = data.tracks.items;
localPage = Number(data.tracks.pageNumber);
localHasNext = Boolean(data.tracks.hasNextPage);
const tracks = $derived(data.tracks); if (data.query) {
const currentPage = $derived(Number(data.tracks.pageNumber)); ytItems = appendUnique(
const total = $derived(Number(data.tracks.totalItemCount)); [],
(data.ytResults?.items ?? []) as YouTubeSong[],
const localVisible = $derived(!data.query || tracks.items.length > 0); item => item.videoId
const youtubeVisible = $derived(
!!data.query && (data.needsAuth || data.ytError || ytItems.length > 0)
); );
const localHeadingVisible = $derived(!!data.query && localVisible); ytContinuation = data.ytResults?.continuationToken ?? '';
const youtubeHeadingVisible = $derived(!!data.query && youtubeVisible); ytSearchTerm = data.query;
const nothingFound = $derived(
!!data.query && !localVisible && !youtubeVisible && !data.needsAuth && !data.ytError
);
function buildHref(page = 1, query = data.query) {
const params = new URLSearchParams();
if (query) params.set('q', query);
if (page > 1) params.set('page', String(page));
const qs = params.toString();
return qs ? `/explore?${qs}` : '/explore';
}
function onSearchInput(event: Event) {
const value = (event.currentTarget as HTMLInputElement).value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
goto(buildHref(1, value.trim()), {
keepFocus: true,
replaceState: true,
noScroll: true
});
}, 350);
}
function hueFor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
function togglePlayLocal(index: number) {
const track = data.tracks.items[index];
if (player.current?.id === queueIdForTrack(track)) player.toggle();
else player.playQueue(toQueueItems(data.tracks.items), index);
}
function togglePlayYouTube(index: number) {
const song = ytItems[index];
if (player.current?.id === song.videoId) {
player.toggle();
return; return;
} }
player.playQueue( ytItems = [];
ytItems.map((s) => ({ ytContinuation = '';
id: s.videoId, ytSearchTerm = '';
title: s.title, data.youtubeFiller?.then((filler) => {
artist: s.artist, if (!filler) return;
source: 'youtube' as const, ytItems = appendUnique(
coverUrl: s.thumbnailUrl [],
})), filler.items,
index item => item.videoId
); );
ytContinuation = filler.continuationToken;
ytSearchTerm = filler.query;
});
});
const items = $derived<ContextMenuTarget[]>([
...localItems.map((track) => ({ kind: 'local' as const, track })),
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
]);
const hasMore = $derived(localHasNext || ytContinuation !== '');
const nothingFound = $derived(
!!data.query && items.length === 0 && !data.needsAuth && !data.ytError
);
function itemId(item: ContextMenuTarget) {
return item.kind === 'youtube' ? item.song.videoId : item.track.id;
} }
function openContextMenu(event: MouseEvent, target: ContextMenuTarget) { function itemTitle(item: ContextMenuTarget) {
if (data.playlists.length === 0) return; return item.kind === 'youtube' ? item.song.title : item.track.title;
event.preventDefault();
const menuWidth = 208;
const submenuWidth = 224;
contextMenu = {
...target,
x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
y: Math.min(event.clientY, window.innerHeight - 60),
openLeft: event.clientX + menuWidth + submenuWidth + 16 > window.innerWidth
};
} }
function closeContextMenu() { function itemArtist(item: ContextMenuTarget) {
contextMenu = null; return item.kind === 'youtube' ? item.song.artist : item.track.artist;
}
function itemExplicit(item: ContextMenuTarget) {
return item.kind === 'youtube' ? item.song.isExplicit : (item.track.isExplicit ?? false);
}
function itemActive(item: ContextMenuTarget) {
return (
player.current?.id === (item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track))
);
} }
function queueItemFor(target: ContextMenuTarget): QueueItem { function queueItemFor(target: ContextMenuTarget): QueueItem {
@@ -138,31 +109,88 @@
title: target.song.title, title: target.song.title,
artist: target.song.artist, artist: target.song.artist,
source: 'youtube', source: 'youtube',
coverUrl: target.song.thumbnailUrl coverUrl: target.song.thumbnailUrl,
explicit: target.song.isExplicit
} }
: toQueueItems([target.track])[0]; : toQueueItems([target.track])[0];
} }
function addToQueue(target: ContextMenuTarget | null) { function buildHref(query: string) {
if (!target) return; return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
player.addToQueue(queueItemFor(target)); }
function onSearchInput(event: Event) {
const value = (event.currentTarget as HTMLInputElement).value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
goto(buildHref(value.trim()), {
keepFocus: true,
replaceState: true,
noScroll: true
});
}, 350);
}
function togglePlay(index: number) {
player.playOrToggle(items.map(queueItemFor), index);
}
function openContextMenu(event: MouseEvent, target: ContextMenuTarget) {
contextMenu = contextMenuStateFor(event, target);
}
function closeContextMenu() {
contextMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemFor(contextMenu));
closeContextMenu(); closeContextMenu();
} }
async function loadMoreLocal() {
const params = new URLSearchParams({
pageNumber: String(localPage + 1),
pageSize: String(EXPLORE_PAGE_SIZE)
});
if (data.query) params.set('name', data.query);
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks;
localItems = appendUnique(
localItems,
next.items,
track => track.id
);
localPage = Number(next.pageNumber);
localHasNext = Boolean(next.hasNextPage);
}
async function loadMoreYouTube() { async function loadMoreYouTube() {
if (!ytContinuation || ytLoadingMore) return; const params = new URLSearchParams({ query: ytSearchTerm, continuation: ytContinuation });
ytLoadingMore = true;
try {
const params = new URLSearchParams({ query: data.query, continuation: ytContinuation });
const res = await fetch(`/api/youtube/search?${params}`); const res = await fetch(`/api/youtube/search?${params}`);
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string }; const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
ytItems = [...ytItems, ...next.items]; ytItems = appendUnique(
ytItems,
next.items,
item => item.videoId
);
ytContinuation = next.continuationToken; ytContinuation = next.continuationToken;
}
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
try {
if (localHasNext) await loadMoreLocal();
else if (ytContinuation && ytSearchTerm) await loadMoreYouTube();
} catch { } catch {
ytContinuation = ''; if (localHasNext) localHasNext = false;
else ytContinuation = '';
} finally { } finally {
ytLoadingMore = false; loadingMore = false;
} }
} }
</script> </script>
@@ -172,287 +200,62 @@
<meta name="description" content="Explora tu música y busca canciones en YouTube Music." /> <meta name="description" content="Explora tu música y busca canciones en YouTube Music." />
</svelte:head> </svelte:head>
<section class="px-8 pt-12 pb-8"> <Page>
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Explorar</h1> <PageHeader title="Explorar" subtitle="Busca a la vez en tu música y en YouTube Music." />
<p class="mt-2 text-neutral-400">Busca a la vez en tu música y en YouTube Music.</p>
<div class="relative mt-8"> <div class="mt-8">
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-neutral-500"> <Input
<Search class="h-4 w-4" />
</span>
<input
type="search" type="search"
icon={Search}
bind:value={searchValue} bind:value={searchValue}
oninput={onSearchInput} oninput={onSearchInput}
placeholder="Buscar en tu música y en YouTube Music..." placeholder="Buscar en tu música y en YouTube Music..."
autocomplete="off"
class="w-full rounded-lg border border-white/15 bg-neutral-950 py-3 pl-11 pr-4 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none"
/> />
</div> </div>
{#if form?.message} {#if form?.message}
<p class="mt-4 rounded-xl border border-white/10 bg-neutral-900/40 px-4 py-3 text-sm text-neutral-300"> <Alert class="mt-4">{form.message}</Alert>
{form.message}
</p>
{/if} {/if}
{#if nothingFound} {#if nothingFound}
<div class="mt-10 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md"> <EmptyState icon={Music} title="Sin resultados" description="No hay resultados para «{data.query}»." />
<Music class="mx-auto h-10 w-10 text-neutral-500" />
<p class="mt-4 text-neutral-300">No hay resultados para «{data.query}».</p>
</div>
{/if} {/if}
{#if localVisible} {#if items.length > 0}
{#if localHeadingVisible} <MediaGrid as="ul" class="mt-6">
<h2 class="mt-10 font-display text-lg font-bold tracking-tight">Musify</h2> {#each items as item, i (itemId(item))}
{/if} <TrackTile
id={itemId(item)}
<p class="{localHeadingVisible ? 'mt-1' : 'mt-6'} text-sm text-neutral-500"> title={itemTitle(item)}
{#if data.query} artist={itemArtist(item)}
{total} coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
{total === 1 ? 'resultado' : 'resultados'} para «{data.query}» hue={hueFor(itemId(item))}
{:else} explicit={itemExplicit(item)}
{total} active={itemActive(item)}
{total === 1 ? 'canción' : 'canciones'} playing={player.isPlaying}
{/if} index={i}
</p> onClick={() => togglePlay(i)}
onContextMenu={(e) => openContextMenu(e, item)}
{#if tracks.items.length === 0} />
<div class="mt-6 rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center backdrop-blur-md">
<Music class="mx-auto h-8 w-8 text-neutral-500" />
<p class="mt-3 text-sm text-neutral-300">Todavía no hay canciones. ¡Sé el primero en subir una!</p>
</div>
{:else}
<ul class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
{#each tracks.items as track, i (track.id)}
<li
class="group animate-enter relative"
style="animation-delay:{i * 40}ms"
oncontextmenu={(e) => openContextMenu(e, { kind: 'local', track })}
>
<button type="button" onclick={() => togglePlayLocal(i)} class="block w-full text-left">
<Cover
trackId={track.id}
hue={hueFor(track.id)}
size="large"
alt={track.title}
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
class:!opacity-100={player.current?.id === queueIdForTrack(track)}
class:!translate-y-0={player.current?.id === queueIdForTrack(track)}
>
{#if player.current?.id === queueIdForTrack(track) && player.isPlaying}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</span>
</Cover>
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]" title={track.title}>
{track.title}
</div>
{#if track.artist}
<div class="mt-0.5 truncate text-xs text-neutral-500">{track.artist}</div>
{/if}
</button>
</li>
{/each} {/each}
</ul> </MediaGrid>
{#if tracks.hasPreviousPage || tracks.hasNextPage} <InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
<nav class="mt-8 flex items-center justify-center gap-4">
{#if tracks.hasPreviousPage}
<a
href={buildHref(currentPage - 1)}
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
>
Anterior
</a>
{/if}
<span class="text-sm text-neutral-500">Página {currentPage}</span>
{#if tracks.hasNextPage}
<a
href={buildHref(currentPage + 1)}
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
>
Siguiente
</a>
{/if}
</nav>
{/if}
{/if}
{/if}
{#if youtubeVisible}
{#if youtubeHeadingVisible}
<h2 class="mt-12 font-display text-lg font-bold tracking-tight">YouTube Music</h2>
{/if} {/if}
{#if data.needsAuth} {#if data.needsAuth}
<div class="mt-6 rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center backdrop-blur-md"> <EmptyState
<Music class="mx-auto h-8 w-8 text-neutral-500" /> icon={Music}
<p class="mt-3 text-sm text-neutral-300">Inicia sesión para buscar en YouTube Music.</p> description="Inicia sesión para ver también resultados de YouTube Music."
</div> />
{:else if data.ytError} {:else if data.ytError}
<div class="mt-6 rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center backdrop-blur-md"> <EmptyState
<Music class="mx-auto h-8 w-8 text-neutral-500" /> icon={Music}
<p class="mt-3 text-sm text-neutral-300">YouTube Music no está disponible ahora mismo. Inténtalo de nuevo.</p> description="YouTube Music no está disponible ahora mismo. Inténtalo de nuevo."
</div> />
{:else}
<ul class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
{#each ytItems as song, i (song.videoId)}
<li
class="group animate-enter relative"
style="animation-delay:{i * 40}ms"
oncontextmenu={(e) => openContextMenu(e, { kind: 'youtube', song })}
>
<button type="button" onclick={() => togglePlayYouTube(i)} class="block w-full text-left">
<Cover
trackId={song.videoId}
src={song.thumbnailUrl}
hue={hueFor(song.videoId)}
alt={song.title}
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
class:!opacity-100={player.current?.id === song.videoId}
class:!translate-y-0={player.current?.id === song.videoId}
>
{#if player.current?.id === song.videoId && player.isPlaying}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M8 5v14l11-7z" />
</svg>
{/if} {/if}
</span> </Page>
</Cover>
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]" title={song.title}>
{song.title}
</div>
<div class="mt-0.5 flex min-w-0 items-center gap-1.5">
{#if song.isExplicit}
<span
class="grid h-3.5 w-3.5 shrink-0 place-items-center rounded-[3px] bg-white/20 text-[9px] font-bold leading-none text-neutral-200"
title="Contenido explícito"
>
E
</span>
{/if}
<span class="truncate text-xs text-neutral-500" title={song.artist}>{song.artist}</span>
</div>
</button>
</li>
{/each}
</ul>
{#if ytContinuation}
<div class="mt-8 flex justify-center">
<button
type="button"
onclick={loadMoreYouTube}
disabled={ytLoadingMore}
class="flex items-center gap-2 rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white/5 disabled:opacity-50"
>
{#if ytLoadingMore}
<LoaderCircle class="h-4 w-4 animate-spin" />
{/if}
Cargar más
</button>
</div>
{/if}
{/if}
{/if}
</section>
{#if contextMenu} {#if contextMenu}
<div <TrackContextMenu menu={contextMenu} playlists={data.playlists} onClose={closeContextMenu} onAddToQueue={addToQueue} />
class="fixed inset-0 z-30"
role="button"
tabindex="0"
onclick={closeContextMenu}
onkeydown={(e) => {
if (e.key === 'Escape') closeContextMenu();
}}
oncontextmenu={(e) => {
e.preventDefault();
closeContextMenu();
}}
></div>
<div
class="fixed z-40 w-60 rounded-xl bg-[var(--mf-elevated)] p-1.5 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
style="left:{contextMenu.x}px; top:{contextMenu.y}px;"
>
<button
type="button"
onclick={() => addToQueue(contextMenu)}
class="flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-sm text-[var(--mf-text-2)] transition hover:bg-white/5"
>
<ListPlus class="h-6 w-6 shrink-0" strokeWidth={2} />
<span class="text-base">Añadir a la cola</span>
</button>
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-lg px-2 py-2.5 text-sm text-[var(--mf-text-2)] transition group-hover/addmenu:bg-white/5"
>
<span class="flex-1 text-base cursor-default">Añadir a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-[var(--mf-text-3)]" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 {contextMenu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div class="w-64 overflow-hidden rounded-xl bg-[var(--mf-elevated)] p-2 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]">
<div class="max-h-80 overflow-y-auto">
{#each data.playlists as playlist (playlist.id)}
<form
method="POST"
action={contextMenu.kind === 'youtube' ? '?/addYouTubeToPlaylist' : '?/addTrack'}
use:enhance={() =>
({ update }) => {
closeContextMenu();
return update({ reset: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
{#if contextMenu.kind === 'youtube'}
<input type="hidden" name="videoId" value={contextMenu.song.videoId} />
<input type="hidden" name="title" value={contextMenu.song.title} />
<input type="hidden" name="artist" value={contextMenu.song.artist} />
<input type="hidden" name="durationSeconds" value={contextMenu.song.durationSeconds} />
<input type="hidden" name="thumbnailUrl" value={contextMenu.song.thumbnailUrl} />
{:else}
<input type="hidden" name="trackId" value={contextMenu.track.id} />
{/if}
<button
type="submit"
class="flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base text-[var(--mf-text)] transition hover:bg-white/5"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
hue={hueFor(playlist.id)}
size="small"
class="h-8 w-8 flex-shrink-0 rounded-lg"
/>
<span class="truncate text-base text-[var(--mf-text-2)]">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
</div>
{/if} {/if}
@@ -1,33 +1,31 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { error, fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api'; import { createApiClient, requireAccessTokenAction, unwrapOrError, unwrapOrFail } from '$lib/server/api';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ locals, fetch }) => { export const load: PageServerLoad = async ({ locals, fetch }) => {
if (!locals.user) error(401, 'Inicia sesión.');
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const { data, error: err } = await api.GET('/tracks/users/{userId}', { const result = await api.GET('/tracks/users/{userId}', {
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } } 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: unwrapOrError(result, 'No se pudo cargar tu biblioteca.') };
return { tracks: data };
}; };
export const actions: Actions = { export const actions: Actions = {
deleteTrack: async ({ request, locals, fetch }) => { 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') ?? ''); const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' }); if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken: locals.accessToken }); const api = createApiClient({ fetch, accessToken });
const { error: err } = await api.DELETE('/tracks/{trackId}', { const result = await api.DELETE('/tracks/{trackId}', {
params: { path: { 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 }; return { deleted: true };
} }
}; };
+58 -94
View File
@@ -1,30 +1,28 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import Play from '@lucide/svelte/icons/play';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte'; import Music from '@lucide/svelte/icons/music';
import { HUES, fmtTime } from '$lib/theme/color'; import { player, toQueueItems } from '$lib/player/player.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import { hueFor, fmtTime, fmtDate } from '$lib/theme/color';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte'; import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
let { data } = $props(); let { data } = $props();
const items = $derived(data.tracks.items); const items = $derived(data.tracks.items);
const total = $derived(Number(data.tracks.totalItemCount)); const total = $derived(Number(data.tracks.totalItemCount));
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
function hueFor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
function togglePlay(index: number) { function togglePlay(index: number) {
const track = items[index]; player.playOrToggle(toQueueItems(items), index);
if (player.current.id === queueIdForTrack(track)) player.toggle();
else player.playQueue(toQueueItems(items), index);
} }
</script> </script>
@@ -33,103 +31,69 @@
<meta name="description" content="Todas tus canciones subidas a Musify." /> <meta name="description" content="Todas tus canciones subidas a Musify." />
</svelte:head> </svelte:head>
<section class="px-8 pt-11 pb-8"> <Page>
<div class="flex items-end justify-between gap-4"> <PageHeader
<div> title="Tus canciones subidas"
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Tus canciones subidas</h1> subtitle="{total} {total === 1 ? 'canción' : 'canciones'} subida{total === 1 ? '' : 's'}."
<p class="mt-2 text-[var(--mf-text-2)]">
{total}
{total === 1 ? 'canción' : 'canciones'} subida{total === 1 ? '' : 's'}.
</p>
</div>
<a
href="/upload"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-5 py-2.5 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_8px_24px_-8px] active:scale-[0.97]"
> >
{#snippet actions()}
{#if items.length > 0}
<Button href="/upload">
<Upload class="h-4 w-4" /> <Upload class="h-4 w-4" />
Subir música Subir canción
</a> </Button>
</div> {/if}
{/snippet}
</PageHeader>
{#if items.length > 0} {#if items.length > 0}
<div class="mt-8"> <TrackTable columns="32px 1fr 100px 100px 64px 36px" class="mt-8">
<div {#snippet headers()}
class="grid grid-cols-[32px_1fr_100px_100px_64px_36px] items-center gap-8 border-b border-white/10 px-3 pb-2 text-xs uppercase tracking-wide text-neutral-500"
>
<span class="text-center">#</span> <span class="text-center">#</span>
<span>Título</span> <span>Título</span>
<span class="text-center">Subida</span> <span class="text-center">Subida</span>
<span class="text-center">Escuchas</span> <span class="text-center">Escuchas</span>
<span class="text-center">Duración</span> <span class="text-center">Duración</span>
<span></span> <span></span>
</div> {/snippet}
<div class="mt-1 flex flex-col">
{#each items as track, i (track.id)} {#each items as track, i (track.id)}
<div <TrackRow>
class="group grid grid-cols-[32px_1fr_100px_100px_64px_36px] items-center gap-8 rounded-[10px] px-3 py-[9px] transition hover:bg-neutral-900/60" <TrackIndexCell
> index={i}
<button active={player.current.id === track.id}
type="button" playing={player.playing}
onclick={() => togglePlay(i)} onToggle={() => togglePlay(i)}
aria-label={player.current.id === track.id && player.playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-neutral-500"
>
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
{:else}
<span class="group-hover:hidden">{i + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-white group-hover:block" fill="currentColor" />
{/if}
</button>
<button
type="button"
onclick={() => togglePlay(i)}
class="flex min-w-0 items-center gap-3.5 text-left"
>
<Cover
trackId={track.id}
hue={hueFor(track.id)}
size="small"
alt={track.title}
class="h-[42px] w-[42px] flex-shrink-0 rounded-md"
/> />
<div class="min-w-0 truncate text-[14.5px] font-semibold text-neutral-100">{track.title}</div> <TrackTitleCell
</button> trackId={track.id}
<div class="truncate text-center text-[12.5px] text-neutral-500"> title={track.title}
{dateFormatter.format(new Date(track.createdAt))} hue={hueFor(track.id)}
</div> onClick={() => togglePlay(i)}
<span class="text-center text-[12.5px] tabular-nums text-neutral-500"> />
{Number(track.listensCount)} <TrackMeta>{fmtDate(track.createdAt)}</TrackMeta>
</span> <TrackMeta>{Number(track.listensCount)}</TrackMeta>
<span class="text-center text-[12.5px] tabular-nums text-neutral-500"> <TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
{fmtTime(Number(track.duration))}
</span>
<form method="POST" action="?/deleteTrack" use:enhance={() => async ({ update }) => update()}> <form method="POST" action="?/deleteTrack" use:enhance={() => async ({ update }) => update()}>
<input type="hidden" name="trackId" value={track.id} /> <input type="hidden" name="trackId" value={track.id} />
<button <IconButton type="submit" label="Borrar canción" size="sm" revealOnHover>
type="submit"
aria-label="Borrar canción"
class="grid h-7 w-7 place-items-center rounded-lg text-neutral-500 opacity-0 transition hover:bg-white/5 hover:text-white group-hover:opacity-100"
>
<X class="h-3.5 w-3.5" /> <X class="h-3.5 w-3.5" />
</button> </IconButton>
</form> </form>
</div> </TrackRow>
{/each} {/each}
</div> </TrackTable>
</div>
{:else} {:else}
<div <EmptyState
class="mt-7 max-w-[560px] rounded-xl border border-[var(--mf-border)] bg-[var(--mf-surface)] px-6 py-10 text-center" icon={Music}
> title="Todavía no tienes canciones"
<p class="text-sm text-[var(--mf-text-2)]">Todavía no tienes canciones</p> description="Sube la primera y aparecerá aquí lista para reproducir."
<a
href="/upload"
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-5 py-2.5 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_8px_24px_-8px] active:scale-[0.97]"
> >
{#snippet actions()}
<Button href="/upload">
<Upload class="h-4 w-4" /> <Upload class="h-4 w-4" />
Sube la primera Sube la primera
</a> </Button>
</div> {/snippet}
</EmptyState>
{/if} {/if}
</section> </Page>
@@ -1,52 +1,34 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { error, fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api'; import {
import { putPresigned } from '$lib/server/upload'; createApiClient,
playlistCoverTrackIds,
requireAccessTokenAction,
unwrapOrError
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
const MAX_NAME = 100; const MAX_NAME = 100;
const IMAGE_TYPES: Record<string, string> = {
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 }) => { export const load: PageServerLoad = async ({ locals, fetch }) => {
if (!locals.user) error(401, 'Inicia sesión.');
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const { data, error: err } = await api.GET('/playlists/users/{userId}', { const result = await api.GET('/playlists/users/{userId}', {
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } } params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: 50 } }
}); });
if (err || !data) error(502, 'No se pudieron cargar tus playlists.'); const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
const trackIds = await playlistCoverTrackIds(
const covers = await Promise.all( api,
data.items.map(async (playlist) => { data.items.map((p) => p.id)
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;
})
); );
return { playlists: data, trackIds: Object.fromEntries(covers) as Record<string, string[]> }; return { playlists: data, trackIds };
}; };
export const actions: Actions = { export const actions: Actions = {
create: async ({ request, locals, fetch }) => { 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 form = await request.formData();
const name = String(form.get('name') ?? '').trim(); 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 }); 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; let pictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) { if (cover instanceof File && cover.size > 0) {
const contentType = contentTypeOf(cover); const result = await uploadPresignedImage(
const { data: upload, error: uploadError } = await api.POST('/playlists/upload-picture', { (args) => api.POST('/playlists/upload-picture', { body: args }),
body: { cover
fileType: extOf(cover.name) || 'jpg', );
contentType, if ('failMessage' in result) {
expectedSizeBytes: cover.size return fail(502, { message: result.failMessage, name, description, detail: result.detail });
} }
}); pictureIntentId = result.intentId;
if (uploadError || !upload) {
return fail(502, { message: 'No se pudo reservar la subida de la portada.', name, description });
}
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;
} }
const { data, error: err } = await api.POST('/playlists', { const { data, error: err } = await api.POST('/playlists', {
+59 -200
View File
@@ -1,54 +1,44 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
import ImageIcon from '@lucide/svelte/icons/image'; import Page from '$lib/components/ui/Page.svelte';
import { HUES } from '$lib/theme/color'; import PageHeader from '$lib/components/ui/PageHeader.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
const playlistNames = [ const playlistNames = [
"Mi Playlist", 'Mi Playlist',
"Tus favoritos", 'Tus favoritos',
"Lo mejor de hoy", 'Lo mejor de hoy',
"Para relajarse", 'Para relajarse',
"Buena vibra", 'Buena vibra',
"Modo estudio", 'Modo estudio',
"Road Trip", 'Road Trip',
"Domingos", 'Domingos',
"Late Night", 'Late Night',
"Descubriendo música", 'Descubriendo música',
"Todo un poco", 'Todo un poco',
"En bucle", 'En bucle',
"Mi colección", 'Mi colección',
"Canciones para repetir", 'Canciones para repetir',
"Hits del momento" 'Hits del momento'
]; ];
const namePlaceholder = playlistNames[Math.floor(Math.random() * playlistNames.length)];
let { data, form } = $props(); let { data, form } = $props();
const items = $derived(data.playlists.items); const items = $derived(data.playlists.items);
const trackIds = $derived(data.trackIds); const trackIds = $derived(data.trackIds);
let creating = $state(false); let creating = $state(false);
let submitting = $state(false);
let name = $state('');
let description = $state('');
let coverPreview = $state('');
function hueFor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
function openCreate() { function openCreate() {
creating = true; creating = true;
} }
function onCoverInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) coverPreview = URL.createObjectURL(file);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -56,185 +46,54 @@
<meta name="description" content="Mis listas en Musify." /> <meta name="description" content="Mis listas en Musify." />
</svelte:head> </svelte:head>
<section class="px-8 py-12"> <Page>
<div class="flex items-end justify-between gap-4"> <PageHeader title="Mis listas" subtitle="Crea y organiza tus colecciones.">
<div> {#snippet actions()}
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Mis listas</h1>
<p class="mt-2 text-neutral-400">Crea y organiza tus colecciones.</p>
</div>
{#if items.length > 0} {#if items.length > 0}
<button <Button onclick={openCreate}>
type="button"
onclick={openCreate}
class="inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-5 py-2.5 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_8px_24px_-8px] active:scale-[0.97]"
>
<Plus class="h-4 w-4" strokeWidth={2.5} /> <Plus class="h-4 w-4" strokeWidth={2.5} />
Crear lista Crear lista
</button> </Button>
{/if} {/if}
</div> {/snippet}
</PageHeader>
{#if items.length > 0} {#if items.length > 0}
<div class="mt-9 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4"> <MediaGrid class="mt-9">
{#each items as playlist, i (playlist.id)} {#each items as playlist, i (playlist.id)}
<a <PlaylistCard
href="/playlists/{playlist.id}" id={playlist.id}
class="group animate-enter text-left" name={playlist.name}
style="animation-delay:{i * 45}ms" description={playlist.description}
>
<PlaylistArt
playlistId={playlist.id}
trackIds={trackIds[playlist.id] ?? []} trackIds={trackIds[playlist.id] ?? []}
hue={hueFor(playlist.id)} updatedAt={playlist.updatedAt}
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)] transition group-hover:shadow-[0_16px_36px_-10px_rgba(0,0,0,0.75)]" index={i}
/> />
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]">
{playlist.name}
</div>
{#if playlist.description}
<div class="mt-0.5 truncate text-xs text-[var(--mf-text-3)]">{playlist.description}</div>
{/if}
</a>
{/each} {/each}
</div> </MediaGrid>
{:else} {:else}
<div <EmptyState
class="mt-9 p-14 text-center" icon={ListMusic}
title="Todavía no tienes listas"
description="Crea la primera y añádele canciones de tu biblioteca."
> >
<span class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950"> {#snippet actions()}
<ListMusic class="h-7 w-7" /> <Button onclick={openCreate}>
</span> <Plus class="h-4 w-4" strokeWidth={2} />
<h3 class="mt-5 font-display text-2xl font-bold tracking-tight">Todavía no tienes listas</h3>
<p class="mt-2 text-neutral-400">Crea la primera y añádele canciones de tu biblioteca.</p>
<button
type="button"
onclick={openCreate}
class="mt-6 inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-5 py-2.5 text-sm font-semibold text-neutral-800 transition active:scale-[0.95]"
>
<Plus class="h-4 w-4" strokeWidth={2.5} />
Crear lista Crear lista
</button> </Button>
</div> {/snippet}
</EmptyState>
{/if} {/if}
</section> </Page>
{#if creating} <Modal open={creating} onClose={() => (creating = false)} title="Nueva lista">
<div <PlaylistForm
class="animate-fade fixed inset-0 z-100 grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="dialog"
tabindex="0"
onclick={(e) => {
if (e.target === e.currentTarget) creating = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape') creating = false;
}}
>
<div class="animate-pop w-full max-w-2xl rounded-lg bg-[var(--mf-bg)] p-6 shadow-2xl">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold tracking-tight">Nueva lista</h2>
<button
type="button"
onclick={() => (creating = false)}
aria-label="Cerrar"
class="grid h-8 w-8 place-items-center rounded-lg text-neutral-400 transition hover:bg-white/5 hover:text-white"
>
<X class="h-4 w-4" />
</button>
</div>
<form
method="POST"
action="?/create" action="?/create"
enctype="multipart/form-data" {namePlaceholder}
use:enhance={() => { formMessage={form?.message}
submitting = true; submitLabel="Crear"
return async ({ update }) => { submittingLabel="Creando…"
await update(); onCancel={() => (creating = false)}
submitting = false;
};
}}
class="mt-4 w-full max-w-4xl space-y-4"
>
<div class="flex items-stretch gap-8">
<div class="flex flex-col">
<span class="mb-3 block text-sm font-medium text-neutral-300">
Portada <span class="text-neutral-500">(Opcional)</span>
</span>
<label
class="relative grid h-52 w-52 cursor-pointer place-items-center overflow-hidden rounded-lg border border-transparent bg-[var(--mf-surface)] transition-colors hover:border-[var(--mf-accent)]/50 "
>
<input
type="file"
name="cover"
accept="image/*"
onchange={onCoverInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
/> />
{#if coverPreview !== ''} </Modal>
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else}
<ImageIcon class="h-12 w-12 text-neutral-500" />
{/if}
</label>
</div>
<div class="flex flex-1 flex-col gap-4">
<div>
<label for="name" class="mb-2 block text-sm font-medium text-neutral-300">Nombre</label>
<input
id="name"
name="name"
type="text"
maxlength={100}
required
autocomplete="off"
bind:value={name}
placeholder={playlistNames[Math.floor(Math.random() * playlistNames.length)]}
class="w-full rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none"
/>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<label for="description" class="mb-2 block text-sm font-medium text-neutral-300">
Descripción <span class="text-neutral-500">(Opcional)</span>
</label>
<textarea
id="description"
name="description"
maxlength={300}
bind:value={description}
placeholder="¿De qué va esta lista?"
class="w-full min-h-0 flex-1 resize-none rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none"
></textarea>
</div>
</div>
</div>
{#if form?.message}
<p class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{form.message}
</p>
{/if}
<div class="flex items-center justify-end gap-3">
<button
type="button"
onclick={() => (creating = false)}
class="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/65 transition hover:bg-white/2.5"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting || name.trim() === ''}
class="rounded-lg bg-[var(--mf-accent)] px-6 py-2.5 text-sm font-semibold text-neutral-800 transition disabled:opacity-40"
>
{submitting ? 'Creando…' : 'Crear'}
</button>
</div>
</form>
</div>
</div>
{/if}
@@ -1,80 +1,92 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { error, fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api'; import {
import { putPresigned } from '$lib/server/upload'; 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 MAX_NAME = 100;
const SUGGESTIONS_LIMIT = 20;
const IMAGE_TYPES: Record<string, string> = {
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 ({ params, locals, fetch }) => { 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 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/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/{playlistId}/tracks', { api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } } params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
}), }),
api.GET('/tracks/users/{userId}', { 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 tracks = tracksRes.data?.items ?? [];
const inPlaylist = new Set(tracks.map((t) => t.id)); const inPlaylist = new Set(tracks.map((t) => t.id));
const library = (libraryRes.data?.items ?? []).filter((t) => !inPlaylist.has(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 = { export const actions: Actions = {
addTrack: async ({ request, params, locals, fetch }) => { addTrack: addTrackAction,
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); addYouTubeToPlaylist: addYouTubeToPlaylistAction,
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 };
},
removeTrack: async ({ request, params, locals, fetch }) => { 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') ?? ''); const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' }); if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken: locals.accessToken }); const api = createApiClient({ fetch, accessToken });
const { error: err } = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', { const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId: params.id, 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 }; return { removed: true };
}, },
rename: async ({ request, params, locals, fetch }) => { 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 form = await request.formData();
const name = String(form.get('name') ?? '').trim(); const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').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).' }); 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; let newPictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) { if (cover instanceof File && cover.size > 0) {
const contentType = contentTypeOf(cover); const result = await uploadPresignedImage(
const { data: upload, error: uploadError } = await api.POST('/playlists/upload-picture', { (args) => api.POST('/playlists/upload-picture', { body: args }),
body: { cover
fileType: extOf(cover.name) || 'jpg', );
contentType, if ('failMessage' in result) {
expectedSizeBytes: cover.size return fail(502, { message: result.failMessage, detail: result.detail });
} }
}); newPictureIntentId = result.intentId;
if (uploadError || !upload) {
return fail(502, { message: 'No se pudo reservar la subida de la portada.' });
} }
try { const result = await api.PUT('/playlists/{playlistId}', {
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;
}
const { error: err } = await api.PUT('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } }, params: { path: { playlistId: params.id } },
body: { newName: name, newDescription: description, newPictureIntentId } 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 }; return { renamed: true };
}, },
delete: async ({ params, locals, fetch }) => { 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 api = createApiClient({ fetch, accessToken });
const { error: err } = await api.DELETE('/playlists/{playlistId}', { const result = await api.DELETE('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } } 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'); redirect(303, '/playlists');
} }
}; };
@@ -1,28 +1,13 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import Page from '$lib/components/ui/Page.svelte';
import Play from '@lucide/svelte/icons/play'; import Modal from '$lib/components/ui/Modal.svelte';
import Pause from '@lucide/svelte/icons/pause'; import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import SquarePause from '@lucide/svelte/icons/square-pause'; import Button from '$lib/components/ui/Button.svelte';
import Pencil from '@lucide/svelte/icons/pencil'; import PlaylistHeader from './components/PlaylistHeader.svelte';
import SquarePencil from '@lucide/svelte/icons/square-pen'; import PlaylistTrackTable from './components/PlaylistTrackTable.svelte';
import Trash from '@lucide/svelte/icons/trash'; import SuggestionsTable from './components/SuggestionsTable.svelte';
import Plus from '@lucide/svelte/icons/plus'; import type { YouTubeSong } from '$lib/types';
import X from '@lucide/svelte/icons/x';
import Clock from '@lucide/svelte/icons/clock';
import ImageIcon from '@lucide/svelte/icons/image';
import Headphones from '@lucide/svelte/icons/headphones';
import {
player,
isPendingYouTubeTrack,
queueIdForTrack,
toQueueItems,
youTubeThumbnailUrl
} from '$lib/player/player.svelte';
import { HUES, fmtTime } from '$lib/theme/color';
import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
let { data, form } = $props(); let { data, form } = $props();
@@ -32,28 +17,14 @@
let editing = $state(false); let editing = $state(false);
let confirmingDelete = $state(false); let confirmingDelete = $state(false);
let editCoverPreview = $state(''); let youtubeSuggestions = $state<YouTubeSong[]>([]);
let editCoverFailed = $state(false);
let savingEdit = $state(false);
function openEdit() { $effect(() => {
editCoverPreview = ''; youtubeSuggestions = [];
editCoverFailed = false; data.youtube.then((result) => {
editing = true; youtubeSuggestions = result.items;
} });
});
function onEditCoverInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) editCoverPreview = URL.createObjectURL(file);
}
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
function hueFor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id)); const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
@@ -62,18 +33,6 @@
if (isCurrentQueue) player.toggle(); if (isCurrentQueue) player.toggle();
else player.playQueue(toQueueItems(tracks), 0); else player.playQueue(toQueueItems(tracks), 0);
} }
function playFrom(index: number) {
const track = tracks[index];
if (player.current.id === queueIdForTrack(track)) player.toggle();
else player.playQueue(toQueueItems(tracks), index);
}
function playFromLibrary(index: number) {
const track = library[index];
if (player.current.id === queueIdForTrack(track)) player.toggle();
else player.playQueue(toQueueItems(library), index);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -81,376 +40,54 @@
<meta name="description" content="Playlist {playlist.name} en Musify." /> <meta name="description" content="Playlist {playlist.name} en Musify." />
</svelte:head> </svelte:head>
<section class="px-8 py-8"> <Page>
<a <PlaylistHeader
href="/playlists" id={playlist.id}
class="group inline-flex items-center font-medium text-neutral-400 transition-colors hover:text-[var(--mf-accent)]" name={playlist.name}
> description={playlist.description}
<span
class="grid h-8 w-8 place-items-center rounded-lg border-white/15 text-neutral-400 transition-colors duration-150 group-hover:text-[var(--mf-accent)]"
>
<ArrowLeft class="h-4 w-4" />
</span>
Volver a tus listas
</a>
<div class="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end">
<PlaylistArt
playlistId={playlist.id}
trackIds={tracks.map((t) => t.id)} trackIds={tracks.map((t) => t.id)}
hue={hueFor(playlist.id)} updatedAt={playlist.updatedAt}
size="large" playing={isCurrentQueue && player.playing}
class="h-44 w-44 flex-shrink-0 rounded-2xl shadow-[0_20px_45px_-15px_rgba(0,0,0,0.7)]" onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
/> />
<div class="min-w-0 flex-1">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-neutral-400">Lista</p>
<h1 class="mt-1.5 font-display text-5xl font-bold tracking-tight sm:text-7xl">{playlist.name}</h1>
{#if playlist.description}
<p class="mt-3 max-w-2xl text-neutral-300">{playlist.description}</p>
{/if}
<p class="mt-3 text-sm text-neutral-500">
{tracks.length}
{tracks.length === 1 ? 'canción' : 'canciones'}
</p>
</div>
</div>
<div class="mt-7 flex items-center gap-2.5">
<button
type="button"
onclick={playAll}
disabled={tracks.length === 0}
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--mf-accent)] px-4 py-2 text-neutral-900 transition hover:brightness-110 active:scale-95 disabled:opacity-40"
>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Reproducir
{/if}
</button>
<button
type="button"
onclick={openEdit}
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-4 py-2 text-white/65 transition hover:bg-white/2.5"
>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</button>
<button
type="button"
onclick={() => (confirmingDelete = true)}
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-4 py-2 text-white/65 transition hover:bg-white/2.5"
>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</button>
</div>
{#if tracks.length > 0} {#if tracks.length > 0}
<div class="mt-8"> <PlaylistTrackTable {tracks} />
<div
class="text-center grid grid-cols-[32px_1fr_140px_64px_36px] items-center gap-4 border-b border-white/10 px-3 pb-2 text-xs uppercase tracking-wide text-neutral-500"
>
<span>#</span>
<span class="text-left">Título</span>
<span>Añadida</span>
<span>Duración</span>
<span></span>
</div>
<div class="mt-1 flex flex-col">
{#each tracks as track, i (track.id)}
<div
class="text-center group grid grid-cols-[32px_1fr_140px_64px_36px] items-center gap-4 rounded-[10px] px-3 py-[9px] transition hover:bg-neutral-900/60"
>
<button
type="button"
onclick={() => playFrom(i)}
aria-label={player.current.id === queueIdForTrack(track) && player.playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-neutral-500"
>
{#if player.current.id === queueIdForTrack(track)}
<NowPlaying paused={!player.playing} />
{:else}
<span class="group-hover:hidden">{i + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-white group-hover:block" fill="currentColor" />
{/if}
</button>
<button type="button" onclick={() => playFrom(i)} class="flex min-w-0 items-center gap-3.5 text-left">
<Cover
trackId={track.id}
src={isPendingYouTubeTrack(track) ? youTubeThumbnailUrl(track.externalId) : undefined}
hue={hueFor(track.id)}
size="small"
alt={track.title}
class="h-[42px] w-[42px] flex-shrink-0 rounded-md"
/>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 truncate text-[14.5px] font-semibold text-neutral-100">{track.title}</span>
{#if track.source === 'YouTube' && track.audioStatus === 'Failed'}
<span class="shrink-0 rounded-full bg-red-500/10 px-2 py-0.5 text-[10px] font-semibold text-red-300">
Error
</span>
{:else if isPendingYouTubeTrack(track)}
<span
class="shrink-0 rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-semibold text-emerald-400"
>
Descargando
</span>
{/if}
</div>
{#if track.artist}
<div class="truncate text-left text-xs text-neutral-500">{track.artist}</div>
{/if}
</div>
</button>
<div class="truncate text-[12.5px] text-neutral-500">
{dateFormatter.format(new Date(track.createdAt))}
</div>
<span class="text-center text-[12.5px] tabular-nums text-neutral-500">
{fmtTime(Number(track.duration))}
</span>
<form method="POST" action="?/removeTrack" use:enhance={() => async ({ update }) => update()}>
<input type="hidden" name="trackId" value={track.id} />
<button
type="submit"
aria-label="Quitar de la playlist"
class="grid h-7 w-7 place-items-center rounded-lg text-neutral-500 opacity-0 transition hover:bg-white/5 hover:text-white group-hover:opacity-100"
>
<X class="h-3.5 w-3.5" />
</button>
</form>
</div>
{/each}
</div>
</div>
{/if} {/if}
{#if library.length > 0} {#if library.length > 0 || youtubeSuggestions.length > 0}
<div class="mt-12"> <SuggestionsTable {library} youtube={youtubeSuggestions} playlists={data.playlists} />
<h2 class="font-display text-lg font-bold tracking-tight">Añadir de tu biblioteca</h2>
<div
class="mt-4 grid grid-cols-[1fr_100px_64px_110px] items-center gap-12 border-b border-white/10 px-3 pb-2 text-xs uppercase tracking-wide text-neutral-500"
>
<span>Título</span>
<span class="text-center">Escuchas</span>
<span class="text-center">Duración</span>
<span></span>
</div>
<div class="mt-1 flex flex-col">
{#each library as track, i (track.id)}
<div
class="group grid grid-cols-[1fr_100px_64px_110px] items-center gap-12 rounded-[10px] px-3 py-[10px] transition hover:bg-neutral-900/60"
>
<button
type="button"
onclick={() => playFromLibrary(i)}
class="flex min-w-0 items-center gap-3.5 text-left"
>
<Cover
trackId={track.id}
hue={hueFor(track.id)}
size="small"
alt={track.title}
class="h-[40px] w-[40px] flex-shrink-0 rounded-md"
>
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
{/if} {/if}
</Cover> </Page>
<div class="min-w-0 flex-1 truncate text-sm font-medium text-neutral-200">{track.title}</div>
</button>
<div class="flex items-center justify-center gap-1.5 text-[12.5px] tabular-nums text-neutral-500">
<Headphones class="h-3.5 w-3.5" />
{Number(track.listensCount)}
</div>
<span class="text-center text-[12.5px] tabular-nums text-neutral-500">
{fmtTime(Number(track.duration))}
</span>
<form
method="POST"
action="?/addTrack"
use:enhance={() => async ({ update }) => update()}
class="flex justify-end"
>
<input type="hidden" name="trackId" value={track.id} />
<button
type="submit"
class="inline-flex items-center gap-1.5 rounded-lg border-2 border-white/20 px-2 py-2 text-xs text-white/80 transition hover:border-[var(--mf-accent)]/50 hover:text-[var(--mf-accent)]"
>
<Plus class="h-4 w-4" /> <Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
</button> <PlaylistForm
</form>
</div>
{/each}
</div>
</div>
{/if}
</section>
{#if editing}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="button"
tabindex="0"
onclick={(e) => {
if (e.target === e.currentTarget) editing = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape') editing = false;
}}
>
<div class="animate-pop w-full max-w-2xl rounded-2xl bg-[var(--mf-bg)] p-6 shadow-2xl">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold tracking-tight">Editar playlist</h2>
<button
type="button"
onclick={() => (editing = false)}
aria-label="Cerrar"
class="grid h-8 w-8 place-items-center rounded-lg text-neutral-400 transition hover:bg-white/5 hover:text-white"
>
<X class="h-4 w-4" />
</button>
</div>
<form
method="POST"
action="?/rename" action="?/rename"
enctype="multipart/form-data" initialName={playlist.name}
use:enhance={() => { initialDescription={playlist.description}
savingEdit = true; coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(playlist.updatedAt)}"
return async ({ update, result }) => { formMessage={form?.message}
await update({ reset: false }); submitLabel="Guardar"
savingEdit = false; submittingLabel="Guardando…"
if (result.type === 'success') editing = false; onCancel={() => (editing = false)}
}; onSuccess={() => (editing = false)}
}}
class="mt-4 w-full max-w-4xl space-y-4"
>
<div class="flex items-stretch gap-8">
<div class="flex flex-col">
<span class="mb-3 block text-sm font-medium text-neutral-300">
Portada <span class="text-neutral-500">(Opcional)</span>
</span>
<label
class="relative grid h-52 w-52 cursor-pointer place-items-center overflow-hidden rounded-lg border border-transparent bg-[var(--mf-surface)] transition-colors hover:border-[var(--mf-accent)]/50"
>
<input
type="file"
name="cover"
accept="image/*"
onchange={onEditCoverInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
/> />
{#if editCoverPreview !== ''} </Modal>
<img src={editCoverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else if !editCoverFailed}
<img
src="/api/playlists/{playlist.id}/cover?size=medium"
alt="Portada actual"
loading="lazy"
onerror={() => (editCoverFailed = true)}
class="h-full w-full object-cover"
/>
{:else}
<ImageIcon class="h-12 w-12 text-neutral-500" />
{/if}
</label>
</div>
<div class="flex flex-1 flex-col gap-4"> <Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<div> <h2 class="text-lg font-bold tracking-tight text-fg">
<label for="edit-name" class="mb-2 block text-sm font-medium text-neutral-300">Nombre</label> ¿Eliminar «{playlist.name}»?
<input </h2>
id="edit-name" <p class="mt-2 text-base text-fg-3">
name="name" Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
type="text" dispositivos donde la tengas guardada.
maxlength={100}
required
value={playlist.name}
class="w-full rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 focus:border-[var(--mf-accent)]/50 focus:outline-none"
/>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<label for="edit-desc" class="mb-2 block text-sm font-medium text-neutral-300">
Descripción <span class="text-neutral-500">(Opcional)</span>
</label>
<textarea
id="edit-desc"
name="description"
rows="3"
maxlength={300}
value={playlist.description}
class="w-full min-h-0 flex-1 resize-none rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 focus:border-[var(--mf-accent)]/50 focus:outline-none"
></textarea>
</div>
</div>
</div>
{#if form?.message}
<p class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{form.message}
</p>
{/if}
<div class="flex items-center justify-end gap-3">
<button
type="button"
onclick={() => (editing = false)}
class="rounded-lg border border-white/15 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-white/5"
>
Cancelar
</button>
<button
type="submit"
disabled={savingEdit}
class="rounded-lg bg-[var(--mf-accent)] px-6 py-2.5 text-sm font-semibold text-neutral-800 transition disabled:opacity-40"
>
{savingEdit ? 'Guardando…' : 'Guardar'}
</button>
</div>
</form>
</div>
</div>
{/if}
{#if confirmingDelete}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="button"
tabindex="0"
onclick={(e) => {
if (e.target === e.currentTarget) confirmingDelete = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape') confirmingDelete = false;
}}
>
<div class="animate-pop w-full max-w-sm rounded-2xl border border-white/10 bg-neutral-900 p-6 shadow-2xl">
<h2 class="font-display text-lg font-bold tracking-tight">¿Eliminar «{playlist.name}»?</h2>
<p class="mt-2 text-sm text-neutral-400">
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los dispositivos donde la tengas guardada.
</p> </p>
<div class="mt-6 flex items-center justify-end gap-3"> <div class="mt-6 flex items-center justify-end gap-3">
<button <Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
type="button"
onclick={() => (confirmingDelete = false)}
class="rounded-lg border border-white/15 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-white/5"
>
Cancelar
</button>
<form method="POST" action="?/delete"> <form method="POST" action="?/delete">
<button <Button type="submit" variant="danger">Eliminar</Button>
type="submit"
class="rounded-lg bg-red-500 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-red-400"
>
Eliminar
</button>
</form> </form>
</div> </div>
</div> </Modal>
</div>
{/if}
@@ -0,0 +1,77 @@
<script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
id: string;
name: string;
description?: string;
trackIds: (string | number)[];
updatedAt: string;
playing: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let { id, name, description, trackIds, updatedAt, playing, onPlayAll, onEdit, onDelete }: Props =
$props();
</script>
<a
href="/playlists"
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
>
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
<ArrowLeft class="h-4 w-4" />
</span>
Volver a tus listas
</a>
<div class="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end">
<PlaylistArt
playlistId={id}
{trackIds}
hue={hueFor(id)}
size="large"
version={updatedAt}
class="h-44 w-44 flex-shrink-0 rounded-art-lg shadow-art-lg"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Lista</p>
<h1 class="mt-1.5 text-5xl font-bold tracking-tight text-fg sm:text-7xl">{name}</h1>
{#if description}
<p class="mt-3 max-w-2xl text-fg-2">{description}</p>
{/if}
<p class="mt-3 text-sm text-muted">
{trackIds.length}
{trackIds.length === 1 ? 'canción' : 'canciones'}
</p>
</div>
</div>
<div class="mt-7 flex items-center gap-2.5">
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button size="sm" variant="secondary" onclick={onEdit}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
</div>
@@ -0,0 +1,79 @@
<script lang="ts">
import { enhance } from '$app/forms';
import X from '@lucide/svelte/icons/x';
import {
player,
isPendingYouTubeTrack,
queueIdForTrack,
toQueueItems,
youTubeThumbnailUrl,
type ApiTrackLike
} from '$lib/player/player.svelte';
import { hueFor, fmtTime, fmtDate } from '$lib/theme/color';
import Badge from '$lib/components/ui/Badge.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
type PlaylistTrack = ApiTrackLike & { createdAt: string; duration: number | string };
interface Props {
tracks: PlaylistTrack[];
}
let { tracks }: Props = $props();
function playFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
}
</script>
<TrackTable columns="32px 1fr 140px 64px 36px" class="mt-8">
{#snippet headers()}
<span class="text-center">#</span>
<span>Título</span>
<span class="text-center">Añadida</span>
<span class="text-center">Duración</span>
<span></span>
{/snippet}
{#each tracks as track, i (track.id)}
<TrackRow>
<TrackIndexCell
index={i}
active={player.current.id === queueIdForTrack(track)}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
coverSrc={isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId)
: undefined}
hue={hueFor(track.id)}
explicit={track.isExplicit}
onClick={() => playFrom(i)}
>
{#snippet badge()}
{#if track.source === 'YouTube' && track.audioStatus === 'Failed'}
<Badge tone="danger">Error</Badge>
{:else if isPendingYouTubeTrack(track)}
<Badge tone="accent">Descargando</Badge>
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta>{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form method="POST" action="?/removeTrack" use:enhance={() => async ({ update }) => update()}>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Quitar de la playlist" size="sm" revealOnHover>
<X class="h-3.5 w-3.5" />
</IconButton>
</form>
</TrackRow>
{/each}
</TrackTable>
@@ -0,0 +1,151 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Plus from '@lucide/svelte/icons/plus';
import {
player,
queueIdForTrack,
type ApiTrackLike,
type QueueItem
} from '$lib/player/player.svelte';
import { hueFor, fmtTime } from '$lib/theme/color';
import type { YouTubeSong } from '$lib/types';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
type LibraryTrack = ApiTrackLike & { duration: number | string };
type Suggestion =
| { kind: 'local'; track: LibraryTrack }
| { kind: 'youtube'; song: YouTubeSong };
interface Props {
library: LibraryTrack[];
youtube: YouTubeSong[];
playlists: { id: string; name: string }[];
}
let { library, youtube, playlists }: Props = $props();
let contextMenu = $state<ContextMenuState | null>(null);
function openContextMenu(event: MouseEvent, item: Suggestion) {
contextMenu = contextMenuStateFor(event, item);
}
function closeContextMenu() {
contextMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemFor(contextMenu as Suggestion));
closeContextMenu();
}
const suggestions = $derived<Suggestion[]>([
...library.map((track) => ({ kind: 'local' as const, track })),
...youtube.map((song) => ({ kind: 'youtube' as const, song }))
]);
function idFor(item: Suggestion) {
return item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track);
}
function queueItemFor(item: Suggestion): QueueItem {
return item.kind === 'youtube'
? {
id: item.song.videoId,
title: item.song.title,
artist: item.song.artist,
source: 'youtube',
coverUrl: item.song.thumbnailUrl,
explicit: item.song.isExplicit
}
: {
id: queueIdForTrack(item.track),
title: item.track.title,
artist: item.track.artist,
source: 'local',
coverUrl: `/api/tracks/${item.track.id}/cover?size=small`
};
}
function playFrom(index: number) {
player.playOrToggle(suggestions.map(queueItemFor), index);
}
</script>
<div class="mt-12">
<SectionHeading title="Sugerencias" />
<TrackTable columns="1fr 64px 36px">
{#snippet headers()}
<span>Título</span>
<span class="text-center">Duración</span>
<span></span>
{/snippet}
{#each suggestions as item, i (idFor(item))}
<TrackRow oncontextmenu={(e) => openContextMenu(e, item)}>
<TrackTitleCell
trackId={item.kind === 'youtube' ? item.song.videoId : item.track.id}
title={item.kind === 'youtube' ? item.song.title : item.track.title}
artist={item.kind === 'youtube' ? item.song.artist : item.track.artist}
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
hue={hueFor(idFor(item))}
explicit={item.kind === 'youtube' && item.song.isExplicit}
onClick={() => playFrom(i)}
>
{#snippet overlay()}
{#if player.current.id === idFor(item)}
<NowPlaying paused={!player.playing} />
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta>
{fmtTime(
item.kind === 'youtube' ? item.song.durationSeconds : Number(item.track.duration)
)}
</TrackMeta>
{#if item.kind === 'youtube'}
<form
method="POST"
action="?/addYouTubeToPlaylist"
use:enhance={() => async ({ update }) => update()}
>
<input type="hidden" name="videoId" value={item.song.videoId} />
<input type="hidden" name="title" value={item.song.title} />
<input type="hidden" name="artist" value={item.song.artist} />
<input type="hidden" name="durationSeconds" value={item.song.durationSeconds} />
<input type="hidden" name="thumbnailUrl" value={item.song.thumbnailUrl} />
<IconButton type="submit" label="Añadir a la playlist" size="sm">
<Plus class="h-4 w-4" />
</IconButton>
</form>
{:else}
<form method="POST" action="?/addTrack" use:enhance={() => async ({ update }) => update()}>
<input type="hidden" name="trackId" value={item.track.id} />
<IconButton type="submit" label="Añadir a la playlist" size="sm">
<Plus class="h-4 w-4" />
</IconButton>
</form>
{/if}
</TrackRow>
{/each}
</TrackTable>
</div>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
{playlists}
onClose={closeContextMenu}
onAddToQueue={addToQueue}
/>
{/if}
@@ -1,42 +1,14 @@
import type { Actions } from './$types'; import type { Actions } from './$types';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api'; import { createApiClient, requireAccessTokenAction, unwrapOrFail } from '$lib/server/api';
import { putPresigned } from '$lib/server/upload'; import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
const MAX_TITLE = 100; const MAX_TITLE = 100;
const AUDIO_TYPES: Record<string, string> = {
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<string, string> = {
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, string>): string {
if (file.type) return file.type;
return table[extOf(file.name)] ?? 'application/octet-stream';
}
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, locals, fetch }) => { default: async ({ request, locals, fetch }) => {
if (!locals.accessToken) { const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.');
return fail(401, { message: 'Inicia sesión para subir música.' }); if (typeof accessToken !== 'string') return accessToken;
}
const form = await request.formData(); const form = await request.formData();
const title = String(form.get('title') ?? '').trim(); const title = String(form.get('title') ?? '').trim();
@@ -55,9 +27,9 @@ export const actions: Actions = {
const audioContentType = contentTypeOf(audio, AUDIO_TYPES); const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES); const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
const api = createApiClient({ fetch, accessToken: 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: { body: {
pictureFileType: extOf(cover.name) || 'jpg', pictureFileType: extOf(cover.name) || 'jpg',
pictureContentType, pictureContentType,
@@ -68,9 +40,9 @@ export const actions: Actions = {
} }
}); });
if (urlsError || !urls) { const urlsFailure = unwrapOrFail(urlsResult, 'No se pudieron reservar las URLs de subida.');
return fail(502, { message: 'No se pudieron reservar las URLs de subida.' }); if (urlsFailure) return urlsFailure;
} const urls = urlsResult.data!;
try { try {
const [audioBuffer, coverBuffer] = await Promise.all([ 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: { body: {
title, title,
pictureIntentId: urls.pictureIntentId, pictureIntentId: urls.pictureIntentId,
@@ -96,9 +68,9 @@ export const actions: Actions = {
} }
}); });
if (createError || !track) { const trackFailure = unwrapOrFail(trackResult, 'No se pudo crear la pista tras la subida.');
return fail(502, { message: '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 }; return { success: true, trackId: track.id, title: track.title };
} }
+58 -103
View File
@@ -4,7 +4,14 @@
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import ImageIcon from '@lucide/svelte/icons/image'; import ImageIcon from '@lucide/svelte/icons/image';
import Check from '@lucide/svelte/icons/check'; 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 Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done'; type StepState = 'idle' | 'active' | 'done';
@@ -41,15 +48,13 @@
}); });
const dropClass = $derived( const dropClass = $derived(
dragging || audioName !== '' dragging || audioName !== '' ? 'border-accent/60' : 'border-line hover:border-accent/60'
? 'border-emerald-500/60'
: 'border-white/15 hover:border-emerald-500/60'
); );
function stepClass(s: StepState) { function stepClass(s: StepState) {
if (s === 'active') return 'border-emerald-500/40 bg-emerald-500/5'; if (s === 'active') return 'border-accent/40 bg-accent/5';
if (s === 'done') return 'border-emerald-500/40'; if (s === 'done') return 'border-accent/40';
return 'border-white/15'; return '';
} }
function formatSize(bytes: number) { function formatSize(bytes: number) {
@@ -101,29 +106,19 @@
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." /> <meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
</svelte:head> </svelte:head>
<section class="px-8 pt-8 pb-16 animate-[fade-up_0.45s_cubic-bezier(0.16,1,0.3,1)]"> <Page>
<a <PageHeader
href="/library" title="Subir música"
class="group inline-flex items-center gap-2 text-sm font-medium text-neutral-400 transition-colors hover:text-white" subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
> />
<span
class="grid h-8 w-8 place-items-center rounded-lg border border-white/15 transition-all duration-200 group-hover:-translate-x-0.5 group-hover:border-emerald-500/60 group-hover:text-emerald-400"
>
<ArrowLeft class="h-4 w-4" />
</span>
Volver a la biblioteca
</a>
<h1 class="mt-6 font-display text-4xl font-bold tracking-tight sm:text-5xl">Subir música</h1>
<p class="mt-4 text-lg text-neutral-400">
Añade una canción con su portada y título. Nosotros la procesamos para streaming.
</p>
<ol class="mt-10 grid gap-4 sm:grid-cols-3"> <ol class="mt-10 grid gap-4 sm:grid-cols-3">
{#each steps as step, i (step.key)} {#each steps as step, i (step.key)}
<li class="rounded-xl border p-5 transition {stepClass(stepStates[i])}"> <li>
<Surface class={stepClass(stepStates[i])}>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span <span
class="grid h-8 w-8 place-items-center rounded-full bg-[var(--mf-accent)] text-sm font-bold text-neutral-950" class="grid h-8 w-8 place-items-center rounded-full bg-accent-soft text-sm font-bold text-on-accent"
> >
{#if stepStates[i] === 'done'} {#if stepStates[i] === 'done'}
<Check class="h-4 w-4" strokeWidth={2.5} /> <Check class="h-4 w-4" strokeWidth={2.5} />
@@ -131,41 +126,31 @@
{i + 1} {i + 1}
{/if} {/if}
</span> </span>
<span class="text-base font-semibold">{step.label}</span> <span class="text-base font-semibold text-fg">{step.label}</span>
</div> </div>
<p class="mt-3 text-sm text-neutral-500">{step.hint}</p> <p class="mt-3 text-base text-muted">{step.hint}</p>
</Surface>
</li> </li>
{/each} {/each}
</ol> </ol>
{#if status === 'done'} {#if status === 'done'}
<div class="mt-10 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-12 text-center"> <Surface padding="lg" class="mt-10 border-accent/30 bg-accent/10 text-center">
<span <span
class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950" class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-accent-soft text-on-accent"
> >
<Check class="h-8 w-8" strokeWidth={2.5} /> <Check class="h-8 w-8" strokeWidth={2.5} />
</span> </span>
<h2 class="mt-6 font-display text-2xl font-bold tracking-tight">¡Subida!</h2> <h2 class="mt-6 text-2xl font-bold tracking-tight text-fg">¡Subida!</h2>
<p class="mt-3 text-lg text-neutral-300"> <p class="mt-3 text-fg-2">
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en «{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
unos momentos. unos momentos.
</p> </p>
<div class="mt-8 flex items-center justify-center gap-4"> <div class="mt-8 flex items-center justify-center gap-4">
<a <Button href="/library" size="lg">Ir a la biblioteca</Button>
href="/library" <Button variant="secondary" size="lg" onclick={reset}>Subir otra</Button>
class="rounded-lg bg-[var(--mf-accent)] px-8 py-3.5 text-base font-semibold text-neutral-950 transition hover:brightness-110"
>
Ir a la biblioteca
</a>
<button
type="button"
onclick={reset}
class="rounded-lg border border-white/10 px-8 py-3.5 text-base text-white/65 transition hover:bg-white/2.5"
>
Subir otra
</button>
</div>
</div> </div>
</Surface>
{:else} {:else}
<form <form
method="POST" method="POST"
@@ -197,7 +182,7 @@
}} }}
ondragleave={() => (dragging = false)} ondragleave={() => (dragging = false)}
ondrop={onDrop} 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}"
> >
<input <input
bind:this={audioInput} bind:this={audioInput}
@@ -208,7 +193,7 @@
class="absolute inset-0 cursor-pointer opacity-0" class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar archivo de audio" aria-label="Seleccionar archivo de audio"
/> />
<span class="grid h-20 w-20 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950"> <span class="grid h-20 w-20 place-items-center rounded-full bg-accent-soft text-on-accent">
{#if audioName === ''} {#if audioName === ''}
<Headphones class="h-9 w-9" /> <Headphones class="h-9 w-9" />
{:else} {:else}
@@ -216,21 +201,20 @@
{/if} {/if}
</span> </span>
{#if audioName === ''} {#if audioName === ''}
<p class="mt-5 text-lg font-medium text-neutral-200"> <p class="mt-5 text-lg font-medium text-fg">
Arrastra tu audio aquí o haz clic para elegir Arrastra tu audio aquí o haz clic para elegir
</p> </p>
<p class="mt-2 text-sm text-neutral-500">MP3, FLAC, WAV…</p> <p class="mt-2 text-sm text-muted">MP3, FLAC, WAV…</p>
{:else} {:else}
<p class="mt-5 max-w-full truncate text-lg font-medium text-neutral-100">{audioName}</p> <p class="mt-5 max-w-full truncate text-lg font-medium text-fg">{audioName}</p>
<p class="mt-2 text-sm text-neutral-500">{formatSize(audioSize)} · Listo para subir</p> <p class="mt-2 text-sm text-muted">{formatSize(audioSize)} · Listo para subir</p>
{/if} {/if}
</div> </div>
<div class="grid items-start gap-8 sm:grid-cols-[auto_1fr]"> <div class="grid items-start gap-8 sm:grid-cols-[auto_1fr]">
<div> <Field label="Portada">
<span class="mb-3 block text-sm font-medium text-neutral-300">Portada</span>
<label <label
class="relative grid h-44 w-44 cursor-pointer place-items-center overflow-hidden rounded-xl border border-white/15 bg-neutral-950 transition hover:border-emerald-500/40" class="relative grid h-44 w-44 cursor-pointer place-items-center overflow-hidden rounded-control border border-line bg-surface transition hover:border-accent/50"
> >
<input <input
type="file" type="file"
@@ -243,86 +227,57 @@
{#if coverPreview !== ''} {#if coverPreview !== ''}
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" /> <img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else} {:else}
<ImageIcon class="h-12 w-12 text-neutral-500" /> <ImageIcon class="h-12 w-12 text-muted" />
{/if} {/if}
</label> </label>
</div> </Field>
<div class="flex flex-col"> <Field label="Título" for="title">
<label for="title" class="mb-3 block text-sm font-medium text-neutral-300">Título</label> <Input
<input
id="title" id="title"
name="title" name="title"
type="text"
bind:value={title} bind:value={title}
maxlength={MAX_TITLE} maxlength={MAX_TITLE}
placeholder="Nombre de la canción" placeholder="Nombre de la canción"
class="w-full rounded-lg border border-white/15 bg-neutral-950 px-5 py-4 text-base text-neutral-100 placeholder:text-neutral-500 focus:border-emerald-500/60 focus:outline-none"
/> />
<div class="mt-3 flex items-center justify-between gap-3 text-sm text-neutral-500"> {#snippet hint()}
<p>Se usa como nombre público de la pista en el catálogo.</p> <p>Se usa como nombre público de la pista en el catálogo.</p>
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span> <span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
</div> {/snippet}
</div> </Field>
</div> </div>
<label class="flex items-center gap-3 text-sm text-neutral-300"> <Checkbox bind:checked={acceptedTerms}>
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<input
type="checkbox"
bind:checked={acceptedTerms}
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-white/15 bg-neutral-950 transition checked:border-emerald-500 checked:bg-[var(--mf-accent)] focus-visible:border-emerald-500/60 focus-visible:outline-none"
/>
<Check
class="pointer-events-none absolute h-3.5 w-3.5 text-neutral-950 opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
<span>
Acepto los Acepto los
<a <a
href="/terms" href="/terms"
class="text-emerald-400 underline underline-offset-2 transition hover:text-emerald-300" class="text-accent-soft underline underline-offset-2 transition hover:brightness-110"
> >
términos y condiciones términos y condiciones
</a> </a>
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}. al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
</span> </Checkbox>
</label>
{#if status === 'uploading'} {#if status === 'uploading'}
<div class="rounded-xl border border-white/15 bg-neutral-950 p-6"> <Surface>
<div class="flex items-center justify-between text-base"> <div class="flex items-center justify-between text-base">
<span class="font-medium text-neutral-200">Subiendo y creando la pista…</span> <span class="font-medium text-fg">Subiendo y creando la pista…</span>
<span class="text-neutral-500">un momento</span> <span class="text-muted">un momento</span>
</div>
<div class="mt-4 h-2.5 overflow-hidden rounded-full bg-white/10">
<div class="h-full w-full animate-pulse rounded-full bg-[var(--mf-accent)]"></div>
</div> </div>
<div class="mt-4 h-2.5 overflow-hidden rounded-full bg-surface-2">
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
</div> </div>
</Surface>
{/if} {/if}
{#if status === 'error'} {#if status === 'error'}
<div class="rounded-xl border border-red-500/40 bg-red-500/10 p-5 text-sm text-red-200"> <Alert tone="danger">{errorMsg}</Alert>
{errorMsg}
</div>
{/if} {/if}
<div class="flex items-center justify-end gap-4"> <div class="flex items-center justify-end gap-4">
<a <Button href="/explore" variant="secondary" size="lg">Cancelar</Button>
href="/explore" <Button type="submit" size="lg" disabled={!canPublish}>Publicar</Button>
class="rounded-lg border border-white/10 px-8 py-3.5 text-base text-white/65 transition hover:bg-white/2.5"
>
Cancelar
</a>
<button
type="submit"
disabled={!canPublish}
class="cursor-pointer rounded-lg bg-[var(--mf-accent)] px-8 py-3.5 text-base font-semibold text-neutral-950 backdrop-blur-md transition hover:brightness-110 hover:shadow-[0_10px_28px_-8px] active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:shadow-none"
>
Publicar
</button>
</div> </div>
</form> </form>
{/if} {/if}
</section> </Page>
+9 -6
View File
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import '@fontsource-variable/manrope/index.css'; import '@fontsource-variable/inter/index.css';
import '@fontsource-variable/sora/index.css';
import '$lib/theme/theme.css'; import '$lib/theme/theme.css';
import './layout.css'; import './layout.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import PlayerBar from '$lib/components/player/PlayerBar.svelte'; import PlayerBar from '$lib/components/player/PlayerBar.svelte';
import Sidebar from '$lib/components/Sidebar.svelte'; import Sidebar from '$lib/components/Sidebar.svelte';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { ACCENT_LIGHTNESS, ACCENT_CHROMA, ACCENT_HUE } from '$lib/theme/color';
import { page } from '$app/state'; import { page } from '$app/state';
let { children, data } = $props(); let { children, data } = $props();
@@ -23,7 +23,7 @@
return m ? { c: parseFloat(m[1]), h: parseFloat(m[2]) } : null; return m ? { c: parseFloat(m[1]), h: parseFloat(m[2]) } : null;
} }
let displayed = { c: 0.17, h: 145 }; let displayed = { c: ACCENT_CHROMA, h: ACCENT_HUE };
let rafId = 0; let rafId = 0;
$effect(() => { $effect(() => {
@@ -45,7 +45,10 @@
const c = from.c + (target.c - from.c) * e; const c = from.c + (target.c - from.c) * e;
const h = (((from.h + dh * e) % 360) + 360) % 360; const h = (((from.h + dh * e) % 360) + 360) % 360;
displayed = { c, h }; displayed = { c, h };
document.documentElement.style.setProperty('--mf-accent', `oklch(72% ${c.toFixed(3)} ${h.toFixed(1)})`); document.documentElement.style.setProperty(
'--mf-accent',
`oklch(${ACCENT_LIGHTNESS}% ${c.toFixed(3)} ${h.toFixed(1)})`
);
if (t < 1) rafId = requestAnimationFrame(tick); if (t < 1) rafId = requestAnimationFrame(tick);
}; };
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
@@ -55,11 +58,11 @@
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>
{#if isAuthPage} {#if isAuthPage}
<div class="min-h-screen bg-[var(--mf-bg)] text-[var(--mf-text)] antialiased"> <div class="min-h-screen bg-bg text-fg antialiased">
{@render children()} {@render children()}
</div> </div>
{:else} {:else}
<div class="flex min-h-screen bg-[var(--mf-bg)] text-[var(--mf-text)] antialiased"> <div class="flex min-h-screen bg-bg text-fg antialiased">
<Sidebar user={data.user!} /> <Sidebar user={data.user!} />
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<main class="flex-1 transition-[padding] {hasTrack ? 'pb-[92px]' : 'pb-0'}"> <main class="flex-1 transition-[padding] {hasTrack ? 'pb-[92px]' : 'pb-0'}">
+21 -12
View File
@@ -1,11 +1,14 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient } from '$lib/server/api'; import { createApiClient, playlistCoverTrackIds } from '$lib/server/api';
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
export const load: PageServerLoad = async ({ locals, fetch }) => { export const load: PageServerLoad = async ({ locals, fetch }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const latestPromise = api.GET('/tracks', { const latestPromise = api.GET('/tracks', {
params: { query: { pageNumber: 1, pageSize: 12 } } params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
}); });
const playlistsPromise = locals.user const playlistsPromise = locals.user
? api.GET('/playlists/users/{userId}', { ? api.GET('/playlists/users/{userId}', {
@@ -22,20 +25,26 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
recentlyPlayedPromise recentlyPlayedPromise
]); ]);
const playlistItems = playlists.data?.items ?? []; const playlistItems = playlists.data?.items ?? [];
const trackIds = await playlistCoverTrackIds(
api,
playlistItems.map((p) => p.id)
);
const covers = await Promise.all( const novedades = shuffle(
playlistItems.map(async (playlist) => { (latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))
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;
})
); );
return { return {
latest: latest.data?.items ?? [], novedades,
novedadesHasNext: Boolean(latest.data?.hasNextPage),
youtubeFiller: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
playlists: playlistItems, playlists: playlistItems,
trackIds: Object.fromEntries(covers) as Record<string, string[]>, trackIds,
recentlyPlayed: recentlyPlayed.data ?? [] recentlyPlayed: recentlyPlayed.data ?? []
}; };
}; };
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction
};

Some files were not shown because too many files have changed in this diff Show More