diff --git a/REVIEW.md b/REVIEW.md
index 8ac4e4e..0255e01 100644
--- a/REVIEW.md
+++ b/REVIEW.md
@@ -29,44 +29,44 @@
## 🟠 Среднее
### Безопасность
-- [ ] **M1. Cookie без `Secure` за TLS-прокси** — `AuthEndpoints.cs:207`, `StreamingEndpoints.cs:63`.
-- [ ] **M2. Ключи TMDb/OMDb в логах** — `TmdbMetadataProvider.cs:27`, `OmdbMetadataProvider.cs:25` (`System.Net.Http` не приглушён в Serilog).
-- [ ] **M3. Stream-токен неотзывной, TTL 6ч, игнорирует userId/блокировку** — `StreamTokenService.cs:15`, `StreamingEndpoints.cs:98,123`.
-- [ ] **M4. Rate-limiter глобальный (не партиционирован), только на `/api/auth`** — `Program.cs:66`.
+- [x] **M1. Cookie без `Secure` за TLS-прокси** — `AuthEndpoints.cs:207`, `StreamingEndpoints.cs:63`.
+- [x] **M2. Ключи TMDb/OMDb в логах** — `TmdbMetadataProvider.cs:27`, `OmdbMetadataProvider.cs:25` (`System.Net.Http` не приглушён в Serilog).
+- [ ] **M3. Stream-токен неотзывной, TTL 6ч, игнорирует userId/блокировку** — `StreamTokenService.cs:15`, `StreamingEndpoints.cs:98,123`. _(дизайн-решение — отложено)_
+- [x] **M4. Rate-limiter глобальный (не партиционирован), только на `/api/auth`** — `Program.cs:66`.
### Архитектура / транзакции
-- [ ] **M5. `ExecuteDeleteAsync` ломает границу UnitOfWork + файлы удаляются до коммита** — `DeleteShowMediaCommandHandler.cs:36`, `ClearAllMediaCommandHandler.cs`, `DeleteAllShowsCommandHandler.cs`.
-- [ ] **M6. Query с побочным эффектом на ФС** — `RenderBumperPreviewQueryHandler.cs:18` (рендер файлов под видом запроса).
-- [ ] **M7. Файловый/HTTP I/O до коммита** — Delete/Register/RefreshEpisodes хендлеры (orphan-файлы при откате).
+- [ ] **M5. `ExecuteDeleteAsync` ломает границу UnitOfWork + файлы удаляются до коммита** — `DeleteShowMediaCommandHandler.cs:36`, `ClearAllMediaCommandHandler.cs`, `DeleteAllShowsCommandHandler.cs`. _(дизайн-решение — отложено)_
+- [ ] **M6. Query с побочным эффектом на ФС** — `RenderBumperPreviewQueryHandler.cs:18` (рендер файлов под видом запроса). _(дизайн-решение — отложено)_
+- [ ] **M7. Файловый/HTTP I/O до коммита** — Delete/Register/RefreshEpisodes хендлеры (orphan-файлы при откате). _(дизайн-решение — отложено)_
### Планировщик / медиа
-- [ ] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs:63` (зависший процесс держит слот).
-- [ ] **M9. Гонка при конкурентной генерации расписания канала** — `ScheduleGenerator.cs:62` (тик + regenerate → дубли записей).
-- [ ] **M10. Рендер заставок синхронно внутри тика планировщика** — `ScheduleBumperResolver.cs:174`.
-- [ ] **M11. Override через полночь не работает** — `SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно).
+- [ ] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs:63` (зависший процесс держит слот). _(нужен разумный таймаут — отложено на согласование)_
+- [ ] **M9. Гонка при конкурентной генерации расписания канала** — `ScheduleGenerator.cs:62` (тик + regenerate → дубли записей). _(дизайн-решение — отложено)_
+- [ ] **M10. Рендер заставок синхронно внутри тика планировщика** — `ScheduleBumperResolver.cs:174`. _(дизайн-решение — отложено)_
+- [x] **M11. Override через полночь не работает** — `SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно).
### Фронтенд
-- [ ] **M12. Молчаливое проглатывание ошибки → вечный скелетон** — `AirPage.tsx:43`.
-- [ ] **M13. Повторный 401 после refresh не разлогинивает** — `client.ts:80`.
-- [ ] **M14. Клиентская пагинация поверх усечённого ответа** — `ShowDetail.tsx:42` (`pageSize:500`), `ChannelDetail.tsx:32` (`pageSize:100`).
-- [ ] **M15. Дедуп загрузок только по имени файла** — `upload-store.ts:143`.
+- [x] **M12. Молчаливое проглатывание ошибки → вечный скелетон** — `AirPage.tsx:43`.
+- [x] **M13. Повторный 401 после refresh не разлогинивает** — `client.ts:80`.
+- [ ] **M14. Клиентская пагинация поверх усечённого ответа** — `ShowDetail.tsx:42` (`pageSize:500`), `ChannelDetail.tsx:32` (`pageSize:100`). _(нужна серверная пагинация — отложено)_
+- [ ] **M15. Дедуп загрузок только по имени файла** — `upload-store.ts:143`. _(зависит от контракта серверной уникальности — отложено)_
---
## 🟡 Низкое / прочее
-- [ ] **L1. Синхронный I/O без CancellationToken в портах удаления** (`IMediaStorage`/`IImageStore`/`IBumperTemplateStorage`).
-- [ ] **L2. `ValidationBehavior` вызывает `Validate` вместо `ValidateAsync`** — `ValidationBehavior.cs:24`.
-- [ ] **L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате** — `Show.AddEpisode`.
-- [ ] **L4. Двойной `SaveChanges` в командном пути ScheduleGenerator** — `ScheduleGenerator.cs:79,132`.
-- [ ] **L5. HLS-плеер не восстанавливается после fatal network/media error** — `ChannelPlayer.tsx:106`.
-- [ ] **L6. Бесконечная повторная регистрация «падающего» файла из inbox** — `InboxScannerBackgroundService.cs:92`.
-- [ ] **L7. Возможное переполнение int в весах шоу** — `SchedulePlanner.cs:256,265`.
-- [ ] **L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению** — `Program.cs:108`.
-- [ ] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json**.
-- [ ] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16`.
-- [ ] **L11. Документация (CLAUDE.md/README) отстала**: заявлено «каталог каналов и раздача видео не реализованы», хотя реализованы.
-- [ ] **L12. Пробелы в тестах**: фоновые сервисы, ScheduleGenerator-оркестрация, media-конвейер, эндпоинты без тестов.
+- [ ] **L1. Синхронный I/O без CancellationToken в портах удаления** (`IMediaStorage`/`IImageStore`/`IBumperTemplateStorage`). _(меняет интерфейсы портов — отложено)_
+- [x] **L2. `ValidationBehavior` вызывает `Validate` вместо `ValidateAsync`** — `ValidationBehavior.cs:24`.
+- [ ] **L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате** — `Show.AddEpisode`. _(рефактор агрегата — отложено)_
+- [ ] **L4. Двойной `SaveChanges` в командном пути ScheduleGenerator** — `ScheduleGenerator.cs:79,132`. _(связано с M9 — отложено)_
+- [x] **L5. HLS-плеер не восстанавливается после fatal network/media error** — `ChannelPlayer.tsx:106` (+ симметричная очистка слушателей нативного HLS).
+- [x] **L6. Бесконечная повторная регистрация «падающего» файла из inbox** — `InboxScannerBackgroundService.cs:92`.
+- [x] **L7. Возможное переполнение int в весах шоу** — `SchedulePlanner.cs:256,265`.
+- [ ] **L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению** — `Program.cs:108`. _(возможно намеренно для внутреннего инструмента — решение за владельцем)_
+- [ ] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json**. _(dev-дефолты; решение за владельцем)_
+- [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https).
+- [ ] **L11. Документация (CLAUDE.md/README) отстала**: заявлено «каталог каналов и раздача видео не реализованы», хотя реализованы. _(нужна сверка с владельцем по объёму)_
+- [ ] **L12. Пробелы в тестах**: фоновые сервисы, ScheduleGenerator-оркестрация, media-конвейер, эндпоинты без тестов. _(частично: добавлены тесты на M11)_
---
@@ -86,3 +86,43 @@
цепочку. Неиспользуемый `EscapeText` удалён. Файлы чистятся в `finally`.
Проверка: `dotnet build` — 0 warnings/0 errors (при `TreatWarningsAsErrors`); тесты 95 + 49 зелёные.
+
+### 2026-07-25 — механические средние/низкие (M1, M2, M4, M11, M12, M13, L2, L5, L6, L7, L10)
+
+Backend:
+- **M1** — `AuthEndpoints.cs`/`StreamingEndpoints.cs`: cookie-флаг `Secure` вычисляется через
+ `UseSecureCookie` (`!env.IsDevelopment() || request.IsHttps`) вместо голого `request.IsHttps`.
+ Вне Development cookie всегда `Secure` — прод за внешним TLS-прокси больше не отдаёт refresh/stream
+ cookie в открытую при неполной настройке ForwardedHeaders. В Development HTTP-разработка сохранена.
+- **M2** — `appsettings.json`: добавлен override `System.Net.Http.HttpClient: Warning` — URI запросов
+ к TMDb/OMDb (с `api_key` в query) больше не пишутся в лог на уровне Information.
+- **M4** — `Program.cs`: rate-limiter `auth` переведён на партиционирование по IP клиента
+ (`RateLimitPartition.GetFixedWindowLimiter`) — один клиент больше не исчерпывает окно логина для всех.
+- **M11** — `SchedulePlannerModels.cs` + `ProgrammingOverride.cs`: `Covers` для еженедельного override
+ поддерживает окно через полночь (`EndMinute <= StartMinute` → `[start,24:00)` в day + `[0,end)` на
+ следующий день); `end == start` — пустое окно; добавлены null-guards. Регресс-тесты в
+ `ProgrammingOverrideTests` (+2, всего 97 domain).
+- **L2** — `ValidationBehavior.cs`: `ValidateAsync(context, cancellationToken)` вместо синхронного
+ `Validate` — async-правила FluentValidation и отмена работают.
+- **L6** — `InboxScannerBackgroundService.cs`: файлы, стабильно отклоняемые командой регистрации,
+ помечаются (`_failed`) и не регистрируются повторно каждые два тика; метка снимается при исчезновении файла.
+- **L7** — `SchedulePlanner.cs`: веса шоу/вариантов суммируются в `long` с clamp до `int.MaxValue` —
+ экстремальные вес/множитель не переполняют int (иначе взвешенный выбор молча вырождался в первого).
+- **L10** — `ImageDownloader.cs`: allowlist схемы (только абсолютные http/https) — подменённый ответ
+ провайдера не заставит сервер дёрнуть `file://`/`ftp://` и т.п.
+
+Frontend:
+- **M12** — `AirPage.tsx`: провал `watchChannel` показывает offline-панель с кнопкой ретрая (а не
+ бесконечный скелетон); ретрай (`attempt`) заново дёргает `watchChannel`.
+- **M13** — `client.ts`: повторный 401 уже после успешного refresh вызывает `onUnauthorized()` —
+ мёртвая сессия чистит авторизацию, а не остаётся «залогиненной». Плюс guard на тело refresh-ответа
+ (`accessToken` должен быть строкой).
+- **L5** — `ChannelPlayer.tsx`: fatal network/media HLS-ошибка сперва пробует восстановиться
+ (`startLoad`/`recoverMediaError`, до 3 попыток) и лишь затем уходит в offline; слушатели нативной
+ HLS-ветки снимаются в cleanup.
+
+Отложено (требует решения владельца/дизайна): M3, M5–M10, M14, M15, L1, L3, L4, L8, L9, L11, L12 —
+причины помечены у пунктов.
+
+Проверка: `dotnet build` 0/0; тесты 97 + 49 зелёные; frontend `tsc` чистый; `oxlint` без новых
+предупреждений.
diff --git a/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs
index 42ae55e..a686b47 100644
--- a/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs
@@ -1,4 +1,5 @@
using LiteCqrs;
+using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Auth;
using TeleWave.Application.Auth.ChangePassword;
@@ -62,6 +63,7 @@ public static class AuthEndpoints
ISender sender,
HttpRequest request,
HttpResponse response,
+ IHostEnvironment env,
CancellationToken cancellationToken
)
{
@@ -70,10 +72,10 @@ public static class AuthEndpoints
return result.ToHttpResult();
SetRefreshCookie(
- request,
response,
result.Value.RefreshToken,
- result.Value.RefreshTokenExpiresAt
+ result.Value.RefreshTokenExpiresAt,
+ UseSecureCookie(request, env)
);
return Results.Ok(ToLoginResponse(result.Value));
}
@@ -83,6 +85,7 @@ public static class AuthEndpoints
ISender sender,
HttpRequest request,
HttpResponse response,
+ IHostEnvironment env,
CancellationToken cancellationToken
)
{
@@ -91,10 +94,10 @@ public static class AuthEndpoints
return result.ToHttpResult();
SetRefreshCookie(
- request,
response,
result.Value.RefreshToken,
- result.Value.RefreshTokenExpiresAt
+ result.Value.RefreshTokenExpiresAt,
+ UseSecureCookie(request, env)
);
return Results.Ok(ToLoginResponse(result.Value));
}
@@ -103,6 +106,7 @@ public static class AuthEndpoints
HttpRequest request,
HttpResponse response,
ISender sender,
+ IHostEnvironment env,
CancellationToken cancellationToken
)
{
@@ -112,18 +116,19 @@ public static class AuthEndpoints
)
return Results.Unauthorized();
+ var secure = UseSecureCookie(request, env);
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess)
{
- response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
+ response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
return result.ToHttpResult();
}
SetRefreshCookie(
- request,
response,
result.Value.RefreshToken,
- result.Value.RefreshTokenExpiresAt
+ result.Value.RefreshTokenExpiresAt,
+ secure
);
return Results.Ok(ToLoginResponse(result.Value));
}
@@ -135,6 +140,7 @@ public static class AuthEndpoints
HttpRequest request,
HttpResponse response,
ISender sender,
+ IHostEnvironment env,
CancellationToken cancellationToken
)
{
@@ -144,8 +150,9 @@ public static class AuthEndpoints
)
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
- response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
- DeleteStreamCookie(request, response);
+ var secure = UseSecureCookie(request, env);
+ response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
+ DeleteStreamCookie(response, secure);
return Results.NoContent();
}
@@ -179,45 +186,54 @@ public static class AuthEndpoints
HttpRequest request,
HttpResponse response,
ISender sender,
+ IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
- response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
- DeleteStreamCookie(request, response);
+ var secure = UseSecureCookie(request, env);
+ response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
+ DeleteStreamCookie(response, secure);
return result.ToHttpResult();
}
private static void SetRefreshCookie(
- HttpRequest request,
HttpResponse response,
string rawToken,
- DateTimeOffset expiresAt
+ DateTimeOffset expiresAt,
+ bool secure
)
{
- var options = BuildCookieOptions(request);
+ var options = BuildCookieOptions(secure);
options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options);
}
- private static CookieOptions BuildCookieOptions(HttpRequest request) =>
+ private static CookieOptions BuildCookieOptions(bool secure) =>
new()
{
HttpOnly = true,
- Secure = request.IsHttps,
+ Secure = secure,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
+ /// Ставить ли cookie с флагом Secure. Вне Development — всегда true: прод работает за
+ /// внешним TLS-прокси, а ненадёжен (при неполной настройке
+ /// ForwardedHeaders он false, и долгоживущий refresh-cookie ушёл бы без Secure). В Development
+ /// допускаем HTTP-разработку.
+ private static bool UseSecureCookie(HttpRequest request, IHostEnvironment env) =>
+ !env.IsDevelopment() || request.IsHttps;
+
/// Гасит stream-cookie (tw_stream, Path=/api) — иначе после выхода эфир можно смотреть
/// по прямой ссылке ещё до истечения токена.
- private static void DeleteStreamCookie(HttpRequest request, HttpResponse response) =>
+ private static void DeleteStreamCookie(HttpResponse response, bool secure) =>
response.Cookies.Delete(
"tw_stream",
new CookieOptions
{
HttpOnly = true,
- Secure = request.IsHttps,
+ Secure = secure,
SameSite = SameSiteMode.Strict,
Path = "/api",
}
diff --git a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs
index 08001c5..c0be05c 100644
--- a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs;
+using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
@@ -47,7 +48,8 @@ public static class StreamingEndpoints
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
- HttpResponse response
+ HttpResponse response,
+ IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
@@ -60,7 +62,9 @@ public static class StreamingEndpoints
new CookieOptions
{
HttpOnly = true,
- Secure = request.IsHttps,
+ // Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
+ // при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
+ Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs
index c1e1295..0c94fa5 100644
--- a/backend/src/TeleWave.Api/Program.cs
+++ b/backend/src/TeleWave.Api/Program.cs
@@ -1,5 +1,6 @@
using System.Net;
using System.Text.Json.Serialization;
+using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore;
@@ -63,19 +64,23 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
+var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options =>
{
- options.AddFixedWindowLimiter(
+ // Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
+ // непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
+ options.AddPolicy(
RateLimiting.AuthPolicy,
- limiterOptions =>
- {
- limiterOptions.PermitLimit = builder.Configuration.GetValue(
- "RateLimiting:AuthPermitLimit",
- 20
- );
- limiterOptions.Window = TimeSpan.FromMinutes(1);
- limiterOptions.QueueLimit = 0;
- }
+ httpContext =>
+ RateLimitPartition.GetFixedWindowLimiter(
+ httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
+ _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = authPermitLimit,
+ Window = TimeSpan.FromMinutes(1),
+ QueueLimit = 0,
+ }
+ )
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
diff --git a/backend/src/TeleWave.Api/appsettings.json b/backend/src/TeleWave.Api/appsettings.json
index b7de0ce..38b1feb 100644
--- a/backend/src/TeleWave.Api/appsettings.json
+++ b/backend/src/TeleWave.Api/appsettings.json
@@ -61,7 +61,8 @@
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
- "Microsoft.EntityFrameworkCore": "Warning"
+ "Microsoft.EntityFrameworkCore": "Warning",
+ "System.Net.Http.HttpClient": "Warning"
}
},
"WriteTo": [ { "Name": "Console" } ],
diff --git a/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs
index add13d1..264444f 100644
--- a/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs
+++ b/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs
@@ -20,10 +20,10 @@ public sealed class ValidationBehavior(
return await next();
var context = new ValidationContext(request);
- var failures = validators
- .Select(v => v.Validate(context))
- .SelectMany(r => r.Errors)
- .ToList();
+ var results = await Task.WhenAll(
+ validators.Select(v => v.ValidateAsync(context, cancellationToken))
+ );
+ var failures = results.SelectMany(r => r.Errors).ToList();
if (failures.Count == 0)
return await next();
diff --git a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs
index f23ab33..ece36c1 100644
--- a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs
+++ b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs
@@ -77,11 +77,30 @@ public class ProgrammingOverride
{
if (Recurrence == OverrideRecurrence.Weekly)
{
+ if (
+ DayOfWeek is not { } day
+ || StartMinute is not { } start
+ || EndMinute is not { } end
+ )
+ return false;
+
var utc = moment.UtcDateTime;
var minuteOfDay = utc.Hour * 60 + utc.Minute;
- return (int)utc.DayOfWeek == DayOfWeek
- && minuteOfDay >= StartMinute
- && minuteOfDay < EndMinute;
+ var today = (int)utc.DayOfWeek;
+
+ if (end > start)
+ // Обычное окно в пределах одних суток [start, end).
+ return today == day && minuteOfDay >= start && minuteOfDay < end;
+
+ if (end < start)
+ {
+ // Окно пересекает полночь: [start, 24:00) в день `day` и [00:00, end) на следующий день.
+ var nextDay = (day + 1) % 7;
+ return (today == day && minuteOfDay >= start)
+ || (today == nextDay && minuteOfDay < end);
+ }
+
+ return false; // end == start — пустое окно
}
return moment >= StartsAtUtc && moment < EndsAtUtc;
diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs
index 4c2e00a..839686f 100644
--- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs
+++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs
@@ -195,12 +195,12 @@ public static class SchedulePlanner
IRandomSource random
)
{
- var total = eligible.Sum(v => Math.Max(0, v.Weight));
+ var total = eligible.Sum(v => (long)Math.Max(0, v.Weight));
if (total <= 0)
return eligible[random.Next(eligible.Count)];
- var roll = random.Next(total);
- var acc = 0;
+ var roll = random.Next((int)Math.Min(total, int.MaxValue));
+ long acc = 0;
foreach (var v in eligible)
{
acc += Math.Max(0, v.Weight);
@@ -253,7 +253,9 @@ public static class SchedulePlanner
&& show.PreferredHours is { Count: > 0 } windows
&& windows.Any(w => w.Contains(hour))
)
- return show.Weight * show.PreferredWeightMultiplier;
+ // long-умножение + clamp: экстремальные вес/множитель не переполняют int
+ // (иначе отрицательный total молча вырождает взвешенный выбор в первого кандидата).
+ return (int)Math.Min((long)show.Weight * show.PreferredWeightMultiplier, int.MaxValue);
return show.Weight;
}
@@ -262,12 +264,12 @@ public static class SchedulePlanner
IRandomSource random
)
{
- var total = candidates.Sum(c => c.Weight);
+ var total = candidates.Sum(c => (long)c.Weight);
if (total <= 0)
return candidates[0].Show;
- var roll = random.Next(total);
- var acc = 0;
+ var roll = random.Next((int)Math.Min(total, int.MaxValue));
+ long acc = 0;
foreach (var (show, weight) in candidates)
{
acc += weight;
diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs
index fbcf739..2b0a40d 100644
--- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs
+++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs
@@ -39,11 +39,30 @@ public sealed record PlannerOverride(
{
if (Recurrence == OverrideRecurrence.Weekly)
{
+ if (
+ DayOfWeek is not { } day
+ || StartMinute is not { } start
+ || EndMinute is not { } end
+ )
+ return false;
+
var utc = moment.UtcDateTime;
var minuteOfDay = utc.Hour * 60 + utc.Minute;
- return (int)utc.DayOfWeek == DayOfWeek
- && minuteOfDay >= StartMinute
- && minuteOfDay < EndMinute;
+ var today = (int)utc.DayOfWeek;
+
+ if (end > start)
+ // Обычное окно в пределах одних суток [start, end).
+ return today == day && minuteOfDay >= start && minuteOfDay < end;
+
+ if (end < start)
+ {
+ // Окно пересекает полночь: [start, 24:00) в день `day` и [00:00, end) на следующий день.
+ var nextDay = (day + 1) % 7;
+ return (today == day && minuteOfDay >= start)
+ || (today == nextDay && minuteOfDay < end);
+ }
+
+ return false; // end == start — пустое окно
}
return moment >= StartsAtUtc && moment < EndsAtUtc;
diff --git a/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs b/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs
index 584b180..1c2b90c 100644
--- a/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs
+++ b/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs
@@ -10,10 +10,19 @@ public sealed class ImageDownloader(IHttpClientFactory httpFactory) : IImageDown
CancellationToken cancellationToken
)
{
+ // Пускаем только абсолютные http/https URL: URL приходит из ответа metadata-провайдера
+ // (постеры TMDb/OMDb), и скомпрометированный/подменённый ответ не должен заставить сервер
+ // дёрнуть file://, ftp:// или иную схему.
+ if (
+ !Uri.TryCreate(url, UriKind.Absolute, out var uri)
+ || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
+ )
+ return null;
+
try
{
var client = httpFactory.CreateClient("metadata");
- using var response = await client.GetAsync(url, cancellationToken);
+ using var response = await client.GetAsync(uri, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
diff --git a/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs
index 7663ad7..511700c 100644
--- a/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs
+++ b/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs
@@ -26,6 +26,11 @@ public sealed class InboxScannerBackgroundService(
private readonly MediaOptions _media = mediaOptions.Value;
private readonly Dictionary _lastSizes = new(StringComparer.Ordinal);
+ // Файлы, регистрация которых стабильно отклоняется командой (например, неподдерживаемый контейнер):
+ // помечаем, чтобы не пытаться регистрировать их заново каждые два тика и не засорять лог. Забываются,
+ // когда файл исчезает из inbox (тогда одноимённый новый файл снова получит попытку).
+ private readonly HashSet _failed = new(StringComparer.Ordinal);
+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
paths.EnsureDirectories();
@@ -67,6 +72,9 @@ public sealed class InboxScannerBackgroundService(
var name = Path.GetFileName(path);
present.Add(name);
+ if (_failed.Contains(name))
+ continue; // уже пытались и получили отказ — ждём, пока файл уберут/заменят
+
long size;
try
{
@@ -89,16 +97,20 @@ public sealed class InboxScannerBackgroundService(
continue;
}
- await RegisterAsync(name, cancellationToken);
+ var registered = await RegisterAsync(name, cancellationToken);
_lastSizes.Remove(name);
+ if (!registered)
+ _failed.Add(name); // команда отклонила файл — больше не пытаемся, пока он в inbox
}
- // Забываем исчезнувшие файлы, чтобы словарь не рос.
+ // Забываем исчезнувшие файлы, чтобы словари не росли (и одноимённый новый файл снова попробовался).
foreach (var stale in _lastSizes.Keys.Where(k => !present.Contains(k)).ToList())
_lastSizes.Remove(stale);
+ foreach (var stale in _failed.Where(k => !present.Contains(k)).ToList())
+ _failed.Remove(stale);
}
- private async Task RegisterAsync(string fileName, CancellationToken cancellationToken)
+ private async Task RegisterAsync(string fileName, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService();
@@ -116,14 +128,14 @@ public sealed class InboxScannerBackgroundService(
result.Value,
fileName
);
+ return true;
}
- else
- {
- logger.LogWarning(
- "Не удалось зарегистрировать {File} из inbox: {Error}",
- fileName,
- result.Error.Code
- );
- }
+
+ logger.LogWarning(
+ "Не удалось зарегистрировать {File} из inbox: {Error}",
+ fileName,
+ result.Error.Code
+ );
+ return false;
}
}
diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs
index bfa6bfc..4ba329d 100644
--- a/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs
+++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs
@@ -56,4 +56,30 @@ public class ProgrammingOverrideTests
Assert.True(ovr.Covers(Epoch.AddDays(7).AddHours(5)));
Assert.False(ovr.Covers(Epoch.AddDays(1).AddHours(5))); // другой день недели
}
+
+ [Fact]
+ public void Weekly_Covers_WindowCrossingMidnight()
+ {
+ // Epoch = 2026-01-01 — четверг (DayOfWeek=4). Окно 23:00–01:00 пересекает полночь на пятницу.
+ var day = (int)Epoch.UtcDateTime.DayOfWeek;
+ var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 1380, 60); // 23:00–01:00
+
+ Assert.True(ovr.Covers(Epoch.AddMinutes(1380))); // чт 23:00 — начало
+ Assert.True(ovr.Covers(Epoch.AddMinutes(1439))); // чт 23:59
+ Assert.True(ovr.Covers(Epoch.AddDays(1).AddMinutes(0))); // пт 00:00 (следующий день)
+ Assert.True(ovr.Covers(Epoch.AddDays(1).AddMinutes(59))); // пт 00:59
+ Assert.False(ovr.Covers(Epoch.AddDays(1).AddMinutes(60))); // пт 01:00 — конец исключается
+ Assert.False(ovr.Covers(Epoch.AddMinutes(1379))); // чт 22:59 — до начала
+ Assert.False(ovr.Covers(Epoch.AddHours(12))); // чт полдень — вне окна
+ }
+
+ [Fact]
+ public void Weekly_EmptyWindow_WhenStartEqualsEnd_CoversNothing()
+ {
+ var day = (int)Epoch.UtcDateTime.DayOfWeek;
+ var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 600, 600);
+
+ Assert.False(ovr.Covers(Epoch.AddMinutes(600)));
+ Assert.False(ovr.Covers(Epoch.AddMinutes(700)));
+ }
}
diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx
index abd23f8..11d544e 100644
--- a/frontend/src/features/streaming/AirPage.tsx
+++ b/frontend/src/features/streaming/AirPage.tsx
@@ -44,11 +44,18 @@ export function AirPage() {
.then(() => {
if (!cancelled) setWatchReady(true)
})
- .catch(() => undefined)
+ .catch(() => {
+ // Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
+ // а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
+ if (!cancelled) {
+ setWatchReady(true)
+ setPlayerError(true)
+ }
+ })
return () => {
cancelled = true
}
- }, [selected])
+ }, [selected, attempt])
const { data: epg } = useQuery({
queryKey: ['air', 'epg', selected],
diff --git a/frontend/src/features/streaming/ChannelPlayer.tsx b/frontend/src/features/streaming/ChannelPlayer.tsx
index e24a60e..b12547e 100644
--- a/frontend/src/features/streaming/ChannelPlayer.tsx
+++ b/frontend/src/features/streaming/ChannelPlayer.tsx
@@ -97,24 +97,40 @@ export function ChannelPlayer({
})
}
+ // Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
+ const onNativeError = () => onUnavailable?.()
+
if (Hls.isSupported()) {
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
hlsRef.current = hls
hls.loadSource(src)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
+
+ // Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
+ // (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
+ let recoverAttempts = 0
hls.on(Hls.Events.ERROR, (_event, data) => {
- if (data.fatal) {
- hls?.destroy()
- hls = null
- hlsRef.current = null
- onUnavailable?.()
+ if (!data.fatal) return
+ if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
+ recoverAttempts += 1
+ hls?.startLoad()
+ return
}
+ if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
+ recoverAttempts += 1
+ hls?.recoverMediaError()
+ return
+ }
+ hls?.destroy()
+ hls = null
+ hlsRef.current = null
+ onUnavailable?.()
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src
video.addEventListener('loadedmetadata', startPlayback)
- video.addEventListener('error', () => onUnavailable?.())
+ video.addEventListener('error', onNativeError)
} else {
onUnavailable?.()
}
@@ -122,6 +138,8 @@ export function ChannelPlayer({
return () => {
hls?.destroy()
hlsRef.current = null
+ video.removeEventListener('loadedmetadata', startPlayback)
+ video.removeEventListener('error', onNativeError)
}
}, [slug, onUnavailable])
diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts
index 76f2eec..65095f7 100644
--- a/frontend/src/shared/api/client.ts
+++ b/frontend/src/shared/api/client.ts
@@ -30,7 +30,8 @@ export async function refreshAccessToken(): Promise {
try {
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
if (!response.ok) return false
- const data = (await response.json()) as { accessToken: string }
+ const data = (await response.json()) as { accessToken?: unknown }
+ if (typeof data?.accessToken !== 'string') return false
setAccessToken(data.accessToken)
return true
} catch {
@@ -77,9 +78,13 @@ export async function apiRequest(path: string, options: RequestOptions = {}):
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
})
- if (response.status === 401 && !options.skipRefresh) {
- const refreshed = await refreshAccessToken()
- if (refreshed) return apiRequest(path, { ...options, skipRefresh: true })
+ if (response.status === 401) {
+ if (!options.skipRefresh) {
+ const refreshed = await refreshAccessToken()
+ if (refreshed) return apiRequest(path, { ...options, skipRefresh: true })
+ }
+ // 401 и освежить токен нельзя/не помогло (включая повторный 401 уже после успешного refresh —
+ // токен приняли, но прав нет / он тут же отозван): сессия мертва, чистим авторизацию.
onUnauthorized?.()
throw await parseError(response)
}