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:
@@ -3,7 +3,6 @@ using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Admin.Users.BlockUser;
|
||||
using TeleWave.Application.Admin.Users.CreateUser;
|
||||
using TeleWave.Application.Admin.Users.DeleteUser;
|
||||
using TeleWave.Application.Admin.Users.GetUser;
|
||||
using TeleWave.Application.Admin.Users.ListUsers;
|
||||
using TeleWave.Application.Admin.Users.ResetPassword;
|
||||
using TeleWave.Application.Admin.Users.UnblockUser;
|
||||
@@ -23,7 +22,6 @@ public static class AdminUserEndpoints
|
||||
|
||||
admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>();
|
||||
admin.MapPost("", CreateUser).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin.MapGet("/{id:guid}", GetUser).Produces<UserSummaryDto>();
|
||||
admin.MapPost("/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
@@ -79,16 +77,6 @@ public static class AdminUserEndpoints
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetUser(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetUserQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> BlockUser(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
|
||||
@@ -5,7 +5,6 @@ using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Media;
|
||||
using TeleWave.Application.Media.Delete;
|
||||
using TeleWave.Application.Media.GetMedia;
|
||||
using TeleWave.Application.Media.ListMedia;
|
||||
using TeleWave.Application.Media.Register;
|
||||
using TeleWave.Application.Media.Stats;
|
||||
@@ -26,7 +25,6 @@ public static class MediaEndpoints
|
||||
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
||||
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
||||
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
||||
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
|
||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
@@ -128,16 +126,6 @@ public static class MediaEndpoints
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Get(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetMediaAssetQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Delete(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Images.DeleteImage;
|
||||
using TeleWave.Application.Images.UploadImage;
|
||||
using TeleWave.Application.Metadata;
|
||||
using TeleWave.Application.Metadata.ApplyShowMetadata;
|
||||
using TeleWave.Application.Metadata.ClearShowMetadata;
|
||||
@@ -12,25 +9,12 @@ using TeleWave.Application.Metadata.RefreshEpisodes;
|
||||
using TeleWave.Application.Metadata.SearchShows;
|
||||
using TeleWave.Application.Metadata.SetShowPoster;
|
||||
using TeleWave.Application.Metadata.UpdateShowMetadata;
|
||||
using TeleWave.Domain.Images;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
public static class MetadataEndpoints
|
||||
{
|
||||
private const long MaxPosterBytes = 10L * 1024 * 1024; // 10 МБ
|
||||
|
||||
private static readonly IReadOnlySet<string> PosterExtensions = new HashSet<string>(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
{
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
};
|
||||
|
||||
public static IEndpointRouteBuilder MapMetadataEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/metadata")
|
||||
@@ -42,9 +26,6 @@ public static class MetadataEndpoints
|
||||
admin.MapPost("/shows/{showId:guid}/apply", Apply).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/shows/{showId:guid}", Update).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-image", SetPosterImage)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
@@ -118,49 +99,6 @@ public static class MetadataEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadPoster(
|
||||
Guid showId,
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IImageStore imageStore,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
if (
|
||||
request.ContentLength is > MaxPosterBytes or 0 or null
|
||||
|| !PosterExtensions.Contains(ext)
|
||||
)
|
||||
return MetadataErrors.InvalidPoster.ToProblem();
|
||||
|
||||
// Регистрируем постер в общем реестре (категория ShowPoster) и привязываем к шоу.
|
||||
var created = await sender.Send(
|
||||
new UploadImageCommand(ImageCategory.ShowPoster, ext, fileName),
|
||||
cancellationToken
|
||||
);
|
||||
if (!created.IsSuccess)
|
||||
return created.ToHttpResult();
|
||||
|
||||
try
|
||||
{
|
||||
await imageStore.SaveAsync(created.Value, ext, request.Body, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
var result = await sender.Send(
|
||||
new SetShowPosterCommand(showId, created.Value),
|
||||
cancellationToken
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetPosterImage(
|
||||
Guid showId,
|
||||
SetPosterImageBody body,
|
||||
|
||||
@@ -37,11 +37,17 @@ public static class SettingsEndpoints
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateSiteSettingsCommand(body.RegistrationEnabled, body.PreferredAudioLanguages ?? ""),
|
||||
new UpdateSiteSettingsCommand(
|
||||
body.RegistrationEnabled,
|
||||
body.PreferredAudioLanguages ?? ""
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateSiteSettingsBody(bool RegistrationEnabled, string? PreferredAudioLanguages);
|
||||
public sealed record UpdateSiteSettingsBody(
|
||||
bool RegistrationEnabled,
|
||||
string? PreferredAudioLanguages
|
||||
);
|
||||
|
||||
@@ -98,7 +98,6 @@ var app = builder.Build();
|
||||
|
||||
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
||||
await app.Services.ApplyMigrationsAsync();
|
||||
await app.Services.RelocateLegacyImagesAsync();
|
||||
await app.Services.SeedDataAsync();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.GetUser;
|
||||
|
||||
public sealed record GetUserQuery(Guid Id) : IQuery<Result<UserSummaryDto>>;
|
||||
@@ -1,20 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.GetUser;
|
||||
|
||||
public sealed class GetUserQueryHandler(IIdentityService identityService)
|
||||
: IQueryHandler<GetUserQuery, Result<UserSummaryDto>>
|
||||
{
|
||||
public async Task<Result<UserSummaryDto>> Handle(
|
||||
GetUserQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var user = await identityService.GetUserAsync(query.Id, cancellationToken);
|
||||
return user is null
|
||||
? Result.Failure<UserSummaryDto>(UserErrors.NotFound)
|
||||
: Result.Success(user);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Media;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||
|
||||
|
||||
@@ -3,6 +3,4 @@ namespace TeleWave.Application.Common.Interfaces;
|
||||
public interface ICurrentUser
|
||||
{
|
||||
Guid? UserId { get; }
|
||||
string? UserName { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
}
|
||||
|
||||
@@ -2,13 +2,7 @@ using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public sealed record CurrentUserProfile(
|
||||
Guid Id,
|
||||
string UserName,
|
||||
Guid RoleId,
|
||||
string Role,
|
||||
bool IsBlocked
|
||||
);
|
||||
public sealed record CurrentUserProfile(Guid Id, string UserName, string Role, bool IsBlocked);
|
||||
|
||||
public sealed record UserSummaryDto(
|
||||
Guid Id,
|
||||
@@ -72,6 +66,4 @@ public interface IIdentityService
|
||||
bool desc,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<UserSummaryDto?> GetUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -37,5 +37,9 @@ public interface IMediaStorage
|
||||
);
|
||||
|
||||
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
|
||||
Task DeleteAssetArtifactsAsync(Guid assetId, string extension, CancellationToken cancellationToken);
|
||||
Task DeleteAssetArtifactsAsync(
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Media.GetMedia;
|
||||
|
||||
public sealed record GetMediaAssetQuery(Guid Id) : IQuery<Result<MediaAssetDto>>;
|
||||
@@ -1,24 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Media.GetMedia;
|
||||
|
||||
public sealed class GetMediaAssetQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetMediaAssetQuery, Result<MediaAssetDto>>
|
||||
{
|
||||
public async Task<Result<MediaAssetDto>> Handle(
|
||||
GetMediaAssetQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var asset = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == query.Id, cancellationToken);
|
||||
|
||||
return asset is null
|
||||
? Result.Failure<MediaAssetDto>(MediaErrors.NotFound)
|
||||
: Result.Success(MediaAssetDto.From(asset));
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,14 @@ public sealed class GetMediaStatsQueryHandler(IAppDbContext dbContext)
|
||||
)
|
||||
{
|
||||
// Сгенерированные (ТВ-заставки) в статистику библиотеки не входят.
|
||||
var assets = dbContext.MediaAssets.AsNoTracking().Where(x => x.Source != MediaSource.Generated);
|
||||
var assets = dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(x => x.Source != MediaSource.Generated);
|
||||
|
||||
var queued = await assets.CountAsync(x => x.Status == MediaAssetStatus.Pending, cancellationToken);
|
||||
var queued = await assets.CountAsync(
|
||||
x => x.Status == MediaAssetStatus.Pending,
|
||||
cancellationToken
|
||||
);
|
||||
var processing = await assets.CountAsync(
|
||||
x => x.Status == MediaAssetStatus.Processing,
|
||||
cancellationToken
|
||||
|
||||
+7
-5
@@ -44,7 +44,10 @@ public sealed class FindMissingEpisodesQueryHandler(
|
||||
{
|
||||
var season = episode.Season;
|
||||
var number = episode.Episode;
|
||||
if ((season is null || number is null) && names.TryGetValue(episode.MediaAssetId, out var name))
|
||||
if (
|
||||
(season is null || number is null)
|
||||
&& names.TryGetValue(episode.MediaAssetId, out var name)
|
||||
)
|
||||
{
|
||||
if (EpisodeName.Parse(name) is { } parsed)
|
||||
(season, number) = (parsed.Season, parsed.Episode);
|
||||
@@ -66,10 +69,9 @@ public sealed class FindMissingEpisodesQueryHandler(
|
||||
season,
|
||||
cancellationToken
|
||||
);
|
||||
var missing =
|
||||
expected is { } exp
|
||||
? Enumerable.Range(1, exp).Where(n => !loaded.Contains(n)).ToList()
|
||||
: [];
|
||||
var missing = expected is { } exp
|
||||
? Enumerable.Range(1, exp).Where(n => !loaded.Contains(n)).ToList()
|
||||
: [];
|
||||
seasons.Add(new SeasonGapDto(season, expected, loaded.Count, missing));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,6 @@ public static class MetadataErrors
|
||||
"Метаданные по этому источнику не найдены."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidPoster = Error.Validation(
|
||||
"Metadata.InvalidPoster",
|
||||
"Недопустимый файл постера (формат или размер)."
|
||||
);
|
||||
|
||||
public static readonly Error NoLinkedSource = Error.Validation(
|
||||
"Metadata.NoLinkedSource",
|
||||
"У шоу не привязан внешний источник — сначала найдите шоу в TMDb/OMDb."
|
||||
|
||||
+4
-2
@@ -3,5 +3,7 @@ using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
||||
|
||||
public sealed record UpdateSiteSettingsCommand(bool RegistrationEnabled, string PreferredAudioLanguages)
|
||||
: ICommand<Result>;
|
||||
public sealed record UpdateSiteSettingsCommand(
|
||||
bool RegistrationEnabled,
|
||||
string PreferredAudioLanguages
|
||||
) : ICommand<Result>;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -26,9 +26,7 @@ public class LoginCommandHandlerTests
|
||||
.Returns(Result.Success(new AuthenticatedUser(userId, "alice", "user")));
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsBlocked: false)
|
||||
);
|
||||
.Returns(new CurrentUserProfile(userId, "alice", "user", IsBlocked: false));
|
||||
_jwtTokenService
|
||||
.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
|
||||
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
|
||||
@@ -67,9 +65,7 @@ public class LoginCommandHandlerTests
|
||||
.Returns(Result.Success(new AuthenticatedUser(userId, "alice", "user")));
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsBlocked: true)
|
||||
);
|
||||
.Returns(new CurrentUserProfile(userId, "alice", "user", IsBlocked: true));
|
||||
|
||||
var result = await CreateHandler()
|
||||
.Handle(new LoginCommand("alice", "password123"), CancellationToken.None);
|
||||
|
||||
@@ -32,9 +32,7 @@ public class RegisterCommandHandlerTests
|
||||
.Returns(Result.Success(userId));
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new CurrentUserProfile(userId, "bob", Guid.NewGuid(), "user", IsBlocked: false)
|
||||
);
|
||||
.Returns(new CurrentUserProfile(userId, "bob", "user", IsBlocked: false));
|
||||
_jwtTokenService
|
||||
.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
|
||||
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
|
||||
|
||||
@@ -9,7 +9,6 @@ using TeleWave.Application.Broadcast.RemoveChannelAd;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Library.DeleteShow;
|
||||
using TeleWave.Application.Library.GetShow;
|
||||
using TeleWave.Application.Media.GetMedia;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
@@ -152,31 +151,6 @@ public class QueryHandlersTests
|
||||
Assert.False(missing.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMedia_ReturnsDtoOrNotFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var asset = MediaAsset.Register("clip.mp4", ".mp4", MediaSource.Upload);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.MediaAssets.Add(asset);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var ok = await new GetMediaAssetQueryHandler(db).Handle(
|
||||
new GetMediaAssetQuery(asset.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(ok.IsSuccess);
|
||||
|
||||
var missing = await new GetMediaAssetQueryHandler(db).Handle(
|
||||
new GetMediaAssetQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.False(missing.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteShow_RemovesOrNotFound()
|
||||
{
|
||||
|
||||
@@ -30,12 +30,8 @@ public class FindMissingEpisodesTests
|
||||
}
|
||||
|
||||
var provider = Substitute.For<IMetadataProvider>();
|
||||
provider
|
||||
.GetSeasonEpisodeCountAsync("123", 1, Arg.Any<CancellationToken>())
|
||||
.Returns(5);
|
||||
provider
|
||||
.GetSeasonEpisodeCountAsync("123", 2, Arg.Any<CancellationToken>())
|
||||
.Returns(3);
|
||||
provider.GetSeasonEpisodeCountAsync("123", 1, Arg.Any<CancellationToken>()).Returns(5);
|
||||
provider.GetSeasonEpisodeCountAsync("123", 2, Arg.Any<CancellationToken>()).Returns(3);
|
||||
var resolver = Substitute.For<IMetadataProviderResolver>();
|
||||
resolver.Resolve("tmdb").Returns(provider);
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
||||
|
||||
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
||||
internal sealed class SequenceRandom(params int[] sequence) : Domain.Broadcast.Scheduling.IRandomSource
|
||||
internal sealed class SequenceRandom(params int[] sequence)
|
||||
: Domain.Broadcast.Scheduling.IRandomSource
|
||||
{
|
||||
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
|
||||
private int _i;
|
||||
|
||||
@@ -74,7 +74,11 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
||||
|
||||
var storage = Substitute.For<IMediaStorage>();
|
||||
storage
|
||||
.DeleteAssetArtifactsAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.DeleteAssetArtifactsAsync(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await using (var db = fixture.CreateContext())
|
||||
@@ -89,9 +93,7 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
||||
await using var verify = fixture.CreateContext();
|
||||
Assert.False(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id));
|
||||
Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.MediaAssetId == asset.Id));
|
||||
var reloaded = await verify
|
||||
.Shows.Include(s => s.Episodes)
|
||||
.FirstAsync(s => s.Id == show.Id);
|
||||
var reloaded = await verify.Shows.Include(s => s.Episodes).FirstAsync(s => s.Id == show.Id);
|
||||
Assert.Empty(reloaded.Episodes);
|
||||
await storage
|
||||
.Received()
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc -b",
|
||||
"preview": "vite preview",
|
||||
"gen:api": "openapi-typescript http://localhost:8080/openapi/v1.json -o src/shared/api/schema.gen.ts"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
@@ -40,7 +39,6 @@
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
Generated
+14
-206
@@ -71,7 +71,7 @@ importers:
|
||||
version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
|
||||
'@tanstack/router-plugin':
|
||||
specifier: ^1.168.18
|
||||
version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(supports-color@10.2.2)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
|
||||
version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
|
||||
'@types/node':
|
||||
specifier: ^24.13.2
|
||||
version: 24.13.3
|
||||
@@ -84,9 +84,6 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
|
||||
openapi-typescript:
|
||||
specifier: ^7.13.0
|
||||
version: 7.13.0(typescript@6.0.3)
|
||||
oxlint:
|
||||
specifier: ^1.71.0
|
||||
version: 1.75.0
|
||||
@@ -631,16 +628,6 @@ packages:
|
||||
'@radix-ui/rect@1.1.3':
|
||||
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
|
||||
|
||||
'@redocly/ajv@8.11.2':
|
||||
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
|
||||
|
||||
'@redocly/config@0.22.0':
|
||||
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
|
||||
|
||||
'@redocly/openapi-core@1.34.17':
|
||||
resolution: {integrity: sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==}
|
||||
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.1.5':
|
||||
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -928,21 +915,10 @@ packages:
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ansi-colors@4.1.3:
|
||||
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
ansis@4.3.1:
|
||||
resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -950,17 +926,11 @@ packages:
|
||||
babel-dead-code-elimination@1.0.12:
|
||||
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
baseline-browser-mapping@2.11.1:
|
||||
resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
brace-expansion@2.1.2:
|
||||
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
|
||||
|
||||
browserslist@4.28.7:
|
||||
resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
@@ -969,9 +939,6 @@ packages:
|
||||
caniuse-lite@1.0.30001806:
|
||||
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
||||
|
||||
change-case@5.4.4:
|
||||
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
|
||||
|
||||
chokidar@5.0.0:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
@@ -983,9 +950,6 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
colorette@1.4.0:
|
||||
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
@@ -1026,9 +990,6 @@ packages:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1060,10 +1021,6 @@ packages:
|
||||
html-parse-stringify@4.0.1:
|
||||
resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
i18next@26.3.6:
|
||||
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==}
|
||||
peerDependencies:
|
||||
@@ -1072,10 +1029,6 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
index-to-position@1.2.0:
|
||||
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
isbot@5.2.1:
|
||||
resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1084,25 +1037,14 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-levenshtein@1.1.6:
|
||||
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@4.2.0:
|
||||
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
|
||||
hasBin: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1267,10 +1209,6 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
minimatch@5.1.9:
|
||||
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1283,12 +1221,6 @@ packages:
|
||||
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
openapi-typescript@7.13.0:
|
||||
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^5.x
|
||||
|
||||
oxlint@1.75.0:
|
||||
resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1302,10 +1234,6 @@ packages:
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
parse-json@8.3.0:
|
||||
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
@@ -1316,10 +1244,6 @@ packages:
|
||||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pluralize@8.0.0:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.5.22:
|
||||
resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1394,10 +1318,6 @@ packages:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
rolldown@1.1.5:
|
||||
resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1424,10 +1344,6 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tailwind-merge@3.6.0:
|
||||
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
||||
|
||||
@@ -1445,10 +1361,6 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
type-fest@4.41.0:
|
||||
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -1496,9 +1408,6 @@ packages:
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
uri-js-replace@1.0.1:
|
||||
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
|
||||
|
||||
use-callback-ref@1.3.3:
|
||||
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1573,13 +1482,6 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yaml-ast-parser@0.0.43:
|
||||
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
@@ -1611,7 +1513,7 @@ snapshots:
|
||||
|
||||
'@babel/compat-data@7.29.7': {}
|
||||
|
||||
'@babel/core@7.29.7(supports-color@10.2.2)':
|
||||
'@babel/core@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
@@ -1620,11 +1522,11 @@ snapshots:
|
||||
'@babel/helpers': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@10.2.2)
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
debug: 4.4.3
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -1651,17 +1553,17 @@ snapshots:
|
||||
|
||||
'@babel/helper-module-imports@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7(supports-color@10.2.2)
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.2.2)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@10.2.2)
|
||||
'@babel/traverse': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -1688,7 +1590,7 @@ snapshots:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/traverse@7.29.7(supports-color@10.2.2)':
|
||||
'@babel/traverse@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
@@ -1696,7 +1598,7 @@ snapshots:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2083,29 +1985,6 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.3': {}
|
||||
|
||||
'@redocly/ajv@8.11.2':
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
uri-js-replace: 1.0.1
|
||||
|
||||
'@redocly/config@0.22.0': {}
|
||||
|
||||
'@redocly/openapi-core@1.34.17(supports-color@10.2.2)':
|
||||
dependencies:
|
||||
'@redocly/ajv': 8.11.2
|
||||
'@redocly/config': 0.22.0
|
||||
colorette: 1.4.0
|
||||
https-proxy-agent: 7.0.6(supports-color@10.2.2)
|
||||
js-levenshtein: 1.1.6
|
||||
js-yaml: 4.2.0
|
||||
minimatch: 5.1.9
|
||||
pluralize: 8.0.0
|
||||
yaml-ast-parser: 0.0.43
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@rolldown/binding-android-arm64@1.1.5':
|
||||
optional: true
|
||||
|
||||
@@ -2272,9 +2151,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(supports-color@10.2.2)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))':
|
||||
'@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.2.2)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
'@tanstack/router-core': 1.171.15
|
||||
@@ -2335,35 +2214,23 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ansi-colors@4.1.3: {}
|
||||
|
||||
ansis@4.3.1: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
babel-dead-code-elimination@1.0.12:
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.2.2)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@10.2.2)
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
baseline-browser-mapping@2.11.1: {}
|
||||
|
||||
brace-expansion@2.1.2:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
browserslist@4.28.7:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.11.1
|
||||
@@ -2374,8 +2241,6 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001806: {}
|
||||
|
||||
change-case@5.4.4: {}
|
||||
|
||||
chokidar@5.0.0:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
@@ -2386,19 +2251,15 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
colorette@1.4.0: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie-es@3.1.1: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
debug@4.4.3(supports-color@10.2.2):
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 10.2.2
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
@@ -2415,8 +2276,6 @@ snapshots:
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.5):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
@@ -2434,35 +2293,18 @@ snapshots:
|
||||
|
||||
html-parse-stringify@4.0.1: {}
|
||||
|
||||
https-proxy-agent@7.0.6(supports-color@10.2.2):
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
i18next@26.3.6(typescript@6.0.3):
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
index-to-position@1.2.0: {}
|
||||
|
||||
isbot@5.2.1: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-levenshtein@1.1.6: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.2.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
@@ -2575,26 +2417,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.2
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.16: {}
|
||||
|
||||
node-releases@2.0.51: {}
|
||||
|
||||
openapi-typescript@7.13.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@redocly/openapi-core': 1.34.17(supports-color@10.2.2)
|
||||
ansi-colors: 4.1.3
|
||||
change-case: 5.4.4
|
||||
parse-json: 8.3.0
|
||||
supports-color: 10.2.2
|
||||
typescript: 6.0.3
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
oxlint@1.75.0:
|
||||
optionalDependencies:
|
||||
'@oxlint/binding-android-arm-eabi': 1.75.0
|
||||
@@ -2617,20 +2445,12 @@ snapshots:
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.75.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.75.0
|
||||
|
||||
parse-json@8.3.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
index-to-position: 1.2.0
|
||||
type-fest: 4.41.0
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.5: {}
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
postcss@8.5.22:
|
||||
dependencies:
|
||||
nanoid: 3.3.16
|
||||
@@ -2690,8 +2510,6 @@ snapshots:
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
rolldown@1.1.5:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.139.0
|
||||
@@ -2725,8 +2543,6 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
tailwind-merge@3.6.0: {}
|
||||
|
||||
tailwindcss@4.3.3: {}
|
||||
@@ -2740,8 +2556,6 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-fest@4.41.0: {}
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
@@ -2761,8 +2575,6 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uri-js-replace@1.0.1: {}
|
||||
|
||||
use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.8):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
@@ -2798,10 +2610,6 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml-ast-parser@0.0.43: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
zustand@5.0.14(@types/react@19.2.17)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiRequest, setAccessToken } from '@/shared/api/client'
|
||||
import type { AuthResponse, CurrentUser, RegistrationStatus } from '@/shared/api/types'
|
||||
import type { AuthResponse, RegistrationStatus } from '@/shared/api/types'
|
||||
import { useAuthStore } from './store'
|
||||
|
||||
/** Публично: включена ли открытая регистрация (для страниц входа/регистрации). */
|
||||
@@ -19,10 +19,6 @@ export function logout() {
|
||||
return apiRequest<void>('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function fetchCurrentUser() {
|
||||
return apiRequest<CurrentUser>('/auth/me')
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string) {
|
||||
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
|
||||
}
|
||||
|
||||
@@ -53,11 +53,8 @@ export type MediaAssetDto = {
|
||||
source: MediaSource
|
||||
status: MediaAssetStatus
|
||||
durationSeconds: number | null
|
||||
segmentCount: number | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
videoCodec: string | null
|
||||
audioCodec: string | null
|
||||
errorMessage: string | null
|
||||
processingSeconds: number | null
|
||||
createdAt: string
|
||||
@@ -119,7 +116,6 @@ export type EpisodeDto = {
|
||||
title: string | null
|
||||
overview: string | null
|
||||
stillImageId: string | null
|
||||
airDate: string | null
|
||||
}
|
||||
|
||||
export type ImageCategory = 'Library' | 'ShowPoster' | 'EpisodeStill' | 'BumperBackground'
|
||||
@@ -214,7 +210,6 @@ export type ChannelShowDto = {
|
||||
blockMode: BlockMode
|
||||
blockValue: number
|
||||
isEnabled: boolean
|
||||
nextEpisodeIndex: number
|
||||
/** Во сколько раз усиливать вес в предпочтительные часы (1 — без буста). */
|
||||
preferredWeightMultiplier: number
|
||||
preferredHours: HourWindow[]
|
||||
|
||||
@@ -6,7 +6,6 @@ const resources = {
|
||||
translation: {
|
||||
appName: 'TeleWave',
|
||||
nav: {
|
||||
home: 'Главная',
|
||||
dashboard: 'Эфир',
|
||||
admin: 'Админка',
|
||||
settings: 'Настройки',
|
||||
@@ -24,7 +23,6 @@ const resources = {
|
||||
create: 'Создать',
|
||||
loading: 'Загрузка…',
|
||||
error: 'Что-то пошло не так',
|
||||
confirm: 'Подтвердить',
|
||||
search: 'Поиск',
|
||||
actions: 'Действия',
|
||||
prevPage: 'Предыдущая страница',
|
||||
@@ -57,11 +55,6 @@ const resources = {
|
||||
blocked: 'Аккаунт заблокирован администратором',
|
||||
genericError: 'Не удалось выполнить вход. Попробуйте ещё раз',
|
||||
},
|
||||
dashboard: {
|
||||
welcome: 'На связи, {{userName}}',
|
||||
placeholder: 'Список каналов появится здесь позже — пока в эфире только тестовая заставка.',
|
||||
role: 'Роль',
|
||||
},
|
||||
air: {
|
||||
now: 'Сейчас',
|
||||
next: 'Далее',
|
||||
@@ -94,8 +87,6 @@ const resources = {
|
||||
system: 'Системная',
|
||||
create: 'Новая роль',
|
||||
rename: 'Переименовать',
|
||||
cannotModifySystem: 'Системную роль нельзя изменить или удалить',
|
||||
roleInUse: 'Роль назначена пользователям',
|
||||
},
|
||||
users: {
|
||||
title: 'Пользователи',
|
||||
@@ -123,14 +114,9 @@ const resources = {
|
||||
upload: 'Загрузить',
|
||||
uploadToShow: 'Загрузить в шоу',
|
||||
toShowTitle: 'Загрузить и добавить в шоу',
|
||||
toShowSubtitle: 'Файлов выбрано: {{count}}. После загрузки они добавятся сериями в выбранное шоу.',
|
||||
autoDetectLabel: 'Определять шоу по названию файла',
|
||||
autoDetectHint:
|
||||
'Каждый файл привяжется к шоу, чьё оригинальное (или отображаемое) название есть в имени релиза, напр. «The.Simpsons.S33E01…» → The Simpsons.',
|
||||
toShowShow: 'Шоу',
|
||||
toShowFallback: 'Для нераспознанных',
|
||||
toShowLibrary: 'В библиотеку',
|
||||
toShowPick: 'Выберите шоу',
|
||||
toShowSeason: 'Сезон (вручную)',
|
||||
toShowAuto: 'авто',
|
||||
toShowRegex: 'Regex серии',
|
||||
@@ -138,12 +124,10 @@ const resources = {
|
||||
toShowHint:
|
||||
'Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\\d+) для «01. Название.mkv».',
|
||||
toShowPreview: 'Что распознаем',
|
||||
toShowRecognized: 'распознано {{recognized}} из {{total}}',
|
||||
toShowMatched: 'шоу распознано у {{matched}} из {{total}}',
|
||||
applyToAll: 'Задать всем…',
|
||||
toShowUnknown: '—',
|
||||
toShowConfirm: 'Загрузить и добавить',
|
||||
uploaded: 'Файл загружен, идёт обработка',
|
||||
uploadedCount: 'Загружено файлов: {{count}}',
|
||||
uploadingCount: 'Загрузка {{done}}/{{total}}',
|
||||
cancelAll: 'Отменить все загрузки',
|
||||
@@ -202,8 +186,6 @@ const resources = {
|
||||
loadedSeasons: 'Загружены сезоны',
|
||||
episodes: 'Серии',
|
||||
episode: 'Серия',
|
||||
addEpisode: 'Добавить серию',
|
||||
pickAsset: 'Выберите файл',
|
||||
noEpisodes: 'Серий пока нет',
|
||||
filterAssets: 'Фильтр по имени, напр. Family.Guy.S16',
|
||||
selectAll: 'Выбрать все',
|
||||
@@ -251,7 +233,6 @@ const resources = {
|
||||
bumperBg2: 'Фон (цвет 2)',
|
||||
bumperAccent: 'Акцент',
|
||||
bumperText: 'Текст',
|
||||
bumperOnlyDifferent: 'Только на смене шоу (не внутри марафона)',
|
||||
bumperTemplates: 'Блоки заставок',
|
||||
bumperTemplatesHint:
|
||||
'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.',
|
||||
@@ -359,15 +340,12 @@ const resources = {
|
||||
},
|
||||
metadata: {
|
||||
title: 'Метаданные',
|
||||
pickFromGallery: 'Выбрать из галереи',
|
||||
pickPoster: 'Из галереи',
|
||||
name: 'Название',
|
||||
originalName: 'Оригинальное название (eng)',
|
||||
originalNamePlaceholder: 'Например: Family Guy',
|
||||
originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.',
|
||||
source: 'Источник',
|
||||
sourceLabel: 'Источник',
|
||||
searchPlaceholder: 'Название для поиска',
|
||||
searchBtn: 'Искать',
|
||||
nothingFound: 'Ничего не найдено',
|
||||
apply: 'Применить',
|
||||
@@ -375,7 +353,6 @@ const resources = {
|
||||
overview: 'Описание',
|
||||
year: 'Год',
|
||||
clear: 'Очистить',
|
||||
uploadPoster: 'Загрузить постер',
|
||||
noPoster: 'Нет постера',
|
||||
refreshEpisodes: 'Обновить серии',
|
||||
refreshing: 'Обновляем…',
|
||||
@@ -396,7 +373,6 @@ const resources = {
|
||||
translation: {
|
||||
appName: 'TeleWave',
|
||||
nav: {
|
||||
home: 'Home',
|
||||
dashboard: 'On Air',
|
||||
admin: 'Admin',
|
||||
settings: 'Settings',
|
||||
@@ -414,7 +390,6 @@ const resources = {
|
||||
create: 'Create',
|
||||
loading: 'Loading…',
|
||||
error: 'Something went wrong',
|
||||
confirm: 'Confirm',
|
||||
search: 'Search',
|
||||
actions: 'Actions',
|
||||
prevPage: 'Previous page',
|
||||
@@ -447,11 +422,6 @@ const resources = {
|
||||
blocked: 'Account blocked by an administrator',
|
||||
genericError: 'Could not sign in. Please try again',
|
||||
},
|
||||
dashboard: {
|
||||
welcome: 'On air, {{userName}}',
|
||||
placeholder: 'The channel list will show up here later — for now, enjoy the test card.',
|
||||
role: 'Role',
|
||||
},
|
||||
air: {
|
||||
now: 'Now',
|
||||
next: 'Up next',
|
||||
@@ -484,8 +454,6 @@ const resources = {
|
||||
system: 'System',
|
||||
create: 'New role',
|
||||
rename: 'Rename',
|
||||
cannotModifySystem: 'A system role cannot be modified or deleted',
|
||||
roleInUse: 'Role is assigned to users',
|
||||
},
|
||||
users: {
|
||||
title: 'Users',
|
||||
@@ -513,14 +481,9 @@ const resources = {
|
||||
upload: 'Upload',
|
||||
uploadToShow: 'Upload to show',
|
||||
toShowTitle: 'Upload and add to show',
|
||||
toShowSubtitle: '{{count}} file(s) selected. After upload they are added as episodes to the chosen show.',
|
||||
autoDetectLabel: 'Detect show from file name',
|
||||
autoDetectHint:
|
||||
'Each file is linked to the show whose original (or display) name appears in the release name, e.g. “The.Simpsons.S33E01…” → The Simpsons.',
|
||||
toShowShow: 'Show',
|
||||
toShowFallback: 'For unrecognized',
|
||||
toShowLibrary: 'To library',
|
||||
toShowPick: 'Pick a show',
|
||||
toShowSeason: 'Season (manual)',
|
||||
toShowAuto: 'auto',
|
||||
toShowRegex: 'Episode regex',
|
||||
@@ -528,12 +491,10 @@ const resources = {
|
||||
toShowHint:
|
||||
'Season and regex are optional: numbers are usually detected automatically (see below). Regex: 1 group = episode, 2 groups = season and episode. Example: ^(\\d+) for “01. Title.mkv”.',
|
||||
toShowPreview: 'What we detect',
|
||||
toShowRecognized: '{{recognized}} of {{total}} recognized',
|
||||
toShowMatched: 'show detected for {{matched}} of {{total}}',
|
||||
applyToAll: 'Set for all…',
|
||||
toShowUnknown: '—',
|
||||
toShowConfirm: 'Upload and add',
|
||||
uploaded: 'File uploaded, processing started',
|
||||
uploadedCount: 'Uploaded files: {{count}}',
|
||||
uploadingCount: 'Uploading {{done}}/{{total}}',
|
||||
cancelAll: 'Cancel all uploads',
|
||||
@@ -592,8 +553,6 @@ const resources = {
|
||||
loadedSeasons: 'Loaded seasons',
|
||||
episodes: 'Episodes',
|
||||
episode: 'Episode',
|
||||
addEpisode: 'Add episode',
|
||||
pickAsset: 'Pick a file',
|
||||
noEpisodes: 'No episodes yet',
|
||||
filterAssets: 'Filter by name, e.g. Family.Guy.S16',
|
||||
selectAll: 'Select all',
|
||||
@@ -641,7 +600,6 @@ const resources = {
|
||||
bumperBg2: 'Background (color 2)',
|
||||
bumperAccent: 'Accent',
|
||||
bumperText: 'Text',
|
||||
bumperOnlyDifferent: 'Only on show change (not within a marathon)',
|
||||
bumperTemplates: 'Bumper blocks',
|
||||
bumperTemplatesHint:
|
||||
'Each block has its own sound and style. The first block is the default and cannot be removed. Bumper length follows the sound length.',
|
||||
@@ -749,15 +707,12 @@ const resources = {
|
||||
},
|
||||
metadata: {
|
||||
title: 'Metadata',
|
||||
pickFromGallery: 'Pick from gallery',
|
||||
pickPoster: 'From gallery',
|
||||
name: 'Name',
|
||||
originalName: 'Original name (eng)',
|
||||
originalNamePlaceholder: 'e.g. Family Guy',
|
||||
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',
|
||||
source: 'Source',
|
||||
sourceLabel: 'Source',
|
||||
searchPlaceholder: 'Title to search',
|
||||
searchBtn: 'Search',
|
||||
nothingFound: 'Nothing found',
|
||||
apply: 'Apply',
|
||||
@@ -765,7 +720,6 @@ const resources = {
|
||||
overview: 'Overview',
|
||||
year: 'Year',
|
||||
clear: 'Clear',
|
||||
uploadPoster: 'Upload poster',
|
||||
noPoster: 'No poster',
|
||||
refreshEpisodes: 'Refresh episodes',
|
||||
refreshing: 'Refreshing…',
|
||||
|
||||
@@ -27,8 +27,3 @@ export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElem
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
@@ -5,7 +5,6 @@ import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Dialog = DialogPrimitive.Root
|
||||
export const DialogTrigger = DialogPrimitive.Trigger
|
||||
export const DialogClose = DialogPrimitive.Close
|
||||
|
||||
export const DialogOverlay = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
|
||||
Reference in New Issue
Block a user