feat(backend): record listening history for YouTube playback
Playing a YouTube song now creates a lightweight track row (without queueing a download) and writes listening history; saving to a playlist queues the download via the new DownloadRequested flag.
This commit is contained in:
@@ -21,5 +21,7 @@ namespace Musify.Application.Contracts
|
||||
Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken);
|
||||
|
||||
Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken);
|
||||
|
||||
Task<ErrorOr<YouTubeSongResult>> GetSongAsync(string videoId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,16 +90,22 @@ namespace Musify.Application.PlayLists
|
||||
|
||||
if (track is not null)
|
||||
{
|
||||
if (track.AudioTranscodeProcessingStatus == ProcessingStatus.Failed)
|
||||
var failed = track.AudioTranscodeProcessingStatus == ProcessingStatus.Failed;
|
||||
var neverQueued = !track.DownloadRequested && !track.IsAudioProcessed;
|
||||
if (failed || neverQueued)
|
||||
{
|
||||
track.AudioTranscodeProcessingStatus = ProcessingStatus.Pending;
|
||||
track.PicturesProcessingStatus = ProcessingStatus.Pending;
|
||||
track.LifeCycleStatus = LifeCycleStatus.Active;
|
||||
track.RetryCount += 1;
|
||||
track.LastRetryAt = DateTime.UtcNow;
|
||||
if (failed)
|
||||
{
|
||||
track.AudioTranscodeProcessingStatus = ProcessingStatus.Pending;
|
||||
track.PicturesProcessingStatus = ProcessingStatus.Pending;
|
||||
track.LifeCycleStatus = LifeCycleStatus.Active;
|
||||
track.RetryCount += 1;
|
||||
track.LastRetryAt = DateTime.UtcNow;
|
||||
}
|
||||
track.DownloadRequested = true;
|
||||
await PublishDownloadEventAsync(track.Id, request, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Re-queued failed YouTube track {VideoId} for download", request.VideoId);
|
||||
logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId);
|
||||
}
|
||||
return track;
|
||||
}
|
||||
@@ -112,6 +118,7 @@ namespace Musify.Application.PlayLists
|
||||
Artist = Truncate(request.Artist, 200),
|
||||
Source = TrackSource.YouTube,
|
||||
ExternalId = request.VideoId,
|
||||
DownloadRequested = true,
|
||||
Duration = request.DurationSeconds,
|
||||
OriginalPictureName = string.Empty,
|
||||
OriginalAudioName = string.Empty,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.YouTube.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.YouTube
|
||||
@@ -14,19 +17,17 @@ namespace Musify.Application.YouTube
|
||||
public class GetYouTubeTrackStreamQueryHandler(
|
||||
IDatabase database,
|
||||
IYouTubeMusicService youTubeMusicService,
|
||||
TrackStreamIssuer streamIssuer)
|
||||
TrackStreamIssuer streamIssuer,
|
||||
TrackConfiguration trackConfiguration,
|
||||
ILogger<GetYouTubeTrackStreamQueryHandler> logger)
|
||||
: IQueryHandler<GetYouTubeTrackStreamQuery, ErrorOr<YouTubeStreamResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<YouTubeStreamResponse>> Handle(GetYouTubeTrackStreamQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var track = await database.Tracks.AsNoTracking()
|
||||
.Where(t => t.Source == TrackSource.YouTube && t.ExternalId == request.VideoId)
|
||||
.Select(t => new { t.Id, t.AudioTranscodeProcessingStatus, t.AudioFolderName } )
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
var track = await database.Tracks
|
||||
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == request.VideoId, cancellationToken);
|
||||
|
||||
if (track != null
|
||||
&& !string.IsNullOrWhiteSpace(track.AudioFolderName)
|
||||
&& track.AudioTranscodeProcessingStatus == ProcessingStatus.Completed)
|
||||
if (track is not null && track.IsAudioProcessed)
|
||||
{
|
||||
var serverStream = await streamIssuer.IssueAsync(track.Id, track.AudioFolderName, request.UserId, cancellationToken);
|
||||
return new YouTubeStreamResponse(
|
||||
@@ -41,12 +42,81 @@ namespace Musify.Application.YouTube
|
||||
if (streamInfo.IsError)
|
||||
return streamInfo.Errors;
|
||||
|
||||
track ??= await CreateTrackFromYouTubeAsync(request.VideoId, cancellationToken);
|
||||
|
||||
if (track is not null)
|
||||
{
|
||||
await database.ListeningHistories.AddAsync(new ListeningHistory
|
||||
{
|
||||
UserId = request.UserId,
|
||||
TrackId = track.Id,
|
||||
}, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to record listening history for YouTube video {VideoId}", request.VideoId);
|
||||
}
|
||||
}
|
||||
|
||||
return new YouTubeStreamResponse(
|
||||
YouTubeStreamMode.YouTube,
|
||||
streamInfo.Value.Url,
|
||||
string.Empty,
|
||||
streamInfo.Value.ExpiresInSeconds,
|
||||
Guid.Empty);
|
||||
track?.Id ?? Guid.Empty);
|
||||
}
|
||||
|
||||
private async Task<Track?> CreateTrackFromYouTubeAsync(string videoId, CancellationToken cancellationToken)
|
||||
{
|
||||
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
|
||||
if (songResult.IsError)
|
||||
{
|
||||
logger.LogWarning("Could not load YouTube metadata for {VideoId}: {Error}", videoId, songResult.FirstError.Description);
|
||||
return null;
|
||||
}
|
||||
|
||||
var song = songResult.Value;
|
||||
var title = Truncate(song.Title, 50);
|
||||
|
||||
var track = new Track
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Artist = Truncate(song.Artist, 200),
|
||||
Source = TrackSource.YouTube,
|
||||
ExternalId = videoId,
|
||||
DownloadRequested = false,
|
||||
Duration = song.DurationSeconds,
|
||||
OriginalPictureName = string.Empty,
|
||||
OriginalAudioName = string.Empty,
|
||||
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
|
||||
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
|
||||
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
|
||||
PicturesProcessingStatus = ProcessingStatus.Pending,
|
||||
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
|
||||
};
|
||||
|
||||
await database.Tracks.AddAsync(track, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Created YouTube track {VideoId} ({TrackId}) from playback", videoId, track.Id);
|
||||
return track;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
database.Tracks.Remove(track);
|
||||
return await database.Tracks
|
||||
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength) =>
|
||||
value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user