fix: refresh uploads and remember login

This commit is contained in:
2026-08-13 22:25:00 +08:00
parent e0bd001541
commit fdab9978bf
17 changed files with 424 additions and 19 deletions
+3 -1
View File
@@ -154,7 +154,7 @@ function notifyBackendUnavailable(message: string) {
} }
apiClient.interceptors.request.use((config) => { apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("live-recorder-token"); const token = localStorage.getItem("live-recorder-token") ?? sessionStorage.getItem("live-recorder-token");
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
@@ -172,6 +172,8 @@ apiClient.interceptors.response.use(
markBackendAvailable(); markBackendAvailable();
localStorage.removeItem("live-recorder-token"); localStorage.removeItem("live-recorder-token");
localStorage.removeItem("live-recorder-user"); localStorage.removeItem("live-recorder-user");
sessionStorage.removeItem("live-recorder-token");
sessionStorage.removeItem("live-recorder-user");
if (window.location.pathname !== "/login") { if (window.location.pathname !== "/login") {
window.location.href = "/login"; window.location.href = "/login";
+24 -10
View File
@@ -6,38 +6,51 @@ import type { AuthenticatedUser, LoginResponse } from "@/types";
const USER_STORAGE_KEY = "live-recorder-user"; const USER_STORAGE_KEY = "live-recorder-user";
const TOKEN_STORAGE_KEY = "live-recorder-token"; const TOKEN_STORAGE_KEY = "live-recorder-token";
function readStoredValue(key: string) {
return localStorage.getItem(key) ?? sessionStorage.getItem(key);
}
function clearStoredSession() {
[localStorage, sessionStorage].forEach((storage) => {
storage.removeItem(TOKEN_STORAGE_KEY);
storage.removeItem(USER_STORAGE_KEY);
});
}
export const useAuthStore = defineStore("auth", () => { export const useAuthStore = defineStore("auth", () => {
const token = ref(localStorage.getItem(TOKEN_STORAGE_KEY) ?? ""); const token = ref(readStoredValue(TOKEN_STORAGE_KEY) ?? "");
const user = ref<AuthenticatedUser | null>( const user = ref<AuthenticatedUser | null>(
(() => { (() => {
const raw = localStorage.getItem(USER_STORAGE_KEY); const raw = readStoredValue(USER_STORAGE_KEY);
return raw ? (JSON.parse(raw) as AuthenticatedUser) : null; return raw ? (JSON.parse(raw) as AuthenticatedUser) : null;
})() })()
); );
const isAuthenticated = computed(() => Boolean(token.value)); const isAuthenticated = computed(() => Boolean(token.value));
function persistSession(session: LoginResponse) { function persistSession(session: LoginResponse, rememberMe: boolean) {
token.value = session.token; token.value = session.token;
user.value = session.user; user.value = session.user;
localStorage.setItem(TOKEN_STORAGE_KEY, session.token); clearStoredSession();
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user)); const storage = rememberMe ? localStorage : sessionStorage;
storage.setItem(TOKEN_STORAGE_KEY, session.token);
storage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user));
} }
function clearSession() { function clearSession() {
token.value = ""; token.value = "";
user.value = null; user.value = null;
localStorage.removeItem(TOKEN_STORAGE_KEY); clearStoredSession();
localStorage.removeItem(USER_STORAGE_KEY);
} }
async function login(username: string, password: string) { async function login(username: string, password: string, rememberMe: boolean) {
const { data } = await apiClient.post<LoginResponse>("/auth/login", { const { data } = await apiClient.post<LoginResponse>("/auth/login", {
username, username,
password password,
rememberMe
}); });
persistSession(data); persistSession(data, rememberMe);
return data; return data;
} }
@@ -54,6 +67,7 @@ export const useAuthStore = defineStore("auth", () => {
currentPassword, currentPassword,
newPassword newPassword
}); });
clearSession();
} }
return { return {
+11 -4
View File
@@ -16,7 +16,8 @@ const { themeMode } = useUiPreferences();
const form = reactive({ const form = reactive({
username: "admin", username: "admin",
password: "" password: "",
rememberMe: false
}); });
const currentThemeIcon = computed(() => { const currentThemeIcon = computed(() => {
@@ -35,7 +36,7 @@ async function handleLogin() {
loading.value = true; loading.value = true;
try { try {
await authStore.login(form.username, form.password); await authStore.login(form.username, form.password, form.rememberMe);
ElMessage.success("登录成功"); ElMessage.success("登录成功");
await router.push({ name: "dashboard" }); await router.push({ name: "dashboard" });
} catch (error) { } catch (error) {
@@ -94,10 +95,12 @@ async function handleLogin() {
:description="backendMessage" :description="backendMessage"
/> />
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin"> <el-form label-position="top" class="login-form" autocomplete="on" @submit.prevent="handleLogin">
<el-form-item label="用户名"> <el-form-item label="用户名">
<el-input <el-input
v-model="form.username" v-model="form.username"
id="username"
name="username"
:prefix-icon="User" :prefix-icon="User"
autocomplete="username" autocomplete="username"
spellcheck="false" spellcheck="false"
@@ -107,6 +110,8 @@ async function handleLogin() {
<el-form-item label="密码"> <el-form-item label="密码">
<el-input <el-input
v-model="form.password" v-model="form.password"
id="password"
name="password"
:prefix-icon="Lock" :prefix-icon="Lock"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
@@ -115,7 +120,9 @@ async function handleLogin() {
/> />
</el-form-item> </el-form-item>
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin"> <el-checkbox v-model="form.rememberMe" name="rememberMe">记住登录状态30 </el-checkbox>
<el-button class="login-form__submit" native-type="submit" type="primary" :loading="loading">
登录控制台 <el-icon class="el-icon--right"><ArrowRight /></el-icon> 登录控制台 <el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button> </el-button>
</el-form> </el-form>
+2 -1
View File
@@ -691,11 +691,12 @@ async function changePassword() {
changingPassword.value = true; changingPassword.value = true;
try { try {
await authStore.changePassword(pwdForm.currentPassword, pwdForm.newPassword); await authStore.changePassword(pwdForm.currentPassword, pwdForm.newPassword);
ElMessage.success("密码已更新"); ElMessage.success("密码已更新,请重新登录");
pwdForm.currentPassword = ""; pwdForm.currentPassword = "";
pwdForm.newPassword = ""; pwdForm.newPassword = "";
pwdForm.confirmPassword = ""; pwdForm.confirmPassword = "";
pwdFormRef.value?.resetFields(); pwdFormRef.value?.resetFields();
await router.push({ name: "login" });
} catch (error) { } catch (error) {
ElMessage.error(getApiErrorMessage(error, "密码修改失败,请稍后重试。")); ElMessage.error(getApiErrorMessage(error, "密码修改失败,请稍后重试。"));
} finally { } finally {
@@ -197,6 +197,8 @@ public interface IUserSessionRepository
Task AddAsync(UserSession session, CancellationToken cancellationToken = default); Task AddAsync(UserSession session, CancellationToken cancellationToken = default);
Task<IReadOnlyList<UserSession>> ListActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
void Update(UserSession session); void Update(UserSession session);
} }
@@ -5,6 +5,8 @@ public sealed class LoginRequest
public string Username { get; set; } = string.Empty; public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; }
} }
public sealed class AuthenticatedUser public sealed class AuthenticatedUser
@@ -43,7 +43,7 @@ public sealed class AuthService : IAuthService
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var expiresAt = now.AddHours(12); var expiresAt = request.RememberMe ? now.AddDays(30) : now.AddHours(12);
var session = new UserSession(user.Id, token, expiresAt, now); var session = new UserSession(user.Id, token, expiresAt, now);
await _userSessionRepository.AddAsync(session, cancellationToken); await _userSessionRepository.AddAsync(session, cancellationToken);
@@ -137,6 +137,12 @@ public sealed class AuthService : IAuthService
user.UpdatePassword(PasswordHasher.Hash(request.NewPassword)); user.UpdatePassword(PasswordHasher.Hash(request.NewPassword));
_userAccountRepository.Update(user); _userAccountRepository.Update(user);
var now = DateTimeOffset.UtcNow;
foreach (var session in await _userSessionRepository.ListActiveByUserIdAsync(userId, cancellationToken))
{
session.Revoke(now);
_userSessionRepository.Update(session);
}
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
} }
} }
@@ -103,6 +103,15 @@ public class RecordResult
DeletedLocalFilesAfterUpload = false; DeletedLocalFilesAfterUpload = false;
} }
public void ResetRemoteArtifactsForReplacement(DateTimeOffset updatedAt)
{
RemoteVideoPath = null;
RemoteDanmakuPath = null;
LastUploadedAt = updatedAt;
UploadErrorMessage = null;
DeletedLocalFilesAfterUpload = false;
}
public void MarkUploadWaitingRetry(string provider, string? errorMessage, DateTimeOffset updatedAt) public void MarkUploadWaitingRetry(string provider, string? errorMessage, DateTimeOffset updatedAt)
{ {
UploadStatus = RecordArtifactUploadStatus.WaitingRetry; UploadStatus = RecordArtifactUploadStatus.WaitingRetry;
@@ -76,6 +76,14 @@ public sealed class RecordUploadJob
public string? TransferTargetPath { get; private set; } public string? TransferTargetPath { get; private set; }
public string? PendingCleanupVideoPath { get; private set; }
public string? PendingCleanupDanmakuPath { get; private set; }
public string? CleanupErrorMessage { get; private set; }
public DateTimeOffset? NextCleanupAttemptAt { get; private set; }
public DateTimeOffset? NextAttemptAt { get; private set; } public DateTimeOffset? NextAttemptAt { get; private set; }
public DateTimeOffset? VerificationStartedAt { get; private set; } public DateTimeOffset? VerificationStartedAt { get; private set; }
@@ -127,6 +135,69 @@ public sealed class RecordUploadJob
UpdatedAt = requestedAt; UpdatedAt = requestedAt;
} }
public bool HasArtifactDrift(
string providerEndpoint,
string sourceVideoPath,
string targetVideoPath,
long videoSizeBytes,
string? sourceDanmakuPath,
string? targetDanmakuPath,
long? danmakuSizeBytes) =>
!string.Equals(ProviderEndpoint, NormalizeRequired(providerEndpoint), StringComparison.Ordinal) ||
!string.Equals(SourceVideoPath, NormalizeRequired(sourceVideoPath), StringComparison.Ordinal) ||
!string.Equals(TargetVideoPath, NormalizeRequired(targetVideoPath), StringComparison.Ordinal) ||
VideoSizeBytes != Math.Max(0, videoSizeBytes) ||
!string.Equals(SourceDanmakuPath, NormalizeNullable(sourceDanmakuPath), StringComparison.Ordinal) ||
!string.Equals(TargetDanmakuPath, NormalizeNullable(targetDanmakuPath), StringComparison.Ordinal) ||
DanmakuSizeBytes != (danmakuSizeBytes.HasValue ? Math.Max(0, danmakuSizeBytes.Value) : null);
public void RefreshForArtifactDrift(
string providerEndpoint,
string sourceVideoPath,
string targetVideoPath,
long videoSizeBytes,
string? sourceDanmakuPath,
string? targetDanmakuPath,
long? danmakuSizeBytes,
bool deleteLocalFilesAfterUpload,
string? uploadedVideoPath,
string? uploadedDanmakuPath,
DateTimeOffset requestedAt)
{
TrackCleanupPath(uploadedVideoPath, isVideo: true, targetVideoPath);
TrackCleanupPath(uploadedDanmakuPath, isVideo: false, targetDanmakuPath);
RefreshRequest(
providerEndpoint,
sourceVideoPath,
targetVideoPath,
videoSizeBytes,
sourceDanmakuPath,
targetDanmakuPath,
danmakuSizeBytes,
deleteLocalFilesAfterUpload,
requestedAt);
}
public bool HasPendingRemoteCleanup =>
!string.IsNullOrWhiteSpace(PendingCleanupVideoPath) ||
!string.IsNullOrWhiteSpace(PendingCleanupDanmakuPath);
public void MarkRemoteCleanupFailed(string errorMessage, DateTimeOffset nextAttemptAt, DateTimeOffset updatedAt)
{
CleanupErrorMessage = NormalizeNullable(errorMessage);
NextCleanupAttemptAt = nextAttemptAt;
UpdatedAt = updatedAt;
}
public void MarkRemoteCleanupCompleted(DateTimeOffset updatedAt)
{
PendingCleanupVideoPath = null;
PendingCleanupDanmakuPath = null;
CleanupErrorMessage = null;
NextCleanupAttemptAt = null;
UpdatedAt = updatedAt;
}
public void MarkProcessing(DateTimeOffset updatedAt) public void MarkProcessing(DateTimeOffset updatedAt)
{ {
Status = RecordArtifactUploadStatus.Uploading; Status = RecordArtifactUploadStatus.Uploading;
@@ -354,6 +425,24 @@ public sealed class RecordUploadJob
return Math.Clamp(VideoSizeBytes * 100d / totalSize, 0, 100); return Math.Clamp(VideoSizeBytes * 100d / totalSize, 0, 100);
} }
private void TrackCleanupPath(string? uploadedPath, bool isVideo, string? replacementPath)
{
var normalized = NormalizeNullable(uploadedPath);
if (normalized is null || string.Equals(normalized, replacementPath, StringComparison.Ordinal))
{
return;
}
if (isVideo)
{
PendingCleanupVideoPath = normalized;
}
else
{
PendingCleanupDanmakuPath = normalized;
}
}
private static string NormalizeRequired(string value) private static string NormalizeRequired(string value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
@@ -142,6 +142,9 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
builder.Property(static x => x.ExternalTaskId).HasMaxLength(128); builder.Property(static x => x.ExternalTaskId).HasMaxLength(128);
builder.Property(static x => x.ExternalTaskType).HasMaxLength(32); builder.Property(static x => x.ExternalTaskType).HasMaxLength(32);
builder.Property(static x => x.TransferTargetPath).HasMaxLength(2048); builder.Property(static x => x.TransferTargetPath).HasMaxLength(2048);
builder.Property(static x => x.PendingCleanupVideoPath).HasMaxLength(2048);
builder.Property(static x => x.PendingCleanupDanmakuPath).HasMaxLength(2048);
builder.Property(static x => x.CleanupErrorMessage).HasMaxLength(4096);
builder.Property(static x => x.ErrorMessage).HasMaxLength(4096); builder.Property(static x => x.ErrorMessage).HasMaxLength(4096);
builder.HasIndex(static x => x.RecordTaskId).IsUnique(); builder.HasIndex(static x => x.RecordTaskId).IsUnique();
builder.HasIndex(static x => new { x.Status, x.NextAttemptAt, x.RequestedAt }); builder.HasIndex(static x => new { x.Status, x.NextAttemptAt, x.RequestedAt });
@@ -0,0 +1,28 @@
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiveRecorder.Infrastructure.Persistence.Migrations;
[DbContext(typeof(LiveRecorderDbContext))]
[Migration("20260813214000_AddUploadReplacementCleanup")]
public sealed class AddUploadReplacementCleanup : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>("CleanupErrorMessage", "RecordUploadJobs", type: "character varying(4096)", maxLength: 4096, nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>("NextCleanupAttemptAt", "RecordUploadJobs", type: "timestamp with time zone", nullable: true);
migrationBuilder.AddColumn<string>("PendingCleanupDanmakuPath", "RecordUploadJobs", type: "character varying(2048)", maxLength: 2048, nullable: true);
migrationBuilder.AddColumn<string>("PendingCleanupVideoPath", "RecordUploadJobs", type: "character varying(2048)", maxLength: 2048, nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn("CleanupErrorMessage", "RecordUploadJobs");
migrationBuilder.DropColumn("NextCleanupAttemptAt", "RecordUploadJobs");
migrationBuilder.DropColumn("PendingCleanupDanmakuPath", "RecordUploadJobs");
migrationBuilder.DropColumn("PendingCleanupVideoPath", "RecordUploadJobs");
}
}
@@ -542,6 +542,10 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
b.Property<DateTimeOffset?>("CompletedAt") b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("CleanupErrorMessage")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<int>("CurrentArtifact") b.Property<int>("CurrentArtifact")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -572,6 +576,17 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
b.Property<DateTimeOffset?>("NextAttemptAt") b.Property<DateTimeOffset?>("NextAttemptAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("NextCleanupAttemptAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PendingCleanupDanmakuPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PendingCleanupVideoPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<double>("ProgressPercent") b.Property<double>("ProgressPercent")
.HasColumnType("double precision"); .HasColumnType("double precision");
@@ -757,5 +757,10 @@ public sealed class UserSessionRepository : IUserSessionRepository
public Task AddAsync(UserSession session, CancellationToken cancellationToken = default) => public Task AddAsync(UserSession session, CancellationToken cancellationToken = default) =>
_dbContext.UserSessions.AddAsync(session, cancellationToken).AsTask(); _dbContext.UserSessions.AddAsync(session, cancellationToken).AsTask();
public async Task<IReadOnlyList<UserSession>> ListActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) =>
await _dbContext.UserSessions
.Where(item => item.UserAccountId == userId && item.RevokedAt == null)
.ToListAsync(cancellationToken);
public void Update(UserSession session) => _dbContext.UserSessions.Update(session); public void Update(UserSession session) => _dbContext.UserSessions.Update(session);
} }
@@ -60,6 +60,11 @@ public interface IOpenListClient
string sourcePath, string sourcePath,
string targetDirectory, string targetDirectory,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task DeleteFileAsync(
OpenListConnectionRequest connection,
string path,
CancellationToken cancellationToken = default);
} }
public sealed record OpenListObjectInfo( public sealed record OpenListObjectInfo(
@@ -540,6 +545,26 @@ public sealed class OpenListClient : IOpenListClient
EnsureSuccess(envelope, $"OpenList move '{sourcePath}' to '{targetDirectory}'"); EnsureSuccess(envelope, $"OpenList move '{sourcePath}' to '{targetDirectory}'");
} }
public async Task DeleteFileAsync(
OpenListConnectionRequest connection,
string path,
CancellationToken cancellationToken = default)
{
path = NormalizePath(path);
var existing = await TryGetObjectAsync(connection, path, cancellationToken);
if (existing is null) return;
if (existing.IsDirectory) throw new InvalidOperationException($"OpenList cleanup path is a directory: '{path}'.");
var envelope = await SendAuthorizedAsync(
connection,
() => CreateJsonRequest(
HttpMethod.Post,
"/api/fs/remove",
new { dir = GetDirectoryName(path), names = new[] { GetFileName(path) } }),
cancellationToken);
EnsureSuccess(envelope, $"OpenList remove '{path}'");
}
public static string NormalizeBaseUrl(string baseUrl) public static string NormalizeBaseUrl(string baseUrl)
{ {
if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri) || if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri) ||
@@ -316,6 +316,24 @@ public sealed class OpenListUploadQueueService
return false; return false;
} }
var cleanupJob = await _dbContext.RecordUploadJobs
.Include(static item => item.RecordTask)
.Where(item => item.Status == RecordArtifactUploadStatus.Succeeded &&
(item.PendingCleanupVideoPath != null || item.PendingCleanupDanmakuPath != null) &&
(!item.NextCleanupAttemptAt.HasValue || item.NextCleanupAttemptAt <= now))
.OrderBy(static item => item.NextCleanupAttemptAt)
.FirstOrDefaultAsync(cancellationToken);
if (cleanupJob is not null)
{
await TryCleanupReplacedRemoteArtifactsAsync(cleanupJob, new OpenListConnectionRequest
{
BaseUrl = cleanupJob.ProviderEndpoint,
Username = settings.OpenListUpload.Username,
Password = settings.OpenListUpload.Password
}, cancellationToken);
return true;
}
var jobs = _dbContext.RecordUploadJobs var jobs = _dbContext.RecordUploadJobs
.Include(static item => item.RecordTask) .Include(static item => item.RecordTask)
.ThenInclude(static item => item!.Result) .ThenInclude(static item => item!.Result)
@@ -380,6 +398,11 @@ public sealed class OpenListUploadQueueService
return true; return true;
} }
if (await RefreshJobForCurrentArtifactsAsync(job, result, settings, connection, cancellationToken))
{
return true;
}
var validationError = await GetUploadValidationErrorAsync( var validationError = await GetUploadValidationErrorAsync(
job.RecordTask, job.RecordTask,
NormalizeAbsolutePath(result.FilePath), NormalizeAbsolutePath(result.FilePath),
@@ -510,14 +533,35 @@ public sealed class OpenListUploadQueueService
now); now);
await _dbContext.RecordUploadJobs.AddAsync(job, cancellationToken); await _dbContext.RecordUploadJobs.AddAsync(job, cancellationToken);
} }
else if (job.Status == RecordArtifactUploadStatus.Succeeded) else if (job.Status == RecordArtifactUploadStatus.Succeeded &&
!job.HasArtifactDrift(endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes, sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes))
{ {
return SuccessFromExisting(recordTaskId, result); return SuccessFromExisting(recordTaskId, result);
} }
else if (job.Status is RecordArtifactUploadStatus.Queued or RecordArtifactUploadStatus.Uploading or RecordArtifactUploadStatus.WaitingRetry) else if (job.Status is RecordArtifactUploadStatus.Queued or RecordArtifactUploadStatus.Uploading or RecordArtifactUploadStatus.WaitingRetry &&
!job.HasArtifactDrift(endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes, sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes))
{ {
return QueuedResult(recordTaskId, result, job, "该分片已在 OpenList 上传队列中。"); return QueuedResult(recordTaskId, result, job, "该分片已在 OpenList 上传队列中。");
} }
else if (job.HasArtifactDrift(endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes, sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes))
{
if (!string.IsNullOrWhiteSpace(job.ExternalTaskId))
{
var connection = new OpenListConnectionRequest
{
BaseUrl = endpoint,
Username = settings.OpenListUpload.Username,
Password = settings.OpenListUpload.Password
};
_ = await _openListClient.TryCancelCopyTaskAsync(connection, job.ExternalTaskId, cancellationToken);
}
job.RefreshForArtifactDrift(
endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes,
sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes,
settings.DeleteLocalFilesAfterUpload,
result.RemoteVideoPath, result.RemoteDanmakuPath, now);
result.ResetRemoteArtifactsForReplacement(now);
}
else else
{ {
job.RefreshRequest( job.RefreshRequest(
@@ -911,6 +955,14 @@ public sealed class OpenListUploadQueueService
now); now);
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
var settings = await _settingsService.GetAsync(cancellationToken);
await TryCleanupReplacedRemoteArtifactsAsync(job, new OpenListConnectionRequest
{
BaseUrl = job.ProviderEndpoint,
Username = settings.OpenListUpload.Username,
Password = settings.OpenListUpload.Password
}, cancellationToken);
await _systemLogService.WriteAsync( await _systemLogService.WriteAsync(
cleanupWarning is null ? SystemLogLevel.Info : SystemLogLevel.Warning, cleanupWarning is null ? SystemLogLevel.Info : SystemLogLevel.Warning,
"Upload", "Upload",
@@ -923,6 +975,81 @@ public sealed class OpenListUploadQueueService
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
} }
private async Task<bool> RefreshJobForCurrentArtifactsAsync(
RecordUploadJob job,
RecordResult result,
SystemSettingsDto settings,
OpenListConnectionRequest connection,
CancellationToken cancellationToken)
{
var localVideoPath = NormalizeAbsolutePath(result.FilePath);
if (!File.Exists(localVideoPath)) return false;
var outputRoot = Path.GetFullPath(settings.OutputRoot, AppContext.BaseDirectory);
var videoRelativePath = GetSafeRelativePath(outputRoot, localVideoPath);
var sourceVideoPath = OpenListClient.CombinePath(settings.OpenListUpload.SourcePath, videoRelativePath);
var targetVideoPath = OpenListClient.CombinePath(settings.OpenListUpload.DestinationPath, videoRelativePath);
var videoSizeBytes = new FileInfo(localVideoPath).Length;
var localDanmakuPath = NormalizeNullableAbsolutePath(result.DanmakuFilePath);
var hasDanmaku = !string.IsNullOrWhiteSpace(localDanmakuPath) && File.Exists(localDanmakuPath);
var sourceDanmakuPath = hasDanmaku
? OpenListClient.CombinePath(settings.OpenListUpload.SourcePath, GetSafeRelativePath(outputRoot, localDanmakuPath!))
: null;
var targetDanmakuPath = hasDanmaku
? OpenListClient.CombinePath(settings.OpenListUpload.DestinationPath, GetSafeRelativePath(outputRoot, localDanmakuPath!))
: null;
var danmakuSizeBytes = hasDanmaku ? new FileInfo(localDanmakuPath!).Length : (long?)null;
var endpoint = OpenListClient.NormalizeBaseUrl(settings.OpenListUpload.BaseUrl);
if (!job.HasArtifactDrift(endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes, sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes)) return false;
if (!string.IsNullOrWhiteSpace(job.ExternalTaskId))
{
_ = await _openListClient.TryCancelCopyTaskAsync(connection, job.ExternalTaskId, cancellationToken);
}
var now = DateTimeOffset.UtcNow;
job.RefreshForArtifactDrift(
endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes,
sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes,
settings.DeleteLocalFilesAfterUpload,
result.RemoteVideoPath, result.RemoteDanmakuPath, now);
result.ResetRemoteArtifactsForReplacement(now);
result.MarkUploadQueued("openlist", now);
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
private async Task TryCleanupReplacedRemoteArtifactsAsync(
RecordUploadJob job,
OpenListConnectionRequest connection,
CancellationToken cancellationToken)
{
if (!job.HasPendingRemoteCleanup) return;
var settings = await _settingsService.GetAsync(cancellationToken);
var destinationRoot = OpenListClient.NormalizePath(settings.OpenListUpload.DestinationPath).TrimEnd('/') + "/";
try
{
foreach (var path in new[] { job.PendingCleanupVideoPath, job.PendingCleanupDanmakuPath }.Where(static path => !string.IsNullOrWhiteSpace(path)))
{
var normalized = OpenListClient.NormalizePath(path!);
if (!normalized.StartsWith(destinationRoot, StringComparison.Ordinal) ||
string.Equals(normalized, job.TargetVideoPath, StringComparison.Ordinal) ||
string.Equals(normalized, job.TargetDanmakuPath, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Refusing unsafe OpenList cleanup path: '{normalized}'.");
}
await _openListClient.DeleteFileAsync(connection, normalized, cancellationToken);
}
job.MarkRemoteCleanupCompleted(DateTimeOffset.UtcNow);
}
catch (Exception ex)
{
job.MarkRemoteCleanupFailed(ex.Message, DateTimeOffset.UtcNow.Add(CleanupRetryDelay), DateTimeOffset.UtcNow);
await _systemLogService.WriteAsync(SystemLogLevel.Warning, "Upload", "OpenList replaced artifact cleanup will retry.", ex.Message,
job.RecordTask?.LiveRoomId, job.RecordTask?.RecordSessionId, job.RecordTaskId, cancellationToken);
}
await _dbContext.SaveChangesAsync(cancellationToken);
}
private async Task ScheduleRetryOrFailAsync( private async Task ScheduleRetryOrFailAsync(
RecordUploadJob job, RecordUploadJob job,
RecordResult result, RecordResult result,
@@ -0,0 +1,38 @@
using LiveRecorder.Application.Common;
using LiveRecorder.Application.Models.Auth;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Infrastructure.Persistence;
using LiveRecorder.Infrastructure.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Tests;
public sealed class AuthServiceTests
{
[Theory]
[InlineData(false, 12)]
[InlineData(true, 720)]
public async Task Login_UsesExpectedSessionLifetime(bool rememberMe, int expectedHours)
{
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
.UseInMemoryDatabase($"auth-{Guid.NewGuid():N}")
.Options;
await using var context = new LiveRecorderDbContext(options);
var user = new UserAccount("admin", "Admin", PasswordHasher.Hash("secret123"), DateTimeOffset.UtcNow);
context.UserAccounts.Add(user);
await context.SaveChangesAsync();
var service = new AuthService(new UserAccountRepository(context), new UserSessionRepository(context), context);
var before = DateTimeOffset.UtcNow;
var response = await service.LoginAsync(new LoginRequest
{
Username = "admin",
Password = "secret123",
RememberMe = rememberMe
});
var after = DateTimeOffset.UtcNow;
Assert.InRange(response.ExpiresAt, before.AddHours(expectedHours), after.AddHours(expectedHours));
}
}
@@ -434,6 +434,28 @@ public sealed class OpenListUploadTests
Assert.Equal(1, await fixture.Context.RecordUploadJobs.CountAsync()); Assert.Equal(1, await fixture.Context.RecordUploadJobs.CountAsync());
} }
[Fact]
public async Task Queue_RefreshesQueuedJobWhenConsolidationChangesResultPath()
{
await using var fixture = await QueueFixture.CreateAsync();
await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
var originalJob = await fixture.Context.RecordUploadJobs.AsNoTracking().SingleAsync();
var result = await fixture.Context.RecordResults.SingleAsync();
var mergedPath = Path.Combine(Path.GetDirectoryName(result.FilePath)!, "segment-merged.mp4");
await File.WriteAllBytesAsync(mergedPath, Encoding.UTF8.GetBytes("merged-video-content"));
result.Update(mergedPath, new FileInfo(mergedPath).Length, 60, null, 0, RecordTaskStatus.Completed, null);
await fixture.Context.SaveChangesAsync();
Assert.True(await fixture.Queue.ProcessNextAsync());
fixture.Context.ChangeTracker.Clear();
var refreshedJob = await fixture.Context.RecordUploadJobs.SingleAsync();
Assert.Equal("/source/Douyin/2026/08/01/主播/segment-merged.mp4", refreshedJob.SourceVideoPath);
Assert.Equal("/destination/Douyin/2026/08/01/主播/segment-merged.mp4", refreshedJob.TargetVideoPath);
Assert.Equal(RecordArtifactUploadStatus.Queued, refreshedJob.Status);
Assert.NotEqual(originalJob.SourceVideoPath, refreshedJob.SourceVideoPath);
}
[Fact] [Fact]
public async Task Queue_RenamesSameNameConflictWithoutOverwriting() public async Task Queue_RenamesSameNameConflictWithoutOverwriting()
{ {
@@ -906,5 +928,15 @@ public sealed class OpenListUploadTests
Operations.Add($"move:{sourcePath}->{targetDirectory}"); Operations.Add($"move:{sourcePath}->{targetDirectory}");
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task DeleteFileAsync(
OpenListConnectionRequest connection,
string path,
CancellationToken cancellationToken = default)
{
Operations.Add($"delete:{path}");
Objects.Remove(path);
return Task.CompletedTask;
}
} }
} }