Refactor user and media endpoints: remove unused GetUser and GetMedia methods, streamline AdminUserEndpoints and MediaEndpoints, and enhance metadata handling in MetadataEndpoints. Update Program.cs to remove legacy image relocation logic and improve overall code clarity.
build / backend (push) Successful in 1m14s
build / frontend (push) Successful in 35s
tests / backend-tests (push) Successful in 3m16s

This commit is contained in:
Leonid Pershin
2026-07-26 02:58:17 +03:00
parent 44e954920c
commit a0533a8ed8
50 changed files with 230 additions and 849 deletions
@@ -10,10 +10,6 @@ internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : IC
public Guid? UserId => ParseHttpUserId();
public string? UserName => Principal?.FindFirstValue(ClaimTypes.Name);
public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false;
private Guid? ParseHttpUserId()
{
var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -11,7 +11,6 @@ namespace TeleWave.Infrastructure.Identity;
internal sealed class IdentityService(
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
RoleManager<AppRole> roleManager,
AppDbContext dbContext
) : IIdentityService
{
@@ -72,15 +71,8 @@ internal sealed class IdentityService(
return null;
var role = await GetPrimaryRoleAsync(user);
var roleEntity = await roleManager.FindByNameAsync(role);
return new CurrentUserProfile(
user.Id,
user.UserName!,
roleEntity?.Id ?? Guid.Empty,
role,
user.IsBlocked
);
return new CurrentUserProfile(user.Id, user.UserName!, role, user.IsBlocked);
}
public async Task<Result> ChangePasswordAsync(
@@ -260,19 +252,6 @@ internal sealed class IdentityService(
return new PagedList<UserSummaryDto>(items, total, page, pageSize);
}
public async Task<UserSummaryDto?> GetUserAsync(
Guid userId,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return null;
var role = await GetPrimaryRoleAsync(user);
return new UserSummaryDto(user.Id, user.UserName!, role, user.IsBlocked, user.CreatedAt);
}
private async Task<string> GetPrimaryRoleAsync(AppUser user)
{
var roles = await userManager.GetRolesAsync(user);
@@ -122,7 +122,11 @@ public sealed class BumperRenderBackgroundService(
var spec = await BuildSpecAsync(db, assetId, cancellationToken);
if (spec is null)
{
await FailAsync(assetId, "Не удалось восстановить спецификацию заставки", cancellationToken);
await FailAsync(
assetId,
"Не удалось восстановить спецификацию заставки",
cancellationToken
);
return;
}
@@ -195,7 +199,11 @@ public sealed class BumperRenderBackgroundService(
if (variant.Kind == Domain.Broadcast.BumperTextKind.NowNext)
posterPath = await ResolveShowPosterAsync(db, cache.ToShowId, cancellationToken);
var bgPath = await ResolveTemplateBackgroundAsync(db, template.BackgroundImageId, cancellationToken);
var bgPath = await ResolveTemplateBackgroundAsync(
db,
template.BackgroundImageId,
cancellationToken
);
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
_segmentSeconds
@@ -1,20 +1,6 @@
using System.Threading.Channels;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>Сигнальная очередь-будильник поверх Channel (id ассета — лишь сигнал; работу берём из БД).</summary>
public sealed class BumperRenderQueue : IBumperRenderQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions { SingleReader = true }
);
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
public async ValueTask WaitAsync(CancellationToken cancellationToken)
{
await _channel.Reader.ReadAsync(cancellationToken);
while (_channel.Reader.TryRead(out _)) { }
}
}
/// <summary>Сигнальная очередь-будильник рендерера заставок (см. <see cref="SignalQueue"/>).</summary>
public sealed class BumperRenderQueue : SignalQueue, IBumperRenderQueue { }
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Text;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using static TeleWave.Infrastructure.Media.FfmpegText;
namespace TeleWave.Infrastructure.Media;
@@ -221,9 +222,7 @@ public sealed class FfmpegBumperRenderer(
var nextSize = FitSize(spec.NextTitle, titleSize, textWidth);
vchain
.Append(',')
.Append(
DrawLabel(font, nowLabelFile, spec.AccentColor, labelSize, nowLabelY, 0.2)
);
.Append(DrawLabel(font, nowLabelFile, spec.AccentColor, labelSize, nowLabelY, 0.2));
vchain
.Append(',')
.Append(DrawTitle(font, nowFile, spec.TextColor, nowSize, nowTitleY, 0.3));
@@ -340,9 +339,6 @@ public sealed class FfmpegBumperRenderer(
/// двоеточие экранируется). На Linux (контейнере) — фактически no-op.</summary>
private static string EscapePath(string path) => path.Replace('\\', '/').Replace(":", "\\:");
private static string Fmt(double value) =>
value.ToString("0.###", CultureInfo.InvariantCulture);
private static void TryDelete(string path)
{
try
@@ -355,11 +351,4 @@ public sealed class FfmpegBumperRenderer(
// Файл-подсказка для drawtext; не критично, если не удалился.
}
}
private static string Tail(string text)
{
text = text.Trim();
const int max = 500;
return text.Length <= max ? text : text[^max..];
}
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media;
using static TeleWave.Infrastructure.Media.FfmpegText;
namespace TeleWave.Infrastructure.Media;
@@ -98,14 +99,7 @@ public sealed class FfmpegMediaProcessor(
if (padding)
vfilter += $",tpad=stop_duration={Fmt(pad)}:stop_mode=add:color=black";
var args = new List<string>
{
"-hide_banner",
"-nostdin",
"-y",
"-i",
input,
};
var args = new List<string> { "-hide_banner", "-nostdin", "-y", "-i", input };
// Явный выбор дорожки по предпочитаемому языку: маппим видео + конкретную аудиодорожку.
// Без выбора (audioTrack == null) — не маппим, оставляя дефолтную эвристику ffmpeg (как раньше).
@@ -262,13 +256,17 @@ public sealed class FfmpegMediaProcessor(
await using var scope = scopeFactory.CreateAsyncScope();
var settings = scope.ServiceProvider.GetRequiredService<ISiteSettings>();
var configured = await settings.GetPreferredAudioLanguagesAsync(cancellationToken);
var raw = string.IsNullOrWhiteSpace(configured) ? _media.PreferredAudioLanguages : configured;
return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
var raw = string.IsNullOrWhiteSpace(configured)
? _media.PreferredAudioLanguages
: configured;
return raw.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
)
.Select(x => x.ToLowerInvariant())
.ToList();
}
private static (int Width, int Height) ScaleDown(int width, int height)
{
if (width <= MaxWidth)
@@ -279,16 +277,6 @@ public sealed class FfmpegMediaProcessor(
return (MaxWidth, scaledHeight);
}
private static string Fmt(double value) =>
value.ToString("0.###", CultureInfo.InvariantCulture);
private static string Tail(string text)
{
text = text.Trim();
const int max = 500;
return text.Length <= max ? text : text[^max..];
}
private sealed record ProbeInfo(
double Duration,
int Width,
@@ -0,0 +1,18 @@
using System.Globalization;
namespace TeleWave.Infrastructure.Media;
/// <summary>Общие текстовые хелперы для сборки аргументов ffmpeg и разбора его вывода.</summary>
internal static class FfmpegText
{
/// <summary>Формат double для фильтров/аргументов ffmpeg (инвариантная культура, до 3 знаков).</summary>
public static string Fmt(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
/// <summary>Хвост stderr для сообщения об ошибке (последние 500 символов).</summary>
public static string Tail(string text)
{
text = text.Trim();
const int max = 500;
return text.Length <= max ? text : text[^max..];
}
}
@@ -18,7 +18,6 @@ public sealed class MediaPathResolver
OriginalsDir = Path.Combine(_root, "originals");
AssetsDir = Path.Combine(_root, "assets");
BumpersDir = Path.Combine(_root, "bumpers");
MetadataDir = Path.Combine(_root, "metadata");
ImagesDir = Path.Combine(_root, "images");
}
@@ -30,9 +29,6 @@ public sealed class MediaPathResolver
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
public string BumpersDir { get; }
/// <summary>Картинки метаданных (постеры/кадры), скачанные локально.</summary>
public string MetadataDir { get; }
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
public string ImagesDir { get; }
@@ -43,47 +39,9 @@ public sealed class MediaPathResolver
Directory.CreateDirectory(OriginalsDir);
Directory.CreateDirectory(AssetsDir);
Directory.CreateDirectory(BumpersDir);
Directory.CreateDirectory(MetadataDir);
Directory.CreateDirectory(ImagesDir);
}
public string MetadataShowDir(Guid showId) =>
EnsureWithinRoot(Path.Combine(MetadataDir, "shows", showId.ToString("N")));
/// <summary>Абсолютный путь к файлу постера шоу (extension — с точкой).</summary>
public string MetadataShowPosterPath(Guid showId, string extension) =>
EnsureWithinRoot(
Path.Combine(MetadataDir, "shows", showId.ToString("N"), "poster" + extension)
);
/// <summary>Относительный путь постера от корня (для хранения в БД и отдачи).</summary>
public string MetadataShowPosterRelative(Guid showId, string 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>
public string? ResolveRelative(string relativePath)
{
try
{
return EnsureWithinRoot(Path.Combine(_root, relativePath));
}
catch (UnauthorizedAccessException)
{
return null;
}
}
public string BumperTemplateDir(Guid templateId) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
@@ -1,21 +1,6 @@
using System.Threading.Channels;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>Сигнальная очередь-будильник поверх Channel (id ассета используется лишь как сигнал).</summary>
public sealed class MediaProcessingQueue : IMediaProcessingQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions { SingleReader = true }
);
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
public async ValueTask WaitAsync(CancellationToken cancellationToken)
{
await _channel.Reader.ReadAsync(cancellationToken);
// Сдренировать накопившиеся сигналы — работу всё равно берём из БД пачкой.
while (_channel.Reader.TryRead(out _)) { }
}
}
/// <summary>Сигнальная очередь-будильник медиа-конвейера (см. <see cref="SignalQueue"/>).</summary>
public sealed class MediaProcessingQueue : SignalQueue, IMediaProcessingQueue { }
@@ -63,13 +63,9 @@ internal static class ProcessRunner
// (битый источник, -stream_loop и т.п.) не должен держать слот параллелизма/тик планировщика вечно.
using var timeoutCts =
timeout > TimeSpan.Zero ? new CancellationTokenSource(timeout) : null;
using var linked =
timeoutCts is null
? null
: CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
timeoutCts.Token
);
using var linked = timeoutCts is null
? null
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var waitToken = linked?.Token ?? cancellationToken;
try
@@ -79,7 +75,10 @@ internal static class ProcessRunner
catch (OperationCanceledException)
{
TryKill(process);
if (timeoutCts is { IsCancellationRequested: true } && !cancellationToken.IsCancellationRequested)
if (
timeoutCts is { IsCancellationRequested: true }
&& !cancellationToken.IsCancellationRequested
)
throw new TimeoutException(
$"Процесс {fileName} превысил таймаут {timeout.TotalSeconds:0}с и был прерван."
);
@@ -0,0 +1,26 @@
using System.Threading.Channels;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Базовая сигнальная очередь-будильник поверх Channel: id ассета используется лишь как сигнал,
/// а работу фоновый обработчик всё равно берёт из БД пачкой (по статусу Pending). Поэтому потеря
/// сигнала при рестарте не теряет задачи — они подхватываются из базы.
/// </summary>
public abstract class SignalQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions { SingleReader = true }
);
/// <summary>Разбудить обработчик: появился ассет в статусе Pending.</summary>
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
/// <summary>Ждать сигнала о новой работе (с дренажом накопленных).</summary>
public async ValueTask WaitAsync(CancellationToken cancellationToken)
{
await _channel.Reader.ReadAsync(cancellationToken);
// Сдренировать накопившиеся сигналы — работу всё равно берём из БД пачкой.
while (_channel.Reader.TryRead(out _)) { }
}
}
@@ -0,0 +1,53 @@
using System.Text.Json;
namespace TeleWave.Infrastructure.Metadata;
/// <summary>Общие для провайдеров метаданных хелперы: HTTP-загрузка JSON и чтение полей.</summary>
internal static class MetadataJson
{
/// <summary>GET+parse через клиент "metadata"; бросает при не-2xx/сетевой ошибке (для поиска — показать сбой).</summary>
public static async Task<JsonDocument> GetAsync(
IHttpClientFactory httpFactory,
string url,
CancellationToken cancellationToken
)
{
var client = httpFactory.CreateClient("metadata");
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
}
/// <summary>Как <see cref="GetAsync"/>, но глотает ошибки в null (для get/episode — деградируем мягко).</summary>
public static async Task<JsonDocument?> TryGetAsync(
IHttpClientFactory httpFactory,
string url,
CancellationToken cancellationToken
)
{
try
{
return await GetAsync(httpFactory, url, cancellationToken);
}
catch (Exception ex)
when (ex is HttpRequestException or JsonException or TaskCanceledException)
{
return null;
}
}
public static string? GetString(JsonElement el, string name) =>
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
public static int? GetInt(JsonElement el, string name) =>
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
? v.GetInt32()
: null;
/// <summary>Год из первых 4 символов строки даты/года ("YYYY-MM-DD" или "YYYY").</summary>
public static int? YearFrom(string? value) =>
value is { Length: >= 4 } && int.TryParse(value.AsSpan(0, 4), out var y) ? y : null;
}
@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata;
using static TeleWave.Infrastructure.Metadata.MetadataJson;
namespace TeleWave.Infrastructure.Metadata;
@@ -23,7 +24,7 @@ public sealed class OmdbMetadataProvider(
{
var url =
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&type=series&s={Uri.EscapeDataString(query)}";
using var doc = await GetJsonAsync(url, cancellationToken);
using var doc = await GetAsync(httpFactory, url, cancellationToken);
if (!doc.RootElement.TryGetProperty("Search", out var search))
return [];
@@ -52,7 +53,7 @@ public sealed class OmdbMetadataProvider(
)
{
var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}";
using var doc = await TryGetJsonAsync(url, cancellationToken);
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
if (doc is null || !IsResponseTrue(doc.RootElement))
return null;
var root = doc.RootElement;
@@ -75,7 +76,7 @@ public sealed class OmdbMetadataProvider(
var url =
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}"
+ $"&Season={season}&Episode={episode}";
using var doc = await TryGetJsonAsync(url, cancellationToken);
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
if (doc is null || !IsResponseTrue(doc.RootElement))
return null;
var root = doc.RootElement;
@@ -95,7 +96,7 @@ public sealed class OmdbMetadataProvider(
{
var url =
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}&Season={season}";
using var doc = await TryGetJsonAsync(url, cancellationToken);
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
if (
doc is null
|| !IsResponseTrue(doc.RootElement)
@@ -106,53 +107,14 @@ public sealed class OmdbMetadataProvider(
return episodes.GetArrayLength();
}
/// <summary>GET+parse; бросает при не-2xx/сетевой ошибке (для поиска — чтобы показать сбой).</summary>
private async Task<JsonDocument> GetJsonAsync(string url, CancellationToken cancellationToken)
{
var client = httpFactory.CreateClient("metadata");
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
}
/// <summary>Как GetJsonAsync, но глотает ошибки в null (для get/episode — деградируем мягко).</summary>
private async Task<JsonDocument?> TryGetJsonAsync(
string url,
CancellationToken cancellationToken
)
{
try
{
return await GetJsonAsync(url, cancellationToken);
}
catch (Exception ex)
when (ex is HttpRequestException or JsonException or TaskCanceledException)
{
return null;
}
}
private static bool IsResponseTrue(JsonElement root) =>
GetString(root, "Response") is { } r
&& r.Equals("True", StringComparison.OrdinalIgnoreCase);
private static string? GetString(JsonElement el, string name) =>
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
/// <summary>OMDb отдаёт «N/A» вместо отсутствующих значений — приводим к null.</summary>
private static string? Clean(string? value) =>
string.IsNullOrWhiteSpace(value) || value == "N/A" ? null : value;
private static int? YearFrom(string? year)
{
if (year is { Length: >= 4 } && int.TryParse(year.AsSpan(0, 4), out var y))
return y;
return null;
}
private static DateOnly? DateFrom(string? released) =>
DateTime.TryParse(released, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)
? DateOnly.FromDateTime(dt)
@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata;
using static TeleWave.Infrastructure.Metadata.MetadataJson;
namespace TeleWave.Infrastructure.Metadata;
@@ -26,7 +27,7 @@ public sealed class TmdbMetadataProvider(
var url =
$"{Tmdb.BaseUrl}/search/tv?api_key={Tmdb.ApiKey}&language={_options.Language}"
+ $"&include_adult=false&query={Uri.EscapeDataString(query)}";
using var doc = await GetJsonAsync(url, cancellationToken);
using var doc = await GetAsync(httpFactory, url, cancellationToken);
if (!doc.RootElement.TryGetProperty("results", out var results))
return [];
@@ -56,7 +57,7 @@ public sealed class TmdbMetadataProvider(
{
var url =
$"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}";
using var doc = await TryGetJsonAsync(url, cancellationToken);
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
if (doc is null)
return null;
var root = doc.RootElement;
@@ -79,7 +80,7 @@ public sealed class TmdbMetadataProvider(
var url =
$"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}"
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}";
using var doc = await TryGetJsonAsync(url, cancellationToken);
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
if (doc is null)
return null;
var root = doc.RootElement;
@@ -100,7 +101,7 @@ public sealed class TmdbMetadataProvider(
var url =
$"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}"
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}";
using var doc = await TryGetJsonAsync(url, cancellationToken);
using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
if (
doc is null
|| !doc.RootElement.TryGetProperty("episodes", out var episodes)
@@ -116,46 +117,6 @@ public sealed class TmdbMetadataProvider(
private string? StillUrl(string? path) =>
string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.StillSize}{path}";
/// <summary>GET+parse; бросает при не-2xx/сетевой ошибке (для поиска — чтобы показать сбой).</summary>
private async Task<JsonDocument> GetJsonAsync(string url, CancellationToken cancellationToken)
{
var client = httpFactory.CreateClient("metadata");
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
}
/// <summary>Как GetJsonAsync, но глотает ошибки в null (для get/episode — деградируем мягко).</summary>
private async Task<JsonDocument?> TryGetJsonAsync(
string url,
CancellationToken cancellationToken
)
{
try
{
return await GetJsonAsync(url, cancellationToken);
}
catch (Exception ex)
when (ex is HttpRequestException or JsonException or TaskCanceledException)
{
return null;
}
}
private static string? GetString(JsonElement el, string name) =>
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
private static int? GetInt(JsonElement el, string name) =>
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
? v.GetInt32()
: null;
private static int? YearFrom(string? date) =>
date is { Length: >= 4 } && int.TryParse(date.AsSpan(0, 4), out var y) ? y : null;
private static DateOnly? DateFrom(string? date) =>
DateOnly.TryParse(date, CultureInfo.InvariantCulture, out var d) ? d : null;
}
@@ -16,37 +16,34 @@ namespace TeleWave.Infrastructure.Migrations
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>(
name: "TemplateId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>(
name: "VariantId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ChannelId",
table: "BumperAssets");
migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets");
migrationBuilder.DropColumn(
name: "TemplateId",
table: "BumperAssets");
migrationBuilder.DropColumn(name: "TemplateId", table: "BumperAssets");
migrationBuilder.DropColumn(
name: "VariantId",
table: "BumperAssets");
migrationBuilder.DropColumn(name: "VariantId", table: "BumperAssets");
}
}
}
@@ -15,25 +15,23 @@ namespace TeleWave.Infrastructure.Migrations
name: "ProcessingDuration",
table: "MediaAssets",
type: "interval",
nullable: true);
nullable: true
);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ProcessingStartedAt",
table: "MediaAssets",
type: "timestamp with time zone",
nullable: true);
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProcessingDuration",
table: "MediaAssets");
migrationBuilder.DropColumn(name: "ProcessingDuration", table: "MediaAssets");
migrationBuilder.DropColumn(
name: "ProcessingStartedAt",
table: "MediaAssets");
migrationBuilder.DropColumn(name: "ProcessingStartedAt", table: "MediaAssets");
}
}
}
@@ -15,15 +15,14 @@ namespace TeleWave.Infrastructure.Migrations
table: "Shows",
type: "integer",
nullable: false,
defaultValue: 0);
defaultValue: 0
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Audience",
table: "Shows");
migrationBuilder.DropColumn(name: "Audience", table: "Shows");
}
}
}
@@ -40,10 +40,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
// корректности не нарушают.
var key = BitConverter.ToInt64(channelId.ToByteArray());
return Database.ExecuteSqlAsync(
$"SELECT pg_advisory_xact_lock({key})",
cancellationToken
);
return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Infrastructure.Persistence;
@@ -16,99 +15,4 @@ public static class MigrationExtensions
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync(cancellationToken);
}
/// <summary>
/// Идемпотентно переносит файлы постеров шоу, мигрированных в реестр изображений, из старого
/// расположения metadata/shows/{showId}/poster{ext} в images/{imageId}{ext}. Безопасно к повторным
/// запускам (пропускает, если целевой файл уже на месте).
/// </summary>
public static async Task RelocateLegacyImagesAsync(
this IServiceProvider services,
CancellationToken cancellationToken = default
)
{
await using var scope = services.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var paths = scope.ServiceProvider.GetRequiredService<MediaPathResolver>();
Directory.CreateDirectory(paths.ImagesDir);
// Постеры шоу: metadata/shows/{showId}/poster{ext} → images/{imageId}{ext}.
var posters = await dbContext
.Shows.AsNoTracking()
.Where(s => s.PosterImageId != null)
.Join(
dbContext.Images,
s => s.PosterImageId,
i => i.Id,
(s, i) =>
new
{
EntityId = s.Id,
ImageId = i.Id,
i.FileExtension,
}
)
.ToListAsync(cancellationToken);
foreach (var p in posters)
Relocate(
paths.ImagePath(p.ImageId, p.FileExtension),
paths.MetadataShowPosterPath(p.EntityId, p.FileExtension)
);
// Кадры серий: metadata/episodes/{episodeId}/still{ext} → images/{imageId}{ext}.
var stills = await dbContext
.Shows.AsNoTracking()
.SelectMany(s => s.Episodes)
.Where(e => e.StillImageId != null)
.Join(
dbContext.Images,
e => e.StillImageId,
i => i.Id,
(e, i) =>
new
{
EntityId = e.Id,
ImageId = i.Id,
i.FileExtension,
}
)
.ToListAsync(cancellationToken);
foreach (var s in stills)
Relocate(
paths.ImagePath(s.ImageId, s.FileExtension),
paths.MetadataEpisodeStillPath(s.EntityId, s.FileExtension)
);
// Фоны блоков заставок: bumpers/{templateId}/background{ext} → images/{imageId}{ext}.
var backgrounds = await dbContext
.Channels.AsNoTracking()
.SelectMany(c => c.BumperTemplates)
.Where(t => t.BackgroundImageId != null)
.Join(
dbContext.Images,
t => t.BackgroundImageId,
i => i.Id,
(t, i) =>
new
{
EntityId = t.Id,
ImageId = i.Id,
i.FileExtension,
}
)
.ToListAsync(cancellationToken);
foreach (var b in backgrounds)
Relocate(
paths.ImagePath(b.ImageId, b.FileExtension),
paths.BumperTemplateFilePath(b.EntityId, "background", b.FileExtension)
);
static void Relocate(string target, string legacy)
{
if (File.Exists(target) || !File.Exists(legacy))
return;
File.Move(legacy, target);
}
}
}
@@ -14,14 +14,20 @@ public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken)
{
await UpsertAsync(SettingKeys.RegistrationEnabled, enabled ? "true" : "false", cancellationToken);
await UpsertAsync(
SettingKeys.RegistrationEnabled,
enabled ? "true" : "false",
cancellationToken
);
}
public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
public Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken) =>
UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
public Task SetPreferredAudioLanguagesAsync(
string value,
CancellationToken cancellationToken
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
{