Changes
This commit is contained in:
@@ -60,11 +60,7 @@
|
||||
"OriginalPicturesFolder": "OriginalPictures",
|
||||
"SmallPicturesFolder": "SmallPictures",
|
||||
"MediumPicturesFolder": "MediumPictures",
|
||||
"LargePicturesFolder": "LargePictures",
|
||||
"PresetOriginalPicture": "PresetOriginalPicture.webp",
|
||||
"PresetSmallPicture": "PresetSmallPicture.webp",
|
||||
"PresetMediumPicture": "PresetMediumPicture.webp",
|
||||
"PresetLargePicture": "PresetLargePicture.webp"
|
||||
"LargePicturesFolder": "LargePictures"
|
||||
},
|
||||
"PicturesSizes": {
|
||||
"SmallPictureWidth": 128,
|
||||
|
||||
@@ -34,30 +34,12 @@ namespace Musify.Application.Configuration
|
||||
[Required]
|
||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
||||
|
||||
[Required]
|
||||
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
||||
|
||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
|
||||
|
||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
||||
|
||||
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
|
||||
|
||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolders, PresetSmallPicture);
|
||||
|
||||
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolders, PresetMediumPicture);
|
||||
|
||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolders, PresetLargePicture);
|
||||
|
||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
|
||||
|
||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
||||
|
||||
@@ -11,6 +11,10 @@ namespace Musify.Application.Contracts
|
||||
|
||||
DbSet<Track> Tracks { get; }
|
||||
|
||||
DbSet<LocalTrack> LocalTracks { get; }
|
||||
|
||||
DbSet<ExternalTrack> ExternalTracks { get; }
|
||||
|
||||
DbSet<Artist> Artists { get; }
|
||||
|
||||
DbSet<TrackArtist> TrackArtists { get; }
|
||||
|
||||
@@ -42,8 +42,6 @@ namespace Musify.Application.PlayLists
|
||||
}
|
||||
|
||||
UploadIntent? pictureIntent = null;
|
||||
string originalPictureName;
|
||||
|
||||
if (request.PictureIntentId.HasValue)
|
||||
{
|
||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
@@ -53,11 +51,6 @@ namespace Musify.Application.PlayLists
|
||||
return validation.Errors;
|
||||
|
||||
pictureIntent = validation.Value;
|
||||
originalPictureName = pictureIntent.ObjectName;
|
||||
}
|
||||
else
|
||||
{
|
||||
originalPictureName = playListConfiguration.Routes.PresetOriginalPicture;
|
||||
}
|
||||
|
||||
var playList = new PlayList
|
||||
@@ -66,20 +59,15 @@ namespace Musify.Application.PlayLists
|
||||
Name = request.Name,
|
||||
NormalizedName = request.Name.Trim().ToUpperInvariant(),
|
||||
Description = request.Description,
|
||||
Pictures = new PlayListPictures
|
||||
{
|
||||
OriginalName = originalPictureName,
|
||||
SmallName = playListConfiguration.Routes.PresetSmallPicture,
|
||||
MediumName = playListConfiguration.Routes.PresetMediumPicture,
|
||||
LargeName = playListConfiguration.Routes.PresetLargePicture
|
||||
}
|
||||
Pictures = pictureIntent == null ? null
|
||||
: new PlayListPictures { OriginalName = pictureIntent.ObjectName }
|
||||
};
|
||||
|
||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
||||
|
||||
if (pictureIntent is not null)
|
||||
if (pictureIntent != null)
|
||||
{
|
||||
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, originalPictureName);
|
||||
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||
var publishResult = await PublishCreatePlayListEventAsync(
|
||||
playList.Id, pictureIntent, finalPictureKey, cancellationToken);
|
||||
if (publishResult.IsError)
|
||||
|
||||
@@ -20,23 +20,23 @@ namespace Musify.Application.PlayLists
|
||||
{
|
||||
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)
|
||||
.Select(p => new { p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName })
|
||||
.Select(p => p.Pictures)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (playList == null)
|
||||
if (pictures is null)
|
||||
return Error.NotFound();
|
||||
|
||||
var routes = playListConfiguration.Routes;
|
||||
var (name, key, presetName) = request.Size.ToLowerInvariant() switch
|
||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
||||
{
|
||||
"small" => (playList.SmallName, BuildKey(routes.BuildSmallPicturePath, playList.SmallName), routes.PresetSmallPicture),
|
||||
"large" => (playList.LargeName, BuildKey(routes.BuildLargePicturePath, playList.LargeName), routes.PresetLargePicture),
|
||||
_ => (playList.MediumName, BuildKey(routes.BuildMediumPicturePath, playList.MediumName), routes.PresetMediumPicture)
|
||||
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
|
||||
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
|
||||
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key) || name == presetName)
|
||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
||||
return Error.NotFound();
|
||||
|
||||
var contentType = MimeUtility.GetMimeMapping(name);
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
|
||||
@@ -28,9 +29,9 @@ namespace Musify.Application.PlayLists
|
||||
|
||||
var tracksQuery = database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(plt => plt.Track)
|
||||
.ThenInclude(track => track.TrackArtists)
|
||||
.Include(plt => ((ExternalTrack)plt.Track).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(plt => ((LocalTrack)plt.Track).Owner)
|
||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
@@ -5,10 +5,10 @@ namespace Musify.Application.PlayLists.Responses
|
||||
public record PlayListApplicationResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Description,
|
||||
string SmallImageKeyName,
|
||||
string MediumImageKeyName,
|
||||
string LargeImageKeyName,
|
||||
string? Description,
|
||||
string? SmallImageKeyName,
|
||||
string? MediumImageKeyName,
|
||||
string? LargeImageKeyName,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt)
|
||||
{
|
||||
@@ -18,9 +18,9 @@ namespace Musify.Application.PlayLists.Responses
|
||||
playList.Id,
|
||||
playList.Name,
|
||||
playList.Description,
|
||||
playList.Pictures.SmallName,
|
||||
playList.Pictures.MediumName,
|
||||
playList.Pictures.LargeName,
|
||||
playList.Pictures?.SmallName,
|
||||
playList.Pictures?.MediumName,
|
||||
playList.Pictures?.LargeName,
|
||||
playList.CreatedAt,
|
||||
playList.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using Musify.Application.Events;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.PlayLists
|
||||
@@ -68,7 +69,7 @@ namespace Musify.Application.PlayLists
|
||||
return validation.Errors;
|
||||
|
||||
pictureIntent = validation.Value;
|
||||
playListEntity.Pictures.OriginalName = pictureIntent.ObjectName;
|
||||
playListEntity.Pictures = new PlayListPictures { OriginalName = pictureIntent.ObjectName };
|
||||
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Services;
|
||||
|
||||
public sealed record YouTubeTrackProvision(Track Track, YouTubeSongResult? Song);
|
||||
public sealed record YouTubeTrackProvision(ExternalTrack Track, YouTubeSongResult? Song);
|
||||
|
||||
public sealed class YouTubeTrackProvisioner(
|
||||
IDatabase database,
|
||||
@@ -16,8 +16,8 @@ public sealed class YouTubeTrackProvisioner(
|
||||
TrackConfiguration trackConfiguration,
|
||||
ILogger<YouTubeTrackProvisioner> logger)
|
||||
{
|
||||
public Task<Track?> FindAsync(string videoId, CancellationToken cancellationToken) =>
|
||||
database.Tracks
|
||||
public Task<ExternalTrack?> FindAsync(string videoId, CancellationToken cancellationToken) =>
|
||||
database.ExternalTracks
|
||||
.Include(track => track.TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken);
|
||||
@@ -44,7 +44,7 @@ public sealed class YouTubeTrackProvisioner(
|
||||
artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
|
||||
|
||||
var title = Truncate(song.Title, 50);
|
||||
var track = new Track
|
||||
var track = new ExternalTrack
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
@@ -65,7 +65,7 @@ public sealed class YouTubeTrackProvisioner(
|
||||
}
|
||||
};
|
||||
|
||||
await database.Tracks.AddAsync(track, cancellationToken);
|
||||
await database.ExternalTracks.AddAsync(track, cancellationToken);
|
||||
|
||||
for (var position = 0; position < artists.Count; position++)
|
||||
{
|
||||
@@ -85,7 +85,7 @@ public sealed class YouTubeTrackProvisioner(
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
database.Tracks.Remove(track);
|
||||
database.ExternalTracks.Remove(track);
|
||||
var refetched = await FindAsync(videoId, cancellationToken);
|
||||
if (refetched is null)
|
||||
{
|
||||
@@ -100,43 +100,17 @@ public sealed class YouTubeTrackProvisioner(
|
||||
{
|
||||
var name = Truncate(reference.Name, 200);
|
||||
var normalized = name.ToUpperInvariant();
|
||||
var byExternalId = !string.IsNullOrEmpty(reference.Id);
|
||||
var externalId = reference.Id ?? $"name:{normalized}";
|
||||
|
||||
var existing = byExternalId
|
||||
? await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == reference.Id, cancellationToken)
|
||||
: await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken);
|
||||
var existing = await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == externalId, cancellationToken);
|
||||
if (existing is not null)
|
||||
return existing;
|
||||
|
||||
if (byExternalId)
|
||||
{
|
||||
var adoptable = await database.Artists
|
||||
.SingleOrDefaultAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken);
|
||||
if (adoptable is not null)
|
||||
{
|
||||
var previousName = adoptable.Name;
|
||||
adoptable.ExternalId = reference.Id;
|
||||
adoptable.Name = name;
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
return adoptable;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
adoptable.ExternalId = null;
|
||||
adoptable.Name = previousName;
|
||||
return await database.Artists.SingleAsync(a => a.ExternalId == reference.Id, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var artist = new Artist
|
||||
{
|
||||
Name = name,
|
||||
NormalizedName = normalized,
|
||||
ExternalId = byExternalId ? reference.Id : null
|
||||
ExternalId = externalId
|
||||
};
|
||||
|
||||
await database.Artists.AddAsync(artist, cancellationToken);
|
||||
@@ -149,9 +123,7 @@ public sealed class YouTubeTrackProvisioner(
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
database.Artists.Remove(artist);
|
||||
return byExternalId
|
||||
? await database.Artists.SingleAsync(a => a.ExternalId == reference.Id, cancellationToken)
|
||||
: await database.Artists.SingleAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken);
|
||||
return await database.Artists.SingleAsync(a => a.ExternalId == externalId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace Musify.Application.Tracks
|
||||
{
|
||||
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);
|
||||
if (user is null)
|
||||
{
|
||||
@@ -59,13 +59,12 @@ namespace Musify.Application.Tracks
|
||||
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||
|
||||
var artist = await GetOrCreateUserArtistAsync(user, cancellationToken);
|
||||
|
||||
var trackEntity = new Track
|
||||
var trackEntity = new LocalTrack
|
||||
{
|
||||
Title = request.Title,
|
||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
||||
OwnerUserId = user.Id,
|
||||
Owner = user,
|
||||
Pictures = new TrackPictures
|
||||
{
|
||||
OriginalName = pictureIntent.ObjectName,
|
||||
@@ -81,14 +80,7 @@ namespace Musify.Application.Tracks
|
||||
}
|
||||
};
|
||||
|
||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||
|
||||
await database.TrackArtists.AddAsync(new TrackArtist
|
||||
{
|
||||
TrackId = trackEntity.Id,
|
||||
ArtistId = artist.Id,
|
||||
Position = 0
|
||||
}, cancellationToken);
|
||||
await database.LocalTracks.AddAsync(trackEntity, cancellationToken);
|
||||
|
||||
await database.UserHasTracks.AddAsync(new UserHasTrack
|
||||
{
|
||||
@@ -141,32 +133,5 @@ namespace Musify.Application.Tracks
|
||||
|
||||
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
|
||||
}
|
||||
|
||||
private async Task<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 Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Contracts;
|
||||
@@ -28,7 +29,7 @@ namespace Musify.Application.Tracks
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
if (track.OwnerUserId != request.UserId)
|
||||
if (track is not LocalTrack localTrack || localTrack.OwnerUserId != request.UserId)
|
||||
{
|
||||
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
|
||||
return Error.Unauthorized();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
@@ -24,8 +25,9 @@ namespace Musify.Application.Tracks
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var tracksById = await database.Tracks
|
||||
.Include(t => t.TrackArtists)
|
||||
.Include(t => ((ExternalTrack)t).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(t => ((LocalTrack)t).Owner)
|
||||
.Where(t => recentTrackIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } )
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
|
||||
@@ -3,6 +3,7 @@ using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
@@ -15,8 +16,9 @@ namespace Musify.Application.Tracks
|
||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await database.Tracks.AsNoTracking()
|
||||
.Include(t => t.TrackArtists)
|
||||
.Include(t => ((ExternalTrack)t).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(t => ((LocalTrack)t).Owner)
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
@@ -6,6 +6,7 @@ using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
@@ -19,9 +20,9 @@ namespace Musify.Application.Tracks
|
||||
{
|
||||
var tracksQuery = database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(ut => ut.Track)
|
||||
.ThenInclude(track => track.TrackArtists)
|
||||
.Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(ut => ((LocalTrack)ut.Track).Owner)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Name))
|
||||
|
||||
@@ -6,6 +6,7 @@ using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
@@ -26,9 +27,9 @@ namespace Musify.Application.Tracks
|
||||
|
||||
var tracksQuery = database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(ut => ut.Track)
|
||||
.ThenInclude(track => track.TrackArtists)
|
||||
.Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(ut => ((LocalTrack)ut.Track).Owner)
|
||||
.Where(p => p.UserId == request.UserId);
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Name))
|
||||
|
||||
@@ -22,23 +22,23 @@ namespace Musify.Application.Tracks.Responses
|
||||
track.Id,
|
||||
track.Title,
|
||||
FormatArtist(track),
|
||||
track.Source,
|
||||
track.ExternalId,
|
||||
track is ExternalTrack external ? external.Source : TrackSource.Local,
|
||||
(track as ExternalTrack)?.ExternalId,
|
||||
track.Audio.TranscodeStatus,
|
||||
track.DurationSeconds,
|
||||
listensCount,
|
||||
track.OwnerUserId,
|
||||
(track as LocalTrack)?.OwnerUserId,
|
||||
track.CreatedAt,
|
||||
track.UpdatedAt);
|
||||
|
||||
private static string? FormatArtist(Track track)
|
||||
private static string? FormatArtist(Track track) => track switch
|
||||
{
|
||||
if (track.TrackArtists.Count == 0)
|
||||
return null;
|
||||
|
||||
return string.Join(", ", track.TrackArtists
|
||||
LocalTrack local => local.Owner.Name,
|
||||
ExternalTrack { TrackArtists.Count: > 0 } external =>
|
||||
string.Join(", ", external.TrackArtists
|
||||
.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.ProfilePictureUrl = request.ProfilePictureUrl;
|
||||
|
||||
var artist = await database.Artists
|
||||
.SingleOrDefaultAsync(a => a.UserId == request.Id, cancellationToken);
|
||||
if (artist is not null)
|
||||
{
|
||||
artist.Name = request.Name;
|
||||
artist.NormalizedName = request.Name.ToUpperInvariant();
|
||||
}
|
||||
|
||||
var written = await database.SaveChangesAsync(cancellationToken);
|
||||
if (written > 0)
|
||||
logger.LogInformation("Synced profile for user {UserId}", request.Id);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Musify.Domain.Abstractions;
|
||||
|
||||
namespace Musify.Domain.Entities
|
||||
@@ -18,10 +17,9 @@ namespace Musify.Domain.Entities
|
||||
[MaxLength(200)]
|
||||
public required string NormalizedName { get; set; }
|
||||
|
||||
public long? UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string? ExternalId { get; set; }
|
||||
public required string ExternalId { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
@@ -29,9 +27,6 @@ namespace Musify.Domain.Entities
|
||||
[Required]
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[ForeignKey(nameof(UserId)) ]
|
||||
public User? User { get; set; }
|
||||
|
||||
public ICollection<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; }
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,9 @@ namespace Musify.Domain.Entities
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string Description { get; set; } = "No description was provided.";
|
||||
public string? Description { get; set; }
|
||||
|
||||
public required PlayListPictures Pictures { get; set; }
|
||||
public PlayListPictures? Pictures { get; set; }
|
||||
|
||||
[Required]
|
||||
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Musify.Domain.Abstractions;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Domain.Entities
|
||||
{
|
||||
#pragma warning disable CS8618
|
||||
public class Track : IAuditable
|
||||
public abstract class Track : IAuditable
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -22,14 +21,6 @@ namespace Musify.Domain.Entities
|
||||
[Required]
|
||||
public int DurationSeconds { get; set; }
|
||||
|
||||
public long? OwnerUserId { get; set; }
|
||||
|
||||
[Required]
|
||||
public TrackSource Source { get; set; } = TrackSource.Local;
|
||||
|
||||
[MaxLength(16)]
|
||||
public string? ExternalId { get; set; }
|
||||
|
||||
[Required]
|
||||
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
||||
|
||||
@@ -43,18 +34,10 @@ namespace Musify.Domain.Entities
|
||||
|
||||
public TrackAudio Audio { get; set; } = new();
|
||||
|
||||
[ForeignKey(nameof(OwnerUserId)) ]
|
||||
public User? Owner { get; set; }
|
||||
|
||||
public ICollection<UserHasTrack> UserTracks { get; set; } = new List<UserHasTrack>();
|
||||
|
||||
public ICollection<PlayListHasTrack> PlayListTracks { get; set; } = new List<PlayListHasTrack>();
|
||||
|
||||
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;
|
||||
|
||||
[ForeignKey(nameof(TrackId)) ]
|
||||
public Track Track { get; set; }
|
||||
public ExternalTrack Track { get; set; }
|
||||
|
||||
[ForeignKey(nameof(ArtistId)) ]
|
||||
public Artist Artist { get; set; }
|
||||
|
||||
@@ -2,12 +2,12 @@ namespace Musify.Domain.ValueObjects
|
||||
{
|
||||
public class PlayListPictures
|
||||
{
|
||||
public required string OriginalName { get; set; }
|
||||
public string? OriginalName { get; set; }
|
||||
|
||||
public required string SmallName { get; set; }
|
||||
public string? SmallName { get; set; }
|
||||
|
||||
public required string MediumName { get; set; }
|
||||
public string? MediumName { get; set; }
|
||||
|
||||
public required string LargeName { get; set; }
|
||||
public string? LargeName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+15
-4
@@ -2,6 +2,7 @@ using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.MassTransit.Arguments;
|
||||
using Musify.Infrastructure.MassTransit.Logs;
|
||||
|
||||
@@ -37,6 +38,13 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures
|
||||
throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} not found");
|
||||
}
|
||||
|
||||
if (playList.Pictures is null)
|
||||
{
|
||||
logger.LogWarning("Playlist {PlayListId} has no pending picture upload",
|
||||
executeContext.Arguments.PlayListId);
|
||||
throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} has no pending picture upload");
|
||||
}
|
||||
|
||||
var log = new UpdatePlayListPictureLog(
|
||||
playList.Id,
|
||||
playList.Pictures.OriginalName,
|
||||
@@ -82,10 +90,13 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures
|
||||
return compensateContext.Compensated();
|
||||
}
|
||||
|
||||
playList.Pictures.OriginalName = compensateContext.Log.PreviousOriginalPictureKey;
|
||||
playList.Pictures.SmallName = compensateContext.Log.PreviousSmallPictureKey;
|
||||
playList.Pictures.MediumName = compensateContext.Log.PreviousMediumPictureKey;
|
||||
playList.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey;
|
||||
playList.Pictures = new PlayListPictures
|
||||
{
|
||||
OriginalName = compensateContext.Log.PreviousOriginalPictureKey,
|
||||
SmallName = compensateContext.Log.PreviousSmallPictureKey,
|
||||
MediumName = compensateContext.Log.PreviousMediumPictureKey,
|
||||
LargeName = compensateContext.Log.PreviousLargePictureKey
|
||||
};
|
||||
|
||||
database.PlayLists.Update(playList);
|
||||
await database.SaveChangesAsync(compensateContext.CancellationToken);
|
||||
|
||||
@@ -2,8 +2,8 @@ namespace Musify.Infrastructure.MassTransit.Logs
|
||||
{
|
||||
public record UpdatePlayListPictureLog(
|
||||
Guid PlayListId,
|
||||
string PreviousOriginalPictureKey,
|
||||
string PreviousSmallPictureKey,
|
||||
string PreviousMediumPictureKey,
|
||||
string PreviousLargePictureKey);
|
||||
string? PreviousOriginalPictureKey,
|
||||
string? PreviousSmallPictureKey,
|
||||
string? PreviousMediumPictureKey,
|
||||
string? PreviousLargePictureKey);
|
||||
}
|
||||
|
||||
+9
-9
@@ -37,49 +37,49 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||
EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName),
|
||||
new MarkPlayListAsRemovingArguments(playListId));
|
||||
|
||||
if (playList is not null)
|
||||
if (playList?.Pictures is not null)
|
||||
{
|
||||
var bucket = storageConfiguration.Bucket;
|
||||
var routes = playListConfiguration.Routes;
|
||||
|
||||
if (playList.Pictures.OriginalName != routes.PresetOriginalPicture)
|
||||
if (playList.Pictures.OriginalName is { } originalName)
|
||||
{
|
||||
routingSlipBuilder.AddActivity(
|
||||
ActivityNames.RemovePlayListOriginalPicture,
|
||||
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||
new RemoveFileFromBucketArguments(
|
||||
bucket,
|
||||
routes.BuildOriginalPicturePath(userId, playList.Pictures.OriginalName)));
|
||||
routes.BuildOriginalPicturePath(userId, originalName)));
|
||||
}
|
||||
|
||||
if (playList.Pictures.SmallName != routes.PresetSmallPicture)
|
||||
if (playList.Pictures.SmallName is { } smallName)
|
||||
{
|
||||
routingSlipBuilder.AddActivity(
|
||||
ActivityNames.RemovePlayListSmallPicture,
|
||||
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||
new RemoveFileFromBucketArguments(
|
||||
bucket,
|
||||
routes.BuildSmallPicturePath(playList.Pictures.SmallName)));
|
||||
routes.BuildSmallPicturePath(smallName)));
|
||||
}
|
||||
|
||||
if (playList.Pictures.MediumName != routes.PresetMediumPicture)
|
||||
if (playList.Pictures.MediumName is { } mediumName)
|
||||
{
|
||||
routingSlipBuilder.AddActivity(
|
||||
ActivityNames.RemovePlayListMediumPicture,
|
||||
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||
new RemoveFileFromBucketArguments(
|
||||
bucket,
|
||||
routes.BuildMediumPicturePath(playList.Pictures.MediumName)));
|
||||
routes.BuildMediumPicturePath(mediumName)));
|
||||
}
|
||||
|
||||
if (playList.Pictures.LargeName != routes.PresetLargePicture)
|
||||
if (playList.Pictures.LargeName is { } largeName)
|
||||
{
|
||||
routingSlipBuilder.AddActivity(
|
||||
ActivityNames.RemovePlayListLargePicture,
|
||||
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||
new RemoveFileFromBucketArguments(
|
||||
bucket,
|
||||
routes.BuildLargePicturePath(playList.Pictures.LargeName)));
|
||||
routes.BuildLargePicturePath(largeName)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,21 +30,6 @@ namespace Musify.Infrastructure.Persistence
|
||||
trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
|
||||
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>()
|
||||
.OwnsOne(track => track.Pictures, pictures =>
|
||||
{
|
||||
@@ -68,6 +53,22 @@ namespace Musify.Infrastructure.Persistence
|
||||
});
|
||||
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>()
|
||||
.HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId });
|
||||
|
||||
@@ -83,19 +84,10 @@ namespace Musify.Infrastructure.Persistence
|
||||
.HasForeignKey(trackArtist => trackArtist.ArtistId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<Artist>()
|
||||
.HasIndex(artist => artist.UserId)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<Artist>()
|
||||
.HasIndex(artist => artist.ExternalId)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<Artist>()
|
||||
.HasIndex(artist => artist.NormalizedName)
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL");
|
||||
|
||||
modelBuilder.Entity<PlayList>()
|
||||
.OwnsOne(playList => playList.Pictures, pictures =>
|
||||
{
|
||||
@@ -104,7 +96,7 @@ namespace Musify.Infrastructure.Persistence
|
||||
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
|
||||
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
|
||||
});
|
||||
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired();
|
||||
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(false);
|
||||
|
||||
var playListProcessing = modelBuilder.Entity<PlayListProcessingState>();
|
||||
playListProcessing.HasKey(state => state.CorrelationId);
|
||||
@@ -116,6 +108,10 @@ namespace Musify.Infrastructure.Persistence
|
||||
|
||||
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<TrackArtist> TrackArtists => Set<TrackArtist>();
|
||||
|
||||
+903
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+274
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+898
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
@@ -216,21 +217,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExternalId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Artists");
|
||||
});
|
||||
|
||||
@@ -337,10 +328,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.Property<int>("DurationSeconds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<int>("LifeCycleStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -349,12 +336,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<long?>("OwnerUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
@@ -365,15 +346,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
b.ToTable("Tracks");
|
||||
|
||||
b.HasIndex("Source", "ExternalId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tracks", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Tracks_OwnerUserId_Source", "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)");
|
||||
});
|
||||
b.UseTptMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b =>
|
||||
@@ -596,6 +571,36 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("TrackProcessingState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b =>
|
||||
{
|
||||
b.HasBaseType("Musify.Domain.Entities.Track");
|
||||
|
||||
b.Property<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)
|
||||
@@ -608,15 +613,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.HasPrincipalKey("MessageId", "ConsumerId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.Artist", b =>
|
||||
{
|
||||
b.HasOne("Musify.Domain.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b =>
|
||||
{
|
||||
b.HasOne("Musify.Domain.Entities.Track", "Track")
|
||||
@@ -650,25 +646,21 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<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");
|
||||
@@ -681,8 +673,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.HasForeignKey("PlayListId");
|
||||
});
|
||||
|
||||
b.Navigation("Pictures")
|
||||
.IsRequired();
|
||||
b.Navigation("Pictures");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
@@ -708,11 +699,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
|
||||
{
|
||||
b.HasOne("Musify.Domain.Entities.User", "Owner")
|
||||
.WithMany()
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("TrackId")
|
||||
@@ -792,8 +778,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Audio")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
|
||||
b.Navigation("Pictures")
|
||||
.IsRequired();
|
||||
});
|
||||
@@ -806,7 +790,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Musify.Domain.Entities.Track", "Track")
|
||||
b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track")
|
||||
.WithMany("TrackArtists")
|
||||
.HasForeignKey("TrackId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
@@ -847,6 +831,32 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b =>
|
||||
{
|
||||
b.HasOne("Musify.Domain.Entities.Track", null)
|
||||
.WithOne()
|
||||
.HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b =>
|
||||
{
|
||||
b.HasOne("Musify.Domain.Entities.Track", null)
|
||||
.WithOne()
|
||||
.HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Musify.Domain.Entities.User", "Owner")
|
||||
.WithMany()
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.Artist", b =>
|
||||
{
|
||||
b.Navigation("TrackArtists");
|
||||
@@ -863,8 +873,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.Navigation("PlayListTracks");
|
||||
|
||||
b.Navigation("TrackArtists");
|
||||
|
||||
b.Navigation("UserTracks");
|
||||
});
|
||||
|
||||
@@ -876,6 +884,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.Navigation("UserTracks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b =>
|
||||
{
|
||||
b.Navigation("TrackArtists");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,7 @@
|
||||
"OriginalPicturesFolder": "OriginalPictures",
|
||||
"SmallPicturesFolder": "SmallPictures",
|
||||
"MediumPicturesFolder": "MediumPictures",
|
||||
"LargePicturesFolder": "LargePictures",
|
||||
"PresetOriginalPicture": "PresetOriginalPicture.webp",
|
||||
"PresetSmallPicture": "PresetSmallPicture.webp",
|
||||
"PresetMediumPicture": "PresetMediumPicture.webp",
|
||||
"PresetLargePicture": "PresetLargePicture.webp"
|
||||
"LargePicturesFolder": "LargePictures"
|
||||
},
|
||||
"PicturesSizes": {
|
||||
"SmallPictureWidth": 128,
|
||||
|
||||
Generated
-20
@@ -9,8 +9,6 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/sora": "^5.2.8",
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"jose": "^6.2.3",
|
||||
@@ -49,24 +47,6 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/manrope": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
|
||||
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/sora": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.2.8.tgz",
|
||||
"integrity": "sha512-sP+ILTfi5r3cqmMBntI4ooQNxFI+LyHyD5hcPbWwwL4uh4kKFAkOz0w2veCXjz18fKRdva6OG0dY+5yQ02f30w==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/sora": "^5.2.8",
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"jose": "^6.2.3",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -3,8 +3,8 @@
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Library from '@lucide/svelte/icons/library-big';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
const mainLinks = [
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Buscar', icon: Search },
|
||||
{ href: '/playlists', label: 'Listas', icon: ListMusic }
|
||||
{ href: '/playlists', label: 'Listas', icon: Music }
|
||||
];
|
||||
|
||||
const secondaryLinks = [
|
||||
{ href: '/library', label: 'Canciones subidas', icon: Library },
|
||||
{ href: '/library', label: 'Canciones subidas', icon: Folder },
|
||||
{ href: '/upload', label: 'Subir música', icon: Upload }
|
||||
];
|
||||
|
||||
@@ -65,32 +65,32 @@
|
||||
<a
|
||||
href={link.href}
|
||||
data-active={active}
|
||||
class="relative flex items-center gap-3 rounded-[10px] px-3 py-2.5 font-semibold transition-colors duration-100 {active
|
||||
? 'text-[var(--mf-text)]'
|
||||
: 'text-[var(--mf-text-2)] hover:bg-white/5 hover:text-[var(--mf-text)]'}"
|
||||
class="relative flex items-center gap-4 rounded-control px-4 py-3 transition-colors duration-100 {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-3 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon
|
||||
class="h-[18px] w-[18px] flex-shrink-0"
|
||||
strokeWidth={2}
|
||||
style={active ? 'color:var(--mf-accent)' : ''}
|
||||
class="h-6 w-6 shrink-0"
|
||||
style={active ? 'color:var(--mf-accent-60)' : ''}
|
||||
/>
|
||||
{link.label}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<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="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)]'
|
||||
: ''}"
|
||||
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>
|
||||
|
||||
<nav class="relative z-10 flex flex-col gap-1">
|
||||
@@ -99,7 +99,7 @@
|
||||
{/each}
|
||||
</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">
|
||||
{#each secondaryLinks as link (link.href)}
|
||||
@@ -111,12 +111,12 @@
|
||||
<div class="relative mt-auto" bind:this={menuRef}>
|
||||
{#if menuOpen}
|
||||
<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>
|
||||
<button
|
||||
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} />
|
||||
Salir
|
||||
@@ -127,18 +127,18 @@
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
|
||||
{:else}
|
||||
<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)}
|
||||
</span>
|
||||
{/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>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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
|
||||
class="grid h-full items-center gap-5 px-8"
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full max-w-[900px] items-center gap-[9px]">
|
||||
<span class="w-8 text-right text-[11px] tabular-nums text-[var(--mf-text-4)]">
|
||||
<div class="flex w-full max-w-172 items-center gap-3">
|
||||
<span class="text-right text-xs tabular-nums text-muted">
|
||||
{fmtTime(player.progress)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={seek}
|
||||
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
|
||||
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}%"
|
||||
></div>
|
||||
<span
|
||||
@@ -27,7 +27,7 @@
|
||||
style="left:{player.progressPercent}%"
|
||||
></span>
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
@@ -11,18 +12,21 @@
|
||||
hue={player.current.hue}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-[52px] w-[52px] flex-shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
|
||||
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
|
||||
/>
|
||||
{:else}
|
||||
<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}"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[13.5px] font-semibold text-[var(--mf-text)]">
|
||||
{player.current.title}
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if player.current.explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-fg">{player.current.title}</span>
|
||||
</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>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
type="button"
|
||||
onclick={() => player.previous()}
|
||||
aria-label="Anterior"
|
||||
class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90"
|
||||
class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90"
|
||||
>
|
||||
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
|
||||
<rect x="0" y="0" width="2.5" height="14" rx="1" />
|
||||
@@ -19,7 +19,7 @@
|
||||
type="button"
|
||||
onclick={() => player.toggle()}
|
||||
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
|
||||
class="grid h-[36px] w-[36px] place-items-center rounded-full bg-[var(--mf-text)] text-[var(--mf-bg)] shadow-lg transition duration-100 active:scale-95"
|
||||
class="grid h-[36px] w-[36px] place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95"
|
||||
>
|
||||
{#if player.playing}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
@@ -37,7 +37,7 @@
|
||||
type="button"
|
||||
onclick={() => player.next()}
|
||||
aria-label="Siguiente"
|
||||
class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90"
|
||||
class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90"
|
||||
>
|
||||
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
|
||||
<path d="M1 0 11 7 1 14z" />
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
oninput={handleVolumeChange}
|
||||
aria-label="Volumen"
|
||||
class="mf-volume"
|
||||
style="background:linear-gradient(to right, var(--mf-accent) {player.volume}%, var(--mf-track) {player.volume}%)"
|
||||
style="background:linear-gradient(to right, var(--mf-accent-60) {player.volume}%, var(--mf-track) {player.volume}%)"
|
||||
/>
|
||||
</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">
|
||||
import Cover from './Cover.svelte';
|
||||
import { gradientForHue, HUES } from '$lib/theme/color';
|
||||
import { gradientForHue, hueFor } from '$lib/theme/color';
|
||||
|
||||
interface Props {
|
||||
playlistId?: string;
|
||||
trackIds: (string | number)[];
|
||||
hue: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
version?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { playlistId, trackIds, hue, size = 'medium', 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 { playlistId, trackIds, hue, size = 'medium', version, class: klass = '' }: Props = $props();
|
||||
|
||||
let coverFailed = $state(false);
|
||||
|
||||
@@ -28,7 +22,7 @@
|
||||
{#if playlistId && !coverFailed}
|
||||
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
|
||||
<img
|
||||
src="/api/playlists/{playlistId}/cover?size={size}"
|
||||
src="/api/playlists/{playlistId}/cover?size={size}{version ? `&v=${encodeURIComponent(version)}` : ''}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export const EXPLORE_PAGE_SIZE = 27;
|
||||
export const HOME_LATEST_PAGE_SIZE = 12;
|
||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { HUES, accentForHue, gradientForHue } from '$lib/theme/color';
|
||||
import { HUES, accentForHue, gradientForHue, hueFor } from '$lib/theme/color';
|
||||
import { extractAccent, type Accent } from '$lib/theme/palette';
|
||||
|
||||
export type TrackSourceKind = 'local' | 'youtube';
|
||||
@@ -12,6 +12,7 @@ export interface PlayerTrack {
|
||||
hue: number;
|
||||
source: TrackSourceKind;
|
||||
coverUrl?: string;
|
||||
explicit?: boolean;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
@@ -26,6 +27,7 @@ export interface QueueItem {
|
||||
artist?: string;
|
||||
source?: TrackSourceKind;
|
||||
coverUrl?: string;
|
||||
explicit?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiTrackLike {
|
||||
@@ -35,6 +37,7 @@ export interface ApiTrackLike {
|
||||
source?: 'Local' | 'YouTube';
|
||||
externalId?: string;
|
||||
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||
isExplicit?: boolean;
|
||||
}
|
||||
|
||||
export function isYouTubeTrack(track: ApiTrackLike): boolean {
|
||||
@@ -65,7 +68,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
||||
? isPendingYouTubeTrack(track) && track.externalId
|
||||
? youTubeThumbnailUrl(track.externalId)
|
||||
: `/api/tracks/${track.id}/cover?size=small`
|
||||
: undefined
|
||||
: undefined,
|
||||
explicit: track.isExplicit
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -74,13 +78,6 @@ const RECENT_LIMIT = 15;
|
||||
|
||||
const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0], source: 'local' };
|
||||
|
||||
function hueFor(id: string | number): number {
|
||||
const text = String(id);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
return HUES[hash % HUES.length];
|
||||
}
|
||||
|
||||
function toTrack(item: QueueItem): PlayerTrack {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -88,6 +85,7 @@ function toTrack(item: QueueItem): PlayerTrack {
|
||||
artist: item.artist ?? '',
|
||||
duration: 0,
|
||||
hue: hueFor(item.id),
|
||||
explicit: item.explicit,
|
||||
source: item.source ?? 'local',
|
||||
coverUrl: item.coverUrl
|
||||
};
|
||||
@@ -99,7 +97,7 @@ class PlayerState {
|
||||
playing = $state(false);
|
||||
progress = $state(0);
|
||||
volume = $state(
|
||||
browser ? Number(localStorage.getItem("player.volume") ?? 100) : 100
|
||||
browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100
|
||||
);
|
||||
loading = $state(false);
|
||||
error = $state('');
|
||||
@@ -333,6 +331,12 @@ class PlayerState {
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
playOrToggle(list: QueueItem[], index: number) {
|
||||
if (index < 0 || index >= list.length) return;
|
||||
if (this.currentId === list[index].id) this.toggle();
|
||||
else this.playQueue(list, index);
|
||||
}
|
||||
|
||||
addToQueue(item: QueueItem) {
|
||||
if (this.currentId === null) {
|
||||
this.playQueue([item], 0);
|
||||
@@ -404,7 +408,7 @@ class PlayerState {
|
||||
const audio = this.#audioEl();
|
||||
if (audio) {
|
||||
audio.volume = this.volume / 100;
|
||||
localStorage.setItem("player.volume", String(this.volume));
|
||||
localStorage.setItem('player.volume', String(this.volume));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import { error, fail, type ActionFailure } from '@sveltejs/kit';
|
||||
import type { paths } from '$lib/api/schema';
|
||||
import { apiConfig } from '$lib/server/config';
|
||||
|
||||
@@ -14,3 +15,48 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function requireAccessToken(locals: App.Locals, message = 'Inicia sesión.'): string {
|
||||
if (!locals.accessToken) error(401, message);
|
||||
return locals.accessToken;
|
||||
}
|
||||
|
||||
export function requireAccessTokenAction(
|
||||
locals: App.Locals,
|
||||
message = 'Inicia sesión.'
|
||||
): string | ActionFailure<{ message: string }> {
|
||||
return locals.accessToken ?? fail(401, { message });
|
||||
}
|
||||
|
||||
export function unwrapOrError<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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -4,6 +4,33 @@ import { env } from '$env/dynamic/private';
|
||||
|
||||
const CONNECT_HOST = env.S3_UPLOAD_CONNECT_HOST || '127.0.0.1';
|
||||
|
||||
export const IMAGE_TYPES: Record<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(
|
||||
presignedUrl: string,
|
||||
body: Buffer,
|
||||
@@ -49,3 +76,39 @@ export async function putPresigned(
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
|
||||
|
||||
export async function uploadPresignedImage(
|
||||
reserve: (args: {
|
||||
fileType: string;
|
||||
contentType: string;
|
||||
expectedSizeBytes: number;
|
||||
}) => Promise<{
|
||||
data?: { uploadUrl: string; contentType: string; intentId: string } | null;
|
||||
error?: unknown;
|
||||
}>,
|
||||
file: File
|
||||
): Promise<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 };
|
||||
}
|
||||
|
||||
@@ -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 ?? ''
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
export const HUES = [145, 330, 40, 152, 300, 200, 10, 120, 280];
|
||||
|
||||
export const ACCENT_LIGHTNESS = 60;
|
||||
export const ACCENT_CHROMA = 0.17;
|
||||
export const ACCENT_HUE = 145;
|
||||
|
||||
export function hueFor(id: string | number): number {
|
||||
const text = String(id);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
return HUES[hash % HUES.length];
|
||||
}
|
||||
|
||||
export function accentForHue(hue: number): string {
|
||||
return `oklch(72% 0.17 ${hue})`;
|
||||
return `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${hue})`;
|
||||
}
|
||||
|
||||
export function accentSoft(hue: number, pct: number): string {
|
||||
@@ -18,3 +29,9 @@ export function fmtTime(seconds: number): string {
|
||||
const rest = total % 60;
|
||||
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`;
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
||||
|
||||
export function fmtDate(value: string | number | Date): string {
|
||||
return dateFormatter.format(new Date(value));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { ACCENT_LIGHTNESS } from './color';
|
||||
|
||||
export interface Accent {
|
||||
accent: string;
|
||||
@@ -107,7 +108,7 @@ export async function extractAccent(url: string): Promise<Accent | null> {
|
||||
const h2 = ((hue + 25) % 360).toFixed(1);
|
||||
|
||||
return {
|
||||
accent: `oklch(72% ${c} ${h})`,
|
||||
accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
|
||||
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
/* Paleta de Musify — cambia los colores aquí y afecta a toda la app.
|
||||
El acento (--mf-accent) es dinámico: lo fija el player según la pista sonando. */
|
||||
:root {
|
||||
--mf-bg: #08080a;
|
||||
--mf-bg: rgb(9, 9, 9);
|
||||
--mf-elevated: #0b0b0d;
|
||||
|
||||
--mf-text: #f5f4f2;
|
||||
--mf-text-2: rgba(245, 244, 242, 0.7);
|
||||
--mf-text-3: rgba(245, 244, 242, 0.5);
|
||||
--mf-text-4: rgba(245, 244, 242, 0.4);
|
||||
--mf-text: #c7c6c5;
|
||||
--mf-text-2: #a5a4a3;
|
||||
--mf-text-3: #8c8b8a;
|
||||
--mf-text-4: #7f7e7d;
|
||||
|
||||
--mf-surface: rgba(255, 255, 255, 0.045);
|
||||
--mf-surface: rgb(18, 18, 18);
|
||||
--mf-surface-2: rgba(255, 255, 255, 0.06);
|
||||
--mf-surface-hover: rgba(255, 255, 255, 0.09);
|
||||
|
||||
@@ -17,6 +15,8 @@
|
||||
--mf-border-strong: rgba(255, 255, 255, 0.12);
|
||||
--mf-track: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--mf-accent: oklch(72% 0.17 145);
|
||||
--mf-accent: oklch(60% 0.17 145);
|
||||
--mf-accent-60: color-mix(in oklch, var(--mf-accent) 80%, transparent);
|
||||
--mf-radius: 12px;
|
||||
--mf-sidebar-w: 264px;
|
||||
}
|
||||
|
||||
@@ -4,3 +4,13 @@ export interface SessionUser {
|
||||
email: string;
|
||||
picture: string;
|
||||
}
|
||||
|
||||
export interface YouTubeSong {
|
||||
videoId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
durationSeconds: number;
|
||||
thumbnailUrl: string;
|
||||
isExplicit: boolean;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
|
||||
const PAGE_SIZE = 27;
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import { EXPLORE_PAGE_SIZE as PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
@@ -31,75 +31,22 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
playlistsPromise
|
||||
]);
|
||||
|
||||
if (tracksRes.error || !tracksRes.data) {
|
||||
error(502, 'No se pudieron cargar las canciones.');
|
||||
}
|
||||
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
|
||||
|
||||
return {
|
||||
query,
|
||||
tracks: tracksRes.data,
|
||||
tracks,
|
||||
ytResults: searchRes?.data ?? null,
|
||||
ytError: Boolean(searchRes?.error),
|
||||
youtubeFiller: query
|
||||
? Promise.resolve(null)
|
||||
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
|
||||
playlists: playlistsRes?.data?.items ?? [],
|
||||
needsAuth: !locals.user
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: async ({ request, locals, fetch }) => {
|
||||
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||
|
||||
const form = await request.formData();
|
||||
const playlistId = String(form.get('playlistId') ?? '');
|
||||
const trackId = String(form.get('trackId') ?? '');
|
||||
|
||||
if (!playlistId || !trackId) {
|
||||
return fail(400, { message: 'Faltan datos de la canción.' });
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
|
||||
params: { path: { playlistId, trackId } }
|
||||
});
|
||||
|
||||
if (err) {
|
||||
if (response?.status === 409) {
|
||||
return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
|
||||
}
|
||||
return fail(502, { message: 'No se pudo añadir la canción.', trackId });
|
||||
}
|
||||
|
||||
return { added: true, trackId, playlistId };
|
||||
},
|
||||
|
||||
addYouTubeToPlaylist: async ({ request, locals, fetch }) => {
|
||||
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||
|
||||
const form = await request.formData();
|
||||
const playlistId = String(form.get('playlistId') ?? '');
|
||||
const videoId = String(form.get('videoId') ?? '');
|
||||
const title = String(form.get('title') ?? '');
|
||||
const artist = String(form.get('artist') ?? '');
|
||||
const durationSeconds = Number(form.get('durationSeconds') ?? 0);
|
||||
const thumbnailUrl = String(form.get('thumbnailUrl') ?? '');
|
||||
|
||||
if (!playlistId || !videoId || !title) {
|
||||
return fail(400, { message: 'Faltan datos de la canción.' });
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const { error: err, response } = await api.POST('/playlists/{playlistId}/youtube-tracks', {
|
||||
params: { path: { playlistId } },
|
||||
body: { videoId, title, artist, durationSeconds, thumbnailUrl }
|
||||
});
|
||||
|
||||
if (err) {
|
||||
if (response?.status === 409) {
|
||||
return fail(409, { message: 'La canción ya está en esa playlist.', videoId });
|
||||
}
|
||||
return fail(502, { message: 'No se pudo añadir la canción.', videoId });
|
||||
}
|
||||
|
||||
return { added: true, videoId, playlistId };
|
||||
}
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
||||
};
|
||||
|
||||
@@ -1,36 +1,25 @@
|
||||
<script lang="ts">
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
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 { enhance } from '$app/forms';
|
||||
import {
|
||||
player,
|
||||
queueIdForTrack,
|
||||
toQueueItems,
|
||||
type ApiTrackLike,
|
||||
type QueueItem
|
||||
} from '$lib/player/player.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import { HUES } from '$lib/theme/color';
|
||||
|
||||
interface YouTubeSong {
|
||||
videoId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
durationSeconds: number;
|
||||
thumbnailUrl: string;
|
||||
isExplicit: boolean;
|
||||
}
|
||||
|
||||
type ContextMenuTarget =
|
||||
| { kind: 'youtube'; song: YouTubeSong }
|
||||
| { kind: 'local'; track: ApiTrackLike };
|
||||
import { player, queueIdForTrack, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import TrackTile from '$lib/components/TrackTile.svelte';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuTarget,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import { appendUnique } from '$lib/collections.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -39,96 +28,78 @@
|
||||
|
||||
let ytItems = $state<YouTubeSong[]>([]);
|
||||
let ytContinuation = $state('');
|
||||
let ytLoadingMore = $state(false);
|
||||
let contextMenu = $state<(ContextMenuTarget & { x: number; y: number; openLeft: boolean }) | null>(
|
||||
null
|
||||
);
|
||||
let ytSearchTerm = $state('');
|
||||
let contextMenu = $state<ContextMenuState | 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(() => {
|
||||
ytItems = (data.ytResults?.items ?? []) as YouTubeSong[];
|
||||
ytContinuation = data.ytResults?.continuationToken ?? '';
|
||||
contextMenu = null;
|
||||
});
|
||||
localItems = data.tracks.items;
|
||||
localPage = Number(data.tracks.pageNumber);
|
||||
localHasNext = Boolean(data.tracks.hasNextPage);
|
||||
|
||||
const tracks = $derived(data.tracks);
|
||||
const currentPage = $derived(Number(data.tracks.pageNumber));
|
||||
const total = $derived(Number(data.tracks.totalItemCount));
|
||||
|
||||
const localVisible = $derived(!data.query || tracks.items.length > 0);
|
||||
const youtubeVisible = $derived(
|
||||
!!data.query && (data.needsAuth || data.ytError || ytItems.length > 0)
|
||||
if (data.query) {
|
||||
ytItems = appendUnique(
|
||||
[],
|
||||
(data.ytResults?.items ?? []) as YouTubeSong[],
|
||||
item => item.videoId
|
||||
);
|
||||
const localHeadingVisible = $derived(!!data.query && localVisible);
|
||||
const youtubeHeadingVisible = $derived(!!data.query && youtubeVisible);
|
||||
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();
|
||||
ytContinuation = data.ytResults?.continuationToken ?? '';
|
||||
ytSearchTerm = data.query;
|
||||
return;
|
||||
}
|
||||
player.playQueue(
|
||||
ytItems.map((s) => ({
|
||||
id: s.videoId,
|
||||
title: s.title,
|
||||
artist: s.artist,
|
||||
source: 'youtube' as const,
|
||||
coverUrl: s.thumbnailUrl
|
||||
})),
|
||||
index
|
||||
ytItems = [];
|
||||
ytContinuation = '';
|
||||
ytSearchTerm = '';
|
||||
data.youtubeFiller?.then((filler) => {
|
||||
if (!filler) return;
|
||||
ytItems = appendUnique(
|
||||
[],
|
||||
filler.items,
|
||||
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) {
|
||||
if (data.playlists.length === 0) return;
|
||||
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 itemTitle(item: ContextMenuTarget) {
|
||||
return item.kind === 'youtube' ? item.song.title : item.track.title;
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
function itemArtist(item: ContextMenuTarget) {
|
||||
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 {
|
||||
@@ -138,31 +109,88 @@
|
||||
title: target.song.title,
|
||||
artist: target.song.artist,
|
||||
source: 'youtube',
|
||||
coverUrl: target.song.thumbnailUrl
|
||||
coverUrl: target.song.thumbnailUrl,
|
||||
explicit: target.song.isExplicit
|
||||
}
|
||||
: toQueueItems([target.track])[0];
|
||||
}
|
||||
|
||||
function addToQueue(target: ContextMenuTarget | null) {
|
||||
if (!target) return;
|
||||
player.addToQueue(queueItemFor(target));
|
||||
function buildHref(query: string) {
|
||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!ytContinuation || ytLoadingMore) return;
|
||||
ytLoadingMore = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ query: data.query, continuation: ytContinuation });
|
||||
const params = new URLSearchParams({ query: ytSearchTerm, continuation: ytContinuation });
|
||||
const res = await fetch(`/api/youtube/search?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
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;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
if (localHasNext) await loadMoreLocal();
|
||||
else if (ytContinuation && ytSearchTerm) await loadMoreYouTube();
|
||||
} catch {
|
||||
ytContinuation = '';
|
||||
if (localHasNext) localHasNext = false;
|
||||
else ytContinuation = '';
|
||||
} finally {
|
||||
ytLoadingMore = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -172,287 +200,62 @@
|
||||
<meta name="description" content="Explora tu música y busca canciones en YouTube Music." />
|
||||
</svelte:head>
|
||||
|
||||
<section class="px-8 pt-12 pb-8">
|
||||
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Explorar</h1>
|
||||
<p class="mt-2 text-neutral-400">Busca a la vez en tu música y en YouTube Music.</p>
|
||||
<Page>
|
||||
<PageHeader title="Explorar" subtitle="Busca a la vez en tu música y en YouTube Music." />
|
||||
|
||||
<div class="relative mt-8">
|
||||
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-neutral-500">
|
||||
<Search class="h-4 w-4" />
|
||||
</span>
|
||||
<input
|
||||
<div class="mt-8">
|
||||
<Input
|
||||
type="search"
|
||||
icon={Search}
|
||||
bind:value={searchValue}
|
||||
oninput={onSearchInput}
|
||||
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>
|
||||
|
||||
{#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">
|
||||
{form.message}
|
||||
</p>
|
||||
<Alert class="mt-4">{form.message}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if nothingFound}
|
||||
<div class="mt-10 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
||||
<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>
|
||||
<EmptyState icon={Music} title="Sin resultados" description="No hay resultados para «{data.query}»." />
|
||||
{/if}
|
||||
|
||||
{#if localVisible}
|
||||
{#if localHeadingVisible}
|
||||
<h2 class="mt-10 font-display text-lg font-bold tracking-tight">Musify</h2>
|
||||
{/if}
|
||||
|
||||
<p class="{localHeadingVisible ? 'mt-1' : 'mt-6'} text-sm text-neutral-500">
|
||||
{#if data.query}
|
||||
{total}
|
||||
{total === 1 ? 'resultado' : 'resultados'} para «{data.query}»
|
||||
{:else}
|
||||
{total}
|
||||
{total === 1 ? 'canción' : 'canciones'}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#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>
|
||||
{#if items.length > 0}
|
||||
<MediaGrid as="ul" class="mt-6">
|
||||
{#each items as item, i (itemId(item))}
|
||||
<TrackTile
|
||||
id={itemId(item)}
|
||||
title={itemTitle(item)}
|
||||
artist={itemArtist(item)}
|
||||
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
|
||||
hue={hueFor(itemId(item))}
|
||||
explicit={itemExplicit(item)}
|
||||
active={itemActive(item)}
|
||||
playing={player.isPlaying}
|
||||
index={i}
|
||||
onClick={() => togglePlay(i)}
|
||||
onContextMenu={(e) => openContextMenu(e, item)}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
</MediaGrid>
|
||||
|
||||
{#if tracks.hasPreviousPage || tracks.hasNextPage}
|
||||
<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>
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{/if}
|
||||
|
||||
{#if data.needsAuth}
|
||||
<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">Inicia sesión para buscar en YouTube Music.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
description="Inicia sesión para ver también resultados de YouTube Music."
|
||||
/>
|
||||
{: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">
|
||||
<Music class="mx-auto h-8 w-8 text-neutral-500" />
|
||||
<p class="mt-3 text-sm text-neutral-300">YouTube Music no está disponible ahora mismo. Inténtalo de nuevo.</p>
|
||||
</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>
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
description="YouTube Music no está disponible ahora mismo. Inténtalo de nuevo."
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</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>
|
||||
</Page>
|
||||
|
||||
{#if contextMenu}
|
||||
<div
|
||||
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>
|
||||
<TrackContextMenu menu={contextMenu} playlists={data.playlists} onClose={closeContextMenu} onAddToQueue={addToQueue} />
|
||||
{/if}
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessTokenAction, unwrapOrError, unwrapOrFail } from '$lib/server/api';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
if (!locals.user) error(401, 'Inicia sesión.');
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const { data, error: err } = await api.GET('/tracks/users/{userId}', {
|
||||
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
|
||||
const result = await api.GET('/tracks/users/{userId}', {
|
||||
params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
|
||||
});
|
||||
|
||||
if (err || !data) error(502, 'No se pudo cargar tu biblioteca.');
|
||||
|
||||
return { tracks: data };
|
||||
return { tracks: unwrapOrError(result, 'No se pudo cargar tu biblioteca.') };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
deleteTrack: async ({ request, locals, fetch }) => {
|
||||
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const { error: err } = await api.DELETE('/tracks/{trackId}', {
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.DELETE('/tracks/{trackId}', {
|
||||
params: { path: { trackId } }
|
||||
});
|
||||
if (err) return fail(502, { message: 'No se pudo borrar la canción.' });
|
||||
const failure = unwrapOrFail(result, 'No se pudo borrar la canción.');
|
||||
if (failure) return failure;
|
||||
return { deleted: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
|
||||
import { HUES, fmtTime } from '$lib/theme/color';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { player, toQueueItems } from '$lib/player/player.svelte';
|
||||
import { hueFor, fmtTime, fmtDate } from '$lib/theme/color';
|
||||
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();
|
||||
|
||||
const items = $derived(data.tracks.items);
|
||||
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) {
|
||||
const track = items[index];
|
||||
if (player.current.id === queueIdForTrack(track)) player.toggle();
|
||||
else player.playQueue(toQueueItems(items), index);
|
||||
player.playOrToggle(toQueueItems(items), index);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -33,103 +31,69 @@
|
||||
<meta name="description" content="Todas tus canciones subidas a Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<section class="px-8 pt-11 pb-8">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Tus canciones subidas</h1>
|
||||
<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]"
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="Tus canciones subidas"
|
||||
subtitle="{total} {total === 1 ? 'canción' : 'canciones'} subida{total === 1 ? '' : 's'}."
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button href="/upload">
|
||||
<Upload class="h-4 w-4" />
|
||||
Subir música
|
||||
</a>
|
||||
</div>
|
||||
Subir canción
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if items.length > 0}
|
||||
<div class="mt-8">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<TrackTable columns="32px 1fr 100px 100px 64px 36px" class="mt-8">
|
||||
{#snippet headers()}
|
||||
<span class="text-center">#</span>
|
||||
<span>Título</span>
|
||||
<span class="text-center">Subida</span>
|
||||
<span class="text-center">Escuchas</span>
|
||||
<span class="text-center">Duración</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-col">
|
||||
{/snippet}
|
||||
{#each items as track, i (track.id)}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => 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"
|
||||
<TrackRow>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === track.id}
|
||||
playing={player.playing}
|
||||
onToggle={() => togglePlay(i)}
|
||||
/>
|
||||
<div class="min-w-0 truncate text-[14.5px] font-semibold text-neutral-100">{track.title}</div>
|
||||
</button>
|
||||
<div class="truncate text-center 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">
|
||||
{Number(track.listensCount)}
|
||||
</span>
|
||||
<span class="text-center text-[12.5px] tabular-nums text-neutral-500">
|
||||
{fmtTime(Number(track.duration))}
|
||||
</span>
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
hue={hueFor(track.id)}
|
||||
onClick={() => togglePlay(i)}
|
||||
/>
|
||||
<TrackMeta>{fmtDate(track.createdAt)}</TrackMeta>
|
||||
<TrackMeta>{Number(track.listensCount)}</TrackMeta>
|
||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||
<form method="POST" action="?/deleteTrack" use:enhance={() => async ({ update }) => update()}>
|
||||
<input type="hidden" name="trackId" value={track.id} />
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<IconButton type="submit" label="Borrar canción" size="sm" revealOnHover>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</form>
|
||||
</div>
|
||||
</TrackRow>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</TrackTable>
|
||||
{:else}
|
||||
<div
|
||||
class="mt-7 max-w-[560px] rounded-xl border border-[var(--mf-border)] bg-[var(--mf-surface)] px-6 py-10 text-center"
|
||||
>
|
||||
<p class="text-sm text-[var(--mf-text-2)]">Todavía no tienes canciones</p>
|
||||
<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]"
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
title="Todavía no tienes canciones"
|
||||
description="Sube la primera y aparecerá aquí lista para reproducir."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/upload">
|
||||
<Upload class="h-4 w-4" />
|
||||
Sube la primera
|
||||
</a>
|
||||
</div>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</section>
|
||||
</Page>
|
||||
|
||||
@@ -1,52 +1,34 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
import { putPresigned } from '$lib/server/upload';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
playlistCoverTrackIds,
|
||||
requireAccessTokenAction,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
|
||||
const MAX_NAME = 100;
|
||||
|
||||
const IMAGE_TYPES: Record<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 }) => {
|
||||
if (!locals.user) error(401, 'Inicia sesión.');
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const { data, error: err } = await api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
const result = await api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
});
|
||||
|
||||
if (err || !data) error(502, 'No se pudieron cargar tus playlists.');
|
||||
|
||||
const covers = await Promise.all(
|
||||
data.items.map(async (playlist) => {
|
||||
const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 4 } }
|
||||
});
|
||||
return [playlist.id, tracks?.items.map((t) => t.id) ?? []] as const;
|
||||
})
|
||||
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
|
||||
const trackIds = await playlistCoverTrackIds(
|
||||
api,
|
||||
data.items.map((p) => p.id)
|
||||
);
|
||||
|
||||
return { playlists: data, trackIds: Object.fromEntries(covers) as Record<string, string[]> };
|
||||
return { playlists: data, trackIds };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals, fetch }) => {
|
||||
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
@@ -57,36 +39,18 @@ export const actions: Actions = {
|
||||
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).', name, description });
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
|
||||
let pictureIntentId: string | null = null;
|
||||
if (cover instanceof File && cover.size > 0) {
|
||||
const contentType = contentTypeOf(cover);
|
||||
const { data: upload, error: uploadError } = await api.POST('/playlists/upload-picture', {
|
||||
body: {
|
||||
fileType: extOf(cover.name) || 'jpg',
|
||||
contentType,
|
||||
expectedSizeBytes: cover.size
|
||||
const result = await uploadPresignedImage(
|
||||
(args) => api.POST('/playlists/upload-picture', { body: args }),
|
||||
cover
|
||||
);
|
||||
if ('failMessage' in result) {
|
||||
return fail(502, { message: result.failMessage, name, description, detail: result.detail });
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
pictureIntentId = result.intentId;
|
||||
}
|
||||
|
||||
const { data, error: err } = await api.POST('/playlists', {
|
||||
|
||||
@@ -1,54 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import { HUES } from '$lib/theme/color';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.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 = [
|
||||
"Mi Playlist",
|
||||
"Tus favoritos",
|
||||
"Lo mejor de hoy",
|
||||
"Para relajarse",
|
||||
"Buena vibra",
|
||||
"Modo estudio",
|
||||
"Road Trip",
|
||||
"Domingos",
|
||||
"Late Night",
|
||||
"Descubriendo música",
|
||||
"Todo un poco",
|
||||
"En bucle",
|
||||
"Mi colección",
|
||||
"Canciones para repetir",
|
||||
"Hits del momento"
|
||||
'Mi Playlist',
|
||||
'Tus favoritos',
|
||||
'Lo mejor de hoy',
|
||||
'Para relajarse',
|
||||
'Buena vibra',
|
||||
'Modo estudio',
|
||||
'Road Trip',
|
||||
'Domingos',
|
||||
'Late Night',
|
||||
'Descubriendo música',
|
||||
'Todo un poco',
|
||||
'En bucle',
|
||||
'Mi colección',
|
||||
'Canciones para repetir',
|
||||
'Hits del momento'
|
||||
];
|
||||
const namePlaceholder = playlistNames[Math.floor(Math.random() * playlistNames.length)];
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const items = $derived(data.playlists.items);
|
||||
const trackIds = $derived(data.trackIds);
|
||||
|
||||
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() {
|
||||
creating = true;
|
||||
}
|
||||
|
||||
function onCoverInput(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (file) coverPreview = URL.createObjectURL(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -56,185 +46,54 @@
|
||||
<meta name="description" content="Mis listas en Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<section class="px-8 py-12">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<Page>
|
||||
<PageHeader title="Mis listas" subtitle="Crea y organiza tus colecciones.">
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<button
|
||||
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]"
|
||||
>
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2.5} />
|
||||
Crear lista
|
||||
</button>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#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)}
|
||||
<a
|
||||
href="/playlists/{playlist.id}"
|
||||
class="group animate-enter text-left"
|
||||
style="animation-delay:{i * 45}ms"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
<PlaylistCard
|
||||
id={playlist.id}
|
||||
name={playlist.name}
|
||||
description={playlist.description}
|
||||
trackIds={trackIds[playlist.id] ?? []}
|
||||
hue={hueFor(playlist.id)}
|
||||
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)]"
|
||||
updatedAt={playlist.updatedAt}
|
||||
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}
|
||||
</div>
|
||||
</MediaGrid>
|
||||
{:else}
|
||||
<div
|
||||
class="mt-9 p-14 text-center"
|
||||
<EmptyState
|
||||
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">
|
||||
<ListMusic class="h-7 w-7" />
|
||||
</span>
|
||||
<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} />
|
||||
{#snippet actions()}
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Crear lista
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</section>
|
||||
</Page>
|
||||
|
||||
{#if creating}
|
||||
<div
|
||||
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"
|
||||
<Modal open={creating} onClose={() => (creating = false)} title="Nueva lista">
|
||||
<PlaylistForm
|
||||
action="?/create"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
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"
|
||||
{namePlaceholder}
|
||||
formMessage={form?.message}
|
||||
submitLabel="Crear"
|
||||
submittingLabel="Creando…"
|
||||
onCancel={() => (creating = false)}
|
||||
/>
|
||||
{#if coverPreview !== ''}
|
||||
<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}
|
||||
</Modal>
|
||||
|
||||
@@ -1,80 +1,92 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
import { putPresigned } from '$lib/server/upload';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
unwrapOrError,
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
const MAX_NAME = 100;
|
||||
|
||||
const IMAGE_TYPES: Record<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';
|
||||
}
|
||||
const SUGGESTIONS_LIMIT = 20;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
||||
if (!locals.user) error(401, 'Inicia sesión.');
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [playlistRes, tracksRes, libraryRes] = await Promise.all([
|
||||
const [playlistRes, tracksRes, libraryRes, playlistsRes] = await Promise.all([
|
||||
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
|
||||
api.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
|
||||
}),
|
||||
api.GET('/tracks/users/{userId}', {
|
||||
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
}),
|
||||
api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
]);
|
||||
|
||||
if (playlistRes.error || !playlistRes.data) error(404, 'Playlist no encontrada.');
|
||||
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
|
||||
|
||||
const tracks = tracksRes.data?.items ?? [];
|
||||
const inPlaylist = new Set(tracks.map((t) => t.id));
|
||||
const library = (libraryRes.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
|
||||
|
||||
return { playlist: playlistRes.data, tracks, library };
|
||||
const alreadyLinked = new Set(
|
||||
tracks.filter((t) => t.source === 'YouTube' && t.externalId).map((t) => t.externalId)
|
||||
);
|
||||
const seeds = [...new Set(tracks.map((t) => t.artist).filter((a) => !!a))];
|
||||
const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined;
|
||||
|
||||
const youtube = fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
SUGGESTIONS_LIMIT + alreadyLinked.size,
|
||||
seed
|
||||
)
|
||||
.then((filler) => ({
|
||||
query: filler.query,
|
||||
items: (filler.items as YouTubeSong[])
|
||||
.filter((song) => !alreadyLinked.has(song.videoId))
|
||||
.slice(0, SUGGESTIONS_LIMIT)
|
||||
}))
|
||||
.catch(() => ({ query: '', items: [] as YouTubeSong[] }));
|
||||
|
||||
return {
|
||||
playlist,
|
||||
tracks,
|
||||
library,
|
||||
youtube,
|
||||
playlists: playlistsRes.data?.items ?? []
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: async ({ request, params, locals, fetch }) => {
|
||||
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const { error: err } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
|
||||
params: { path: { playlistId: params.id, trackId } }
|
||||
});
|
||||
if (err) return fail(502, { message: 'No se pudo añadir la canción.' });
|
||||
return { added: true };
|
||||
},
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
||||
|
||||
removeTrack: async ({ request, params, locals, fetch }) => {
|
||||
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const { error: err } = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
|
||||
params: { path: { playlistId: params.id, trackId } }
|
||||
});
|
||||
if (err) return fail(502, { message: 'No se pudo quitar la canción.' });
|
||||
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
|
||||
if (failure) return failure;
|
||||
return { removed: true };
|
||||
},
|
||||
|
||||
rename: async ({ request, params, locals, fetch }) => {
|
||||
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
@@ -83,52 +95,39 @@ export const actions: Actions = {
|
||||
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
|
||||
}
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
|
||||
let newPictureIntentId: string | null = null;
|
||||
if (cover instanceof File && cover.size > 0) {
|
||||
const contentType = contentTypeOf(cover);
|
||||
const { data: upload, error: uploadError } = await api.POST('/playlists/upload-picture', {
|
||||
body: {
|
||||
fileType: extOf(cover.name) || 'jpg',
|
||||
contentType,
|
||||
expectedSizeBytes: cover.size
|
||||
const result = await uploadPresignedImage(
|
||||
(args) => api.POST('/playlists/upload-picture', { body: args }),
|
||||
cover
|
||||
);
|
||||
if ('failMessage' in result) {
|
||||
return fail(502, { message: result.failMessage, detail: result.detail });
|
||||
}
|
||||
});
|
||||
|
||||
if (uploadError || !upload) {
|
||||
return fail(502, { message: 'No se pudo reservar la subida de la portada.' });
|
||||
newPictureIntentId = result.intentId;
|
||||
}
|
||||
|
||||
try {
|
||||
const coverBuffer = await cover.arrayBuffer();
|
||||
await putPresigned(upload.uploadUrl, Buffer.from(coverBuffer), upload.contentType);
|
||||
} catch (err) {
|
||||
return fail(502, {
|
||||
message: 'Falló la subida de la portada al almacenamiento.',
|
||||
detail: err instanceof Error ? err.message : undefined
|
||||
});
|
||||
}
|
||||
|
||||
newPictureIntentId = upload.intentId;
|
||||
}
|
||||
|
||||
const { error: err } = await api.PUT('/playlists/{playlistId}', {
|
||||
const result = await api.PUT('/playlists/{playlistId}', {
|
||||
params: { path: { playlistId: params.id } },
|
||||
body: { newName: name, newDescription: description, newPictureIntentId }
|
||||
});
|
||||
if (err) return fail(502, { message: 'No se pudo actualizar la playlist.' });
|
||||
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
|
||||
if (failure) return failure;
|
||||
return { renamed: true };
|
||||
},
|
||||
|
||||
delete: async ({ params, locals, fetch }) => {
|
||||
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const { error: err } = await api.DELETE('/playlists/{playlistId}', {
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.DELETE('/playlists/{playlistId}', {
|
||||
params: { path: { playlistId: params.id } }
|
||||
});
|
||||
if (err) return fail(502, { message: 'No se pudo eliminar la playlist.' });
|
||||
const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.');
|
||||
if (failure) return failure;
|
||||
redirect(303, '/playlists');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import SquarePause from '@lucide/svelte/icons/square-pause';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
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';
|
||||
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import PlaylistHeader from './components/PlaylistHeader.svelte';
|
||||
import PlaylistTrackTable from './components/PlaylistTrackTable.svelte';
|
||||
import SuggestionsTable from './components/SuggestionsTable.svelte';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -32,28 +17,14 @@
|
||||
|
||||
let editing = $state(false);
|
||||
let confirmingDelete = $state(false);
|
||||
let editCoverPreview = $state('');
|
||||
let editCoverFailed = $state(false);
|
||||
let savingEdit = $state(false);
|
||||
let youtubeSuggestions = $state<YouTubeSong[]>([]);
|
||||
|
||||
function openEdit() {
|
||||
editCoverPreview = '';
|
||||
editCoverFailed = false;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
$effect(() => {
|
||||
youtubeSuggestions = [];
|
||||
data.youtube.then((result) => {
|
||||
youtubeSuggestions = result.items;
|
||||
});
|
||||
});
|
||||
|
||||
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
|
||||
|
||||
@@ -62,18 +33,6 @@
|
||||
if (isCurrentQueue) player.toggle();
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -81,376 +40,54 @@
|
||||
<meta name="description" content="Playlist {playlist.name} en Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<section class="px-8 py-8">
|
||||
<a
|
||||
href="/playlists"
|
||||
class="group inline-flex items-center font-medium text-neutral-400 transition-colors hover:text-[var(--mf-accent)]"
|
||||
>
|
||||
<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}
|
||||
<Page>
|
||||
<PlaylistHeader
|
||||
id={playlist.id}
|
||||
name={playlist.name}
|
||||
description={playlist.description}
|
||||
trackIds={tracks.map((t) => t.id)}
|
||||
hue={hueFor(playlist.id)}
|
||||
size="large"
|
||||
class="h-44 w-44 flex-shrink-0 rounded-2xl shadow-[0_20px_45px_-15px_rgba(0,0,0,0.7)]"
|
||||
updatedAt={playlist.updatedAt}
|
||||
playing={isCurrentQueue && player.playing}
|
||||
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}
|
||||
<div class="mt-8">
|
||||
<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>
|
||||
<PlaylistTrackTable {tracks} />
|
||||
{/if}
|
||||
|
||||
{#if library.length > 0}
|
||||
<div class="mt-12">
|
||||
<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 library.length > 0 || youtubeSuggestions.length > 0}
|
||||
<SuggestionsTable {library} youtube={youtubeSuggestions} playlists={data.playlists} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<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)]"
|
||||
>
|
||||
</Page>
|
||||
|
||||
<Plus class="h-4 w-4" />
|
||||
</button>
|
||||
</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"
|
||||
<Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
|
||||
<PlaylistForm
|
||||
action="?/rename"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
savingEdit = true;
|
||||
return async ({ update, result }) => {
|
||||
await update({ reset: false });
|
||||
savingEdit = false;
|
||||
if (result.type === 'success') editing = false;
|
||||
};
|
||||
}}
|
||||
class="mt-4 w-full max-w-4xl space-y-4"
|
||||
>
|
||||
<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"
|
||||
initialName={playlist.name}
|
||||
initialDescription={playlist.description}
|
||||
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(playlist.updatedAt)}"
|
||||
formMessage={form?.message}
|
||||
submitLabel="Guardar"
|
||||
submittingLabel="Guardando…"
|
||||
onCancel={() => (editing = false)}
|
||||
onSuccess={() => (editing = false)}
|
||||
/>
|
||||
{#if editCoverPreview !== ''}
|
||||
<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>
|
||||
</Modal>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4">
|
||||
<div>
|
||||
<label for="edit-name" class="mb-2 block text-sm font-medium text-neutral-300">Nombre</label>
|
||||
<input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
type="text"
|
||||
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.
|
||||
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
|
||||
<h2 class="text-lg font-bold tracking-tight text-fg">
|
||||
¿Eliminar «{playlist.name}»?
|
||||
</h2>
|
||||
<p class="mt-2 text-base text-fg-3">
|
||||
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
|
||||
dispositivos donde la tengas guardada.
|
||||
</p>
|
||||
<div class="mt-6 flex items-center justify-end gap-3">
|
||||
<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>
|
||||
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
|
||||
<form method="POST" action="?/delete">
|
||||
<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>
|
||||
<Button type="submit" variant="danger">Eliminar</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
@@ -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 { fail } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
import { putPresigned } from '$lib/server/upload';
|
||||
import { createApiClient, requireAccessTokenAction, unwrapOrFail } from '$lib/server/api';
|
||||
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
|
||||
|
||||
const MAX_TITLE = 100;
|
||||
|
||||
const AUDIO_TYPES: Record<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 = {
|
||||
default: async ({ request, locals, fetch }) => {
|
||||
if (!locals.accessToken) {
|
||||
return fail(401, { message: 'Inicia sesión para subir música.' });
|
||||
}
|
||||
const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.');
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const form = await request.formData();
|
||||
const title = String(form.get('title') ?? '').trim();
|
||||
@@ -55,9 +27,9 @@ export const actions: Actions = {
|
||||
|
||||
const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
|
||||
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
|
||||
const { data: urls, error: urlsError } = await api.POST('/tracks/upload-urls', {
|
||||
const urlsResult = await api.POST('/tracks/upload-urls', {
|
||||
body: {
|
||||
pictureFileType: extOf(cover.name) || 'jpg',
|
||||
pictureContentType,
|
||||
@@ -68,9 +40,9 @@ export const actions: Actions = {
|
||||
}
|
||||
});
|
||||
|
||||
if (urlsError || !urls) {
|
||||
return fail(502, { message: 'No se pudieron reservar las URLs de subida.' });
|
||||
}
|
||||
const urlsFailure = unwrapOrFail(urlsResult, 'No se pudieron reservar las URLs de subida.');
|
||||
if (urlsFailure) return urlsFailure;
|
||||
const urls = urlsResult.data!;
|
||||
|
||||
try {
|
||||
const [audioBuffer, coverBuffer] = await Promise.all([
|
||||
@@ -88,7 +60,7 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
|
||||
const { data: track, error: createError } = await api.POST('/tracks', {
|
||||
const trackResult = await api.POST('/tracks', {
|
||||
body: {
|
||||
title,
|
||||
pictureIntentId: urls.pictureIntentId,
|
||||
@@ -96,9 +68,9 @@ export const actions: Actions = {
|
||||
}
|
||||
});
|
||||
|
||||
if (createError || !track) {
|
||||
return fail(502, { message: 'No se pudo crear la pista tras la subida.' });
|
||||
}
|
||||
const trackFailure = unwrapOrFail(trackResult, 'No se pudo crear la pista tras la subida.');
|
||||
if (trackFailure) return trackFailure;
|
||||
const track = trackResult.data!;
|
||||
|
||||
return { success: true, trackId: track.id, title: track.title };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import Surface from '$lib/components/ui/Surface.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Field from '$lib/components/ui/Field.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Checkbox from '$lib/components/ui/Checkbox.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
||||
type StepState = 'idle' | 'active' | 'done';
|
||||
@@ -41,15 +48,13 @@
|
||||
});
|
||||
|
||||
const dropClass = $derived(
|
||||
dragging || audioName !== ''
|
||||
? 'border-emerald-500/60'
|
||||
: 'border-white/15 hover:border-emerald-500/60'
|
||||
dragging || audioName !== '' ? 'border-accent/60' : 'border-line hover:border-accent/60'
|
||||
);
|
||||
|
||||
function stepClass(s: StepState) {
|
||||
if (s === 'active') return 'border-emerald-500/40 bg-emerald-500/5';
|
||||
if (s === 'done') return 'border-emerald-500/40';
|
||||
return 'border-white/15';
|
||||
if (s === 'active') return 'border-accent/40 bg-accent/5';
|
||||
if (s === 'done') return 'border-accent/40';
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
@@ -101,29 +106,19 @@
|
||||
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
|
||||
</svelte:head>
|
||||
|
||||
<section class="px-8 pt-8 pb-16 animate-[fade-up_0.45s_cubic-bezier(0.16,1,0.3,1)]">
|
||||
<a
|
||||
href="/library"
|
||||
class="group inline-flex items-center gap-2 text-sm font-medium text-neutral-400 transition-colors hover:text-white"
|
||||
>
|
||||
<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>
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="Subir música"
|
||||
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
||||
/>
|
||||
|
||||
<ol class="mt-10 grid gap-4 sm:grid-cols-3">
|
||||
{#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">
|
||||
<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'}
|
||||
<Check class="h-4 w-4" strokeWidth={2.5} />
|
||||
@@ -131,41 +126,31 @@
|
||||
{i + 1}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-base font-semibold">{step.label}</span>
|
||||
<span class="text-base font-semibold text-fg">{step.label}</span>
|
||||
</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>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
{#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
|
||||
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} />
|
||||
</span>
|
||||
<h2 class="mt-6 font-display text-2xl font-bold tracking-tight">¡Subida!</h2>
|
||||
<p class="mt-3 text-lg text-neutral-300">
|
||||
<h2 class="mt-6 text-2xl font-bold tracking-tight text-fg">¡Subida!</h2>
|
||||
<p class="mt-3 text-fg-2">
|
||||
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
|
||||
unos momentos.
|
||||
</p>
|
||||
<div class="mt-8 flex items-center justify-center gap-4">
|
||||
<a
|
||||
href="/library"
|
||||
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>
|
||||
<Button href="/library" size="lg">Ir a la biblioteca</Button>
|
||||
<Button variant="secondary" size="lg" onclick={reset}>Subir otra</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
@@ -197,7 +182,7 @@
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={onDrop}
|
||||
class="relative flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed bg-neutral-950 p-10 text-center transition {dropClass}"
|
||||
class="relative flex min-h-72 flex-col items-center justify-center rounded-panel border border-dashed bg-surface p-10 text-center transition {dropClass}"
|
||||
>
|
||||
<input
|
||||
bind:this={audioInput}
|
||||
@@ -208,7 +193,7 @@
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
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 === ''}
|
||||
<Headphones class="h-9 w-9" />
|
||||
{:else}
|
||||
@@ -216,21 +201,20 @@
|
||||
{/if}
|
||||
</span>
|
||||
{#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
|
||||
</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}
|
||||
<p class="mt-5 max-w-full truncate text-lg font-medium text-neutral-100">{audioName}</p>
|
||||
<p class="mt-2 text-sm text-neutral-500">{formatSize(audioSize)} · Listo para subir</p>
|
||||
<p class="mt-5 max-w-full truncate text-lg font-medium text-fg">{audioName}</p>
|
||||
<p class="mt-2 text-sm text-muted">{formatSize(audioSize)} · Listo para subir</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid items-start gap-8 sm:grid-cols-[auto_1fr]">
|
||||
<div>
|
||||
<span class="mb-3 block text-sm font-medium text-neutral-300">Portada</span>
|
||||
<Field label="Portada">
|
||||
<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
|
||||
type="file"
|
||||
@@ -243,86 +227,57 @@
|
||||
{#if coverPreview !== ''}
|
||||
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<ImageIcon class="h-12 w-12 text-neutral-500" />
|
||||
<ImageIcon class="h-12 w-12 text-muted" />
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<label for="title" class="mb-3 block text-sm font-medium text-neutral-300">Título</label>
|
||||
<input
|
||||
<Field label="Título" for="title">
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
maxlength={MAX_TITLE}
|
||||
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>
|
||||
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-3 text-sm text-neutral-300">
|
||||
<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>
|
||||
<Checkbox bind:checked={acceptedTerms}>
|
||||
Acepto los
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
|
||||
</span>
|
||||
</label>
|
||||
</Checkbox>
|
||||
|
||||
{#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">
|
||||
<span class="font-medium text-neutral-200">Subiendo y creando la pista…</span>
|
||||
<span class="text-neutral-500">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>
|
||||
<span class="font-medium text-fg">Subiendo y creando la pista…</span>
|
||||
<span class="text-muted">un momento</span>
|
||||
</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>
|
||||
</Surface>
|
||||
{/if}
|
||||
|
||||
{#if status === 'error'}
|
||||
<div class="rounded-xl border border-red-500/40 bg-red-500/10 p-5 text-sm text-red-200">
|
||||
{errorMsg}
|
||||
</div>
|
||||
<Alert tone="danger">{errorMsg}</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<a
|
||||
href="/explore"
|
||||
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>
|
||||
<Button href="/explore" variant="secondary" size="lg">Cancelar</Button>
|
||||
<Button type="submit" size="lg" disabled={!canPublish}>Publicar</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
</Page>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import '@fontsource-variable/manrope/index.css';
|
||||
import '@fontsource-variable/sora/index.css';
|
||||
import '@fontsource-variable/inter/index.css';
|
||||
import '$lib/theme/theme.css';
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import PlayerBar from '$lib/components/player/PlayerBar.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { ACCENT_LIGHTNESS, ACCENT_CHROMA, ACCENT_HUE } from '$lib/theme/color';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children, data } = $props();
|
||||
@@ -23,7 +23,7 @@
|
||||
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;
|
||||
|
||||
$effect(() => {
|
||||
@@ -45,7 +45,10 @@
|
||||
const c = from.c + (target.c - from.c) * e;
|
||||
const h = (((from.h + dh * e) % 360) + 360) % 360;
|
||||
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);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
@@ -55,11 +58,11 @@
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
|
||||
{#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()}
|
||||
</div>
|
||||
{: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!} />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<main class="flex-1 transition-[padding] {hasTrack ? 'pb-[92px]' : 'pb-0'}">
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
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 }) => {
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const latestPromise = api.GET('/tracks', {
|
||||
params: { query: { pageNumber: 1, pageSize: 12 } }
|
||||
params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
|
||||
});
|
||||
const playlistsPromise = locals.user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
@@ -22,20 +25,26 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
recentlyPlayedPromise
|
||||
]);
|
||||
const playlistItems = playlists.data?.items ?? [];
|
||||
const trackIds = await playlistCoverTrackIds(
|
||||
api,
|
||||
playlistItems.map((p) => p.id)
|
||||
);
|
||||
|
||||
const covers = await Promise.all(
|
||||
playlistItems.map(async (playlist) => {
|
||||
const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 4 } }
|
||||
});
|
||||
return [playlist.id, tracks?.items.map((t) => t.id) ?? []] as const;
|
||||
})
|
||||
const novedades = shuffle(
|
||||
(latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))
|
||||
);
|
||||
|
||||
return {
|
||||
latest: latest.data?.items ?? [],
|
||||
novedades,
|
||||
novedadesHasNext: Boolean(latest.data?.hasNextPage),
|
||||
youtubeFiller: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
|
||||
playlists: playlistItems,
|
||||
trackIds: Object.fromEntries(covers) as Record<string, string[]>,
|
||||
trackIds,
|
||||
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
Reference in New Issue
Block a user