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:
2026-09-15 21:17:03 +02:00
parent 5696683859
commit 31032bd8e8
137 changed files with 1933 additions and 3969 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
# Musify
Music streaming platform: upload your own tracks (or import them from YouTube),
process them asynchronously (audio transcoded to `.m4a`, thumbnails generated)
and stream them without the bytes ever going through the API.
Music streaming platform: upload your own tracks, process them asynchronously
(audio transcoded to `.m4a`, thumbnails generated) and stream them without the
bytes ever going through the API.
```
backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests
@@ -46,7 +46,7 @@ production prerequisites.
|---|---|---|
| `Musify.Api` | 5111 | REST API, issues presigned upload URLs and stream tickets |
| `Musify.StreamingGateway` | 8081 | YARP proxy that validates the stream ticket and serves the audio |
| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails, YouTube downloads |
| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails |
| `web-player` | 5173 / 3000 | Web client (SvelteKit, server-side OIDC session) |
| Zitadel | 8080 | Identity (OIDC/OAuth2) |
| PostgreSQL / RabbitMQ / SeaweedFS / Jaeger | 59000 / 5672 / 8333 / 16686 | Metadata, messaging, object storage, traces |
@@ -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,191 +93,15 @@ 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)
@@ -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 -&gt; Items -&gt; Track -&gt; 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);
}
+8 -1
View File
@@ -1,11 +1,12 @@
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 abstract class Album : IAuditable
public class Album : IAuditable
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
@@ -25,6 +26,12 @@ namespace Musify.Domain.Entities
public AlbumPictures? Pictures { get; set; }
[Required]
public required long OwnerUserId { get; set; }
[ForeignKey(nameof(OwnerUserId))]
public User Owner { get; set; }
[Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
@@ -1,32 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Musify.Domain.Abstractions;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
public class Artist : IAuditable
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(200)]
public required string Name { get; set; }
[Required]
[MaxLength(200)]
public required string NormalizedName { get; set; }
[Required]
[MaxLength(64)]
public required string ExternalId { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Required]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<TrackArtist> TrackArtists { get; set; } = new List<TrackArtist>();
}
}
@@ -1,18 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities
{
public class ExternalAlbum : Album
{
[Required]
public required TrackSource Source { get; set; }
[Required]
[MaxLength(64)]
public required string ExternalId { get; set; }
[MaxLength(512)]
public string? ThumbnailUrl { get; set; }
}
}
@@ -1,17 +0,0 @@
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>();
}
}
@@ -1,14 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
public class LocalTrack : Track
{
[Required]
public required long OwnerUserId { get; set; }
[ForeignKey(nameof(OwnerUserId)) ]
public required User Owner { get; set; }
}
}
+2 -24
View File
@@ -1,8 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
@@ -19,32 +17,12 @@ namespace Musify.Domain.Entities
public int Position { get; set; } = 0;
[Required]
public required MixItemSource Source { get; set; }
public Guid? TrackId { get; set; }
[MaxLength(64)]
public string? VideoId { get; set; }
[MaxLength(300)]
public string? Title { get; set; }
[MaxLength(300)]
public string? Artist { get; set; }
[MaxLength(512)]
public string? ThumbnailUrl { get; set; }
[Required]
public double DurationSeconds { get; set; }
[Required]
public bool IsExplicit { get; set; }
public required Guid TrackId { get; set; }
[ForeignKey(nameof(MixId))]
public Mix Mix { get; set; }
[ForeignKey(nameof(TrackId))]
public Track? Track { get; set; }
public Track Track { get; set; }
}
}
+8 -1
View File
@@ -1,11 +1,12 @@
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 abstract class Track : IAuditable
public class Track : IAuditable
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
@@ -21,6 +22,12 @@ namespace Musify.Domain.Entities
[Required]
public double DurationSeconds { get; set; }
[Required]
public required long OwnerUserId { get; set; }
[ForeignKey(nameof(OwnerUserId))]
public required User Owner { get; set; }
[Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
@@ -1,25 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("TrackArtist")]
public class TrackArtist
{
[Required]
public required Guid TrackId { get; set; }
[Required]
public required Guid ArtistId { get; set; }
[Required]
public int Position { get; set; } = 0;
[ForeignKey(nameof(TrackId)) ]
public ExternalTrack Track { get; set; }
[ForeignKey(nameof(ArtistId)) ]
public Artist Artist { get; set; }
}
}
@@ -1,15 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
public class UserAlbum : Album
{
[Required]
public required long OwnerUserId { get; set; }
[ForeignKey(nameof(OwnerUserId)) ]
public User Owner { get; set; }
}
}
@@ -1,8 +0,0 @@
namespace Musify.Domain.ValueObjects
{
public enum MixItemSource
{
Musify = 0,
YouTube = 1
}
}
@@ -1,8 +0,0 @@
namespace Musify.Domain.ValueObjects
{
public enum TrackSource
{
Local = 0,
YouTube = 1
}
}
@@ -1,35 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Infrastructure.Configuration
{
public class YouTubeConfiguration
{
public const string SectionName = "YouTube";
/// <summary>Turns the whole YouTube Music source on or off: search, albums, streaming
/// and adding new YouTube tracks all fail with a clear error while this is <c>false</c>,
/// and mix generation silently skips YouTube candidates instead of failing.</summary>
public bool Enabled { get; set; } = true;
[Required]
public string GeographicalLocation { get; set; } = "US";
[Range(60, 21600)]
public int StreamUrlCacheSeconds { get; set; } = 1800;
[Range(60, 3600)]
public int SearchContinuationCacheSeconds { get; set; } = 600;
[Range(0, 86400)]
public int SearchResultsCacheSeconds { get; set; } = 900;
[Range(0, 604800)]
public int SongInfoCacheSeconds { get; set; } = 86400;
[Range(32, 720)]
public int ThumbnailSize { get; set; } = 240;
[Range(120, 1080)]
public int ArtworkSize { get; set; } = 544;
}
}
@@ -1,20 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Infrastructure.Configuration
{
public class YtDlpConfiguration
{
public const string SectionName = "YtDlp";
[Required]
public string ExecutableName { get; set; } = "yt-dlp";
[Required]
public string Format { get; set; } = "bestaudio";
public string AdditionalArguments { get; set; } = string.Empty;
[Required]
public TimeSpan DownloadTimeout { get; set; } = TimeSpan.FromMinutes(5);
}
}
@@ -1,55 +0,0 @@
namespace Musify.Infrastructure.Helpers
{
public static class YouTubeThumbNailHelper
{
public static bool IsSquare(string url) =>
url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase);
public static string WithSize(string url, int size)
{
if (string.IsNullOrEmpty(url))
return string.Empty;
if (url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase))
return ResizeGoogleUserContent(url, size);
if (url.Contains("ytimg.com", StringComparison.OrdinalIgnoreCase))
return ResizeYtImg(url, size);
return url;
}
private static string ResizeGoogleUserContent(string url, int size)
{
var separator = url.LastIndexOf('=');
var baseUrl = separator > 0 && IsSizeSuffix(url.AsSpan(separator + 1))
? url[..separator]
: url;
return $"{baseUrl}=w{size}-h{size}-l90-rj";
}
private static string ResizeYtImg(string url, int size)
{
var queryStart = url.IndexOf('?');
var path = queryStart >= 0 ? url[..queryStart] : url;
var lastSlash = path.LastIndexOf('/');
if (lastSlash < 0)
return path;
return $"{path[..lastSlash] }/{YtImgFileName(size) }";
}
private static string YtImgFileName(int size) => size switch
{
<= 90 => "default.jpg",
<= 180 => "mqdefault.jpg",
<= 360 => "hqdefault.jpg",
_ => "maxresdefault.jpg"
};
private static bool IsSizeSuffix(ReadOnlySpan<char> suffix) =>
suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1] );
}
}
@@ -33,7 +33,6 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="SixLabors.ImageSharp" />
<PackageReference Include="YouTubeMusicAPI" />
</ItemGroup>
<ItemGroup>
@@ -1,94 +0,0 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Audio
{
internal class DownloadYouTubeAudioActivity(
IDatabase database,
IYouTubeDownloaderService youTubeDownloader,
ILogger<DownloadYouTubeAudioActivity> logger)
: IActivity<DownloadYouTubeAudioArguments, DownloadYouTubeAudioLog>
{
public const string ExecuteEndpointName = "download-you-tube-audio";
public async Task<ExecutionResult> Execute(ExecuteContext<DownloadYouTubeAudioArguments> executeContext)
{
var destinationPath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath));
Track track;
try
{
var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken);
track = getTrack ?? throw new InvalidOperationException($"Track with Id {executeContext.Arguments.TrackId} not found");
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to retrieve track with Id {TrackId} from database", executeContext.Arguments.TrackId);
throw;
}
try
{
var downloadResult = await youTubeDownloader.DownloadAudioAsync(
executeContext.Arguments.VideoId,
destinationPath,
executeContext.CancellationToken);
if (downloadResult.ExitCode != 0)
throw new InvalidOperationException(
$"YouTube audio download failed: {downloadResult.StandardError}");
if (!File.Exists(destinationPath))
throw new InvalidOperationException($"YouTube audio download did not produce the expected file {destinationPath}.");
logger.LogInformation("Downloaded YouTube audio for video {VideoId} to {DestinationPath}",
executeContext.Arguments.VideoId, destinationPath);
return executeContext.Completed(new DownloadYouTubeAudioLog(
executeContext.Arguments.VideoId,
destinationPath));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to download YouTube audio for video {VideoId}",
executeContext.Arguments.VideoId);
try
{
track.Audio.TranscodeStatus = ProcessingStatus.Failed;
await database.SaveChangesAsync(executeContext.CancellationToken);
}
catch (Exception dbException)
{
logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}",
executeContext.Arguments.TrackId);
}
throw;
}
}
public Task<CompensationResult> Compensate(CompensateContext<DownloadYouTubeAudioLog> compensateContext)
{
try
{
if (File.Exists(compensateContext.Log.DestinationFilePath))
File.Delete(compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Compensated());
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate downloaded YouTube audio {DestinationFilePath}",
compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Failed(exception));
}
}
}
}
@@ -1,7 +0,0 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record DownloadYouTubeAudioArguments(
Guid TrackId,
string VideoId,
string DestinationFilePathVariable);
}
@@ -1,26 +0,0 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class DownloadYouTubeTrackConsumer(
IBus bus,
YouTubeTrackRoutingSlipBuilder routingSlipBuilder)
: IConsumer<DownloadYouTubeTrackEvent>
{
public async Task Consume(ConsumeContext<DownloadYouTubeTrackEvent> consumeContext)
{
var audioRoutingSlip = routingSlipBuilder
.BuildAudioWorkflow(consumeContext.Message, consumeContext.CorrelationId)
.Build();
var pictureRoutingSlip = routingSlipBuilder
.BuildPictureWorkflow(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(audioRoutingSlip);
await bus.Execute(pictureRoutingSlip);
}
}
}
@@ -25,7 +25,6 @@ namespace Musify.Infrastructure.MassTransit
services.AddScoped<CreateTrackRoutingSlipBuilder>();
services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>();
services.AddScoped<AlbumPictureSourceRoutingSlipBuilder>();
services.AddScoped<YouTubeTrackRoutingSlipBuilder>();
}
static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options)
@@ -42,7 +41,6 @@ namespace Musify.Infrastructure.MassTransit
options.AddConsumer<ProcessingSlipFaultConsumer>();
options.AddConsumer<TrackProcessingFailedConsumer>();
options.AddConsumer<PlayListProcessingFailedConsumer>();
options.AddConsumer<DownloadYouTubeTrackConsumer>();
options.AddConsumer<CreateAlbumConsumer>();
options.AddConsumer<UpdateAlbumPictureSourceConsumer>();
options.AddConsumer<UpdateAlbumPictureConsumer>();
@@ -73,7 +71,6 @@ namespace Musify.Infrastructure.MassTransit
options.AddActivity<UploadFileToBucketActivity, UploadFileToBucketArguments, UploadFileToBucketLog>();
options.AddActivity<CopyFileInBucketActivity, CopyFileInBucketArguments, CopyFileInBucketLog>();
options.AddActivity<ConsumeUploadIntentsActivity, ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>();
options.AddActivity<DownloadYouTubeAudioActivity, DownloadYouTubeAudioArguments, DownloadYouTubeAudioLog>();
options.AddActivity<DownloadFileFromUrlActivity, DownloadFileFromUrlArguments, DownloadFileFromUrlLog>();
}
}
@@ -1,6 +0,0 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record DownloadYouTubeAudioLog(
string VideoId,
string DestinationFilePath);
}
@@ -14,7 +14,6 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
internal const string DownloadFile = "DownloadFile";
internal const string DownloadYouTubeAudio = "DownloadYouTubeAudio";
internal const string DownloadThumbnail = "DownloadThumbnail";
internal const string TranscodeAudio = "TranscodeAudio";
@@ -1,166 +0,0 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.MassTransit.Activities.Audio;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.Pictures;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public class YouTubeTrackRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
{
public RoutingSlipBuilder BuildAudioWorkflow(DownloadYouTubeTrackEvent message, Guid? correlationId)
{
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
routingSlipBuilder.AddActivity(
ActivityNames.GenerateAudioWorkflowPaths,
EndpointHelper.BuildExecuteActivityUri(GenerateAudioWorkflowPathsActivity.ExecuteEndpointName),
new GenerateAudioWorkflowPathsArguments(
message.TrackId,
workerConfiguration.Routes.TemporaryFilesDirectory,
$"{message.VideoId}.m4a"));
routingSlipBuilder.AddActivity(
ActivityNames.DownloadYouTubeAudio,
EndpointHelper.BuildExecuteActivityUri(DownloadYouTubeAudioActivity.ExecuteEndpointName),
new DownloadYouTubeAudioArguments(
message.TrackId,
message.VideoId,
RoutingSlipVariableNames.Audio.SourceFilePath));
routingSlipBuilder.AddActivity(
ActivityNames.TranscodeAudio,
EndpointHelper.BuildExecuteActivityUri(TranscodeAudioActivity.ExecuteEndpointName),
new TranscodeAudioArguments(
message.TrackId,
RoutingSlipVariableNames.Audio.SourceFilePath,
RoutingSlipVariableNames.Workflow.TemporalDirectory));
routingSlipBuilder.AddActivity(
ActivityNames.TransferFiles,
EndpointHelper.BuildExecuteActivityUri(TransferFilesToBucketActivity.ExecuteEndpointName),
new TransferFilesToBucketArguments(
message.Bucket,
RoutingSlipVariableNames.Audio.TranscodedDirectory,
message.AudioProcessedFolderKey));
routingSlipBuilder.AddActivity(
ActivityNames.UpdateTrackAudio,
EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName),
new UpdateTrackAudioArguments(
message.TrackId,
message.AudioProcessedFolderKey,
RoutingSlipVariableNames.Audio.DurationSeconds));
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId);
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackAudio);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName),
RoutingSlipEvents.Faulted);
return routingSlipBuilder;
}
public RoutingSlipBuilder BuildPictureWorkflow(DownloadYouTubeTrackEvent message, Guid? correlationId)
{
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
routingSlipBuilder.AddActivity(
ActivityNames.GeneratePictureWorkflowPaths,
EndpointHelper.BuildExecuteActivityUri(GeneratePictureWorkflowPathsActivity.ExecuteEndpointName),
new GeneratePictureWorkflowPathsArguments(
workerConfiguration.Routes.TemporaryFilesDirectory,
$"{message.VideoId}.jpg"));
routingSlipBuilder.AddActivity(
ActivityNames.DownloadThumbnail,
EndpointHelper.BuildExecuteActivityUri(DownloadFileFromUrlActivity.ExecuteEndpointName),
new DownloadFileFromUrlArguments(
message.ThumbnailUrl,
RoutingSlipVariableNames.Picture.OriginalFilePath));
AddResizeActivity(
routingSlipBuilder,
ActivityNames.ResizeSmall,
ActivityNames.UploadSmall,
message.Bucket,
RoutingSlipVariableNames.Picture.SmallResizedFilePath,
message.Small);
AddResizeActivity(
routingSlipBuilder,
ActivityNames.ResizeMedium,
ActivityNames.UploadMedium,
message.Bucket,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
message.Medium);
AddResizeActivity(
routingSlipBuilder,
ActivityNames.ResizeLarge,
ActivityNames.UploadLarge,
message.Bucket,
RoutingSlipVariableNames.Picture.LargeResizedFilePath,
message.Large);
routingSlipBuilder.AddActivity(
ActivityNames.UpdateTrackPicture,
EndpointHelper.BuildExecuteActivityUri(UpdateTrackPictureActivity.ExecuteEndpointName),
new UpdateTrackPictureArguments(
message.TrackId,
string.Empty,
RoutingSlipVariableNames.Picture.SmallResizedFilePath,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
RoutingSlipVariableNames.Picture.LargeResizedFilePath));
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId);
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackPicture);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName),
RoutingSlipEvents.Faulted);
return routingSlipBuilder;
}
static void AddResizeActivity(
RoutingSlipBuilder routingSlipBuilder,
string resizeActivityName,
string uploadActivityName,
string bucketName,
string destinationFilePathVariableName,
ImageSize imageSize)
{
routingSlipBuilder.AddActivity(
resizeActivityName,
EndpointHelper.BuildExecuteActivityUri(ResizePictureActivity.ExecuteEndpointName),
new ResizePictureLocalArguments(
RoutingSlipVariableNames.Picture.OriginalFilePath,
destinationFilePathVariableName,
imageSize.Width,
imageSize.Height));
routingSlipBuilder.AddActivity(
uploadActivityName,
EndpointHelper.BuildExecuteActivityUri(UploadFileToBucketActivity.ExecuteEndpointName),
new UploadFileToBucketArguments(
destinationFilePathVariableName,
bucketName,
imageSize.SavePath));
}
}
}
@@ -9,7 +9,6 @@ namespace Musify.Infrastructure.MassTransit.Sagas
public State Failed { get; private set; } = null!;
public Event<CreateTrackResourcesEvent> ProcessingStarted { get; private set; } = null!;
public Event<DownloadYouTubeTrackEvent> YouTubeDownloadStarted { get; private set; } = null!;
public Event<TrackPictureProcessed> PictureProcessed { get; private set; } = null!;
public Event<TrackAudioProcessed> AudioProcessed { get; private set; } = null!;
public Event<TrackPictureProcessingFailed> PictureFailed { get; private set; } = null!;
@@ -20,7 +19,6 @@ namespace Musify.Infrastructure.MassTransit.Sagas
InstanceState(state => state.CurrentState);
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId));
Event(() => YouTubeDownloadStarted, config => config.CorrelateById(context => context.Message.TrackId));
Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.TrackId));
Event(() => AudioProcessed, config => config.CorrelateById(context => context.Message.TrackId));
Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.TrackId));
@@ -36,20 +34,9 @@ namespace Musify.Infrastructure.MassTransit.Sagas
context.Saga.PictureKey = context.Message.PictureDestinationKey;
context.Saga.AudioKey = context.Message.AudioDestinationKey;
} )
.TransitionTo(Processing),
When(YouTubeDownloadStarted)
.Then(context =>
{
context.Saga.CreatedAt = DateTime.UtcNow;
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = string.Empty;
context.Saga.AudioKey = string.Empty;
} )
.TransitionTo(Processing));
During(Processing,
Ignore(YouTubeDownloadStarted),
When(PictureProcessed)
.Then(context =>
{
@@ -85,18 +72,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas
Ignore(PictureProcessed),
Ignore(AudioProcessed),
Ignore(PictureFailed),
Ignore(AudioFailed),
When(YouTubeDownloadStarted)
.Then(context =>
{
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.PictureProcessed = false;
context.Saga.AudioProcessed = false;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = string.Empty;
context.Saga.AudioKey = string.Empty;
} )
.TransitionTo(Processing));
Ignore(AudioFailed));
SetCompletedWhenFinalized();
}
@@ -54,41 +54,12 @@ namespace Musify.Infrastructure.Persistence
});
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
modelBuilder.Entity<LocalTrack>()
.ToTable("LocalTracks");
modelBuilder.Entity<LocalTrack>()
modelBuilder.Entity<Track>()
.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 });
modelBuilder.Entity<TrackArtist>()
.HasOne(trackArtist => trackArtist.Track)
.WithMany(track => track.TrackArtists)
.HasForeignKey(trackArtist => trackArtist.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackArtist>()
.HasOne(trackArtist => trackArtist.Artist)
.WithMany(artist => artist.TrackArtists)
.HasForeignKey(trackArtist => trackArtist.ArtistId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Artist>()
.HasIndex(artist => artist.ExternalId)
.IsUnique();
modelBuilder.Entity<PlayList>()
.Property(playList => playList.Visibility)
.HasDefaultValue(PlaylistVisibility.Private);
@@ -113,22 +84,12 @@ namespace Musify.Infrastructure.Persistence
});
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
modelBuilder.Entity<UserAlbum>()
.ToTable("UserAlbums");
modelBuilder.Entity<UserAlbum>()
modelBuilder.Entity<Album>()
.HasOne(album => album.Owner)
.WithMany()
.HasForeignKey(album => album.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ExternalAlbum>()
.ToTable("ExternalAlbums");
modelBuilder.Entity<ExternalAlbum>()
.HasIndex(album => new { album.Source, album.ExternalId })
.IsUnique();
modelBuilder.Entity<AlbumHasTrack>()
.HasOne(albumTrack => albumTrack.Album)
.WithMany(album => album.AlbumTracks)
@@ -158,7 +119,6 @@ namespace Musify.Infrastructure.Persistence
.HasOne(item => item.Track)
.WithMany()
.HasForeignKey(item => item.TrackId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
@@ -199,22 +159,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>();
public DbSet<PlayList> PlayLists => Set<PlayList>();
public DbSet<Album> Albums => Set<Album>();
public DbSet<UserAlbum> UserAlbums => Set<UserAlbum>();
public DbSet<ExternalAlbum> ExternalAlbums => Set<ExternalAlbum>();
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
@@ -0,0 +1,381 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveYouTubeIntegration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Data cleanup first, while the External*/Local*/UserAlbums tables (and the loose
// MixItem columns) still exist to query/cascade from. Deleting the base Tracks/Albums
// rows for anything that only ever existed as an ExternalTrack/ExternalAlbum cascades
// (FKs are ON DELETE CASCADE) to their own ExternalTracks/ExternalAlbums row, TrackArtist,
// PlayListHasTrack, AlbumHasTrack, TrackLike, ListeningHistory, UserHasTrack and MixItems
// rows that referenced them — this is deliberately lossy: those rows only existed to back
// the YouTube integration being removed here.
migrationBuilder.Sql("""DELETE FROM "Tracks" WHERE "Id" IN (SELECT "Id" FROM "ExternalTracks");""");
migrationBuilder.Sql("""DELETE FROM "Albums" WHERE "Id" IN (SELECT "Id" FROM "ExternalAlbums");""");
// Live YouTube search hits that were seeded into a mix but never materialized into a
// real track (MixItemSource.YouTube, no TrackId) — MixItem.TrackId is about to become
// required, so these rows can't be kept.
migrationBuilder.Sql("""DELETE FROM "MixItems" WHERE "TrackId" IS NULL;""");
migrationBuilder.AddColumn<long>(
name: "OwnerUserId",
table: "Tracks",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.Sql("""
UPDATE "Tracks" t SET "OwnerUserId" = lt."OwnerUserId"
FROM "LocalTracks" lt WHERE lt."Id" = t."Id";
""");
migrationBuilder.AddColumn<long>(
name: "OwnerUserId",
table: "Albums",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.Sql("""
UPDATE "Albums" a SET "OwnerUserId" = ua."OwnerUserId"
FROM "UserAlbums" ua WHERE ua."Id" = a."Id";
""");
migrationBuilder.AlterColumn<Guid>(
name: "TrackId",
table: "MixItems",
type: "uuid",
nullable: false,
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.DropColumn(
name: "Artist",
table: "MixItems");
migrationBuilder.DropColumn(
name: "DurationSeconds",
table: "MixItems");
migrationBuilder.DropColumn(
name: "IsExplicit",
table: "MixItems");
migrationBuilder.DropColumn(
name: "Source",
table: "MixItems");
migrationBuilder.DropColumn(
name: "ThumbnailUrl",
table: "MixItems");
migrationBuilder.DropColumn(
name: "Title",
table: "MixItems");
migrationBuilder.DropColumn(
name: "VideoId",
table: "MixItems");
migrationBuilder.DropTable(
name: "TrackArtist");
migrationBuilder.DropTable(
name: "ExternalTracks");
migrationBuilder.DropTable(
name: "LocalTracks");
migrationBuilder.DropTable(
name: "ExternalAlbums");
migrationBuilder.DropTable(
name: "UserAlbums");
migrationBuilder.DropTable(
name: "Artists");
migrationBuilder.CreateIndex(
name: "IX_Tracks_OwnerUserId",
table: "Tracks",
column: "OwnerUserId");
migrationBuilder.CreateIndex(
name: "IX_Albums_OwnerUserId",
table: "Albums",
column: "OwnerUserId");
migrationBuilder.AddForeignKey(
name: "FK_Albums_User_OwnerUserId",
table: "Albums",
column: "OwnerUserId",
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Tracks_User_OwnerUserId",
table: "Tracks",
column: "OwnerUserId",
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Albums_User_OwnerUserId",
table: "Albums");
migrationBuilder.DropForeignKey(
name: "FK_Tracks_User_OwnerUserId",
table: "Tracks");
migrationBuilder.DropIndex(
name: "IX_Tracks_OwnerUserId",
table: "Tracks");
migrationBuilder.DropIndex(
name: "IX_Albums_OwnerUserId",
table: "Albums");
migrationBuilder.DropColumn(
name: "OwnerUserId",
table: "Tracks");
migrationBuilder.DropColumn(
name: "OwnerUserId",
table: "Albums");
migrationBuilder.AlterColumn<Guid>(
name: "TrackId",
table: "MixItems",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<string>(
name: "Artist",
table: "MixItems",
type: "character varying(300)",
maxLength: 300,
nullable: true);
migrationBuilder.AddColumn<double>(
name: "DurationSeconds",
table: "MixItems",
type: "double precision",
nullable: false,
defaultValue: 0.0);
migrationBuilder.AddColumn<bool>(
name: "IsExplicit",
table: "MixItems",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "Source",
table: "MixItems",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "ThumbnailUrl",
table: "MixItems",
type: "character varying(512)",
maxLength: 512,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Title",
table: "MixItems",
type: "character varying(300)",
maxLength: 300,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VideoId",
table: "MixItems",
type: "character varying(64)",
maxLength: 64,
nullable: true);
migrationBuilder.CreateTable(
name: "Artists",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ExternalId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
NormalizedName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Artists", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ExternalAlbums",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ExternalId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Source = table.Column<int>(type: "integer", nullable: false),
ThumbnailUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalAlbums", x => x.Id);
table.ForeignKey(
name: "FK_ExternalAlbums_Albums_Id",
column: x => x.Id,
principalTable: "Albums",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExternalTracks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ExternalId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Source = table.Column<int>(type: "integer", 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.CreateTable(
name: "UserAlbums",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OwnerUserId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserAlbums", x => x.Id);
table.ForeignKey(
name: "FK_UserAlbums_Albums_Id",
column: x => x.Id,
principalTable: "Albums",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserAlbums_User_OwnerUserId",
column: x => x.OwnerUserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "TrackArtist",
columns: table => new
{
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
ArtistId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TrackArtist", x => new { x.TrackId, x.ArtistId });
table.ForeignKey(
name: "FK_TrackArtist_Artists_ArtistId",
column: x => x.ArtistId,
principalTable: "Artists",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TrackArtist_ExternalTracks_TrackId",
column: x => x.TrackId,
principalTable: "ExternalTracks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Artists_ExternalId",
table: "Artists",
column: "ExternalId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExternalAlbums_Source_ExternalId",
table: "ExternalAlbums",
columns: new[] { "Source", "ExternalId" },
unique: true);
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.CreateIndex(
name: "IX_TrackArtist_ArtistId",
table: "TrackArtist",
column: "ArtistId");
migrationBuilder.CreateIndex(
name: "IX_UserAlbums_OwnerUserId",
table: "UserAlbums",
column: "OwnerUserId");
}
}
}
@@ -211,6 +211,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("OwnerUserId")
.HasColumnType("bigint");
b.Property<int?>("ReleaseYear")
.HasColumnType("integer");
@@ -224,9 +227,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.ToTable("Albums");
b.HasIndex("OwnerUserId");
b.UseTptMappingStrategy();
b.ToTable("Albums");
});
modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b =>
@@ -257,41 +260,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("AlbumHasTrack");
});
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")
@@ -356,40 +324,15 @@ namespace Musify.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Artist")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<double>("DurationSeconds")
.HasColumnType("double precision");
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<Guid>("MixId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<Guid?>("TrackId")
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<string>("VideoId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("TrackId");
@@ -490,6 +433,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("OwnerUserId")
.HasColumnType("bigint");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
@@ -500,27 +446,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("OwnerUserId");
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.TrackLike", b =>
@@ -802,70 +730,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("TrackProcessingState");
});
modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b =>
{
b.HasBaseType("Musify.Domain.Entities.Album");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasIndex("Source", "ExternalId")
.IsUnique();
b.ToTable("ExternalAlbums", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b =>
{
b.HasBaseType("Musify.Domain.Entities.Album");
b.Property<long>("OwnerUserId")
.HasColumnType("bigint");
b.HasIndex("OwnerUserId");
b.ToTable("UserAlbums", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b =>
{
b.HasBaseType("Musify.Domain.Entities.Track");
b.Property<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)
@@ -880,6 +744,12 @@ namespace Musify.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Musify.Domain.Entities.Album", b =>
{
b.HasOne("Musify.Domain.Entities.User", "Owner")
.WithMany()
.HasForeignKey("OwnerUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 =>
{
b1.Property<Guid>("AlbumId")
@@ -917,6 +787,8 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasForeignKey("AlbumId");
});
b.Navigation("Owner");
b.Navigation("Pictures");
});
@@ -980,7 +852,8 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany()
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade);
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Mix");
@@ -1058,6 +931,12 @@ 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)
.IsRequired();
b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 =>
{
b1.Property<Guid>("TrackId")
@@ -1137,29 +1016,12 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("Audio")
.IsRequired();
b.Navigation("Owner");
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.TrackLike", b =>
{
b.HasOne("Musify.Domain.Entities.Track", "Track")
@@ -1228,68 +1090,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b =>
{
b.HasOne("Musify.Domain.Entities.Album", null)
.WithOne()
.HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b =>
{
b.HasOne("Musify.Domain.Entities.Album", null)
.WithOne()
.HasForeignKey("Musify.Domain.Entities.UserAlbum", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "Owner")
.WithMany()
.HasForeignKey("OwnerUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b =>
{
b.HasOne("Musify.Domain.Entities.Track", null)
.WithOne()
.HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b =>
{
b.HasOne("Musify.Domain.Entities.Track", null)
.WithOne()
.HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "Owner")
.WithMany()
.HasForeignKey("OwnerUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Musify.Domain.Entities.Album", b =>
{
b.Navigation("AlbumTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Artist", b =>
{
b.Navigation("TrackArtists");
});
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
{
b.Navigation("Items");
@@ -1319,11 +1124,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("UserTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b =>
{
b.Navigation("TrackArtists");
});
#pragma warning restore 612, 618
}
}
@@ -1,37 +0,0 @@
using ErrorOr;
using Musify.Application.Contracts;
namespace Musify.Infrastructure.Services
{
/// <summary>Stands in for <see cref="IYouTubeMusicService"/> when
/// <see cref="Musify.Infrastructure.Configuration.YouTubeConfiguration.Enabled"/> is <c>false</c>,
/// so the source can be turned off without a client ever being constructed. Every read fails
/// with the same <see cref="ErrorType.Forbidden"/> error; callers already treat a failed
/// YouTube call as "unavailable" (an HTTP error for direct requests, a skipped candidate for
/// mix generation), so nothing downstream needs to know the source is disabled specifically.</summary>
public sealed class DisabledYouTubeMusicService : IYouTubeMusicService
{
private static readonly Error DisabledError =
Error.Forbidden(description: "The YouTube Music source is disabled.");
public Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken) =>
Task.FromResult<ErrorOr<YouTubeSearchResult>>(DisabledError);
public Task<ErrorOr<YouTubeAlbumSearchResult>> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken) =>
Task.FromResult<ErrorOr<YouTubeAlbumSearchResult>>(DisabledError);
public Task<ErrorOr<YouTubeAlbumDetail>> GetAlbumAsync(string albumId, CancellationToken cancellationToken) =>
Task.FromResult<ErrorOr<YouTubeAlbumDetail>>(DisabledError);
public Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken) =>
Task.FromResult<ErrorOr<YouTubeStreamInfo>>(DisabledError);
public Task<ErrorOr<YouTubeSongResult>> GetSongAsync(string videoId, CancellationToken cancellationToken) =>
Task.FromResult<ErrorOr<YouTubeSongResult>>(DisabledError);
// Never reached in practice: every caller only reaches this after a successful search or
// song lookup above, neither of which can succeed while disabled. Passing the URL through
// unchanged is the safest thing to do if it's ever called anyway.
public string ResolveArtworkUrl(string thumbnailUrl) => thumbnailUrl;
}
}
@@ -1,7 +1,6 @@
using Amazon.S3;
using Hangfire;
using Hangfire.PostgreSql;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -99,33 +98,6 @@ namespace Musify.Infrastructure.Services
return serviceDescriptors;
}
public static IServiceCollection AddYouTubeMusicService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors.AddValidatedOptions<YouTubeConfiguration>(configuration, YouTubeConfiguration.SectionName);
serviceDescriptors.AddMemoryCache();
serviceDescriptors.AddSingleton<IYouTubeMusicService>(serviceProvider =>
{
var youTubeConfiguration = serviceProvider.GetRequiredService<YouTubeConfiguration>();
if (!youTubeConfiguration.Enabled)
return new DisabledYouTubeMusicService();
return new YouTubeMusicService(
youTubeConfiguration,
serviceProvider.GetRequiredService<IMemoryCache>(),
serviceProvider.GetRequiredService<ILogger<YouTubeMusicService>>());
} );
return serviceDescriptors;
}
public static IServiceCollection AddYouTubeDownloader(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors.AddValidatedOptions<YtDlpConfiguration>(configuration, YtDlpConfiguration.SectionName);
serviceDescriptors.AddScoped<IYouTubeDownloaderService, YouTubeDownloaderService>();
return serviceDescriptors;
}
public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
@@ -1,89 +0,0 @@
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.Configuration;
using System.Diagnostics;
namespace Musify.Infrastructure.Services
{
public class YouTubeDownloaderService(
ILogger<YouTubeDownloaderService> logger,
YtDlpConfiguration configuration)
: IYouTubeDownloaderService
{
public async Task<YouTubeDownloadResult> DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken)
{
var destinationDirectory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
var additionalArguments = string.IsNullOrWhiteSpace(configuration.AdditionalArguments)
? string.Empty
: $" {configuration.AdditionalArguments.Trim()}";
var arguments =
$"-f {configuration.Format} " +
"--no-playlist " +
$"-o \"{destinationFilePath}\"" +
additionalArguments +
$" -- {videoId}";
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = configuration.ExecutableName,
WorkingDirectory = destinationDirectory ?? Environment.CurrentDirectory,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
}
};
process.Start();
logger.LogInformation("yt-dlp started (PID: {ProcessId} ) for video {VideoId}", process.Id, videoId);
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linkedCancellationTokens.CancelAfter(configuration.DownloadTimeout);
try
{
await process.WaitForExitAsync(linkedCancellationTokens.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError("yt-dlp timed out after {Timeout} for video {VideoId} (PID: {ProcessId} )",
configuration.DownloadTimeout, videoId, process.Id);
TryKillProcess(process);
throw new TimeoutException($"yt-dlp execution exceeded timeout {configuration.DownloadTimeout}.");
}
var standardError = await errorTask;
var standardOutput = await outputTask;
logger.LogDebug("yt-dlp finished (PID: {ProcessId} ). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B",
process.Id, process.ExitCode, standardOutput.Length, standardError.Length);
return new YouTubeDownloadResult(process.ExitCode, standardError);
}
void TryKillProcess(Process process)
{
try
{
if (!process.HasExited)
process.Kill();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to kill yt-dlp process (PID: {ProcessId} )", process.Id);
}
}
}
}
@@ -1,310 +0,0 @@
using ErrorOr;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Helpers;
using YouTubeMusicAPI.Client;
using YouTubeMusicAPI.Models.Search;
using YouTubeMusicAPI.Models.Streaming;
using YouTubeMusicAPI.Pagination;
namespace Musify.Infrastructure.Services
{
public class YouTubeMusicService(YouTubeConfiguration configuration, IMemoryCache cache, ILogger<YouTubeMusicService> logger) : IYouTubeMusicService
{
private readonly YouTubeMusicClient client = new YouTubeMusicClient(geographicalLocation: configuration.GeographicalLocation);
private readonly SingleFlightCache singleFlight = new SingleFlightCache(cache);
private sealed record CachedStreamInfo(string Url, DateTime ExpiresAtUtc);
public async Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(continuationToken))
return await FetchSearchPageAsync(query, continuationToken, cancellationToken);
return await singleFlight.GetOrCreateAsync(
SearchResultsCacheKey(query),
TimeSpan.FromSeconds(configuration.SearchResultsCacheSeconds),
() => FetchSearchPageAsync(query, continuationToken, cancellationToken));
}
private sealed record SearchPage(IReadOnlyList<SearchResult> Results, string NextToken);
private async Task<ErrorOr<SearchPage>> FetchPageAsync(
string query,
SearchCategory category,
string continuationToken,
CancellationToken cancellationToken)
{
PaginatedAsyncEnumerable<SearchResult>? paginator;
if (string.IsNullOrEmpty(continuationToken))
{
paginator = client.SearchAsync(query, category);
}
else if (!cache.TryGetValue(SearchCacheKey(continuationToken), out paginator) || paginator is null)
{
return Error.NotFound(description: "The search continuation has expired.");
}
IReadOnlyList<SearchResult> results;
try
{
results = await paginator.FetchNextPageAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "YouTube Music {Category} search failed for query '{Query}'", category, query);
return Error.Failure(description: $"YouTube Music search failed: {exception.Message}");
}
var nextToken = string.Empty;
if (paginator.HasMore)
{
nextToken = Guid.NewGuid().ToString("N");
cache.Set(
SearchCacheKey(nextToken),
paginator,
TimeSpan.FromSeconds(configuration.SearchContinuationCacheSeconds));
}
return new SearchPage(results, nextToken);
}
private async Task<ErrorOr<YouTubeSearchResult>> FetchSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken)
{
var page = await FetchPageAsync(query, SearchCategory.Songs, continuationToken, cancellationToken);
if (page.IsError)
return page.Errors;
var items = page.Value.Results
.OfType<SongSearchResult>()
.Select(song => new YouTubeSongResult(
song.Id,
song.Name,
string.Join(", ", song.Artists.Select(artist => artist.Name)),
song.Album?.Name ?? string.Empty,
(int)song.Duration.TotalSeconds,
PickThumbnail(song.Thumbnails),
song.IsExplicit,
song.Artists
.Select(artist => new YouTubeArtistRef(
string.IsNullOrEmpty(artist.Id)? null: artist.Id,
artist.Name))
.ToList()))
.ToList();
return new YouTubeSearchResult(items, page.Value.NextToken);
}
public async Task<ErrorOr<YouTubeAlbumSearchResult>> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(continuationToken))
return await FetchAlbumSearchPageAsync(query, continuationToken, cancellationToken);
return await singleFlight.GetOrCreateAsync(
AlbumSearchResultsCacheKey(query),
TimeSpan.FromSeconds(configuration.SearchResultsCacheSeconds),
() => FetchAlbumSearchPageAsync(query, continuationToken, cancellationToken));
}
private async Task<ErrorOr<YouTubeAlbumSearchResult>> FetchAlbumSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken)
{
var page = await FetchPageAsync(query, SearchCategory.Albums, continuationToken, cancellationToken);
if (page.IsError)
return page.Errors;
var items = page.Value.Results
.OfType<AlbumSearchResult>()
.Select(ToAlbumResult)
.ToList();
return new YouTubeAlbumSearchResult(items, page.Value.NextToken);
}
public async Task<ErrorOr<YouTubeAlbumDetail>> GetAlbumAsync(string albumId, CancellationToken cancellationToken)
{
return await singleFlight.GetOrCreateAsync(
AlbumCacheKey(albumId),
TimeSpan.FromSeconds(configuration.SongInfoCacheSeconds),
() => FetchAlbumAsync(albumId, cancellationToken));
}
private async Task<ErrorOr<YouTubeAlbumDetail>> FetchAlbumAsync(string albumId, CancellationToken cancellationToken)
{
try
{
var browseId = await client.GetAlbumBrowseIdAsync(albumId, cancellationToken);
var info = await client.GetAlbumInfoAsync(browseId, cancellationToken);
var album = new YouTubeAlbumResult(
albumId,
info.Name,
string.Join(", ", info.Artists.Select(artist => artist.Name)),
PickThumbnail(info.Thumbnails),
info.ReleaseYear,
info.IsSingle,
info.IsEp,
info.Artists
.Select(artist => new YouTubeArtistRef(
string.IsNullOrEmpty(artist.Id)? null: artist.Id,
artist.Name))
.ToList());
var tracks = info.Songs
.Select((song, index) => new YouTubeAlbumTrack(
song.Id ?? string.Empty,
song.Name,
(int)song.Duration.TotalSeconds,
song.SongNumber ?? index + 1,
song.IsExplicit))
.Where(track => !string.IsNullOrEmpty(track.VideoId))
.ToList();
return new YouTubeAlbumDetail(
album,
StripWikipediaAttribution(info.Description),
(int)info.Duration.TotalSeconds,
tracks);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to load YouTube album '{AlbumId}'", albumId);
return Error.Failure(description: $"Failed to load YouTube album '{albumId}': {exception.Message}");
}
}
private static string StripWikipediaAttribution(string? description)
{
if (string.IsNullOrEmpty(description))
return string.Empty;
var markerIndex = description.IndexOf("From Wikipedia", StringComparison.OrdinalIgnoreCase);
var cleaned = markerIndex >= 0 ? description[..markerIndex] : description;
return cleaned.TrimEnd();
}
private YouTubeAlbumResult ToAlbumResult(AlbumSearchResult album) =>
new(
album.Id,
album.Name,
string.Join(", ", album.Artists.Select(artist => artist.Name)),
PickThumbnail(album.Thumbnails),
album.ReleaseYear,
album.IsSingle,
album.IsEp,
album.Artists
.Select(artist => new YouTubeArtistRef(
string.IsNullOrEmpty(artist.Id) ? null : artist.Id,
artist.Name))
.ToList());
public async Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
if (cache.TryGetValue(StreamCacheKey(videoId), out CachedStreamInfo? cached)
&& cached != null
&& cached.ExpiresAtUtc > now)
{
return new YouTubeStreamInfo(cached.Url, (int)(cached.ExpiresAtUtc - now).TotalSeconds);
}
StreamingData streamingData;
try
{
streamingData = await client.GetStreamingDataAsync(videoId, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to resolve YouTube stream for '{VideoId}'", videoId);
return Error.Failure(description: $"Failed to resolve YouTube stream for '{videoId}': {exception.Message}");
}
var audioStream = streamingData.StreamInfo
.OfType<AudioStreamInfo>()
.OrderByDescending(stream => stream.Bitrate)
.FirstOrDefault();
if (audioStream is null || string.IsNullOrEmpty(audioStream.Url))
return Error.NotFound(description: $"No audio stream available for '{videoId}'.");
var expiresInSeconds = Math.Min(
configuration.StreamUrlCacheSeconds,
(int)streamingData.ExpiresIn.TotalSeconds);
if (expiresInSeconds > 0)
{
cache.Set(
StreamCacheKey(videoId),
new CachedStreamInfo(audioStream.Url, now.AddSeconds(expiresInSeconds)),
TimeSpan.FromSeconds(expiresInSeconds));
}
return new YouTubeStreamInfo(audioStream.Url, expiresInSeconds);
}
public async Task<ErrorOr<YouTubeSongResult>> GetSongAsync(string videoId, CancellationToken cancellationToken)
{
return await singleFlight.GetOrCreateAsync(
SongInfoCacheKey(videoId),
TimeSpan.FromSeconds(configuration.SongInfoCacheSeconds),
() => FetchSongAsync(videoId, cancellationToken));
}
private async Task<ErrorOr<YouTubeSongResult>> FetchSongAsync(string videoId, CancellationToken cancellationToken)
{
try
{
var info = await client.GetSongVideoInfoAsync(videoId, cancellationToken);
return new YouTubeSongResult(
videoId,
info.Name,
string.Join(", ", info.Artists.Select(artist => artist.Name)),
info.Album?.Name ?? string.Empty,
(int)info.Duration.TotalSeconds,
PickThumbnail(info.Thumbnails),
info.IsExplicit,
info.Artists
.Select(artist => new YouTubeArtistRef(
string.IsNullOrEmpty(artist.Id)? null: artist.Id,
artist.Name))
.ToList());
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to load YouTube song info for '{VideoId}'", videoId);
return Error.Failure(description: $"Failed to load YouTube song info for '{videoId}': {exception.Message}");
}
}
public string ResolveArtworkUrl(string thumbnailUrl) =>
YouTubeThumbNailHelper.WithSize(thumbnailUrl, configuration.ArtworkSize);
private string PickThumbnail(IEnumerable<YouTubeMusicAPI.Models.Thumbnail> thumbnails)
{
var ordered = thumbnails.OrderBy(thumbnail => thumbnail.Width).ToList();
var square = ordered.Where(thumbnail => YouTubeThumbNailHelper.IsSquare(thumbnail.Url)).ToList();
var candidates = square.Count > 0 ? square : ordered;
var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize)
?? candidates.LastOrDefault();
return chosen ==null? string.Empty
: YouTubeThumbNailHelper.WithSize(chosen.Url, configuration.ThumbnailSize);
}
private static string SearchCacheKey(string token) => $"yt-search:{token}";
private static string SearchResultsCacheKey(string query) => $"yt-search-results:{query.Trim().ToLowerInvariant()}";
private static string AlbumSearchResultsCacheKey(string query) => $"yt-album-search:{query.Trim().ToLowerInvariant()}";
private static string AlbumCacheKey(string albumId) => $"yt-album:{albumId}";
private static string SongInfoCacheKey(string videoId) => $"yt-song:{videoId}";
private static string StreamCacheKey(string videoId) => $"yt-stream:{videoId}";
}
}
-1
View File
@@ -49,7 +49,6 @@
<PackageVersion Include="X.PagedList" Version="10.5.9" />
<PackageVersion Include="X.PagedList.EF" Version="10.5.9" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
<PackageVersion Include="YouTubeMusicAPI" Version="3.0.7" />
<!-- Test-only -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
@@ -36,9 +36,6 @@
"OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317"
},
"YouTube": {
"GeographicalLocation": "ES"
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": "../../../deploy/.tmp"
+1 -14
View File
@@ -53,21 +53,8 @@
"AnonymousFragmentSeconds": 0,
"EstimatedAudioBytesPerSecond": 16000
},
"YouTube": {
"Enabled": true,
"GeographicalLocation": "US",
"StreamUrlCacheSeconds": 1800,
"SearchContinuationCacheSeconds": 600,
"SearchResultsCacheSeconds": 900,
"SongInfoCacheSeconds": 86400,
"ThumbnailSize": 240,
"ArtworkSize": 544
},
"Mix": {
"ArtistMixCount": 3,
"ItemsPerMix": 12,
"SeedArtistCount": 3,
"YouTubeResultsPerSeed": 12
"ItemsPerMix": 12
},
"PlayList": {
"Routes": {
@@ -1,8 +1,3 @@
using Musify.Domain.ValueObjects;
namespace Musify.Api.DataTransferObjects.Likes;
/// <summary>Toggle a like on a track already in the catalog by <see cref="TrackId"/>, or one from an
/// external source by <see cref="Source"/> + <see cref="ExternalId"/> (provisioning it in the
/// background if needed) — exactly one of the two must be given.</summary>
public record ToggleLikeRequest(Guid? TrackId, TrackSource? Source, string? ExternalId);
public record ToggleLikeRequest(Guid TrackId);
@@ -1,8 +1,3 @@
using Musify.Domain.ValueObjects;
namespace Musify.Api.DataTransferObjects.PlayLists;
/// <summary>Add a track already in the catalog by <see cref="TrackId"/>, or one from an external
/// source by <see cref="Source"/> + <see cref="ExternalId"/> (provisioning it in the background if
/// needed) — exactly one of the two must be given.</summary>
public record AddPlayListTrackRequest(Guid? TrackId, TrackSource? Source, string? ExternalId);
public record AddPlayListTrackRequest(Guid TrackId);
@@ -8,8 +8,6 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Application.YouTube;
using Musify.Domain.ValueObjects;
namespace Musify.Api.Endpoints;
@@ -22,7 +20,7 @@ public static class AlbumEndpoints
group.MapGet("/", GetAlbums)
.WithName("GetAlbums")
.WithSummary("Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given.")
.WithSummary("Get Paginated Albums, Optionally Filtered By Title.")
.Produces<AlbumsSearchResponse>();
group.MapGet("/{id}", GetAlbumById)
@@ -31,12 +29,6 @@ public static class AlbumEndpoints
.Produces<AlbumApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/external/{source}/{externalId}", GetExternalAlbum)
.WithName("GetExternalAlbum")
.WithSummary("Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist.")
.Produces<YouTubeAlbumDetail>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetAlbumsByUserId)
.WithName("GetAlbumsByUserId")
.WithSummary("Get Paginated Albums For A User.")
@@ -125,10 +117,9 @@ public static class AlbumEndpoints
CancellationToken cancellationToken,
string? title,
int pageNumber = 1,
int pageSize = 10,
string? youtubeContinuationToken = null)
int pageSize = 10)
{
var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize, youtubeContinuationToken), cancellationToken);
var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
@@ -141,19 +132,6 @@ public static class AlbumEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> GetExternalAlbum(
IMediator mediator,
string source,
string externalId,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<TrackSource>(source, ignoreCase: true, out var trackSource) || trackSource != TrackSource.YouTube)
return Results.NotFound($"Unknown or unsupported source '{source}'.");
var result = await mediator.Send(new GetYouTubeAlbumQuery(externalId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetAlbumsByUserId(
IMediator mediator,
long userId,
@@ -25,10 +25,8 @@ public static class LikeEndpoints
group.MapPost("/toggle", ToggleTrackLike)
.WithName("ToggleTrackLike")
.WithSummary("Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed.")
.AddEndpointFilter<ValidationFilter<ToggleLikeRequest>>()
.WithSummary("Like Or Unlike A Track By Id.")
.Produces<bool>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
@@ -53,7 +51,7 @@ public static class LikeEndpoints
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId, request.Source, request.ExternalId),
new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId),
cancellationToken);
return result.ToHttpResult();
@@ -5,7 +5,6 @@ using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Application.Shared;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.PlayLists;
using Musify.Api.Extensions;
@@ -88,11 +87,9 @@ public static class PlayListEndpoints
group.MapPost("/{playlistId}/tracks", AddPlayListTrack)
.WithName("AddPlayListTrack")
.WithSummary("Add A Track To A PlayList, By Id Or By External Source, Provisioning It In The Background If Needed.")
.AddEndpointFilter<ValidationFilter<AddPlayListTrackRequest>>()
.WithSummary("Add One Of Your Tracks To A PlayList.")
.RequireAuthorization()
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
@@ -226,28 +223,17 @@ public static class PlayListEndpoints
Guid playlistId,
AddPlayListTrackRequest request,
CancellationToken cancellationToken)
{
if (request.TrackId is { } trackId)
{
var addResult = await mediator.Send(
new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, trackId),
new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.TrackId),
cancellationToken);
if (addResult.IsError)
return addResult.ToHttpResult();
var trackResult = await mediator.Send(new GetTrackByIdQuery(trackId), cancellationToken);
var trackResult = await mediator.Send(new GetTrackByIdQuery(request.TrackId), cancellationToken);
return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}");
}
// The validator guarantees Source/ExternalId are set whenever TrackId isn't, and that Source
// is one of the currently supported external sources (only YouTube for now).
var result = await mediator.Send(
new AddYouTubeTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.ExternalId!),
cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
}
private static async Task<IResult> RemoveTrackFromPlayList(
IMediator mediator,
CurrentUser currentUser,
@@ -3,9 +3,6 @@ using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Application.Shared;
using Musify.Application.Contracts;
using Musify.Application.YouTube;
using Musify.Application.YouTube.Responses;
using Musify.Domain.ValueObjects;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Extensions;
@@ -22,7 +19,7 @@ public static class TrackEndpoints
group.MapGet("/", GetTracks)
.WithName("GetTracks")
.WithSummary("Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given.")
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name.")
.Produces<TracksSearchResponse>();
group.MapGet("/{id}", GetTrackById)
@@ -44,14 +41,6 @@ public static class TrackEndpoints
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/external/{source}/{externalId}/stream", GetExternalTrackStream)
.WithName("GetExternalTrackStream")
.WithSummary("Resolve Playback For A Track From An External Source (Currently Only YouTube Music): Server Stream If Downloaded, Direct Stream From The Source Otherwise. Works Anonymously When The Playback Configuration Allows It.")
.Produces<YouTubeStreamResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapGet("/users/{userId}", GetTracksByUserId)
.WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User.")
@@ -96,10 +85,9 @@ public static class TrackEndpoints
CancellationToken cancellationToken,
string? name,
int pageNumber = 1,
int pageSize = 10,
string? youtubeContinuationToken = null)
int pageSize = 10)
{
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, youtubeContinuationToken), cancellationToken);
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
@@ -140,20 +128,6 @@ public static class TrackEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> GetExternalTrackStream(
IMediator mediator,
CurrentUser currentUser,
string source,
string externalId,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<TrackSource>(source, ignoreCase: true, out var trackSource) || trackSource != TrackSource.YouTube)
return Results.NotFound($"Unknown or unsupported source '{source}'.");
var result = await mediator.Send(new ResolveYouTubeTrackStreamCommand(externalId, currentUser.Id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTracksByUserId(
IMediator mediator,
long userId,
@@ -18,7 +18,6 @@ public static class ServiceCollectionExtensions
services.AddDatabase(configuration);
services.AddStorageService(configuration);
services.AddStreamTicketService(configuration);
services.AddYouTubeMusicService(configuration);
services.AddMassTransitClient(configuration);
services.AddAuthenticationConfiguration(configuration);
services.AddOpenApiConfiguration();
@@ -1,27 +0,0 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Likes;
using Musify.Domain.ValueObjects;
namespace Musify.Api.Validators.Likes;
public sealed class ToggleLikeRequestValidator : AbstractValidator<ToggleLikeRequest>
{
public ToggleLikeRequestValidator()
{
RuleFor(x => x)
.Must(x => x.TrackId.HasValue ^ (x.Source.HasValue && !string.IsNullOrEmpty(x.ExternalId)))
.WithMessage("Provide either trackId, or source and externalId, but not both.");
When(x => x.Source.HasValue, () =>
{
RuleFor(x => x.Source)
.Must(source => source == TrackSource.YouTube)
.WithMessage("Unsupported source.");
RuleFor(x => x.ExternalId)
.NotEmpty()
.Matches("^[A-Za-z0-9_-]{11}$")
.WithMessage("externalId must be a valid YouTube video id.");
});
}
}
@@ -1,27 +0,0 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.PlayLists;
using Musify.Domain.ValueObjects;
namespace Musify.Api.Validators.PlayLists;
public sealed class AddPlayListTrackRequestValidator : AbstractValidator<AddPlayListTrackRequest>
{
public AddPlayListTrackRequestValidator()
{
RuleFor(x => x)
.Must(x => x.TrackId.HasValue ^ (x.Source.HasValue && !string.IsNullOrEmpty(x.ExternalId)))
.WithMessage("Provide either trackId, or source and externalId, but not both.");
When(x => x.Source.HasValue, () =>
{
RuleFor(x => x.Source)
.Must(source => source == TrackSource.YouTube)
.WithMessage("Unsupported source.");
RuleFor(x => x.ExternalId)
.NotEmpty()
.Matches("^[A-Za-z0-9_-]{11}$")
.WithMessage("externalId must be a valid YouTube video id.");
});
}
}
@@ -16,9 +16,6 @@
"OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317"
},
"YouTube": {
"GeographicalLocation": "ES"
},
"InfrastructureStorage": {
"Address": "http://localhost:8333",
"AccessKey": "admin_access_key",
+1 -13
View File
@@ -26,14 +26,8 @@
"ApplicationStorage": {
"Bucket": ""
},
"YouTube": {
"Enabled": true
},
"Mix": {
"ArtistMixCount": 3,
"ItemsPerMix": 12,
"SeedArtistCount": 3,
"YouTubeResultsPerSeed": 12
"ItemsPerMix": 12
},
"PlayList": {
"Routes": {
@@ -83,12 +77,6 @@
},
"TranscodingTimeout": "00:05:00"
},
"YtDlp": {
"ExecutableName": "yt-dlp",
"Format": "bestaudio",
"AdditionalArguments": "",
"DownloadTimeout": "00:05:00"
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": ""
-4
View File
@@ -1,6 +1,5 @@
using Hangfire;
using Musify.Application.Configuration;
using Musify.Application.Services;
using Musify.Infrastructure.Jobs;
using Musify.Infrastructure.MassTransit;
using Musify.Infrastructure.Persistence;
@@ -29,7 +28,6 @@ services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfigurati
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddStorageService(configuration);
services.AddAudioTranscoder(configuration);
services.AddYouTubeDownloader(configuration);
services.AddHttpClient();
services.AddPictureService(configuration);
services.AddPlayListPresetSeeder(configuration);
@@ -37,8 +35,6 @@ services.AddDatabase(configuration);
services.AddMassTransitConsumers(configuration);
services.AddUploadIntentJobs(configuration);
services.AddMediator();
services.AddScoped<YouTubeTrackProvisioner>();
services.AddYouTubeMusicService(configuration);
services.AddDailyMixGenerationJob(configuration);
var host = builder.Build();
+3 -12
View File
@@ -17,18 +17,9 @@ RUN cp /app/AppSettings.json /app/appsettings.json \
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# ffmpeg: the Worker transcodes audio to .m4a. yt-dlp: downloads YouTube audio.
# yt-dlp is installed via pip in a venv rather than the standalone PyInstaller
# binary: the binary adds a ~1s unpack cost per invocation and extracts slower,
# so pip roughly halves resolution latency. deno solves nsig challenges.
# ffmpeg: the Worker transcodes uploaded audio to .m4a.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg python3 python3-venv ca-certificates curl unzip \
&& python3 -m venv /opt/ytdlp \
&& /opt/ytdlp/bin/pip install --no-cache-dir -U yt-dlp \
&& ln -s /opt/ytdlp/bin/yt-dlp /usr/local/bin/yt-dlp \
&& curl -fsSL https://deno.land/install.sh | sh -s -- -y \
&& mv /root/.deno/bin/deno /usr/local/bin/deno \
&& apt-get purge -y curl unzip \
&& rm -rf /var/lib/apt/lists/* /root/.deno
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app ./
ENTRYPOINT ["dotnet", "Musify.Worker.dll"]
@@ -25,8 +25,6 @@ namespace Musify.Api.Tests.TestSupport
public IEventBus EventBus { get; } = Substitute.For<IEventBus>();
public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For<IYouTubeMusicService>();
public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
public async ValueTask InitializeAsync()
@@ -88,7 +86,6 @@ namespace Musify.Api.Tests.TestSupport
{
services.Replace(ServiceDescriptor.Singleton(StorageService));
services.Replace(ServiceDescriptor.Scoped(_ => EventBus));
services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService));
services.Replace(ServiceDescriptor.Singleton(StreamTicketService));
services
@@ -15,8 +15,8 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_FirstTrack_AddsItAtNumberOne()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, album, track);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
@@ -30,7 +30,7 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken);
@@ -43,8 +43,8 @@ namespace Musify.Application.Tests.Albums
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, stranger, album, track);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
@@ -56,7 +56,7 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
@@ -69,8 +69,8 @@ namespace Musify.Application.Tests.Albums
{
var owner = TestEntities.User(1, "owner");
var otherOwner = TestEntities.User(2, "other");
var album = TestEntities.UserAlbum(owner.Id);
var foreignTrack = TestEntities.LocalTrack(otherOwner);
var album = TestEntities.Album(owner.Id);
var foreignTrack = TestEntities.Track(otherOwner);
await SeedAsync(owner, otherOwner, album, foreignTrack);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken);
@@ -82,8 +82,8 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_AlreadyInAlbum_ReturnsConflict()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
await SeedAsync(owner, album, track, link);
@@ -96,9 +96,9 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var firstTrack = TestEntities.LocalTrack(owner, "First");
var secondTrack = TestEntities.LocalTrack(owner, "Second");
var album = TestEntities.Album(owner.Id);
var firstTrack = TestEntities.Track(owner, "First");
var secondTrack = TestEntities.Track(owner, "Second");
var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 };
await SeedAsync(owner, album, firstTrack, secondTrack, existingLink);
@@ -44,7 +44,7 @@ namespace Musify.Application.Tests.Albums
Assert.Equal(owner.Id, result.Value.OwnerUserId);
Assert.Equal(0, result.Value.TrackCount);
var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
var stored = await Database.Albums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("MY ALBUM", stored.NormalizedTitle);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
@@ -13,13 +13,13 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_Owner_RemovesAlbum()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Null(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
}
[Fact]
@@ -35,13 +35,13 @@ namespace Musify.Application.Tests.Albums
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.NotNull(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
}
}
}
@@ -14,8 +14,8 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken);
@@ -17,7 +17,7 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken);
@@ -14,9 +14,9 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var second = TestEntities.LocalTrack(owner, "Second");
var first = TestEntities.LocalTrack(owner, "First");
var album = TestEntities.Album(owner.Id);
var second = TestEntities.Track(owner, "Second");
var first = TestEntities.Track(owner, "First");
await SeedAsync(
owner, album, second, first,
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
@@ -14,7 +14,7 @@ namespace Musify.Application.Tests.Albums
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
await SeedAsync(owner, other, TestEntities.UserAlbum(owner.Id, "Mine"), TestEntities.UserAlbum(other.Id, "Theirs"));
await SeedAsync(owner, other, TestEntities.Album(owner.Id, "Mine"), TestEntities.Album(other.Id, "Theirs"));
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);

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