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
@@ -21,4 +21,7 @@ public sealed class LiveDanmakuAdapterFactory : ILiveDanmakuAdapterFactory
return adapter;
}
public ILiveDanmakuAdapter? TryGetByPlatform(LivePlatformType platformType) =>
_adapterByPlatform.TryGetValue(platformType, out var adapter) ? adapter : null;
}
@@ -22,6 +22,14 @@ public sealed class LiveRoomService
"""^(?:(?:https?:\/\/)?live\.bilibili\.com\/(?:blanc\/|h5\/)?)?(?<id>\d+)\/?(?:[#\?].*)?$""",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex YouTubeVideoIdRegex = new(
@"[A-Za-z0-9_-]{11}",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex TikTokHandleRegex = new(
@"@(?<handle>[A-Za-z0-9._-]{2,})",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly ILiveRoomRepository _liveRoomRepository;
private readonly IRecordSessionRepository _recordSessionRepository;
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
@@ -507,6 +515,15 @@ public sealed class LiveRoomService
{
LivePlatformType.Douyin => ParseDouyinRoomLocally(input),
LivePlatformType.Bilibili => ParseBilibiliRoomLocally(input),
LivePlatformType.Huya => ParsePathRoomLocally(input, platform, "https://www.huya.com/"),
LivePlatformType.Douyu => ParsePathRoomLocally(input, platform, "https://www.douyu.com/"),
LivePlatformType.Kuaishou => ParsePathRoomLocally(input, platform, "https://live.kuaishou.com/"),
LivePlatformType.TikTok => ParseTikTokRoomLocally(input),
LivePlatformType.Xiaohongshu => ParsePathRoomLocally(input, platform, "https://www.xiaohongshu.com/"),
LivePlatformType.YouTube => ParseYouTubeRoomLocally(input),
LivePlatformType.Twitch => ParseTwitchRoomLocally(input),
LivePlatformType.PandaTV => ParsePathRoomLocally(input, platform, "https://www.pandalive.co.kr/"),
LivePlatformType.Migu => ParsePathRoomLocally(input, platform, "https://www.miguvideo.com/"),
_ => throw new NotSupportedException(
"This platform still requires live room detection during import. Please enable detection or choose a supported platform.")
};
@@ -530,6 +547,53 @@ public sealed class LiveRoomService
return LivePlatformType.Bilibili;
}
if (input.Contains("huya.com", StringComparison.OrdinalIgnoreCase))
{
return LivePlatformType.Huya;
}
if (input.Contains("douyu.com", StringComparison.OrdinalIgnoreCase))
{
return LivePlatformType.Douyu;
}
if (input.Contains("kuaishou.com", StringComparison.OrdinalIgnoreCase))
{
return LivePlatformType.Kuaishou;
}
if (input.Contains("tiktok.com", StringComparison.OrdinalIgnoreCase))
{
return LivePlatformType.TikTok;
}
if (input.Contains("xiaohongshu.com", StringComparison.OrdinalIgnoreCase) ||
input.Contains("xhslink.com", StringComparison.OrdinalIgnoreCase))
{
return LivePlatformType.Xiaohongshu;
}
if (input.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) ||
input.Contains("youtu.be", StringComparison.OrdinalIgnoreCase))
{
return LivePlatformType.YouTube;
}
if (input.Contains("twitch.tv", StringComparison.OrdinalIgnoreCase))
{
return LivePlatformType.Twitch;
}
if (input.Contains("pandalive.co.kr", StringComparison.OrdinalIgnoreCase))
{
return LivePlatformType.PandaTV;
}
if (input.Contains("miguvideo.com", StringComparison.OrdinalIgnoreCase))
{
return LivePlatformType.Migu;
}
throw new NotSupportedException(
"Unable to infer the platform without detection. Please specify a platform override or keep detection enabled.");
}
@@ -566,6 +630,74 @@ public sealed class LiveRoomService
$"https://live.bilibili.com/{roomId}");
}
private static ParsedLiveRoom ParsePathRoomLocally(string input, LivePlatformType platformType, string rootUrl)
{
if (!TryExtractPathBasedRoomId(input, out var roomId))
{
throw new InvalidOperationException(
$"Unable to extract the {platformType} room id locally. Please keep detection enabled for this link.");
}
return new ParsedLiveRoom(
platformType,
roomId,
input.Trim(),
$"{rootUrl.TrimEnd('/')}/{roomId}");
}
private static ParsedLiveRoom ParseTikTokRoomLocally(string input)
{
var match = TikTokHandleRegex.Match(input.Trim());
if (!match.Success)
{
throw new InvalidOperationException(
"Unable to extract the TikTok handle locally. Please keep detection enabled for this link.");
}
var handle = match.Groups["handle"].Value;
return new ParsedLiveRoom(
LivePlatformType.TikTok,
handle,
input.Trim(),
$"https://www.tiktok.com/@{handle}/live");
}
private static ParsedLiveRoom ParseYouTubeRoomLocally(string input)
{
var roomId = ExtractYouTubeVideoId(input);
if (string.IsNullOrWhiteSpace(roomId))
{
throw new InvalidOperationException(
"Unable to extract the YouTube video id locally. Please keep detection enabled for this link.");
}
return new ParsedLiveRoom(
LivePlatformType.YouTube,
roomId,
input.Trim(),
$"https://www.youtube.com/watch?v={roomId}");
}
private static ParsedLiveRoom ParseTwitchRoomLocally(string input)
{
if (!TryExtractPathBasedRoomId(input, out var roomId))
{
roomId = input.Trim().Trim('/');
}
if (string.IsNullOrWhiteSpace(roomId))
{
throw new InvalidOperationException(
"Unable to extract the Twitch channel login locally. Please keep detection enabled for this link.");
}
return new ParsedLiveRoom(
LivePlatformType.Twitch,
roomId,
input.Trim(),
$"https://www.twitch.tv/{roomId}");
}
private static string? ExtractDouyinRoomId(string input)
{
var trimmedInput = input.Trim();
@@ -593,6 +725,56 @@ public sealed class LiveRoomService
.FirstOrDefault(static item => !string.IsNullOrWhiteSpace(item) && item.All(char.IsDigit));
}
private static string? ExtractYouTubeVideoId(string input)
{
var trimmedInput = input.Trim();
var directMatch = YouTubeVideoIdRegex.Match(trimmedInput);
if (directMatch.Success && directMatch.Index == 0 && directMatch.Length == trimmedInput.Length)
{
return directMatch.Value;
}
if (Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
{
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
if (query.TryGetValue("v", out var videoId) &&
!string.IsNullOrWhiteSpace(videoId.ToString()) &&
YouTubeVideoIdRegex.IsMatch(videoId.ToString()))
{
return videoId.ToString();
}
var segment = uri.Segments
.Select(static item => item.Trim('/'))
.FirstOrDefault(static item => !string.IsNullOrWhiteSpace(item) && YouTubeVideoIdRegex.IsMatch(item));
if (!string.IsNullOrWhiteSpace(segment))
{
return segment;
}
}
return null;
}
private static bool TryExtractPathBasedRoomId(string input, out string roomId)
{
roomId = string.Empty;
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
var trimmedInput = input.Trim().Trim('/');
if (!Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
{
roomId = trimmedInput;
return !string.IsNullOrWhiteSpace(roomId);
}
roomId = uri.AbsolutePath.Trim('/');
return !string.IsNullOrWhiteSpace(roomId);
}
private async Task<Dictionary<Guid, RecordingExecutionSettings>> BuildEffectiveSettingsLookupAsync(
IReadOnlyCollection<LiveRoom> rooms,
CancellationToken cancellationToken)
@@ -60,9 +60,15 @@ public sealed class LiveRoomStatusService
return;
}
await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
await _webhookNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
var eventScriptResult = await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
await _emailNotificationService.SendLiveStartedAsync(
liveRoom,
cancellationToken,
eventScriptOutput: eventScriptResult?.CustomLogOutput);
await _webhookNotificationService.SendLiveStartedAsync(
liveRoom,
cancellationToken,
eventScriptOutput: eventScriptResult?.CustomLogOutput);
liveRoom.MarkLiveNotificationSent(observedAt);
}
}
@@ -1,5 +1,6 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Common;
using LiveRecorder.Application.Models.Cleanup;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
@@ -154,21 +155,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
UploadTarget = Enum.TryParse(GetValue(lookup, UploadTargetKey, "None"), true, out UploadTargetType uploadTarget)
? uploadTarget
: UploadTargetType.None,
DouyinProxy = new PlatformProxySettingsDto
{
Enabled = bool.TryParse(GetValue(lookup, DouyinProxyEnabledKey, "false"), out var douyinProxyEnabled) && douyinProxyEnabled,
ProxyUrl = GetValue(lookup, DouyinProxyUrlKey, string.Empty)
},
BilibiliProxy = new PlatformProxySettingsDto
{
Enabled = bool.TryParse(GetValue(lookup, BilibiliProxyEnabledKey, "false"), out var bilibiliProxyEnabled) && bilibiliProxyEnabled,
ProxyUrl = GetValue(lookup, BilibiliProxyUrlKey, string.Empty)
},
HuyaProxy = new PlatformProxySettingsDto
{
Enabled = bool.TryParse(GetValue(lookup, HuyaProxyEnabledKey, "false"), out var huyaProxyEnabled) && huyaProxyEnabled,
ProxyUrl = GetValue(lookup, HuyaProxyUrlKey, string.Empty)
},
PlatformRequestSettings = BuildPlatformRequestSettingsMap(lookup),
WebDavUpload = new WebDavUploadSettingsDto
{
Endpoint = GetValue(lookup, WebDavEndpointKey, string.Empty),
@@ -274,13 +261,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
WebhookBodyTemplate = GetValue(lookup, WebhookBodyTemplateKey, string.Empty),
WebhookTimeoutSeconds = GetIntValue(lookup, WebhookTimeoutSecondsKey, 15, 1, 300),
NotifyWebhookOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyWebhookOnLiveStartedKey, "true"), out var notifyWebhookOnLiveStarted) && notifyWebhookOnLiveStarted,
NotifyWebhookOnException = bool.TryParse(GetValue(lookup, NotifyWebhookOnExceptionKey, "true"), out var notifyWebhookOnException) && notifyWebhookOnException,
DouyinUserAgent = GetValue(
lookup,
DouyinUserAgentKey,
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"),
DouyinReferer = GetValue(lookup, DouyinRefererKey, "https://live.douyin.com/"),
DouyinCookie = GetValue(lookup, DouyinCookieKey, string.Empty)
NotifyWebhookOnException = bool.TryParse(GetValue(lookup, NotifyWebhookOnExceptionKey, "true"), out var notifyWebhookOnException) && notifyWebhookOnException
};
}
@@ -289,9 +270,6 @@ public sealed class SystemSettingsService : ISystemSettingsService
ArgumentNullException.ThrowIfNull(request);
var now = DateTimeOffset.UtcNow;
var douyinProxy = request.DouyinProxy ?? new PlatformProxySettingsDto();
var bilibiliProxy = request.BilibiliProxy ?? new PlatformProxySettingsDto();
var huyaProxy = request.HuyaProxy ?? new PlatformProxySettingsDto();
var webDavUpload = request.WebDavUpload ?? new WebDavUploadSettingsDto();
var s3Upload = request.S3Upload ?? new S3UploadSettingsDto();
@@ -351,12 +329,15 @@ public sealed class SystemSettingsService : ISystemSettingsService
await UpsertAsync(S3SecretKeyKey, s3Upload.SecretKey, now, cancellationToken);
await UpsertAsync(S3PrefixKey, s3Upload.Prefix.Trim(), now, cancellationToken);
await UpsertAsync(S3ForcePathStyleKey, s3Upload.ForcePathStyle.ToString(), now, cancellationToken);
await UpsertAsync(DouyinProxyEnabledKey, douyinProxy.Enabled.ToString(), now, cancellationToken);
await UpsertAsync(DouyinProxyUrlKey, douyinProxy.ProxyUrl.Trim(), now, cancellationToken);
await UpsertAsync(BilibiliProxyEnabledKey, bilibiliProxy.Enabled.ToString(), now, cancellationToken);
await UpsertAsync(BilibiliProxyUrlKey, bilibiliProxy.ProxyUrl.Trim(), now, cancellationToken);
await UpsertAsync(HuyaProxyEnabledKey, huyaProxy.Enabled.ToString(), now, cancellationToken);
await UpsertAsync(HuyaProxyUrlKey, huyaProxy.ProxyUrl.Trim(), now, cancellationToken);
foreach (var platformDefinition in LivePlatformCatalog.All)
{
var platformRequestSettings = request.GetPlatformRequestSettings(platformDefinition.Type);
await UpsertAsync(GetPlatformProxyEnabledKey(platformDefinition.Key), platformRequestSettings.Proxy.Enabled.ToString(), now, cancellationToken);
await UpsertAsync(GetPlatformProxyUrlKey(platformDefinition.Key), platformRequestSettings.Proxy.ProxyUrl.Trim(), now, cancellationToken);
await UpsertAsync(GetPlatformUserAgentKey(platformDefinition.Key), platformRequestSettings.UserAgent.Trim(), now, cancellationToken);
await UpsertAsync(GetPlatformRefererKey(platformDefinition.Key), platformRequestSettings.Referer.Trim(), now, cancellationToken);
await UpsertAsync(GetPlatformCookieKey(platformDefinition.Key), platformRequestSettings.Cookie.Trim(), now, cancellationToken);
}
await UpsertAsync(EnableEventScriptsKey, request.EnableEventScripts.ToString(), now, cancellationToken);
await UpsertAsync(EnableLiveStartedScriptKey, request.EnableLiveStartedScript.ToString(), now, cancellationToken);
await UpsertAsync(LiveStartedScriptModeKey, NormalizeEventScriptMode(request.LiveStartedScriptMode), now, cancellationToken);
@@ -398,14 +379,50 @@ public sealed class SystemSettingsService : ISystemSettingsService
await UpsertAsync(WebhookTimeoutSecondsKey, Math.Clamp(request.WebhookTimeoutSeconds, 1, 300).ToString(), now, cancellationToken);
await UpsertAsync(NotifyWebhookOnLiveStartedKey, request.NotifyWebhookOnLiveStarted.ToString(), now, cancellationToken);
await UpsertAsync(NotifyWebhookOnExceptionKey, request.NotifyWebhookOnException.ToString(), now, cancellationToken);
await UpsertAsync(DouyinUserAgentKey, request.DouyinUserAgent.Trim(), now, cancellationToken);
await UpsertAsync(DouyinRefererKey, request.DouyinReferer.Trim(), now, cancellationToken);
await UpsertAsync(DouyinCookieKey, request.DouyinCookie.Trim(), now, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return await GetAsync(cancellationToken);
}
private static Dictionary<string, PlatformRequestSettingsDto> BuildPlatformRequestSettingsMap(
IReadOnlyDictionary<string, string> lookup)
{
var result = SystemSettingsDto.CreatePlatformRequestSettingsMap();
foreach (var platformDefinition in LivePlatformCatalog.All)
{
var settings = result[platformDefinition.Key];
settings.Proxy.Enabled = bool.TryParse(
GetPlatformValue(
lookup,
GetPlatformProxyEnabledKey(platformDefinition.Key),
GetLegacyProxyEnabledKey(platformDefinition.Type),
"false"),
out var proxyEnabled) && proxyEnabled;
settings.Proxy.ProxyUrl = GetPlatformValue(
lookup,
GetPlatformProxyUrlKey(platformDefinition.Key),
GetLegacyProxyUrlKey(platformDefinition.Type),
string.Empty);
settings.UserAgent = GetPlatformValue(
lookup,
GetPlatformUserAgentKey(platformDefinition.Key),
GetLegacyUserAgentKey(platformDefinition.Type),
platformDefinition.DefaultUserAgent);
settings.Referer = GetPlatformValue(
lookup,
GetPlatformRefererKey(platformDefinition.Key),
GetLegacyRefererKey(platformDefinition.Type),
platformDefinition.DefaultReferer);
settings.Cookie = GetPlatformValue(
lookup,
GetPlatformCookieKey(platformDefinition.Key),
GetLegacyCookieKey(platformDefinition.Type),
string.Empty);
}
return result;
}
private static string GetValue(IReadOnlyDictionary<string, string> lookup, string key, string fallback) =>
lookup.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback;
@@ -498,6 +515,69 @@ public sealed class SystemSettingsService : ISystemSettingsService
.OrderBy(static item => item)
.ToArray());
private static string GetPlatformProxyEnabledKey(string platformKey) =>
$"platform_request.{platformKey}.proxy.enabled";
private static string GetPlatformProxyUrlKey(string platformKey) =>
$"platform_request.{platformKey}.proxy.url";
private static string GetPlatformUserAgentKey(string platformKey) =>
$"platform_request.{platformKey}.user_agent";
private static string GetPlatformRefererKey(string platformKey) =>
$"platform_request.{platformKey}.referer";
private static string GetPlatformCookieKey(string platformKey) =>
$"platform_request.{platformKey}.cookie";
private static string? GetLegacyProxyEnabledKey(LivePlatformType platformType) =>
platformType switch
{
LivePlatformType.Douyin => DouyinProxyEnabledKey,
LivePlatformType.Bilibili => BilibiliProxyEnabledKey,
LivePlatformType.Huya => HuyaProxyEnabledKey,
_ => null
};
private static string? GetLegacyProxyUrlKey(LivePlatformType platformType) =>
platformType switch
{
LivePlatformType.Douyin => DouyinProxyUrlKey,
LivePlatformType.Bilibili => BilibiliProxyUrlKey,
LivePlatformType.Huya => HuyaProxyUrlKey,
_ => null
};
private static string? GetLegacyUserAgentKey(LivePlatformType platformType) =>
platformType == LivePlatformType.Douyin ? DouyinUserAgentKey : null;
private static string? GetLegacyRefererKey(LivePlatformType platformType) =>
platformType == LivePlatformType.Douyin ? DouyinRefererKey : null;
private static string? GetLegacyCookieKey(LivePlatformType platformType) =>
platformType == LivePlatformType.Douyin ? DouyinCookieKey : null;
private static string GetPlatformValue(
IReadOnlyDictionary<string, string> lookup,
string key,
string? legacyKey,
string fallback)
{
if (lookup.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
if (!string.IsNullOrWhiteSpace(legacyKey) &&
lookup.TryGetValue(legacyKey, out var legacyValue) &&
!string.IsNullOrWhiteSpace(legacyValue))
{
return legacyValue.Trim();
}
return fallback;
}
private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken)
{
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);