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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+9
-12
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-8
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user