using System.Text.RegularExpressions; using dy.net.model.dto; using dy.net.model.entity; using dy.net.model.response; using dy.net.utils; namespace dy.net.storage { public sealed class StorageMigrationPathPlan { public string VideoPath { get; set; } public string CoverPath { get; set; } public string AvatarPath { get; set; } public string DirectoryPath => StoragePath.DirectoryName(VideoPath); } public static class StorageMigrationPathPolicy { private static readonly Regex EpisodeName = new("^S(?\\d{2})E(?\\d{2,})$", RegexOptions.IgnoreCase | RegexOptions.Compiled); public static StorageMigrationPathPlan Build( DouyinVideo video, DouyinCookie cookie, DouyinCollectCate category, DouyinFollowed followed, AppConfig config, int episodeNumber = 1) { if (video == null) throw new ArgumentNullException(nameof(video)); if (cookie == null) throw new InvalidOperationException($"视频 {video.AwemeId} 缺少所属账号"); var root = GetRemoteRoot(cookie, video.ViedoType); if (string.IsNullOrWhiteSpace(root)) throw new InvalidOperationException($"账号 {cookie.UserName} 未配置 {video.ViedoType} 的远端目标路径"); var extension = NormalizeExtension(Path.GetExtension(video.VideoSavePath)); var awemeId = string.IsNullOrWhiteSpace(video.AwemeId) ? video.Id : video.AwemeId; var titleFolder = DouyinFileNameHelper.SanitizeLinuxFileName(video.VideoTitle, awemeId, true); var authorFolder = DouyinFileNameHelper.SanitizeLinuxFileName(video.Author, video.AuthorId ?? "未知博主", true); string directory; string fileName; switch (video.ViedoType) { case VideoTypeEnum.dy_custom_collect: var customCategory = DouyinFileNameHelper.SanitizeLinuxFileName(category?.SaveFolder, category?.Name ?? "未分类", true); directory = StoragePath.CombineRemote(root, customCategory, titleFolder + "_" + awemeId); fileName = awemeId + extension; break; case VideoTypeEnum.dy_mix: case VideoTypeEnum.dy_series: var seriesFolder = DouyinFileNameHelper.SanitizeLinuxFileName(category?.SaveFolder, category?.Name ?? video.CateXId ?? "未分类", true); directory = StoragePath.CombineRemote(root, seriesFolder); var oldEpisode = EpisodeName.Match(Path.GetFileNameWithoutExtension(video.VideoSavePath) ?? string.Empty); fileName = oldEpisode.Success ? oldEpisode.Value.ToUpperInvariant() + extension : $"S01E{Math.Max(1, episodeNumber):D2}{extension}"; break; case VideoTypeEnum.dy_follows: var followedFolder = string.IsNullOrWhiteSpace(followed?.SavePath) ? authorFolder : followed.SavePath; directory = StoragePath.CombineRemote(root, followedFolder, titleFolder); fileName = BuildFollowedFileName(video, config, extension, awemeId); break; case VideoTypeEnum.dy_favorite: case VideoTypeEnum.dy_collects: default: directory = StoragePath.CombineRemote(root, authorFolder, titleFolder + "_" + awemeId); fileName = awemeId + extension; break; } var videoPath = StoragePath.CombineRemote(directory, fileName); var coverName = video.ViedoType is VideoTypeEnum.dy_mix or VideoTypeEnum.dy_series ? "poster.jpg" : Path.GetFileNameWithoutExtension(fileName) + "-poster.jpg"; return new StorageMigrationPathPlan { VideoPath = videoPath, CoverPath = StoragePath.CombineRemote(directory, coverName), AvatarPath = string.IsNullOrWhiteSpace(video.AuthorAvatar) && string.IsNullOrWhiteSpace(video.AuthorAvatarUrl) ? string.Empty : StoragePath.CombineRemote(root, "author", (string.IsNullOrWhiteSpace(video.AuthorId) ? awemeId : video.AuthorId) + ".jpg") }; } public static StorageMigrationPathPlan Build( Aweme item, VideoTypeEnum type, DouyinCookie cookie, DouyinCollectCate category, DouyinFollowed followed, AppConfig config) { if (item == null) throw new ArgumentNullException(nameof(item)); var bitrate = item.Video?.BitRate?.FirstOrDefault(); var format = string.IsNullOrWhiteSpace(bitrate?.Format) ? "mp4" : bitrate.Format.TrimStart('.'); var createdAt = DateTimeUtil.Convert10BitTimestamp(item.CreateTime); var title = string.IsNullOrWhiteSpace(item.Desc) ? $"{item.Author?.Nickname}-{item.CreateTime}" : item.Desc; var video = new DouyinVideo { ViedoType = type, AwemeId = item.AwemeId, VideoTitle = title, VideoSavePath = $"{item.AwemeId}.{format}", Author = item.Author?.Nickname, AuthorId = item.Author?.Uid, AuthorAvatarUrl = item.Author?.AvatarLarger?.UrlList?.FirstOrDefault() ?? item.Author?.AvatarThumb?.UrlList?.FirstOrDefault(), CreateTime = createdAt, FileHash = bitrate?.PlayAddr?.FileHash, Resolution = bitrate?.PlayAddr == null ? string.Empty : $"{bitrate.PlayAddr.Width}×{bitrate.PlayAddr.Height}" }; var episode = item.MixInfo?.Statis?.CurrentEpisode ?? 1; return Build(video, cookie, category, followed, config, Math.Max(1, episode)); } public static string GetRemoteRoot(DouyinCookie cookie, VideoTypeEnum type) => type switch { VideoTypeEnum.dy_favorite => cookie.WebDavFavoritePath, VideoTypeEnum.dy_follows => cookie.WebDavFollowPath, VideoTypeEnum.dy_mix => cookie.WebDavMixPath, VideoTypeEnum.dy_series => cookie.WebDavSeriesPath, _ => cookie.WebDavCollectPath }; public static IReadOnlyList GetLocalRoots(DouyinCookie cookie) { if (cookie == null) return Array.Empty(); return new[] { cookie.SavePath, cookie.FavSavePath, cookie.UpSavePath, cookie.MixPath, cookie.SeriesPath } .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(Path.GetFullPath) .Distinct(StringComparer.Ordinal) .ToArray(); } private static string BuildFollowedFileName(DouyinVideo video, AppConfig config, string extension, string awemeId) { if (string.IsNullOrWhiteSpace(config?.FullFollowedTitleTemplate)) return awemeId + extension; var generated = VideoTitleGenerator.Generate(config.FullFollowedTitleTemplate, new VideoTitleDataTemplate { Id = awemeId, ReleaseTime = video.CreateTime, VideoTitle = video.VideoTitle, Author = video.Author, FileHash = video.FileHash, Resolution = video.Resolution }); generated = DouyinFileNameHelper.SanitizeLinuxFileName(generated, awemeId); return generated + extension; } private static string NormalizeExtension(string extension) => string.IsNullOrWhiteSpace(extension) ? ".mp4" : extension.ToLowerInvariant(); } }