forked from xiongyuxing/tiku-backend.net
275 lines
11 KiB
C#
275 lines
11 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Assets;
|
|
using Tiku.Application.Catalog;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.QuestionBanks;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Assets;
|
|
|
|
public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlaybackService
|
|
{
|
|
public async Task<CatalogList<VideoExplanationCatalogItem>> SearchAsync(
|
|
VideoPlaybackActor actor,
|
|
VideoSearchQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertActiveMemberAsync(actor, cancellationToken);
|
|
var videos = dbContext.VideoExplanations.AsNoTracking()
|
|
.Where(video => video.TenantId == actor.TenantId && video.IsActive);
|
|
if (query.SubjectId.HasValue)
|
|
{
|
|
videos = videos.Where(video => video.SubjectId == query.SubjectId.Value || video.SubjectId == null);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.Keyword))
|
|
{
|
|
var keyword = query.Keyword.Trim();
|
|
videos = videos.Where(video =>
|
|
video.Title.Contains(keyword) ||
|
|
(video.Description != null && video.Description.Contains(keyword)));
|
|
}
|
|
|
|
var items = await videos
|
|
.OrderBy(video => video.SortOrder)
|
|
.ThenBy(video => video.Title)
|
|
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
|
.Select(video => ToVideoItem(video))
|
|
.ToArrayAsync(cancellationToken);
|
|
return new CatalogList<VideoExplanationCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<VideoPlaybackItem> PlayAsync(
|
|
VideoPlaybackActor actor,
|
|
VideoPlayCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertActiveMemberAsync(actor, cancellationToken);
|
|
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
|
|
if (command.QuestionId.HasValue)
|
|
{
|
|
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken);
|
|
}
|
|
|
|
var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
|
|
progress.PlayCount++;
|
|
progress.LastPlayedAt = DateTimeOffset.UtcNow;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
ActorRole = AssetAccessActorRole.Student,
|
|
AccessType = AssetAccessType.Preview,
|
|
AssetType = ContentAssetType.Video.ToString(),
|
|
Result = AssetAccessResult.Granted,
|
|
Metadata = JsonSerializer.SerializeToElement(new
|
|
{
|
|
videoId = video.Id,
|
|
command.QuestionId,
|
|
source = "student_video_play"
|
|
})
|
|
});
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
return new VideoPlaybackItem(
|
|
video.Id,
|
|
command.QuestionId,
|
|
video.Title,
|
|
video.Description,
|
|
video.VideoUrl,
|
|
video.ThumbnailUrl,
|
|
video.DurationSeconds,
|
|
ToProgressItem(progress),
|
|
video.Metadata);
|
|
}
|
|
|
|
public async Task<VideoProgressItem> ReportProgressAsync(
|
|
VideoPlaybackActor actor,
|
|
VideoProgressCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertActiveMemberAsync(actor, cancellationToken);
|
|
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
|
|
if (command.QuestionId.HasValue)
|
|
{
|
|
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken);
|
|
}
|
|
|
|
var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
|
|
var positionSeconds = Math.Max(command.PositionSeconds, 0);
|
|
var durationSeconds = command.DurationSeconds ?? video.DurationSeconds;
|
|
var watchedSeconds = Math.Max(command.WatchedSeconds ?? positionSeconds, progress.WatchedSeconds);
|
|
var completed = command.IsCompleted == true ||
|
|
durationSeconds is > 0 && positionSeconds >= Math.Max(0, durationSeconds.Value - 3);
|
|
progress.PositionSeconds = positionSeconds;
|
|
progress.DurationSeconds = durationSeconds;
|
|
progress.WatchedSeconds = watchedSeconds;
|
|
progress.IsCompleted = completed;
|
|
progress.CompletedAt = completed ? progress.CompletedAt ?? DateTimeOffset.UtcNow : progress.CompletedAt;
|
|
progress.LastPlayedAt = DateTimeOffset.UtcNow;
|
|
progress.Metadata = command.Metadata.ValueKind == JsonValueKind.Object ? command.Metadata.Clone() : JsonDefaults.Object();
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return ToProgressItem(progress);
|
|
}
|
|
|
|
public async Task<CatalogList<QuestionVideoCatalogItem>> GetQuestionVideosAsync(
|
|
VideoPlaybackActor actor,
|
|
QuestionVideoQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertActiveMemberAsync(actor, cancellationToken);
|
|
var questionVideos = dbContext.QuestionVideos.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId);
|
|
if (query.QuestionId.HasValue)
|
|
{
|
|
questionVideos = questionVideos.Where(item => item.QuestionId == query.QuestionId.Value);
|
|
}
|
|
|
|
if (query.QuestionIds is { Count: > 0 })
|
|
{
|
|
questionVideos = questionVideos.Where(item => item.QuestionId.HasValue && query.QuestionIds.Contains(item.QuestionId.Value));
|
|
}
|
|
|
|
var videos = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId && item.IsActive);
|
|
var items = await questionVideos
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.CreatedAt)
|
|
.Take(Math.Clamp(query.Limit ?? 100, 1, 500))
|
|
.GroupJoin(
|
|
videos,
|
|
questionVideo => new { questionVideo.TenantId, Id = questionVideo.VideoId },
|
|
video => new { video.TenantId, Id = (Guid?)video.Id },
|
|
(questionVideo, matchedVideos) => new
|
|
{
|
|
QuestionVideo = questionVideo,
|
|
Video = matchedVideos.FirstOrDefault()
|
|
})
|
|
.Select(row => new QuestionVideoCatalogItem(
|
|
row.QuestionVideo.Id,
|
|
row.QuestionVideo.LegacyId,
|
|
row.QuestionVideo.QuestionId,
|
|
row.QuestionVideo.VideoId,
|
|
row.QuestionVideo.VideoType,
|
|
row.QuestionVideo.SortOrder,
|
|
row.QuestionVideo.Metadata,
|
|
row.Video == null ? null : ToVideoItem(row.Video)))
|
|
.ToArrayAsync(cancellationToken);
|
|
return new CatalogList<QuestionVideoCatalogItem>(items);
|
|
}
|
|
|
|
private async Task<VideoPlaybackProgress> ResolveProgressAsync(
|
|
VideoPlaybackActor actor,
|
|
Guid videoId,
|
|
Guid? questionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var progress = await dbContext.VideoPlaybackProgress.SingleOrDefaultAsync(
|
|
item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.VideoId == videoId &&
|
|
item.QuestionId == questionId,
|
|
cancellationToken);
|
|
if (progress is not null)
|
|
{
|
|
return progress;
|
|
}
|
|
|
|
progress = new VideoPlaybackProgress
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
VideoId = videoId,
|
|
QuestionId = questionId,
|
|
LastPlayedAt = DateTimeOffset.UtcNow,
|
|
Metadata = JsonDefaults.Object()
|
|
};
|
|
dbContext.VideoPlaybackProgress.Add(progress);
|
|
return progress;
|
|
}
|
|
|
|
private async Task<VideoExplanation> ResolveVideoAsync(
|
|
Guid tenantId,
|
|
Guid videoId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await dbContext.VideoExplanations.SingleOrDefaultAsync(
|
|
video => video.TenantId == tenantId && video.Id == videoId && video.IsActive,
|
|
cancellationToken)
|
|
?? throw new VideoPlaybackException("Video was not found.", "video_not_found");
|
|
}
|
|
|
|
private async Task AssertQuestionVideoAsync(
|
|
Guid tenantId,
|
|
Guid questionId,
|
|
Guid videoId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var exists = await dbContext.QuestionVideos.AnyAsync(
|
|
item =>
|
|
item.TenantId == tenantId &&
|
|
item.QuestionId == questionId &&
|
|
item.VideoId == videoId,
|
|
cancellationToken);
|
|
if (!exists)
|
|
{
|
|
throw new VideoPlaybackException("Question video was not found.", "question_video_not_found");
|
|
}
|
|
}
|
|
|
|
private async Task AssertActiveMemberAsync(VideoPlaybackActor actor, CancellationToken cancellationToken)
|
|
{
|
|
var exists = await dbContext.TenantMemberships.AnyAsync(
|
|
membership =>
|
|
membership.TenantId == actor.TenantId &&
|
|
membership.UserId == actor.UserId &&
|
|
membership.Status == MembershipStatus.Active,
|
|
cancellationToken);
|
|
if (!exists)
|
|
{
|
|
throw new VideoPlaybackException("Current user is not a member of the tenant.", "video_access_denied");
|
|
}
|
|
}
|
|
|
|
private static VideoExplanationCatalogItem ToVideoItem(VideoExplanation video)
|
|
{
|
|
return new VideoExplanationCatalogItem(
|
|
video.Id,
|
|
video.LegacyId,
|
|
video.SubjectId,
|
|
video.Title,
|
|
video.Description,
|
|
video.VideoUrl,
|
|
video.ThumbnailUrl,
|
|
video.DurationSeconds,
|
|
video.KnowledgeTags,
|
|
video.IsGeneral,
|
|
video.Difficulty,
|
|
video.SortOrder,
|
|
video.IsActive,
|
|
video.Metadata);
|
|
}
|
|
|
|
private static VideoProgressItem ToProgressItem(VideoPlaybackProgress progress)
|
|
{
|
|
return new VideoProgressItem(
|
|
progress.Id,
|
|
progress.VideoId,
|
|
progress.QuestionId,
|
|
progress.PositionSeconds,
|
|
progress.DurationSeconds,
|
|
progress.WatchedSeconds,
|
|
progress.IsCompleted,
|
|
progress.CompletedAt,
|
|
progress.LastPlayedAt,
|
|
progress.PlayCount,
|
|
progress.Metadata);
|
|
}
|
|
}
|