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,50 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
/// <summary>Resolves an Authentik username to the Discord user id stored on their OAuth source connection.</summary>
|
||||
public sealed class AuthentikClient
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly ILogger<AuthentikClient> _log;
|
||||
|
||||
public AuthentikClient(IHttpClientFactory http, ILogger<AuthentikClient> log) { _http = http; _log = log; }
|
||||
|
||||
public async Task<string?> GetDiscordIdForUsernameAsync(string baseUrl, string token, string username, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(token)) return null;
|
||||
var c = _http.CreateClient(NamedClient.Default);
|
||||
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var b = baseUrl.TrimEnd('/');
|
||||
try
|
||||
{
|
||||
using var u = await c.GetAsync($"{b}/api/v3/core/users/?username={Uri.EscapeDataString(username)}", ct).ConfigureAwait(false);
|
||||
if (!u.IsSuccessStatusCode) { _log.LogWarning("Authentik users lookup {User}: {Status}", username, u.StatusCode); return null; }
|
||||
using var ud = JsonDocument.Parse(await u.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||
var results = ud.RootElement.GetProperty("results");
|
||||
if (results.GetArrayLength() == 0) return null;
|
||||
var pk = results[0].GetProperty("pk").GetInt32();
|
||||
|
||||
using var cres = await c.GetAsync($"{b}/api/v3/sources/user_connections/oauth/?user={pk}", ct).ConfigureAwait(false);
|
||||
if (!cres.IsSuccessStatusCode) { _log.LogWarning("Authentik connections lookup {User}: {Status}", username, cres.StatusCode); return null; }
|
||||
using var cd = JsonDocument.Parse(await cres.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||
foreach (var conn in cd.RootElement.GetProperty("results").EnumerateArray())
|
||||
{
|
||||
var slug = conn.TryGetProperty("source", out var src) && src.ValueKind == JsonValueKind.Object && src.TryGetProperty("slug", out var sl) ? sl.GetString() : null;
|
||||
var ident = conn.TryGetProperty("identifier", out var idp) ? idp.GetString() : null;
|
||||
if (!string.IsNullOrEmpty(ident) && (slug is null || slug.Contains("discord", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return ident;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
|
||||
{
|
||||
_log.LogWarning(ex, "Authentik lookup failed for {User}", username);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent history of Discord scheduled events (Discord only returns upcoming/active ones, so we keep
|
||||
/// everything we have ever seen) plus an in-memory member cache.
|
||||
/// </summary>
|
||||
public sealed class EventStore
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly string _path;
|
||||
private Dictionary<string, DiscordEvent> _events = new();
|
||||
private readonly Dictionary<string, (DiscordMember Member, DateTimeOffset At)> _members = new();
|
||||
private readonly Dictionary<string, (string? DiscordId, DateTimeOffset At)> _identities = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public EventStore(string dataFolder)
|
||||
{
|
||||
Directory.CreateDirectory(dataFolder);
|
||||
_path = Path.Combine(dataFolder, "events.json");
|
||||
Load();
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_path))
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<DiscordEvent>>(File.ReadAllText(_path)) ?? [];
|
||||
_events = list.ToDictionary(e => e.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException) { _events = new(); }
|
||||
}
|
||||
|
||||
public void Merge(IEnumerable<DiscordEvent> fresh)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var e in fresh) _events[e.Id] = e;
|
||||
var tmp = _path + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(_events.Values.OrderBy(e => e.Start).ToList(), new JsonSerializerOptions { WriteIndented = true }));
|
||||
File.Move(tmp, _path, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Event whose [start - slack, end + slack] window contains <paramref name="at"/>; closest start wins.</summary>
|
||||
public DiscordEvent? FindEventAt(DateTimeOffset at, TimeSpan slack, TimeSpan defaultDuration)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _events.Values
|
||||
.Where(e => at >= e.Start - slack && at <= (e.End ?? e.Start + defaultDuration) + slack)
|
||||
.OrderBy(e => Math.Abs((e.Start - at).Ticks))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
public int Count { get { lock (_lock) return _events.Count; } }
|
||||
|
||||
public DiscordMember? GetCachedMember(string discordId, TimeSpan maxAge)
|
||||
{
|
||||
lock (_lock) return _members.TryGetValue(discordId, out var m) && DateTimeOffset.UtcNow - m.At < maxAge ? m.Member : null;
|
||||
}
|
||||
public void CacheMember(DiscordMember m) { lock (_lock) _members[m.UserId] = (m, DateTimeOffset.UtcNow); }
|
||||
|
||||
public bool TryGetCachedIdentity(string folder, TimeSpan maxAge, out string? discordId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_identities.TryGetValue(folder, out var v) && DateTimeOffset.UtcNow - v.At < maxAge) { discordId = v.DiscordId; return true; }
|
||||
discordId = null; return false;
|
||||
}
|
||||
}
|
||||
public void CacheIdentity(string folder, string? discordId) { lock (_lock) _identities[folder] = (discordId, DateTimeOffset.UtcNow); }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||
using Jellyfin.Plugin.Vanguard.Providers;
|
||||
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
/// <summary>Posts "new stream recording" / "new clip" embeds to a Discord channel when items land in the library.</summary>
|
||||
public sealed class FeedService : IHostedService
|
||||
{
|
||||
private readonly ILibraryManager _library;
|
||||
private readonly DiscordClient _discord;
|
||||
private readonly IdentityResolver _identity;
|
||||
private readonly LibraryPaths _paths;
|
||||
private readonly ILogger<FeedService> _log;
|
||||
private readonly HashSet<Guid> _announced = new();
|
||||
|
||||
public FeedService(ILibraryManager library, DiscordClient discord, IdentityResolver identity, LibraryPaths paths, ILogger<FeedService> log)
|
||||
{ _library = library; _discord = discord; _identity = identity; _paths = paths; _log = log; }
|
||||
|
||||
public Task StartAsync(CancellationToken ct) { _library.ItemAdded += OnItemAdded; return Task.CompletedTask; }
|
||||
public Task StopAsync(CancellationToken ct) { _library.ItemAdded -= OnItemAdded; return Task.CompletedTask; }
|
||||
|
||||
private void OnItemAdded(object? sender, ItemChangeEventArgs e)
|
||||
{
|
||||
if (e.Item is not Episode ep || string.IsNullOrEmpty(ep.Path)) return;
|
||||
_ = Task.Run(() => AnnounceAsync(ep));
|
||||
}
|
||||
|
||||
private async Task AnnounceAsync(Episode ep)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
if (string.IsNullOrWhiteSpace(cfg.NotifyChannelId) || string.IsNullOrWhiteSpace(cfg.DiscordBotToken)) return;
|
||||
lock (_announced) { if (!_announced.Add(ep.Id)) return; }
|
||||
|
||||
var kind = Classify(ep.Path, cfg);
|
||||
if (kind == Kind.Other) return;
|
||||
if (kind == Kind.Stream && !cfg.NotifyNewStreams) return;
|
||||
if (kind == Kind.Clip && !cfg.NotifyNewClips) return;
|
||||
|
||||
var parts = _paths.Split(ep.Path);
|
||||
var who = parts is null ? null : await _identity.ResolveFolderAsync(parts.SeriesFolder, CancellationToken.None).ConfigureAwait(false);
|
||||
var tz = VanguardEpisodeProvider.Tz(cfg);
|
||||
var when = TimeZoneInfo.ConvertTime(ItemTimestamp.Resolve(ep.Path, tz).Utc, tz);
|
||||
var title = parts?.Title ?? parts?.Stem ?? Path.GetFileNameWithoutExtension(ep.Path);
|
||||
var link = string.IsNullOrWhiteSpace(cfg.PublicUrl) ? null : $"{cfg.PublicUrl.TrimEnd('/')}/web/#/details?id={ep.Id:N}";
|
||||
var name = who?.DisplayName ?? parts?.SeriesFolder ?? "someone";
|
||||
|
||||
var heading = kind == Kind.Stream ? $"🔴 {name} is recording: {title}" : $"🎬 New clip from {name}: {title}";
|
||||
var fields = new List<(string, string)> { ("When", when.ToString("ddd MMM d, h:mm tt", CultureInfo.InvariantCulture)) };
|
||||
if (parts?.Title is not null && kind == Kind.Clip) fields.Add(("Folder", parts.Title));
|
||||
|
||||
// Jellyfin's Screen Grabber extracts the episode's primary image during the refresh that follows
|
||||
// ItemAdded; wait a little for it so the embed can carry the frame. Falls back to avatar only.
|
||||
var (bytes, imgName) = await WaitForPrimaryImageAsync(ep.Id, TimeSpan.FromSeconds(90)).ConfigureAwait(false);
|
||||
await _discord.PostEmbedAsync(cfg.DiscordBotToken, cfg.NotifyChannelId, heading, null, link, who?.AvatarUrl, fields,
|
||||
kind == Kind.Stream ? 0xE74C3C : 0x3D8BFD, CancellationToken.None, bytes, imgName).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Feed announce failed for {Path}", ep.Path); }
|
||||
}
|
||||
|
||||
private async Task<(byte[]? Bytes, string Name)> WaitForPrimaryImageAsync(Guid id, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (_library.GetItemById(id) is BaseItem fresh && fresh.HasImage(ImageType.Primary))
|
||||
{
|
||||
var path = fresh.GetImagePath(ImageType.Primary, 0);
|
||||
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||
{
|
||||
try { return (await File.ReadAllBytesAsync(path).ConfigureAwait(false), "thumb" + Path.GetExtension(path)); }
|
||||
catch (IOException) { /* still being written; retry */ }
|
||||
}
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
}
|
||||
return (null, "thumb.jpg");
|
||||
}
|
||||
|
||||
private enum Kind { Stream, Clip, Other }
|
||||
|
||||
private Kind Classify(string path, PluginConfiguration cfg)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
foreach (var vf in _library.GetVirtualFolders())
|
||||
{
|
||||
var hit = (vf.Locations ?? []).Any(l => full.StartsWith(Path.GetFullPath(l).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.Ordinal));
|
||||
if (!hit) continue;
|
||||
var hay = (vf.Name ?? string.Empty) + " " + string.Join(' ', vf.Locations ?? []);
|
||||
if (!string.IsNullOrWhiteSpace(cfg.StreamsLibraryHint) && hay.Contains(cfg.StreamsLibraryHint, StringComparison.OrdinalIgnoreCase)) return Kind.Stream;
|
||||
if (!string.IsNullOrWhiteSpace(cfg.ClipsLibraryHint) && hay.Contains(cfg.ClipsLibraryHint, StringComparison.OrdinalIgnoreCase)) return Kind.Clip;
|
||||
}
|
||||
return Kind.Other;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
/// <summary>Folder name (streamer / submitter) -> Discord member, via the config map or Authentik.</summary>
|
||||
public sealed class IdentityResolver
|
||||
{
|
||||
private static readonly TimeSpan MemberTtl = TimeSpan.FromHours(6);
|
||||
private static readonly TimeSpan IdentityTtl = TimeSpan.FromHours(24);
|
||||
|
||||
private readonly DiscordClient _discord;
|
||||
private readonly AuthentikClient _authentik;
|
||||
private readonly EventStore _store;
|
||||
private readonly ILogger<IdentityResolver> _log;
|
||||
|
||||
public IdentityResolver(DiscordClient discord, AuthentikClient authentik, EventStore store, ILogger<IdentityResolver> log)
|
||||
{ _discord = discord; _authentik = authentik; _store = store; _log = log; }
|
||||
|
||||
private static PluginConfiguration Cfg => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
public async Task<DiscordMember?> ResolveFolderAsync(string folderName, CancellationToken ct)
|
||||
{
|
||||
var cfg = Cfg;
|
||||
if (string.IsNullOrWhiteSpace(cfg.DiscordBotToken) || string.IsNullOrWhiteSpace(cfg.DiscordGuildId)) return null;
|
||||
|
||||
if (!_store.TryGetCachedIdentity(folderName, IdentityTtl, out var discordId))
|
||||
{
|
||||
discordId = await LookupDiscordIdAsync(cfg, folderName, ct).ConfigureAwait(false);
|
||||
_store.CacheIdentity(folderName, discordId);
|
||||
}
|
||||
if (discordId is null) return null;
|
||||
|
||||
var cached = _store.GetCachedMember(discordId, MemberTtl);
|
||||
if (cached is not null) return cached;
|
||||
var m = await _discord.GetGuildMemberAsync(cfg.DiscordBotToken, cfg.DiscordGuildId, discordId, ct).ConfigureAwait(false);
|
||||
if (m is not null) _store.CacheMember(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
private async Task<string?> LookupDiscordIdAsync(PluginConfiguration cfg, string folder, CancellationToken ct)
|
||||
{
|
||||
string authentikUser = folder;
|
||||
foreach (var raw in (cfg.FolderIdentityMap ?? string.Empty).Split('\n'))
|
||||
{
|
||||
var line = raw.Trim();
|
||||
var eq = line.IndexOf('=');
|
||||
if (eq <= 0 || !line[..eq].Trim().Equals(folder, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var val = line[(eq + 1)..].Trim();
|
||||
if (val.StartsWith("authentik:", StringComparison.OrdinalIgnoreCase)) { authentikUser = val["authentik:".Length..].Trim(); break; }
|
||||
if (val.All(char.IsDigit) && val.Length >= 15) return val; // a Discord snowflake
|
||||
}
|
||||
var id = await _authentik.GetDiscordIdForUsernameAsync(cfg.AuthentikUrl, cfg.AuthentikApiToken, authentikUser, ct).ConfigureAwait(false);
|
||||
if (id is null) _log.LogInformation("No Discord identity for folder {Folder} (authentik user {User})", folder, authentikUser);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
/// <summary>Splits a media path into (series folder, dock title or null, file stem) relative to its library root.</summary>
|
||||
public sealed class LibraryPaths
|
||||
{
|
||||
private readonly ILibraryManager _library;
|
||||
public LibraryPaths(ILibraryManager library) { _library = library; }
|
||||
|
||||
public sealed record Parts(string SeriesFolder, string? Title, string Stem);
|
||||
|
||||
public Parts? Split(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
string? root = null;
|
||||
foreach (var vf in _library.GetVirtualFolders())
|
||||
{
|
||||
foreach (var loc in vf.Locations ?? [])
|
||||
{
|
||||
var l = Path.GetFullPath(loc).TrimEnd(Path.DirectorySeparatorChar);
|
||||
if (full.StartsWith(l + Path.DirectorySeparatorChar, StringComparison.Ordinal) && (root is null || l.Length > root.Length)) root = l;
|
||||
}
|
||||
}
|
||||
if (root is null) return null;
|
||||
var rel = full[(root.Length + 1)..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (rel.Length < 2) return null;
|
||||
var stem = Path.GetFileNameWithoutExtension(rel[^1]);
|
||||
return rel.Length >= 3 ? new Parts(rel[0], rel[^2], stem) : new Parts(rel[0], null, stem);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user