Remove YouTube integration from backend and deployment config
Drops the YouTube-backed second catalog source entirely: the Local/External split on Track/Album collapses into single concrete entities, Artist/TrackArtist/ExternalTrack/ExternalAlbum and the YouTube service/download pipeline (yt-dlp, MassTransit consumer, routing slip) are deleted, and mixes now source discovery only from local listening history. Includes the RemoveYouTubeIntegration EF Core migration and matching Worker/deploy/compose cleanup (no more yt-dlp/python/deno in the Worker image, no thumbnail-cache/YtDlp config sections).
This commit is contained in:
@@ -17,7 +17,7 @@ namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var album = await database.UserAlbums
|
||||
var album = await database.Albums
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
||||
if (album is null)
|
||||
@@ -32,19 +32,16 @@ namespace Musify.Application.Albums
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
var trackExists = await database.Tracks
|
||||
var track = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||
if (!trackExists)
|
||||
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||
if (track is null)
|
||||
{
|
||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
||||
}
|
||||
|
||||
var localTrack = await database.LocalTracks
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||
if (localTrack is not null && localTrack.OwnerUserId != request.UserId)
|
||||
if (track.OwnerUserId != request.UserId)
|
||||
{
|
||||
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
|
||||
return Error.Unauthorized();
|
||||
|
||||
@@ -50,7 +50,7 @@ namespace Musify.Application.Albums
|
||||
var intent = pictureIntent.Value;
|
||||
var title = request.Title.Trim();
|
||||
|
||||
var album = new UserAlbum
|
||||
var album = new Album
|
||||
{
|
||||
OwnerUserId = request.UserId,
|
||||
Title = title,
|
||||
@@ -66,7 +66,7 @@ namespace Musify.Application.Albums
|
||||
}
|
||||
};
|
||||
|
||||
await database.UserAlbums.AddAsync(album, cancellationToken);
|
||||
await database.Albums.AddAsync(album, cancellationToken);
|
||||
|
||||
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
|
||||
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var album = await database.UserAlbums
|
||||
var album = await database.Albums
|
||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
||||
if (album is null)
|
||||
{
|
||||
@@ -30,7 +30,7 @@ namespace Musify.Application.Albums
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
database.UserAlbums.Remove(album);
|
||||
database.Albums.Remove(album);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var album = await database.UserAlbums
|
||||
var album = await database.Albums
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Id == request.Id)
|
||||
.Select(a => new
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
|
||||
@@ -29,9 +28,7 @@ namespace Musify.Application.Albums
|
||||
|
||||
var tracksQuery = database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(albumTrack => ((ExternalTrack)albumTrack.Track).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(albumTrack => ((LocalTrack)albumTrack.Track).Owner)
|
||||
.Include(albumTrack => albumTrack.Track.Owner)
|
||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Albums.Responses;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize, string? YoutubeContinuationToken = null)
|
||||
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
||||
: IQuery<ErrorOr<AlbumsSearchResponse>>;
|
||||
|
||||
public class GetAlbumsQueryHandler(
|
||||
IDatabase database,
|
||||
IYouTubeMusicService youTubeMusicService,
|
||||
ILogger<GetAlbumsQueryHandler> logger)
|
||||
public class GetAlbumsQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
|
||||
{
|
||||
// Mirrors GetTracksQueryHandler's threshold: a title shorter than this is too likely to
|
||||
// still be mid-keystroke to justify a live call to the external search API.
|
||||
private const int MinTitleLengthForYouTubeSearch = 2;
|
||||
|
||||
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var albumsQuery = database.Albums
|
||||
@@ -52,62 +42,19 @@ namespace Musify.Application.Albums
|
||||
})
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var localItems = pagedEntities
|
||||
var items = pagedEntities
|
||||
.Select(entry => AlbumSearchItemResponse.FromAlbum(
|
||||
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
|
||||
.ToList();
|
||||
|
||||
var knownExternalIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in pagedEntities)
|
||||
{
|
||||
if (entry.Album is ExternalAlbum { Source: TrackSource.YouTube } external)
|
||||
knownExternalIds.Add(external.ExternalId);
|
||||
}
|
||||
|
||||
var youtubeItems = new List<AlbumSearchItemResponse>();
|
||||
string? nextContinuationToken = null;
|
||||
var youtubeUnavailable = false;
|
||||
|
||||
if (request.Title?.Trim().Length >= MinTitleLengthForYouTubeSearch)
|
||||
(youtubeItems, nextContinuationToken, youtubeUnavailable) =
|
||||
await SearchYouTubeAsync(request.Title, request.YoutubeContinuationToken, knownExternalIds, cancellationToken);
|
||||
|
||||
return AlbumsSearchResponse.Create(
|
||||
[.. localItems, .. youtubeItems],
|
||||
return new AlbumsSearchResponse(
|
||||
items,
|
||||
pagedEntities.PageNumber,
|
||||
pagedEntities.PageSize,
|
||||
pagedEntities.PageCount,
|
||||
pagedEntities.TotalItemCount,
|
||||
pagedEntities.HasPreviousPage,
|
||||
pagedEntities.HasNextPage,
|
||||
nextContinuationToken,
|
||||
youtubeUnavailable);
|
||||
}
|
||||
|
||||
private async Task<(List<AlbumSearchItemResponse> Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync(
|
||||
string title,
|
||||
string? continuationToken,
|
||||
HashSet<string> knownExternalIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var searchResult = await youTubeMusicService.SearchAlbumsAsync(title, continuationToken ?? string.Empty, cancellationToken);
|
||||
if (searchResult.IsError)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"YouTube album search unavailable while combining album results: {Error}", searchResult.FirstError.Description);
|
||||
return ([], null, true);
|
||||
}
|
||||
|
||||
var items = searchResult.Value.Items
|
||||
.Where(album => knownExternalIds.Add(album.AlbumId))
|
||||
.Select(AlbumSearchItemResponse.FromYouTubeAlbum)
|
||||
.ToList();
|
||||
|
||||
var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken)
|
||||
? null
|
||||
: searchResult.Value.ContinuationToken;
|
||||
|
||||
return (items, nextToken, false);
|
||||
pagedEntities.HasNextPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace Musify.Application.Albums
|
||||
if (!userExists)
|
||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||
|
||||
var albumsQuery = database.UserAlbums
|
||||
var albumsQuery = database.Albums
|
||||
.AsNoTracking()
|
||||
.Where(album => album.OwnerUserId == request.UserId);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var album = await database.UserAlbums
|
||||
var album = await database.Albums
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
||||
if (album is null)
|
||||
|
||||
@@ -4,15 +4,6 @@ using Musify.Application.Serialization;
|
||||
|
||||
namespace Musify.Application.Albums.Responses
|
||||
{
|
||||
// [JsonPolymorphic]/[JsonDerivedType] are required here, not optional: without them
|
||||
// System.Text.Json serializes strictly by the declared CLR type at each call site
|
||||
// (AlbumApplicationResponse, both as a bare return type and nested inside
|
||||
// AlbumSearchItemResponse.Album), so an ExternalAlbumApplicationResponse instance would
|
||||
// silently lose ExternalId/ThumbnailUrl on the wire. This also gives every response a
|
||||
// "source" discriminator field for free, matching AlbumSearchItemResponse.Source.
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "source")]
|
||||
[JsonDerivedType(typeof(AlbumApplicationResponse), typeDiscriminator: "Local")]
|
||||
[JsonDerivedType(typeof(ExternalAlbumApplicationResponse), typeDiscriminator: "YouTube")]
|
||||
public record AlbumApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
@@ -30,7 +21,7 @@ namespace Musify.Application.Albums.Responses
|
||||
public const int CoverTrackCount = 4;
|
||||
|
||||
public static AlbumApplicationResponse FromEntity(
|
||||
UserAlbum album,
|
||||
Album album,
|
||||
int trackCount,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||
{
|
||||
@@ -48,18 +39,5 @@ namespace Musify.Application.Albums.Responses
|
||||
album.UpdatedAt,
|
||||
coverTrackIds ?? []);
|
||||
}
|
||||
|
||||
public static AlbumApplicationResponse FromEntity(
|
||||
Album album,
|
||||
int trackCount,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||
{
|
||||
return album switch
|
||||
{
|
||||
ExternalAlbum externalAlbum => ExternalAlbumApplicationResponse.FromEntity(externalAlbum, trackCount, coverTrackIds),
|
||||
UserAlbum userAlbum => FromEntity(userAlbum, trackCount, coverTrackIds),
|
||||
_ => throw new NotSupportedException($"Unsupported album entity type '{album.GetType().Name}'.")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Albums.Responses
|
||||
{
|
||||
/// <summary>One item of a combined local/external album listing. Exactly one of
|
||||
/// <see cref="Album"/> or <see cref="YouTubeAlbum"/> is populated: <see cref="Album"/> for
|
||||
/// anything already in the catalog (locally created albums and external albums materialized
|
||||
/// earlier), <see cref="YouTubeAlbum"/> for a live external search hit that hasn't been
|
||||
/// materialized yet, so it has no album id.</summary>
|
||||
public record AlbumSearchItemResponse(
|
||||
TrackSource Source,
|
||||
AlbumApplicationResponse? Album,
|
||||
YouTubeAlbumResult? YouTubeAlbum)
|
||||
public record AlbumSearchItemResponse(AlbumApplicationResponse Album)
|
||||
{
|
||||
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) =>
|
||||
new(album is ExternalAlbumApplicationResponse ? TrackSource.YouTube : TrackSource.Local, album, null);
|
||||
|
||||
public static AlbumSearchItemResponse FromYouTubeAlbum(YouTubeAlbumResult album) =>
|
||||
new(TrackSource.YouTube, null, album);
|
||||
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
namespace Musify.Application.Albums.Responses
|
||||
{
|
||||
/// <summary>Result of a combined albums listing/search: a page of local catalog albums
|
||||
/// (user-created and previously materialized external albums), plus, when a search term was
|
||||
/// given, live external hits appended after them. Pagination metadata (<see cref="PageNumber"/>,
|
||||
/// <see cref="PageSize"/>, <see cref="PageCount"/>, <see cref="TotalItemCount"/>) describes only
|
||||
/// the local page — the external source has no stable total, so its continuation is tracked
|
||||
/// separately via <see cref="NextYoutubeContinuationToken"/>.</summary>
|
||||
/// <summary>A page of the user's catalog albums (their own).</summary>
|
||||
public record AlbumsSearchResponse(
|
||||
IReadOnlyList<AlbumSearchItemResponse> Items,
|
||||
int PageNumber,
|
||||
@@ -13,26 +8,5 @@ namespace Musify.Application.Albums.Responses
|
||||
int PageCount,
|
||||
int TotalItemCount,
|
||||
bool HasPreviousPage,
|
||||
bool HasNextLocalPage,
|
||||
string? NextYoutubeContinuationToken,
|
||||
bool YoutubeUnavailable,
|
||||
bool HasNextPage)
|
||||
{
|
||||
// HasNextPage is a real field (not a computed property) so it shows up as a required member
|
||||
// of the generated OpenAPI schema, same as every other field here — a get-only property added
|
||||
// outside the primary constructor gets reflected as optional instead, which is never true here.
|
||||
public static AlbumsSearchResponse Create(
|
||||
IReadOnlyList<AlbumSearchItemResponse> items,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
int pageCount,
|
||||
int totalItemCount,
|
||||
bool hasPreviousPage,
|
||||
bool hasNextLocalPage,
|
||||
string? nextYoutubeContinuationToken,
|
||||
bool youtubeUnavailable) =>
|
||||
new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage,
|
||||
nextYoutubeContinuationToken, youtubeUnavailable,
|
||||
HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken));
|
||||
}
|
||||
bool HasNextPage);
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Albums.Responses
|
||||
{
|
||||
public record ExternalAlbumApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear,
|
||||
long OwnerUserId,
|
||||
int TrackCount,
|
||||
string? SmallImageKeyName,
|
||||
string? MediumImageKeyName,
|
||||
string? LargeImageKeyName,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<Guid> CoverTrackIds,
|
||||
string ExternalId,
|
||||
string? ThumbnailUrl)
|
||||
: AlbumApplicationResponse(
|
||||
Id, Title, Description, ReleaseYear, OwnerUserId, TrackCount,
|
||||
SmallImageKeyName, MediumImageKeyName, LargeImageKeyName,
|
||||
CreatedAt, UpdatedAt, CoverTrackIds)
|
||||
{
|
||||
public static ExternalAlbumApplicationResponse FromEntity(
|
||||
ExternalAlbum album,
|
||||
int trackCount,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||
{
|
||||
return new ExternalAlbumApplicationResponse(
|
||||
album.Id,
|
||||
album.Title,
|
||||
album.Description,
|
||||
album.ReleaseYear,
|
||||
0,
|
||||
trackCount,
|
||||
album.Pictures?.SmallName,
|
||||
album.Pictures?.MediumName,
|
||||
album.Pictures?.LargeName,
|
||||
album.CreatedAt,
|
||||
album.UpdatedAt,
|
||||
coverTrackIds ?? [],
|
||||
album.ExternalId,
|
||||
album.ThumbnailUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var album = await database.UserAlbums
|
||||
var album = await database.Albums
|
||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
||||
if (album is null)
|
||||
{
|
||||
@@ -77,7 +77,7 @@ namespace Musify.Application.Albums
|
||||
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||
}
|
||||
|
||||
database.UserAlbums.Update(album);
|
||||
database.Albums.Update(album);
|
||||
|
||||
if (pictureIntent != null && finalPictureKey != null)
|
||||
{
|
||||
|
||||
@@ -12,7 +12,6 @@ public static class ApplicationDependencyInjection
|
||||
services.AddMediator();
|
||||
services.AddScoped<UploadIntentValidator>();
|
||||
services.AddScoped<TrackStreamIssuer>();
|
||||
services.AddScoped<YouTubeTrackProvisioner>();
|
||||
|
||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
||||
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
|
||||
|
||||
@@ -6,16 +6,7 @@ namespace Musify.Application.Configuration
|
||||
{
|
||||
public const string SectionName = "Mix";
|
||||
|
||||
[Range(1, 20)]
|
||||
public int ArtistMixCount { get; set; } = 3;
|
||||
|
||||
[Range(4, 100)]
|
||||
public int ItemsPerMix { get; set; } = 12;
|
||||
|
||||
[Range(1, 10)]
|
||||
public int SeedArtistCount { get; set; } = 3;
|
||||
|
||||
[Range(1, 100)]
|
||||
public int YouTubeResultsPerSeed { get; set; } = 12;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,22 +11,10 @@ namespace Musify.Application.Contracts
|
||||
|
||||
DbSet<Album> Albums { get; }
|
||||
|
||||
DbSet<UserAlbum> UserAlbums { get; }
|
||||
|
||||
DbSet<ExternalAlbum> ExternalAlbums { get; }
|
||||
|
||||
DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
||||
|
||||
DbSet<Track> Tracks { get; }
|
||||
|
||||
DbSet<LocalTrack> LocalTracks { get; }
|
||||
|
||||
DbSet<ExternalTrack> ExternalTracks { get; }
|
||||
|
||||
DbSet<Artist> Artists { get; }
|
||||
|
||||
DbSet<TrackArtist> TrackArtists { get; }
|
||||
|
||||
DbSet<UserHasTrack> UserHasTracks { get; }
|
||||
|
||||
DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public record YouTubeDownloadResult(int ExitCode, string StandardError);
|
||||
|
||||
public interface IYouTubeDownloaderService
|
||||
{
|
||||
Task<YouTubeDownloadResult> DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using ErrorOr;
|
||||
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public record YouTubeArtistRef(string? Id, string Name);
|
||||
|
||||
public record YouTubeSongResult(
|
||||
string VideoId,
|
||||
string Title,
|
||||
string Artist,
|
||||
string Album,
|
||||
int DurationSeconds,
|
||||
string ThumbnailUrl,
|
||||
bool IsExplicit,
|
||||
IReadOnlyList<YouTubeArtistRef> Artists);
|
||||
|
||||
public record YouTubeSearchResult(
|
||||
IReadOnlyList<YouTubeSongResult> Items,
|
||||
string ContinuationToken);
|
||||
|
||||
public record YouTubeAlbumResult(
|
||||
string AlbumId,
|
||||
string Title,
|
||||
string Artist,
|
||||
string ThumbnailUrl,
|
||||
int? ReleaseYear,
|
||||
bool IsSingle,
|
||||
bool IsEp,
|
||||
IReadOnlyList<YouTubeArtistRef> Artists);
|
||||
|
||||
public record YouTubeAlbumSearchResult(
|
||||
IReadOnlyList<YouTubeAlbumResult> Items,
|
||||
string ContinuationToken);
|
||||
|
||||
public record YouTubeAlbumTrack(
|
||||
string VideoId,
|
||||
string Title,
|
||||
int DurationSeconds,
|
||||
int TrackNumber,
|
||||
bool IsExplicit);
|
||||
|
||||
public record YouTubeAlbumDetail(
|
||||
YouTubeAlbumResult Album,
|
||||
string Description,
|
||||
int TotalDurationSeconds,
|
||||
IReadOnlyList<YouTubeAlbumTrack> Tracks);
|
||||
|
||||
public record YouTubeStreamInfo(string Url, int ExpiresInSeconds);
|
||||
|
||||
public interface IYouTubeMusicService
|
||||
{
|
||||
Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken);
|
||||
|
||||
Task<ErrorOr<YouTubeAlbumSearchResult>> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken);
|
||||
|
||||
Task<ErrorOr<YouTubeAlbumDetail>> GetAlbumAsync(string albumId, CancellationToken cancellationToken);
|
||||
|
||||
Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken);
|
||||
|
||||
Task<ErrorOr<YouTubeSongResult>> GetSongAsync(string videoId, CancellationToken cancellationToken);
|
||||
|
||||
string ResolveArtworkUrl(string thumbnailUrl);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record DownloadYouTubeTrackEvent(
|
||||
Guid TrackId,
|
||||
string VideoId,
|
||||
string ThumbnailUrl,
|
||||
string Bucket,
|
||||
string AudioProcessedFolderKey,
|
||||
ImageSize Small,
|
||||
ImageSize Medium,
|
||||
ImageSize Large);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
|
||||
@@ -23,9 +22,7 @@ namespace Musify.Application.Likes
|
||||
{
|
||||
var likesQuery = database.TrackLikes
|
||||
.AsNoTracking()
|
||||
.Include(like => ((ExternalTrack)like.Track).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(like => ((LocalTrack)like.Track).Owner)
|
||||
.Include(like => like.Track.Owner)
|
||||
.Where(like => like.UserId == request.UserId);
|
||||
|
||||
var totalCount = await likesQuery.CountAsync(cancellationToken);
|
||||
|
||||
@@ -3,35 +3,31 @@ using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Likes
|
||||
{
|
||||
public record ToggleTrackLikeCommand(
|
||||
long UserId,
|
||||
Guid? TrackId,
|
||||
TrackSource? Source,
|
||||
string? ExternalId)
|
||||
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
|
||||
: ICommand<ErrorOr<bool>>;
|
||||
|
||||
public class ToggleTrackLikeCommandHandler(
|
||||
IDatabase database,
|
||||
YouTubeTrackProvisioner provisioner,
|
||||
ILogger<ToggleTrackLikeCommandHandler> logger)
|
||||
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var trackIdResult = await ResolveTrackIdAsync(request, cancellationToken);
|
||||
if (trackIdResult.IsError)
|
||||
return trackIdResult.Errors;
|
||||
|
||||
var trackId = trackIdResult.Value;
|
||||
var trackExists = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||
if (!trackExists)
|
||||
{
|
||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
||||
}
|
||||
|
||||
var existingLike = await database.TrackLikes
|
||||
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == trackId, cancellationToken);
|
||||
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
||||
|
||||
bool nowLiked;
|
||||
if (existingLike is not null)
|
||||
@@ -44,7 +40,7 @@ namespace Musify.Application.Likes
|
||||
await database.TrackLikes.AddAsync(new TrackLike
|
||||
{
|
||||
UserId = request.UserId,
|
||||
TrackId = trackId
|
||||
TrackId = request.TrackId
|
||||
}, cancellationToken);
|
||||
nowLiked = true;
|
||||
}
|
||||
@@ -52,32 +48,9 @@ namespace Musify.Application.Likes
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
||||
request.UserId, nowLiked ? "liked" : "unliked", trackId);
|
||||
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
||||
|
||||
return nowLiked;
|
||||
}
|
||||
|
||||
private async ValueTask<ErrorOr<Guid>> ResolveTrackIdAsync(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.TrackId.HasValue)
|
||||
{
|
||||
var trackExists = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(t => t.Id == request.TrackId.Value, cancellationToken);
|
||||
if (!trackExists)
|
||||
{
|
||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
||||
}
|
||||
|
||||
return request.TrackId.Value;
|
||||
}
|
||||
|
||||
var provisionResult = await provisioner.GetOrCreateAsync(request.ExternalId!, cancellationToken);
|
||||
if (provisionResult.IsError)
|
||||
return provisionResult.Errors;
|
||||
|
||||
return provisionResult.Value.Track.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
@@ -13,28 +12,35 @@ namespace Musify.Application.Mixes
|
||||
public record GenerateMixesForUserCommand(long UserId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
/// <summary>Builds each user's mixes purely from their own library: a "Descubrimiento" mix of
|
||||
/// tracks they own but haven't listened to yet, and a "Tu mezcla diaria" mix blending recently
|
||||
/// listened tracks with unheard ones. There's no artist/genre metadata for local uploads today,
|
||||
/// so mixes can't be seeded by "similar artist" the way the old YouTube-backed discovery did —
|
||||
/// once a tagging system exists, this is the place to seed mixes by tag instead.</summary>
|
||||
public class GenerateMixesForUserCommandHandler(
|
||||
IDatabase database,
|
||||
IYouTubeMusicService youTubeMusicService,
|
||||
MixConfiguration mixConfiguration,
|
||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
||||
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
|
||||
{
|
||||
private sealed record SeedArtist(Guid Id, string Name);
|
||||
|
||||
private sealed record LocalCandidate(Guid TrackId, string Title, string? Artist, double DurationSeconds);
|
||||
|
||||
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<MixItem> Items);
|
||||
|
||||
private const int TitleLength = 300;
|
||||
private const int ArtistLength = 300;
|
||||
private const int ThumbnailUrlLength = 512;
|
||||
private const int MixTitleLength = 100;
|
||||
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
|
||||
|
||||
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = request.UserId;
|
||||
|
||||
var libraryTrackIds = await database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(userTrack => userTrack.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (libraryTrackIds.Count == 0)
|
||||
{
|
||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
||||
return new Success();
|
||||
}
|
||||
|
||||
var historyTrackIds = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(history => history.UserId == userId)
|
||||
@@ -42,33 +48,29 @@ namespace Musify.Application.Mixes
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (historyTrackIds.Count == 0)
|
||||
var historySet = new HashSet<Guid>(historyTrackIds);
|
||||
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
|
||||
|
||||
var drafts = new List<MixDraft>();
|
||||
|
||||
var discovery = Take(Shuffle(unheardTrackIds));
|
||||
if (discovery.Count > 0)
|
||||
{
|
||||
logger.LogInformation("User {UserId} has no listening history yet, skipping mix generation", userId);
|
||||
return new Success();
|
||||
drafts.Add(new MixDraft(
|
||||
"Descubrimiento",
|
||||
"Canciones de tu biblioteca que todavía no has escuchado.",
|
||||
discovery));
|
||||
}
|
||||
|
||||
var seedArtists = await GetSeedArtistsAsync(historyTrackIds, cancellationToken);
|
||||
|
||||
if (seedArtists.Count == 0)
|
||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
||||
if (daily.Count > 0)
|
||||
{
|
||||
logger.LogInformation("User {UserId} has no artists to seed mixes from, skipping mix generation", userId);
|
||||
return new Success();
|
||||
drafts.Add(new MixDraft(
|
||||
"Tu mezcla diaria",
|
||||
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
|
||||
daily));
|
||||
}
|
||||
|
||||
var knownVideoIds = await database.ExternalTracks
|
||||
.AsNoTracking()
|
||||
.Where(track => track.Source == TrackSource.YouTube && historyTrackIds.Contains(track.Id))
|
||||
.Select(track => track.ExternalId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var excludedVideoIds = new HashSet<string>(knownVideoIds, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var youTubeByArtist = await SearchYouTubeAsync(seedArtists, excludedVideoIds, cancellationToken);
|
||||
var localByArtist = await GetLocalCandidatesAsync(seedArtists, historyTrackIds, cancellationToken);
|
||||
|
||||
var drafts = BuildDrafts(seedArtists, localByArtist, youTubeByArtist);
|
||||
|
||||
if (drafts.Count == 0)
|
||||
{
|
||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
||||
@@ -91,197 +93,21 @@ namespace Musify.Application.Mixes
|
||||
return new Success();
|
||||
}
|
||||
|
||||
private async Task<List<SeedArtist>> GetSeedArtistsAsync(
|
||||
IReadOnlyList<Guid> historyTrackIds,
|
||||
CancellationToken cancellationToken)
|
||||
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
||||
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
|
||||
|
||||
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
||||
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
||||
|
||||
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
|
||||
{
|
||||
var ranked = await database.TrackArtists
|
||||
.AsNoTracking()
|
||||
.Where(trackArtist => historyTrackIds.Contains(trackArtist.TrackId))
|
||||
.GroupBy(trackArtist => trackArtist.ArtistId)
|
||||
.Select(group => new { ArtistId = group.Key, Listens = group.Count() } )
|
||||
.OrderByDescending(entry => entry.Listens)
|
||||
.Take(mixConfiguration.SeedArtistCount)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var artistIds = ranked.Select(entry => entry.ArtistId).ToList();
|
||||
|
||||
var names = await database.Artists
|
||||
.AsNoTracking()
|
||||
.Where(artist => artistIds.Contains(artist.Id))
|
||||
.ToDictionaryAsync(artist => artist.Id, artist => artist.Name, cancellationToken);
|
||||
|
||||
return [.. ranked
|
||||
.Where(entry => names.ContainsKey(entry.ArtistId))
|
||||
.Select(entry => new SeedArtist(entry.ArtistId, names[entry.ArtistId])) ];
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, List<MixItem>> > SearchYouTubeAsync(
|
||||
IReadOnlyList<SeedArtist> seedArtists,
|
||||
HashSet<string> excludedVideoIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var byArtist = new Dictionary<Guid, List<MixItem>>();
|
||||
|
||||
foreach (var seed in seedArtists)
|
||||
{
|
||||
var searchResult = await youTubeMusicService.SearchSongsAsync(seed.Name, string.Empty, cancellationToken);
|
||||
if (searchResult.IsError)
|
||||
{
|
||||
logger.LogWarning("YouTube Music search failed for seed artist {Artist}: {Error}",
|
||||
seed.Name, searchResult.FirstError.Description);
|
||||
byArtist[seed.Id] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
var songs = new List<MixItem>();
|
||||
foreach (var song in searchResult.Value.Items.Take(mixConfiguration.YouTubeResultsPerSeed))
|
||||
{
|
||||
if (!excludedVideoIds.Add(song.VideoId))
|
||||
continue;
|
||||
|
||||
songs.Add(new MixItem
|
||||
{
|
||||
MixId = Guid.Empty,
|
||||
Source = MixItemSource.YouTube,
|
||||
VideoId = song.VideoId,
|
||||
Title = Truncate(song.Title, TitleLength),
|
||||
Artist = Truncate(song.Artist, ArtistLength),
|
||||
ThumbnailUrl = Truncate(youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl), ThumbnailUrlLength),
|
||||
DurationSeconds = song.DurationSeconds,
|
||||
IsExplicit = song.IsExplicit
|
||||
} );
|
||||
}
|
||||
|
||||
byArtist[seed.Id] = songs;
|
||||
}
|
||||
|
||||
return byArtist;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, List<MixItem>> > GetLocalCandidatesAsync(
|
||||
IReadOnlyList<SeedArtist> seedArtists,
|
||||
IReadOnlyList<Guid> historyTrackIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var artistIds = seedArtists.Select(seed => seed.Id).ToList();
|
||||
|
||||
var links = await database.TrackArtists
|
||||
.AsNoTracking()
|
||||
.Where(trackArtist => artistIds.Contains(trackArtist.ArtistId) && !historyTrackIds.Contains(trackArtist.TrackId))
|
||||
.Select(trackArtist => new { trackArtist.ArtistId, trackArtist.TrackId } )
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var trackIds = links.Select(link => link.TrackId).Distinct().ToList();
|
||||
|
||||
var candidates = await LoadCandidatesAsync(trackIds, cancellationToken);
|
||||
|
||||
var byArtist = new Dictionary<Guid, List<MixItem>>();
|
||||
foreach (var seed in seedArtists)
|
||||
{
|
||||
byArtist[seed.Id] = [.. links
|
||||
.Where(link => link.ArtistId == seed.Id)
|
||||
.Select(link => candidates.GetValueOrDefault(link.TrackId))
|
||||
.Where(candidate => candidate is not null)
|
||||
.Select(candidate => ToMixItem(candidate!))];
|
||||
}
|
||||
|
||||
return byArtist;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, LocalCandidate>> LoadCandidatesAsync(
|
||||
IReadOnlyList<Guid> trackIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (trackIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var tracks = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Include(track => ((ExternalTrack)track).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(track => ((LocalTrack)track).Owner)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return tracks
|
||||
.Select(track => TrackApplicationResponse.FromEntity(track, 0))
|
||||
.ToDictionary(
|
||||
response => response.Id,
|
||||
response => new LocalCandidate(response.Id, response.Title, response.Artist, response.Duration));
|
||||
}
|
||||
|
||||
private static MixItem ToMixItem(LocalCandidate candidate) => new()
|
||||
{
|
||||
MixId = Guid.Empty,
|
||||
Source = MixItemSource.Musify,
|
||||
TrackId = candidate.TrackId,
|
||||
Title = candidate.Title,
|
||||
Artist = candidate.Artist,
|
||||
DurationSeconds = candidate.DurationSeconds
|
||||
};
|
||||
|
||||
private List<MixDraft> BuildDrafts(
|
||||
IReadOnlyList<SeedArtist> seedArtists,
|
||||
IReadOnlyDictionary<Guid, List<MixItem>> localByArtist,
|
||||
IReadOnlyDictionary<Guid, List<MixItem>> youTubeByArtist)
|
||||
{
|
||||
var drafts = new List<MixDraft>();
|
||||
|
||||
var everyYouTube = seedArtists.SelectMany(seed => youTubeByArtist[seed.Id]).ToList();
|
||||
var everyLocal = seedArtists.SelectMany(seed => localByArtist[seed.Id]).ToList();
|
||||
|
||||
var discovery = Take(Shuffle(everyYouTube));
|
||||
if (discovery.Count > 0)
|
||||
{
|
||||
drafts.Add(new MixDraft(
|
||||
"Descubrimiento",
|
||||
"Canciones nuevas que todavía no has escuchado.",
|
||||
discovery));
|
||||
}
|
||||
|
||||
var daily = Take(Interleave(Shuffle(everyLocal), Shuffle(everyYouTube)));
|
||||
if (daily.Count > 0)
|
||||
{
|
||||
drafts.Add(new MixDraft(
|
||||
"Tu mezcla diaria",
|
||||
"Lo que más escuchas, con alguna sorpresa nueva.",
|
||||
daily));
|
||||
}
|
||||
|
||||
foreach (var seed in seedArtists.Take(mixConfiguration.ArtistMixCount))
|
||||
{
|
||||
var items = Take(Interleave(Shuffle(localByArtist[seed.Id]), Shuffle(youTubeByArtist[seed.Id])));
|
||||
if (items.Count == 0)
|
||||
continue;
|
||||
|
||||
drafts.Add(new MixDraft(
|
||||
Truncate(seed.Name, MixTitleLength),
|
||||
$"Canciones de {seed.Name} y de artistas parecidos.",
|
||||
items));
|
||||
}
|
||||
|
||||
return drafts;
|
||||
}
|
||||
|
||||
private List<MixItem> Take(IEnumerable<MixItem> items) =>
|
||||
[.. items.Take(mixConfiguration.ItemsPerMix)];
|
||||
|
||||
private static string Truncate(string value, int maxLength) =>
|
||||
value.Length <= maxLength ? value : value[..maxLength];
|
||||
|
||||
private static List<MixItem> Shuffle(IEnumerable<MixItem> items) =>
|
||||
[.. items.OrderBy(_ => Guid.NewGuid())];
|
||||
|
||||
private static List<MixItem> Interleave(IReadOnlyList<MixItem> first, IReadOnlyList<MixItem> second)
|
||||
{
|
||||
var merged = new List<MixItem>(first.Count + second.Count);
|
||||
var merged = new List<Guid>(first.Count + second.Count);
|
||||
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
|
||||
{
|
||||
if (index < first.Count)
|
||||
merged.Add(first[index] );
|
||||
merged.Add(first[index]);
|
||||
if (index < second.Count)
|
||||
merged.Add(second[index] );
|
||||
merged.Add(second[index]);
|
||||
}
|
||||
|
||||
return merged;
|
||||
@@ -317,21 +143,13 @@ namespace Musify.Application.Mixes
|
||||
|
||||
await database.Mixes.AddAsync(mix, cancellationToken);
|
||||
|
||||
for (var itemPosition = 0; itemPosition < draft.Items.Count; itemPosition++)
|
||||
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
|
||||
{
|
||||
var item = draft.Items[itemPosition];
|
||||
await database.MixItems.AddAsync(new MixItem
|
||||
{
|
||||
MixId = mix.Id,
|
||||
Position = itemPosition,
|
||||
Source = item.Source,
|
||||
TrackId = item.TrackId,
|
||||
VideoId = item.VideoId,
|
||||
Title = item.Title,
|
||||
Artist = item.Artist,
|
||||
ThumbnailUrl = item.ThumbnailUrl,
|
||||
DurationSeconds = item.DurationSeconds,
|
||||
IsExplicit = item.IsExplicit
|
||||
TrackId = draft.TrackIds[itemPosition]
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,10 @@ namespace Musify.Application.Mixes
|
||||
if (mix is null)
|
||||
return Error.NotFound(description: $"Mix {request.MixId} was not found");
|
||||
|
||||
var items = mix.Items.OrderBy(item => item.Position).ToList();
|
||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
||||
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
||||
|
||||
return MixApplicationResponse.FromEntity(mix, items.Count, items);
|
||||
return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,24 +14,24 @@ namespace Musify.Application.Mixes
|
||||
{
|
||||
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = await database.Mixes
|
||||
var mixes = await database.Mixes
|
||||
.AsNoTracking()
|
||||
.Where(mix => mix.UserId == request.UserId)
|
||||
.Include(mix => mix.Items)
|
||||
.OrderBy(mix => mix.Position)
|
||||
.Select(mix => new
|
||||
{
|
||||
Mix = mix,
|
||||
ItemCount = mix.Items.Count,
|
||||
CoverItems = mix.Items
|
||||
.OrderBy(item => item.Position)
|
||||
.Take(MixApplicationResponse.CoverItemCount)
|
||||
.ToList()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entries
|
||||
.Select(entry => MixApplicationResponse.FromEntity(entry.Mix, entry.ItemCount, entry.CoverItems))
|
||||
.ToList();
|
||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
||||
foreach (var mix in mixes)
|
||||
{
|
||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
||||
var coverItems = await MixItemMapper.ToResponsesAsync(
|
||||
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
|
||||
|
||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Mixes.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Mixes
|
||||
{
|
||||
/// <summary>Resolves MixItem rows to their MixItemApplicationResponse shape via a single batched
|
||||
/// track lookup, instead of an EF Include chain deep enough (Mix -> Items -> Track -> Owner) to
|
||||
/// force a correlated-subquery translation some providers (SQLite, notably) can't produce.</summary>
|
||||
internal static class MixItemMapper
|
||||
{
|
||||
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
||||
IDatabase database,
|
||||
IReadOnlyList<MixItem> orderedItems,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
|
||||
|
||||
var tracksById = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Include(track => track.Owner)
|
||||
.Where(track => trackIds.Contains(track.Id))
|
||||
.ToDictionaryAsync(track => track.Id, cancellationToken);
|
||||
|
||||
return orderedItems
|
||||
.Where(item => tracksById.ContainsKey(item.TrackId))
|
||||
.Select(item =>
|
||||
{
|
||||
var track = tracksById[item.TrackId];
|
||||
return new MixItemApplicationResponse(item.TrackId, track.Title, track.Owner.Name, track.DurationSeconds);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Mixes.Responses
|
||||
{
|
||||
public record MixApplicationResponse(
|
||||
@@ -10,16 +8,5 @@ namespace Musify.Application.Mixes.Responses
|
||||
IReadOnlyList<MixItemApplicationResponse> Items)
|
||||
{
|
||||
public const int CoverItemCount = 4;
|
||||
|
||||
public static MixApplicationResponse FromEntity(
|
||||
Mix mix,
|
||||
int itemCount,
|
||||
IReadOnlyList<MixItem> items) =>
|
||||
new(
|
||||
mix.Id,
|
||||
mix.Title,
|
||||
mix.Subtitle,
|
||||
itemCount,
|
||||
[.. items.Select(MixItemApplicationResponse.FromEntity)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Mixes.Responses
|
||||
{
|
||||
public record MixItemApplicationResponse(
|
||||
MixItemSource Source,
|
||||
Guid? TrackId,
|
||||
string? VideoId,
|
||||
Guid TrackId,
|
||||
string Title,
|
||||
string? Artist,
|
||||
string? ThumbnailUrl,
|
||||
double DurationSeconds,
|
||||
bool IsExplicit)
|
||||
{
|
||||
public static MixItemApplicationResponse FromEntity(MixItem item) =>
|
||||
new(
|
||||
item.Source,
|
||||
item.TrackId,
|
||||
item.VideoId,
|
||||
item.Title ?? string.Empty,
|
||||
item.Artist,
|
||||
item.ThumbnailUrl,
|
||||
item.DurationSeconds,
|
||||
item.IsExplicit);
|
||||
}
|
||||
double DurationSeconds);
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public record AddYouTubeTrackToPlayListCommand(
|
||||
long UserId,
|
||||
Guid PlayListId,
|
||||
string VideoId)
|
||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
||||
|
||||
public class AddYouTubeTrackToPlayListCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
IYouTubeMusicService youTubeMusicService,
|
||||
YouTubeTrackProvisioner provisioner,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
TrackConfiguration trackConfiguration,
|
||||
ILogger<AddYouTubeTrackToPlayListCommandHandler> logger)
|
||||
: ICommandHandler<AddYouTubeTrackToPlayListCommand, ErrorOr<TrackApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists.AsNoTracking()
|
||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||
if (playList is null)
|
||||
{
|
||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
if (playList.UserId != request.UserId)
|
||||
{
|
||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken);
|
||||
if (provisionResult.IsError)
|
||||
return provisionResult.Errors;
|
||||
|
||||
var track = provisionResult.Value.Track;
|
||||
|
||||
var failed = track.Audio.IsFailed;
|
||||
var neverQueued = !track.Audio.DownloadRequested && !track.Audio.IsProcessed;
|
||||
if (failed || neverQueued)
|
||||
{
|
||||
if (failed)
|
||||
{
|
||||
track.Audio.TranscodeStatus = ProcessingStatus.Pending;
|
||||
track.Pictures.ProcessingStatus = ProcessingStatus.Pending;
|
||||
track.LifeCycleStatus = LifeCycleStatus.Active;
|
||||
track.Audio.RetryCount += 1;
|
||||
track.Audio.LastRetryAt = DateTime.UtcNow;
|
||||
}
|
||||
track.Audio.DownloadRequested = true;
|
||||
|
||||
var thumbnailUrl = await ResolveThumbnailUrlAsync(request.VideoId, provisionResult.Value.Song, cancellationToken);
|
||||
if (thumbnailUrl.IsError)
|
||||
return thumbnailUrl.Errors;
|
||||
|
||||
await PublishDownloadEventAsync(track.Id, request.VideoId, thumbnailUrl.Value, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId);
|
||||
}
|
||||
|
||||
var alreadyAdded = await database.PlayListHasTracks.AsNoTracking()
|
||||
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken);
|
||||
if (alreadyAdded)
|
||||
{
|
||||
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", track.Id, request.PlayListId);
|
||||
return Error.Conflict(description: "Track is already in the playlist.");
|
||||
}
|
||||
|
||||
var playListTracksQuery = database.PlayListHasTracks
|
||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
||||
|
||||
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
|
||||
var nextPosition = hasTracks
|
||||
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
|
||||
: 0;
|
||||
|
||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||
{
|
||||
PlayListId = request.PlayListId,
|
||||
TrackId = track.Id,
|
||||
Position = nextPosition
|
||||
}, cancellationToken);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Added YouTube track {VideoId} ({TrackId}) to playlist {PlayListId} at position {Position}",
|
||||
request.VideoId, track.Id, request.PlayListId, nextPosition);
|
||||
|
||||
var listensCount = await database.ListeningHistories.AsNoTracking()
|
||||
.CountAsync(lh => lh.TrackId == track.Id, cancellationToken);
|
||||
|
||||
return TrackApplicationResponse.FromEntity(track, listensCount);
|
||||
}
|
||||
|
||||
private async Task<ErrorOr<string>> ResolveThumbnailUrlAsync(string videoId, YouTubeSongResult? song, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(song?.ThumbnailUrl))
|
||||
return youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl);
|
||||
|
||||
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
|
||||
if (songResult.IsError)
|
||||
return songResult.Errors;
|
||||
|
||||
return youTubeMusicService.ResolveArtworkUrl(songResult.Value.ThumbnailUrl);
|
||||
}
|
||||
|
||||
private async Task PublishDownloadEventAsync(Guid trackId, string videoId, string thumbnailUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||
|
||||
await eventBus.PublishAsync(
|
||||
new DownloadYouTubeTrackEvent(
|
||||
trackId,
|
||||
videoId,
|
||||
thumbnailUrl,
|
||||
storageConfiguration.Bucket,
|
||||
audioProcessedFolderKey,
|
||||
new ImageSize(
|
||||
trackConfiguration.Routes.SmallPicturesPath,
|
||||
trackConfiguration.PicturesSizes.SmallPictureWidth,
|
||||
trackConfiguration.PicturesSizes.SmallPictureHeight),
|
||||
new ImageSize(
|
||||
trackConfiguration.Routes.MediumPicturesPath,
|
||||
trackConfiguration.PicturesSizes.MediumPictureWidth,
|
||||
trackConfiguration.PicturesSizes.MediumPictureHeight),
|
||||
new ImageSize(
|
||||
trackConfiguration.Routes.LargePicturesPath,
|
||||
trackConfiguration.PicturesSizes.LargePictureWidth,
|
||||
trackConfiguration.PicturesSizes.LargePictureHeight)),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
|
||||
@@ -29,9 +28,7 @@ namespace Musify.Application.PlayLists
|
||||
|
||||
var tracksQuery = database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(plt => ((ExternalTrack)plt.Track).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(plt => ((LocalTrack)plt.Track).Owner)
|
||||
.Include(plt => plt.Track.Owner)
|
||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Services;
|
||||
|
||||
public sealed record YouTubeTrackProvision(ExternalTrack Track, YouTubeSongResult? Song);
|
||||
|
||||
public sealed class YouTubeTrackProvisioner(
|
||||
IDatabase database,
|
||||
IYouTubeMusicService youTubeMusicService,
|
||||
TrackConfiguration trackConfiguration,
|
||||
ILogger<YouTubeTrackProvisioner> logger)
|
||||
{
|
||||
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);
|
||||
|
||||
public async Task<ErrorOr<YouTubeTrackProvision>> GetOrCreateAsync(string videoId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await FindAsync(videoId, cancellationToken);
|
||||
if (existing is not null)
|
||||
return new YouTubeTrackProvision(existing, Song: null);
|
||||
|
||||
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
|
||||
if (songResult.IsError)
|
||||
return songResult.Errors;
|
||||
|
||||
var song = songResult.Value;
|
||||
|
||||
var artistRefs = song.Artists
|
||||
.GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant())
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
|
||||
var artists = new List<Artist>();
|
||||
foreach (var artistRef in artistRefs)
|
||||
artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
|
||||
|
||||
var title = Truncate(song.Title, 200);
|
||||
var track = new ExternalTrack
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Source = TrackSource.YouTube,
|
||||
ExternalId = videoId,
|
||||
DurationSeconds = song.DurationSeconds,
|
||||
Pictures = new TrackPictures
|
||||
{
|
||||
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
||||
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
||||
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
||||
ProcessingStatus = ProcessingStatus.Pending
|
||||
},
|
||||
Audio = new TrackAudio
|
||||
{
|
||||
DownloadRequested = false,
|
||||
TranscodeStatus = ProcessingStatus.Pending
|
||||
}
|
||||
};
|
||||
|
||||
await database.ExternalTracks.AddAsync(track, cancellationToken);
|
||||
|
||||
for (var position = 0; position < artists.Count; position++)
|
||||
{
|
||||
await database.TrackArtists.AddAsync(new TrackArtist
|
||||
{
|
||||
TrackId = track.Id,
|
||||
ArtistId = artists[position].Id,
|
||||
Position = position
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Created YouTube track {VideoId} ({TrackId})", videoId, track.Id);
|
||||
return new YouTubeTrackProvision(track, Song: song);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
database.ExternalTracks.Remove(track);
|
||||
var refetched = await FindAsync(videoId, cancellationToken);
|
||||
if (refetched is null)
|
||||
{
|
||||
logger.LogWarning("YouTube track {VideoId} creation raced but could not be refetched", videoId);
|
||||
return Error.Conflict(description: "The track was just created by another request. Retry the operation.");
|
||||
}
|
||||
return new YouTubeTrackProvision(refetched, Song: null);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MaterializeAlbumAsync(string albumExternalId, YouTubeAlbumDetail detail, CancellationToken cancellationToken)
|
||||
{
|
||||
var validTracks = detail.Tracks
|
||||
.Where(track => !string.IsNullOrEmpty(track.VideoId))
|
||||
.ToList();
|
||||
if (validTracks.Count == 0)
|
||||
return;
|
||||
|
||||
var album = await GetOrCreateExternalAlbumAsync(albumExternalId, detail.Album.Title, detail.Album.ThumbnailUrl, cancellationToken);
|
||||
|
||||
var videoIds = validTracks.Select(track => track.VideoId).ToList();
|
||||
|
||||
var tracksByVideoId = await database.ExternalTracks
|
||||
.Where(track => track.Source == TrackSource.YouTube && videoIds.Contains(track.ExternalId))
|
||||
.ToDictionaryAsync(track => track.ExternalId, cancellationToken);
|
||||
|
||||
var linkedTrackIds = (await database.AlbumHasTracks
|
||||
.Where(albumTrack => albumTrack.AlbumId == album.Id)
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToHashSet();
|
||||
|
||||
if (tracksByVideoId.Count == validTracks.Count && linkedTrackIds.Count >= validTracks.Count)
|
||||
return;
|
||||
|
||||
var artistRefs = detail.Album.Artists
|
||||
.GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant())
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
|
||||
var albumArtists = new List<Artist>();
|
||||
foreach (var artistRef in artistRefs)
|
||||
albumArtists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
|
||||
|
||||
foreach (var albumTrack in validTracks)
|
||||
{
|
||||
if (!tracksByVideoId.TryGetValue(albumTrack.VideoId, out var track))
|
||||
{
|
||||
track = BuildStubTrack(albumTrack);
|
||||
await database.ExternalTracks.AddAsync(track, cancellationToken);
|
||||
|
||||
for (var position = 0; position < albumArtists.Count; position++)
|
||||
{
|
||||
await database.TrackArtists.AddAsync(new TrackArtist
|
||||
{
|
||||
TrackId = track.Id,
|
||||
ArtistId = albumArtists[position].Id,
|
||||
Position = position
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
tracksByVideoId[albumTrack.VideoId] = track;
|
||||
}
|
||||
|
||||
if (linkedTrackIds.Contains(track.Id))
|
||||
continue;
|
||||
|
||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||
{
|
||||
AlbumId = album.Id,
|
||||
TrackId = track.Id,
|
||||
TrackNumber = albumTrack.TrackNumber
|
||||
}, cancellationToken);
|
||||
linkedTrackIds.Add(track.Id);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Materialized YouTube album {AlbumId} ({TrackCount} tracks)", albumExternalId, validTracks.Count);
|
||||
}
|
||||
catch (DbUpdateException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Race while materializing YouTube album {AlbumId}", albumExternalId);
|
||||
}
|
||||
}
|
||||
|
||||
private ExternalTrack BuildStubTrack(YouTubeAlbumTrack albumTrack)
|
||||
{
|
||||
var title = Truncate(albumTrack.Title, 200);
|
||||
return new ExternalTrack
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Source = TrackSource.YouTube,
|
||||
ExternalId = albumTrack.VideoId,
|
||||
DurationSeconds = albumTrack.DurationSeconds,
|
||||
Pictures = new TrackPictures
|
||||
{
|
||||
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
||||
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
||||
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
||||
ProcessingStatus = ProcessingStatus.Pending
|
||||
},
|
||||
Audio = new TrackAudio
|
||||
{
|
||||
DownloadRequested = false,
|
||||
TranscodeStatus = ProcessingStatus.Pending
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Artist> GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken)
|
||||
{
|
||||
var name = Truncate(reference.Name, 200);
|
||||
var normalized = name.ToUpperInvariant();
|
||||
var externalId = reference.Id ?? $"name:{normalized}";
|
||||
|
||||
var existing = await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == externalId, cancellationToken);
|
||||
if (existing is not null)
|
||||
return existing;
|
||||
|
||||
var artist = new Artist
|
||||
{
|
||||
Name = name,
|
||||
NormalizedName = normalized,
|
||||
ExternalId = externalId
|
||||
};
|
||||
|
||||
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.ExternalId == externalId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ExternalAlbum> GetOrCreateExternalAlbumAsync(string externalId, string name, string? thumbnailUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await database.ExternalAlbums
|
||||
.SingleOrDefaultAsync(album => album.Source == TrackSource.YouTube && album.ExternalId == externalId, cancellationToken);
|
||||
if (existing is not null)
|
||||
return existing;
|
||||
|
||||
var title = Truncate(name, 200);
|
||||
var album = new ExternalAlbum
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Source = TrackSource.YouTube,
|
||||
ExternalId = externalId,
|
||||
ThumbnailUrl = string.IsNullOrEmpty(thumbnailUrl) ? null : Truncate(thumbnailUrl, 512)
|
||||
};
|
||||
|
||||
await database.ExternalAlbums.AddAsync(album, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
return album;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
database.ExternalAlbums.Remove(album);
|
||||
return await database.ExternalAlbums.SingleAsync(a => a.Source == TrackSource.YouTube && a.ExternalId == externalId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength) =>
|
||||
value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
@@ -59,7 +59,7 @@ namespace Musify.Application.Tracks
|
||||
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||
|
||||
var trackEntity = new LocalTrack
|
||||
var trackEntity = new Track
|
||||
{
|
||||
Title = request.Title,
|
||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
||||
@@ -80,7 +80,7 @@ namespace Musify.Application.Tracks
|
||||
}
|
||||
};
|
||||
|
||||
await database.LocalTracks.AddAsync(trackEntity, cancellationToken);
|
||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||
|
||||
await database.UserHasTracks.AddAsync(new UserHasTrack
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ namespace Musify.Application.Tracks
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
if (track is not LocalTrack localTrack || localTrack.OwnerUserId != request.UserId)
|
||||
if (track.OwnerUserId != request.UserId)
|
||||
{
|
||||
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
|
||||
return Error.Unauthorized();
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
@@ -25,9 +24,7 @@ namespace Musify.Application.Tracks
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var tracksById = await database.Tracks
|
||||
.Include(t => ((ExternalTrack)t).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(t => ((LocalTrack)t).Owner)
|
||||
.Include(t => 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,7 +3,6 @@ using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
@@ -16,9 +15,7 @@ namespace Musify.Application.Tracks
|
||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await database.Tracks.AsNoTracking()
|
||||
.Include(t => ((ExternalTrack)t).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(t => ((LocalTrack)t).Owner)
|
||||
.Include(t => t.Owner)
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, string? YoutubeContinuationToken = null)
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
|
||||
: IQuery<ErrorOr<TracksSearchResponse>>;
|
||||
|
||||
public class GetTracksQueryHandler(
|
||||
IDatabase database,
|
||||
IYouTubeMusicService youTubeMusicService,
|
||||
ILogger<GetTracksQueryHandler> logger)
|
||||
public class GetTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
|
||||
{
|
||||
// Mirrors the web client's search-box debounce threshold: a query shorter than this is too
|
||||
// likely to still be mid-keystroke to justify a live call to the YouTube Music API for every
|
||||
// client that hits this endpoint.
|
||||
private const int MinNameLengthForYouTubeSearch = 2;
|
||||
|
||||
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tracksQuery = database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(ut => ((LocalTrack)ut.Track).Owner)
|
||||
.Include(ut => ut.Track.Owner)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Name))
|
||||
@@ -48,61 +35,18 @@ namespace Musify.Application.Tracks
|
||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var localItems = pagedEntities
|
||||
var items = pagedEntities
|
||||
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
|
||||
.ToList();
|
||||
|
||||
var knownVideoIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in pagedEntities)
|
||||
{
|
||||
if (entry.Track is ExternalTrack { Source: TrackSource.YouTube } external)
|
||||
knownVideoIds.Add(external.ExternalId);
|
||||
}
|
||||
|
||||
var youtubeItems = new List<TrackSearchItemResponse>();
|
||||
string? nextContinuationToken = null;
|
||||
var youtubeUnavailable = false;
|
||||
|
||||
if (request.Name?.Trim().Length >= MinNameLengthForYouTubeSearch)
|
||||
(youtubeItems, nextContinuationToken, youtubeUnavailable) =
|
||||
await SearchYouTubeAsync(request.Name, request.YoutubeContinuationToken, knownVideoIds, cancellationToken);
|
||||
|
||||
return TracksSearchResponse.Create(
|
||||
[.. localItems, .. youtubeItems],
|
||||
return new TracksSearchResponse(
|
||||
items,
|
||||
pagedEntities.PageNumber,
|
||||
pagedEntities.PageSize,
|
||||
pagedEntities.PageCount,
|
||||
pagedEntities.TotalItemCount,
|
||||
pagedEntities.HasPreviousPage,
|
||||
pagedEntities.HasNextPage,
|
||||
nextContinuationToken,
|
||||
youtubeUnavailable);
|
||||
}
|
||||
|
||||
private async Task<(List<TrackSearchItemResponse> Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync(
|
||||
string name,
|
||||
string? continuationToken,
|
||||
HashSet<string> knownVideoIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var searchResult = await youTubeMusicService.SearchSongsAsync(name, continuationToken ?? string.Empty, cancellationToken);
|
||||
if (searchResult.IsError)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"YouTube search unavailable while combining track results: {Error}", searchResult.FirstError.Description);
|
||||
return ([], null, true);
|
||||
}
|
||||
|
||||
var items = searchResult.Value.Items
|
||||
.Where(song => knownVideoIds.Add(song.VideoId))
|
||||
.Select(TrackSearchItemResponse.FromYouTubeSong)
|
||||
.ToList();
|
||||
|
||||
var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken)
|
||||
? null
|
||||
: searchResult.Value.ContinuationToken;
|
||||
|
||||
return (items, nextToken, false);
|
||||
pagedEntities.HasNextPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
@@ -27,9 +26,7 @@ namespace Musify.Application.Tracks
|
||||
|
||||
var tracksQuery = database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
|
||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||
.Include(ut => ((LocalTrack)ut.Track).Owner)
|
||||
.Include(ut => ut.Track.Owner)
|
||||
.Where(p => p.UserId == request.UserId);
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Name))
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public record ExternalTrackApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Artist,
|
||||
ProcessingStatus AudioStatus,
|
||||
double Duration,
|
||||
int ListensCount,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
string ExternalId)
|
||||
: TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt)
|
||||
{
|
||||
public static ExternalTrackApplicationResponse FromEntity(ExternalTrack track, int listensCount) =>
|
||||
new(
|
||||
track.Id,
|
||||
track.Title,
|
||||
FormatArtist(track),
|
||||
track.Audio.TranscodeStatus,
|
||||
track.DurationSeconds,
|
||||
listensCount,
|
||||
track.CreatedAt,
|
||||
track.UpdatedAt,
|
||||
track.ExternalId);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public record LocalTrackApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Artist,
|
||||
ProcessingStatus AudioStatus,
|
||||
double Duration,
|
||||
int ListensCount,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId)
|
||||
: TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt)
|
||||
{
|
||||
public static LocalTrackApplicationResponse FromEntity(LocalTrack track, int listensCount) =>
|
||||
new(
|
||||
track.Id,
|
||||
track.Title,
|
||||
FormatArtist(track),
|
||||
track.Audio.TranscodeStatus,
|
||||
track.DurationSeconds,
|
||||
listensCount,
|
||||
track.CreatedAt,
|
||||
track.UpdatedAt,
|
||||
track.OwnerUserId);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
// See AlbumApplicationResponse for why [JsonPolymorphic]/[JsonDerivedType] are required
|
||||
// (not optional) whenever a base type carries subtype-only data across the wire. Track is a
|
||||
// cleaner case than Album: both OwnerUserId (local-only) and ExternalId (external-only) are
|
||||
// genuinely exclusive to one side, so the base has no "meaningless sentinel" field at all —
|
||||
// every real value is one of the two derived types, hence base is abstract.
|
||||
//
|
||||
// No explicit Source property here (unlike the domain entities): STJ's polymorphism support
|
||||
// already injects a "source" discriminator property with the same "Local"/"YouTube" values a
|
||||
// hand-written TrackSource field would have, and a real property with that exact name would
|
||||
// collide with it ("contains property 'source' that conflicts with an existing metadata
|
||||
// property name"). Use the discriminator (or a type check) instead of a Source field.
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "source")]
|
||||
[JsonDerivedType(typeof(LocalTrackApplicationResponse), typeDiscriminator: "Local")]
|
||||
[JsonDerivedType(typeof(ExternalTrackApplicationResponse), typeDiscriminator: "YouTube")]
|
||||
public abstract record TrackApplicationResponse(
|
||||
public record TrackApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Artist,
|
||||
@@ -26,24 +13,19 @@ namespace Musify.Application.Tracks.Responses
|
||||
double Duration,
|
||||
int ListensCount,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt)
|
||||
DateTime UpdatedAt,
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId)
|
||||
{
|
||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
||||
track switch
|
||||
{
|
||||
LocalTrack local => LocalTrackApplicationResponse.FromEntity(local, listensCount),
|
||||
ExternalTrack external => ExternalTrackApplicationResponse.FromEntity(external, listensCount),
|
||||
_ => throw new NotSupportedException($"Unsupported track entity type '{track.GetType().Name}'.")
|
||||
};
|
||||
|
||||
protected static string? FormatArtist(Track track) => track switch
|
||||
{
|
||||
LocalTrack local => local.Owner.Name,
|
||||
ExternalTrack { TrackArtists.Count: > 0 } external =>
|
||||
string.Join(", ", external.TrackArtists
|
||||
.OrderBy(trackArtist => trackArtist.Position)
|
||||
.Select(trackArtist => trackArtist.Artist.Name)),
|
||||
_ => null
|
||||
};
|
||||
new(
|
||||
track.Id,
|
||||
track.Title,
|
||||
track.Owner.Name,
|
||||
track.Audio.TranscodeStatus,
|
||||
track.DurationSeconds,
|
||||
listensCount,
|
||||
track.CreatedAt,
|
||||
track.UpdatedAt,
|
||||
track.OwnerUserId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
/// <summary>One item of a combined local/YouTube track listing. Exactly one of
|
||||
/// <see cref="Track"/> or <see cref="YouTubeSong"/> is populated: <see cref="Track"/> for
|
||||
/// anything already in the catalog (local uploads and YouTube tracks provisioned earlier),
|
||||
/// <see cref="YouTubeSong"/> for a live YouTube Music search hit that has not been
|
||||
/// provisioned yet, so it has no track id.</summary>
|
||||
public record TrackSearchItemResponse(
|
||||
TrackSource Source,
|
||||
TrackApplicationResponse? Track,
|
||||
YouTubeSongResult? YouTubeSong)
|
||||
public record TrackSearchItemResponse(TrackApplicationResponse Track)
|
||||
{
|
||||
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) =>
|
||||
new(track is ExternalTrackApplicationResponse ? TrackSource.YouTube : TrackSource.Local, track, null);
|
||||
|
||||
public static TrackSearchItemResponse FromYouTubeSong(YouTubeSongResult song) =>
|
||||
new(TrackSource.YouTube, null, song);
|
||||
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
/// <summary>Result of a combined tracks listing/search: a page of local catalog tracks
|
||||
/// (uploads and previously provisioned YouTube tracks), plus, when a search term was given,
|
||||
/// live YouTube Music hits appended after them. Pagination metadata (<see cref="PageNumber"/>,
|
||||
/// <see cref="PageSize"/>, <see cref="PageCount"/>, <see cref="TotalItemCount"/>) describes only
|
||||
/// the local page — YouTube has no stable total, so its continuation is tracked separately via
|
||||
/// <see cref="NextYoutubeContinuationToken"/>.</summary>
|
||||
/// <summary>A page of the user's catalog tracks (their own uploads).</summary>
|
||||
public record TracksSearchResponse(
|
||||
IReadOnlyList<TrackSearchItemResponse> Items,
|
||||
int PageNumber,
|
||||
@@ -13,26 +8,5 @@ namespace Musify.Application.Tracks.Responses
|
||||
int PageCount,
|
||||
int TotalItemCount,
|
||||
bool HasPreviousPage,
|
||||
bool HasNextLocalPage,
|
||||
string? NextYoutubeContinuationToken,
|
||||
bool YoutubeUnavailable,
|
||||
bool HasNextPage)
|
||||
{
|
||||
// HasNextPage is a real field (not a computed property) so it shows up as a required member
|
||||
// of the generated OpenAPI schema, same as every other field here — a get-only property added
|
||||
// outside the primary constructor gets reflected as optional instead, which is never true here.
|
||||
public static TracksSearchResponse Create(
|
||||
IReadOnlyList<TrackSearchItemResponse> items,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
int pageCount,
|
||||
int totalItemCount,
|
||||
bool hasPreviousPage,
|
||||
bool hasNextLocalPage,
|
||||
string? nextYoutubeContinuationToken,
|
||||
bool youtubeUnavailable) =>
|
||||
new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage,
|
||||
nextYoutubeContinuationToken, youtubeUnavailable,
|
||||
HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken));
|
||||
}
|
||||
bool HasNextPage);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
|
||||
namespace Musify.Application.YouTube
|
||||
{
|
||||
public record GetYouTubeAlbumQuery(string AlbumId)
|
||||
: IQuery<ErrorOr<YouTubeAlbumDetail>>;
|
||||
|
||||
public class GetYouTubeAlbumQueryHandler(
|
||||
IYouTubeMusicService youTubeMusicService,
|
||||
YouTubeTrackProvisioner provisioner,
|
||||
ILogger<GetYouTubeAlbumQueryHandler> logger)
|
||||
: IQueryHandler<GetYouTubeAlbumQuery, ErrorOr<YouTubeAlbumDetail>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<YouTubeAlbumDetail>> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
|
||||
if (result.IsError)
|
||||
return result;
|
||||
|
||||
try
|
||||
{
|
||||
await provisioner.MaterializeAlbumAsync(request.AlbumId, result.Value, cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to materialize YouTube album {AlbumId}", request.AlbumId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.YouTube.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.YouTube
|
||||
{
|
||||
public record ResolveYouTubeTrackStreamCommand(string VideoId, long? UserId)
|
||||
: ICommand<ErrorOr<YouTubeStreamResponse>>;
|
||||
|
||||
public class ResolveYouTubeTrackStreamCommandHandler(
|
||||
IDatabase database,
|
||||
IYouTubeMusicService youTubeMusicService,
|
||||
YouTubeTrackProvisioner provisioner,
|
||||
TrackStreamIssuer streamIssuer,
|
||||
PlaybackConfiguration playbackConfiguration,
|
||||
ILogger<ResolveYouTubeTrackStreamCommandHandler> logger)
|
||||
: ICommandHandler<ResolveYouTubeTrackStreamCommand, ErrorOr<YouTubeStreamResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<YouTubeStreamResponse>> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var isAnonymous = !request.UserId.HasValue;
|
||||
if (isAnonymous && !playbackConfiguration.AllowAnonymousListening)
|
||||
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||
|
||||
var track = await provisioner.FindAsync(request.VideoId, cancellationToken);
|
||||
|
||||
if (track != null && track.Audio.IsProcessed)
|
||||
{
|
||||
var serverStream = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
||||
return new YouTubeStreamResponse(
|
||||
track.Id,
|
||||
YouTubeStreamMode.Server,
|
||||
serverStream.ManifestUrl,
|
||||
serverStream.Ticket,
|
||||
serverStream.ExpiresInSeconds);
|
||||
}
|
||||
|
||||
if (isAnonymous && playbackConfiguration.AnonymousFragmentSeconds > 0)
|
||||
return Error.Conflict(description: "This track isn't available for anonymous preview yet. Try again in a moment.");
|
||||
|
||||
var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken);
|
||||
if (streamInfo.IsError)
|
||||
return streamInfo.Errors;
|
||||
|
||||
if (track == null)
|
||||
{
|
||||
var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken);
|
||||
if (!provisionResult.IsError)
|
||||
track = provisionResult.Value.Track;
|
||||
else
|
||||
logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description);
|
||||
}
|
||||
|
||||
if (track != null&& request.UserId != null)
|
||||
{
|
||||
await database.ListeningHistories.AddAsync(new ListeningHistory
|
||||
{
|
||||
UserId = request.UserId.Value,
|
||||
TrackId = track.Id,
|
||||
}, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to record listening history for YouTube video {VideoId}", request.VideoId);
|
||||
}
|
||||
}
|
||||
|
||||
return new YouTubeStreamResponse(
|
||||
track?.Id,
|
||||
YouTubeStreamMode.YouTube,
|
||||
streamInfo.Value.Url, null,
|
||||
streamInfo.Value.ExpiresInSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace Musify.Application.YouTube.Responses
|
||||
{
|
||||
public enum YouTubeStreamMode
|
||||
{
|
||||
Server = 0,
|
||||
YouTube = 1
|
||||
}
|
||||
|
||||
public record YouTubeStreamResponse(
|
||||
Guid? TrackId,
|
||||
YouTubeStreamMode Mode,
|
||||
string StreamUrl,
|
||||
string? Ticket,
|
||||
int ExpiresInSeconds);
|
||||
}
|
||||
Reference in New Issue
Block a user