using dy.net.model.dto; using dy.net.model.entity; using dy.net.model.response; using dy.net.utils; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Primitives; using Newtonsoft.Json; using Serilog; using System.IO; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using dy.net.storage; namespace dy.net.service { public class DouyinHttpClientService : IDisposable { private readonly IHttpClientFactory _clientFactory; private readonly MediaStorageRouter _storageRouter; private readonly Func _delayAsync; private const int TransientRequestRetryCount = 2; private const int MediaNetworkRetryCount = 2; private readonly JsonSerializerSettings _jsonSettings=new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, MissingMemberHandling = MissingMemberHandling.Ignore }; private bool _disposedValue; public DouyinHttpClientService(IHttpClientFactory clientFactory, MediaStorageRouter storageRouter) : this(clientFactory, storageRouter, Task.Delay) { } internal DouyinHttpClientService( IHttpClientFactory clientFactory, MediaStorageRouter storageRouter, Func delayAsync) { _clientFactory = clientFactory; _storageRouter = storageRouter; _delayAsync = delayAsync ?? Task.Delay; } /// /// 异步获取HTTP响应消息(优化版) /// private async Task GetHttpResponseMessage( HttpMethod httpMethod, string requestUrl, Dictionary requestParameters, string refererValue, string cookie) { string fullUrl = requestUrl; if (requestParameters != null && requestParameters.Count > 0) { fullUrl = QueryHelpers.AddQueryString( requestUrl, requestParameters.ToDictionary( kv => kv.Key, kv => new StringValues(kv.Value) ) ); } var httpClient = _clientFactory.CreateClient(DouyinRequestParamManager.DY_HTTP_CLIENT); for (var attempt = 0; ; attempt++) { using var requestMessage = new HttpRequestMessage(httpMethod, fullUrl); if (!string.IsNullOrEmpty(refererValue) && Uri.IsWellFormedUriString(refererValue, UriKind.Absolute)) requestMessage.Headers.Referrer = new Uri(refererValue); if (!string.IsNullOrEmpty(cookie)) requestMessage.Headers.TryAddWithoutValidation("Cookie", cookie); try { var response = await httpClient.SendAsync(requestMessage); if (!IsTransientStatus(response.StatusCode) || attempt >= TransientRequestRetryCount) return response; var status = (int)response.StatusCode; response.Dispose(); await DelayTransientRetryAsync(attempt + 1, CancellationToken.None); Log.Warning("抖音接口暂时返回 HTTP {Status},正在进行第 {Retry}/{MaxRetry} 次有限重试", status, attempt + 1, TransientRequestRetryCount); } catch (Exception ex) when (IsTransientRequestFailure(ex) && attempt < TransientRequestRetryCount) { Log.Warning("抖音接口连接失败,正在进行第 {Retry}/{MaxRetry} 次有限重试:{Reason}", attempt + 1, TransientRequestRetryCount, ex.GetBaseException().Message); await DelayTransientRetryAsync(attempt + 1, CancellationToken.None); } } } private static bool IsTransientStatus(HttpStatusCode status) => status == HttpStatusCode.RequestTimeout || (int)status >= 500; private static bool IsTransientRequestFailure(Exception error) => error is HttpRequestException or TimeoutException or TaskCanceledException; private Task DelayTransientRetryAsync(int retryNumber, CancellationToken cancellationToken) { var exponential = retryNumber <= 1 ? 1 : 3; var jitter = Random.Shared.Next(0, 401); return _delayAsync(TimeSpan.FromMilliseconds(exponential * 1000 + jitter), cancellationToken); } #region 收藏夹相关方法(保留原有逻辑,仅优化资源释放) public async Task SyncCollectVideos(string cursor, string count, string cookie) { if (string.IsNullOrWhiteSpace(cursor)) throw new ArgumentException($"“{nameof(cursor)}”不能为 null 或空。", nameof(cursor)); if (string.IsNullOrWhiteSpace(count)) throw new ArgumentException($"“{nameof(count)}”不能为 null 或空。", nameof(count)); if (string.IsNullOrWhiteSpace(cookie)) throw new ArgumentException($"“{nameof(cookie)}”不能为 null 或空。", nameof(cookie)); try { var requestUrl = "/aweme/v1/web/aweme/listcollection"; var refererValue = "https://www.douyin.com"; var requestParameters = DouyinRequestParamManager.DouyinCollectParams; requestParameters["cursor"] = cursor; requestParameters["count"] = count; using var response = await GetHttpResponseMessage(HttpMethod.Post, requestUrl, requestParameters, refererValue, cookie); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var model = JsonSerializer.Create(_jsonSettings).Deserialize(jsonReader); if (model == null) Log.Error($"SyncCollectVideos fail, data is null"); return model; } else { Log.Error($"SyncCollectVideos fail: {response.StatusCode}"); return null; } } catch (Exception ex) { Log.Error($"SyncCollectVideos error: {ex.Message}", ex); return null; } } public async Task SyncCollectFolderList(string cookie, string cursor) { if (string.IsNullOrWhiteSpace(cookie)) { Log.Error("cookie为空,无法获取收藏夹列表 "); return null; } try { var requestUrl = "/aweme/v1/web/collects/list"; var refererValue = "https://www.douyin.com/user/self?from_tab_name=main&showSubTab=favorite_folder&showTab=favorite_collection"; var requestParameters = DouyinRequestParamManager.DouyinCollectListParams; requestParameters["count"] = "10"; requestParameters["cursor"] = cursor; using var response = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var model = JsonSerializer.Create(_jsonSettings).Deserialize(jsonReader); if (model == null) Log.Error($"SyncCollectFolderList fail, data is null"); return model; } else { Log.Error("SyncCollectFolderList ,{StatusCode}", response.StatusCode); return null; } } catch (Exception ex) { Log.Error("SyncCollectFolderList ,{error}", ex); return null; } } public async Task SyncCollectVideosByCollectId(string cursor, string count, string cookie, string collectsId) { if (string.IsNullOrWhiteSpace(collectsId)) throw new ArgumentException($"“{nameof(collectsId)}”不能为 null 或空。", nameof(collectsId)); if (string.IsNullOrWhiteSpace(cursor)) throw new ArgumentException($"“{nameof(cursor)}”不能为 null 或空。", nameof(cursor)); if (string.IsNullOrWhiteSpace(count)) throw new ArgumentException($"“{nameof(count)}”不能为 null 或空。", nameof(count)); if (string.IsNullOrWhiteSpace(cookie)) throw new ArgumentException($"“{nameof(cookie)}”不能为 null 或空。", nameof(cookie)); try { var requestUrl = "/aweme/v1/web/collects/video/list"; var refererValue = "https://www.douyin.com/user/self?from_tab_name=main&showSubTab=favorite_folder&showTab=favorite_collection"; var requestParameters = DouyinRequestParamManager.DouyinFolderCollectParams; requestParameters["cursor"] = cursor; requestParameters["count"] = "15"; requestParameters["collects_id"] = collectsId; using var response = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var model = JsonSerializer.Create(_jsonSettings).Deserialize(jsonReader); if (model == null) Log.Error($"SyncCollectVideosByCollectId fail: data is null"); return model; } else { Log.Error($"SyncCollectVideosByCollectId fail: {response.StatusCode}"); return null; } } catch (Exception ex) { Log.Error($"SyncCollectVideosByCollectId error: {ex.Message}", ex); return null; } } #endregion #region 合集相关方法 public async Task SyncMixList(string cookie, string cursor) { if (string.IsNullOrWhiteSpace(cookie)) { Log.Error("cookie为空,无法获取收藏夹列表 "); return null; } try { var requestUrl = "/aweme/v1/web/mix/listcollection"; var refererValue = "https://www.douyin.com/user/self?"; var requestParameters = DouyinRequestParamManager.DouyinMixListParams; requestParameters["count"] = "10"; requestParameters["cursor"] = cursor; using var response = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var model = JsonSerializer.Create(_jsonSettings).Deserialize(jsonReader); if (model == null) Log.Error($"SyncMixList fail, data is null"); return model; } else { Log.Error($"SyncMixList : {response.StatusCode}"); return null; } } catch (Exception ex) { Log.Error("SyncMixList ,{error}", ex); return null; } } public async Task SyncMixViedosByMixId(string cursor, string count, string cookie, string mixId) { if (string.IsNullOrWhiteSpace(mixId)) throw new ArgumentException($"“{nameof(mixId)}”不能为 null 或空。", nameof(mixId)); if (string.IsNullOrWhiteSpace(cursor)) throw new ArgumentException($"“{nameof(cursor)}”不能为 null 或空。", nameof(cursor)); if (string.IsNullOrWhiteSpace(count)) throw new ArgumentException($"“{nameof(count)}”不能为 null 或空。", nameof(count)); if (string.IsNullOrWhiteSpace(cookie)) throw new ArgumentException($"“{nameof(cookie)}”不能为 null 或空。", nameof(cookie)); try { var requestUrl = "/aweme/v1/web/mix/aweme"; var refererValue = "https://www.douyin.com/user/self?"; var requestParameters = DouyinRequestParamManager.DouyinMixVideoParams; requestParameters["cursor"] = cursor; requestParameters["count"] = "15"; requestParameters["mix_id"] = mixId; using var response = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var model = JsonSerializer.Create(_jsonSettings).Deserialize(jsonReader); if (model == null) Log.Error($"SyncMixViedosByMixId fail:data is null"); return model; } else { Log.Error($"SyncMixViedosByMixId fail: {response.StatusCode}"); return null; } } catch (Exception ex) { Log.Error($"SyncMixViedosByMixId error: {ex.Message}", ex); return null; } } #endregion #region 短剧相关方法 public async Task SyncSeriesList(string cookie, string cursor) { if (string.IsNullOrWhiteSpace(cookie)) { Log.Error("cookie为空,无法获取收藏夹列表 "); return null; } try { var requestUrl = "/aweme/v1/web/series/collections"; var refererValue = "https://www.douyin.com/user/self?"; var requestParameters = DouyinRequestParamManager.DouyinSeriesListParams; requestParameters["count"] = "15"; requestParameters["cursor"] = cursor; using var response = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var model = JsonSerializer.Create(_jsonSettings).Deserialize(jsonReader); if (model == null) Log.Error($"SyncShortList fail, data is null"); return model; } else { Log.Error($"SyncShortList fail: {response.StatusCode}"); return null; } } catch (Exception ex) { Log.Error("SyncShortList ,{error}", ex); return null; } } public async Task SyncSeriesViedosByMSeriesId(string cursor, string count, string cookie, string seriesId) { if (string.IsNullOrWhiteSpace(seriesId)) throw new ArgumentException($"“{nameof(seriesId)}”不能为 null 或空。", nameof(seriesId)); if (string.IsNullOrWhiteSpace(cursor)) throw new ArgumentException($"“{nameof(cursor)}”不能为 null 或空。", nameof(cursor)); if (string.IsNullOrWhiteSpace(count)) throw new ArgumentException($"“{nameof(count)}”不能为 null 或空。", nameof(count)); if (string.IsNullOrWhiteSpace(cookie)) throw new ArgumentException($"“{nameof(cookie)}”不能为 null 或空。", nameof(cookie)); try { var requestUrl = "/aweme/v1/web/series/aweme"; var refererValue = "https://www.douyin.com/user/self?"; var requestParameters = DouyinRequestParamManager.DouyinSeriesVideosParams; requestParameters["cursor"] = cursor; requestParameters["count"] = count; requestParameters["series_id"] = seriesId; using var response = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var model = JsonSerializer.Create(_jsonSettings).Deserialize(jsonReader); if (model == null) Log.Error($"SyncSeriesViedosByMSeriesId fail:data is null"); return model; } else { Log.Error($"SyncSeriesViedosByMSeriesId fail: {response.StatusCode}"); return null; } } catch (Exception ex) { Log.Error($"SyncSeriesViedosByMSeriesId error: {ex.Message}", ex); return null; } } #endregion #region 喜欢/博主/关注相关方法 public async Task SyncFavoriteVideos(string count, string cursor, string secUserId, string cookie) { if (string.IsNullOrWhiteSpace(cursor)) throw new ArgumentException($"“{nameof(cursor)}”不能为 null 或空。", nameof(cursor)); if (string.IsNullOrWhiteSpace(count)) throw new ArgumentException($"“{nameof(count)}”不能为 null 或空。", nameof(count)); if (string.IsNullOrWhiteSpace(secUserId)) throw new ArgumentException($"“{nameof(secUserId)}”不能为 null 或空。", nameof(secUserId)); if (string.IsNullOrWhiteSpace(cookie)) throw new ArgumentException($"“{nameof(cookie)}”不能为 null 或空。", nameof(cookie)); try { var requestUrl = "/aweme/v1/web/aweme/favorite"; var refererValue = "https://www.douyin.com/user/self?showTab=like"; var requestParameters = DouyinRequestParamManager.DouyinFavoriteParams; requestParameters["max_cursor"] = cursor; requestParameters["sec_user_id"] = secUserId; requestParameters["count"] = count; using var response = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var model = JsonSerializer.Create(_jsonSettings).Deserialize(jsonReader); if (model == null) Log.Error($"SyncFavoriteVideos fail, data is null"); return model; } else { Log.Error($"SyncFavoriteVideos fail: {response.StatusCode}"); return null; } } catch (Exception ex) { Log.Error($"SyncFavoriteVideos error: {ex.Message}", ex); return null; } } public async Task SyncUpderPostVideos(string count, string cursor, string secUserId, string cookie) { if (string.IsNullOrWhiteSpace(cursor)) throw new ArgumentException($"“{nameof(cursor)}”不能为 null 或空。", nameof(cursor)); if (string.IsNullOrWhiteSpace(count)) throw new ArgumentException($"“{nameof(count)}”不能为 null 或空。", nameof(count)); if (string.IsNullOrWhiteSpace(secUserId)) throw new ArgumentException($"“{nameof(secUserId)}”不能为 null 或空。", nameof(secUserId)); if (string.IsNullOrWhiteSpace(cookie)) throw new ArgumentException($"“{nameof(cookie)}”不能为 null 或空。", nameof(cookie)); try { var requestUrl = "/aweme/v1/web/aweme/post"; var refererValue = "https://www.douyin.com/user/"; var requestParameters = DouyinRequestParamManager.DouyinUpderPostParams; requestParameters["max_cursor"] = cursor; requestParameters["sec_user_id"] = secUserId; requestParameters["count"] = count; using var response = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var model = JsonSerializer.Create(_jsonSettings).Deserialize(jsonReader); if (model == null) throw new InvalidDataException("获取博主作品列表失败:响应内容为空或无法解析"); return model; } else { throw new HttpRequestException( $"获取博主作品列表失败:HTTP {(int)response.StatusCode} {response.StatusCode}", null, response.StatusCode); } } catch (Exception ex) { Log.Error($"SyncUpderPostVideos error: {ex.Message}", ex); throw; } } public async Task SyncMyFollows(string count, string offset, string secUserId, string cookie) { if (string.IsNullOrWhiteSpace(offset)) throw new ArgumentException($"“{nameof(offset)}”不能为 null 或空。", nameof(offset)); if (string.IsNullOrWhiteSpace(count)) throw new ArgumentException($"“{nameof(count)}”不能为 null 或空。", nameof(count)); if (string.IsNullOrWhiteSpace(secUserId)) throw new ArgumentException($"“{nameof(secUserId)}”不能为 null 或空。", nameof(secUserId)); if (string.IsNullOrWhiteSpace(cookie)) throw new ArgumentException($"“{nameof(cookie)}”不能为 null 或空。", nameof(cookie)); try { var requestUrl = "/aweme/v1/web/user/following/list"; var refererValue = "https://www.douyin.com/user/self?showTab=like"; var requestParameters = DouyinRequestParamManager.DouyinMyFollowParams; requestParameters["sec_user_id"] = secUserId; requestParameters["count"] = count; requestParameters["offset"] = offset; using var response = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie); if (response.IsSuccessStatusCode) { using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var model = JsonSerializer.Create(_jsonSettings).Deserialize(jsonReader); return model; } else { Log.Error($"SyncMyFollows fail: {response.StatusCode}"); return null; } } catch (Exception ex) { Log.Error($"SyncMyFollows error: {ex.Message}", ex); // 优化:抛出前确保资源释放,使用包装异常保留原始堆栈 throw new InvalidOperationException("获取关注列表失败", ex); } } #endregion #region Cookie检查 public async Task CheckCookie(DouyinCookie douyinCookie) { if (douyinCookie == null || string.IsNullOrWhiteSpace(douyinCookie.Cookies)) return false; try { if (!string.IsNullOrWhiteSpace(douyinCookie.SecUserId)) { var res = await SyncMyFollows("1", "10", douyinCookie.SecUserId, douyinCookie.Cookies); return res != null && res.StatusCode == 0; } else { var res = await SyncCollectVideos("0", "10", douyinCookie.Cookies); return res != null && res.StatusCode == 0; } } catch (Exception ex) { Log.Error(ex, "检查Cookie有效性失败"); return false; } } #endregion #region 下载相关 public Task DownloadAsync( string videoUrl, string savePath, string cookie, List otherUrls = null, CancellationToken cancellationToken = default, TimeSpan? streamTimeout = null, int maxRetryCount = 12, TimeSpan? initialRetryDelay = null) { var urls = BuildCandidateUrls(videoUrl, otherUrls, maxRetryCount); return DownloadCandidatesAsync( urls, savePath, cookie, cancellationToken, streamTimeout ?? TimeSpan.FromSeconds(60), initialRetryDelay ?? TimeSpan.FromSeconds(1), async (response, actualPath, timeout, token) => { await WriteLocalAsync(response, actualPath, timeout, token); }); } /// /// 将抖音媒体写入指定存储。远端存储负责安全中转和最终提交, /// 不会先落入永久本地媒体目录。 /// public async Task DownloadToStorageAsync( StorageType storageType, string mediaUrl, string savePath, string cookie, List otherUrls = null, CancellationToken cancellationToken = default, int maxRetryCount = 12, IMediaStorage storageOverride = null) { if (storageType == StorageType.Local) return await DownloadAsync(mediaUrl, savePath, cookie, otherUrls, cancellationToken, maxRetryCount: maxRetryCount); var storage = storageOverride ?? _storageRouter.Resolve(storageType); if (storage.StorageType != storageType) throw new InvalidOperationException("指定的媒体存储与目标存储类型不一致"); if (storage is ICanonicalMediaStorage canonicalStorage) savePath = await canonicalStorage.CanonicalizePathAsync(savePath, false, cancellationToken); var urls = BuildCandidateUrls(mediaUrl, otherUrls, maxRetryCount); return await DownloadCandidatesAsync( urls, savePath, cookie, cancellationToken, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(1), async (response, actualPath, _, token) => { try { await using var responseStream = await response.Content.ReadAsStreamAsync(token); await storage.WriteAsync( actualPath, responseStream, response.Content.Headers.ContentLength, response.Content.Headers.ContentType?.MediaType, token); } catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } catch (MediaIntegrityException) { throw; } catch (Exception ex) { throw new MediaStorageException($"{storageType} 媒体写入失败:{actualPath}", ex); } }); } private async Task DownloadCandidatesAsync( IReadOnlyList urls, string savePath, string cookie, CancellationToken cancellationToken, TimeSpan streamTimeout, TimeSpan retryDelay, Func writeAsync) { if (urls.Count == 0) return Failure(MediaDownloadFailureKind.SourceUnavailable, savePath, null, null, 0, null, "没有有效的媒体下载地址。"); var client = _clientFactory.CreateClient(DouyinRequestParamManager.DY_HTTP_CLIENT_DOWN); MediaDownloadResult lastFailure = null; var forbiddenCount = 0; var attempted = 0; foreach (var source in urls) { attempted++; var networkRetry = 0; var rateLimitRetry = 0; while (true) { HttpResponseMessage response = null; Uri finalUri = source; try { (response, finalUri) = await SendWithSafeRedirectsAsync(client, source, cookie, cancellationToken); var status = (int)response.StatusCode; var host = SafeHost(finalUri); if (response.StatusCode == HttpStatusCode.Unauthorized) return Failure(MediaDownloadFailureKind.SourceUnauthorized, savePath, status, host, attempted, null, "抖音媒体请求返回 401,需要重新授权。"); if (response.StatusCode == HttpStatusCode.Forbidden) { forbiddenCount++; lastFailure = Failure(MediaDownloadFailureKind.SourceForbidden, savePath, status, host, attempted, null, "抖音媒体来源拒绝访问(403)。"); break; } if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone) { lastFailure = Failure(MediaDownloadFailureKind.SourceNotFound, savePath, status, host, attempted, null, $"媒体来源返回 {status},作品可能已下架或链接已失效。"); break; } if ((int)response.StatusCode == 429) { var retryAt = GetRetryAfter(response); if (rateLimitRetry++ == 0) { var wait = retryAt.HasValue ? retryAt.Value - DateTime.UtcNow : TimeSpan.FromSeconds(5); if (wait < TimeSpan.Zero) wait = TimeSpan.Zero; if (wait > TimeSpan.FromSeconds(60)) wait = TimeSpan.FromSeconds(60); await _delayAsync(wait, cancellationToken); continue; } return Failure(MediaDownloadFailureKind.SourceRateLimited, savePath, status, host, attempted, retryAt, "抖音媒体来源请求过于频繁(429),已进入冷却等待。"); } if ((int)response.StatusCode >= 500) { if (networkRetry < MediaNetworkRetryCount) { networkRetry++; await DelayMediaRetryAsync(retryDelay, networkRetry, cancellationToken); continue; } lastFailure = Failure(MediaDownloadFailureKind.SourceUnavailable, savePath, status, host, attempted, null, $"媒体来源暂时不可用({status})。"); break; } if (!response.IsSuccessStatusCode) { lastFailure = Failure(MediaDownloadFailureKind.SourceUnavailable, savePath, status, host, attempted, null, $"媒体来源请求失败({status})。"); break; } var mediaType = response.Content.Headers.ContentType?.MediaType; if (IsObviousNonMedia(mediaType)) { lastFailure = Failure(MediaDownloadFailureKind.SourceInvalidContent, savePath, status, host, attempted, null, $"媒体来源返回了非媒体内容({mediaType})。"); break; } if (response.Content.Headers.ContentLength == 0) { lastFailure = Failure(MediaDownloadFailureKind.IntegrityCheckFailed, savePath, status, host, attempted, null, "媒体来源返回空内容。"); break; } var actualPath = DetectActualSavePath(savePath, mediaType); await writeAsync(response, actualPath, streamTimeout, cancellationToken); return MediaDownloadResult.Succeeded(actualPath, host, attempted); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { CleanupIncompleteFile(savePath); return Failure(MediaDownloadFailureKind.Cancelled, savePath, null, SafeHost(finalUri), attempted, null, "媒体下载已取消。"); } catch (MediaStorageException ex) { CleanupIncompleteFile(savePath); Log.Error(ex, "媒体存储写入失败:{Path}", savePath); return Failure(MediaDownloadFailureKind.StorageUnavailable, savePath, null, SafeHost(finalUri), attempted, null, DescribeStorageFailure(ex)); } catch (MediaIntegrityException ex) { CleanupIncompleteFile(savePath); lastFailure = Failure(MediaDownloadFailureKind.IntegrityCheckFailed, savePath, null, SafeHost(finalUri), attempted, null, ex.Message); break; } catch (MediaSourceException ex) { lastFailure = Failure(ex.FailureKind, savePath, ex.StatusCode, ex.SourceHost, attempted, ex.RetryAfter, ex.Message); break; } catch (Exception ex) when (ex is HttpRequestException or TimeoutException or TaskCanceledException or IOException) { if (networkRetry < MediaNetworkRetryCount) { networkRetry++; await DelayMediaRetryAsync(retryDelay, networkRetry, cancellationToken); continue; } lastFailure = Failure(MediaDownloadFailureKind.SourceUnavailable, savePath, null, SafeHost(finalUri), attempted, null, "连接媒体来源失败。"); break; } finally { response?.Dispose(); } } } if (forbiddenCount == attempted && attempted > 0) lastFailure = Failure(MediaDownloadFailureKind.SourceForbidden, savePath, 403, lastFailure?.SourceHost, attempted, null, $"全部 {attempted} 个候选媒体地址均返回 403。"); else if (lastFailure?.FailureKind == MediaDownloadFailureKind.SourceForbidden) lastFailure = Failure(MediaDownloadFailureKind.SourceUnavailable, savePath, null, lastFailure.SourceHost, attempted, null, "候选媒体地址返回了多种来源错误。"); lastFailure ??= Failure(MediaDownloadFailureKind.SourceUnavailable, savePath, null, null, attempted, null, "所有候选媒体地址均不可用。"); if (lastFailure.FailureKind == MediaDownloadFailureKind.SourceForbidden) Log.Warning("抖音媒体来源被拒绝:Host={SourceHost}, Status={Status}, Candidates={Candidates}", lastFailure.SourceHost, lastFailure.HttpStatusCode, lastFailure.AttemptedUrlCount); else Log.Warning("媒体来源下载失败:Host={SourceHost}, Status={Status}, Kind={Kind}, Candidates={Candidates}", lastFailure.SourceHost, lastFailure.HttpStatusCode, lastFailure.FailureKind, lastFailure.AttemptedUrlCount); return lastFailure; } private Task DelayMediaRetryAsync(TimeSpan baseDelay, int retryNumber, CancellationToken cancellationToken) { var multiplier = Math.Pow(2, Math.Max(0, retryNumber - 1)); var milliseconds = Math.Min(5000, Math.Max(100, baseDelay.TotalMilliseconds * multiplier)); milliseconds += Random.Shared.Next(0, 251); return _delayAsync(TimeSpan.FromMilliseconds(milliseconds), cancellationToken); } private static List BuildCandidateUrls(string primary, IEnumerable alternatives, int limit) { var result = new List(); var seen = new HashSet(StringComparer.Ordinal); foreach (var value in new[] { primary }.Concat(alternatives ?? Enumerable.Empty())) { if (result.Count >= Math.Clamp(limit, 1, 12)) break; if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) continue; if (seen.Add(uri.AbsoluteUri)) result.Add(uri); } return result; } internal static string DescribeStorageFailure(MediaStorageException error) { if (error == null) return "媒体存储写入失败。"; var message = error.Message?.Trim(); var reason = error.GetBaseException()?.Message?.Trim(); if (string.IsNullOrWhiteSpace(message)) message = "媒体存储写入失败。"; if (string.IsNullOrWhiteSpace(reason) || string.Equals(message, reason, StringComparison.Ordinal)) return message; return $"{message};原因:{reason}"; } private static async Task<(HttpResponseMessage Response, Uri FinalUri)> SendWithSafeRedirectsAsync( HttpClient client, Uri initialUri, string cookie, CancellationToken cancellationToken) { var current = initialUri; for (var redirect = 0; redirect <= 5; redirect++) { using var request = new HttpRequestMessage(HttpMethod.Get, current); if (!string.IsNullOrWhiteSpace(cookie) && IsDouyinHost(current.Host)) request.Headers.TryAddWithoutValidation("Cookie", cookie); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!IsRedirect(response.StatusCode)) return (response, current); var location = response.Headers.Location; response.Dispose(); if (location == null) throw new MediaSourceException(MediaDownloadFailureKind.SourceUnavailable, "媒体来源返回了无目标地址的跳转。", (int)response.StatusCode, SafeHost(current)); if (redirect == 5) throw new MediaSourceException(MediaDownloadFailureKind.SourceUnavailable, "媒体来源跳转次数超过 5 次。", (int)response.StatusCode, SafeHost(current)); current = location.IsAbsoluteUri ? location : new Uri(current, location); if (current.Scheme != Uri.UriSchemeHttp && current.Scheme != Uri.UriSchemeHttps) throw new MediaSourceException(MediaDownloadFailureKind.SourceUnavailable, "媒体来源返回了不安全的跳转协议。", sourceHost: SafeHost(current)); } throw new MediaSourceException(MediaDownloadFailureKind.SourceUnavailable, "媒体来源跳转失败。", sourceHost: SafeHost(current)); } private static async Task WriteLocalAsync( HttpResponseMessage response, string actualSavePath, TimeSpan streamTimeout, CancellationToken cancellationToken) { var directory = Path.GetDirectoryName(actualSavePath); FileStream fileStream = null; try { try { if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); CleanupIncompleteFile(actualSavePath); fileStream = new FileStream(actualSavePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); } catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) { throw new MediaStorageException($"本地媒体写入失败:{actualSavePath}", ex); } Stream responseStream; try { responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); } catch (Exception ex) when (ex is HttpRequestException or IOException) { throw new MediaSourceException(MediaDownloadFailureKind.SourceUnavailable, "读取媒体来源响应失败。", sourceHost: SafeHost(response.RequestMessage?.RequestUri), innerException: ex); } await using var ownedResponseStream = responseStream; var buffer = System.Buffers.ArrayPool.Shared.Rent(81920); long totalRead = 0; var lastActivity = DateTime.UtcNow; try { int read; while (true) { try { read = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); } catch (Exception ex) when (ex is HttpRequestException or IOException) { throw new MediaSourceException(MediaDownloadFailureKind.SourceUnavailable, "读取媒体来源响应失败。", sourceHost: SafeHost(response.RequestMessage?.RequestUri), innerException: ex); } if (read <= 0) break; if (DateTime.UtcNow - lastActivity > streamTimeout) throw new MediaSourceException(MediaDownloadFailureKind.SourceUnavailable, $"媒体流超过 {streamTimeout.TotalSeconds:0} 秒没有数据。"); try { await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); } catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) { throw new MediaStorageException($"本地媒体写入失败:{actualSavePath}", ex); } totalRead += read; lastActivity = DateTime.UtcNow; } try { await fileStream.FlushAsync(cancellationToken); } catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) { throw new MediaStorageException($"本地媒体写入失败:{actualSavePath}", ex); } } finally { System.Buffers.ArrayPool.Shared.Return(buffer); } if (totalRead <= 0) throw new MediaIntegrityException("下载内容为空。"); var expected = response.Content.Headers.ContentLength; if (expected.HasValue && expected.Value != totalRead) throw new MediaIntegrityException($"下载长度不一致:期望 {expected.Value},实际 {totalRead}。"); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (MediaSourceException) { CleanupIncompleteFile(actualSavePath); throw; } catch (MediaIntegrityException) { CleanupIncompleteFile(actualSavePath); throw; } catch (MediaStorageException) { CleanupIncompleteFile(actualSavePath); throw; } finally { if (fileStream != null) await fileStream.DisposeAsync(); } } private static string DetectActualSavePath(string savePath, string mediaType) { if (!Path.GetExtension(savePath).Equals(".mp3", StringComparison.OrdinalIgnoreCase)) return savePath; if (mediaType?.Contains("audio/mp4", StringComparison.OrdinalIgnoreCase) == true || mediaType?.Contains("audio/m4a", StringComparison.OrdinalIgnoreCase) == true) return Path.ChangeExtension(savePath, ".m4a"); if (mediaType?.Contains("video/mp4", StringComparison.OrdinalIgnoreCase) == true) return Path.ChangeExtension(savePath, ".mp4"); return savePath; } private static bool IsObviousNonMedia(string mediaType) => !string.IsNullOrWhiteSpace(mediaType) && (mediaType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) || mediaType.Contains("json", StringComparison.OrdinalIgnoreCase)); private static bool IsDouyinHost(string host) => string.Equals(host, "douyin.com", StringComparison.OrdinalIgnoreCase) || host.EndsWith(".douyin.com", StringComparison.OrdinalIgnoreCase); private static bool IsRedirect(HttpStatusCode code) => code is HttpStatusCode.MovedPermanently or HttpStatusCode.Redirect or HttpStatusCode.RedirectMethod or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect; private static string SafeHost(Uri uri) => uri?.IsAbsoluteUri == true ? uri.IdnHost : null; private static DateTime? GetRetryAfter(HttpResponseMessage response) { var value = response.Headers.RetryAfter; if (value?.Date.HasValue == true) return value.Date.Value.UtcDateTime; if (value?.Delta.HasValue == true) return DateTime.UtcNow.Add(value.Delta.Value); return DateTime.UtcNow.AddMinutes(15); } private static MediaDownloadResult Failure( MediaDownloadFailureKind kind, string savePath, int? status, string host, int attempted, DateTime? retryAfter, string message) => new() { Success = false, ActualSavePath = savePath, FailureKind = kind, HttpStatusCode = status, SourceHost = host, AttemptedUrlCount = attempted, RetryAfter = retryAfter, Message = message }; private static void CleanupIncompleteFile(string savePath) { if (File.Exists(savePath)) { try { File.Delete(savePath); } catch (IOException ex) { Log.Error(ex, $"清理无效文件失败:{savePath}(可能被占用)"); } } } #endregion #region IDisposable 实现(优化版) protected virtual void Dispose(bool disposing) { if (!_disposedValue) { if (disposing) { // 释放托管资源:仅清理本类创建的托管资源(此处无) } // 释放非托管资源:本类无非托管资源 // 优化:移除强制GC调用,让GC自动管理 _disposedValue = true; } } public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); } #endregion } }