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 GetStorageInventoryAsync(MediaStorageType currentStorageType) { var records = await sqlSugarClient.Queryable() .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 DeleteById(string Id) { return await _dyCollectVideoRepository.DeleteByIdAsync(Id); } public async Task BatchInsertOrUpdate(List 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 updates = new List(); 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; } /// /// 普通同步已经把同一作品提交到远端存储后,安全清理不再被本地记录引用的旧文件。 /// 数据库或远端主媒体未验证通过时不会删除任何本地文件。 /// public async Task 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() .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().InSingleAsync(snapshot.CookieId); var roots = StorageMigrationPathPolicy.GetLocalRoots(cookie); if (roots.Count == 0) throw new InvalidOperationException("旧存储根目录不可用,保留旧本地文件。"); var localVideos = await sqlSugarClient.Queryable() .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 RetryTaskItemLocalCleanupAsync(string taskId, string itemId) { var task = await sqlSugarClient.Queryable().InSingleAsync(taskId) ?? throw new KeyNotFoundException("任务不存在。"); if (task.Type != VideoTaskType.Sync) throw new InvalidOperationException("仅普通同步任务支持重试旧本地文件清理。"); var item = await sqlSugarClient.Queryable().InSingleAsync(itemId) ?? throw new KeyNotFoundException("任务条目不存在。"); if (item.TaskId != taskId || !VideoTaskService.CanRetryCleanup(item)) throw new InvalidOperationException("该条目当前没有可重试的旧本地文件清理。"); DouyinVideo replacement; try { replacement = JsonConvert.DeserializeObject(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().InSingleAsync(item.VideoId) : null; current ??= await sqlSugarClient.Queryable() .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 BuildLocalCleanupCandidates(DouyinVideo snapshot) { var result = new List { 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>(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 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 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 UpdateOne(DouyinVideo video) { return await _dyCollectVideoRepository.UpdateAsync(video); } public async Task GetStatics() { List 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; } /// /// /// /// /// public async Task GetByAwemeId(string awemeId) { return await _dyCollectVideoRepository.GetFirstAsync(x => x.AwemeId == awemeId); } /// /// /// /// /// public async Task<(List list, int totalCount)> GetPagedAsync(DouyinVideoPageRequestDto dto) { return await _dyCollectVideoRepository.GetPagedAsync(dto); } public async Task> GetAllAsync() { return await _dyCollectVideoRepository.GetAllAsync(); } /// /// 关注的博主的视频如果配置为视频标题作为文件名,生成文件名 /// /// /// /// public (string, string) GetUperLastViedoFileName(string AuthorId, string ViedoNameSimplify) { return _dyCollectVideoRepository.GetUperLastViedoFileName(AuthorId, ViedoNameSimplify); } /// /// 根据ID获取视频信息 /// /// /// public async Task GetById(string id) { return await _dyCollectVideoRepository.GetByIdAsync(id); } /// /// 重新下载选中的视频 /// /// 重新下载请求DTO(包含待处理视频ID列表) /// 是否执行成功(true=流程执行完成,false=无有效数据或执行失败) /// DTO或ID列表为空时抛出 /// 文件操作失败时抛出(可根据业务调整处理方式) public async Task 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(); 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> GetLastSyncTop(int top = 5) { return await _dyCollectVideoRepository.GetTopsOrderBySyncTime(top); } /// /// /// /// public async Task> 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; } /// /// 删除无效记录(记录存在,用户手动把目录下的视频删了的情况,视频记录依然存在) /// /// public async Task> DeleteInvalidVideo() { var videos = await _dyCollectVideoRepository.GetAllAsync(); List vList = new List(); List douyinVideoIds = new List(); 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; } /// /// 根据博主ID获取视频列表 /// /// /// internal async Task> GetByAuthorId(string uperUid) { return await _dyCollectVideoRepository.GetListAsync(x => x.DyUserId == uperUid); } internal async Task AddDeleteVideo(List videos) { var awemeIds = videos.Select(x => x.AwemeId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); var existing = await sqlSugarClient.Queryable() .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(deletes).ExecuteCommandAsync(); return existing.Distinct().Count() + deletes.Count; } /// /// 彻底删除视频 /// /// /// public async Task RealDeleteVideos(List 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; } } /// /// /// /// public async Task HandOldFolderVideos() { // 1. 查询目标数据 var list = await _dyCollectVideoRepository.GetListAsync(x => x.ViedoType == VideoTypeEnum.dy_favorite || x.ViedoType == VideoTypeEnum.dy_collects); // 缓存已处理的「Tag1+下一级文件夹」组合(避免重复移动) var processedFolderPairs = new HashSet(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; } } }