refactor(backend): group track pipeline state into owned types

Track mixed catalog fields (Title, Artist credit, Duration) with
picture-processing and audio-transcode pipeline state as ten flat
columns plus two computed [NotMapped] flags, making the entity read
like a media-pipeline worksheet.

Introduce TrackPictures and TrackAudio as EF owned types mapped onto
the same existing columns (explicit column names, no schema change -
verified via `dotnet ef migrations has-pending-model-changes`).
Track now reads as Title/Artist/Duration/Pictures/Audio instead of
fourteen loose properties.
This commit is contained in:
2026-07-17 17:58:56 +02:00
parent 64e1f3903e
commit daec1a8d05
19 changed files with 270 additions and 124 deletions
@@ -51,19 +51,19 @@ namespace Musify.Application.PlayLists
var track = provisionResult.Value.Track;
var failed = track.AudioTranscodeProcessingStatus == ProcessingStatus.Failed;
var neverQueued = !track.DownloadRequested && !track.IsAudioProcessed;
var failed = track.Audio.TranscodeStatus == ProcessingStatus.Failed;
var neverQueued = !track.Audio.DownloadRequested && !track.Audio.IsProcessed;
if (failed || neverQueued)
{
if (failed)
{
track.AudioTranscodeProcessingStatus = ProcessingStatus.Pending;
track.PicturesProcessingStatus = ProcessingStatus.Pending;
track.Audio.TranscodeStatus = ProcessingStatus.Pending;
track.Pictures.ProcessingStatus = ProcessingStatus.Pending;
track.LifeCycleStatus = LifeCycleStatus.Active;
track.RetryCount += 1;
track.LastRetryAt = DateTime.UtcNow;
track.Audio.RetryCount += 1;
track.Audio.LastRetryAt = DateTime.UtcNow;
}
track.DownloadRequested = true;
track.Audio.DownloadRequested = true;
var thumbnailUrl = await ResolveThumbnailUrlAsync(request.VideoId, provisionResult.Value.Song, cancellationToken);
if (thumbnailUrl.IsError)
@@ -50,15 +50,19 @@ public sealed class YouTubeTrackProvisioner(
NormalizedTitle = title.ToUpperInvariant(),
Source = TrackSource.YouTube,
ExternalId = videoId,
DownloadRequested = false,
Duration = song.DurationSeconds,
OriginalPictureName = null,
OriginalAudioName = null,
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
PicturesProcessingStatus = ProcessingStatus.Pending,
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
Pictures = new TrackPictures
{
SmallName = trackConfiguration.Routes.PresetSmallPicture,
MediumName = trackConfiguration.Routes.PresetMediumPicture,
LargeName = trackConfiguration.Routes.PresetLargePicture,
ProcessingStatus = ProcessingStatus.Pending
},
Audio = new TrackAudio
{
DownloadRequested = false,
TranscodeStatus = ProcessingStatus.Pending
}
};
await database.Tracks.AddAsync(track, cancellationToken);
@@ -66,13 +66,19 @@ namespace Musify.Application.Tracks
Title = request.Title,
NormalizedTitle = request.Title.ToUpperInvariant(),
OwnerUserId = user.Id,
OriginalPictureName = pictureIntent.ObjectName,
OriginalAudioName = audioIntent.ObjectName,
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
PicturesProcessingStatus = ProcessingStatus.Pending,
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
Pictures = new TrackPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = trackConfiguration.Routes.PresetSmallPicture,
MediumName = trackConfiguration.Routes.PresetMediumPicture,
LargeName = trackConfiguration.Routes.PresetLargePicture,
ProcessingStatus = ProcessingStatus.Pending
},
Audio = new TrackAudio
{
OriginalName = audioIntent.ObjectName,
TranscodeStatus = ProcessingStatus.Pending
}
};
await database.Tracks.AddAsync(trackEntity, cancellationToken);
@@ -34,8 +34,8 @@ namespace Musify.Application.Tracks
return Error.Unauthorized();
}
if (track.AudioTranscodeProcessingStatus == ProcessingStatus.Processing
|| track.AudioTranscodeProcessingStatus == ProcessingStatus.Pending)
if (track.Audio.TranscodeStatus == ProcessingStatus.Processing
|| track.Audio.TranscodeStatus == ProcessingStatus.Pending)
{
logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId);
return Error.Conflict(description: "Track is currently being processed and cannot be deleted");
@@ -21,7 +21,7 @@ namespace Musify.Application.Tracks
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Select(t => new { t.SmallPictureName, t.MediumPictureName, t.LargePictureName })
.Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName })
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
@@ -30,9 +30,9 @@ namespace Musify.Application.Tracks
var routes = trackConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (track.SmallPictureName, BuildKey(routes.BuildSmallPicturePath, track.SmallPictureName)),
"large" => (track.LargePictureName, BuildKey(routes.BuildLargePicturePath, track.LargePictureName)),
_ => (track.MediumPictureName, BuildKey(routes.BuildMediumPicturePath, track.MediumPictureName))
"small" => (track.SmallName, BuildKey(routes.BuildSmallPicturePath, track.SmallName)),
"large" => (track.LargeName, BuildKey(routes.BuildLargePicturePath, track.LargeName)),
_ => (track.MediumName, BuildKey(routes.BuildMediumPicturePath, track.MediumName))
};
if (string.IsNullOrEmpty(name) || key == null)
@@ -22,20 +22,20 @@ namespace Musify.Application.Tracks
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Select(t => new { t.Id, t.AudioTranscodeProcessingStatus, t.AudioFolderName } )
.Select(t => new { t.Id, t.Audio.TranscodeStatus, t.Audio.FolderName } )
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return Error.NotFound();
if (string.IsNullOrWhiteSpace(track.AudioFolderName)
|| track.AudioTranscodeProcessingStatus != ProcessingStatus.Completed)
if (string.IsNullOrWhiteSpace(track.FolderName)
|| track.TranscodeStatus != ProcessingStatus.Completed)
{
logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId);
return Error.Conflict(description: "Track audio is not available for streaming yet.");
}
var response = await streamIssuer.IssueAsync(track.Id, track.AudioFolderName, request.UserId, cancellationToken);
var response = await streamIssuer.IssueAsync(track.Id, track.FolderName, request.UserId, cancellationToken);
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId);
@@ -24,7 +24,7 @@ namespace Musify.Application.Tracks.Responses
FormatArtist(track),
track.Source,
track.ExternalId,
track.AudioTranscodeProcessingStatus,
track.Audio.TranscodeStatus,
track.Duration,
listensCount,
track.OwnerUserId,
@@ -24,9 +24,9 @@ namespace Musify.Application.YouTube
{
var track = await provisioner.FindAsync(request.VideoId, cancellationToken);
if (track is not null && track.IsAudioProcessed)
if (track is not null && track.Audio.IsProcessed)
{
var serverStream = await streamIssuer.IssueAsync(track.Id, track.AudioFolderName, request.UserId, cancellationToken);
var serverStream = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
return new YouTubeStreamResponse(
YouTubeStreamMode.Server,
serverStream.ManifestUrl,