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:
@@ -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>
|
||||
|
||||
-94
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
namespace Musify.Infrastructure.MassTransit.Arguments
|
||||
{
|
||||
public record DownloadYouTubeAudioArguments(
|
||||
Guid TrackId,
|
||||
string VideoId,
|
||||
string DestinationFilePathVariable);
|
||||
}
|
||||
-26
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-3
@@ -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";
|
||||
|
||||
-166
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-25
@@ -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>();
|
||||
|
||||
+1133
File diff suppressed because it is too large
Load Diff
+381
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-229
@@ -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}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user