mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
0.7.9.1
This commit is contained in:
@@ -262,7 +262,12 @@ public static class DesktopComponentEditorRegistryFactory
|
||||
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshIntervalMinutes),
|
||||
nameof(ComponentSettingsSnapshot.Stcn24ForumSourceType)
|
||||
]
|
||||
}))
|
||||
})),
|
||||
[BuiltInComponentIds.DesktopZhiJiaoHub] = new(
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
context => new ZhiJiaoHubComponentEditor(context),
|
||||
preferredWidth: 480d,
|
||||
preferredHeight: 520d)
|
||||
};
|
||||
|
||||
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
|
||||
|
||||
@@ -107,7 +107,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
|
||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
||||
{
|
||||
@@ -141,7 +142,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
|
||||
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
||||
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
||||
? parsedTagVersion.ToString(3)
|
||||
? FormatVersionText(parsedTagVersion)
|
||||
: release.TagName;
|
||||
|
||||
var isUpdateAvailable = parsedTagVersion is not null && parsedTagVersion > currentVersion;
|
||||
@@ -180,7 +181,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
|
||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
||||
{
|
||||
@@ -216,7 +218,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
|
||||
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
||||
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
||||
? parsedTagVersion.ToString(3)
|
||||
? FormatVersionText(parsedTagVersion)
|
||||
: release.TagName;
|
||||
|
||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||
@@ -740,8 +742,18 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
{
|
||||
var major = Math.Max(0, version.Major);
|
||||
var minor = Math.Max(0, version.Minor);
|
||||
var build = Math.Max(0, version.Build);
|
||||
return new Version(major, minor, build);
|
||||
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
|
||||
var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0);
|
||||
return revision > 0
|
||||
? new Version(major, minor, build, revision)
|
||||
: new Version(major, minor, build);
|
||||
}
|
||||
|
||||
private static string FormatVersionText(Version version)
|
||||
{
|
||||
return version.Revision > 0
|
||||
? version.ToString(4)
|
||||
: version.ToString(3);
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
|
||||
@@ -52,6 +52,22 @@ public sealed record ExchangeRateQuery(
|
||||
string? TargetCurrency = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record ZhiJiaoHubQuery(
|
||||
string? Source = null,
|
||||
int? ImageIndex = null,
|
||||
bool ForceRefresh = false,
|
||||
string? MirrorSource = null);
|
||||
|
||||
public sealed record ZhiJiaoHubImageItem(
|
||||
string Name,
|
||||
string Url,
|
||||
int Index);
|
||||
|
||||
public sealed record ZhiJiaoHubSnapshot(
|
||||
IReadOnlyList<ZhiJiaoHubImageItem> Images,
|
||||
int CurrentIndex,
|
||||
string Source);
|
||||
|
||||
public sealed record RecommendationQueryResult<T>(
|
||||
bool Success,
|
||||
T? Data,
|
||||
@@ -285,6 +301,14 @@ public sealed record RecommendationApiOptions
|
||||
public int DefaultBaiduHotSearchCount { get; init; } = 4;
|
||||
|
||||
public int DefaultStcn24ForumPostCount { get; init; } = 4;
|
||||
|
||||
public string ClassIslandHubApiUrl { get; init; } = "https://api.github.com/repos/ClassIsland/classisland-hub/contents/images";
|
||||
|
||||
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/images";
|
||||
|
||||
public string ClassIslandHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/ClassIsland/classisland-hub/main/images/{0}";
|
||||
|
||||
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/images/{0}";
|
||||
}
|
||||
|
||||
public interface IRecommendationInfoService
|
||||
@@ -325,5 +349,19 @@ public interface IRecommendationInfoService
|
||||
ExchangeRateQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<ZhiJiaoHubSnapshot>> GetZhiJiaoHubImagesAsync(
|
||||
ZhiJiaoHubQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ZhiJiaoHubSyncResult> SyncZhiJiaoHubImagesAsync(
|
||||
string source,
|
||||
string mirrorSource,
|
||||
IProgress<(int Current, int Total, string Status)>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source);
|
||||
|
||||
bool HasZhiJiaoHubLocalCache(string source);
|
||||
|
||||
void ClearCache();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
@@ -53,6 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
Dictionary<string, decimal> Rates,
|
||||
DateTimeOffset ExpireAt,
|
||||
DateTimeOffset FetchedAt);
|
||||
private sealed record ZhiJiaoHubCacheEntry(ZhiJiaoHubSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
private sealed record ArtworkCandidate(
|
||||
string Title,
|
||||
string? Artist,
|
||||
@@ -80,6 +81,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, ZhiJiaoHubCacheEntry> _zhiJiaoHubCacheBySource =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private int _dailyNewsRotationCursor;
|
||||
|
||||
static RecommendationDataService()
|
||||
@@ -94,7 +97,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
_options = options ?? new RecommendationApiOptions();
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
// 配置 HttpClientHandler 以支持所有 TLS 版本
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
|
||||
System.Security.Authentication.SslProtocols.Tls13,
|
||||
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
|
||||
};
|
||||
|
||||
_httpClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = _options.RequestTimeout
|
||||
};
|
||||
@@ -128,6 +139,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
_dailyWordCache = null;
|
||||
_stcn24ForumPostsCacheBySource.Clear();
|
||||
_exchangeRateCacheByBaseCurrency.Clear();
|
||||
_zhiJiaoHubCacheBySource.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3194,4 +3206,254 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
? text
|
||||
: $"{text[..maxLength]}...";
|
||||
}
|
||||
|
||||
// 智教Hub相关方法
|
||||
public async Task<RecommendationQueryResult<ZhiJiaoHubSnapshot>> GetZhiJiaoHubImagesAsync(
|
||||
ZhiJiaoHubQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = query ?? new ZhiJiaoHubQuery();
|
||||
var source = ZhiJiaoHubSources.Normalize(normalizedQuery.Source);
|
||||
var mirrorSource = ZhiJiaoHubMirrorSources.Normalize(normalizedQuery.MirrorSource);
|
||||
var cacheKey = $"{source}|{mirrorSource}";
|
||||
|
||||
if (!normalizedQuery.ForceRefresh && TryGetZhiJiaoHubFromCache(cacheKey, out var cached))
|
||||
{
|
||||
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Ok(cached);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await FetchZhiJiaoHubSnapshotAsync(source, mirrorSource, cancellationToken);
|
||||
SetZhiJiaoHubCache(cacheKey, snapshot);
|
||||
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Fail("upstream_network_error", ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Fail("upstream_parse_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ZhiJiaoHubSnapshot> FetchZhiJiaoHubSnapshotAsync(string source, string mirrorSource, CancellationToken cancellationToken)
|
||||
{
|
||||
var (owner, repo, path) = source switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => ("SECTL", "SECTL-hub", "docs/.vuepress/public/images"),
|
||||
_ => ("ClassIsland", "classisland-hub", "images")
|
||||
};
|
||||
|
||||
var contentsUrl = $"https://api.github.com/repos/{owner}/{repo}/contents/{path}";
|
||||
|
||||
// 如果使用镜像加速,代理 GitHub API 请求
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var images = await FetchImagesFromContentsApi(owner, repo, path, contentsUrl, mirrorSource, cancellationToken);
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("未找到图片文件");
|
||||
}
|
||||
|
||||
// 随机打乱图片顺序
|
||||
var random = new Random();
|
||||
var shuffled = images.OrderBy(_ => random.Next()).ToList();
|
||||
|
||||
// 重新设置索引
|
||||
for (int i = 0; i < shuffled.Count; i++)
|
||||
{
|
||||
var item = shuffled[i];
|
||||
shuffled[i] = item with { Index = i };
|
||||
}
|
||||
|
||||
return new ZhiJiaoHubSnapshot(shuffled, 0, source);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.Message.Contains("403") || ex.Message.Contains("rate limit"))
|
||||
{
|
||||
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new HttpRequestException($"获取图片列表失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(string owner, string repo, string path, string contentsUrl, string mirrorSource, CancellationToken cancellationToken)
|
||||
{
|
||||
var images = new List<ZhiJiaoHubImageItem>();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, contentsUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
|
||||
request.Headers.TryAddWithoutValidation("Accept", "application/vnd.github+json");
|
||||
request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if ((int)response.StatusCode == 403)
|
||||
{
|
||||
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
|
||||
}
|
||||
throw new HttpRequestException($"API 返回错误: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
|
||||
}
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("message", out var messageNode))
|
||||
{
|
||||
var errorMessage = messageNode.GetString();
|
||||
throw new InvalidOperationException($"GitHub API 错误: {errorMessage}");
|
||||
}
|
||||
throw new InvalidOperationException("Invalid response format from GitHub API.");
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
foreach (var item in root.EnumerateArray())
|
||||
{
|
||||
var type = ReadString(item, "type");
|
||||
if (type != "file")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = ReadString(item, "name");
|
||||
var downloadUrl = ReadString(item, "download_url");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只处理图片文件
|
||||
var extension = Path.GetExtension(name).ToLowerInvariant();
|
||||
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".gif" && extension != ".webp")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解码文件名
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
decodedName = Path.GetFileNameWithoutExtension(decodedName);
|
||||
|
||||
// 构造图片 URL
|
||||
string imageUrl;
|
||||
if (!string.IsNullOrWhiteSpace(downloadUrl))
|
||||
{
|
||||
imageUrl = downloadUrl;
|
||||
}
|
||||
else
|
||||
{
|
||||
imageUrl = $"https://raw.githubusercontent.com/{owner}/{repo}/main/{path}/{Uri.EscapeDataString(name)}";
|
||||
}
|
||||
|
||||
// 应用镜像加速到图片 URL
|
||||
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
|
||||
|
||||
images.Add(new ZhiJiaoHubImageItem(decodedName, imageUrl, index));
|
||||
index++;
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
private bool TryGetZhiJiaoHubFromCache(string cacheKey, out ZhiJiaoHubSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
if (_zhiJiaoHubCacheBySource.TryGetValue(cacheKey, out var cacheEntry) &&
|
||||
cacheEntry.ExpireAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
snapshot = cacheEntry.Snapshot;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetZhiJiaoHubCache(string cacheKey, ZhiJiaoHubSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
// 使用较长的缓存时间(1小时),因为图片列表不常变化
|
||||
_zhiJiaoHubCacheBySource[cacheKey] = new ZhiJiaoHubCacheEntry(
|
||||
snapshot,
|
||||
DateTimeOffset.UtcNow.Add(TimeSpan.FromHours(1)));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly ZhiJiaoHubCacheService _zhiJiaoHubCacheService = new();
|
||||
|
||||
public async Task<ZhiJiaoHubSyncResult> SyncZhiJiaoHubImagesAsync(
|
||||
string source,
|
||||
string mirrorSource,
|
||||
IProgress<(int Current, int Total, string Status)>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
|
||||
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
|
||||
|
||||
try
|
||||
{
|
||||
var query = new ZhiJiaoHubQuery(normalizedSource, ForceRefresh: true, MirrorSource: normalizedMirror);
|
||||
var result = await GetZhiJiaoHubImagesAsync(query, cancellationToken);
|
||||
|
||||
if (!result.Success || result.Data == null)
|
||||
{
|
||||
return new ZhiJiaoHubSyncResult(
|
||||
false,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
result.ErrorMessage ?? "Failed to fetch image list");
|
||||
}
|
||||
|
||||
return await _zhiJiaoHubCacheService.SyncImagesAsync(
|
||||
normalizedSource,
|
||||
result.Data.Images,
|
||||
normalizedMirror,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source)
|
||||
{
|
||||
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
|
||||
return _zhiJiaoHubCacheService.LoadLocalSnapshot(normalizedSource);
|
||||
}
|
||||
|
||||
public bool HasZhiJiaoHubLocalCache(string source)
|
||||
{
|
||||
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
|
||||
return _zhiJiaoHubCacheService.HasLocalCache(normalizedSource);
|
||||
}
|
||||
}
|
||||
|
||||
359
LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs
Normal file
359
LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs
Normal file
@@ -0,0 +1,359 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record ZhiJiaoHubLocalImageItem(
|
||||
string Name,
|
||||
string OriginalUrl,
|
||||
string LocalPath,
|
||||
int Index);
|
||||
|
||||
public sealed record ZhiJiaoHubLocalSnapshot(
|
||||
IReadOnlyList<ZhiJiaoHubLocalImageItem> Images,
|
||||
string Source,
|
||||
DateTimeOffset LastUpdated,
|
||||
int TotalCount);
|
||||
|
||||
public sealed record ZhiJiaoHubSyncResult(
|
||||
bool Success,
|
||||
ZhiJiaoHubLocalSnapshot? Snapshot,
|
||||
int DownloadedCount,
|
||||
int SkippedCount,
|
||||
int FailedCount,
|
||||
string? ErrorMessage = null);
|
||||
|
||||
public sealed class ZhiJiaoHubCacheService : IDisposable
|
||||
{
|
||||
private static readonly HttpClient DownloadClient;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _cacheDirectory;
|
||||
private readonly string _manifestPath;
|
||||
private readonly object _manifestLock = new();
|
||||
private bool _isDisposed;
|
||||
|
||||
static ZhiJiaoHubCacheService()
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
|
||||
};
|
||||
|
||||
DownloadClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
DownloadClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop/1.0");
|
||||
}
|
||||
|
||||
public ZhiJiaoHubCacheService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var dataDirectory = Path.Combine(appData, "LanMountainDesktop", "cache", "zhijiaohub");
|
||||
_cacheDirectory = dataDirectory;
|
||||
_manifestPath = Path.Combine(dataDirectory, "manifest.json");
|
||||
}
|
||||
|
||||
public string CacheDirectory => _cacheDirectory;
|
||||
|
||||
public bool HasLocalCache(string source)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
if (!File.Exists(_manifestPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
return manifest?.Entries?.ContainsKey(source) == true &&
|
||||
manifest.Entries[source].Images.Count > 0 &&
|
||||
Directory.Exists(GetSourceDirectory(source));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ZhiJiaoHubLocalSnapshot? LoadLocalSnapshot(string source)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
if (!File.Exists(_manifestPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
var images = entry.Images
|
||||
.Where(img => File.Exists(Path.Combine(sourceDir, img.LocalFileName)))
|
||||
.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
|
||||
img.Name,
|
||||
img.OriginalUrl,
|
||||
Path.Combine(sourceDir, img.LocalFileName),
|
||||
idx))
|
||||
.ToList();
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ZhiJiaoHubLocalSnapshot(
|
||||
images,
|
||||
source,
|
||||
entry.LastUpdated,
|
||||
images.Count);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ZhiJiaoHubSyncResult> SyncImagesAsync(
|
||||
string source,
|
||||
IReadOnlyList<ZhiJiaoHubImageItem> remoteImages,
|
||||
string mirrorSource,
|
||||
IProgress<(int Current, int Total, string Status)>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (remoteImages == null || remoteImages.Count == 0)
|
||||
{
|
||||
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, "No images to sync");
|
||||
}
|
||||
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var downloadedCount = 0;
|
||||
var skippedCount = 0;
|
||||
var failedCount = 0;
|
||||
var localImages = new List<CachedImageInfo>();
|
||||
|
||||
var existingFiles = new HashSet<string>(
|
||||
Directory.Exists(sourceDir)
|
||||
? Directory.GetFiles(sourceDir, "*.jpg").Concat(Directory.GetFiles(sourceDir, "*.png")).Concat(Directory.GetFiles(sourceDir, "*.gif"))
|
||||
: Array.Empty<string>(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < remoteImages.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var remoteImage = remoteImages[i];
|
||||
var fileName = GetSafeFileName(remoteImage.Name, remoteImage.Url);
|
||||
var localPath = Path.Combine(sourceDir, fileName);
|
||||
|
||||
progress?.Report((i + 1, remoteImages.Count, $"Downloading {remoteImage.Name}..."));
|
||||
|
||||
if (File.Exists(localPath))
|
||||
{
|
||||
skippedCount++;
|
||||
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var downloadUrl = ResolveDownloadUrl(remoteImage.Url, mirrorSource);
|
||||
using var response = await DownloadClient.GetAsync(downloadUrl, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var fileStream = File.Create(localPath);
|
||||
await response.Content.CopyToAsync(fileStream, cancellationToken);
|
||||
|
||||
downloadedCount++;
|
||||
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (localImages.Count == 0)
|
||||
{
|
||||
return new ZhiJiaoHubSyncResult(false, null, downloadedCount, skippedCount, failedCount, "All downloads failed");
|
||||
}
|
||||
|
||||
SaveManifest(source, localImages);
|
||||
|
||||
var snapshot = new ZhiJiaoHubLocalSnapshot(
|
||||
localImages.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
|
||||
img.Name,
|
||||
img.OriginalUrl,
|
||||
Path.Combine(sourceDir, img.LocalFileName),
|
||||
idx)).ToList(),
|
||||
source,
|
||||
DateTimeOffset.UtcNow,
|
||||
localImages.Count);
|
||||
|
||||
return new ZhiJiaoHubSyncResult(true, snapshot, downloadedCount, skippedCount, failedCount);
|
||||
}
|
||||
|
||||
public void ClearCache(string? source = null)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
if (source != null)
|
||||
{
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
if (Directory.Exists(sourceDir))
|
||||
{
|
||||
Directory.Delete(sourceDir, true);
|
||||
}
|
||||
|
||||
if (File.Exists(_manifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
if (manifest?.Entries != null && manifest.Entries.ContainsKey(source))
|
||||
{
|
||||
manifest.Entries.Remove(source);
|
||||
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Directory.Exists(_cacheDirectory))
|
||||
{
|
||||
Directory.Delete(_cacheDirectory, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSourceDirectory(string source)
|
||||
{
|
||||
return Path.Combine(_cacheDirectory, source.ToLowerInvariant().Replace(" ", "-"));
|
||||
}
|
||||
|
||||
private static string GetSafeFileName(string name, string url)
|
||||
{
|
||||
var ext = Path.GetExtension(new Uri(url).AbsolutePath);
|
||||
if (string.IsNullOrEmpty(ext) || ext.Length > 5)
|
||||
{
|
||||
ext = ".jpg";
|
||||
}
|
||||
|
||||
var safeName = string.Concat(name.Split(Path.GetInvalidFileNameChars()));
|
||||
if (string.IsNullOrWhiteSpace(safeName))
|
||||
{
|
||||
safeName = Guid.NewGuid().ToString("N")[..8];
|
||||
}
|
||||
|
||||
return $"{safeName}{ext}";
|
||||
}
|
||||
|
||||
private static string ResolveDownloadUrl(string originalUrl, string mirrorSource)
|
||||
{
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + originalUrl;
|
||||
}
|
||||
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
private void SaveManifest(string source, List<CachedImageInfo> images)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
CacheManifest manifest;
|
||||
if (File.Exists(_manifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions) ?? new CacheManifest();
|
||||
}
|
||||
catch
|
||||
{
|
||||
manifest = new CacheManifest();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
manifest = new CacheManifest();
|
||||
}
|
||||
|
||||
manifest.Entries[source] = new CacheEntry(images, DateTimeOffset.UtcNow);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
|
||||
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
}
|
||||
|
||||
private sealed class CacheManifest
|
||||
{
|
||||
public Dictionary<string, CacheEntry> Entries { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class CacheEntry
|
||||
{
|
||||
public List<CachedImageInfo> Images { get; set; }
|
||||
public DateTimeOffset LastUpdated { get; set; }
|
||||
|
||||
public CacheEntry(List<CachedImageInfo> images, DateTimeOffset lastUpdated)
|
||||
{
|
||||
Images = images;
|
||||
LastUpdated = lastUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CachedImageInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string OriginalUrl { get; set; }
|
||||
public string LocalFileName { get; set; }
|
||||
|
||||
public CachedImageInfo(string name, string originalUrl, string localFileName)
|
||||
{
|
||||
Name = name;
|
||||
OriginalUrl = originalUrl;
|
||||
LocalFileName = localFileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user