feat: expand platform adapters and preview tooling

This commit is contained in:
2026-05-13 19:40:43 +08:00
parent b81ead700d
commit 7a286fd619
44 changed files with 4370 additions and 115 deletions
@@ -0,0 +1,24 @@
using LiveRecorder.Application.Common;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Tests;
public sealed class LivePlatformCatalogTests
{
[Fact]
public void All_contains_expected_new_platforms()
{
var platforms = LivePlatformCatalog.All.ToDictionary(static item => item.Type);
Assert.Contains(LivePlatformType.Douyu, platforms.Keys);
Assert.Contains(LivePlatformType.Kuaishou, platforms.Keys);
Assert.Contains(LivePlatformType.TikTok, platforms.Keys);
Assert.Contains(LivePlatformType.Xiaohongshu, platforms.Keys);
Assert.Contains(LivePlatformType.YouTube, platforms.Keys);
Assert.Contains(LivePlatformType.Twitch, platforms.Keys);
Assert.Contains(LivePlatformType.PandaTV, platforms.Keys);
Assert.Contains(LivePlatformType.Migu, platforms.Keys);
Assert.Equal("youtube", LivePlatformCatalog.GetKey(LivePlatformType.YouTube));
Assert.Equal("Twitch", LivePlatformCatalog.GetDisplayName(LivePlatformType.Twitch));
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\LiveRecorder.Application\LiveRecorder.Application.csproj" />
<ProjectReference Include="..\..\src\LiveRecorder.Domain\LiveRecorder.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,71 @@
using System.Reflection;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Tests;
public sealed class LiveRoomServiceLocalParsingTests
{
[Theory]
[InlineData("https://www.huya.com/660000", LivePlatformType.Huya, "660000", "https://www.huya.com/660000")]
[InlineData("https://www.douyu.com/9999", LivePlatformType.Douyu, "9999", "https://www.douyu.com/9999")]
[InlineData("https://live.kuaishou.com/u/3xabc123", LivePlatformType.Kuaishou, "u/3xabc123", "https://live.kuaishou.com/u/3xabc123")]
[InlineData("https://www.tiktok.com/@creator/live", LivePlatformType.TikTok, "creator", "https://www.tiktok.com/@creator/live")]
[InlineData("https://www.xiaohongshu.com/live/room-100", LivePlatformType.Xiaohongshu, "live/room-100", "https://www.xiaohongshu.com/live/room-100")]
[InlineData("https://www.youtube.com/watch?v=abcDEF12345", LivePlatformType.YouTube, "abcDEF12345", "https://www.youtube.com/watch?v=abcDEF12345")]
[InlineData("https://www.twitch.tv/some_channel", LivePlatformType.Twitch, "some_channel", "https://www.twitch.tv/some_channel")]
[InlineData("https://www.pandalive.co.kr/live/room77", LivePlatformType.PandaTV, "live/room77", "https://www.pandalive.co.kr/live/room77")]
[InlineData("https://www.miguvideo.com/live/8888", LivePlatformType.Migu, "live/8888", "https://www.miguvideo.com/live/8888")]
public void ParseRoomLocally_parses_supported_urls(
string input,
LivePlatformType expectedPlatform,
string expectedRoomId,
string expectedNormalizedUrl)
{
var parsed = ParseRoomLocally(input, platformOverride: null);
Assert.Equal(expectedPlatform, parsed.PlatformType);
Assert.Equal(expectedRoomId, parsed.RoomId);
Assert.Equal(expectedNormalizedUrl, parsed.NormalizedUrl);
}
[Theory]
[InlineData("some_channel", LivePlatformType.Twitch, "some_channel", "https://www.twitch.tv/some_channel")]
[InlineData("abcDEF12345", LivePlatformType.YouTube, "abcDEF12345", "https://www.youtube.com/watch?v=abcDEF12345")]
public void ParseRoomLocally_supports_direct_ids_with_platform_override(
string input,
LivePlatformType platformOverride,
string expectedRoomId,
string expectedNormalizedUrl)
{
var parsed = ParseRoomLocally(input, platformOverride);
Assert.Equal(platformOverride, parsed.PlatformType);
Assert.Equal(expectedRoomId, parsed.RoomId);
Assert.Equal(expectedNormalizedUrl, parsed.NormalizedUrl);
}
[Fact]
public void ParseRoomLocally_requires_detection_when_platform_cannot_be_inferred()
{
var exception = Assert.Throws<NotSupportedException>(() => ParseRoomLocally("creator-room", platformOverride: null));
Assert.Contains("Unable to infer the platform", exception.Message);
}
private static ParsedLiveRoom ParseRoomLocally(string input, LivePlatformType? platformOverride)
{
var method = typeof(LiveRoomService).GetMethod("ParseRoomLocally", BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("ParseRoomLocally reflection lookup failed.");
try
{
return (ParsedLiveRoom)(method.Invoke(null, [input, platformOverride]) ??
throw new InvalidOperationException("ParseRoomLocally returned null."));
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
throw ex.InnerException;
}
}
}
@@ -0,0 +1,152 @@
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Models.Reports;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Tests;
public sealed class LiveRoomStatusServiceTests
{
[Fact]
public async Task ApplySnapshotAsync_passes_event_script_output_to_notifications()
{
var email = new FakeEmailNotificationService();
var webhook = new FakeWebhookNotificationService();
var eventScript = new FakeEventScriptService
{
LiveStartedResult = new EventScriptExecutionResultDto
{
Success = true,
Message = "ok",
CustomLogOutput = "line one from script\nline two from script"
}
};
var service = new LiveRoomStatusService(email, webhook, eventScript);
var now = DateTimeOffset.UtcNow;
var liveRoom = new LiveRoom(
LivePlatformType.Douyin,
"https://live.douyin.com/123456",
"123456",
"https://live.douyin.com/123456",
now);
var snapshot = new LiveStatusSnapshot(
true,
"Sample title",
"Sample anchor",
"anchor-1",
AvatarUrl: null,
CoverUrl: null,
1,
"live");
await service.ApplySnapshotAsync(liveRoom, snapshot, now);
Assert.Equal(eventScript.LiveStartedResult.CustomLogOutput, email.LastLiveStartedEventScriptOutput);
Assert.Equal(eventScript.LiveStartedResult.CustomLogOutput, webhook.LastLiveStartedEventScriptOutput);
Assert.True(liveRoom.HasSentLiveNotificationForCurrentSession);
}
private sealed class FakeEmailNotificationService : IEmailNotificationService
{
public string? LastLiveStartedEventScriptOutput { get; private set; }
public Task SendLiveStartedAsync(
LiveRoom liveRoom,
CancellationToken cancellationToken = default,
string? eventScriptOutput = null)
{
LastLiveStartedEventScriptOutput = eventScriptOutput;
return Task.CompletedTask;
}
public Task SendExceptionAsync(
string source,
string summary,
string? detail = null,
LiveRoom? liveRoom = null,
RecordTask? recordTask = null,
CancellationToken cancellationToken = default,
string? eventScriptOutput = null) => Task.CompletedTask;
public Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task SendDailyReviewAsync(DailyReviewReportDto report, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class FakeWebhookNotificationService : IWebhookNotificationService
{
public string? LastLiveStartedEventScriptOutput { get; private set; }
public Task SendLiveStartedAsync(
LiveRoom liveRoom,
CancellationToken cancellationToken = default,
string? eventScriptOutput = null)
{
LastLiveStartedEventScriptOutput = eventScriptOutput;
return Task.CompletedTask;
}
public Task SendExceptionAsync(
string source,
string summary,
string? detail = null,
LiveRoom? liveRoom = null,
RecordTask? recordTask = null,
CancellationToken cancellationToken = default,
string? eventScriptOutput = null) => Task.CompletedTask;
public Task<WebhookTestResultDto> SendTestAsync(
SendTestWebhookRequest request,
CancellationToken cancellationToken = default) =>
Task.FromResult(new WebhookTestResultDto
{
Success = true,
Message = "ok"
});
public Task SendDailyReviewAsync(
DailyReviewReportDto report,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class FakeEventScriptService : IEventScriptService
{
public EventScriptExecutionResultDto? LiveStartedResult { get; init; }
public Task<EventScriptExecutionResultDto?> RunLiveStartedAsync(
LiveRoom liveRoom,
DateTimeOffset occurredAt,
CancellationToken cancellationToken = default) =>
Task.FromResult(LiveStartedResult);
public Task<EventScriptExecutionResultDto?> RunLiveEndedAsync(
LiveRoom liveRoom,
DateTimeOffset occurredAt,
CancellationToken cancellationToken = default) =>
Task.FromResult<EventScriptExecutionResultDto?>(null);
public Task<EventScriptExecutionResultDto?> RunSegmentCompletedAsync(
LiveRoom? liveRoom,
RecordSession recordSession,
RecordTask recordTask,
RecordResult? recordResult,
string segmentFilePath,
DateTimeOffset occurredAt,
bool forceRun = false,
CancellationToken cancellationToken = default) =>
Task.FromResult<EventScriptExecutionResultDto?>(null);
public Task<EventScriptTestResultDto> TestAsync(
TestEventScriptRequest request,
CancellationToken cancellationToken = default) =>
Task.FromResult(new EventScriptTestResultDto
{
Success = true,
Message = "ok"
});
}
}
@@ -0,0 +1,100 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Tests;
public sealed class SystemSettingsServiceTests
{
[Fact]
public async Task GetAsync_reads_new_and_legacy_platform_settings()
{
var repository = new InMemoryAppSettingRepository(
[
new AppSetting("platform_request.tiktok.proxy.enabled", "true", DateTimeOffset.UtcNow),
new AppSetting("platform_request.tiktok.proxy.url", "http://127.0.0.1:8899", DateTimeOffset.UtcNow),
new AppSetting("platform_request.tiktok.user_agent", "TikTok UA", DateTimeOffset.UtcNow),
new AppSetting("platform_request.tiktok.referer", "https://www.tiktok.com/", DateTimeOffset.UtcNow),
new AppSetting("platform_request.tiktok.cookie", "sessionid=tiktok", DateTimeOffset.UtcNow),
new AppSetting("platform_proxy.douyin.enabled", "true", DateTimeOffset.UtcNow),
new AppSetting("platform_proxy.douyin.url", "http://127.0.0.1:7890", DateTimeOffset.UtcNow),
new AppSetting("douyin.user_agent", "Legacy Douyin UA", DateTimeOffset.UtcNow),
new AppSetting("douyin.referer", "https://live.douyin.com/", DateTimeOffset.UtcNow),
new AppSetting("douyin.cookie", "ttwid=legacy", DateTimeOffset.UtcNow)
]);
var service = new SystemSettingsService(repository, new NoOpUnitOfWork());
var settings = await service.GetAsync();
var tiktok = settings.GetPlatformRequestSettings(LivePlatformType.TikTok);
Assert.True(tiktok.Proxy.Enabled);
Assert.Equal("http://127.0.0.1:8899", tiktok.Proxy.ProxyUrl);
Assert.Equal("TikTok UA", tiktok.UserAgent);
Assert.Equal("sessionid=tiktok", tiktok.Cookie);
var douyin = settings.GetPlatformRequestSettings(LivePlatformType.Douyin);
Assert.True(douyin.Proxy.Enabled);
Assert.Equal("http://127.0.0.1:7890", douyin.Proxy.ProxyUrl);
Assert.Equal("Legacy Douyin UA", douyin.UserAgent);
Assert.Equal("ttwid=legacy", douyin.Cookie);
}
[Fact]
public async Task UpdateAsync_persists_platform_request_keys()
{
var repository = new InMemoryAppSettingRepository([]);
var service = new SystemSettingsService(repository, new NoOpUnitOfWork());
var request = new Application.Models.Settings.UpdateSystemSettingsRequest
{
PlatformRequestSettings = Application.Models.Settings.SystemSettingsDto.CreatePlatformRequestSettingsMap()
};
request.GetPlatformRequestSettings(LivePlatformType.YouTube).Proxy.Enabled = true;
request.GetPlatformRequestSettings(LivePlatformType.YouTube).Proxy.ProxyUrl = "http://127.0.0.1:8001";
request.GetPlatformRequestSettings(LivePlatformType.YouTube).UserAgent = "YouTube UA";
request.GetPlatformRequestSettings(LivePlatformType.Twitch).Cookie = "auth-token=123";
await service.UpdateAsync(request);
var allSettings = await repository.ListAsync();
Assert.Contains(allSettings, static item => item.Key == "platform_request.youtube.proxy.enabled" && item.Value == "True");
Assert.Contains(allSettings, static item => item.Key == "platform_request.youtube.proxy.url" && item.Value == "http://127.0.0.1:8001");
Assert.Contains(allSettings, static item => item.Key == "platform_request.youtube.user_agent" && item.Value == "YouTube UA");
Assert.Contains(allSettings, static item => item.Key == "platform_request.twitch.cookie" && item.Value == "auth-token=123");
}
private sealed class InMemoryAppSettingRepository : IAppSettingRepository
{
private readonly Dictionary<string, AppSetting> _items;
public InMemoryAppSettingRepository(IEnumerable<AppSetting> items)
{
_items = items.ToDictionary(static item => item.Key, StringComparer.OrdinalIgnoreCase);
}
public Task<AppSetting?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
{
_items.TryGetValue(key, out var item);
return Task.FromResult(item);
}
public Task<IReadOnlyList<AppSetting>> ListAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<AppSetting>>(_items.Values.ToList());
public Task AddAsync(AppSetting setting, CancellationToken cancellationToken = default)
{
_items[setting.Key] = setting;
return Task.CompletedTask;
}
public void Update(AppSetting setting)
{
_items[setting.Key] = setting;
}
}
private sealed class NoOpUnitOfWork : IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0);
}
}
+1
View File
@@ -0,0 +1 @@
global using Xunit;