Vanguard media stack: ingest, copyparty, Syncthing, normalize sweep, Authentik, Jellyfin + Vanguard Metadata plugin, MariaDB, Gitea, bot runtime
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
public sealed record DiscordMember(string UserId, string Username, string DisplayName, string? AvatarUrl);
|
||||
public sealed record DiscordEvent(string Id, string Name, string? Description, DateTimeOffset Start, DateTimeOffset? End, string? Status, DateTimeOffset SeenAt);
|
||||
|
||||
/// <summary>Thin REST client for the Discord bot API (no gateway). Uses the same application Authentik uses; add a bot user to it.</summary>
|
||||
public sealed class DiscordClient
|
||||
{
|
||||
private const string Api = "https://discord.com/api/v10";
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly ILogger<DiscordClient> _log;
|
||||
|
||||
public DiscordClient(IHttpClientFactory http, ILogger<DiscordClient> log) { _http = http; _log = log; }
|
||||
|
||||
private HttpClient Client(string token)
|
||||
{
|
||||
var c = _http.CreateClient(NamedClient.Default);
|
||||
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bot", token);
|
||||
return c;
|
||||
}
|
||||
|
||||
public async Task<DiscordMember?> GetGuildMemberAsync(string token, string guildId, string userId, CancellationToken ct)
|
||||
{
|
||||
using var resp = await Client(token).GetAsync($"{Api}/guilds/{guildId}/members/{userId}", ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_log.LogWarning("Discord member lookup {User} failed: {Status}", userId, resp.StatusCode);
|
||||
return null;
|
||||
}
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||
var root = doc.RootElement;
|
||||
var user = root.GetProperty("user");
|
||||
var username = user.GetProperty("username").GetString() ?? userId;
|
||||
var global = user.TryGetProperty("global_name", out var g) && g.ValueKind == JsonValueKind.String ? g.GetString() : null;
|
||||
var nick = root.TryGetProperty("nick", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null;
|
||||
var guildAvatar = root.TryGetProperty("avatar", out var ga) && ga.ValueKind == JsonValueKind.String ? ga.GetString() : null;
|
||||
var userAvatar = user.TryGetProperty("avatar", out var ua) && ua.ValueKind == JsonValueKind.String ? ua.GetString() : null;
|
||||
string? avatarUrl = guildAvatar is not null
|
||||
? $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{guildAvatar}.png?size=512"
|
||||
: userAvatar is not null ? $"https://cdn.discordapp.com/avatars/{userId}/{userAvatar}.png?size=512"
|
||||
: $"https://cdn.discordapp.com/embed/avatars/{(ulong.TryParse(userId, out var id) ? (id >> 22) % 6 : 0)}.png";
|
||||
return new DiscordMember(userId, username, nick ?? global ?? username, avatarUrl);
|
||||
}
|
||||
|
||||
/// <summary>Post an embed to a channel. <paramref name="fields"/> are (name, value) pairs.</summary>
|
||||
public async Task<bool> PostEmbedAsync(string token, string channelId, string title, string? description, string? url, string? thumbnailUrl, IEnumerable<(string Name, string Value)> fields, int color, CancellationToken ct, byte[]? imageBytes = null, string imageName = "thumb.jpg")
|
||||
{
|
||||
var embed = new Dictionary<string, object?>
|
||||
{
|
||||
["title"] = title.Length > 256 ? title[..256] : title,
|
||||
["description"] = description is { Length: > 0 } ? (description.Length > 2000 ? description[..2000] : description) : null,
|
||||
["url"] = url,
|
||||
["color"] = color,
|
||||
["timestamp"] = DateTimeOffset.UtcNow.ToString("o"),
|
||||
["fields"] = fields.Select(f => new { name = f.Name, value = f.Value, inline = true }).ToArray(),
|
||||
};
|
||||
if (thumbnailUrl is not null) embed["thumbnail"] = new { url = thumbnailUrl };
|
||||
if (imageBytes is { Length: > 0 }) embed["image"] = new { url = $"attachment://{imageName}" }; // big picture = the frame grab
|
||||
var payload = JsonSerializer.Serialize(new { embeds = new[] { embed } }, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
||||
|
||||
HttpContent content;
|
||||
if (imageBytes is { Length: > 0 })
|
||||
{
|
||||
var mp = new MultipartFormDataContent();
|
||||
mp.Add(new StringContent(payload, System.Text.Encoding.UTF8, "application/json"), "payload_json");
|
||||
var file = new ByteArrayContent(imageBytes);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue(imageName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" : "image/jpeg");
|
||||
mp.Add(file, "files[0]", imageName);
|
||||
content = mp;
|
||||
}
|
||||
else
|
||||
{
|
||||
content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
||||
}
|
||||
using var _ = content;
|
||||
using var resp = await Client(token).PostAsync($"{Api}/channels/{channelId}/messages", content, ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode) _log.LogWarning("Discord post to {Channel} failed: {Status} {Body}", channelId, resp.StatusCode, await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscordEvent>> GetScheduledEventsAsync(string token, string guildId, CancellationToken ct)
|
||||
{
|
||||
using var resp = await Client(token).GetAsync($"{Api}/guilds/{guildId}/scheduled-events", ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_log.LogWarning("Discord scheduled-events failed: {Status}", resp.StatusCode);
|
||||
return [];
|
||||
}
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||
var list = new List<DiscordEvent>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var e in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var start = e.GetProperty("scheduled_start_time").GetDateTimeOffset();
|
||||
DateTimeOffset? end = e.TryGetProperty("scheduled_end_time", out var et) && et.ValueKind == JsonValueKind.String ? et.GetDateTimeOffset() : null;
|
||||
list.Add(new DiscordEvent(
|
||||
e.GetProperty("id").GetString()!,
|
||||
e.GetProperty("name").GetString() ?? "(event)",
|
||||
e.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null,
|
||||
start, end,
|
||||
e.TryGetProperty("status", out var st) ? st.ToString() : null,
|
||||
now));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user