减少工程复杂度
This commit is contained in:
lincube
2026-03-04 16:43:10 +08:00
parent f78a56cb2c
commit b21bb490fa
27 changed files with 422 additions and 1744 deletions

View File

@@ -246,7 +246,7 @@
"artwork.widget.loading_subtitle": "Fetching today's masterpiece",
"artwork.widget.fetch_failed": "Artwork fetch failed",
"artwork.widget.fallback_title": "Daily Artwork",
"artwork.widget.fallback_artist": "Recommendation backend unavailable",
"artwork.widget.fallback_artist": "Recommendation service unavailable",
"artwork.widget.fallback_year": "Try again later",
"artwork.widget.unknown_artist": "Unknown artist",
"music.widget.unsupported": "Music control is not supported on this platform",

View File

@@ -246,7 +246,7 @@
"artwork.widget.loading_subtitle": "正在获取今日名画",
"artwork.widget.fetch_failed": "名画获取失败",
"artwork.widget.fallback_title": "每日名画",
"artwork.widget.fallback_artist": "推荐后端不可用",
"artwork.widget.fallback_artist": "推荐服务不可用",
"artwork.widget.fallback_year": "稍后重试",
"artwork.widget.unknown_artist": "未知作者",
"music.widget.unsupported": "当前平台不支持音乐控制",

View File

@@ -1,9 +1,4 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
@@ -35,14 +30,8 @@ public sealed record RecommendationQueryResult<T>(
}
}
public sealed record RecommendationBackendOptions
public sealed record RecommendationApiOptions
{
public string BaseUrl { get; init; } = "http://127.0.0.1:5057";
public string DailyArtworkPath { get; init; } = "/api/recommendation/daily-artwork";
public string DailyPoetryPath { get; init; } = "/api/recommendation/daily-poetry";
public string JinriShiciPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json";
public string ArtInstituteArtworkApiTemplate { get; init; } =
@@ -70,623 +59,3 @@ public interface IRecommendationInfoService
void ClearCache();
}
public sealed class RecommendationBackendService : IRecommendationInfoService, IDisposable
{
private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record ArtworkCandidate(
string Title,
string? Artist,
string? Year,
string? ArtworkUrl,
string? ImageId);
private readonly RecommendationBackendOptions _options;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly object _cacheGate = new();
private DailyArtworkCacheEntry? _dailyArtworkCache;
private DailyPoetryCacheEntry? _dailyPoetryCache;
public RecommendationBackendService(
RecommendationBackendOptions? options = null,
HttpClient? httpClient = null)
{
_options = options ?? new RecommendationBackendOptions();
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = _options.RequestTimeout
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
public void ClearCache()
{
lock (_cacheGate)
{
_dailyArtworkCache = null;
_dailyPoetryCache = null;
}
}
public async Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
DailyPoetryQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyPoetryQuery();
if (!normalizedQuery.ForceRefresh && TryGetDailyPoetryFromCache(out var cached))
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(cached);
}
var uri = BuildDailyPoetryUri(normalizedQuery.Locale, normalizedQuery.ForceRefresh);
string responseText;
try
{
using var response = await _httpClient.GetAsync(uri, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
"http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}",
cancellationToken);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
"network_error",
ex.Message,
cancellationToken);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
var success = ReadBool(root, "success");
if (!success.GetValueOrDefault())
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
ReadString(root, "errorCode") ?? "upstream_error",
ReadString(root, "errorMessage") ?? "Recommendation backend returned an unsuccessful response.",
cancellationToken);
}
if (!root.TryGetProperty("data", out var dataNode) || dataNode.ValueKind != JsonValueKind.Object)
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
"parse_error",
"Daily poetry payload is missing.",
cancellationToken);
}
var content = ReadString(dataNode, "content");
if (string.IsNullOrWhiteSpace(content))
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
"parse_error",
"Poetry content is missing.",
cancellationToken);
}
var snapshot = new DailyPoetrySnapshot(
Provider: ReadString(dataNode, "provider") ?? "RecommendationBackend",
Content: content.Trim(),
Origin: ReadString(dataNode, "origin"),
Author: ReadString(dataNode, "author"),
Category: ReadString(dataNode, "category"),
FetchedAt: ParseDateTimeOffset(ReadString(dataNode, "fetchedAt")) ?? DateTimeOffset.UtcNow);
SetDailyPoetryCache(snapshot);
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
"parse_error",
ex.Message,
cancellationToken);
}
}
public async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
DailyArtworkQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyArtworkQuery();
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached))
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(cached);
}
var uri = BuildDailyArtworkUri(normalizedQuery.Locale, normalizedQuery.ForceRefresh);
string responseText;
try
{
using var response = await _httpClient.GetAsync(uri, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return await TryDirectFallbackAsync(
normalizedQuery,
"http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}",
cancellationToken);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return await TryDirectFallbackAsync(
normalizedQuery,
"network_error",
ex.Message,
cancellationToken);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
var success = ReadBool(root, "success");
if (!success.GetValueOrDefault())
{
return await TryDirectFallbackAsync(
normalizedQuery,
ReadString(root, "errorCode") ?? "upstream_error",
ReadString(root, "errorMessage") ?? "Recommendation backend returned an unsuccessful response.",
cancellationToken);
}
if (!root.TryGetProperty("data", out var dataNode) || dataNode.ValueKind != JsonValueKind.Object)
{
return await TryDirectFallbackAsync(
normalizedQuery,
"parse_error",
"Daily artwork payload is missing.",
cancellationToken);
}
var title = ReadString(dataNode, "title");
if (string.IsNullOrWhiteSpace(title))
{
return await TryDirectFallbackAsync(
normalizedQuery,
"parse_error",
"Artwork title is missing.",
cancellationToken);
}
var snapshot = new DailyArtworkSnapshot(
Provider: ReadString(dataNode, "provider") ?? "RecommendationBackend",
Title: title.Trim(),
Artist: ReadString(dataNode, "artist"),
Year: ReadString(dataNode, "year"),
Museum: ReadString(dataNode, "museum"),
ArtworkUrl: ReadString(dataNode, "artworkUrl"),
ImageUrl: ReadString(dataNode, "imageUrl"),
FetchedAt: ParseDateTimeOffset(ReadString(dataNode, "fetchedAt")) ?? DateTimeOffset.UtcNow);
SetDailyArtworkCache(snapshot);
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return await TryDirectFallbackAsync(
normalizedQuery,
"parse_error",
ex.Message,
cancellationToken);
}
}
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> TryDirectFallbackAsync(
DailyArtworkQuery query,
string errorCode,
string errorMessage,
CancellationToken cancellationToken)
{
var fallback = await GetDailyArtworkDirectAsync(query, cancellationToken);
if (fallback.Success && fallback.Data is not null)
{
SetDailyArtworkCache(fallback.Data);
return fallback;
}
var fallbackMessage = string.IsNullOrWhiteSpace(fallback.ErrorMessage)
? "Direct upstream fallback failed."
: fallback.ErrorMessage;
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
errorCode,
$"{errorMessage}; fallback: {fallbackMessage}");
}
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkDirectAsync(
DailyArtworkQuery query,
CancellationToken cancellationToken)
{
var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100);
var localDate = GetChinaLocalDate();
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
var requestUrl = string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteArtworkApiTemplate,
page,
candidateCount);
string responseText;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
using var response = await _httpClient.SendAsync(request, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
"upstream_http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Artwork list is missing.");
}
var candidates = new List<ArtworkCandidate>();
foreach (var item in dataArray.EnumerateArray())
{
var title = ReadString(item, "title");
var imageId = ReadString(item, "image_id");
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId))
{
continue;
}
var artist = ReadString(item, "artist_title");
if (string.IsNullOrWhiteSpace(artist))
{
artist = ReadFirstNonEmptyLine(ReadString(item, "artist_display"));
}
candidates.Add(new ArtworkCandidate(
title.Trim(),
artist,
ReadString(item, "date_display"),
ReadString(item, "api_link"),
imageId.Trim()));
}
if (candidates.Count == 0)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_empty_result", "No artwork candidates were returned.");
}
var indexSeed = localDate.Year * 1000 + localDate.DayOfYear;
var selected = candidates[Math.Abs(indexSeed) % candidates.Count];
var snapshot = new DailyArtworkSnapshot(
Provider: "ArtInstituteOfChicago",
Title: selected.Title,
Artist: selected.Artist,
Year: selected.Year,
Museum: "The Art Institute of Chicago",
ArtworkUrl: selected.ArtworkUrl,
ImageUrl: BuildArtworkImageUrl(selected.ImageId),
FetchedAt: DateTimeOffset.UtcNow);
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
private async Task<RecommendationQueryResult<DailyPoetrySnapshot>> TryDirectPoetryFallbackAsync(
DailyPoetryQuery query,
string errorCode,
string errorMessage,
CancellationToken cancellationToken)
{
var fallback = await GetDailyPoetryDirectAsync(query, cancellationToken);
if (fallback.Success && fallback.Data is not null)
{
SetDailyPoetryCache(fallback.Data);
return fallback;
}
var fallbackMessage = string.IsNullOrWhiteSpace(fallback.ErrorMessage)
? "Direct upstream fallback failed."
: fallback.ErrorMessage;
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
errorCode,
$"{errorMessage}; fallback: {fallbackMessage}");
}
private async Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryDirectAsync(
DailyPoetryQuery query,
CancellationToken cancellationToken)
{
_ = query;
string responseText;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, _options.JinriShiciPoetryUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
using var response = await _httpClient.SendAsync(request, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
"upstream_http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("upstream_network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
var content = ReadString(root, "content");
if (string.IsNullOrWhiteSpace(content))
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
"upstream_parse_error",
"Poetry content is empty.");
}
var snapshot = new DailyPoetrySnapshot(
Provider: "JinriShici",
Content: content.Trim(),
Origin: ReadString(root, "origin"),
Author: ReadString(root, "author"),
Category: ReadString(root, "category"),
FetchedAt: DateTimeOffset.UtcNow);
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
private Uri BuildDailyArtworkUri(string? locale, bool forceRefresh)
{
var baseUrl = _options.BaseUrl.TrimEnd('/');
var path = _options.DailyArtworkPath.StartsWith("/", StringComparison.Ordinal)
? _options.DailyArtworkPath
: $"/{_options.DailyArtworkPath}";
var localePart = string.IsNullOrWhiteSpace(locale)
? string.Empty
: $"locale={Uri.EscapeDataString(locale.Trim())}&";
var forcePart = forceRefresh ? "true" : "false";
return new Uri($"{baseUrl}{path}?{localePart}forceRefresh={forcePart}", UriKind.Absolute);
}
private Uri BuildDailyPoetryUri(string? locale, bool forceRefresh)
{
var baseUrl = _options.BaseUrl.TrimEnd('/');
var path = _options.DailyPoetryPath.StartsWith("/", StringComparison.Ordinal)
? _options.DailyPoetryPath
: $"/{_options.DailyPoetryPath}";
var localePart = string.IsNullOrWhiteSpace(locale)
? string.Empty
: $"locale={Uri.EscapeDataString(locale.Trim())}&";
var forcePart = forceRefresh ? "true" : "false";
return new Uri($"{baseUrl}{path}?{localePart}forceRefresh={forcePart}", UriKind.Absolute);
}
private bool TryGetDailyArtworkFromCache(out DailyArtworkSnapshot snapshot)
{
lock (_cacheGate)
{
if (_dailyArtworkCache is not null && _dailyArtworkCache.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = _dailyArtworkCache.Snapshot;
return true;
}
}
snapshot = null!;
return false;
}
private void SetDailyArtworkCache(DailyArtworkSnapshot snapshot)
{
lock (_cacheGate)
{
_dailyArtworkCache = new DailyArtworkCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
}
}
private bool TryGetDailyPoetryFromCache(out DailyPoetrySnapshot snapshot)
{
lock (_cacheGate)
{
if (_dailyPoetryCache is not null && _dailyPoetryCache.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = _dailyPoetryCache.Snapshot;
return true;
}
}
snapshot = null!;
return false;
}
private void SetDailyPoetryCache(DailyPoetrySnapshot snapshot)
{
lock (_cacheGate)
{
_dailyPoetryCache = new DailyPoetryCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
}
}
private static string? ReadString(JsonElement node, params string[] path)
{
var target = TryGetNode(node, path);
if (!target.HasValue)
{
return null;
}
return target.Value.ValueKind switch
{
JsonValueKind.String => target.Value.GetString(),
JsonValueKind.Number => target.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null
};
}
private static bool? ReadBool(JsonElement node, params string[] path)
{
var target = TryGetNode(node, path);
if (!target.HasValue)
{
return null;
}
return target.Value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String when bool.TryParse(target.Value.GetString(), out var parsed) => parsed,
_ => null
};
}
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
{
var current = node;
foreach (var segment in path)
{
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next))
{
return null;
}
current = next;
}
return current;
}
private static DateTimeOffset? ParseDateTimeOffset(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
}
private string? BuildArtworkImageUrl(string? imageId)
{
if (string.IsNullOrWhiteSpace(imageId))
{
return null;
}
return string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteImageUrlTemplate,
imageId.Trim());
}
private static string? ReadFirstNonEmptyLine(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return text
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
}
private static DateOnly GetChinaLocalDate()
{
var now = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8));
return DateOnly.FromDateTime(now.Date);
}
private static string Truncate(string? text, int maxLength)
{
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
return text.Length <= maxLength
? text
: $"{text[..maxLength]}...";
}
}

View File

@@ -0,0 +1,358 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class RecommendationDataService : IRecommendationInfoService, IDisposable
{
private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record ArtworkCandidate(
string Title,
string? Artist,
string? Year,
string? ArtworkUrl,
string? ImageId);
private readonly RecommendationApiOptions _options;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly object _cacheGate = new();
private DailyArtworkCacheEntry? _dailyArtworkCache;
private DailyPoetryCacheEntry? _dailyPoetryCache;
public RecommendationDataService(
RecommendationApiOptions? options = null,
HttpClient? httpClient = null)
{
_options = options ?? new RecommendationApiOptions();
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = _options.RequestTimeout
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
public void ClearCache()
{
lock (_cacheGate)
{
_dailyArtworkCache = null;
_dailyPoetryCache = null;
}
}
public async Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
DailyPoetryQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyPoetryQuery();
if (!normalizedQuery.ForceRefresh && TryGetDailyPoetryFromCache(out var cached))
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(cached);
}
string responseText;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, _options.JinriShiciPoetryUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
using var response = await _httpClient.SendAsync(request, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
"upstream_http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("upstream_network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
var content = ReadString(root, "content");
if (string.IsNullOrWhiteSpace(content))
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
"upstream_parse_error",
"Poetry content is empty.");
}
var snapshot = new DailyPoetrySnapshot(
Provider: "JinriShici",
Content: content.Trim(),
Origin: ReadString(root, "origin"),
Author: ReadString(root, "author"),
Category: ReadString(root, "category"),
FetchedAt: DateTimeOffset.UtcNow);
SetDailyPoetryCache(snapshot);
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
public async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
DailyArtworkQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyArtworkQuery();
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached))
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(cached);
}
var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100);
var localDate = GetChinaLocalDate();
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
var requestUrl = string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteArtworkApiTemplate,
page,
candidateCount);
string responseText;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
using var response = await _httpClient.SendAsync(request, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
"upstream_http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Artwork list is missing.");
}
var candidates = new List<ArtworkCandidate>();
foreach (var item in dataArray.EnumerateArray())
{
var title = ReadString(item, "title");
var imageId = ReadString(item, "image_id");
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId))
{
continue;
}
var artist = ReadString(item, "artist_title");
if (string.IsNullOrWhiteSpace(artist))
{
artist = ReadFirstNonEmptyLine(ReadString(item, "artist_display"));
}
candidates.Add(new ArtworkCandidate(
title.Trim(),
artist,
ReadString(item, "date_display"),
ReadString(item, "api_link"),
imageId.Trim()));
}
if (candidates.Count == 0)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_empty_result", "No artwork candidates were returned.");
}
var indexSeed = localDate.Year * 1000 + localDate.DayOfYear;
var selected = candidates[Math.Abs(indexSeed) % candidates.Count];
var snapshot = new DailyArtworkSnapshot(
Provider: "ArtInstituteOfChicago",
Title: selected.Title,
Artist: selected.Artist,
Year: selected.Year,
Museum: "The Art Institute of Chicago",
ArtworkUrl: selected.ArtworkUrl,
ImageUrl: BuildArtworkImageUrl(selected.ImageId),
FetchedAt: DateTimeOffset.UtcNow);
SetDailyArtworkCache(snapshot);
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
private bool TryGetDailyArtworkFromCache(out DailyArtworkSnapshot snapshot)
{
lock (_cacheGate)
{
if (_dailyArtworkCache is not null && _dailyArtworkCache.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = _dailyArtworkCache.Snapshot;
return true;
}
}
snapshot = null!;
return false;
}
private void SetDailyArtworkCache(DailyArtworkSnapshot snapshot)
{
lock (_cacheGate)
{
_dailyArtworkCache = new DailyArtworkCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
}
}
private bool TryGetDailyPoetryFromCache(out DailyPoetrySnapshot snapshot)
{
lock (_cacheGate)
{
if (_dailyPoetryCache is not null && _dailyPoetryCache.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = _dailyPoetryCache.Snapshot;
return true;
}
}
snapshot = null!;
return false;
}
private void SetDailyPoetryCache(DailyPoetrySnapshot snapshot)
{
lock (_cacheGate)
{
_dailyPoetryCache = new DailyPoetryCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
}
}
private static string? ReadString(JsonElement node, params string[] path)
{
var target = TryGetNode(node, path);
if (!target.HasValue)
{
return null;
}
return target.Value.ValueKind switch
{
JsonValueKind.String => target.Value.GetString(),
JsonValueKind.Number => target.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null
};
}
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
{
var current = node;
foreach (var segment in path)
{
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next))
{
return null;
}
current = next;
}
return current;
}
private string? BuildArtworkImageUrl(string? imageId)
{
if (string.IsNullOrWhiteSpace(imageId))
{
return null;
}
return string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteImageUrlTemplate,
imageId.Trim());
}
private static string? ReadFirstNonEmptyLine(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return text
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
}
private static DateOnly GetChinaLocalDate()
{
var now = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8));
return DateOnly.FromDateTime(now.Date);
}
private static string Truncate(string? text, int maxLength)
{
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
return text.Length <= maxLength
? text
: $"{text[..maxLength]}...";
}
}

View File

@@ -16,7 +16,7 @@ using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
{
private static readonly IReadOnlyDictionary<DayOfWeek, string> ZhWeekdays =
new Dictionary<DayOfWeek, string>
@@ -45,7 +45,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationBackendService();
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private readonly DispatcherTimer _refreshTimer = new()
{
@@ -113,6 +113,15 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget
UpdateAdaptiveLayout();
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshArtworkAsync(forceRefresh: false);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
@@ -266,7 +275,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget
StatusTextBlock.IsVisible = true;
StatusTextBlock.Text = L("artwork.widget.fetch_failed", "Artwork fetch failed");
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.fallback_title", "Daily Artwork"));
ArtistTextBlock.Text = L("artwork.widget.fallback_artist", "Recommendation backend unavailable");
ArtistTextBlock.Text = L("artwork.widget.fallback_artist", "Recommendation service unavailable");
YearTextBlock.Text = L("artwork.widget.fallback_year", "Try again later");
UpdateAdaptiveLayout();
}

View File

@@ -16,7 +16,7 @@ using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget
public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
{
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly char[] NaturalBreakChars =
@@ -40,7 +40,7 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget
private static readonly HashSet<char> NaturalBreakCharSet = new(NaturalBreakChars);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationBackendService();
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
@@ -143,6 +143,15 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget
ApplyModeVisualIfNeeded(force: true);
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshPoetryAsync(forceRefresh: false);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;

View File

@@ -37,7 +37,11 @@ public sealed class DesktopComponentRuntimeDescriptor
public string DisplayNameLocalizationKey { get; }
public Control CreateControl(double cellSize, TimeZoneService timeZoneService, IWeatherInfoService weatherInfoService)
public Control CreateControl(
double cellSize,
TimeZoneService timeZoneService,
IWeatherInfoService weatherInfoService,
IRecommendationInfoService recommendationInfoService)
{
var control = _controlFactory();
if (control is IDesktopComponentWidget sizedComponent)
@@ -55,6 +59,11 @@ public sealed class DesktopComponentRuntimeDescriptor
weatherInfoAwareComponent.SetWeatherInfoService(weatherInfoService);
}
if (control is IRecommendationInfoAwareComponentWidget recommendationInfoAwareComponent)
{
recommendationInfoAwareComponent.SetRecommendationInfoService(recommendationInfoService);
}
return control;
}

View File

@@ -18,6 +18,11 @@ public interface IWeatherInfoAwareComponentWidget
void SetWeatherInfoService(IWeatherInfoService weatherInfoService);
}
public interface IRecommendationInfoAwareComponentWidget
{
void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService);
}
public interface IDesktopPageVisibilityAwareComponentWidget
{
void SetDesktopPageContext(bool isOnActivePage, bool isEditMode);

View File

@@ -1425,7 +1425,11 @@ public partial class MainWindow
return null;
}
var component = runtimeDescriptor.CreateControl(_currentDesktopCellSize, _timeZoneService, _weatherDataService);
var component = runtimeDescriptor.CreateControl(
_currentDesktopCellSize,
_timeZoneService,
_weatherDataService,
_recommendationInfoService);
component.Classes.Add(DesktopComponentClass);
return component;
}
@@ -2533,7 +2537,11 @@ public partial class MainWindow
var previewHeight = previewSpan.HeightCells * previewCellSize;
var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110);
var previewControl = descriptor.CreateControl(renderCellSize, _timeZoneService, _weatherDataService);
var previewControl = descriptor.CreateControl(
renderCellSize,
_timeZoneService,
_weatherDataService,
_recommendationInfoService);
var previewSurface = new Border
{

View File

@@ -91,6 +91,7 @@ public partial class MainWindow : Window
private readonly LocalizationService _localizationService = new();
private readonly TimeZoneService _timeZoneService = new();
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
.CreateDefault()
.RegisterExtensions(
@@ -275,6 +276,10 @@ public partial class MainWindow : Window
{
weatherServiceDisposable.Dispose();
}
if (_recommendationInfoService is IDisposable recommendationServiceDisposable)
{
recommendationServiceDisposable.Dispose();
}
_wallpaperBitmap?.Dispose();
_wallpaperBitmap = null;
PropertyChanged -= OnWindowPropertyChanged;