Enhance schedule and show management: add season and episode information to ScheduleEntryDto, update ListShowsQueryHandler to include season counts, and modify frontend components to display this new data. Improve EPG handling by consolidating program entries into blocks for better clarity in the streaming interface.

This commit is contained in:
Leonid Pershin
2026-07-24 22:18:50 +03:00
parent 517f11c897
commit 2523808e3b
11 changed files with 134 additions and 26 deletions
@@ -2,6 +2,7 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Library;
namespace TeleWave.Application.Broadcast.GetSchedule; namespace TeleWave.Application.Broadcast.GetSchedule;
@@ -36,6 +37,17 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
.Select(s => new { s.Id, s.Name }) .Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
// Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки.
var assetIds = entries
.Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Program)
.Select(e => e.MediaAssetId)
.Distinct()
.ToList();
var assetNames = await dbContext.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
var dtos = entries var dtos = entries
.Select(e => new ScheduleEntryDto( .Select(e => new ScheduleEntryDto(
e.Id, e.Id,
@@ -45,7 +57,8 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
e.EndsAtUtc, e.EndsAtUtc,
e.ShowId, e.ShowId,
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null, e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
e.EpisodeIndex e.EpisodeIndex,
assetNames.TryGetValue(e.MediaAssetId, out var name) ? EpisodeName.ParseLabel(name) : null
)) ))
.ToList(); .ToList();
@@ -10,5 +10,6 @@ public sealed record ScheduleEntryDto(
DateTimeOffset EndsAtUtc, DateTimeOffset EndsAtUtc,
Guid? ShowId, Guid? ShowId,
string? ShowName, string? ShowName,
int? EpisodeIndex int? EpisodeIndex,
string? SeasonEpisode
); );
@@ -0,0 +1,40 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace TeleWave.Application.Library;
/// <summary>Разбор сезона/серии из имени файла (SxxEyy либо NxNN). Номера в модели не хранятся —
/// вытаскиваются из имён по мере надобности (сводка в списке шоу, метка в расписании админки).</summary>
public static class EpisodeName
{
private static readonly Regex SxxEyy = new(
@"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})",
RegexOptions.Compiled
);
private static readonly Regex NxNN = new(
@"(?:^|[^\d])(\d{1,2})x(\d{1,3})",
RegexOptions.Compiled | RegexOptions.IgnoreCase
);
public static (int Season, int Episode)? Parse(string? name)
{
if (string.IsNullOrEmpty(name))
return null;
var match = SxxEyy.Match(name);
if (!match.Success)
match = NxNN.Match(name);
return match.Success
? (int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture),
int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture))
: null;
}
public static int? ParseSeason(string? name) => Parse(name)?.Season;
/// <summary>Метка вида «S16E03», либо null если распознать не удалось.</summary>
public static string? ParseLabel(string? name) =>
Parse(name) is { } p ? $"S{p.Season:D2}E{p.Episode:D2}" : null;
}
@@ -12,9 +12,28 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
return await dbContext.Shows.AsNoTracking() var shows = await dbContext.Shows.AsNoTracking()
.Include(s => s.Episodes)
.OrderBy(s => s.Name) .OrderBy(s => s.Name)
.Select(s => new ShowSummaryDto(s.Id, s.Name, s.Kind, s.Episodes.Count, s.CreatedAt))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
var assetIds = shows.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList();
var names = await dbContext.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
return shows
.Select(s =>
{
var seasons = s.Episodes
.Select(e => names.TryGetValue(e.MediaAssetId, out var n) ? EpisodeName.ParseSeason(n) : null)
.Where(season => season is not null)
.Distinct()
.Count();
return new ShowSummaryDto(s.Id, s.Name, s.Kind, s.Episodes.Count, seasons, s.CreatedAt);
})
.ToList();
} }
} }
@@ -8,6 +8,7 @@ public sealed record ShowSummaryDto(
string Name, string Name,
ShowKind Kind, ShowKind Kind,
int EpisodeCount, int EpisodeCount,
int SeasonCount,
DateTimeOffset CreatedAt DateTimeOffset CreatedAt
); );
@@ -45,7 +45,8 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
e.EndsAtUtc, e.EndsAtUtc,
e.ShowId, e.ShowId,
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null, e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
e.EpisodeIndex e.EpisodeIndex,
null // сезон/серию зрителю не показываем (и не светим имена файлов)
)) ))
.ToList(); .ToList();
@@ -614,8 +614,12 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
) : ( ) : (
<span> <span>
{e.showName ?? '—'} {e.showName ?? '—'}
{e.episodeIndex != null && ( {e.seasonEpisode ? (
<span className="text-muted-foreground"> · {t('air.episode')} {e.episodeIndex + 1}</span> <span className="text-muted-foreground"> · {e.seasonEpisode}</span>
) : (
e.episodeIndex != null && (
<span className="text-muted-foreground"> · {t('air.episode')} {e.episodeIndex + 1}</span>
)
)} )}
</span> </span>
)} )}
@@ -67,6 +67,7 @@ export function ShowsPanel() {
<tr> <tr>
<th className="px-4 py-2 font-medium">{t('admin.shows.name')}</th> <th className="px-4 py-2 font-medium">{t('admin.shows.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.kind')}</th> <th className="px-4 py-2 font-medium">{t('admin.shows.kind')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.seasons')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.episodes')}</th> <th className="px-4 py-2 font-medium">{t('admin.shows.episodes')}</th>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th> <th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr> </tr>
@@ -74,7 +75,7 @@ export function ShowsPanel() {
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={4}> <td className="px-4 py-3 text-muted-foreground" colSpan={5}>
{t('common.loading')} {t('common.loading')}
</td> </td>
</tr> </tr>
@@ -93,6 +94,7 @@ export function ShowsPanel() {
<td className="px-4 py-2"> <td className="px-4 py-2">
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge> <Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</td> </td>
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td> <td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<Button <Button
+41 -18
View File
@@ -62,7 +62,7 @@ export function AirPage() {
refetchInterval: 60_000, refetchInterval: 60_000,
}) })
const { current, upcoming } = useMemo(() => splitEpg(epg ?? []), [epg]) const { current, upcoming } = useMemo(() => buildGuide(epg ?? []), [epg])
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p> if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
@@ -125,7 +125,7 @@ export function AirPage() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge>{t('air.now')}</Badge> <Badge>{t('air.now')}</Badge>
<span className="font-medium">{programLabel(current, t)}</span> <span className="font-medium">{current.showName}</span>
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)} {formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)}
@@ -139,12 +139,12 @@ export function AirPage() {
{t('air.next')} {t('air.next')}
</div> </div>
<ul className="divide-y divide-border"> <ul className="divide-y divide-border">
{upcoming.slice(0, 6).map((entry) => ( {upcoming.slice(0, 6).map((block) => (
<li key={entry.id} className="flex items-center gap-3 px-4 py-2 text-sm"> <li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="w-12 shrink-0 text-muted-foreground"> <span className="w-24 shrink-0 text-muted-foreground">
{formatTime(entry.startsAtUtc)} {formatTime(block.startsAtUtc)} {formatTime(block.endsAtUtc)}
</span> </span>
<span>{programLabel(entry, t)}</span> <span>{block.showName}</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -157,17 +157,40 @@ export function AirPage() {
) )
} }
function splitEpg(entries: ScheduleEntryDto[]) { type GuideBlock = {
const now = Date.now() key: string
const current = entries.find( showId: string | null
(e) => new Date(e.startsAtUtc).getTime() <= now && new Date(e.endsAtUtc).getTime() > now, showName: string
) startsAtUtc: string
const upcoming = entries.filter((e) => new Date(e.startsAtUtc).getTime() > now) endsAtUtc: string
return { current, upcoming }
} }
function programLabel(entry: ScheduleEntryDto, t: (key: string) => string) { /**
// На странице эфира — только название шоу (сезон/серия видны в админке). * Строит телегид: рекламу не показываем, а подряд идущие серии одного шоу склеиваем в один блок
if (entry.kind === 'Ad') return t('air.ad') * с диапазоном «с – по». Реклама между сериями одного шоу поглощается блоком (как в обычном EPG).
return entry.showName ?? '—' */
function buildGuide(entries: ScheduleEntryDto[]): { current?: GuideBlock; upcoming: GuideBlock[] } {
const blocks: GuideBlock[] = []
for (const entry of entries) {
if (entry.kind !== 'Program') continue
const last = blocks[blocks.length - 1]
if (last && last.showId === entry.showId) {
last.endsAtUtc = entry.endsAtUtc
} else {
blocks.push({
key: entry.id,
showId: entry.showId,
showName: entry.showName ?? '—',
startsAtUtc: entry.startsAtUtc,
endsAtUtc: entry.endsAtUtc,
})
}
}
const now = Date.now()
const current = blocks.find(
(b) => new Date(b.startsAtUtc).getTime() <= now && new Date(b.endsAtUtc).getTime() > now,
)
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
return { current, upcoming }
} }
+2
View File
@@ -66,6 +66,7 @@ export type ShowSummaryDto = {
name: string name: string
kind: ShowKind kind: ShowKind
episodeCount: number episodeCount: number
seasonCount: number
createdAt: string createdAt: string
} }
@@ -149,6 +150,7 @@ export type ScheduleEntryDto = {
showId: string | null showId: string | null
showName: string | null showName: string | null
episodeIndex: number | null episodeIndex: number | null
seasonEpisode: string | null
} }
// ── Публичный эфир ───────────────────────────────────────────────────────── // ── Публичный эфир ─────────────────────────────────────────────────────────
+2
View File
@@ -127,6 +127,7 @@ const resources = {
name: 'Название', name: 'Название',
kind: 'Тип', kind: 'Тип',
kinds: { Series: 'Сериал', Single: 'Полнометражка' }, kinds: { Series: 'Сериал', Single: 'Полнометражка' },
seasons: 'Сезоны',
episodes: 'Серии', episodes: 'Серии',
episode: 'Серия', episode: 'Серия',
addEpisode: 'Добавить серию', addEpisode: 'Добавить серию',
@@ -321,6 +322,7 @@ const resources = {
name: 'Name', name: 'Name',
kind: 'Kind', kind: 'Kind',
kinds: { Series: 'Series', Single: 'Movie' }, kinds: { Series: 'Series', Single: 'Movie' },
seasons: 'Seasons',
episodes: 'Episodes', episodes: 'Episodes',
episode: 'Episode', episode: 'Episode',
addEpisode: 'Add episode', addEpisode: 'Add episode',