Files
tiku-backend.net/Tiku.Infrastructure/Assets/VideoPlaybackService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

261 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.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Assets;
public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPersistence,
IIdentityPersistence identityPersistence) : IVideoPlaybackService
{
public async Task<CatalogList<VideoExplanationCatalogItem>> SearchAsync(
VideoPlaybackActor actor,
VideoSearchQuery query,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var videos = contentAssetPersistence.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 contentAssetPersistence.SaveChangesAsync(cancellationToken);
contentAssetPersistence.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 contentAssetPersistence.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 contentAssetPersistence.SaveChangesAsync(cancellationToken);
return ToProgressItem(progress);
}
public async Task<CatalogList<QuestionVideoCatalogItem>> GetQuestionVideosAsync(
VideoPlaybackActor actor,
QuestionVideoQuery query,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var questionVideos = contentAssetPersistence.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 = contentAssetPersistence.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 contentAssetPersistence.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()
};
contentAssetPersistence.VideoPlaybackProgress.Add(progress);
return progress;
}
private async Task<VideoExplanation> ResolveVideoAsync(
Guid tenantId,
Guid videoId,
CancellationToken cancellationToken)
{
return await contentAssetPersistence.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 contentAssetPersistence.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 identityPersistence.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);
}
}