Commit: 7fa8785
Parent: a6d85a9

Expose additional MediaInfo to Api and Frontend

Mårten Åsberg committed on 2026-05-15 at 22:50
src/Api/ApiEndpoints.cs +18 -10
diff --git a/src/Api/ApiEndpoints.cs b/src/Api/ApiEndpoints.cs
index c048e7c..e7b0511 100644
@@ -39,11 +39,11 @@ public static class ApiEndpoints
}
private static IAsyncEnumerable<Clip> GetClips(
[FromServices] IClipRepository clipRepository,
[FromServices] ClipDetailsService clipDetailsService,
CancellationToken cancellationToken,
[FromQuery] Guid? after = null,
[FromQuery, Range(0, 64)] int limit = 10
) => clipRepository.GetLatest(after, limit, cancellationToken).Select(Clip.FromDomain);
) => clipDetailsService.GetLatest(after, limit, cancellationToken).Select(Clip.FromDomain);
private static async Task<Results<PhysicalFileHttpResult, NotFound>> GetClipStream(
[FromServices] IClipRepository clipRepository,
@@ -123,18 +123,26 @@ public sealed record Clip(
DateTimeOffset CreatedAt,
string? Caption,
string[] Tags,
ClipUpload[] Uploads
ClipUpload[] Uploads,
ClipMediaInfo MediaInfo
)
{
public static Clip FromDomain(Domain.Clip clip) =>
public static Clip FromDomain(Domain.ClipDetails details) =>
new(
clip.Id,
clip.Duration,
clip.CreatedAt,
clip.Caption,
[.. clip.Tags.Select(t => t.Value)],
[.. clip.Uploads.Select(u => new ClipUpload(u.CanonicalUrl.ToString(), u.Platform, u.PublishedAt))]
details.Clip.Id,
details.Clip.Duration,
details.Clip.CreatedAt,
details.Clip.Caption,
[.. details.Clip.Tags.Select(t => t.Value)],
[.. details.Clip.Uploads.Select(u => new ClipUpload(u.CanonicalUrl.ToString(), u.Platform, u.PublishedAt))],
new ClipMediaInfo(
details.MediaInfo?.Name,
details.MediaInfo?.SeriesName,
details.MediaInfo?.OriginalReleaseDate
)
);
}
public sealed record ClipUpload(string CanonicalUrl, string Platform, DateTimeOffset PublishedAt);
public sealed record ClipMediaInfo(string? Name, string? SeriesName, DateOnly? OriginalReleaseDate);
src/Api/Program.cs +1 -1
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
index 6a9d0ca..e6dfb5d 100644
@@ -33,7 +33,7 @@ builder.ConfigureOpenTelemetry();
builder.Services.AddOpenApi();
builder.Services.AddClipSelector().AddClipGenerator().AddCleaner().AddUploader();
builder.Services.AddClipSelector().AddClipGenerator().AddCleaner().AddUploader().AddClipDetailsService();
builder.Services.AddJellyfinDatabase().AddSlopperDatabase().AddFfmpegServices().AddAi().AddYouTubeUploader();
src/Domain/ClipDetails.cs +3 -0
diff --git a/src/Domain/ClipDetails.cs b/src/Domain/ClipDetails.cs
new file mode 100644
index 0000000..8630341
@@ -0,0 +1,3 @@
namespace Slopper.Domain;
public sealed record ClipDetails(Clip Clip, MediaInfo? MediaInfo);
src/Domain/ClipDetailsService.cs +54 -0
diff --git a/src/Domain/ClipDetailsService.cs b/src/Domain/ClipDetailsService.cs
new file mode 100644
index 0000000..5967eee
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Slopper.Domain;
public sealed class ClipDetailsService(
ILogger<ClipDetailsService> logger,
IClipRepository clipRepository,
IMediaRepository mediaRepository
)
{
public async IAsyncEnumerable<ClipDetails> GetLatest(
Guid? after = null,
int limit = 10,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
await foreach (var clip in clipRepository.GetLatest(after, limit, cancellationToken))
{
MediaInfo? mediaInfo = null;
try
{
mediaInfo = await mediaRepository.GetMediaInfo(clip.MediaItemId, cancellationToken);
}
catch (Exception ex) when (ex is not TaskCanceledException)
{
logger.LogWarning(
ex,
"Could not fetch media info for {ClipId} ({MediaItemId})",
clip.Id,
clip.MediaItemId
);
}
yield return new ClipDetails(clip, mediaInfo);
}
}
}
public static class ClipDetailsServiceCollectionExtensions
{
extension(IServiceCollection services)
{
public IServiceCollection AddClipDetailsService()
{
services.AddTransient<ClipDetailsService>();
return services;
}
}
}
src/Domain/IMediaRepository.cs +3 -0
diff --git a/src/Domain/IMediaRepository.cs b/src/Domain/IMediaRepository.cs
index 14316e3..4447cad 100644
@@ -1,3 +1,4 @@
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -6,4 +7,6 @@ namespace Slopper.Domain;
public interface IMediaRepository
{
Task<MediaItem> GetRandomMediaItem(CancellationToken cancellationToken);
Task<MediaInfo> GetMediaInfo(Guid itemId, CancellationToken cancellationToken);
}
src/Domain/MediaInfo.cs +5 -0
diff --git a/src/Domain/MediaInfo.cs b/src/Domain/MediaInfo.cs
new file mode 100644
index 0000000..8acb217
@@ -0,0 +1,5 @@
using System;
namespace Slopper.Domain;
public sealed record MediaInfo(string Name, string? SeriesName, DateOnly? OriginalReleaseDate);
src/Frontend/src/Clip.ts +7 -0
diff --git a/src/Frontend/src/Clip.ts b/src/Frontend/src/Clip.ts
index ddfd9f4..f48af63 100644
@@ -4,6 +4,12 @@ export interface Upload {
publishedAt: Date;
}
export interface MediaInfo {
name: string | null;
seriesName: string | null;
originalReleaseDate: string | null;
}
export interface Clip {
id: string;
duration: string;
@@ -11,4 +17,5 @@ export interface Clip {
caption: string | null;
tags: Array<string>;
uploads: Array<Upload>;
mediaInfo: MediaInfo;
}
src/Frontend/src/components/ClipDetails.vue +16 -0
diff --git a/src/Frontend/src/components/ClipDetails.vue b/src/Frontend/src/components/ClipDetails.vue
index 2fa6990..0e01fcb 100644
@@ -17,6 +17,10 @@ function formatDuration(duration: string): string {
function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
}
function formatReleaseDate(date: string): string {
return new Date(date + "T00:00:00").toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
}
</script>
<template>
@@ -28,6 +32,18 @@ function formatDate(date: Date): string {
</template>
</p>
<dl>
<template v-if="clip.mediaInfo.seriesName">
<dt>Series</dt>
<dd>{{ clip.mediaInfo.seriesName }}</dd>
</template>
<template v-if="clip.mediaInfo.name">
<dt>Name</dt>
<dd>{{ clip.mediaInfo.name }}</dd>
</template>
<template v-if="clip.mediaInfo.originalReleaseDate">
<dt>Released</dt>
<dd>{{ formatReleaseDate(clip.mediaInfo.originalReleaseDate) }}</dd>
</template>
<dt>Duration</dt>
<dd>{{ formatDuration(clip.duration) }}</dd>
<dt>Posted</dt>
src/Infrastructure/Database/Jellyfin/MediaRepository.cs +10 -0
diff --git a/src/Infrastructure/Database/Jellyfin/MediaRepository.cs b/src/Infrastructure/Database/Jellyfin/MediaRepository.cs
index 40705d0..030c401 100644
@@ -55,4 +55,14 @@ internal sealed class MediaRepository(
mediaStreamInfo.IsExternal
? new Subtitles.External(mediaStreamInfo.Path ?? throw new Exception("External subtitles without path"))
: new Subtitles.Embedded(mediaStreamInfo.StreamIndex);
public async Task<MediaInfo> GetMediaInfo(Guid itemId, CancellationToken cancellationToken) =>
Map(await jellyfinDbContext.BaseItems.AsNoTracking().FirstAsync(s => s.Id == itemId, cancellationToken));
private static MediaInfo Map(BaseItemEntity item) =>
new(
item.Name ?? throw new Exception("No name for available"),
item.SeriesName,
item.PremiereDate is { } premiereDate ? DateOnly.FromDateTime(premiereDate) : null
);
}