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