using System.Text.Json.Nodes; using Blazored.LocalStorage; using decePubClient.Extensions; using decePubClient.Helpers; using decePubClient.Models; using decePubClient.Models.Mastodon; namespace decePubClient.Services { /// /// 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 /// 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. /// 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 servers = new(StringComparer.OrdinalIgnoreCase); readonly Dictionary> histories = new(StringComparer.OrdinalIgnoreCase); readonly Dictionary cdns = new(StringComparer.OrdinalIgnoreCase); bool loaded; string selfHost; /// /// 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. /// 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 Self(CancellationToken cancellationToken = default) { await Resolve([SelfHost], cancellationToken); return Known(SelfHost); } /// Reads the servers not known yet (or known for too long) from PrivaPub. public async Task Resolve(IEnumerable 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>(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); } public List KnownHistory(string host) => host is not null ? histories.GetValueOrDefault(host) : null; public CdnDetail KnownCdn(string key) => key is not null ? cdns.GetValueOrDefault(key) : null; /// The server's weekly snapshots, newest first; empty when PrivaPub has none. public async Task> 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>(cancellationToken) ?? [] : []; histories[host] = history; return history; } /// A CDN's servers and its weeks, or null when PrivaPub does not know it. public async Task 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(cancellationToken); if (cdn is not null) cdns[key] = cdn; return cdn; } /// Places each post; nearYou is the reader's position, for located posts. public void Locate(IEnumerable 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; if (geo?.Cdn is { Length: > 0 } cdn) return new(0, 0, LocationSource.Cdn, cdn, 0, geo.CdnDomain ?? cdn.ToLowerInvariant(), geo.BeforeCdn); 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; } /// /// 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 ("日本"). /// 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(out var number) ? number : null; } async Task Load(CancellationToken cancellationToken) { if (loaded) return; loaded = true; try { foreach (var server in await localStorage.GetItemAsync>(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); } } } }