📄 src/Domain/Describer/ClipDescriber.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;

namespace Slopper.Domain.Describer;

public sealed class ClipDescriber(
    IFrameExtractor frameExtractor,
    ISubtitleReader subtitleReader,
    IMediaRepository mediaRepository,
    IChatClient chatClient,
    IOptionsMonitor<ClipDescriberOptions> options
)
{
    public async Task<ClipDescription> DescribeClip(
        MediaItem media,
        TimeSpan start,
        TimeSpan duration,
        CancellationToken cancellationToken
    )
    {
        var framesTask = frameExtractor.ExtractFrames(
            media,
            [start, start + duration / 3, start + 2 * duration / 3, start + duration],
            cancellationToken
        );
        var subtitlesTask = subtitleReader.ReadSubtitles(media, start, duration, cancellationToken);
        var metadataTask = mediaRepository.GetMediaInfo(media.Id, cancellationToken);

        var frames = await framesTask;
        var subtitles = await subtitlesTask;
        var metadata = await metadataTask;

        List<AIContent> contents = [];
        foreach (var frame in frames)
        {
            contents.Add(new DataContent(frame, "image/png"));
        }
        contents.Add(new TextContent(options.CurrentValue.Prompt));
        contents.Add(
            new TextContent(
                metadata.SeriesName is null
                    ? string.Format(
                        options.CurrentValue.MovieMetadataPrompt,
                        metadata.Name,
                        metadata.OriginalReleaseDate
                    )
                    : string.Format(
                        options.CurrentValue.ShowMetadataPrompt,
                        metadata.Name,
                        metadata.SeriesName,
                        metadata.OriginalReleaseDate
                    )
            )
        );
        if (subtitles.Count > 0)
        {
            contents.Add(
                new TextContent(
                    $"Subtitles from this clip:\n{string.Join("\n", subtitles.Select(s => string.Join(" / ", s.Lines)))}"
                )
            );
        }

        var response = await chatClient.GetResponseAsync(
            [new ChatMessage(ChatRole.User, contents)],
            new()
            {
                ResponseFormat = ChatResponseFormat.ForJsonSchema<ClipDescriptionAiDto>(
                    AiDtoSerializerContext.Default.Options
                ),
            },
            cancellationToken
        );

        var result =
            JsonSerializer.Deserialize(response.Text, AiDtoSerializerContext.Default.ClipDescriptionAiDto)
            ?? throw new Exception("Literal null response from description agent.");

        return new(result.Caption, result.Tags.Select(t => new Tag(t)).ToHashSet());
    }
}