Implement episode metadata management: add functionality to refresh episode metadata from external sources, including new API endpoints and UI integration. Enhance Show and Episode models to support additional metadata fields, and update database schema accordingly. Update ShowDetail and ShowMetadataCard components to display refreshed episode information and provide user feedback on metadata updates.

This commit is contained in:
Leonid Pershin
2026-07-25 09:46:19 +03:00
parent 0d2dee815e
commit 7fb46b5e0d
30 changed files with 1563 additions and 81 deletions
@@ -1,8 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Streaming.GetPublicEpg;
public sealed record GetPublicEpgQuery(string Slug, DateTimeOffset FromUtc, DateTimeOffset ToUtc)
: IQuery<Result<IReadOnlyList<ScheduleEntryDto>>>;
: IQuery<Result<IReadOnlyList<PublicEpgEntryDto>>>;
@@ -7,9 +7,9 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Streaming.GetPublicEpg;
public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetPublicEpgQuery, Result<IReadOnlyList<ScheduleEntryDto>>>
: IQueryHandler<GetPublicEpgQuery, Result<IReadOnlyList<PublicEpgEntryDto>>>
{
public async Task<Result<IReadOnlyList<ScheduleEntryDto>>> Handle(
public async Task<Result<IReadOnlyList<PublicEpgEntryDto>>> Handle(
GetPublicEpgQuery query,
CancellationToken cancellationToken
)
@@ -19,7 +19,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
.Select(c => (Guid?)c.Id)
.FirstOrDefaultAsync(cancellationToken);
if (channelId is null)
return Result.Failure<IReadOnlyList<ScheduleEntryDto>>(ChannelErrors.NotFound);
return Result.Failure<IReadOnlyList<PublicEpgEntryDto>>(ChannelErrors.NotFound);
var entries = await dbContext.ScheduleEntries.AsNoTracking()
.Where(e =>
@@ -28,28 +28,65 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
&& e.EndsAtUtc > query.FromUtc
)
.OrderBy(e => e.StartsAtUtc)
.ToListAsync(cancellationToken);
var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList();
var showNames = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var dtos = entries
.Select(e => new ScheduleEntryDto(
e.Id,
.Select(e => new
{
e.Kind,
e.MediaAssetId,
e.StartsAtUtc,
e.EndsAtUtc,
e.ShowId,
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
e.EpisodeIndex,
null // сезон/серию зрителю не показываем (и не светим имена файлов)
))
e.MediaAssetId,
})
.ToListAsync(cancellationToken);
var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList();
var shows = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name, s.PosterPath })
.ToDictionaryAsync(s => s.Id, cancellationToken);
// Метаданные серий: ключ — (шоу, ассет).
var assetIds = entries.Select(e => e.MediaAssetId).Distinct().ToList();
var episodes = await dbContext.Shows.AsNoTracking()
.SelectMany(s => s.Episodes)
.Where(e => showIds.Contains(e.ShowId) && assetIds.Contains(e.MediaAssetId))
.Select(e => new
{
e.ShowId,
e.MediaAssetId,
e.Id,
e.Title,
e.Overview,
e.StillPath,
})
.ToListAsync(cancellationToken);
var episodeByKey = episodes
.GroupBy(e => (e.ShowId, e.MediaAssetId))
.ToDictionary(g => g.Key, g => g.First());
var dtos = entries
.Select(e =>
{
var show = e.ShowId is { } sid ? shows.GetValueOrDefault(sid) : null;
var episode =
e.ShowId is { } showId
&& episodeByKey.TryGetValue((showId, e.MediaAssetId), out var ep)
? ep
: null;
return new PublicEpgEntryDto(
e.Kind,
e.StartsAtUtc,
e.EndsAtUtc,
e.ShowId,
show?.Name,
show?.PosterPath is not null,
episode?.Id,
episode?.Title,
episode?.Overview,
episode?.StillPath is not null
);
})
.ToList();
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
return Result.Success<IReadOnlyList<PublicEpgEntryDto>>(dtos);
}
}
@@ -1,6 +1,7 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming.ListPublicChannels;
@@ -12,10 +13,52 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
CancellationToken cancellationToken
)
{
return await dbContext.Channels.AsNoTracking()
var channels = await dbContext.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
.OrderBy(c => c.Name)
.Select(c => new PublicChannelDto(c.Id, c.Slug, c.Name))
.Select(c => new { c.Id, c.Slug, c.Name })
.ToListAsync(cancellationToken);
if (channels.Count == 0)
return [];
var now = DateTimeOffset.UtcNow;
var channelIds = channels.Select(c => c.Id).ToList();
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
var currentByChannel = await dbContext.ScheduleEntries.AsNoTracking()
.Where(e =>
channelIds.Contains(e.ChannelId)
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc <= now
&& e.EndsAtUtc > now
&& e.ShowId != null
)
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
.ToListAsync(cancellationToken);
var currentShowByChannel = currentByChannel
.GroupBy(x => x.ChannelId)
.ToDictionary(g => g.Key, g => g.First().ShowId);
var showIds = currentShowByChannel.Values.Distinct().ToList();
var shows = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name, s.PosterPath })
.ToDictionaryAsync(s => s.Id, cancellationToken);
return channels
.Select(c =>
{
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
return new PublicChannelDto(
c.Id,
c.Slug,
c.Name,
show is null ? null : showId,
show?.Name,
show?.PosterPath is not null
);
})
.ToList();
}
}
@@ -1,6 +1,29 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto(Guid Id, string Slug, string Name);
public sealed record PublicChannelDto(
Guid Id,
string Slug,
string Name,
Guid? CurrentShowId,
string? CurrentShowName,
bool CurrentShowHasPoster
);
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
public sealed record PublicEpgEntryDto(
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
bool ShowHasPoster,
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,
bool EpisodeHasStill
);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);