feat: expand platform adapters and preview tooling
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
Reference in New Issue
Block a user