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

This commit is contained in:
Leonid Pershin
2026-07-26 02:58:17 +03:00
parent 44e954920c
commit a0533a8ed8
50 changed files with 230 additions and 849 deletions
@@ -3,7 +3,6 @@ using TeleWave.Api.Common;
using TeleWave.Application.Admin.Users.BlockUser; using TeleWave.Application.Admin.Users.BlockUser;
using TeleWave.Application.Admin.Users.CreateUser; using TeleWave.Application.Admin.Users.CreateUser;
using TeleWave.Application.Admin.Users.DeleteUser; using TeleWave.Application.Admin.Users.DeleteUser;
using TeleWave.Application.Admin.Users.GetUser;
using TeleWave.Application.Admin.Users.ListUsers; using TeleWave.Application.Admin.Users.ListUsers;
using TeleWave.Application.Admin.Users.ResetPassword; using TeleWave.Application.Admin.Users.ResetPassword;
using TeleWave.Application.Admin.Users.UnblockUser; using TeleWave.Application.Admin.Users.UnblockUser;
@@ -23,7 +22,6 @@ public static class AdminUserEndpoints
admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>(); admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin.MapPost("", CreateUser).Produces<CreatedIdResponse>(StatusCodes.Status201Created); 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}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent); admin.MapPost("/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
admin admin
@@ -79,16 +77,6 @@ public static class AdminUserEndpoints
: result.ToHttpResult(); : 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( private static async Task<IResult> BlockUser(
Guid id, Guid id,
ISender sender, ISender sender,
@@ -5,7 +5,6 @@ using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Media; using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete; using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.GetMedia;
using TeleWave.Application.Media.ListMedia; using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.Register; using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Stats; using TeleWave.Application.Media.Stats;
@@ -26,7 +25,6 @@ public static class MediaEndpoints
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created); admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>(); admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>(); admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
return app; return app;
@@ -128,16 +126,6 @@ public static class MediaEndpoints
return Results.Ok(result); 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( private static async Task<IResult> Delete(
Guid id, Guid id,
ISender sender, ISender sender,
@@ -1,8 +1,5 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Api.Common; 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;
using TeleWave.Application.Metadata.ApplyShowMetadata; using TeleWave.Application.Metadata.ApplyShowMetadata;
using TeleWave.Application.Metadata.ClearShowMetadata; using TeleWave.Application.Metadata.ClearShowMetadata;
@@ -12,25 +9,12 @@ using TeleWave.Application.Metadata.RefreshEpisodes;
using TeleWave.Application.Metadata.SearchShows; using TeleWave.Application.Metadata.SearchShows;
using TeleWave.Application.Metadata.SetShowPoster; using TeleWave.Application.Metadata.SetShowPoster;
using TeleWave.Application.Metadata.UpdateShowMetadata; using TeleWave.Application.Metadata.UpdateShowMetadata;
using TeleWave.Domain.Images;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
public static class MetadataEndpoints 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) public static IEndpointRouteBuilder MapMetadataEndpoints(this IEndpointRouteBuilder app)
{ {
var admin = app.MapGroup("/api/admin/metadata") 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.MapPost("/shows/{showId:guid}/apply", Apply).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent); admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/shows/{showId:guid}/poster", UploadPoster)
.Produces(StatusCodes.Status204NoContent);
admin admin
.MapPut("/shows/{showId:guid}/poster-image", SetPosterImage) .MapPut("/shows/{showId:guid}/poster-image", SetPosterImage)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
@@ -118,49 +99,6 @@ public static class MetadataEndpoints
return result.ToHttpResult(); 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( private static async Task<IResult> SetPosterImage(
Guid showId, Guid showId,
SetPosterImageBody body, SetPosterImageBody body,
@@ -37,11 +37,17 @@ public static class SettingsEndpoints
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateSiteSettingsCommand(body.RegistrationEnabled, body.PreferredAudioLanguages ?? ""), new UpdateSiteSettingsCommand(
body.RegistrationEnabled,
body.PreferredAudioLanguages ?? ""
),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record UpdateSiteSettingsBody(bool RegistrationEnabled, string? PreferredAudioLanguages); public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled,
string? PreferredAudioLanguages
);
-1
View File
@@ -98,7 +98,6 @@ var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. // Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync(); await app.Services.ApplyMigrationsAsync();
await app.Services.RelocateLegacyImagesAsync();
await app.Services.SeedDataAsync(); await app.Services.SeedDataAsync();
app.UseForwardedHeaders(); 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.Security.Cryptography;
using System.Text; using System.Text;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming; using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using Microsoft.Extensions.Options;
namespace TeleWave.Application.Broadcast.Scheduling; namespace TeleWave.Application.Broadcast.Scheduling;
@@ -3,6 +3,4 @@ namespace TeleWave.Application.Common.Interfaces;
public interface ICurrentUser public interface ICurrentUser
{ {
Guid? UserId { get; } Guid? UserId { get; }
string? UserName { get; }
bool IsAuthenticated { get; }
} }
@@ -2,13 +2,7 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Interfaces; namespace TeleWave.Application.Common.Interfaces;
public sealed record CurrentUserProfile( public sealed record CurrentUserProfile(Guid Id, string UserName, string Role, bool IsBlocked);
Guid Id,
string UserName,
Guid RoleId,
string Role,
bool IsBlocked
);
public sealed record UserSummaryDto( public sealed record UserSummaryDto(
Guid Id, Guid Id,
@@ -72,6 +66,4 @@ public interface IIdentityService
bool desc, bool desc,
CancellationToken cancellationToken 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> /// <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( var processing = await assets.CountAsync(
x => x.Status == MediaAssetStatus.Processing, x => x.Status == MediaAssetStatus.Processing,
cancellationToken cancellationToken
@@ -44,7 +44,10 @@ public sealed class FindMissingEpisodesQueryHandler(
{ {
var season = episode.Season; var season = episode.Season;
var number = episode.Episode; 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) if (EpisodeName.Parse(name) is { } parsed)
(season, number) = (parsed.Season, parsed.Episode); (season, number) = (parsed.Season, parsed.Episode);
@@ -66,10 +69,9 @@ public sealed class FindMissingEpisodesQueryHandler(
season, season,
cancellationToken cancellationToken
); );
var missing = var missing = expected is { } exp
expected is { } exp ? Enumerable.Range(1, exp).Where(n => !loaded.Contains(n)).ToList()
? Enumerable.Range(1, exp).Where(n => !loaded.Contains(n)).ToList() : [];
: [];
seasons.Add(new SeasonGapDto(season, expected, loaded.Count, missing)); 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( public static readonly Error NoLinkedSource = Error.Validation(
"Metadata.NoLinkedSource", "Metadata.NoLinkedSource",
"У шоу не привязан внешний источник — сначала найдите шоу в TMDb/OMDb." "У шоу не привязан внешний источник — сначала найдите шоу в TMDb/OMDb."
@@ -3,5 +3,7 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings; namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed record UpdateSiteSettingsCommand(bool RegistrationEnabled, string PreferredAudioLanguages) public sealed record UpdateSiteSettingsCommand(
: ICommand<Result>; bool RegistrationEnabled,
string PreferredAudioLanguages
) : ICommand<Result>;
@@ -10,10 +10,6 @@ internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : IC
public Guid? UserId => ParseHttpUserId(); public Guid? UserId => ParseHttpUserId();
public string? UserName => Principal?.FindFirstValue(ClaimTypes.Name);
public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false;
private Guid? ParseHttpUserId() private Guid? ParseHttpUserId()
{ {
var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier); var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -11,7 +11,6 @@ namespace TeleWave.Infrastructure.Identity;
internal sealed class IdentityService( internal sealed class IdentityService(
UserManager<AppUser> userManager, UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager, SignInManager<AppUser> signInManager,
RoleManager<AppRole> roleManager,
AppDbContext dbContext AppDbContext dbContext
) : IIdentityService ) : IIdentityService
{ {
@@ -72,15 +71,8 @@ internal sealed class IdentityService(
return null; return null;
var role = await GetPrimaryRoleAsync(user); var role = await GetPrimaryRoleAsync(user);
var roleEntity = await roleManager.FindByNameAsync(role);
return new CurrentUserProfile( return new CurrentUserProfile(user.Id, user.UserName!, role, user.IsBlocked);
user.Id,
user.UserName!,
roleEntity?.Id ?? Guid.Empty,
role,
user.IsBlocked
);
} }
public async Task<Result> ChangePasswordAsync( public async Task<Result> ChangePasswordAsync(
@@ -260,19 +252,6 @@ internal sealed class IdentityService(
return new PagedList<UserSummaryDto>(items, total, page, pageSize); 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) private async Task<string> GetPrimaryRoleAsync(AppUser user)
{ {
var roles = await userManager.GetRolesAsync(user); var roles = await userManager.GetRolesAsync(user);
@@ -122,7 +122,11 @@ public sealed class BumperRenderBackgroundService(
var spec = await BuildSpecAsync(db, assetId, cancellationToken); var spec = await BuildSpecAsync(db, assetId, cancellationToken);
if (spec is null) if (spec is null)
{ {
await FailAsync(assetId, "Не удалось восстановить спецификацию заставки", cancellationToken); await FailAsync(
assetId,
"Не удалось восстановить спецификацию заставки",
cancellationToken
);
return; return;
} }
@@ -195,7 +199,11 @@ public sealed class BumperRenderBackgroundService(
if (variant.Kind == Domain.Broadcast.BumperTextKind.NowNext) if (variant.Kind == Domain.Broadcast.BumperTextKind.NowNext)
posterPath = await ResolveShowPosterAsync(db, cache.ToShowId, cancellationToken); 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( var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template), BumperDuration.TemplateSeconds(template),
_segmentSeconds _segmentSeconds
@@ -1,20 +1,6 @@
using System.Threading.Channels;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
/// <summary>Сигнальная очередь-будильник поверх Channel (id ассета — лишь сигнал; работу берём из БД).</summary> /// <summary>Сигнальная очередь-будильник рендерера заставок (см. <see cref="SignalQueue"/>).</summary>
public sealed class BumperRenderQueue : IBumperRenderQueue public sealed class BumperRenderQueue : SignalQueue, 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 _)) { }
}
}
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Text; using System.Text;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using static TeleWave.Infrastructure.Media.FfmpegText;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
@@ -221,9 +222,7 @@ public sealed class FfmpegBumperRenderer(
var nextSize = FitSize(spec.NextTitle, titleSize, textWidth); var nextSize = FitSize(spec.NextTitle, titleSize, textWidth);
vchain vchain
.Append(',') .Append(',')
.Append( .Append(DrawLabel(font, nowLabelFile, spec.AccentColor, labelSize, nowLabelY, 0.2));
DrawLabel(font, nowLabelFile, spec.AccentColor, labelSize, nowLabelY, 0.2)
);
vchain vchain
.Append(',') .Append(',')
.Append(DrawTitle(font, nowFile, spec.TextColor, nowSize, nowTitleY, 0.3)); .Append(DrawTitle(font, nowFile, spec.TextColor, nowSize, nowTitleY, 0.3));
@@ -340,9 +339,6 @@ public sealed class FfmpegBumperRenderer(
/// двоеточие экранируется). На Linux (контейнере) — фактически no-op.</summary> /// двоеточие экранируется). На Linux (контейнере) — фактически no-op.</summary>
private static string EscapePath(string path) => path.Replace('\\', '/').Replace(":", "\\:"); 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) private static void TryDelete(string path)
{ {
try try
@@ -355,11 +351,4 @@ public sealed class FfmpegBumperRenderer(
// Файл-подсказка для drawtext; не критично, если не удалился. // Файл-подсказка для 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 Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media; using TeleWave.Application.Media;
using static TeleWave.Infrastructure.Media.FfmpegText;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
@@ -98,14 +99,7 @@ public sealed class FfmpegMediaProcessor(
if (padding) if (padding)
vfilter += $",tpad=stop_duration={Fmt(pad)}:stop_mode=add:color=black"; vfilter += $",tpad=stop_duration={Fmt(pad)}:stop_mode=add:color=black";
var args = new List<string> var args = new List<string> { "-hide_banner", "-nostdin", "-y", "-i", input };
{
"-hide_banner",
"-nostdin",
"-y",
"-i",
input,
};
// Явный выбор дорожки по предпочитаемому языку: маппим видео + конкретную аудиодорожку. // Явный выбор дорожки по предпочитаемому языку: маппим видео + конкретную аудиодорожку.
// Без выбора (audioTrack == null) — не маппим, оставляя дефолтную эвристику ffmpeg (как раньше). // Без выбора (audioTrack == null) — не маппим, оставляя дефолтную эвристику ffmpeg (как раньше).
@@ -262,13 +256,17 @@ public sealed class FfmpegMediaProcessor(
await using var scope = scopeFactory.CreateAsyncScope(); await using var scope = scopeFactory.CreateAsyncScope();
var settings = scope.ServiceProvider.GetRequiredService<ISiteSettings>(); var settings = scope.ServiceProvider.GetRequiredService<ISiteSettings>();
var configured = await settings.GetPreferredAudioLanguagesAsync(cancellationToken); var configured = await settings.GetPreferredAudioLanguagesAsync(cancellationToken);
var raw = string.IsNullOrWhiteSpace(configured) ? _media.PreferredAudioLanguages : configured; var raw = string.IsNullOrWhiteSpace(configured)
return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ? _media.PreferredAudioLanguages
: configured;
return raw.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
)
.Select(x => x.ToLowerInvariant()) .Select(x => x.ToLowerInvariant())
.ToList(); .ToList();
} }
private static (int Width, int Height) ScaleDown(int width, int height) private static (int Width, int Height) ScaleDown(int width, int height)
{ {
if (width <= MaxWidth) if (width <= MaxWidth)
@@ -279,16 +277,6 @@ public sealed class FfmpegMediaProcessor(
return (MaxWidth, scaledHeight); 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( private sealed record ProbeInfo(
double Duration, double Duration,
int Width, 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"); OriginalsDir = Path.Combine(_root, "originals");
AssetsDir = Path.Combine(_root, "assets"); AssetsDir = Path.Combine(_root, "assets");
BumpersDir = Path.Combine(_root, "bumpers"); BumpersDir = Path.Combine(_root, "bumpers");
MetadataDir = Path.Combine(_root, "metadata");
ImagesDir = Path.Combine(_root, "images"); ImagesDir = Path.Combine(_root, "images");
} }
@@ -30,9 +29,6 @@ public sealed class MediaPathResolver
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary> /// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
public string BumpersDir { get; } public string BumpersDir { get; }
/// <summary>Картинки метаданных (постеры/кадры), скачанные локально.</summary>
public string MetadataDir { get; }
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary> /// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
public string ImagesDir { get; } public string ImagesDir { get; }
@@ -43,47 +39,9 @@ public sealed class MediaPathResolver
Directory.CreateDirectory(OriginalsDir); Directory.CreateDirectory(OriginalsDir);
Directory.CreateDirectory(AssetsDir); Directory.CreateDirectory(AssetsDir);
Directory.CreateDirectory(BumpersDir); Directory.CreateDirectory(BumpersDir);
Directory.CreateDirectory(MetadataDir);
Directory.CreateDirectory(ImagesDir); 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) => public string BumperTemplateDir(Guid templateId) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"))); EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
@@ -1,21 +1,6 @@
using System.Threading.Channels;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
/// <summary>Сигнальная очередь-будильник поверх Channel (id ассета используется лишь как сигнал).</summary> /// <summary>Сигнальная очередь-будильник медиа-конвейера (см. <see cref="SignalQueue"/>).</summary>
public sealed class MediaProcessingQueue : IMediaProcessingQueue public sealed class MediaProcessingQueue : SignalQueue, 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 _)) { }
}
}
@@ -63,13 +63,9 @@ internal static class ProcessRunner
// (битый источник, -stream_loop и т.п.) не должен держать слот параллелизма/тик планировщика вечно. // (битый источник, -stream_loop и т.п.) не должен держать слот параллелизма/тик планировщика вечно.
using var timeoutCts = using var timeoutCts =
timeout > TimeSpan.Zero ? new CancellationTokenSource(timeout) : null; timeout > TimeSpan.Zero ? new CancellationTokenSource(timeout) : null;
using var linked = using var linked = timeoutCts is null
timeoutCts is null ? null
? null : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
: CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
timeoutCts.Token
);
var waitToken = linked?.Token ?? cancellationToken; var waitToken = linked?.Token ?? cancellationToken;
try try
@@ -79,7 +75,10 @@ internal static class ProcessRunner
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
TryKill(process); TryKill(process);
if (timeoutCts is { IsCancellationRequested: true } && !cancellationToken.IsCancellationRequested) if (
timeoutCts is { IsCancellationRequested: true }
&& !cancellationToken.IsCancellationRequested
)
throw new TimeoutException( throw new TimeoutException(
$"Процесс {fileName} превысил таймаут {timeout.TotalSeconds:0}с и был прерван." $"Процесс {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 Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata; using TeleWave.Application.Metadata;
using static TeleWave.Infrastructure.Metadata.MetadataJson;
namespace TeleWave.Infrastructure.Metadata; namespace TeleWave.Infrastructure.Metadata;
@@ -23,7 +24,7 @@ public sealed class OmdbMetadataProvider(
{ {
var url = var url =
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&type=series&s={Uri.EscapeDataString(query)}"; $"{_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)) if (!doc.RootElement.TryGetProperty("Search", out var search))
return []; return [];
@@ -52,7 +53,7 @@ public sealed class OmdbMetadataProvider(
) )
{ {
var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}"; 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)) if (doc is null || !IsResponseTrue(doc.RootElement))
return null; return null;
var root = doc.RootElement; var root = doc.RootElement;
@@ -75,7 +76,7 @@ public sealed class OmdbMetadataProvider(
var url = var url =
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}" $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}"
+ $"&Season={season}&Episode={episode}"; + $"&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)) if (doc is null || !IsResponseTrue(doc.RootElement))
return null; return null;
var root = doc.RootElement; var root = doc.RootElement;
@@ -95,7 +96,7 @@ public sealed class OmdbMetadataProvider(
{ {
var url = var url =
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}&Season={season}"; $"{_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 ( if (
doc is null doc is null
|| !IsResponseTrue(doc.RootElement) || !IsResponseTrue(doc.RootElement)
@@ -106,53 +107,14 @@ public sealed class OmdbMetadataProvider(
return episodes.GetArrayLength(); 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) => private static bool IsResponseTrue(JsonElement root) =>
GetString(root, "Response") is { } r GetString(root, "Response") is { } r
&& r.Equals("True", StringComparison.OrdinalIgnoreCase); && 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> /// <summary>OMDb отдаёт «N/A» вместо отсутствующих значений — приводим к null.</summary>
private static string? Clean(string? value) => private static string? Clean(string? value) =>
string.IsNullOrWhiteSpace(value) || value == "N/A" ? null : 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) => private static DateOnly? DateFrom(string? released) =>
DateTime.TryParse(released, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) DateTime.TryParse(released, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)
? DateOnly.FromDateTime(dt) ? DateOnly.FromDateTime(dt)
@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata; using TeleWave.Application.Metadata;
using static TeleWave.Infrastructure.Metadata.MetadataJson;
namespace TeleWave.Infrastructure.Metadata; namespace TeleWave.Infrastructure.Metadata;
@@ -26,7 +27,7 @@ public sealed class TmdbMetadataProvider(
var url = var url =
$"{Tmdb.BaseUrl}/search/tv?api_key={Tmdb.ApiKey}&language={_options.Language}" $"{Tmdb.BaseUrl}/search/tv?api_key={Tmdb.ApiKey}&language={_options.Language}"
+ $"&include_adult=false&query={Uri.EscapeDataString(query)}"; + $"&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)) if (!doc.RootElement.TryGetProperty("results", out var results))
return []; return [];
@@ -56,7 +57,7 @@ public sealed class TmdbMetadataProvider(
{ {
var url = var url =
$"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}"; $"{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) if (doc is null)
return null; return null;
var root = doc.RootElement; var root = doc.RootElement;
@@ -79,7 +80,7 @@ public sealed class TmdbMetadataProvider(
var url = var url =
$"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}" $"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}"
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}"; + $"?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) if (doc is null)
return null; return null;
var root = doc.RootElement; var root = doc.RootElement;
@@ -100,7 +101,7 @@ public sealed class TmdbMetadataProvider(
var url = var url =
$"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}" $"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}"
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}"; + $"?api_key={Tmdb.ApiKey}&language={_options.Language}";
using var doc = await TryGetJsonAsync(url, cancellationToken); using var doc = await TryGetAsync(httpFactory, url, cancellationToken);
if ( if (
doc is null doc is null
|| !doc.RootElement.TryGetProperty("episodes", out var episodes) || !doc.RootElement.TryGetProperty("episodes", out var episodes)
@@ -116,46 +117,6 @@ public sealed class TmdbMetadataProvider(
private string? StillUrl(string? path) => private string? StillUrl(string? path) =>
string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.StillSize}{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) => private static DateOnly? DateFrom(string? date) =>
DateOnly.TryParse(date, CultureInfo.InvariantCulture, out var d) ? d : null; DateOnly.TryParse(date, CultureInfo.InvariantCulture, out var d) ? d : null;
} }
@@ -16,37 +16,34 @@ namespace TeleWave.Infrastructure.Migrations
table: "BumperAssets", table: "BumperAssets",
type: "uuid", type: "uuid",
nullable: false, nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "TemplateId", name: "TemplateId",
table: "BumperAssets", table: "BumperAssets",
type: "uuid", type: "uuid",
nullable: false, nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "VariantId", name: "VariantId",
table: "BumperAssets", table: "BumperAssets",
type: "uuid", type: "uuid",
nullable: false, nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets");
name: "ChannelId",
table: "BumperAssets");
migrationBuilder.DropColumn( migrationBuilder.DropColumn(name: "TemplateId", table: "BumperAssets");
name: "TemplateId",
table: "BumperAssets");
migrationBuilder.DropColumn( migrationBuilder.DropColumn(name: "VariantId", table: "BumperAssets");
name: "VariantId",
table: "BumperAssets");
} }
} }
} }
@@ -15,25 +15,23 @@ namespace TeleWave.Infrastructure.Migrations
name: "ProcessingDuration", name: "ProcessingDuration",
table: "MediaAssets", table: "MediaAssets",
type: "interval", type: "interval",
nullable: true); nullable: true
);
migrationBuilder.AddColumn<DateTimeOffset>( migrationBuilder.AddColumn<DateTimeOffset>(
name: "ProcessingStartedAt", name: "ProcessingStartedAt",
table: "MediaAssets", table: "MediaAssets",
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: true); nullable: true
);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(name: "ProcessingDuration", table: "MediaAssets");
name: "ProcessingDuration",
table: "MediaAssets");
migrationBuilder.DropColumn( migrationBuilder.DropColumn(name: "ProcessingStartedAt", table: "MediaAssets");
name: "ProcessingStartedAt",
table: "MediaAssets");
} }
} }
} }
@@ -15,15 +15,14 @@ namespace TeleWave.Infrastructure.Migrations
table: "Shows", table: "Shows",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0); defaultValue: 0
);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(name: "Audience", table: "Shows");
name: "Audience",
table: "Shows");
} }
} }
} }
@@ -40,10 +40,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию, // Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
// корректности не нарушают. // корректности не нарушают.
var key = BitConverter.ToInt64(channelId.ToByteArray()); var key = BitConverter.ToInt64(channelId.ToByteArray());
return Database.ExecuteSqlAsync( return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
$"SELECT pg_advisory_xact_lock({key})",
cancellationToken
);
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Infrastructure.Persistence; namespace TeleWave.Infrastructure.Persistence;
@@ -16,99 +15,4 @@ public static class MigrationExtensions
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync(cancellationToken); 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) 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) => public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken); dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
public Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken) => public Task SetPreferredAudioLanguagesAsync(
UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken); string value,
CancellationToken cancellationToken
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
private async Task UpsertAsync(string key, string value, CancellationToken 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"))); .Returns(Result.Success(new AuthenticatedUser(userId, "alice", "user")));
_identityService _identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>()) .GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns( .Returns(new CurrentUserProfile(userId, "alice", "user", IsBlocked: false));
new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsBlocked: false)
);
_jwtTokenService _jwtTokenService
.GenerateAccessToken(Arg.Any<AuthenticatedUser>()) .GenerateAccessToken(Arg.Any<AuthenticatedUser>())
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15))); .Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
@@ -67,9 +65,7 @@ public class LoginCommandHandlerTests
.Returns(Result.Success(new AuthenticatedUser(userId, "alice", "user"))); .Returns(Result.Success(new AuthenticatedUser(userId, "alice", "user")));
_identityService _identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>()) .GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns( .Returns(new CurrentUserProfile(userId, "alice", "user", IsBlocked: true));
new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsBlocked: true)
);
var result = await CreateHandler() var result = await CreateHandler()
.Handle(new LoginCommand("alice", "password123"), CancellationToken.None); .Handle(new LoginCommand("alice", "password123"), CancellationToken.None);
@@ -32,9 +32,7 @@ public class RegisterCommandHandlerTests
.Returns(Result.Success(userId)); .Returns(Result.Success(userId));
_identityService _identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>()) .GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns( .Returns(new CurrentUserProfile(userId, "bob", "user", IsBlocked: false));
new CurrentUserProfile(userId, "bob", Guid.NewGuid(), "user", IsBlocked: false)
);
_jwtTokenService _jwtTokenService
.GenerateAccessToken(Arg.Any<AuthenticatedUser>()) .GenerateAccessToken(Arg.Any<AuthenticatedUser>())
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15))); .Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
@@ -9,7 +9,6 @@ using TeleWave.Application.Broadcast.RemoveChannelAd;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow; using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Media.GetMedia;
using TeleWave.Application.Tests.Support; using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
@@ -152,31 +151,6 @@ public class QueryHandlersTests
Assert.False(missing.IsSuccess); 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] [Fact]
public async Task DeleteShow_RemovesOrNotFound() public async Task DeleteShow_RemovesOrNotFound()
{ {
@@ -30,12 +30,8 @@ public class FindMissingEpisodesTests
} }
var provider = Substitute.For<IMetadataProvider>(); var provider = Substitute.For<IMetadataProvider>();
provider provider.GetSeasonEpisodeCountAsync("123", 1, Arg.Any<CancellationToken>()).Returns(5);
.GetSeasonEpisodeCountAsync("123", 1, Arg.Any<CancellationToken>()) provider.GetSeasonEpisodeCountAsync("123", 2, Arg.Any<CancellationToken>()).Returns(3);
.Returns(5);
provider
.GetSeasonEpisodeCountAsync("123", 2, Arg.Any<CancellationToken>())
.Returns(3);
var resolver = Substitute.For<IMetadataProviderResolver>(); var resolver = Substitute.For<IMetadataProviderResolver>();
resolver.Resolve("tmdb").Returns(provider); resolver.Resolve("tmdb").Returns(provider);
@@ -47,7 +47,8 @@ public sealed class PostgresFixture : IAsyncLifetime
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>; public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary> /// <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 readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
private int _i; private int _i;
@@ -74,7 +74,11 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
var storage = Substitute.For<IMediaStorage>(); var storage = Substitute.For<IMediaStorage>();
storage storage
.DeleteAssetArtifactsAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>()) .DeleteAssetArtifactsAsync(
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>()
)
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
await using (var db = fixture.CreateContext()) await using (var db = fixture.CreateContext())
@@ -89,9 +93,7 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
await using var verify = fixture.CreateContext(); await using var verify = fixture.CreateContext();
Assert.False(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id)); Assert.False(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id));
Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.MediaAssetId == asset.Id)); Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.MediaAssetId == asset.Id));
var reloaded = await verify var reloaded = await verify.Shows.Include(s => s.Episodes).FirstAsync(s => s.Id == show.Id);
.Shows.Include(s => s.Episodes)
.FirstAsync(s => s.Id == show.Id);
Assert.Empty(reloaded.Episodes); Assert.Empty(reloaded.Episodes);
await storage await storage
.Received() .Received()
+1 -3
View File
@@ -9,8 +9,7 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"typecheck": "tsc -b", "typecheck": "tsc -b",
"preview": "vite preview", "preview": "vite preview"
"gen:api": "openapi-typescript http://localhost:8080/openapi/v1.json -o src/shared/api/schema.gen.ts"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
@@ -40,7 +39,6 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"openapi-typescript": "^7.13.0",
"oxlint": "^1.71.0", "oxlint": "^1.71.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "~6.0.2", "typescript": "~6.0.2",
+14 -206
View File
@@ -71,7 +71,7 @@ importers:
version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
'@tanstack/router-plugin': '@tanstack/router-plugin':
specifier: ^1.168.18 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': '@types/node':
specifier: ^24.13.2 specifier: ^24.13.2
version: 24.13.3 version: 24.13.3
@@ -84,9 +84,6 @@ importers:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) 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: oxlint:
specifier: ^1.71.0 specifier: ^1.71.0
version: 1.75.0 version: 1.75.0
@@ -631,16 +628,6 @@ packages:
'@radix-ui/rect@1.1.3': '@radix-ui/rect@1.1.3':
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} 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': '@rolldown/binding-android-arm64@1.1.5':
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -928,21 +915,10 @@ packages:
babel-plugin-react-compiler: babel-plugin-react-compiler:
optional: true 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: ansis@4.3.1:
resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==}
engines: {node: '>=14'} engines: {node: '>=14'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6: aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -950,17 +926,11 @@ packages:
babel-dead-code-elimination@1.0.12: babel-dead-code-elimination@1.0.12:
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
baseline-browser-mapping@2.11.1: baseline-browser-mapping@2.11.1:
resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
brace-expansion@2.1.2:
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
browserslist@4.28.7: browserslist@4.28.7:
resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -969,9 +939,6 @@ packages:
caniuse-lite@1.0.30001806: caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
chokidar@5.0.0: chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
@@ -983,9 +950,6 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -1026,9 +990,6 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -1060,10 +1021,6 @@ packages:
html-parse-stringify@4.0.1: html-parse-stringify@4.0.1:
resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} 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: i18next@26.3.6:
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==}
peerDependencies: peerDependencies:
@@ -1072,10 +1029,6 @@ packages:
typescript: typescript:
optional: true optional: true
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
isbot@5.2.1: isbot@5.2.1:
resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1084,25 +1037,14 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.2.0:
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
hasBin: true
jsesc@3.1.0: jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json5@2.2.3: json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1267,10 +1209,6 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1283,12 +1221,6 @@ packages:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true
peerDependencies:
typescript: ^5.x
oxlint@1.75.0: oxlint@1.75.0:
resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1302,10 +1234,6 @@ packages:
vite-plus: vite-plus:
optional: true optional: true
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -1316,10 +1244,6 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'} engines: {node: '>=12'}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
postcss@8.5.22: postcss@8.5.22:
resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -1394,10 +1318,6 @@ packages:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'} 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: rolldown@1.1.5:
resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1424,10 +1344,6 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tailwind-merge@3.6.0: tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
@@ -1445,10 +1361,6 @@ packages:
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
typescript@6.0.3: typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -1496,9 +1408,6 @@ packages:
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
use-callback-ref@1.3.3: use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1573,13 +1482,6 @@ packages:
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} 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: zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
@@ -1611,7 +1513,7 @@ snapshots:
'@babel/compat-data@7.29.7': {} '@babel/compat-data@7.29.7': {}
'@babel/core@7.29.7(supports-color@10.2.2)': '@babel/core@7.29.7':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7 '@babel/generator': 7.29.7
@@ -1620,11 +1522,11 @@ snapshots:
'@babel/helpers': 7.29.7 '@babel/helpers': 7.29.7
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
'@babel/template': 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 '@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0 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 gensync: 1.0.0-beta.2
json5: 2.2.3 json5: 2.2.3
semver: 6.3.1 semver: 6.3.1
@@ -1651,17 +1553,17 @@ snapshots:
'@babel/helper-module-imports@7.29.7': '@babel/helper-module-imports@7.29.7':
dependencies: dependencies:
'@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/traverse': 7.29.7
'@babel/types': 7.29.7 '@babel/types': 7.29.7
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies: 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-module-imports': 7.29.7
'@babel/helper-validator-identifier': 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: transitivePeerDependencies:
- supports-color - supports-color
@@ -1688,7 +1590,7 @@ snapshots:
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
'@babel/types': 7.29.7 '@babel/types': 7.29.7
'@babel/traverse@7.29.7(supports-color@10.2.2)': '@babel/traverse@7.29.7':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7 '@babel/generator': 7.29.7
@@ -1696,7 +1598,7 @@ snapshots:
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
'@babel/template': 7.29.7 '@babel/template': 7.29.7
'@babel/types': 7.29.7 '@babel/types': 7.29.7
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -2083,29 +1985,6 @@ snapshots:
'@radix-ui/rect@1.1.3': {} '@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': '@rolldown/binding-android-arm64@1.1.5':
optional: true optional: true
@@ -2272,9 +2151,9 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@babel/core': 7.29.7(supports-color@10.2.2) '@babel/core': 7.29.7
'@babel/template': 7.29.7 '@babel/template': 7.29.7
'@babel/types': 7.29.7 '@babel/types': 7.29.7
'@tanstack/router-core': 1.171.15 '@tanstack/router-core': 1.171.15
@@ -2335,35 +2214,23 @@ snapshots:
'@rolldown/pluginutils': 1.0.1 '@rolldown/pluginutils': 1.0.1
vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) 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: {} ansis@4.3.1: {}
argparse@2.0.1: {}
aria-hidden@1.2.6: aria-hidden@1.2.6:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
babel-dead-code-elimination@1.0.12: babel-dead-code-elimination@1.0.12:
dependencies: dependencies:
'@babel/core': 7.29.7(supports-color@10.2.2) '@babel/core': 7.29.7
'@babel/parser': 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 '@babel/types': 7.29.7
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
balanced-match@1.0.2: {}
baseline-browser-mapping@2.11.1: {} baseline-browser-mapping@2.11.1: {}
brace-expansion@2.1.2:
dependencies:
balanced-match: 1.0.2
browserslist@4.28.7: browserslist@4.28.7:
dependencies: dependencies:
baseline-browser-mapping: 2.11.1 baseline-browser-mapping: 2.11.1
@@ -2374,8 +2241,6 @@ snapshots:
caniuse-lite@1.0.30001806: {} caniuse-lite@1.0.30001806: {}
change-case@5.4.4: {}
chokidar@5.0.0: chokidar@5.0.0:
dependencies: dependencies:
readdirp: 5.0.0 readdirp: 5.0.0
@@ -2386,19 +2251,15 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
colorette@1.4.0: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cookie-es@3.1.1: {} cookie-es@3.1.1: {}
csstype@3.2.3: {} csstype@3.2.3: {}
debug@4.4.3(supports-color@10.2.2): debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
@@ -2415,8 +2276,6 @@ snapshots:
escalade@3.2.0: {} escalade@3.2.0: {}
fast-deep-equal@3.1.3: {}
fdir@6.5.0(picomatch@4.0.5): fdir@6.5.0(picomatch@4.0.5):
optionalDependencies: optionalDependencies:
picomatch: 4.0.5 picomatch: 4.0.5
@@ -2434,35 +2293,18 @@ snapshots:
html-parse-stringify@4.0.1: {} 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): i18next@26.3.6(typescript@6.0.3):
optionalDependencies: optionalDependencies:
typescript: 6.0.3 typescript: 6.0.3
index-to-position@1.2.0: {}
isbot@5.2.1: {} isbot@5.2.1: {}
jiti@2.7.0: {} jiti@2.7.0: {}
js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.2.0:
dependencies:
argparse: 2.0.1
jsesc@3.1.0: {} jsesc@3.1.0: {}
json-schema-traverse@1.0.0: {}
json5@2.2.3: {} json5@2.2.3: {}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
@@ -2575,26 +2417,12 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.2
ms@2.1.3: {} ms@2.1.3: {}
nanoid@3.3.16: {} nanoid@3.3.16: {}
node-releases@2.0.51: {} 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: oxlint@1.75.0:
optionalDependencies: optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.75.0 '@oxlint/binding-android-arm-eabi': 1.75.0
@@ -2617,20 +2445,12 @@ snapshots:
'@oxlint/binding-win32-ia32-msvc': 1.75.0 '@oxlint/binding-win32-ia32-msvc': 1.75.0
'@oxlint/binding-win32-x64-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: {} pathe@2.0.3: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.5: {} picomatch@4.0.5: {}
pluralize@8.0.0: {}
postcss@8.5.22: postcss@8.5.22:
dependencies: dependencies:
nanoid: 3.3.16 nanoid: 3.3.16
@@ -2690,8 +2510,6 @@ snapshots:
readdirp@5.0.0: {} readdirp@5.0.0: {}
require-from-string@2.0.2: {}
rolldown@1.1.5: rolldown@1.1.5:
dependencies: dependencies:
'@oxc-project/types': 0.139.0 '@oxc-project/types': 0.139.0
@@ -2725,8 +2543,6 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
supports-color@10.2.2: {}
tailwind-merge@3.6.0: {} tailwind-merge@3.6.0: {}
tailwindcss@4.3.3: {} tailwindcss@4.3.3: {}
@@ -2740,8 +2556,6 @@ snapshots:
tslib@2.8.1: {} tslib@2.8.1: {}
type-fest@4.41.0: {}
typescript@6.0.3: {} typescript@6.0.3: {}
undici-types@7.18.2: {} undici-types@7.18.2: {}
@@ -2761,8 +2575,6 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 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): use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.8):
dependencies: dependencies:
react: 19.2.8 react: 19.2.8
@@ -2798,10 +2610,6 @@ snapshots:
yallist@3.1.1: {} yallist@3.1.1: {}
yaml-ast-parser@0.0.43: {}
yargs-parser@21.1.1: {}
zod@4.4.3: {} 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)): 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
View File
@@ -1,5 +1,5 @@
import { apiRequest, setAccessToken } from '@/shared/api/client' 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' import { useAuthStore } from './store'
/** Публично: включена ли открытая регистрация (для страниц входа/регистрации). */ /** Публично: включена ли открытая регистрация (для страниц входа/регистрации). */
@@ -19,10 +19,6 @@ export function logout() {
return apiRequest<void>('/auth/logout', { method: 'POST' }) return apiRequest<void>('/auth/logout', { method: 'POST' })
} }
export function fetchCurrentUser() {
return apiRequest<CurrentUser>('/auth/me')
}
export function changePassword(currentPassword: string, newPassword: string) { export function changePassword(currentPassword: string, newPassword: string) {
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } }) return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
} }
-5
View File
@@ -53,11 +53,8 @@ export type MediaAssetDto = {
source: MediaSource source: MediaSource
status: MediaAssetStatus status: MediaAssetStatus
durationSeconds: number | null durationSeconds: number | null
segmentCount: number | null
width: number | null width: number | null
height: number | null height: number | null
videoCodec: string | null
audioCodec: string | null
errorMessage: string | null errorMessage: string | null
processingSeconds: number | null processingSeconds: number | null
createdAt: string createdAt: string
@@ -119,7 +116,6 @@ export type EpisodeDto = {
title: string | null title: string | null
overview: string | null overview: string | null
stillImageId: string | null stillImageId: string | null
airDate: string | null
} }
export type ImageCategory = 'Library' | 'ShowPoster' | 'EpisodeStill' | 'BumperBackground' export type ImageCategory = 'Library' | 'ShowPoster' | 'EpisodeStill' | 'BumperBackground'
@@ -214,7 +210,6 @@ export type ChannelShowDto = {
blockMode: BlockMode blockMode: BlockMode
blockValue: number blockValue: number
isEnabled: boolean isEnabled: boolean
nextEpisodeIndex: number
/** Во сколько раз усиливать вес в предпочтительные часы (1 — без буста). */ /** Во сколько раз усиливать вес в предпочтительные часы (1 — без буста). */
preferredWeightMultiplier: number preferredWeightMultiplier: number
preferredHours: HourWindow[] preferredHours: HourWindow[]
-46
View File
@@ -6,7 +6,6 @@ const resources = {
translation: { translation: {
appName: 'TeleWave', appName: 'TeleWave',
nav: { nav: {
home: 'Главная',
dashboard: 'Эфир', dashboard: 'Эфир',
admin: 'Админка', admin: 'Админка',
settings: 'Настройки', settings: 'Настройки',
@@ -24,7 +23,6 @@ const resources = {
create: 'Создать', create: 'Создать',
loading: 'Загрузка…', loading: 'Загрузка…',
error: 'Что-то пошло не так', error: 'Что-то пошло не так',
confirm: 'Подтвердить',
search: 'Поиск', search: 'Поиск',
actions: 'Действия', actions: 'Действия',
prevPage: 'Предыдущая страница', prevPage: 'Предыдущая страница',
@@ -57,11 +55,6 @@ const resources = {
blocked: 'Аккаунт заблокирован администратором', blocked: 'Аккаунт заблокирован администратором',
genericError: 'Не удалось выполнить вход. Попробуйте ещё раз', genericError: 'Не удалось выполнить вход. Попробуйте ещё раз',
}, },
dashboard: {
welcome: 'На связи, {{userName}}',
placeholder: 'Список каналов появится здесь позже — пока в эфире только тестовая заставка.',
role: 'Роль',
},
air: { air: {
now: 'Сейчас', now: 'Сейчас',
next: 'Далее', next: 'Далее',
@@ -94,8 +87,6 @@ const resources = {
system: 'Системная', system: 'Системная',
create: 'Новая роль', create: 'Новая роль',
rename: 'Переименовать', rename: 'Переименовать',
cannotModifySystem: 'Системную роль нельзя изменить или удалить',
roleInUse: 'Роль назначена пользователям',
}, },
users: { users: {
title: 'Пользователи', title: 'Пользователи',
@@ -123,14 +114,9 @@ const resources = {
upload: 'Загрузить', upload: 'Загрузить',
uploadToShow: 'Загрузить в шоу', uploadToShow: 'Загрузить в шоу',
toShowTitle: 'Загрузить и добавить в шоу', toShowTitle: 'Загрузить и добавить в шоу',
toShowSubtitle: 'Файлов выбрано: {{count}}. После загрузки они добавятся сериями в выбранное шоу.',
autoDetectLabel: 'Определять шоу по названию файла',
autoDetectHint: autoDetectHint:
'Каждый файл привяжется к шоу, чьё оригинальное (или отображаемое) название есть в имени релиза, напр. «The.Simpsons.S33E01…» → The Simpsons.', 'Каждый файл привяжется к шоу, чьё оригинальное (или отображаемое) название есть в имени релиза, напр. «The.Simpsons.S33E01…» → The Simpsons.',
toShowShow: 'Шоу',
toShowFallback: 'Для нераспознанных',
toShowLibrary: 'В библиотеку', toShowLibrary: 'В библиотеку',
toShowPick: 'Выберите шоу',
toShowSeason: 'Сезон (вручную)', toShowSeason: 'Сезон (вручную)',
toShowAuto: 'авто', toShowAuto: 'авто',
toShowRegex: 'Regex серии', toShowRegex: 'Regex серии',
@@ -138,12 +124,10 @@ const resources = {
toShowHint: toShowHint:
'Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\\d+) для «01. Название.mkv».', 'Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\\d+) для «01. Название.mkv».',
toShowPreview: 'Что распознаем', toShowPreview: 'Что распознаем',
toShowRecognized: 'распознано {{recognized}} из {{total}}',
toShowMatched: 'шоу распознано у {{matched}} из {{total}}', toShowMatched: 'шоу распознано у {{matched}} из {{total}}',
applyToAll: 'Задать всем…', applyToAll: 'Задать всем…',
toShowUnknown: '—', toShowUnknown: '—',
toShowConfirm: 'Загрузить и добавить', toShowConfirm: 'Загрузить и добавить',
uploaded: 'Файл загружен, идёт обработка',
uploadedCount: 'Загружено файлов: {{count}}', uploadedCount: 'Загружено файлов: {{count}}',
uploadingCount: 'Загрузка {{done}}/{{total}}', uploadingCount: 'Загрузка {{done}}/{{total}}',
cancelAll: 'Отменить все загрузки', cancelAll: 'Отменить все загрузки',
@@ -202,8 +186,6 @@ const resources = {
loadedSeasons: 'Загружены сезоны', loadedSeasons: 'Загружены сезоны',
episodes: 'Серии', episodes: 'Серии',
episode: 'Серия', episode: 'Серия',
addEpisode: 'Добавить серию',
pickAsset: 'Выберите файл',
noEpisodes: 'Серий пока нет', noEpisodes: 'Серий пока нет',
filterAssets: 'Фильтр по имени, напр. Family.Guy.S16', filterAssets: 'Фильтр по имени, напр. Family.Guy.S16',
selectAll: 'Выбрать все', selectAll: 'Выбрать все',
@@ -251,7 +233,6 @@ const resources = {
bumperBg2: 'Фон (цвет 2)', bumperBg2: 'Фон (цвет 2)',
bumperAccent: 'Акцент', bumperAccent: 'Акцент',
bumperText: 'Текст', bumperText: 'Текст',
bumperOnlyDifferent: 'Только на смене шоу (не внутри марафона)',
bumperTemplates: 'Блоки заставок', bumperTemplates: 'Блоки заставок',
bumperTemplatesHint: bumperTemplatesHint:
'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.', 'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.',
@@ -359,15 +340,12 @@ const resources = {
}, },
metadata: { metadata: {
title: 'Метаданные', title: 'Метаданные',
pickFromGallery: 'Выбрать из галереи',
pickPoster: 'Из галереи', pickPoster: 'Из галереи',
name: 'Название', name: 'Название',
originalName: 'Оригинальное название (eng)', originalName: 'Оригинальное название (eng)',
originalNamePlaceholder: 'Например: Family Guy', originalNamePlaceholder: 'Например: Family Guy',
originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.', originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.',
source: 'Источник',
sourceLabel: 'Источник', sourceLabel: 'Источник',
searchPlaceholder: 'Название для поиска',
searchBtn: 'Искать', searchBtn: 'Искать',
nothingFound: 'Ничего не найдено', nothingFound: 'Ничего не найдено',
apply: 'Применить', apply: 'Применить',
@@ -375,7 +353,6 @@ const resources = {
overview: 'Описание', overview: 'Описание',
year: 'Год', year: 'Год',
clear: 'Очистить', clear: 'Очистить',
uploadPoster: 'Загрузить постер',
noPoster: 'Нет постера', noPoster: 'Нет постера',
refreshEpisodes: 'Обновить серии', refreshEpisodes: 'Обновить серии',
refreshing: 'Обновляем…', refreshing: 'Обновляем…',
@@ -396,7 +373,6 @@ const resources = {
translation: { translation: {
appName: 'TeleWave', appName: 'TeleWave',
nav: { nav: {
home: 'Home',
dashboard: 'On Air', dashboard: 'On Air',
admin: 'Admin', admin: 'Admin',
settings: 'Settings', settings: 'Settings',
@@ -414,7 +390,6 @@ const resources = {
create: 'Create', create: 'Create',
loading: 'Loading…', loading: 'Loading…',
error: 'Something went wrong', error: 'Something went wrong',
confirm: 'Confirm',
search: 'Search', search: 'Search',
actions: 'Actions', actions: 'Actions',
prevPage: 'Previous page', prevPage: 'Previous page',
@@ -447,11 +422,6 @@ const resources = {
blocked: 'Account blocked by an administrator', blocked: 'Account blocked by an administrator',
genericError: 'Could not sign in. Please try again', 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: { air: {
now: 'Now', now: 'Now',
next: 'Up next', next: 'Up next',
@@ -484,8 +454,6 @@ const resources = {
system: 'System', system: 'System',
create: 'New role', create: 'New role',
rename: 'Rename', rename: 'Rename',
cannotModifySystem: 'A system role cannot be modified or deleted',
roleInUse: 'Role is assigned to users',
}, },
users: { users: {
title: 'Users', title: 'Users',
@@ -513,14 +481,9 @@ const resources = {
upload: 'Upload', upload: 'Upload',
uploadToShow: 'Upload to show', uploadToShow: 'Upload to show',
toShowTitle: 'Upload and add 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: 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.', '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', toShowLibrary: 'To library',
toShowPick: 'Pick a show',
toShowSeason: 'Season (manual)', toShowSeason: 'Season (manual)',
toShowAuto: 'auto', toShowAuto: 'auto',
toShowRegex: 'Episode regex', toShowRegex: 'Episode regex',
@@ -528,12 +491,10 @@ const resources = {
toShowHint: 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”.', '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', toShowPreview: 'What we detect',
toShowRecognized: '{{recognized}} of {{total}} recognized',
toShowMatched: 'show detected for {{matched}} of {{total}}', toShowMatched: 'show detected for {{matched}} of {{total}}',
applyToAll: 'Set for all…', applyToAll: 'Set for all…',
toShowUnknown: '—', toShowUnknown: '—',
toShowConfirm: 'Upload and add', toShowConfirm: 'Upload and add',
uploaded: 'File uploaded, processing started',
uploadedCount: 'Uploaded files: {{count}}', uploadedCount: 'Uploaded files: {{count}}',
uploadingCount: 'Uploading {{done}}/{{total}}', uploadingCount: 'Uploading {{done}}/{{total}}',
cancelAll: 'Cancel all uploads', cancelAll: 'Cancel all uploads',
@@ -592,8 +553,6 @@ const resources = {
loadedSeasons: 'Loaded seasons', loadedSeasons: 'Loaded seasons',
episodes: 'Episodes', episodes: 'Episodes',
episode: 'Episode', episode: 'Episode',
addEpisode: 'Add episode',
pickAsset: 'Pick a file',
noEpisodes: 'No episodes yet', noEpisodes: 'No episodes yet',
filterAssets: 'Filter by name, e.g. Family.Guy.S16', filterAssets: 'Filter by name, e.g. Family.Guy.S16',
selectAll: 'Select all', selectAll: 'Select all',
@@ -641,7 +600,6 @@ const resources = {
bumperBg2: 'Background (color 2)', bumperBg2: 'Background (color 2)',
bumperAccent: 'Accent', bumperAccent: 'Accent',
bumperText: 'Text', bumperText: 'Text',
bumperOnlyDifferent: 'Only on show change (not within a marathon)',
bumperTemplates: 'Bumper blocks', bumperTemplates: 'Bumper blocks',
bumperTemplatesHint: 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.', '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: { metadata: {
title: 'Metadata', title: 'Metadata',
pickFromGallery: 'Pick from gallery',
pickPoster: 'From gallery', pickPoster: 'From gallery',
name: 'Name', name: 'Name',
originalName: 'Original name (eng)', originalName: 'Original name (eng)',
originalNamePlaceholder: 'e.g. Family Guy', originalNamePlaceholder: 'e.g. Family Guy',
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.', originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',
source: 'Source',
sourceLabel: 'Source', sourceLabel: 'Source',
searchPlaceholder: 'Title to search',
searchBtn: 'Search', searchBtn: 'Search',
nothingFound: 'Nothing found', nothingFound: 'Nothing found',
apply: 'Apply', apply: 'Apply',
@@ -765,7 +720,6 @@ const resources = {
overview: 'Overview', overview: 'Overview',
year: 'Year', year: 'Year',
clear: 'Clear', clear: 'Clear',
uploadPoster: 'Upload poster',
noPoster: 'No poster', noPoster: 'No poster',
refreshEpisodes: 'Refresh episodes', refreshEpisodes: 'Refresh episodes',
refreshing: 'Refreshing…', refreshing: 'Refreshing…',
-5
View File
@@ -27,8 +27,3 @@ export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElem
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)) ))
CardContent.displayName = 'CardContent' 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'
-1
View File
@@ -5,7 +5,6 @@ import { cn } from '@/shared/lib/cn'
export const Dialog = DialogPrimitive.Root export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger export const DialogTrigger = DialogPrimitive.Trigger
export const DialogClose = DialogPrimitive.Close
export const DialogOverlay = forwardRef< export const DialogOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>, ElementRef<typeof DialogPrimitive.Overlay>,