diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 22c0e4d..5aac1c4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -53,6 +53,13 @@ export interface ImportLiveRoomsResult { items: ImportLiveRoomItemResult[]; } +export interface ImportLiveRoomsRequest { + content: string; + platformOverride?: number | null; + detectRoomMetadata: boolean; + detectLiveStatus: boolean; +} + export interface ImportLiveRoomItemResult { lineNumber: number; rawLine: string; diff --git a/frontend/src/views/LiveRoomsView.vue b/frontend/src/views/LiveRoomsView.vue index 20822e8..f611364 100644 --- a/frontend/src/views/LiveRoomsView.vue +++ b/frontend/src/views/LiveRoomsView.vue @@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "v import { ElMessage } from "element-plus"; import apiClient, { getApiErrorMessage } from "@/api/client"; import { useViewport } from "@/composables/useViewport"; -import type { BatchLiveRoomsResult, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types"; +import type { BatchLiveRoomsResult, ImportLiveRoomsRequest, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types"; import { autoStartDecisionLabelMap, availabilityLabelMap, @@ -49,7 +49,9 @@ const createForm = reactive({ const importForm = reactive({ content: "", - platformOverride: null as number | null + platformOverride: null as number | null, + detectRoomMetadata: true, + detectLiveStatus: true }); const importResult = ref(null); @@ -331,6 +333,8 @@ function resetCreateForm() { function resetImportForm() { importForm.content = ""; importForm.platformOverride = null; + importForm.detectRoomMetadata = true; + importForm.detectLiveStatus = true; importResult.value = null; } @@ -367,10 +371,14 @@ async function importRooms() { importResult.value = null; try { - const { data } = await apiClient.post("/live-rooms/import", { + const payload: ImportLiveRoomsRequest = { content: importForm.content, - platformOverride: importForm.platformOverride - }); + platformOverride: importForm.platformOverride, + detectRoomMetadata: importForm.detectRoomMetadata, + detectLiveStatus: importForm.detectLiveStatus + }; + + const { data } = await apiClient.post("/live-rooms/import", payload); importResult.value = data; ElMessage.success(`导入完成:成功 ${data.successCount} 条,失败 ${data.failedCount} 条。`); @@ -1098,6 +1106,31 @@ onBeforeUnmount(() => { +
+
+
+
检测直播间信息
+
关闭后不在导入时拉取标题、头像等信息。
+
+ +
+
+
+
检测开播状态
+
关闭后不在导入时请求开播状态,也不会立即触发自动开录。
+
+ +
+
+ + +
共 {{ importResult.totalCount }} 条,成功 {{ importResult.successCount }} 条,新增 @@ -1594,6 +1627,13 @@ onBeforeUnmount(() => { font-weight: 600; } +.settings-toggle-item__hint { + margin-top: 4px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.5; +} + .dialog-form { display: grid; } @@ -1602,6 +1642,15 @@ onBeforeUnmount(() => { gap: 16px; } +.import-options { + display: grid; + gap: 12px; +} + +.import-options__item { + align-items: flex-start; +} + .import-result { display: grid; gap: 12px; diff --git a/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs b/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs index 8fc836c..9434713 100644 --- a/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs +++ b/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs @@ -16,6 +16,10 @@ public sealed class ImportLiveRoomsRequest public string Content { get; set; } = string.Empty; public LivePlatformType? PlatformOverride { get; set; } + + public bool DetectRoomMetadata { get; set; } = true; + + public bool DetectLiveStatus { get; set; } = true; } public sealed class ImportLiveRoomsResultDto diff --git a/src/LiveRecorder.Application/Services/LiveRoomService.cs b/src/LiveRecorder.Application/Services/LiveRoomService.cs index edfb31b..04fbda6 100644 --- a/src/LiveRecorder.Application/Services/LiveRoomService.cs +++ b/src/LiveRecorder.Application/Services/LiveRoomService.cs @@ -8,11 +8,20 @@ using LiveRecorder.Application.Models.LiveRooms; using LiveRecorder.Application.Models.RecordTasks; using LiveRecorder.Domain.Entities; using LiveRecorder.Domain.Enums; +using System.Text.RegularExpressions; namespace LiveRecorder.Application.Services; public sealed class LiveRoomService { + private static readonly Regex DouyinRoomIdRegex = new( + @"(?\d{8,})", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex BilibiliRoomIdRegex = new( + """^(?:(?:https?:\/\/)?live\.bilibili\.com\/(?:blanc\/|h5\/)?)?(?\d+)\/?(?:[#\?].*)?$""", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private readonly ILiveRoomRepository _liveRoomRepository; private readonly IRecordSessionRepository _recordSessionRepository; private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory; @@ -83,7 +92,7 @@ public sealed class LiveRoomService request.Url, request.PlatformOverride, request.AnchorName, - cancellationToken); + cancellationToken: cancellationToken); var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken) is not null; return Map(liveRoom, effectiveSettings, hasActiveSession); @@ -95,6 +104,9 @@ public sealed class LiveRoomService { ArgumentNullException.ThrowIfNull(request); + var detectRoomMetadata = request.DetectRoomMetadata; + var detectLiveStatus = request.DetectLiveStatus; + var results = new List(); var lines = request.Content .Replace("\r\n", "\n", StringComparison.Ordinal) @@ -129,7 +141,9 @@ public sealed class LiveRoomService url, request.PlatformOverride, anchorName, - cancellationToken); + detectRoomMetadata, + detectLiveStatus, + cancellationToken: cancellationToken); results.Add(new ImportLiveRoomItemResultDto { @@ -190,7 +204,9 @@ public sealed class LiveRoomService string rawInput, LivePlatformType? platformOverride, string? fallbackAnchorName, - CancellationToken cancellationToken) + bool detectRoomMetadata = true, + bool detectLiveStatus = true, + CancellationToken cancellationToken = default) { var input = rawInput.Trim(); if (string.IsNullOrWhiteSpace(input)) @@ -198,12 +214,30 @@ public sealed class LiveRoomService throw new InvalidOperationException("Live room URL is required."); } - var adapter = platformOverride.HasValue && platformOverride.Value != LivePlatformType.Unknown - ? _livePlatformAdapterFactory.GetByPlatform(platformOverride.Value) - : _livePlatformAdapterFactory.GetByInput(input); + ILivePlatformAdapter? adapter = null; + ParsedLiveRoom parsedRoom; + if (detectRoomMetadata || detectLiveStatus) + { + adapter = platformOverride.HasValue && platformOverride.Value != LivePlatformType.Unknown + ? _livePlatformAdapterFactory.GetByPlatform(platformOverride.Value) + : _livePlatformAdapterFactory.GetByInput(input); + + parsedRoom = await adapter.ParseRoomAsync(input, cancellationToken); + } + else + { + parsedRoom = ParseRoomLocally(input, platformOverride); + } + + LiveStatusSnapshot? liveStatus = null; + if (detectLiveStatus) + { + adapter ??= platformOverride.HasValue && platformOverride.Value != LivePlatformType.Unknown + ? _livePlatformAdapterFactory.GetByPlatform(platformOverride.Value) + : _livePlatformAdapterFactory.GetByInput(input); + liveStatus = await adapter.GetLiveStatusAsync(parsedRoom.RoomId, cancellationToken); + } - var parsedRoom = await adapter.ParseRoomAsync(input, cancellationToken); - var liveStatus = await adapter.GetLiveStatusAsync(parsedRoom.RoomId, cancellationToken); var now = DateTimeOffset.UtcNow; var liveRoom = await _liveRoomRepository.GetByPlatformRoomIdAsync(parsedRoom.PlatformType, parsedRoom.RoomId, cancellationToken); @@ -211,7 +245,16 @@ public sealed class LiveRoomService if (liveRoom is null) { liveRoom = new LiveRoom(parsedRoom.PlatformType, parsedRoom.SourceUrl, parsedRoom.RoomId, parsedRoom.NormalizedUrl, now); - await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken); + if (liveStatus is not null) + { + await _liveRoomStatusService.ApplySnapshotAsync( + liveRoom, + liveStatus, + now, + updateMetadata: detectRoomMetadata, + cancellationToken: cancellationToken); + } + liveRoom.UpdateMetadata(null, fallbackAnchorName, null, null, null, now); await _liveRoomRepository.AddAsync(liveRoom, cancellationToken); @@ -220,7 +263,16 @@ public sealed class LiveRoomService { liveRoom.UpdateSource(parsedRoom.SourceUrl, parsedRoom.NormalizedUrl, now); liveRoom.UpdateRoomId(parsedRoom.RoomId, now); - await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken); + if (liveStatus is not null) + { + await _liveRoomStatusService.ApplySnapshotAsync( + liveRoom, + liveStatus, + now, + updateMetadata: detectRoomMetadata, + cancellationToken: cancellationToken); + } + liveRoom.UpdateMetadata(null, fallbackAnchorName, null, null, null, now); } @@ -231,7 +283,10 @@ public sealed class LiveRoomService $"Live room resolved: {liveRoom.RoomId} ({liveRoom.Platform}).", liveRoomId: liveRoom.Id, cancellationToken: cancellationToken); - await TryAutoStartRecordingAsync(liveRoom, liveStatus, cancellationToken); + if (liveStatus is not null) + { + await TryAutoStartRecordingAsync(liveRoom, liveStatus, cancellationToken); + } var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(liveRoom, cancellationToken); return (liveRoom, effectiveSettings, created); @@ -246,7 +301,7 @@ public sealed class LiveRoomService var liveStatus = await adapter.GetLiveStatusAsync(room.RoomId, cancellationToken); var now = DateTimeOffset.UtcNow; - await _liveRoomStatusService.ApplySnapshotAsync(room, liveStatus, now, cancellationToken); + await _liveRoomStatusService.ApplySnapshotAsync(room, liveStatus, now, cancellationToken: cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken); await _systemLogService.WriteAsync( @@ -445,6 +500,99 @@ public sealed class LiveRoomService return BuildBatchResult(request.LiveRoomIds.Count, results); } + private static ParsedLiveRoom ParseRoomLocally(string input, LivePlatformType? platformOverride) + { + var platform = ResolvePlatformForLocalImport(input, platformOverride); + return platform switch + { + LivePlatformType.Douyin => ParseDouyinRoomLocally(input), + LivePlatformType.Bilibili => ParseBilibiliRoomLocally(input), + _ => throw new NotSupportedException( + "This platform still requires live room detection during import. Please enable detection or choose a supported platform.") + }; + } + + private static LivePlatformType ResolvePlatformForLocalImport(string input, LivePlatformType? platformOverride) + { + if (platformOverride.HasValue && platformOverride.Value != LivePlatformType.Unknown) + { + return platformOverride.Value; + } + + if (input.Contains("douyin.com", StringComparison.OrdinalIgnoreCase) || + input.Contains("iesdouyin.com", StringComparison.OrdinalIgnoreCase)) + { + return LivePlatformType.Douyin; + } + + if (input.Contains("live.bilibili.com", StringComparison.OrdinalIgnoreCase)) + { + return LivePlatformType.Bilibili; + } + + throw new NotSupportedException( + "Unable to infer the platform without detection. Please specify a platform override or keep detection enabled."); + } + + private static ParsedLiveRoom ParseDouyinRoomLocally(string input) + { + var roomId = ExtractDouyinRoomId(input); + if (string.IsNullOrWhiteSpace(roomId)) + { + throw new InvalidOperationException( + "Unable to extract the Douyin room id locally. Please keep detection enabled for this link."); + } + + return new ParsedLiveRoom( + LivePlatformType.Douyin, + roomId, + input.Trim(), + $"https://live.douyin.com/{roomId}"); + } + + private static ParsedLiveRoom ParseBilibiliRoomLocally(string input) + { + var roomId = ExtractBilibiliRoomId(input); + if (string.IsNullOrWhiteSpace(roomId)) + { + throw new InvalidOperationException( + "Unable to extract the Bilibili room id locally. Please keep detection enabled for this link."); + } + + return new ParsedLiveRoom( + LivePlatformType.Bilibili, + roomId, + input.Trim(), + $"https://live.bilibili.com/{roomId}"); + } + + private static string? ExtractDouyinRoomId(string input) + { + var trimmedInput = input.Trim(); + var match = DouyinRoomIdRegex.Match(trimmedInput); + return match.Success ? match.Groups["id"].Value : null; + } + + private static string? ExtractBilibiliRoomId(string input) + { + var trimmedInput = input.Trim(); + var directMatch = BilibiliRoomIdRegex.Match(trimmedInput); + if (directMatch.Success) + { + return directMatch.Groups["id"].Value; + } + + if (!Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri) || + !uri.Host.Contains("live.bilibili.com", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return uri.Segments + .Select(static item => item.Trim('/')) + .FirstOrDefault(static item => !string.IsNullOrWhiteSpace(item) && item.All(char.IsDigit)); + } + private async Task> BuildEffectiveSettingsLookupAsync( IReadOnlyCollection rooms, CancellationToken cancellationToken) diff --git a/src/LiveRecorder.Application/Services/LiveRoomStatusService.cs b/src/LiveRecorder.Application/Services/LiveRoomStatusService.cs index 734237e..4e37590 100644 --- a/src/LiveRecorder.Application/Services/LiveRoomStatusService.cs +++ b/src/LiveRecorder.Application/Services/LiveRoomStatusService.cs @@ -26,6 +26,7 @@ public sealed class LiveRoomStatusService LiveRoom liveRoom, LiveStatusSnapshot liveStatus, DateTimeOffset observedAt, + bool updateMetadata = true, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(liveRoom); @@ -33,13 +34,17 @@ public sealed class LiveRoomStatusService var wasLive = liveRoom.AvailabilityStatus == LiveRoomAvailabilityStatus.Live; - liveRoom.UpdateMetadata( - liveStatus.Title, - liveStatus.AnchorName, - liveStatus.AnchorId, - liveStatus.AvatarUrl, - liveStatus.CoverUrl, - observedAt); + if (updateMetadata) + { + liveRoom.UpdateMetadata( + liveStatus.Title, + liveStatus.AnchorName, + liveStatus.AnchorId, + liveStatus.AvatarUrl, + liveStatus.CoverUrl, + observedAt); + } + liveRoom.UpdateAvailability( liveStatus.IsLive ? LiveRoomAvailabilityStatus.Live : LiveRoomAvailabilityStatus.Offline, observedAt); diff --git a/src/LiveRecorder.Application/Services/RecordService.cs b/src/LiveRecorder.Application/Services/RecordService.cs index da8b98b..c6d5355 100644 --- a/src/LiveRecorder.Application/Services/RecordService.cs +++ b/src/LiveRecorder.Application/Services/RecordService.cs @@ -247,7 +247,7 @@ public sealed class RecordService try { var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, cancellationToken); - await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken); + await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken: cancellationToken); if (!liveStatus.IsLive) { diff --git a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs index ac0cb3d..74e31bd 100644 --- a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs +++ b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs @@ -292,7 +292,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, cancellationToken); var now = DateTimeOffset.UtcNow; - await liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken); + await liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken: cancellationToken); await SaveChangesWithRetryAsync(dbContext, cancellationToken); if (!liveStatus.IsLive)