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
@@ -59,7 +59,7 @@ namespace Musify.Application.Tracks
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
var trackEntity = new LocalTrack
var trackEntity = new Track
{
Title = request.Title,
NormalizedTitle = request.Title.ToUpperInvariant(),
@@ -80,7 +80,7 @@ namespace Musify.Application.Tracks
}
};
await database.LocalTracks.AddAsync(trackEntity, cancellationToken);
await database.Tracks.AddAsync(trackEntity, cancellationToken);
await database.UserHasTracks.AddAsync(new UserHasTrack
{
@@ -29,7 +29,7 @@ namespace Musify.Application.Tracks
return Error.NotFound();
}
if (track is not LocalTrack localTrack || localTrack.OwnerUserId != request.UserId)
if (track.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
@@ -2,7 +2,6 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks
{
@@ -25,9 +24,7 @@ namespace Musify.Application.Tracks
.ToListAsync(cancellationToken);
var tracksById = await database.Tracks
.Include(t => ((ExternalTrack)t).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(t => ((LocalTrack)t).Owner)
.Include(t => t.Owner)
.Where(t => recentTrackIds.Contains(t.Id))
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } )
.ToDictionaryAsync(t => t.Id, cancellationToken);
@@ -3,7 +3,6 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks
{
@@ -16,9 +15,7 @@ namespace Musify.Application.Tracks
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
{
var entity = await database.Tracks.AsNoTracking()
.Include(t => ((ExternalTrack)t).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(t => ((LocalTrack)t).Owner)
.Include(t => t.Owner)
.Where(t => t.Id == request.TrackId)
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
.SingleOrDefaultAsync(cancellationToken);
@@ -1,37 +1,24 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, string? YoutubeContinuationToken = null)
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
: IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(
IDatabase database,
IYouTubeMusicService youTubeMusicService,
ILogger<GetTracksQueryHandler> logger)
public class GetTracksQueryHandler(IDatabase database)
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
{
// Mirrors the web client's search-box debounce threshold: a query shorter than this is too
// likely to still be mid-keystroke to justify a live call to the YouTube Music API for every
// client that hits this endpoint.
private const int MinNameLengthForYouTubeSearch = 2;
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
{
var tracksQuery = database.UserHasTracks
.AsNoTracking()
.Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(ut => ((LocalTrack)ut.Track).Owner)
.Include(ut => ut.Track.Owner)
.AsQueryable();
if (!string.IsNullOrEmpty(request.Name))
@@ -48,61 +35,18 @@ namespace Musify.Application.Tracks
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var localItems = pagedEntities
var items = pagedEntities
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
.ToList();
var knownVideoIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in pagedEntities)
{
if (entry.Track is ExternalTrack { Source: TrackSource.YouTube } external)
knownVideoIds.Add(external.ExternalId);
}
var youtubeItems = new List<TrackSearchItemResponse>();
string? nextContinuationToken = null;
var youtubeUnavailable = false;
if (request.Name?.Trim().Length >= MinNameLengthForYouTubeSearch)
(youtubeItems, nextContinuationToken, youtubeUnavailable) =
await SearchYouTubeAsync(request.Name, request.YoutubeContinuationToken, knownVideoIds, cancellationToken);
return TracksSearchResponse.Create(
[.. localItems, .. youtubeItems],
return new TracksSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage,
nextContinuationToken,
youtubeUnavailable);
}
private async Task<(List<TrackSearchItemResponse> Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync(
string name,
string? continuationToken,
HashSet<string> knownVideoIds,
CancellationToken cancellationToken)
{
var searchResult = await youTubeMusicService.SearchSongsAsync(name, continuationToken ?? string.Empty, cancellationToken);
if (searchResult.IsError)
{
logger.LogInformation(
"YouTube search unavailable while combining track results: {Error}", searchResult.FirstError.Description);
return ([], null, true);
}
var items = searchResult.Value.Items
.Where(song => knownVideoIds.Add(song.VideoId))
.Select(TrackSearchItemResponse.FromYouTubeSong)
.ToList();
var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken)
? null
: searchResult.Value.ContinuationToken;
return (items, nextToken, false);
pagedEntities.HasNextPage);
}
}
}
@@ -6,7 +6,6 @@ using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks
{
@@ -27,9 +26,7 @@ namespace Musify.Application.Tracks
var tracksQuery = database.UserHasTracks
.AsNoTracking()
.Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(ut => ((LocalTrack)ut.Track).Owner)
.Include(ut => ut.Track.Owner)
.Where(p => p.UserId == request.UserId);
if (!string.IsNullOrEmpty(request.Name))
@@ -1,30 +0,0 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses
{
public record ExternalTrackApplicationResponse(
Guid Id,
string Title,
string? Artist,
ProcessingStatus AudioStatus,
double Duration,
int ListensCount,
DateTime CreatedAt,
DateTime UpdatedAt,
string ExternalId)
: TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt)
{
public static ExternalTrackApplicationResponse FromEntity(ExternalTrack track, int listensCount) =>
new(
track.Id,
track.Title,
FormatArtist(track),
track.Audio.TranscodeStatus,
track.DurationSeconds,
listensCount,
track.CreatedAt,
track.UpdatedAt,
track.ExternalId);
}
}
@@ -1,32 +0,0 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses
{
public record LocalTrackApplicationResponse(
Guid Id,
string Title,
string? Artist,
ProcessingStatus AudioStatus,
double Duration,
int ListensCount,
DateTime CreatedAt,
DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId)
: TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt)
{
public static LocalTrackApplicationResponse FromEntity(LocalTrack track, int listensCount) =>
new(
track.Id,
track.Title,
FormatArtist(track),
track.Audio.TranscodeStatus,
track.DurationSeconds,
listensCount,
track.CreatedAt,
track.UpdatedAt,
track.OwnerUserId);
}
}
@@ -1,24 +1,11 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using System.Text.Json.Serialization;
namespace Musify.Application.Tracks.Responses
{
// See AlbumApplicationResponse for why [JsonPolymorphic]/[JsonDerivedType] are required
// (not optional) whenever a base type carries subtype-only data across the wire. Track is a
// cleaner case than Album: both OwnerUserId (local-only) and ExternalId (external-only) are
// genuinely exclusive to one side, so the base has no "meaningless sentinel" field at all —
// every real value is one of the two derived types, hence base is abstract.
//
// No explicit Source property here (unlike the domain entities): STJ's polymorphism support
// already injects a "source" discriminator property with the same "Local"/"YouTube" values a
// hand-written TrackSource field would have, and a real property with that exact name would
// collide with it ("contains property 'source' that conflicts with an existing metadata
// property name"). Use the discriminator (or a type check) instead of a Source field.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "source")]
[JsonDerivedType(typeof(LocalTrackApplicationResponse), typeDiscriminator: "Local")]
[JsonDerivedType(typeof(ExternalTrackApplicationResponse), typeDiscriminator: "YouTube")]
public abstract record TrackApplicationResponse(
public record TrackApplicationResponse(
Guid Id,
string Title,
string? Artist,
@@ -26,24 +13,19 @@ namespace Musify.Application.Tracks.Responses
double Duration,
int ListensCount,
DateTime CreatedAt,
DateTime UpdatedAt)
DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId)
{
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
track switch
{
LocalTrack local => LocalTrackApplicationResponse.FromEntity(local, listensCount),
ExternalTrack external => ExternalTrackApplicationResponse.FromEntity(external, listensCount),
_ => throw new NotSupportedException($"Unsupported track entity type '{track.GetType().Name}'.")
};
protected static string? FormatArtist(Track track) => track switch
{
LocalTrack local => local.Owner.Name,
ExternalTrack { TrackArtists.Count: > 0 } external =>
string.Join(", ", external.TrackArtists
.OrderBy(trackArtist => trackArtist.Position)
.Select(trackArtist => trackArtist.Artist.Name)),
_ => null
};
new(
track.Id,
track.Title,
track.Owner.Name,
track.Audio.TranscodeStatus,
track.DurationSeconds,
listensCount,
track.CreatedAt,
track.UpdatedAt,
track.OwnerUserId);
}
}
@@ -1,22 +1,7 @@
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses
{
/// <summary>One item of a combined local/YouTube track listing. Exactly one of
/// <see cref="Track"/> or <see cref="YouTubeSong"/> is populated: <see cref="Track"/> for
/// anything already in the catalog (local uploads and YouTube tracks provisioned earlier),
/// <see cref="YouTubeSong"/> for a live YouTube Music search hit that has not been
/// provisioned yet, so it has no track id.</summary>
public record TrackSearchItemResponse(
TrackSource Source,
TrackApplicationResponse? Track,
YouTubeSongResult? YouTubeSong)
public record TrackSearchItemResponse(TrackApplicationResponse Track)
{
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) =>
new(track is ExternalTrackApplicationResponse ? TrackSource.YouTube : TrackSource.Local, track, null);
public static TrackSearchItemResponse FromYouTubeSong(YouTubeSongResult song) =>
new(TrackSource.YouTube, null, song);
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track);
}
}
@@ -1,11 +1,6 @@
namespace Musify.Application.Tracks.Responses
{
/// <summary>Result of a combined tracks listing/search: a page of local catalog tracks
/// (uploads and previously provisioned YouTube tracks), plus, when a search term was given,
/// live YouTube Music hits appended after them. Pagination metadata (<see cref="PageNumber"/>,
/// <see cref="PageSize"/>, <see cref="PageCount"/>, <see cref="TotalItemCount"/>) describes only
/// the local page — YouTube has no stable total, so its continuation is tracked separately via
/// <see cref="NextYoutubeContinuationToken"/>.</summary>
/// <summary>A page of the user's catalog tracks (their own uploads).</summary>
public record TracksSearchResponse(
IReadOnlyList<TrackSearchItemResponse> Items,
int PageNumber,
@@ -13,26 +8,5 @@ namespace Musify.Application.Tracks.Responses
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextLocalPage,
string? NextYoutubeContinuationToken,
bool YoutubeUnavailable,
bool HasNextPage)
{
// HasNextPage is a real field (not a computed property) so it shows up as a required member
// of the generated OpenAPI schema, same as every other field here — a get-only property added
// outside the primary constructor gets reflected as optional instead, which is never true here.
public static TracksSearchResponse Create(
IReadOnlyList<TrackSearchItemResponse> items,
int pageNumber,
int pageSize,
int pageCount,
int totalItemCount,
bool hasPreviousPage,
bool hasNextLocalPage,
string? nextYoutubeContinuationToken,
bool youtubeUnavailable) =>
new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage,
nextYoutubeContinuationToken, youtubeUnavailable,
HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken));
}
bool HasNextPage);
}