Files
douyin/service/DouyinVideoService.cs

902 lines
44 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ClockSnowFlake;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.repository;
using dy.net.utils;
using Serilog;
using SqlSugar;
using dy.net.storage;
using Newtonsoft.Json;
using MediaStorageType = dy.net.model.dto.StorageType;
namespace dy.net.service
{
public class DouyinVideoService
{
private readonly ISqlSugarClient sqlSugarClient;
private readonly DouyinVideoRepository _dyCollectVideoRepository;
private readonly DouyinCookieRepository douyinCookieRepository;
private readonly MediaStorageRouter _storageRouter;
private readonly VideoTaskService _videoTasks;
public DouyinVideoService(DouyinVideoRepository dyCollectVideoRepository, DouyinCookieRepository douyinCookieRepository, ISqlSugarClient sqlSugarClient, MediaStorageRouter storageRouter, VideoTaskService videoTasks)
{
_dyCollectVideoRepository = dyCollectVideoRepository;
this.douyinCookieRepository = douyinCookieRepository;
this.sqlSugarClient = sqlSugarClient;
_storageRouter = storageRouter;
_videoTasks = videoTasks;
}
public async Task<StorageRecordInventory> GetStorageInventoryAsync(MediaStorageType currentStorageType)
{
var records = await sqlSugarClient.Queryable<DouyinVideo>()
.Select(x => new DouyinVideo { StorageType = x.StorageType, FileSize = x.FileSize })
.ToListAsync();
var local = records.Where(x => x.StorageType == MediaStorageType.Local).ToList();
var webDav = records.Where(x => x.StorageType == MediaStorageType.WebDav).ToList();
var openList = records.Where(x => x.StorageType == MediaStorageType.OpenList).ToList();
var currentCount = currentStorageType switch
{
MediaStorageType.WebDav => webDav.Count,
MediaStorageType.OpenList => openList.Count,
_ => local.Count
};
return new StorageRecordInventory
{
CurrentStorageType = currentStorageType,
TotalRecordCount = records.Count,
LocalRecordCount = local.Count,
WebDavRecordCount = webDav.Count,
OpenListRecordCount = openList.Count,
CurrentStorageRecordCount = currentCount,
OtherStorageRecordCount = records.Count - currentCount,
LocalDeclaredBytes = local.Sum(x => x.FileSize),
WebDavDeclaredBytes = webDav.Sum(x => x.FileSize),
OpenListDeclaredBytes = openList.Sum(x => x.FileSize)
};
}
public async Task<bool> DeleteById(string Id)
{
return await _dyCollectVideoRepository.DeleteByIdAsync(Id);
}
public async Task<bool> BatchInsertOrUpdate(List<DouyinVideo> videos)
{
// 边界处理:传入列表为空直接返回成功
if (videos == null || !videos.Any())
return true;
// 1. 提取所有AwemeId(无需去重,用户保证无重复)
var allAwemeIds = videos.Select(v => v.AwemeId).ToList();
// 2. 查询数据库中已存在的视频记录(用于后续更新)
var existingVideos = await _dyCollectVideoRepository
.Query(x => allAwemeIds.Contains(x.AwemeId))
.ToListAsync();
// 3. 分拆数据集:不存在的(插入)、已存在的(更新)
var existingAwemeIdSet = existingVideos.Select(v => v.AwemeId).ToHashSet();
var videosToInsert = videos
.Where(v => !existingAwemeIdSet.Contains(v.AwemeId))
.ToList();
var videosToUpdate = videos
.Where(v => existingAwemeIdSet.Contains(v.AwemeId))
.ToList();
// 4. 事务包裹:确保插入/更新原子性
var transaction = await _dyCollectVideoRepository.UseTranAsync(async () =>
{
int insertedCount = 0;
int updatedCount = 0;
// 5. 批量插入新记录
if (videosToInsert.Any())
{
insertedCount = await _dyCollectVideoRepository.InsertRangeAsync(videosToInsert);
}
// 6. 批量更新已存在记录(核心逻辑)
if (videosToUpdate.Any())
{
// 建立AwemeId与待更新数据的映射(O(1)匹配效率)
var updateMap = videosToUpdate.ToDictionary(v => v.AwemeId);
// 遍历已存在实体,赋值需要更新的字段
List<DouyinVideo> updates = new List<DouyinVideo>();
foreach (var existingVideo in existingVideos)
{
if (updateMap.TryGetValue(existingVideo.AwemeId, out var updateData))
{
// 保留原记录主键,并把实际主键回写给任务条目使用。
updateData.Id = existingVideo.Id;
existingVideo.VideoSavePath = updateData.VideoSavePath;
existingVideo.VideoCoverSavePath = updateData.VideoCoverSavePath;
existingVideo.ViedoType = updateData.ViedoType;
existingVideo.StorageType = updateData.StorageType;
existingVideo.FileSize = updateData.FileSize;
existingVideo.FileHash = updateData.FileHash;
existingVideo.Resolution = updateData.Resolution;
existingVideo.DynamicVideos = updateData.DynamicVideos;
existingVideo.OnlyImgOrOnlyMp3 = updateData.OnlyImgOrOnlyMp3;
existingVideo.IsMergeVideo = updateData.IsMergeVideo;
existingVideo.VideoUrl = updateData.VideoUrl;
existingVideo.AuthorAvatar = updateData.AuthorAvatar;
existingVideo.CateId = updateData.CateId;
existingVideo.CateXId = updateData.CateXId;
existingVideo.SyncTime = updateData.SyncTime;
}
}
// 批量更新数据库
updatedCount = await _dyCollectVideoRepository.UpdateRangeAsync(existingVideos);
}
//foreach (var item in videos.Where(x=>x.ViedoType==VideoTypeEnum.dy_collects||x.ViedoType == VideoTypeEnum.dy_favorite).GroupBy(x => x.AuthorId))
//{
//}
}, ex =>
{
Serilog.Log.Error(ex, "批量插入/更新抖音视频失败,AwemeIds{AwemeIds}", string.Join(",", allAwemeIds));
});
return transaction;
}
/// <summary>
/// 普通同步已经把同一作品提交到远端存储后,安全清理不再被本地记录引用的旧文件。
/// 数据库或远端主媒体未验证通过时不会删除任何本地文件。
/// </summary>
public async Task<int> CleanupSupersededLocalArtifactsAsync(DouyinVideo snapshot, string replacementPath)
{
if (snapshot == null || snapshot.StorageType != MediaStorageType.Local
|| string.IsNullOrWhiteSpace(snapshot.AwemeId) || string.IsNullOrWhiteSpace(replacementPath)) return 0;
var current = await sqlSugarClient.Queryable<DouyinVideo>()
.Where(x => x.AwemeId == snapshot.AwemeId).FirstAsync();
if (current == null || !current.StorageType.IsRemote()
|| !string.Equals(current.VideoSavePath, replacementPath, StringComparison.Ordinal))
throw new InvalidOperationException("新存储记录尚未提交,保留旧本地文件。");
var remoteLength = await _storageRouter.Resolve(current.StorageType).GetLengthAsync(replacementPath);
if (!remoteLength.HasValue || remoteLength.Value <= 0)
throw new InvalidOperationException("新存储主媒体不存在或为空,保留旧本地文件。");
var cookie = await sqlSugarClient.Queryable<DouyinCookie>().InSingleAsync(snapshot.CookieId);
var roots = StorageMigrationPathPolicy.GetLocalRoots(cookie);
if (roots.Count == 0) throw new InvalidOperationException("旧存储根目录不可用,保留旧本地文件。");
var localVideos = await sqlSugarClient.Queryable<DouyinVideo>()
.Where(x => x.StorageType == MediaStorageType.Local).ToListAsync();
var deleted = 0;
foreach (var candidate in BuildLocalCleanupCandidates(snapshot))
{
if (!File.Exists(candidate.Path)) continue;
if (!SafeLocalMigrationFile.TryResolve(candidate.Path, roots, out var safePath, out var error))
throw new InvalidOperationException($"拒绝清理不安全旧路径 {candidate.Path}{error}");
if (IsLocalPathReferenced(candidate.Path, localVideos, snapshot.Id, candidate.Shared)) continue;
File.Delete(safePath);
deleted++;
}
DeleteEmptyParents(snapshot.VideoSavePath, roots);
return deleted;
}
public async Task<int> RetryTaskItemLocalCleanupAsync(string taskId, string itemId)
{
var task = await sqlSugarClient.Queryable<VideoDownloadTask>().InSingleAsync(taskId)
?? throw new KeyNotFoundException("任务不存在。");
if (task.Type != VideoTaskType.Sync)
throw new InvalidOperationException("仅普通同步任务支持重试旧本地文件清理。");
var item = await sqlSugarClient.Queryable<VideoDownloadTaskItem>().InSingleAsync(itemId)
?? throw new KeyNotFoundException("任务条目不存在。");
if (item.TaskId != taskId || !VideoTaskService.CanRetryCleanup(item))
throw new InvalidOperationException("该条目当前没有可重试的旧本地文件清理。");
DouyinVideo replacement;
try
{
replacement = JsonConvert.DeserializeObject<DouyinVideo>(item.RetrySnapshotJson);
}
catch (JsonException ex)
{
throw new InvalidOperationException("旧本地记录快照损坏,无法安全重试清理。", ex);
}
var snapshot = replacement?.SupersededStorageSnapshot;
if (snapshot == null || snapshot.StorageType != MediaStorageType.Local)
throw new InvalidOperationException("任务没有可验证的旧本地记录快照,拒绝清理。");
var current = !string.IsNullOrWhiteSpace(item.VideoId)
? await sqlSugarClient.Queryable<DouyinVideo>().InSingleAsync(item.VideoId)
: null;
current ??= await sqlSugarClient.Queryable<DouyinVideo>()
.Where(x => x.AwemeId == item.AwemeId).FirstAsync();
if (current == null)
throw new InvalidOperationException("当前视频记录不存在,拒绝清理旧文件。");
try
{
var deleted = await CleanupSupersededLocalArtifactsAsync(snapshot, current.VideoSavePath);
item.CleanupPending = false;
item.CleanupError = null;
item.WarningMessage = RemoveCleanupWarning(item.WarningMessage);
item.Stage = string.IsNullOrWhiteSpace(item.WarningMessage)
? VideoTaskItemStage.Succeeded
: VideoTaskItemStage.SucceededWithWarnings;
item.UpdatedAt = DateTime.Now;
await sqlSugarClient.Updateable(item).ExecuteCommandAsync();
await _videoTasks.RefreshCountsAsync(taskId);
return deleted;
}
catch (Exception ex)
{
var reason = ex.GetBaseException().Message;
item.CleanupPending = true;
item.CleanupError = reason;
item.UpdatedAt = DateTime.Now;
await sqlSugarClient.Updateable(item).ExecuteCommandAsync();
await _videoTasks.RefreshCountsAsync(taskId);
throw new InvalidOperationException($"旧本地文件清理仍失败:{reason}", ex);
}
}
internal static string RemoveCleanupWarning(string warning)
{
if (string.IsNullOrWhiteSpace(warning)) return null;
var failureIndex = warning.IndexOf("旧本地文件清理失败", StringComparison.Ordinal);
if (failureIndex < 0) return warning.Trim();
var start = warning.LastIndexOf(";已切换到", failureIndex, StringComparison.Ordinal);
if (start >= 0) return warning[..start].Trim().TrimEnd('');
start = warning.LastIndexOf("已切换到", failureIndex, StringComparison.Ordinal);
if (start == 0) return null;
return warning[..failureIndex].Trim().TrimEnd('', '', ',');
}
private static List<LocalCleanupCandidate> BuildLocalCleanupCandidates(DouyinVideo snapshot)
{
var result = new List<LocalCleanupCandidate>
{
new(snapshot.VideoSavePath, false),
new(snapshot.VideoCoverSavePath, snapshot.ViedoType is VideoTypeEnum.dy_mix or VideoTypeEnum.dy_series),
new(snapshot.AuthorAvatar, true)
};
if (!string.IsNullOrWhiteSpace(snapshot.VideoSavePath))
{
var directory = Path.GetDirectoryName(snapshot.VideoSavePath);
var basename = Path.GetFileNameWithoutExtension(snapshot.VideoSavePath);
result.Add(new LocalCleanupCandidate(Path.Combine(directory ?? string.Empty, basename + ".nfo"), false));
if (snapshot.ViedoType is VideoTypeEnum.dy_mix or VideoTypeEnum.dy_series)
result.Add(new LocalCleanupCandidate(Path.Combine(directory ?? string.Empty, "tvshow.nfo"), true));
}
if (!string.IsNullOrWhiteSpace(snapshot.DynamicVideos))
{
try
{
foreach (var attachment in JsonConvert.DeserializeObject<List<DouyinMergeVideoDto>>(snapshot.DynamicVideos) ?? new())
result.Add(new LocalCleanupCandidate(attachment.Path, false));
}
catch (JsonException) { }
}
return result.Where(x => !string.IsNullOrWhiteSpace(x.Path)).DistinctBy(x => x.Path).ToList();
}
private static bool IsLocalPathReferenced(string path, IEnumerable<DouyinVideo> localVideos, string excludedVideoId, bool shared)
{
foreach (var video in localVideos.Where(x => x.Id != excludedVideoId))
{
if (path == video.VideoSavePath || path == video.VideoCoverSavePath || path == video.AuthorAvatar) return true;
if (shared && !string.IsNullOrWhiteSpace(video.VideoSavePath)
&& string.Equals(Path.GetDirectoryName(video.VideoSavePath), Path.GetDirectoryName(path), StringComparison.Ordinal)) return true;
if (!string.IsNullOrWhiteSpace(video.DynamicVideos) && video.DynamicVideos.Contains(path, StringComparison.Ordinal)) return true;
}
return false;
}
private static void DeleteEmptyParents(string filePath, IReadOnlyList<string> roots)
{
if (string.IsNullOrWhiteSpace(filePath)) return;
var directory = Path.GetDirectoryName(Path.GetFullPath(filePath));
var root = roots.Where(x => SafeLocalMigrationFile.IsWithin(filePath, x))
.OrderByDescending(x => x.Length).FirstOrDefault();
while (!string.IsNullOrWhiteSpace(directory) && !string.Equals(directory, root, StringComparison.Ordinal)
&& Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory, false);
directory = Path.GetDirectoryName(directory);
}
}
private sealed record LocalCleanupCandidate(string Path, bool Shared);
public async Task<bool> UpdateOne(DouyinVideo video)
{
return await _dyCollectVideoRepository.UpdateAsync(video);
}
public async Task<VideoStaticsDto> GetStatics()
{
List<DouyinVideo> list = await this._dyCollectVideoRepository.GetAllAsync();
if (!list.Any())
return new VideoStaticsDto();
var Categories = list.GroupBy(x => x.Tag1).Select(x => new VideoStaticsItemDto { Name = x.Key, Count = x.LongCount() }).OrderByDescending(p => p.Count).ToList();
Categories.Where(x => string.IsNullOrWhiteSpace(x.Name)).ToList().ForEach(x => x.Name = "其他");
var data = new VideoStaticsDto
{
AuthorCount = list.Select(x => x.AuthorId).Distinct().Count(),
CategoryCount = list.Select(x => x.Tag1).Distinct().Count(),
VideoCount = list.Count,
Categories = Categories,
FavoriteCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_favorite),
CollectCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_collects || x.ViedoType == VideoTypeEnum.dy_custom_collect),
FollowCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_follows),
GraphicVideoCount = list.Count(x => x.IsMergeVideo == 1),
MixCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_mix),
SeriesCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_series),
VideoSizeTotal = DouyinFileUtils.ConvertBytesToGb(list.Sum(x => x.FileSize)),
VideoFavoriteSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_favorite).Sum(x => x.FileSize)),
VideoCollectSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_collects || x.ViedoType == VideoTypeEnum.dy_custom_collect).Sum(x => x.FileSize)),
VideoFollowSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_follows).Sum(x => x.FileSize)),
VideoMixSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_mix).Sum(x => x.FileSize)),
VideoSeriesSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_series).Sum(x => x.FileSize)),
GraphicVideoSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.IsMergeVideo == 1).Sum(x => x.FileSize)),
//TotalDiskSize= ByteToGbConverter.GetHostTotalDiskSpaceGB(),
};
if (data.GraphicVideoSize == "0.00")
{
if (list.Where(x => x.IsMergeVideo == 1).Sum(x => x.FileSize) > 0)
{
data.GraphicVideoSize = "<0.01";//避免显示0.00误导用户
}
}
if (data.VideoFavoriteSize == "0.00")
{
data.VideoFavoriteSize = "<0.01";//避免显示0.00误导用户
}
if (data.VideoCollectSize == "0.00")
{
data.VideoCollectSize = "<0.01";//避免显示0.00误导用户
}
if (data.VideoFollowSize == "0.00")
{
data.VideoFollowSize = "<0.01";//避免显示0.00误导用户
}
if (data.VideoMixSize == "0.00")
{
data.VideoMixSize = "<0.01";//避免显示0.00误导用户
}
if (data.VideoSeriesSize == "0.00")
{
data.VideoSeriesSize = "<0.01";//避免显示0.00误导用户
}
data.Authors = list.GroupBy(x => x.Author).Select(x => new VideoStaticsItemDto
{
Name = x.Key,
Count = x.LongCount(),
Icon = x.LastOrDefault()?.AuthorAvatarUrl,
UperId = x.LastOrDefault()?.AuthorId ?? x.LastOrDefault()?.DyUserId
}).OrderByDescending(d => d.Count).ToList();
return data;
}
/// <summary>
///
/// </summary>
/// <param name="awemeId"></param>
/// <returns></returns>
public async Task<DouyinVideo> GetByAwemeId(string awemeId)
{
return await _dyCollectVideoRepository.GetFirstAsync(x => x.AwemeId == awemeId);
}
/// <summary>
///
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
public async Task<(List<DouyinVideo> list, int totalCount)> GetPagedAsync(DouyinVideoPageRequestDto dto)
{
return await _dyCollectVideoRepository.GetPagedAsync(dto);
}
public async Task<List<DouyinVideo>> GetAllAsync()
{
return await _dyCollectVideoRepository.GetAllAsync();
}
/// <summary>
/// 关注的博主的视频如果配置为视频标题作为文件名,生成文件名
/// </summary>
/// <param name="AuthorId"></param>
/// <param name="ViedoNameSimplify"></param>
/// <returns></returns>
public (string, string) GetUperLastViedoFileName(string AuthorId, string ViedoNameSimplify)
{
return _dyCollectVideoRepository.GetUperLastViedoFileName(AuthorId, ViedoNameSimplify);
}
/// <summary>
/// 根据ID获取视频信息
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
public async Task<DouyinVideo> GetById(string id)
{
return await _dyCollectVideoRepository.GetByIdAsync(id);
}
/// <summary>
/// 重新下载选中的视频
/// </summary>
/// <param name="dto">重新下载请求DTO(包含待处理视频ID列表)</param>
/// <returns>是否执行成功(true=流程执行完成,false=无有效数据或执行失败)</returns>
/// <exception cref="ArgumentNullException">DTO或ID列表为空时抛出</exception>
/// <exception cref="IOException">文件操作失败时抛出(可根据业务调整处理方式)</exception>
public async Task<bool> ReDownloadViedoAsync(ReDownViedoDto dto, bool forever = false)
{
// 1. 严格参数校验(避免无效流程)
if (dto == null)
throw new ArgumentNullException(nameof(dto), "重新下载请求DTO不能为空");
if (dto.Ids == null || !dto.Ids.Any())
{
Serilog.Log.Error("重新下载视频失败:待处理视频ID列表为空");
return false;
}
// 2. 查询有效视频记录(去重+非空校验,避免无效处理)
var videoIds = dto.Ids.Distinct().ToList(); // 去重,减少数据库查询和操作
var videos = await _dyCollectVideoRepository.GetByIds(videoIds);
if (videos == null || !videos.Any())
{
Serilog.Log.Debug("未查询到有效视频记录:Ids={0}", string.Join(",", videoIds));
return false;
}
// Permanent deletion is an explicit, separate operation. A normal re-download never removes
// the database row or old media first; VideoRedownloadWorker replaces the main file atomically.
if (forever)
{
foreach (var video in videos)
{
try
{
var storage = _storageRouter.Resolve(video.StorageType);
if (video.StorageType.IsRemote())
await StorageArtifactCleaner.DeleteWebDavArtifactsAsync(storage, video);
else
{
if (!string.IsNullOrWhiteSpace(video.VideoSavePath)) await storage.DeleteAsync(video.VideoSavePath);
if (!string.IsNullOrWhiteSpace(video.VideoCoverSavePath)) await storage.DeleteAsync(video.VideoCoverSavePath);
}
if (!await _dyCollectVideoRepository.DeleteByIdAsync(video.Id)) return false;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "永久删除视频失败,数据库记录已保留:{VideoId}", video.Id);
return false;
}
}
return true;
}
// 3. 构建重新下载记录(提前准备数据,避免事务内耗时操作)
var reDownList = new List<DouyinReDownload>();
foreach (var video in videos)
{
// 跳过无保存路径的视频(避免创建无效任务)
if (string.IsNullOrWhiteSpace(video.VideoSavePath))
{
Serilog.Log.Debug("视频无保存路径,跳过重新下载:VideoId={0}", video.Id);
continue;
}
// 构建重新下载记录
reDownList.Add(new DouyinReDownload
{
Id = Guid.NewGuid().ToString("N"),
CreateTime = DateTime.UtcNow, // 统一使用UTC时间,避免时区问题
Status = 0, // 0=待下载(建议用枚举替代魔法值)
SavePath = video.VideoSavePath,
ViedoId = video.AwemeId,
CookieId = video.CookieId,
VideoRecordId = video.Id,
UpdateTime = DateTime.UtcNow
});
}
// 无有效重新下载记录时直接返回
if (!reDownList.Any())
{
Serilog.Log.Debug("无有效重新下载记录需要创建:VideoIds={0}", string.Join(",", videoIds));
return false;
}
try
{
var videosById = videos.ToDictionary(x => x.Id);
var groups = reDownList.GroupBy(x => videosById[x.VideoRecordId].StorageType).ToList();
var transaction = await sqlSugarClient.Ado.UseTranAsync(async () =>
{
foreach (var group in groups)
{
var jobs = group.ToList();
var storageLabel = group.Key switch
{
MediaStorageType.WebDav => "WebDAV",
MediaStorageType.OpenList => "OpenList",
_ => "本地"
};
var title = groups.Count == 1
? $"重新下载({jobs.Count} 条)"
: $"重新下载 · {storageLabel}{jobs.Count} 条)";
var task = await _videoTasks.CreateTaskAsync(VideoTaskType.Redownload,
VideoTaskTrigger.UserAction, title, storageType: group.Key);
foreach (var job in jobs)
{
var video = videosById[job.VideoRecordId];
var item = await _videoTasks.AddItemAsync(task.Id, video.AwemeId, video.CookieId, null,
video.ViedoType, video.VideoTitle, video.Author, video.VideoSavePath,
new[] { video.VideoUrl }, video, video.FileSize);
job.TaskId = task.Id;
job.TaskItemId = item.Id;
}
if (await sqlSugarClient.Insertable(jobs).ExecuteCommandAsync() != jobs.Count)
throw new InvalidOperationException($"{storageLabel}重新下载队列写入不完整。");
}
});
if (!transaction.IsSuccess)
throw new InvalidOperationException("创建重新下载任务失败:" + transaction.ErrorMessage,
transaction.ErrorException);
//var CookieIds = reDownList.Select(x => x.CookieId).Distinct();
//foreach (var ck in CookieIds)
//{
// var cookie = douyinCookieRepository.GetById(ck);
// if (cookie == null)
// continue;
// var viedoTypes = videos.Where(x => x.CookieId == ck).Select(x => x.ViedoType).Distinct();
// //if (viedoTypes != null && viedoTypes.Any())
// //{
// // foreach (VideoTypeEnum item in viedoTypes)
// // {
// // switch (item)
// // {
// // case VideoTypeEnum.dy_favorite:
// // cookie.FavHasSyncd = 0;
// // break;
// // case VideoTypeEnum.dy_collects:
// // cookie.CollHasSyncd = 0;
// // break;
// // case VideoTypeEnum.dy_follows:
// // cookie.UperSyncd = 0;
// // break;
// // case VideoTypeEnum.ImageVideo:
// // break;
// // default:
// // break;
// // }
// // }
// //}
// await douyinCookieRepository.UpdateAsync(cookie);
//}
Serilog.Log.Debug("安全重新下载任务已创建:{0} 条;旧记录和旧文件会保留到新主媒体完整写入。", reDownList.Count);
return true;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "重新下载视频执行失败:Ids={0}", string.Join(",", videoIds));
return false;
}
}
public async Task<List<DouyinVideoTopDto>> GetLastSyncTop(int top = 5)
{
return await _dyCollectVideoRepository.GetTopsOrderBySyncTime(top);
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public async Task<List<VideoChartItemDto>> GetChartData(int day = 7)
{
var date = DateTime.Now.AddDays(-day);
var list = await _dyCollectVideoRepository.GetListAsync(x => x.SyncTime > date);
var resultData = list.GroupBy(x => x.SyncTime.ToString("yyyyMMdd")).Select(g => new VideoChartItemDto
{
Date = g.Key,
Collect = g.Count(x => x.ViedoType == VideoTypeEnum.dy_collects || x.ViedoType == VideoTypeEnum.dy_custom_collect),
Favorite = g.Count(x => x.ViedoType == VideoTypeEnum.dy_favorite),
Follow = g.Count(x => x.ViedoType == VideoTypeEnum.dy_follows),
Graphic = g.Count(x => string.IsNullOrEmpty(x.FileHash)),
Mix = g.Count(x => x.ViedoType == VideoTypeEnum.dy_mix),
Series = g.Count(x => x.ViedoType == VideoTypeEnum.dy_series),
})
.ToList();
return resultData;
}
/// <summary>
/// 删除无效记录(记录存在,用户手动把目录下的视频删了的情况,视频记录依然存在)
/// </summary>
/// <returns></returns>
public async Task<List<DeleteInvalidVideoDto>> DeleteInvalidVideo()
{
var videos = await _dyCollectVideoRepository.GetAllAsync();
List<DeleteInvalidVideoDto> vList = new List<DeleteInvalidVideoDto>();
List<string> douyinVideoIds = new List<string>();
foreach (var v in videos)
{
if (v.OnlyImgOrOnlyMp3 && string.IsNullOrWhiteSpace(v.VideoSavePath)) continue;
if (!await _storageRouter.Resolve(v.StorageType).ExistsAsync(v.VideoSavePath))
{
douyinVideoIds.Add(v.Id);
vList.Add(new DeleteInvalidVideoDto { AwId = v.AwemeId, Title = v.VideoTitle, Path = v.VideoSavePath });
}
}
if (douyinVideoIds.Any())
{
await _dyCollectVideoRepository.DeleteByIdsAsync(douyinVideoIds);
}
return vList;
}
/// <summary>
/// 根据博主ID获取视频列表
/// </summary>
/// <param name="uperUid"></param>
/// <returns></returns>
internal async Task<List<DouyinVideo>> GetByAuthorId(string uperUid)
{
return await _dyCollectVideoRepository.GetListAsync(x => x.DyUserId == uperUid);
}
internal async Task<int> AddDeleteVideo(List<DouyinVideo> videos)
{
var awemeIds = videos.Select(x => x.AwemeId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
var existing = await sqlSugarClient.Queryable<DouyinVideoDelete>()
.Where(x => awemeIds.Contains(x.ViedoId)).Select(x => x.ViedoId).ToListAsync();
var deletes = videos.Where(video => !existing.Contains(video.AwemeId)).Select(video => new DouyinVideoDelete
{
ViedoId = video.AwemeId,
VideoTitle = video.VideoTitle,
VideoSavePath = video.VideoSavePath,
Id = IdGener.GetLong().ToString(),
DeleteTime = DateTime.Now,
CookieId = video.CookieId,
VideoType = video.ViedoType,
AuthorId = video.AuthorId,
Author = video.Author,
VideoUrl = video.VideoUrl,
RestoreSnapshotJson = JsonConvert.SerializeObject(video)
})?.ToList();
if (deletes.Count > 0) await sqlSugarClient.Insertable<DouyinVideoDelete>(deletes).ExecuteCommandAsync();
return existing.Distinct().Count() + deletes.Count;
}
/// <summary>
/// 彻底删除视频
/// </summary>
/// <param name="Ids"></param>
/// <returns></returns>
public async Task<bool> RealDeleteVideos(List<string> Ids)
{
if (Ids == null || !Ids.Any())
return false;
var videos = await _dyCollectVideoRepository.GetListAsync(x => Ids.Contains(x.Id));
if (videos != null && videos.Count > 0)
{
if (videos.Count <= 30)
{
var deletes = await AddDeleteVideo(videos);
if (deletes < videos.Select(x => x.AwemeId).Distinct().Count())
{
Serilog.Log.Error("写入永久排除记录失败,已停止删除媒体");
return false;
}
var result = await ReDownloadViedoAsync(new ReDownViedoDto { Ids = videos.Select(x => x.Id)?.ToList() }, true);
if (result)
{
Serilog.Log.Debug($"批量永久删除博主{videos.FirstOrDefault()?.Author},共{deletes}条记录");
return true;
}
else
{
Serilog.Log.Error($"批量删除{videos.FirstOrDefault()?.Author}视频失败");
return false;
}
}
else
{
Task.Run(async () =>
{
var deletes = await AddDeleteVideo(videos);
if (deletes < videos.Select(x => x.AwemeId).Distinct().Count())
{
Serilog.Log.Error("写入永久排除记录失败,已停止后台删除媒体");
return;
}
var result = await ReDownloadViedoAsync(new ReDownViedoDto { Ids = videos.Select(x => x.Id)?.ToList() }, true);
if (result)
{
Serilog.Log.Debug($"批量永久删除博主{videos.FirstOrDefault()?.Author}{deletes}条记录");
}
else
{
Serilog.Log.Error($"批量删除{videos.FirstOrDefault()?.Author}视频失败");
}
});
return true;
}
}
else
{
Serilog.Log.Error($"没有查询到可删除的视频");
return false;
}
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public async Task<bool> HandOldFolderVideos()
{
// 1. 查询目标数据
var list = await _dyCollectVideoRepository.GetListAsync(x => x.ViedoType == VideoTypeEnum.dy_favorite || x.ViedoType == VideoTypeEnum.dy_collects);
// 缓存已处理的「Tag1+下一级文件夹」组合(避免重复移动)
var processedFolderPairs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in list)
{
// 跳过空值(Tag1/Author为空)
if (string.IsNullOrEmpty(item.Tag1) || string.IsNullOrEmpty(item.Author))
{
Log.Debug($"跳过:Tag1/Author为空,ItemId={item.Id}");
continue;
}
try
{
// 2. 标准化路径 + 拆分路径层级(核心:精准定位Tag1和其下一级文件夹)
string oldVideoPath = item.VideoSavePath;
// 统一分隔符为/,方便拆分层级
string standardPath = oldVideoPath.Replace('\\', '/').Trim('/');
string[] pathSegments = standardPath.Split('/'); // 拆分结果:["app","collect","校园教育","期末复习xxx","文件.mp4"]
// 2.1 找到Tag1在路径中的索引(比如"校园教育"的索引是2
int tag1Index = Array.IndexOf(pathSegments, item.Tag1);
if (tag1Index == -1 || tag1Index + 1 >= pathSegments.Length - 1)
{
Log.Debug($"跳过:无Tag1下一级文件夹,Path={oldVideoPath}Tag1={item.Tag1}");
continue;
}
// 2.2 解析核心路径(关键!)
string tag1FolderRelative = string.Join("/", pathSegments.Take(tag1Index + 1)); // Tag1根目录(相对):app/collect/校园教育
// Tag1根目录完整路径(如 D:/app/collect/校园教育 或 /app/collect/校园教育)
string tag1RootFolderFull = Path.GetFullPath(
Path.Combine(Path.GetPathRoot(oldVideoPath) ?? "",
tag1FolderRelative.Replace('/', Path.DirectorySeparatorChar))
);
string tag1NextLevelFolderName = pathSegments[tag1Index + 1]; // Tag1下一级文件夹名:期末复习xxx
// Tag1下一级文件夹完整路径:app/collect/校园教育/期末复习xxx
string tag1NextLevelFolderFull = Path.GetFullPath(
Path.Combine(tag1RootFolderFull, tag1NextLevelFolderName)
);
// 2.3 拼接新路径(替换Tag1为Author,保留下一级文件夹名)
// 新Author根目录完整路径:app/collect/张三
string authorRootFolderFull = tag1RootFolderFull.Replace(item.Tag1, item.Author);
// 新的下一级文件夹完整路径:app/collect/张三/期末复习xxx
string authorNextLevelFolderFull = Path.GetFullPath(
Path.Combine(authorRootFolderFull, tag1NextLevelFolderName)
);
// 2.4 防重复处理(Tag1下一级文件夹已处理则跳过)
string folderPairKey = $"{tag1NextLevelFolderFull}|{authorNextLevelFolderFull}";
if (processedFolderPairs.Contains(folderPairKey))
{
Log.Debug($"跳过:下一级文件夹已处理,Key={folderPairKey}");
continue;
}
// 3. 核心判断:检查Tag1的下一级文件夹是否有文件(而非Tag1根目录)
if (!Directory.Exists(tag1NextLevelFolderFull))
{
Log.Warning($"跳过:Tag1下一级文件夹不存在,Path={tag1NextLevelFolderFull}");
continue;
}
string[] nextLevelFiles = Directory.GetFiles(tag1NextLevelFolderFull); // 非递归,只查该文件夹下的文件
if (nextLevelFiles.Length == 0)
{
Log.Debug($"跳过:Tag1下一级文件夹无文件,Path={tag1NextLevelFolderFull}");
processedFolderPairs.Add(folderPairKey);
continue;
}
// 4. 移动Tag1的下一级文件夹(保留文件夹名,整体移动到Author目录下)
// 4.1 确保新Author目录存在
Directory.CreateDirectory(authorRootFolderFull);
// 4.2 目标文件夹已存在则跳过(如需覆盖,可删除此行+添加Directory.Delete(authorNextLevelFolderFull, true)
if (Directory.Exists(authorNextLevelFolderFull))
{
Log.Debug($"跳过:目标下一级文件夹已存在,Path={authorNextLevelFolderFull}");
processedFolderPairs.Add(folderPairKey);
continue;
}
// 4.3 移动整个下一级文件夹(保留名称和内部所有文件)
Directory.Move(tag1NextLevelFolderFull, authorNextLevelFolderFull);
Log.Debug($"移动Tag1下一级文件夹成功:{tag1NextLevelFolderFull} → {authorNextLevelFolderFull}");
// 5. 关键新增:检查Tag1根目录是否为空,为空则删除
if (Directory.Exists(tag1RootFolderFull))
{
// 检查Tag1根目录下是否还有任何文件/文件夹
bool isTag1RootEmpty = !Directory.EnumerateFileSystemEntries(tag1RootFolderFull).Any();
if (isTag1RootEmpty)
{
Directory.Delete(tag1RootFolderFull, false); // false=仅删除空目录,避免误删
Log.Debug($"删除空Tag1根目录:{tag1RootFolderFull}");
}
else
{
Log.Debug($"Tag1根目录非空,不删除:{tag1RootFolderFull}");
}
}
// 标记已处理
processedFolderPairs.Add(folderPairKey);
// 6. 更新当前Item的视频路径(替换Tag1为Author,保留后续层级)
string newVideoPath = oldVideoPath.Replace(item.Tag1, item.Author);
item.VideoSavePath = newVideoPath;
Log.Debug($"更新Item路径:{oldVideoPath} → {newVideoPath}");
}
catch (IOException ex)
{
Log.Error(ex, $"移动失败(IO异常),ItemId={item.Id}Path={item.VideoSavePath}");
}
catch (UnauthorizedAccessException ex)
{
Log.Error(ex, $"删除/移动失败(权限不足),ItemId={item.Id}Path={item.VideoSavePath}");
}
catch (Exception ex)
{
Log.Error(ex, $"处理失败(未知错误),ItemId={item.Id}Path={item.VideoSavePath}");
}
}
// 批量更新数据库
if (list.Any())
{
await BatchInsertOrUpdate(list);
Log.Debug($"批量更新数据库完成,共处理{list.Count}条数据");
}
return true;
}
}
}