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,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);
}