2026-10-04 11:03:14 +02:00
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
|
|
|
|
|
using Blazored.LocalStorage;
|
|
|
|
|
|
|
|
|
|
using decePubClient.Extensions;
|
|
|
|
|
using decePubClient.Helpers;
|
|
|
|
|
using decePubClient.Models;
|
|
|
|
|
using decePubClient.Models.Mastodon;
|
|
|
|
|
|
|
|
|
|
namespace decePubClient.Services
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// What PrivaPub knows about the servers the feed shows (/api/privapub/v1/instances, up to 40 at once), cached in
|
|
|
|
|
/// memory and localStorage for a day, and where each feed item is drawn. A post is placed, best first: at its own
|
|
|
|
|
/// place (Pixelfed), at its event's venue, at the reader for a located post, at its author's server (0.1°), or at a
|
2026-10-04 11:44:51 +02:00
|
|
|
/// point in the server's country when PrivaPub located it only to a country. A CDN-fronted server has no place: its
|
|
|
|
|
/// posts join their CDN's group. A server's weekly history and a CDN's page are read on demand, once per session.
|
2026-10-04 11:03:14 +02:00
|
|
|
/// </summary>
|
|
|
|
|
public class InstanceService(IHttpService httpService, ILocalStorageService localStorage, AppConfiguration appConfiguration, ILoggingService logger)
|
|
|
|
|
{
|
|
|
|
|
const int MaxHostsPerRead = 40;
|
|
|
|
|
const string StorageKey = "ServerInfos";
|
|
|
|
|
static readonly TimeSpan Lifetime = TimeSpan.FromDays(1);
|
|
|
|
|
static readonly TimeSpan UnknownLifetime = TimeSpan.FromHours(1);
|
|
|
|
|
|
|
|
|
|
readonly Dictionary<string, ServerInfo> servers = new(StringComparer.OrdinalIgnoreCase);
|
2026-10-04 11:44:51 +02:00
|
|
|
readonly Dictionary<string, List<InstanceWeek>> histories = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
readonly Dictionary<string, CdnDetail> cdns = new(StringComparer.OrdinalIgnoreCase);
|
2026-10-04 11:03:14 +02:00
|
|
|
bool loaded;
|
|
|
|
|
string selfHost;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// PrivaPub's own host, the point every arc starts from: the host of the signed-in persona's account (CascadingState
|
|
|
|
|
/// sets it), since the API may be reached under another name; the API's host until then.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string SelfHost
|
|
|
|
|
{
|
|
|
|
|
get => selfHost ?? appConfiguration.ApiHost;
|
|
|
|
|
set => selfHost = value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public ServerInfo Known(string host) => host is not null && servers.TryGetValue(host, out var server) && !server.Unknown ? server : null;
|
|
|
|
|
|
|
|
|
|
public async Task<ServerInfo> Self(CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
await Resolve([SelfHost], cancellationToken);
|
|
|
|
|
return Known(SelfHost);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Reads the servers not known yet (or known for too long) from PrivaPub.</summary>
|
|
|
|
|
public async Task Resolve(IEnumerable<string> hosts, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
await Load(cancellationToken);
|
|
|
|
|
var now = DateTime.UtcNow;
|
|
|
|
|
var wanted = hosts.Where(host => host is { Length: > 0 }).Distinct(StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
.Where(host => !servers.TryGetValue(host, out var server) || now - server.FetchedAt > (server.Unknown ? UnknownLifetime : Lifetime))
|
|
|
|
|
.ToList();
|
|
|
|
|
if (wanted.Count == 0)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
foreach (var chunk in wanted.Chunk(MaxHostsPerRead))
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_Instances, [.. chunk.Select(host => $"host[]={Uri.EscapeDataString(host)}")],
|
|
|
|
|
cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
var described = await response.MastodonReadFromJsonAsync<List<InstanceDescription>>(cancellationToken) ?? [];
|
|
|
|
|
foreach (var host in chunk)
|
|
|
|
|
servers[host] = described.FirstOrDefault(d => string.Equals(d.Host, host, StringComparison.OrdinalIgnoreCase)) is { } description
|
|
|
|
|
? ToServerInfo(description, now)
|
|
|
|
|
: new() { Host = host, FetchedAt = now, Unknown = true };
|
|
|
|
|
}
|
|
|
|
|
await Save(cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 11:44:51 +02:00
|
|
|
public List<InstanceWeek> KnownHistory(string host) => host is not null ? histories.GetValueOrDefault(host) : null;
|
|
|
|
|
|
|
|
|
|
public CdnDetail KnownCdn(string key) => key is not null ? cdns.GetValueOrDefault(key) : null;
|
|
|
|
|
|
|
|
|
|
/// <summary>The server's weekly snapshots, newest first; empty when PrivaPub has none.</summary>
|
|
|
|
|
public async Task<List<InstanceWeek>> History(string host, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
if (host is null)
|
|
|
|
|
return [];
|
|
|
|
|
if (KnownHistory(host) is { } known)
|
|
|
|
|
return known;
|
|
|
|
|
|
|
|
|
|
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_M_InstanceHistory(host), [$"weeks=52"], cancellationToken: cancellationToken);
|
|
|
|
|
var history = response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<List<InstanceWeek>>(cancellationToken) ?? [] : [];
|
|
|
|
|
histories[host] = history;
|
|
|
|
|
return history;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>A CDN's servers and its weeks, or null when PrivaPub does not know it.</summary>
|
|
|
|
|
public async Task<CdnDetail> Cdn(string key, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
if (key is null)
|
|
|
|
|
return null;
|
|
|
|
|
if (KnownCdn(key) is { } known)
|
|
|
|
|
return known;
|
|
|
|
|
|
|
|
|
|
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_M_Cdn(key), [$"weeks=12"], cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return null;
|
|
|
|
|
var cdn = await response.MastodonReadFromJsonAsync<CdnDetail>(cancellationToken);
|
|
|
|
|
if (cdn is not null)
|
|
|
|
|
cdns[key] = cdn;
|
|
|
|
|
return cdn;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 11:03:14 +02:00
|
|
|
/// <summary>Places each post; nearYou is the reader's position, for located posts.</summary>
|
|
|
|
|
public void Locate(IEnumerable<FeedPost> posts, (double Latitude, double Longitude)? nearYou = default)
|
|
|
|
|
{
|
|
|
|
|
foreach (var post in posts)
|
|
|
|
|
post.Location = Locate(post, nearYou);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FeedLocation Locate(FeedPost post, (double Latitude, double Longitude)? nearYou)
|
|
|
|
|
{
|
|
|
|
|
if (post.Status?.Privapub?.Place is { Latitude: { } placeLatitude, Longitude: { } placeLongitude } place)
|
|
|
|
|
return new(placeLatitude, placeLongitude, LocationSource.Place, JoinLabel(place.Name, place.Country), 1);
|
|
|
|
|
|
|
|
|
|
if (post.Status?.Privapub?.Event?.Places.FirstOrDefault(p => p.Latitude is not null && p.Longitude is not null) is { } venue)
|
|
|
|
|
return new(venue.Latitude!.Value, venue.Longitude!.Value, LocationSource.Event, venue.Name ?? venue.Address, 1);
|
|
|
|
|
|
|
|
|
|
if (post.Located && nearYou is { } reader)
|
|
|
|
|
return new(reader.Latitude, reader.Longitude, LocationSource.NearYou, null, post.RangeKm ?? 5);
|
|
|
|
|
|
|
|
|
|
var geo = Known(post.Host)?.Geo;
|
2026-10-04 11:44:51 +02:00
|
|
|
if (geo?.Cdn is { Length: > 0 } cdn)
|
|
|
|
|
return new(0, 0, LocationSource.Cdn, cdn, 0, geo.CdnDomain ?? cdn.ToLowerInvariant(), geo.BeforeCdn);
|
|
|
|
|
|
2026-10-04 11:03:14 +02:00
|
|
|
if (geo is { Latitude: { } latitude, Longitude: { } longitude })
|
|
|
|
|
return new(latitude, longitude, LocationSource.Server, JoinLabel(geo.City, CountryName(geo.Country)), 11);
|
|
|
|
|
|
|
|
|
|
if (CountryPoints.Of(geo?.Country) is { } country)
|
|
|
|
|
return new(country.Latitude, country.Longitude, LocationSource.Country, CountryName(geo.Country), 500);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The country's English name, or the code when .NET does not know it. Not DisplayName: in the browser it falls back
|
|
|
|
|
/// to the country's own language ("日本").
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static string CountryName(string countryCode)
|
|
|
|
|
{
|
|
|
|
|
if (countryCode is not { Length: 2 })
|
|
|
|
|
return countryCode;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
return new System.Globalization.RegionInfo(countryCode).EnglishName;
|
|
|
|
|
}
|
|
|
|
|
catch (ArgumentException)
|
|
|
|
|
{
|
|
|
|
|
return countryCode;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string JoinLabel(params string[] parts) => string.Join(", ", parts.Where(part => !string.IsNullOrWhiteSpace(part)));
|
|
|
|
|
|
|
|
|
|
static ServerInfo ToServerInfo(InstanceDescription description, DateTime fetchedAt) => new()
|
|
|
|
|
{
|
|
|
|
|
Host = description.Host,
|
|
|
|
|
Software = description.Software,
|
|
|
|
|
Version = description.Version,
|
|
|
|
|
NodeName = description.NodeName,
|
|
|
|
|
OpenRegistrations = description.OpenRegistrations,
|
|
|
|
|
Users = Number(description.NodeInfo, "usage", "users", "total"),
|
|
|
|
|
ActiveMonth = Number(description.NodeInfo, "usage", "users", "activeMonth"),
|
|
|
|
|
LocalPosts = Number(description.NodeInfo, "usage", "localPosts"),
|
|
|
|
|
Geo = description.Geo,
|
|
|
|
|
ConsecutiveFailures = description.Delivery?.ConsecutiveFailures ?? 0,
|
|
|
|
|
LastSuccessAt = description.Delivery?.LastSuccessAt,
|
|
|
|
|
LastFailureAt = description.Delivery?.LastFailureAt,
|
|
|
|
|
DescribedAt = description.DescribedAt,
|
|
|
|
|
DescriptionError = description.DescriptionError,
|
|
|
|
|
FetchedAt = fetchedAt
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static long? Number(JsonNode node, params string[] path)
|
|
|
|
|
{
|
|
|
|
|
foreach (var step in path)
|
|
|
|
|
node = node is JsonObject obj ? obj[step] : null;
|
|
|
|
|
return node is JsonValue value && value.TryGetValue<long>(out var number) ? number : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task Load(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (loaded)
|
|
|
|
|
return;
|
|
|
|
|
loaded = true;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
foreach (var server in await localStorage.GetItemAsync<List<ServerInfo>>(StorageKey, cancellationToken) ?? [])
|
|
|
|
|
servers.TryAdd(server.Host, server);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
await logger.ProcessWarning(ex.Message, $"{nameof(InstanceService)}.{nameof(Load)}", cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task Save(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var fresh = servers.Values.Where(server => DateTime.UtcNow - server.FetchedAt <= Lifetime).ToList();
|
|
|
|
|
await localStorage.SetItemAsync(StorageKey, fresh, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
await logger.ProcessWarning(ex.Message, $"{nameof(InstanceService)}.{nameof(Save)}", cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|