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:
@@ -6,6 +6,7 @@ using TeleWave.Application.Metadata;
|
|||||||
using TeleWave.Application.Metadata.ApplyShowMetadata;
|
using TeleWave.Application.Metadata.ApplyShowMetadata;
|
||||||
using TeleWave.Application.Metadata.ClearShowMetadata;
|
using TeleWave.Application.Metadata.ClearShowMetadata;
|
||||||
using TeleWave.Application.Metadata.GetProviders;
|
using TeleWave.Application.Metadata.GetProviders;
|
||||||
|
using TeleWave.Application.Metadata.RefreshEpisodes;
|
||||||
using TeleWave.Application.Metadata.SearchShows;
|
using TeleWave.Application.Metadata.SearchShows;
|
||||||
using TeleWave.Application.Metadata.SetShowPoster;
|
using TeleWave.Application.Metadata.SetShowPoster;
|
||||||
using TeleWave.Application.Metadata.UpdateShowMetadata;
|
using TeleWave.Application.Metadata.UpdateShowMetadata;
|
||||||
@@ -39,11 +40,15 @@ public static class MetadataEndpoints
|
|||||||
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPut("/shows/{showId:guid}/poster", UploadPoster).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/shows/{showId:guid}/poster", UploadPoster).Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
|
||||||
|
|
||||||
// Постеры отдаём публично (просто картинки, id не угадать) — чтобы работал <img src>.
|
// Постеры/кадры отдаём публично (просто картинки, id не угадать) — чтобы работал <img src>.
|
||||||
app.MapGet("/api/metadata/shows/{showId:guid}/poster", ServePoster)
|
app.MapGet("/api/metadata/shows/{showId:guid}/poster", ServePoster)
|
||||||
.WithTags("Metadata")
|
.WithTags("Metadata")
|
||||||
.Produces(StatusCodes.Status200OK);
|
.Produces(StatusCodes.Status200OK);
|
||||||
|
app.MapGet("/api/metadata/episodes/{episodeId:guid}/still", ServeStill)
|
||||||
|
.WithTags("Metadata")
|
||||||
|
.Produces(StatusCodes.Status200OK);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -126,6 +131,19 @@ public static class MetadataEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RefreshEpisodes(
|
||||||
|
Guid showId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new RefreshShowEpisodesMetadataCommand(showId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ServePoster(
|
private static async Task<IResult> ServePoster(
|
||||||
Guid showId,
|
Guid showId,
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
@@ -137,14 +155,30 @@ public static class MetadataEndpoints
|
|||||||
.Where(s => s.Id == showId)
|
.Where(s => s.Id == showId)
|
||||||
.Select(s => s.PosterPath)
|
.Select(s => s.PosterPath)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (string.IsNullOrEmpty(path))
|
return ServeImage(path, imageStore);
|
||||||
return Results.NotFound();
|
}
|
||||||
|
|
||||||
var abs = imageStore.ResolveAbsolutePath(path);
|
private static async Task<IResult> ServeStill(
|
||||||
if (abs is null)
|
Guid episodeId,
|
||||||
return Results.NotFound();
|
IAppDbContext dbContext,
|
||||||
|
IMetadataImageStore imageStore,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var path = await dbContext.Shows.AsNoTracking()
|
||||||
|
.SelectMany(s => s.Episodes)
|
||||||
|
.Where(e => e.Id == episodeId)
|
||||||
|
.Select(e => e.StillPath)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
return ServeImage(path, imageStore);
|
||||||
|
}
|
||||||
|
|
||||||
return Results.File(abs, ContentTypeFor(Path.GetExtension(abs)));
|
private static IResult ServeImage(string? relativePath, IMetadataImageStore imageStore)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(relativePath))
|
||||||
|
return Results.NotFound();
|
||||||
|
var abs = imageStore.ResolveAbsolutePath(relativePath);
|
||||||
|
return abs is null ? Results.NotFound() : Results.File(abs, ContentTypeFor(Path.GetExtension(abs)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ContentTypeFor(string extension) =>
|
private static string ContentTypeFor(string extension) =>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public sealed class ScheduleGenerator(
|
|||||||
IRandomSource random,
|
IRandomSource random,
|
||||||
IBumperRenderer bumperRenderer,
|
IBumperRenderer bumperRenderer,
|
||||||
IBumperTemplateStorage bumperStorage,
|
IBumperTemplateStorage bumperStorage,
|
||||||
|
IMetadataImageStore metadataImages,
|
||||||
IOptions<SchedulerOptions> options,
|
IOptions<SchedulerOptions> options,
|
||||||
IOptions<BumperOptions> bumperOptions,
|
IOptions<BumperOptions> bumperOptions,
|
||||||
IOptions<StreamingOptions> streamingOptions,
|
IOptions<StreamingOptions> streamingOptions,
|
||||||
@@ -192,6 +193,13 @@ public sealed class ScheduleGenerator(
|
|||||||
var fromIds = pairs.Select(p => p.From).Distinct().ToList();
|
var fromIds = pairs.Select(p => p.From).Distinct().ToList();
|
||||||
var toIds = pairs.Select(p => p.To).Distinct().ToList();
|
var toIds = pairs.Select(p => p.To).Distinct().ToList();
|
||||||
|
|
||||||
|
// Постеры шоу-получателей — как фон заставки (если у канала нет своего фона).
|
||||||
|
var showIds = fromIds.Concat(toIds).Distinct().ToList();
|
||||||
|
var posterByShow = await dbContext.Shows.AsNoTracking()
|
||||||
|
.Where(s => showIds.Contains(s.Id) && s.PosterPath != null)
|
||||||
|
.Select(s => new { s.Id, s.PosterPath })
|
||||||
|
.ToDictionaryAsync(s => s.Id, s => s.PosterPath!, cancellationToken);
|
||||||
|
|
||||||
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
|
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
|
||||||
var cached = await dbContext.BumperAssets.AsNoTracking()
|
var cached = await dbContext.BumperAssets.AsNoTracking()
|
||||||
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
|
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
|
||||||
@@ -215,7 +223,8 @@ public sealed class ScheduleGenerator(
|
|||||||
{
|
{
|
||||||
var fromName = showNames.GetValueOrDefault(pair.From, "…");
|
var fromName = showNames.GetValueOrDefault(pair.From, "…");
|
||||||
var toName = showNames.GetValueOrDefault(pair.To, "…");
|
var toName = showNames.GetValueOrDefault(pair.To, "…");
|
||||||
var signature = ComputeSignature(fromName, toName, styleSignature);
|
var toPosterRel = posterByShow.GetValueOrDefault(pair.To);
|
||||||
|
var signature = ComputeSignature(fromName, toName, styleSignature, toPosterRel ?? "-");
|
||||||
|
|
||||||
var hit = cached.FirstOrDefault(c =>
|
var hit = cached.FirstOrDefault(c =>
|
||||||
c.FromShowId == pair.From
|
c.FromShowId == pair.From
|
||||||
@@ -238,6 +247,7 @@ public sealed class ScheduleGenerator(
|
|||||||
fromName,
|
fromName,
|
||||||
toName,
|
toName,
|
||||||
signature,
|
signature,
|
||||||
|
toPosterRel,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
result[pair] = assetId;
|
result[pair] = assetId;
|
||||||
@@ -263,13 +273,17 @@ public sealed class ScheduleGenerator(
|
|||||||
string fromName,
|
string fromName,
|
||||||
string toName,
|
string toName,
|
||||||
string signature,
|
string signature,
|
||||||
|
string? toPosterRelative,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
||||||
|
var posterAbs = toPosterRelative is null
|
||||||
|
? null
|
||||||
|
: metadataImages.ResolveAbsolutePath(toPosterRelative);
|
||||||
var render = await bumperRenderer.RenderAsync(
|
var render = await bumperRenderer.RenderAsync(
|
||||||
asset.Id,
|
asset.Id,
|
||||||
BuildSpec(channel, fromName, toName),
|
BuildSpec(channel, fromName, toName, posterAbs),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -291,7 +305,12 @@ public sealed class ScheduleGenerator(
|
|||||||
return asset.Id;
|
return asset.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BumperRenderSpec BuildSpec(Channel channel, string fromName, string toName) =>
|
private BumperRenderSpec BuildSpec(
|
||||||
|
Channel channel,
|
||||||
|
string fromName,
|
||||||
|
string toName,
|
||||||
|
string? posterAbsolutePath
|
||||||
|
) =>
|
||||||
new(
|
new(
|
||||||
AlignedBumperDuration(channel),
|
AlignedBumperDuration(channel),
|
||||||
_bumper.Width,
|
_bumper.Width,
|
||||||
@@ -306,7 +325,8 @@ public sealed class ScheduleGenerator(
|
|||||||
channel.BumperNextLabel,
|
channel.BumperNextLabel,
|
||||||
toName,
|
toName,
|
||||||
bumperStorage.BackgroundPath(channel.Id, channel.BumperBackgroundExtension),
|
bumperStorage.BackgroundPath(channel.Id, channel.BumperBackgroundExtension),
|
||||||
bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension)
|
bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension),
|
||||||
|
posterAbsolutePath
|
||||||
);
|
);
|
||||||
|
|
||||||
private string FontPath(BumperFont font) =>
|
private string FontPath(BumperFont font) =>
|
||||||
@@ -340,9 +360,14 @@ public sealed class ScheduleGenerator(
|
|||||||
channel.BumperMusicExtension ?? "-"
|
channel.BumperMusicExtension ?? "-"
|
||||||
);
|
);
|
||||||
|
|
||||||
private static string ComputeSignature(string fromName, string toName, string styleSignature)
|
private static string ComputeSignature(
|
||||||
|
string fromName,
|
||||||
|
string toName,
|
||||||
|
string styleSignature,
|
||||||
|
string poster
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var raw = string.Join('', fromName, toName, styleSignature);
|
var raw = string.Join('', fromName, toName, styleSignature, poster);
|
||||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
||||||
return Convert.ToHexString(hash);
|
return Convert.ToHexString(hash);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ public sealed record BumperRenderSpec(
|
|||||||
string NextLabel,
|
string NextLabel,
|
||||||
string NextTitle,
|
string NextTitle,
|
||||||
string? BackgroundFile = null,
|
string? BackgroundFile = null,
|
||||||
string? MusicFile = null
|
string? MusicFile = null,
|
||||||
|
/// <summary>Постер шоу как фон (используется, если нет загруженного фона канала; затемняется).</summary>
|
||||||
|
string? PosterFile = null
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
|
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ public interface IMetadataImageStore
|
|||||||
|
|
||||||
void DeleteShowImages(Guid showId);
|
void DeleteShowImages(Guid showId);
|
||||||
|
|
||||||
|
/// <summary>Скачивает кадр серии по URL. Возвращает относительный путь или null при ошибке.</summary>
|
||||||
|
Task<string?> DownloadEpisodeStillAsync(
|
||||||
|
Guid episodeId,
|
||||||
|
string url,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
void DeleteEpisodeImages(Guid episodeId);
|
||||||
|
|
||||||
/// <summary>Абсолютный путь к файлу по относительному (с защитой от traversal) или null, если вне корня.</summary>
|
/// <summary>Абсолютный путь к файлу по относительному (с защитой от traversal) или null, если вне корня.</summary>
|
||||||
string? ResolveAbsolutePath(string relativePath);
|
string? ResolveAbsolutePath(string relativePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,17 @@ public sealed class AddEpisodeCommandHandler(IAppDbContext dbContext)
|
|||||||
if (!show.CanAddEpisode)
|
if (!show.CanAddEpisode)
|
||||||
return Result.Failure<Guid>(ShowErrors.SingleAlreadyHasEpisode);
|
return Result.Failure<Guid>(ShowErrors.SingleAlreadyHasEpisode);
|
||||||
|
|
||||||
var assetExists = await dbContext.MediaAssets.AnyAsync(
|
var fileName = await dbContext.MediaAssets
|
||||||
a => a.Id == command.MediaAssetId,
|
.Where(a => a.Id == command.MediaAssetId)
|
||||||
cancellationToken
|
.Select(a => a.OriginalFileName)
|
||||||
);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (!assetExists)
|
if (fileName is null)
|
||||||
return Result.Failure<Guid>(ShowErrors.AssetNotFound);
|
return Result.Failure<Guid>(ShowErrors.AssetNotFound);
|
||||||
|
|
||||||
var episode = show.AddEpisode(command.MediaAssetId);
|
var episode = show.AddEpisode(command.MediaAssetId);
|
||||||
|
if (EpisodeName.Parse(fileName) is { } parsed)
|
||||||
|
episode.SetNumbers(parsed.Season, parsed.Episode);
|
||||||
|
|
||||||
return Result.Success(episode.Id);
|
return Result.Success(episode.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,21 +17,35 @@ public static class EpisodeName
|
|||||||
RegexOptions.Compiled | RegexOptions.IgnoreCase
|
RegexOptions.Compiled | RegexOptions.IgnoreCase
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ведущий номер серии: «01. Название», «02 - Название», «03_Название» — сезон считаем первым.
|
||||||
|
private static readonly Regex LeadingNumber = new(
|
||||||
|
@"^\s*(\d{1,3})[\s._)\]-]",
|
||||||
|
RegexOptions.Compiled
|
||||||
|
);
|
||||||
|
|
||||||
public static (int Season, int Episode)? Parse(string? name)
|
public static (int Season, int Episode)? Parse(string? name)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(name))
|
if (string.IsNullOrEmpty(name))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var match = SxxEyy.Match(name);
|
var match = SxxEyy.Match(name);
|
||||||
if (!match.Success)
|
if (match.Success)
|
||||||
match = NxNN.Match(name);
|
return (Int(match, 1), Int(match, 2));
|
||||||
|
|
||||||
return match.Success
|
match = NxNN.Match(name);
|
||||||
? (int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture),
|
if (match.Success)
|
||||||
int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture))
|
return (Int(match, 1), Int(match, 2));
|
||||||
: null;
|
|
||||||
|
match = LeadingNumber.Match(name);
|
||||||
|
if (match.Success)
|
||||||
|
return (1, Int(match, 1));
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int Int(Match match, int group) =>
|
||||||
|
int.Parse(match.Groups[group].Value, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
public static int? ParseSeason(string? name) => Parse(name)?.Season;
|
public static int? ParseSeason(string? name) => Parse(name)?.Season;
|
||||||
|
|
||||||
/// <summary>Метка вида «S16E03», либо null если распознать не удалось.</summary>
|
/// <summary>Метка вида «S16E03», либо null если распознать не удалось.</summary>
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
|||||||
e.Position,
|
e.Position,
|
||||||
asset?.OriginalFileName,
|
asset?.OriginalFileName,
|
||||||
asset?.Status,
|
asset?.Status,
|
||||||
asset?.Duration?.TotalSeconds
|
asset?.Duration?.TotalSeconds,
|
||||||
|
e.Season,
|
||||||
|
e.Episode,
|
||||||
|
e.Title,
|
||||||
|
e.Overview,
|
||||||
|
e.StillPath is not null,
|
||||||
|
e.AirDate
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ public sealed record EpisodeDto(
|
|||||||
int Position,
|
int Position,
|
||||||
string? AssetName,
|
string? AssetName,
|
||||||
MediaAssetStatus? AssetStatus,
|
MediaAssetStatus? AssetStatus,
|
||||||
double? DurationSeconds
|
double? DurationSeconds,
|
||||||
|
int? Season,
|
||||||
|
int? Episode,
|
||||||
|
string? Title,
|
||||||
|
string? Overview,
|
||||||
|
bool HasStill,
|
||||||
|
DateOnly? AirDate
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record ShowDto(
|
public sealed record ShowDto(
|
||||||
|
|||||||
@@ -18,4 +18,9 @@ public static class MetadataErrors
|
|||||||
"Metadata.InvalidPoster",
|
"Metadata.InvalidPoster",
|
||||||
"Недопустимый файл постера (формат или размер)."
|
"Недопустимый файл постера (формат или размер)."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static readonly Error NoLinkedSource = Error.Validation(
|
||||||
|
"Metadata.NoLinkedSource",
|
||||||
|
"У шоу не привязан внешний источник — сначала найдите шоу в TMDb/OMDb."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Metadata.RefreshEpisodes;
|
||||||
|
|
||||||
|
/// <summary>Довыгрузить метаданные всех серий шоу из привязанного источника. Возвращает число обновлённых.</summary>
|
||||||
|
public sealed record RefreshShowEpisodesMetadataCommand(Guid ShowId) : ICommand<Result<int>>;
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Metadata.RefreshEpisodes;
|
||||||
|
|
||||||
|
public sealed class RefreshShowEpisodesMetadataCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IMetadataProviderResolver resolver,
|
||||||
|
IMetadataImageStore imageStore
|
||||||
|
) : ICommandHandler<RefreshShowEpisodesMetadataCommand, Result<int>>
|
||||||
|
{
|
||||||
|
public async Task<Result<int>> Handle(
|
||||||
|
RefreshShowEpisodesMetadataCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var show = await dbContext.Shows
|
||||||
|
.Include(s => s.Episodes)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
|
||||||
|
if (show is null)
|
||||||
|
return Result.Failure<int>(ShowErrors.NotFound);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(show.MetadataExternalId))
|
||||||
|
return Result.Failure<int>(MetadataErrors.NoLinkedSource);
|
||||||
|
|
||||||
|
var provider = show.MetadataProvider is { } key ? resolver.Resolve(key) : null;
|
||||||
|
if (provider is null)
|
||||||
|
return Result.Failure<int>(MetadataErrors.ProviderNotAvailable);
|
||||||
|
|
||||||
|
// Имена файлов — чтобы распознать номера у серий, где они ещё не проставлены.
|
||||||
|
var assetIds = show.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);
|
||||||
|
|
||||||
|
var updated = 0;
|
||||||
|
foreach (var episode in show.Episodes)
|
||||||
|
{
|
||||||
|
var season = episode.Season;
|
||||||
|
var number = episode.Episode;
|
||||||
|
if (season is null || number is null)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
names.TryGetValue(episode.MediaAssetId, out var name)
|
||||||
|
&& EpisodeName.Parse(name) is { } parsed
|
||||||
|
)
|
||||||
|
{
|
||||||
|
season = parsed.Season;
|
||||||
|
number = parsed.Episode;
|
||||||
|
episode.SetNumbers(season, number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (season is null || number is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var meta = await provider.GetEpisodeAsync(
|
||||||
|
show.MetadataExternalId,
|
||||||
|
season.Value,
|
||||||
|
number.Value,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (meta is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string? stillPath = null;
|
||||||
|
if (!string.IsNullOrEmpty(meta.StillUrl))
|
||||||
|
stillPath = await imageStore.DownloadEpisodeStillAsync(
|
||||||
|
episode.Id,
|
||||||
|
meta.StillUrl,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
episode.ApplyMetadata(meta.Title, meta.Overview, stillPath, meta.AirDate);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Broadcast;
|
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Streaming.GetPublicEpg;
|
namespace TeleWave.Application.Streaming.GetPublicEpg;
|
||||||
|
|
||||||
public sealed record GetPublicEpgQuery(string Slug, DateTimeOffset FromUtc, DateTimeOffset ToUtc)
|
public sealed record GetPublicEpgQuery(string Slug, DateTimeOffset FromUtc, DateTimeOffset ToUtc)
|
||||||
: IQuery<Result<IReadOnlyList<ScheduleEntryDto>>>;
|
: IQuery<Result<IReadOnlyList<PublicEpgEntryDto>>>;
|
||||||
|
|||||||
+57
-20
@@ -7,9 +7,9 @@ using TeleWave.Application.Common.Models;
|
|||||||
namespace TeleWave.Application.Streaming.GetPublicEpg;
|
namespace TeleWave.Application.Streaming.GetPublicEpg;
|
||||||
|
|
||||||
public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
|
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,
|
GetPublicEpgQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
@@ -19,7 +19,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
|
|||||||
.Select(c => (Guid?)c.Id)
|
.Select(c => (Guid?)c.Id)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (channelId is null)
|
if (channelId is null)
|
||||||
return Result.Failure<IReadOnlyList<ScheduleEntryDto>>(ChannelErrors.NotFound);
|
return Result.Failure<IReadOnlyList<PublicEpgEntryDto>>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
var entries = await dbContext.ScheduleEntries.AsNoTracking()
|
var entries = await dbContext.ScheduleEntries.AsNoTracking()
|
||||||
.Where(e =>
|
.Where(e =>
|
||||||
@@ -28,28 +28,65 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
|
|||||||
&& e.EndsAtUtc > query.FromUtc
|
&& e.EndsAtUtc > query.FromUtc
|
||||||
)
|
)
|
||||||
.OrderBy(e => e.StartsAtUtc)
|
.OrderBy(e => e.StartsAtUtc)
|
||||||
.ToListAsync(cancellationToken);
|
.Select(e => new
|
||||||
|
{
|
||||||
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,
|
|
||||||
e.Kind,
|
e.Kind,
|
||||||
e.MediaAssetId,
|
|
||||||
e.StartsAtUtc,
|
e.StartsAtUtc,
|
||||||
e.EndsAtUtc,
|
e.EndsAtUtc,
|
||||||
e.ShowId,
|
e.ShowId,
|
||||||
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
|
e.MediaAssetId,
|
||||||
e.EpisodeIndex,
|
})
|
||||||
null // сезон/серию зрителю не показываем (и не светим имена файлов)
|
.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();
|
.ToList();
|
||||||
|
|
||||||
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
|
return Result.Success<IReadOnlyList<PublicEpgEntryDto>>(dtos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-2
@@ -1,6 +1,7 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Streaming.ListPublicChannels;
|
namespace TeleWave.Application.Streaming.ListPublicChannels;
|
||||||
|
|
||||||
@@ -12,10 +13,52 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return await dbContext.Channels.AsNoTracking()
|
var channels = await dbContext.Channels.AsNoTracking()
|
||||||
.Where(c => c.IsEnabled)
|
.Where(c => c.IsEnabled)
|
||||||
.OrderBy(c => c.Name)
|
.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);
|
.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;
|
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);
|
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace TeleWave.Domain.Library;
|
namespace TeleWave.Domain.Library;
|
||||||
|
|
||||||
/// <summary>Одна серия шоу — ссылка на медиа-ассет и позиция в порядке показа.</summary>
|
/// <summary>Одна серия шоу — ссылка на медиа-ассет и позиция в порядке показа + метаданные.</summary>
|
||||||
public class ShowEpisode
|
public class ShowEpisode
|
||||||
{
|
{
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
@@ -10,6 +10,18 @@ public class ShowEpisode
|
|||||||
/// <summary>Порядковый номер внутри шоу (может иметь разрывы после удалений).</summary>
|
/// <summary>Порядковый номер внутри шоу (может иметь разрывы после удалений).</summary>
|
||||||
public int Position { get; private set; }
|
public int Position { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Распознанные сезон/серия (из имени файла) — база для довыгрузки метаданных.</summary>
|
||||||
|
public int? Season { get; private set; }
|
||||||
|
public int? Episode { get; private set; }
|
||||||
|
|
||||||
|
// ── Метаданные серии ──
|
||||||
|
public string? Title { get; private set; }
|
||||||
|
public string? Overview { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Относительный путь локального кадра или null.</summary>
|
||||||
|
public string? StillPath { get; private set; }
|
||||||
|
public DateOnly? AirDate { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
private ShowEpisode() { }
|
private ShowEpisode() { }
|
||||||
@@ -23,4 +35,29 @@ public class ShowEpisode
|
|||||||
Position = position,
|
Position = position,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Проставить распознанные номера сезона/серии.</summary>
|
||||||
|
public void SetNumbers(int? season, int? episode)
|
||||||
|
{
|
||||||
|
Season = season;
|
||||||
|
Episode = episode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Применить метаданные серии (кадр — уже скачанный локально — может быть null).</summary>
|
||||||
|
public void ApplyMetadata(string? title, string? overview, string? stillPath, DateOnly? airDate)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Overview = overview;
|
||||||
|
if (stillPath is not null)
|
||||||
|
StillPath = stillPath;
|
||||||
|
AirDate = airDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearMetadata()
|
||||||
|
{
|
||||||
|
Title = null;
|
||||||
|
Overview = null;
|
||||||
|
StillPath = null;
|
||||||
|
AirDate = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
var font = EscapePath(spec.FontFile);
|
var font = EscapePath(spec.FontFile);
|
||||||
var outStart = Math.Max(0, target - 1);
|
var outStart = Math.Max(0, target - 1);
|
||||||
|
|
||||||
// Вход 0 — видеофон: загруженный файл (петля + масштаб/кроп) либо анимированный градиент.
|
// Вход 0 — видеофон: загруженный файл (петля + масштаб/кроп), постер шоу (затемнённый) либо
|
||||||
|
// анимированный градиент.
|
||||||
var inputs = new List<string>();
|
var inputs = new List<string>();
|
||||||
string videoPrefix;
|
string videoPrefix;
|
||||||
if (!string.IsNullOrEmpty(spec.BackgroundFile))
|
if (!string.IsNullOrEmpty(spec.BackgroundFile))
|
||||||
@@ -117,6 +118,15 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
videoPrefix =
|
videoPrefix =
|
||||||
$"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps=30,format=yuv420p";
|
$"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps=30,format=yuv420p";
|
||||||
}
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(spec.PosterFile))
|
||||||
|
{
|
||||||
|
// Постер (портрет) растягиваем на кадр, размываем и затемняем, чтобы текст читался.
|
||||||
|
inputs.AddRange(["-loop", "1", "-i", spec.PosterFile]);
|
||||||
|
videoPrefix =
|
||||||
|
$"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
||||||
|
+ ",boxblur=6:1,eq=brightness=-0.28:saturation=0.9"
|
||||||
|
+ ",fps=30,format=yuv420p";
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var gradient =
|
var gradient =
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ public sealed class MediaPathResolver
|
|||||||
public string MetadataShowPosterRelative(Guid showId, string extension) =>
|
public string MetadataShowPosterRelative(Guid showId, string extension) =>
|
||||||
$"metadata/shows/{showId:N}/poster{extension}";
|
$"metadata/shows/{showId:N}/poster{extension}";
|
||||||
|
|
||||||
|
public string MetadataEpisodeDir(Guid episodeId) =>
|
||||||
|
EnsureWithinRoot(Path.Combine(MetadataDir, "episodes", episodeId.ToString("N")));
|
||||||
|
|
||||||
|
public string MetadataEpisodeStillPath(Guid episodeId, string extension) =>
|
||||||
|
EnsureWithinRoot(
|
||||||
|
Path.Combine(MetadataDir, "episodes", episodeId.ToString("N"), "still" + extension)
|
||||||
|
);
|
||||||
|
|
||||||
|
public string MetadataEpisodeStillRelative(Guid episodeId, string extension) =>
|
||||||
|
$"metadata/episodes/{episodeId:N}/still{extension}";
|
||||||
|
|
||||||
/// <summary>Абсолютный путь по относительному (с защитой от traversal) или null, если вне корня.</summary>
|
/// <summary>Абсолютный путь по относительному (с защитой от traversal) или null, если вне корня.</summary>
|
||||||
public string? ResolveRelative(string relativePath)
|
public string? ResolveRelative(string relativePath)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,6 +44,43 @@ public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFacto
|
|||||||
Directory.Delete(dir, recursive: true);
|
Directory.Delete(dir, recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string?> DownloadEpisodeStillAsync(
|
||||||
|
Guid episodeId,
|
||||||
|
string url,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = httpFactory.CreateClient("metadata");
|
||||||
|
using var response = await client.GetAsync(url, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var ext = ExtensionFor(url, response.Content.Headers.ContentType?.MediaType);
|
||||||
|
var dir = paths.MetadataEpisodeDir(episodeId);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
RemoveExisting(dir, "still");
|
||||||
|
|
||||||
|
var abs = paths.MetadataEpisodeStillPath(episodeId, ext);
|
||||||
|
await using var content = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
await using (var fs = File.Create(abs))
|
||||||
|
await content.CopyToAsync(fs, cancellationToken);
|
||||||
|
return paths.MetadataEpisodeStillRelative(episodeId, ext);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteEpisodeImages(Guid episodeId)
|
||||||
|
{
|
||||||
|
var dir = paths.MetadataEpisodeDir(episodeId);
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
Directory.Delete(dir, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
public string? ResolveAbsolutePath(string relativePath)
|
public string? ResolveAbsolutePath(string relativePath)
|
||||||
{
|
{
|
||||||
var abs = paths.ResolveRelative(relativePath);
|
var abs = paths.ResolveRelative(relativePath);
|
||||||
|
|||||||
+848
@@ -0,0 +1,848 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using TeleWave.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260725061302_EpisodeMetadata")]
|
||||||
|
partial class EpisodeMetadata
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.10")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FromShowId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("MediaAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Signature")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ToShowId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FromShowId", "ToShowId", "Signature");
|
||||||
|
|
||||||
|
b.ToTable("BumperAssets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AdInsertion")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("AdsPerBreak")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("BumperAccentColor")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("BumperBackgroundColor")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("BumperBackgroundColor2")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("BumperBackgroundExtension")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("BumperDurationSeconds")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BumperFont")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BumperMinIntervalMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BumperMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("BumperMusicExtension")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("BumperNextLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("BumperNowLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("BumperOnlyBetweenDifferentShows")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("BumperRevision")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("BumperTextColor")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("BumpersEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("EpochUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FillerAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<int>("NextAdIndex")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("NextJingleIndex")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Slug")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("MediaAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Position")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId", "Position");
|
||||||
|
|
||||||
|
b.ToTable("ChannelAd");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("MediaAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Position")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId", "Position");
|
||||||
|
|
||||||
|
b.ToTable("ChannelJingle");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("BlockMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BlockValue")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("NextEpisodeIndex")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("ShowId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Weight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId", "ShowId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelShow");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProgrammingOverrideId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ShowId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Weight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProgrammingOverrideId");
|
||||||
|
|
||||||
|
b.ToTable("OverrideShow");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("EndsAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Mode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("StartsAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("ProgrammingOverride");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("EndsAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("EpisodeIndex")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("MediaAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ShowId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("StartsAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId", "EndsAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId", "ShowId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId", "StartsAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("ScheduleEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataExternalId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataProvider")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PosterPath")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<int?>("Year")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Shows");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("AirDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("Episode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("MediaAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)");
|
||||||
|
|
||||||
|
b.Property<int>("Position")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("Season")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("ShowId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("StillPath")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MediaAssetId");
|
||||||
|
|
||||||
|
b.HasIndex("ShowId", "Position");
|
||||||
|
|
||||||
|
b.ToTable("ShowEpisode");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AudioCodec")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("Duration")
|
||||||
|
.HasColumnType("interval");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int?>("Height")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalExtension")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalFileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("RelativePath")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<int?>("SegmentCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SegmentSeconds")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Source")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("VideoCodec")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<int?>("Width")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("MediaAssets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("AppSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBlocked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||||
|
.WithMany("Ads")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||||
|
.WithMany("Jingles")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||||
|
.WithMany("Shows")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
|
||||||
|
.WithMany("Shows")
|
||||||
|
.HasForeignKey("ProgrammingOverrideId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||||
|
.WithMany("Overrides")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Domain.Library.Show", null)
|
||||||
|
.WithMany("Episodes")
|
||||||
|
.HasForeignKey("ShowId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Ads");
|
||||||
|
|
||||||
|
b.Navigation("Jingles");
|
||||||
|
|
||||||
|
b.Navigation("Overrides");
|
||||||
|
|
||||||
|
b.Navigation("Shows");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Shows");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Episodes");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class EpisodeMetadata : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateOnly>(
|
||||||
|
name: "AirDate",
|
||||||
|
table: "ShowEpisode",
|
||||||
|
type: "date",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Episode",
|
||||||
|
table: "ShowEpisode",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Overview",
|
||||||
|
table: "ShowEpisode",
|
||||||
|
type: "character varying(4096)",
|
||||||
|
maxLength: 4096,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Season",
|
||||||
|
table: "ShowEpisode",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "StillPath",
|
||||||
|
table: "ShowEpisode",
|
||||||
|
type: "character varying(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Title",
|
||||||
|
table: "ShowEpisode",
|
||||||
|
type: "character varying(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AirDate",
|
||||||
|
table: "ShowEpisode");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Episode",
|
||||||
|
table: "ShowEpisode");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Overview",
|
||||||
|
table: "ShowEpisode");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Season",
|
||||||
|
table: "ShowEpisode");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "StillPath",
|
||||||
|
table: "ShowEpisode");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Title",
|
||||||
|
table: "ShowEpisode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -488,18 +488,39 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("AirDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("Episode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<Guid>("MediaAssetId")
|
b.Property<Guid>("MediaAssetId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Overview")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)");
|
||||||
|
|
||||||
b.Property<int>("Position")
|
b.Property<int>("Position")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("Season")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<Guid>("ShowId")
|
b.Property<Guid>("ShowId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("StillPath")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("MediaAssetId");
|
b.HasIndex("MediaAssetId");
|
||||||
|
|||||||
@@ -30,5 +30,8 @@ public class ShowEpisodeConfiguration : IEntityTypeConfiguration<ShowEpisode>
|
|||||||
{
|
{
|
||||||
builder.HasIndex(x => new { x.ShowId, x.Position });
|
builder.HasIndex(x => new { x.ShowId, x.Position });
|
||||||
builder.HasIndex(x => x.MediaAssetId);
|
builder.HasIndex(x => x.MediaAssetId);
|
||||||
|
builder.Property(x => x.Title).HasMaxLength(512);
|
||||||
|
builder.Property(x => x.Overview).HasMaxLength(4096);
|
||||||
|
builder.Property(x => x.StillPath).HasMaxLength(256);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from '@/features/admin/media/episode-parse'
|
} from '@/features/admin/media/episode-parse'
|
||||||
import { formatDuration } from '@/features/admin/media/MediaPanel'
|
import { formatDuration } from '@/features/admin/media/MediaPanel'
|
||||||
import { ShowMetadataCard } from './ShowMetadataCard'
|
import { ShowMetadataCard } from './ShowMetadataCard'
|
||||||
import { addEpisode, getShow, removeEpisode } from './api'
|
import { addEpisode, episodeStillUrl, getShow, removeEpisode } from './api'
|
||||||
|
|
||||||
type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode }
|
type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode }
|
||||||
|
|
||||||
@@ -209,14 +209,32 @@ export function ShowDetail({ showId }: { showId: string }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{show.episodes.map((episode, index) => {
|
{show.episodes.map((episode, index) => {
|
||||||
const label = formatSeasonEpisode(parseEpisodeName(episode.assetName ?? ''))
|
const parsed =
|
||||||
|
episode.season != null && episode.episode != null
|
||||||
|
? { season: episode.season, episode: episode.episode }
|
||||||
|
: parseEpisodeName(episode.assetName ?? '')
|
||||||
|
const label = formatSeasonEpisode(parsed)
|
||||||
return (
|
return (
|
||||||
<tr key={episode.id} className="border-b border-border last:border-0">
|
<tr key={episode.id} className="border-b border-border last:border-0">
|
||||||
<td className="px-4 py-2 text-muted-foreground">{index + 1}</td>
|
<td className="px-4 py-2 text-muted-foreground">{index + 1}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{episode.hasStill && (
|
||||||
|
<img
|
||||||
|
src={episodeStillUrl(episode.id)}
|
||||||
|
alt=""
|
||||||
|
className="h-9 w-16 shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{label && <Badge>{label}</Badge>}
|
{label && <Badge>{label}</Badge>}
|
||||||
<span>{episode.assetName ?? '—'}</span>
|
<div className="min-w-0">
|
||||||
|
<div className="truncate">{episode.title ?? episode.assetName ?? '—'}</div>
|
||||||
|
{episode.title && (
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{episode.assetName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-muted-foreground">
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
applyMetadata,
|
applyMetadata,
|
||||||
clearMetadata,
|
clearMetadata,
|
||||||
getMetadataProviders,
|
getMetadataProviders,
|
||||||
|
refreshEpisodesMetadata,
|
||||||
searchMetadata,
|
searchMetadata,
|
||||||
showPosterUrl,
|
showPosterUrl,
|
||||||
updateMetadata,
|
updateMetadata,
|
||||||
@@ -83,6 +84,17 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
onSuccess: changed,
|
onSuccess: changed,
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
|
const refreshEpisodes = useMutation({
|
||||||
|
mutationFn: () => refreshEpisodesMetadata(show.id),
|
||||||
|
onSuccess: (count) => {
|
||||||
|
toast.success(t('admin.metadata.refreshedCount', { count }))
|
||||||
|
onChanged()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const linked =
|
||||||
|
!!show.metadataExternalId && !!show.metadataProvider && show.metadataProvider !== 'manual'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -209,10 +221,22 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button size="sm" disabled={saveManual.isPending} onClick={() => saveManual.mutate()}>
|
<Button size="sm" disabled={saveManual.isPending} onClick={() => saveManual.mutate()}>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{linked && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={refreshEpisodes.isPending}
|
||||||
|
onClick={() => refreshEpisodes.mutate()}
|
||||||
|
>
|
||||||
|
{refreshEpisodes.isPending
|
||||||
|
? t('admin.metadata.refreshing')
|
||||||
|
: t('admin.metadata.refreshEpisodes')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{(show.metadataProvider || show.hasPoster) && (
|
{(show.metadataProvider || show.hasPoster) && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ export function showPosterUrl(showId: string, bust?: string) {
|
|||||||
return `/api/metadata/shows/${showId}/poster${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
|
return `/api/metadata/shows/${showId}/poster${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ссылка на локальный кадр серии. */
|
||||||
|
export function episodeStillUrl(episodeId: string, bust?: string) {
|
||||||
|
return `/api/metadata/episodes/${episodeId}/still${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Довыгрузить метаданные серий из привязанного источника. Возвращает число обновлённых. */
|
||||||
|
export function refreshEpisodesMetadata(showId: string) {
|
||||||
|
return apiRequest<number>(`/admin/metadata/shows/${showId}/refresh-episodes`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
/** Загрузка постера вручную (сырое тело, имя в query — как uploadMedia). */
|
/** Загрузка постера вручную (сырое тело, имя в query — как uploadMedia). */
|
||||||
export function uploadPoster(showId: string, file: File): Promise<void> {
|
export function uploadPoster(showId: string, file: File): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Radio, RotateCw } from 'lucide-react'
|
import { Radio, RotateCw } from 'lucide-react'
|
||||||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { ChannelPlayer } from './ChannelPlayer'
|
import { ChannelPlayer } from './ChannelPlayer'
|
||||||
import { getEpg, listChannels, watchChannel } from './api'
|
import { episodeStillUrl, getEpg, listChannels, showPosterUrl, watchChannel } from './api'
|
||||||
|
|
||||||
function formatTime(iso: string) {
|
function formatTime(iso: string) {
|
||||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -62,7 +62,7 @@ export function AirPage() {
|
|||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { current, upcoming } = useMemo(() => buildGuide(epg ?? []), [epg])
|
const { current, upcoming, currentEntry } = 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>
|
||||||
|
|
||||||
@@ -89,8 +89,23 @@ export function AirPage() {
|
|||||||
selected === channel.slug && 'border-primary text-primary',
|
selected === channel.slug && 'border-primary text-primary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Radio className="h-4 w-4" />
|
{channel.currentShowHasPoster && channel.currentShowId ? (
|
||||||
{channel.name}
|
<img
|
||||||
|
src={showPosterUrl(channel.currentShowId)}
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-7 shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Radio className="h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate">{channel.name}</span>
|
||||||
|
{channel.currentShowName && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{channel.currentShowName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</aside>
|
</aside>
|
||||||
@@ -122,14 +137,37 @@ export function AirPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{current && (
|
{current && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="crt-panel flex gap-3 rounded-md p-3">
|
||||||
<div className="flex items-center gap-2">
|
{currentEntry?.episodeHasStill && currentEntry.episodeId ? (
|
||||||
<Badge>{t('air.now')}</Badge>
|
<img
|
||||||
<span className="font-medium">{current.showName}</span>
|
src={episodeStillUrl(currentEntry.episodeId)}
|
||||||
|
alt=""
|
||||||
|
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||||
|
/>
|
||||||
|
) : currentEntry?.showHasPoster && current.showId ? (
|
||||||
|
<img
|
||||||
|
src={showPosterUrl(current.showId)}
|
||||||
|
alt=""
|
||||||
|
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge>{t('air.now')}</Badge>
|
||||||
|
<span className="font-medium">{current.showName}</span>
|
||||||
|
</div>
|
||||||
|
{currentEntry?.episodeTitle && (
|
||||||
|
<span className="text-sm">{currentEntry.episodeTitle}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)}
|
||||||
|
</span>
|
||||||
|
{currentEntry?.episodeOverview && (
|
||||||
|
<p className="line-clamp-3 text-xs text-muted-foreground">
|
||||||
|
{currentEntry.episodeOverview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -166,10 +204,14 @@ type GuideBlock = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Строит телегид: рекламу не показываем, а подряд идущие серии одного шоу склеиваем в один блок
|
* Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один
|
||||||
* с диапазоном «с – по». Реклама между сериями одного шоу поглощается блоком (как в обычном EPG).
|
* блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»).
|
||||||
*/
|
*/
|
||||||
function buildGuide(entries: ScheduleEntryDto[]): { current?: GuideBlock; upcoming: GuideBlock[] } {
|
function buildGuide(entries: PublicEpgEntryDto[]): {
|
||||||
|
current?: GuideBlock
|
||||||
|
upcoming: GuideBlock[]
|
||||||
|
currentEntry?: PublicEpgEntryDto
|
||||||
|
} {
|
||||||
const blocks: GuideBlock[] = []
|
const blocks: GuideBlock[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.kind !== 'Program') continue
|
if (entry.kind !== 'Program') continue
|
||||||
@@ -178,7 +220,7 @@ function buildGuide(entries: ScheduleEntryDto[]): { current?: GuideBlock; upcomi
|
|||||||
last.endsAtUtc = entry.endsAtUtc
|
last.endsAtUtc = entry.endsAtUtc
|
||||||
} else {
|
} else {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
key: entry.id,
|
key: entry.startsAtUtc,
|
||||||
showId: entry.showId,
|
showId: entry.showId,
|
||||||
showName: entry.showName ?? '—',
|
showName: entry.showName ?? '—',
|
||||||
startsAtUtc: entry.startsAtUtc,
|
startsAtUtc: entry.startsAtUtc,
|
||||||
@@ -188,9 +230,12 @@ function buildGuide(entries: ScheduleEntryDto[]): { current?: GuideBlock; upcomi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const current = blocks.find(
|
const active = (start: string, end: string) =>
|
||||||
(b) => new Date(b.startsAtUtc).getTime() <= now && new Date(b.endsAtUtc).getTime() > now,
|
new Date(start).getTime() <= now && new Date(end).getTime() > now
|
||||||
)
|
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
|
||||||
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
|
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
|
||||||
return { current, upcoming }
|
const currentEntry = entries.find(
|
||||||
|
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
|
||||||
|
)
|
||||||
|
return { current, upcoming, currentEntry }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { apiRequest } from '@/shared/api/client'
|
import { apiRequest } from '@/shared/api/client'
|
||||||
import type { PublicChannelDto, ScheduleEntryDto } from '@/shared/api/types'
|
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
|
||||||
|
|
||||||
|
/** Ссылки на локальные постер шоу / кадр серии (публичные эндпоинты метаданных). */
|
||||||
|
export function showPosterUrl(showId: string) {
|
||||||
|
return `/api/metadata/shows/${showId}/poster`
|
||||||
|
}
|
||||||
|
export function episodeStillUrl(episodeId: string) {
|
||||||
|
return `/api/metadata/episodes/${episodeId}/still`
|
||||||
|
}
|
||||||
|
|
||||||
export function listChannels() {
|
export function listChannels() {
|
||||||
return apiRequest<PublicChannelDto[]>('/channels')
|
return apiRequest<PublicChannelDto[]>('/channels')
|
||||||
@@ -15,5 +23,5 @@ export function getEpg(slug: string, from?: Date, to?: Date) {
|
|||||||
if (from) query.set('from', from.toISOString())
|
if (from) query.set('from', from.toISOString())
|
||||||
if (to) query.set('to', to.toISOString())
|
if (to) query.set('to', to.toISOString())
|
||||||
const qs = query.toString()
|
const qs = query.toString()
|
||||||
return apiRequest<ScheduleEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
|
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ export type EpisodeDto = {
|
|||||||
assetName: string | null
|
assetName: string | null
|
||||||
assetStatus: MediaAssetStatus | null
|
assetStatus: MediaAssetStatus | null
|
||||||
durationSeconds: number | null
|
durationSeconds: number | null
|
||||||
|
season: number | null
|
||||||
|
episode: number | null
|
||||||
|
title: string | null
|
||||||
|
overview: string | null
|
||||||
|
hasStill: boolean
|
||||||
|
airDate: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShowDto = {
|
export type ShowDto = {
|
||||||
@@ -200,4 +206,24 @@ export type ScheduleEntryDto = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Публичный эфир ─────────────────────────────────────────────────────────
|
// ── Публичный эфир ─────────────────────────────────────────────────────────
|
||||||
export type PublicChannelDto = { id: string; slug: string; name: string }
|
export type PublicChannelDto = {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
currentShowId: string | null
|
||||||
|
currentShowName: string | null
|
||||||
|
currentShowHasPoster: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicEpgEntryDto = {
|
||||||
|
kind: ScheduleEntryKind
|
||||||
|
startsAtUtc: string
|
||||||
|
endsAtUtc: string
|
||||||
|
showId: string | null
|
||||||
|
showName: string | null
|
||||||
|
showHasPoster: boolean
|
||||||
|
episodeId: string | null
|
||||||
|
episodeTitle: string | null
|
||||||
|
episodeOverview: string | null
|
||||||
|
episodeHasStill: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -273,6 +273,9 @@ const resources = {
|
|||||||
clear: 'Очистить',
|
clear: 'Очистить',
|
||||||
uploadPoster: 'Загрузить постер',
|
uploadPoster: 'Загрузить постер',
|
||||||
noPoster: 'Нет постера',
|
noPoster: 'Нет постера',
|
||||||
|
refreshEpisodes: 'Обновить серии',
|
||||||
|
refreshing: 'Обновляем…',
|
||||||
|
refreshedCount: 'Обновлено серий: {{count}}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -548,6 +551,9 @@ const resources = {
|
|||||||
clear: 'Clear',
|
clear: 'Clear',
|
||||||
uploadPoster: 'Upload poster',
|
uploadPoster: 'Upload poster',
|
||||||
noPoster: 'No poster',
|
noPoster: 'No poster',
|
||||||
|
refreshEpisodes: 'Refresh episodes',
|
||||||
|
refreshing: 'Refreshing…',
|
||||||
|
refreshedCount: 'Episodes updated: {{count}}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user