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
@@ -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}";
}
}