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> 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(items); } public async Task 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 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> 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(items); } private async Task 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 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); } }