Compare commits
No commits in common. "main" and "v0.3.8" have entirely different histories.
3
.gitignore
vendored
3
.gitignore
vendored
@ -34,5 +34,4 @@ twitch-credentials-lumi.png
|
|||||||
DEVNOTES.md
|
DEVNOTES.md
|
||||||
.QWEN.md
|
.QWEN.md
|
||||||
_*_changes.md
|
_*_changes.md
|
||||||
Lumi-current.zip
|
lumi_current.zip
|
||||||
agent-task*.md
|
|
||||||
|
|||||||
22
CHANGELOG.md
22
CHANGELOG.md
@ -1,27 +1,5 @@
|
|||||||
# Lumi changelog
|
# Lumi changelog
|
||||||
|
|
||||||
## 0.3.11
|
|
||||||
|
|
||||||
- Released Companion 0.2.8 with click-to-capture global hotkeys, independent Windows Media Player, YouTube, Spotify, VLC, Apple Music, SoundCloud, TIDAL, Bandcamp, and Qobuz discovery, and an interactive edit mode built on the actual capture-excluded native overlay renderer.
|
|
||||||
- Refined Stats and Leaderboards to hide raw channel/server IDs, omit empty tenure boards, emphasize current tenure separately from recorded totals, and distinguish both values in comparisons.
|
|
||||||
- Added per-user favorite command tracking and favorite Expression Interaction summaries through an additive usage table; favorite command history begins with this release.
|
|
||||||
- Preserved existing Companion pairing, overlay and media settings, platform-tenure history, plugin data, OBS configuration, databases, uploads, models, and secrets through the normal updater.
|
|
||||||
|
|
||||||
## 0.3.10
|
|
||||||
|
|
||||||
- Added an extensible UTC interval-history engine for Twitch follows, subscriptions, moderators, editors, and VIPs; YouTube channel memberships and moderators; and Discord membership and server boosts.
|
|
||||||
- Added idempotent live-event handling, complete-snapshot reconciliation that never treats API failures as empty state, authoritative timestamp backfill for open intervals, and preserved history across restarts and account changes.
|
|
||||||
- Added contextual `current` and `total` age placeholders with deterministic years/months, selected-unit remainder flow, singular/plural output, and the shared embedded `{format.YY-MM-DD-HH-mm-ss}` grammar.
|
|
||||||
- Added Platform tenure summaries to Stats and scope-separated Platform tenure boards to Leaderboards, plus admin interval diagnostics with source, precision, and authoritative/first-observed provenance.
|
|
||||||
- Preserved all existing settings, identities, statistics, interval history, plugins, pairing records, databases, uploads, models, and secrets through additive migrations and the normal updater.
|
|
||||||
|
|
||||||
## 0.3.9
|
|
||||||
|
|
||||||
- Restored animated Twitch/BTTV and Discord emotes plus Discord GIF media across the existing normalized OBS and native Companion overlay paths, and rendered real Twitch badge artwork with safe fallbacks.
|
|
||||||
- Released Companion 0.2.7 with bounded multi-frame GIF/WebP decoding for the capture-excluded native overlay while retaining the existing OBS browser renderer and media workflow.
|
|
||||||
- Added configurable Auto VC room messages with the room-owner placeholder, persistent lock/unlock controls, timed ownership claiming, and short-lived action reports.
|
|
||||||
- Preserved all existing settings, lobbies, active rooms, ownership, permissions, pairing identities, Companion settings, overlays, databases, uploads, models, and secrets through additive migrations and the normal updater.
|
|
||||||
|
|
||||||
## 0.3.8
|
## 0.3.8
|
||||||
|
|
||||||
- Added the native Windows Lumi Overlay using the existing paired-device authentication, normalized provider streams, live-state tracking, and Companion plugin transport without exposing provider credentials.
|
- Added the native Windows Lumi Overlay using the existing paired-device authentication, normalized provider streams, live-state tracking, and Companion plugin transport without exposing provider credentials.
|
||||||
|
|||||||
@ -25,21 +25,6 @@ DPAPI-protected device identity, so reinstalling does not create a duplicate
|
|||||||
paired device. Pairing packages expire after 15 minutes, work once, and must not
|
paired device. Pairing packages expire after 15 minutes, work once, and must not
|
||||||
be shared or committed.
|
be shared or committed.
|
||||||
|
|
||||||
## Companion features
|
|
||||||
|
|
||||||
Global hotkey fields are buttons: select one, press the desired modifier/key
|
|
||||||
combination, then release it to save the draft. Capture cancels after seven
|
|
||||||
seconds without a complete shortcut.
|
|
||||||
|
|
||||||
Song Overlay discovers independently enabled Windows media sessions. Windows
|
|
||||||
Media Player, YouTube/YouTube Music, and Spotify are enabled by default; VLC,
|
|
||||||
iTunes/Apple Music, SoundCloud, TIDAL, Bandcamp, and Qobuz can be enabled
|
|
||||||
individually.
|
|
||||||
|
|
||||||
Lumi Overlay Edit Mode opens the real monitor renderer with sample content and
|
|
||||||
interactive container outlines. Drag a container to move it or its lower-right
|
|
||||||
corner to resize it; the app retains these as an unsaved draft until Save.
|
|
||||||
|
|
||||||
## Security boundaries
|
## Security boundaries
|
||||||
|
|
||||||
- Companion plugins inherit the shell's paired-device authentication. Plugins do
|
- Companion plugins inherit the shell's paired-device authentication. Plugins do
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Song Overlay Companion integration
|
# Song Overlay Companion integration
|
||||||
|
|
||||||
This integration adds provider-neutral Windows media-session discovery to Lumi Companion. It can identify Spotify, Windows Media Player, YouTube and YouTube Music in Chrome or Edge, VLC, iTunes and Apple Music, SoundCloud, TIDAL, Bandcamp, and Qobuz. Each source is independently switchable; Windows Media Player, YouTube, and Spotify are enabled by default.
|
This integration adds a provider-neutral media event source to Lumi Companion. Spotify is the only available provider in this release, but the Companion protocol and runtime isolate provider-specific behavior behind `IMediaProvider`.
|
||||||
|
|
||||||
## Navigation contract
|
## Navigation contract
|
||||||
|
|
||||||
@ -13,8 +13,8 @@ Plugin actions are caught at the shell boundary so a routine plugin exception do
|
|||||||
|
|
||||||
## Song Overlay flow
|
## Song Overlay flow
|
||||||
|
|
||||||
1. Windows Global System Media Transport Controls exposes playback from enabled desktop and browser sources.
|
1. Windows Global System Media Transport Controls exposes Spotify playback state.
|
||||||
2. The provider identifies the source where Windows exposes enough app or metadata context, then raises media, playback, timeline, and session-availability events.
|
2. The provider raises media, playback, timeline, and session-availability events.
|
||||||
3. The runtime sends only meaningful deltas to Lumi: track change, inferred next/previous, play, resume, pause, stop, seek, metadata enrichment, or a sparse recovery heartbeat.
|
3. The runtime sends only meaningful deltas to Lumi: track change, inferred next/previous, play, resume, pause, stop, seek, metadata enrichment, or a sparse recovery heartbeat.
|
||||||
4. Playback progress is projected from the last event. Continuous progress messages are not sent.
|
4. Playback progress is projected from the last event. Continuous progress messages are not sent.
|
||||||
5. Cover art is resized and sent only with track metadata.
|
5. Cover art is resized and sent only with track metadata.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#ifndef AppVersion
|
#ifndef AppVersion
|
||||||
#define AppVersion "0.2.8"
|
#define AppVersion "0.2.6"
|
||||||
#endif
|
#endif
|
||||||
#ifndef SourceRoot
|
#ifndef SourceRoot
|
||||||
#error SourceRoot must point at the self-contained Companion publish directory.
|
#error SourceRoot must point at the self-contained Companion publish directory.
|
||||||
|
|||||||
@ -56,7 +56,7 @@ public sealed class GlobalHotkeyManager : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static bool TryParse(string chord, out uint modifiers, out uint key)
|
private static bool TryParse(string chord, out uint modifiers, out uint key)
|
||||||
{
|
{
|
||||||
modifiers = 0;
|
modifiers = 0;
|
||||||
key = 0;
|
key = 0;
|
||||||
@ -69,21 +69,11 @@ public sealed class GlobalHotkeyManager : IDisposable
|
|||||||
else if (part.Equals("Win", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0008;
|
else if (part.Equals("Win", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0008;
|
||||||
else if (part.Length == 1 && char.IsAsciiLetterOrDigit(part[0])) key = char.ToUpperInvariant(part[0]);
|
else if (part.Length == 1 && char.IsAsciiLetterOrDigit(part[0])) key = char.ToUpperInvariant(part[0]);
|
||||||
else if (part.StartsWith('F') && int.TryParse(part[1..], out var function) && function is >= 1 and <= 24) key = (uint)(0x70 + function - 1);
|
else if (part.StartsWith('F') && int.TryParse(part[1..], out var function) && function is >= 1 and <= 24) key = (uint)(0x70 + function - 1);
|
||||||
else if (VirtualKeys.TryGetValue(part, out var virtualKey)) key = virtualKey;
|
|
||||||
else return false;
|
else return false;
|
||||||
}
|
}
|
||||||
return key != 0;
|
return key != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly Dictionary<string, uint> VirtualKeys = new(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["Backspace"] = 0x08, ["Tab"] = 0x09, ["Enter"] = 0x0D, ["Pause"] = 0x13,
|
|
||||||
["CapsLock"] = 0x14, ["Escape"] = 0x1B, ["Space"] = 0x20, ["PageUp"] = 0x21,
|
|
||||||
["PageDown"] = 0x22, ["End"] = 0x23, ["Home"] = 0x24, ["Left"] = 0x25,
|
|
||||||
["Up"] = 0x26, ["Right"] = 0x27, ["Down"] = 0x28, ["Insert"] = 0x2D,
|
|
||||||
["Delete"] = 0x2E, ["NumLock"] = 0x90, ["ScrollLock"] = 0x91
|
|
||||||
};
|
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Clear();
|
Clear();
|
||||||
|
|||||||
@ -11,6 +11,5 @@
|
|||||||
<PackageReference Include="Avalonia" Version="12.1.0" />
|
<PackageReference Include="Avalonia" Version="12.1.0" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
||||||
<PackageReference Include="Microsoft.Win32.SystemEvents" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Win32.SystemEvents" Version="8.0.0" />
|
||||||
<PackageReference Include="SkiaSharp" Version="3.119.4" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -30,7 +30,6 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
_transport = transport;
|
_transport = transport;
|
||||||
_window = new NativeOverlayWindow();
|
_window = new NativeOverlayWindow();
|
||||||
_window.NativeStatusChanged += () => RaiseChanged();
|
_window.NativeStatusChanged += () => RaiseChanged();
|
||||||
_window.EditSettingsChanged += () => RaiseChanged();
|
|
||||||
_hotkeys = new GlobalHotkeyManager();
|
_hotkeys = new GlobalHotkeyManager();
|
||||||
Actions =
|
Actions =
|
||||||
[
|
[
|
||||||
@ -39,7 +38,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompanionPluginDescriptor Descriptor { get; } = new(PluginId, "Lumi Overlay", new Version(0, 1, 2),
|
public CompanionPluginDescriptor Descriptor { get; } = new(PluginId, "Lumi Overlay", new Version(0, 1, 0),
|
||||||
"Native, capture-excluded monitor overlay for Lumi chat and stream events.", 150);
|
"Native, capture-excluded monitor overlay for Lumi chat and stream events.", 150);
|
||||||
public IReadOnlyList<CompanionPluginPage> Pages { get; } = [new("LumiOverlay", "Overview & settings", 10)];
|
public IReadOnlyList<CompanionPluginPage> Pages { get; } = [new("LumiOverlay", "Overview & settings", 10)];
|
||||||
public IReadOnlyList<CompanionPluginAction> Actions { get; }
|
public IReadOnlyList<CompanionPluginAction> Actions { get; }
|
||||||
@ -52,7 +51,6 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
public string? MonitorWarning => _window.MonitorWarning;
|
public string? MonitorWarning => _window.MonitorWarning;
|
||||||
public IReadOnlyList<(string Id, string Label)> Monitors => _window.Monitors();
|
public IReadOnlyList<(string Id, string Label)> Monitors => _window.Monitors();
|
||||||
public bool PreviewActive => _preview;
|
public bool PreviewActive => _preview;
|
||||||
public bool EditModeActive { get; private set; }
|
|
||||||
public bool VisibilityOverride => _visibilityOverride.HasValue;
|
public bool VisibilityOverride => _visibilityOverride.HasValue;
|
||||||
public bool IsInitialized => _initialized;
|
public bool IsInitialized => _initialized;
|
||||||
public string HotkeyStatus => _hotkeyStatus;
|
public string HotkeyStatus => _hotkeyStatus;
|
||||||
@ -115,23 +113,6 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
RaiseChanged();
|
RaiseChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void BeginEdit(LumiOverlaySettings draft)
|
|
||||||
{
|
|
||||||
EditModeActive = true;
|
|
||||||
_window.BeginEdit(draft);
|
|
||||||
RaiseChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void EndEdit()
|
|
||||||
{
|
|
||||||
if (!EditModeActive) return;
|
|
||||||
EditModeActive = false;
|
|
||||||
_window.EndEdit();
|
|
||||||
_window.ApplySettings(Settings);
|
|
||||||
ApplyVisibility();
|
|
||||||
RaiseChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunConnectedAsync(ICompanionPluginLiveChannel channel, CancellationToken cancellationToken)
|
private async Task RunConnectedAsync(ICompanionPluginLiveChannel channel, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_channel = channel;
|
_channel = channel;
|
||||||
@ -216,7 +197,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
|
|
||||||
private void ApplyVisibility()
|
private void ApplyVisibility()
|
||||||
{
|
{
|
||||||
_window.SetRequestedVisible(EditModeActive || OverlayVisibilityPolicy.Effective(Settings, Sources, _preview, _visibilityOverride));
|
_window.SetRequestedVisible(OverlayVisibilityPolicy.Effective(Settings, Sources, _preview, _visibilityOverride));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool AutomaticVisibility() => OverlayVisibilityPolicy.Automatic(Settings, Sources);
|
private bool AutomaticVisibility() => OverlayVisibilityPolicy.Automatic(Settings, Sources);
|
||||||
|
|||||||
@ -5,13 +5,11 @@ using Avalonia.Animation;
|
|||||||
using Avalonia.Animation.Easings;
|
using Avalonia.Animation.Easings;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using SkiaSharp;
|
|
||||||
|
|
||||||
namespace Lumi.Companion.Overlay;
|
namespace Lumi.Companion.Overlay;
|
||||||
|
|
||||||
@ -38,9 +36,6 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
private readonly StackPanel _unifiedPanel = new() { Spacing = 8 };
|
private readonly StackPanel _unifiedPanel = new() { Spacing = 8 };
|
||||||
private readonly StackPanel _chatPanel = new() { Spacing = 8 };
|
private readonly StackPanel _chatPanel = new() { Spacing = 8 };
|
||||||
private readonly StackPanel _eventPanel = new() { Spacing = 8 };
|
private readonly StackPanel _eventPanel = new() { Spacing = 8 };
|
||||||
private readonly Border _unifiedEditor;
|
|
||||||
private readonly Border _chatEditor;
|
|
||||||
private readonly Border _eventEditor;
|
|
||||||
private readonly Dictionary<string, CardVisual> _cards = [];
|
private readonly Dictionary<string, CardVisual> _cards = [];
|
||||||
private readonly DispatcherTimer _timer;
|
private readonly DispatcherTimer _timer;
|
||||||
private readonly HttpsImageCache _images = new();
|
private readonly HttpsImageCache _images = new();
|
||||||
@ -50,12 +45,6 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private bool _visibleRequested;
|
private bool _visibleRequested;
|
||||||
private string? _temporaryMonitorWarning;
|
private string? _temporaryMonitorWarning;
|
||||||
private bool _editing;
|
|
||||||
private Border? _dragEditor;
|
|
||||||
private OverlayContainerSettings? _dragSettings;
|
|
||||||
private Point _dragOrigin;
|
|
||||||
private PhysicalRect _dragRect;
|
|
||||||
private bool _resizing;
|
|
||||||
|
|
||||||
public NativeOverlayWindow()
|
public NativeOverlayWindow()
|
||||||
{
|
{
|
||||||
@ -75,12 +64,6 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
_surface.Children.Add(_unifiedHolder);
|
_surface.Children.Add(_unifiedHolder);
|
||||||
_surface.Children.Add(_chatHolder);
|
_surface.Children.Add(_chatHolder);
|
||||||
_surface.Children.Add(_eventHolder);
|
_surface.Children.Add(_eventHolder);
|
||||||
_unifiedEditor = CreateEditor("Overlay container", "unified");
|
|
||||||
_chatEditor = CreateEditor("Chat", "chat");
|
|
||||||
_eventEditor = CreateEditor("Events", "events");
|
|
||||||
_surface.Children.Add(_unifiedEditor);
|
|
||||||
_surface.Children.Add(_chatEditor);
|
|
||||||
_surface.Children.Add(_eventEditor);
|
|
||||||
_chat = new(() => ContainerFor("chat"));
|
_chat = new(() => ContainerFor("chat"));
|
||||||
_events = new(() => ContainerFor("event"));
|
_events = new(() => ContainerFor("event"));
|
||||||
_chat.Changed += Render;
|
_chat.Changed += Render;
|
||||||
@ -101,7 +84,6 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
public CaptureExclusionStatus CaptureExclusion { get; private set; } = new(CaptureExclusionState.Pending, "Waiting for the native overlay window.");
|
public CaptureExclusionStatus CaptureExclusion { get; private set; } = new(CaptureExclusionState.Pending, "Waiting for the native overlay window.");
|
||||||
public string? MonitorWarning => _temporaryMonitorWarning;
|
public string? MonitorWarning => _temporaryMonitorWarning;
|
||||||
public event Action? NativeStatusChanged;
|
public event Action? NativeStatusChanged;
|
||||||
public event Action? EditSettingsChanged;
|
|
||||||
|
|
||||||
public void ApplySettings(LumiOverlaySettings settings)
|
public void ApplySettings(LumiOverlaySettings settings)
|
||||||
{
|
{
|
||||||
@ -125,35 +107,6 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
else if (IsVisible) Hide();
|
else if (IsVisible) Hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void BeginEdit(LumiOverlaySettings settings)
|
|
||||||
{
|
|
||||||
_editing = true;
|
|
||||||
ApplySettings(settings);
|
|
||||||
if (!_chat.Items.Any(item => item.Id.StartsWith("edit-sample-", StringComparison.Ordinal)))
|
|
||||||
{
|
|
||||||
AddChat(new OverlayFeedMessage
|
|
||||||
{
|
|
||||||
Id = $"edit-sample-chat-{Guid.NewGuid():N}",
|
|
||||||
Platform = "twitch",
|
|
||||||
Text = "Drag this container to move it · drag the lower-right corner to resize",
|
|
||||||
Author = new() { Name = "Lumi preview", Badges = [new() { Label = "LIVE" }] }
|
|
||||||
});
|
|
||||||
AddEvent(new($"edit-sample-event-{Guid.NewGuid():N}", "twitch.follow", "twitch", "New follower preview", default));
|
|
||||||
}
|
|
||||||
SetRequestedVisible(true);
|
|
||||||
ApplyMonitorAndStyles();
|
|
||||||
Activate();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void EndEdit()
|
|
||||||
{
|
|
||||||
if (!_editing) return;
|
|
||||||
_editing = false;
|
|
||||||
Clear();
|
|
||||||
ApplyMonitorAndStyles();
|
|
||||||
Render();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddChat(OverlayFeedMessage message) => _chat.Add(message.Id, "chat", message);
|
public void AddChat(OverlayFeedMessage message) => _chat.Add(message.Id, "chat", message);
|
||||||
public void AddEvent(OverlayRenderedEvent value) => _events.Add(value.Id, "event", value);
|
public void AddEvent(OverlayRenderedEvent value) => _events.Add(value.Id, "event", value);
|
||||||
public void Clear() { _chat.Clear(); _events.Clear(); }
|
public void Clear() { _chat.Clear(); _events.Clear(); }
|
||||||
@ -207,118 +160,6 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
var activeIds = _chat.Items.Concat(_events.Items).Select(item => item.Id).ToHashSet();
|
var activeIds = _chat.Items.Concat(_events.Items).Select(item => item.Id).ToHashSet();
|
||||||
foreach (var stale in _cards.Keys.Where(id => !activeIds.Contains(id)).ToList()) _cards.Remove(stale);
|
foreach (var stale in _cards.Keys.Where(id => !activeIds.Contains(id)).ToList()) _cards.Remove(stale);
|
||||||
RenderEditors(width, height, scaling);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Border CreateEditor(string label, string tag)
|
|
||||||
{
|
|
||||||
var editor = new Border
|
|
||||||
{
|
|
||||||
Tag = tag,
|
|
||||||
BorderBrush = Brush.Parse("#28B7C8"),
|
|
||||||
BorderThickness = new Thickness(3),
|
|
||||||
Background = Brush.Parse("#082C3333"),
|
|
||||||
CornerRadius = new CornerRadius(8),
|
|
||||||
Child = new Border
|
|
||||||
{
|
|
||||||
Background = Brush.Parse("#D9121212"),
|
|
||||||
CornerRadius = new CornerRadius(6),
|
|
||||||
Padding = new Thickness(9, 5),
|
|
||||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
|
|
||||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
|
|
||||||
Child = new TextBlock { Text = label, Foreground = Brushes.White, FontWeight = FontWeight.SemiBold, FontSize = 12 }
|
|
||||||
},
|
|
||||||
Cursor = new Cursor(StandardCursorType.SizeAll),
|
|
||||||
IsVisible = false
|
|
||||||
};
|
|
||||||
editor.PointerPressed += OnEditorPointerPressed;
|
|
||||||
editor.PointerMoved += OnEditorPointerMoved;
|
|
||||||
editor.PointerReleased += OnEditorPointerReleased;
|
|
||||||
return editor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderEditors(int width, int height, double scaling)
|
|
||||||
{
|
|
||||||
if (!_editing)
|
|
||||||
{
|
|
||||||
_unifiedEditor.IsVisible = _chatEditor.IsVisible = _eventEditor.IsVisible = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_settings.Layout is OverlayLayoutMode.Unified or OverlayLayoutMode.DividedHorizontal or OverlayLayoutMode.DividedVertical)
|
|
||||||
{
|
|
||||||
ConfigureEditor(_unifiedEditor, Logical(AnchorLayout.Calculate(_settings.Unified, width, height), scaling), true);
|
|
||||||
ConfigureEditor(_chatEditor, default, false);
|
|
||||||
ConfigureEditor(_eventEditor, default, false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ConfigureEditor(_unifiedEditor, default, false);
|
|
||||||
ConfigureEditor(_chatEditor, Logical(AnchorLayout.Calculate(_settings.Chat, width, height), scaling), true);
|
|
||||||
ConfigureEditor(_eventEditor, Logical(AnchorLayout.Calculate(_settings.Events, width, height), scaling), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ConfigureEditor(Border editor, PhysicalRect rect, bool visible)
|
|
||||||
{
|
|
||||||
editor.IsVisible = visible;
|
|
||||||
if (!visible) return;
|
|
||||||
editor.Width = rect.Width;
|
|
||||||
editor.Height = rect.Height;
|
|
||||||
Canvas.SetLeft(editor, rect.X);
|
|
||||||
Canvas.SetTop(editor, rect.Y);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnEditorPointerPressed(object? sender, PointerPressedEventArgs args)
|
|
||||||
{
|
|
||||||
if (!_editing || sender is not Border editor) return;
|
|
||||||
var screen = ResolveScreen();
|
|
||||||
if (screen is null) return;
|
|
||||||
var tag = editor.Tag?.ToString();
|
|
||||||
_dragSettings = tag == "chat" ? _settings.Chat : tag == "events" ? _settings.Events : _settings.Unified;
|
|
||||||
_dragRect = AnchorLayout.Calculate(_dragSettings, screen.Bounds.Width, screen.Bounds.Height);
|
|
||||||
_dragOrigin = args.GetPosition(_surface);
|
|
||||||
var position = args.GetPosition(editor);
|
|
||||||
_resizing = position.X >= editor.Bounds.Width - 24 && position.Y >= editor.Bounds.Height - 24;
|
|
||||||
_dragEditor = editor;
|
|
||||||
editor.Cursor = new Cursor(_resizing ? StandardCursorType.BottomRightCorner : StandardCursorType.SizeAll);
|
|
||||||
args.Pointer.Capture(editor);
|
|
||||||
args.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnEditorPointerMoved(object? sender, PointerEventArgs args)
|
|
||||||
{
|
|
||||||
if (_dragEditor is null || _dragSettings is null) return;
|
|
||||||
var screen = ResolveScreen();
|
|
||||||
if (screen is null) return;
|
|
||||||
var current = args.GetPosition(_surface);
|
|
||||||
var dx = (int)Math.Round((current.X - _dragOrigin.X) * screen.Scaling);
|
|
||||||
var dy = (int)Math.Round((current.Y - _dragOrigin.Y) * screen.Scaling);
|
|
||||||
if (_resizing)
|
|
||||||
{
|
|
||||||
_dragSettings.Width = Math.Clamp(_dragRect.Width + dx, 160, screen.Bounds.Width);
|
|
||||||
_dragSettings.Height = Math.Clamp(_dragRect.Height + dy, 100, screen.Bounds.Height);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_dragSettings.Anchor = OverlayAnchor.TopLeft;
|
|
||||||
_dragSettings.PaddingLeft = Math.Clamp(_dragRect.X + dx, 0, Math.Max(0, screen.Bounds.Width - _dragSettings.Width));
|
|
||||||
_dragSettings.PaddingTop = Math.Clamp(_dragRect.Y + dy, 0, Math.Max(0, screen.Bounds.Height - _dragSettings.Height));
|
|
||||||
_dragSettings.PaddingRight = 0;
|
|
||||||
_dragSettings.PaddingBottom = 0;
|
|
||||||
}
|
|
||||||
Render();
|
|
||||||
args.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnEditorPointerReleased(object? sender, PointerReleasedEventArgs args)
|
|
||||||
{
|
|
||||||
if (_dragEditor is null) return;
|
|
||||||
args.Pointer.Capture(null);
|
|
||||||
_dragEditor.Cursor = new Cursor(StandardCursorType.SizeAll);
|
|
||||||
_dragEditor = null;
|
|
||||||
_dragSettings = null;
|
|
||||||
EditSettingsChanged?.Invoke();
|
|
||||||
args.Handled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ConfigureContainer(Grid holder, StackPanel panel, IEnumerable<OverlayQueueItem> values,
|
private void ConfigureContainer(Grid holder, StackPanel panel, IEnumerable<OverlayQueueItem> values,
|
||||||
@ -458,7 +299,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
var contentColumn = 0;
|
var contentColumn = 0;
|
||||||
if (settings.ShowAvatar && message.Author.Avatar is not null)
|
if (settings.ShowAvatar && message.Author.Avatar is not null)
|
||||||
{
|
{
|
||||||
var avatar = new AnimatedOverlayImage { Width = 36, Height = 36, Stretch = Stretch.UniformToFill };
|
var avatar = new Image { Width = 36, Height = 36, Stretch = Stretch.UniformToFill };
|
||||||
_ = LoadImageAsync(avatar, message.Author.Avatar);
|
_ = LoadImageAsync(avatar, message.Author.Avatar);
|
||||||
grid.Children.Add(avatar);
|
grid.Children.Add(avatar);
|
||||||
contentColumn = 1;
|
contentColumn = 1;
|
||||||
@ -467,26 +308,10 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
var header = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 7 };
|
var header = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 7 };
|
||||||
if (settings.ShowPlatform) header.Children.Add(Text(message.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold));
|
if (settings.ShowPlatform) header.Children.Add(Text(message.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold));
|
||||||
foreach (var badge in message.Author.Badges.Take(6))
|
foreach (var badge in message.Author.Badges.Take(6))
|
||||||
{
|
header.Children.Add(Text(badge.Label, settings, 10, FontWeight.SemiBold));
|
||||||
if (badge.Image is null)
|
|
||||||
{
|
|
||||||
header.Children.Add(Text(badge.Label, settings, 10, FontWeight.SemiBold));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
var token = new Grid();
|
|
||||||
ToolTip.SetTip(token, badge.Label);
|
|
||||||
var fallback = Text(badge.Label, settings, 10, FontWeight.SemiBold);
|
|
||||||
var image = new AnimatedOverlayImage { Width = 18, Height = 18, Stretch = Stretch.Uniform, Opacity = 0 };
|
|
||||||
token.Children.Add(fallback);
|
|
||||||
token.Children.Add(image);
|
|
||||||
header.Children.Add(token);
|
|
||||||
_ = LoadImageAsync(image, badge.Image, fallback);
|
|
||||||
}
|
|
||||||
if (settings.ShowUsername) header.Children.Add(Text(message.Author.Name, settings, settings.FontSize * .72, FontWeight.Bold));
|
if (settings.ShowUsername) header.Children.Add(Text(message.Author.Name, settings, settings.FontSize * .72, FontWeight.Bold));
|
||||||
if (header.Children.Count > 0) stack.Children.Add(header);
|
if (header.Children.Count > 0) stack.Children.Add(header);
|
||||||
stack.Children.Add(CreateMessageBody(message, settings));
|
stack.Children.Add(CreateMessageBody(message, settings));
|
||||||
var media = CreateMessageMedia(message);
|
|
||||||
if (media is not null) stack.Children.Add(media);
|
|
||||||
Grid.SetColumn(stack, contentColumn);
|
Grid.SetColumn(stack, contentColumn);
|
||||||
grid.Children.Add(stack);
|
grid.Children.Add(stack);
|
||||||
return grid;
|
return grid;
|
||||||
@ -504,7 +329,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
var token = new Grid { Margin = new Thickness(2, 0) };
|
var token = new Grid { Margin = new Thickness(2, 0) };
|
||||||
var fallback = Text(string.IsNullOrWhiteSpace(emote.Label) ? message.Text[emote.Start..Math.Min(message.Text.Length, emote.End + 1)] : emote.Label, settings, settings.FontSize);
|
var fallback = Text(string.IsNullOrWhiteSpace(emote.Label) ? message.Text[emote.Start..Math.Min(message.Text.Length, emote.End + 1)] : emote.Label, settings, settings.FontSize);
|
||||||
var image = new AnimatedOverlayImage { Width = settings.FontSize * 1.3, Height = settings.FontSize * 1.3, Opacity = 0 };
|
var image = new Image { Width = settings.FontSize * 1.3, Height = settings.FontSize * 1.3, Opacity = 0 };
|
||||||
token.Children.Add(fallback);
|
token.Children.Add(fallback);
|
||||||
token.Children.Add(image);
|
token.Children.Add(image);
|
||||||
panel.Children.Add(token);
|
panel.Children.Add(token);
|
||||||
@ -518,31 +343,6 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Control? CreateMessageMedia(OverlayFeedMessage message)
|
|
||||||
{
|
|
||||||
var panel = new WrapPanel
|
|
||||||
{
|
|
||||||
Orientation = Avalonia.Layout.Orientation.Horizontal,
|
|
||||||
ItemWidth = 220,
|
|
||||||
ItemHeight = 150
|
|
||||||
};
|
|
||||||
foreach (var media in message.Media.Take(4))
|
|
||||||
{
|
|
||||||
var source = media.Type == "video" ? media.Preview : media.Url;
|
|
||||||
if (string.IsNullOrWhiteSpace(source)) continue;
|
|
||||||
var image = new AnimatedOverlayImage
|
|
||||||
{
|
|
||||||
Width = 220,
|
|
||||||
Height = 150,
|
|
||||||
Stretch = Stretch.Uniform
|
|
||||||
};
|
|
||||||
if (!string.IsNullOrWhiteSpace(media.Alt)) ToolTip.SetTip(image, media.Alt);
|
|
||||||
panel.Children.Add(image);
|
|
||||||
_ = LoadImageAsync(image, source);
|
|
||||||
}
|
|
||||||
return panel.Children.Count == 0 ? null : panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Control CreateEventContent(OverlayRenderedEvent value, OverlayContainerSettings settings)
|
private Control CreateEventContent(OverlayRenderedEvent value, OverlayContainerSettings settings)
|
||||||
{
|
{
|
||||||
var stack = new StackPanel { Spacing = 3 };
|
var stack = new StackPanel { Spacing = 3 };
|
||||||
@ -578,14 +378,14 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadImageAsync(AnimatedOverlayImage target, string url, Control? fallback = null)
|
private async Task LoadImageAsync(Image target, string url, Control? fallback = null)
|
||||||
{
|
{
|
||||||
var asset = await _images.GetAsync(url).ConfigureAwait(false);
|
var bitmap = await _images.GetAsync(url).ConfigureAwait(false);
|
||||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
if (asset is not null)
|
if (bitmap is not null)
|
||||||
{
|
{
|
||||||
target.SetAsset(asset);
|
target.Source = bitmap;
|
||||||
target.Opacity = 1;
|
target.Opacity = 1;
|
||||||
if (fallback is not null) fallback.IsVisible = false;
|
if (fallback is not null) fallback.IsVisible = false;
|
||||||
}
|
}
|
||||||
@ -629,9 +429,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
Height = screen.Bounds.Height / screen.Scaling;
|
Height = screen.Bounds.Height / screen.Scaling;
|
||||||
var hwnd = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
|
var hwnd = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
|
||||||
if (hwnd == IntPtr.Zero) return;
|
if (hwnd == IntPtr.Zero) return;
|
||||||
var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | WsExToolWindow | WsExLayered;
|
var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | WsExTransparent | WsExToolWindow | WsExLayered | WsExNoActivate;
|
||||||
if (_editing) style &= ~(WsExTransparent | WsExNoActivate);
|
|
||||||
else style |= WsExTransparent | WsExNoActivate;
|
|
||||||
SetWindowLongPtr(hwnd, GwlExStyle, new IntPtr(style));
|
SetWindowLongPtr(hwnd, GwlExStyle, new IntPtr(style));
|
||||||
SetWindowPos(hwnd, HwndTopmost, screen.Bounds.X, screen.Bounds.Y, screen.Bounds.Width, screen.Bounds.Height, SwpNoActivate | SwpFrameChanged);
|
SetWindowPos(hwnd, HwndTopmost, screen.Bounds.X, screen.Bounds.Y, screen.Bounds.Width, screen.Bounds.Height, SwpNoActivate | SwpFrameChanged);
|
||||||
if (SetWindowDisplayAffinity(hwnd, WdaExcludeFromCapture))
|
if (SetWindowDisplayAffinity(hwnd, WdaExcludeFromCapture))
|
||||||
@ -677,18 +475,14 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
|||||||
internal sealed class HttpsImageCache : IDisposable
|
internal sealed class HttpsImageCache : IDisposable
|
||||||
{
|
{
|
||||||
private const int Limit = 128;
|
private const int Limit = 128;
|
||||||
private const int MaxEncodedBytes = 2 * 1024 * 1024;
|
|
||||||
private const int MaxDimension = 384;
|
|
||||||
private const int MaxFrames = 60;
|
|
||||||
private const long MaxDecodedPixels = 12_000_000;
|
|
||||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) };
|
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) };
|
||||||
private readonly ConcurrentDictionary<string, Lazy<Task<OverlayImageAsset?>>> _entries = new();
|
private readonly ConcurrentDictionary<string, Lazy<Task<Bitmap?>>> _entries = new();
|
||||||
private readonly Queue<string> _order = new();
|
private readonly Queue<string> _order = new();
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
|
|
||||||
public Task<OverlayImageAsset?> GetAsync(string value)
|
public Task<Bitmap?> GetAsync(string value)
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult<OverlayImageAsset?>(null);
|
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult<Bitmap?>(null);
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_entries.ContainsKey(uri.AbsoluteUri))
|
if (!_entries.ContainsKey(uri.AbsoluteUri))
|
||||||
@ -701,58 +495,19 @@ internal sealed class HttpsImageCache : IDisposable
|
|||||||
return _entries.GetOrAdd(uri.AbsoluteUri, key => new(() => DownloadAsync(key))).Value;
|
return _entries.GetOrAdd(uri.AbsoluteUri, key => new(() => DownloadAsync(key))).Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<OverlayImageAsset?> DownloadAsync(string url)
|
private async Task<Bitmap?> DownloadAsync(string url)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
|
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
|
||||||
if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > MaxEncodedBytes) return null;
|
if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > 2 * 1024 * 1024) return null;
|
||||||
var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
||||||
if (bytes.Length > MaxEncodedBytes) return null;
|
if (bytes.Length > 2 * 1024 * 1024) return null;
|
||||||
return Decode(bytes);
|
return new Bitmap(new MemoryStream(bytes));
|
||||||
}
|
}
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OverlayImageAsset? Decode(byte[] bytes)
|
|
||||||
{
|
|
||||||
using var data = SKData.CreateCopy(bytes);
|
|
||||||
using var codec = SKCodec.Create(data);
|
|
||||||
if (codec is null || codec.Info.Width <= 0 || codec.Info.Height <= 0) return null;
|
|
||||||
var scale = Math.Min(1d, MaxDimension / (double)Math.Max(codec.Info.Width, codec.Info.Height));
|
|
||||||
var dimensions = codec.GetScaledDimensions((float)scale);
|
|
||||||
var width = Math.Max(1, dimensions.Width);
|
|
||||||
var height = Math.Max(1, dimensions.Height);
|
|
||||||
var frameCount = Math.Clamp(codec.FrameCount, 1, MaxFrames);
|
|
||||||
frameCount = (int)Math.Min(frameCount, Math.Max(1, MaxDecodedPixels / ((long)width * height)));
|
|
||||||
var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
|
|
||||||
using var working = new SKBitmap(info);
|
|
||||||
var frameInfo = codec.FrameInfo;
|
|
||||||
var frames = new List<Bitmap>(frameCount);
|
|
||||||
var delays = new List<TimeSpan>(frameCount);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
for (var index = 0; index < frameCount; index++)
|
|
||||||
{
|
|
||||||
var requiredFrame = index < frameInfo.Length ? frameInfo[index].RequiredFrame : -1;
|
|
||||||
if (requiredFrame < 0) working.Erase(SKColors.Transparent);
|
|
||||||
var result = codec.GetPixels(info, working.GetPixels(), working.RowBytes, new SKCodecOptions(index, requiredFrame));
|
|
||||||
if (result is not (SKCodecResult.Success or SKCodecResult.IncompleteInput)) break;
|
|
||||||
using var image = SKImage.FromBitmap(working);
|
|
||||||
using var encoded = image.Encode(SKEncodedImageFormat.Png, 100);
|
|
||||||
frames.Add(new Bitmap(new MemoryStream(encoded.ToArray())));
|
|
||||||
var duration = index < frameInfo.Length ? frameInfo[index].Duration : 100;
|
|
||||||
delays.Add(TimeSpan.FromMilliseconds(Math.Clamp(duration <= 0 ? 100 : duration, 20, 10_000)));
|
|
||||||
}
|
|
||||||
return frames.Count == 0 ? null : new OverlayImageAsset(frames, delays);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
foreach (var frame in frames) frame.Dispose();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_http.Dispose();
|
_http.Dispose();
|
||||||
@ -761,65 +516,6 @@ internal sealed class HttpsImageCache : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class OverlayImageAsset(IReadOnlyList<Bitmap> frames, IReadOnlyList<TimeSpan> delays) : IDisposable
|
|
||||||
{
|
|
||||||
public IReadOnlyList<Bitmap> Frames { get; } = frames;
|
|
||||||
public IReadOnlyList<TimeSpan> Delays { get; } = delays;
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
foreach (var frame in Frames) frame.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class AnimatedOverlayImage : Image
|
|
||||||
{
|
|
||||||
private DispatcherTimer? _timer;
|
|
||||||
private OverlayImageAsset? _asset;
|
|
||||||
private int _frame;
|
|
||||||
|
|
||||||
public AnimatedOverlayImage()
|
|
||||||
{
|
|
||||||
DetachedFromVisualTree += (_, _) => Stop();
|
|
||||||
AttachedToVisualTree += (_, _) => Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetAsset(OverlayImageAsset asset)
|
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
_asset = asset;
|
|
||||||
_frame = 0;
|
|
||||||
Source = asset.Frames[0];
|
|
||||||
Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Start()
|
|
||||||
{
|
|
||||||
if (_asset is null || _asset.Frames.Count < 2 || _timer is not null) return;
|
|
||||||
_timer = new DispatcherTimer { Interval = DelayFor(0) };
|
|
||||||
_timer.Tick += Advance;
|
|
||||||
_timer.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Advance(object? sender, EventArgs args)
|
|
||||||
{
|
|
||||||
if (_asset is null || _asset.Frames.Count < 2) return;
|
|
||||||
_frame = (_frame + 1) % _asset.Frames.Count;
|
|
||||||
Source = _asset.Frames[_frame];
|
|
||||||
if (_timer is not null) _timer.Interval = DelayFor(_frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TimeSpan DelayFor(int frame) =>
|
|
||||||
_asset is not null && frame < _asset.Delays.Count ? _asset.Delays[frame] : TimeSpan.FromMilliseconds(100);
|
|
||||||
|
|
||||||
private void Stop()
|
|
||||||
{
|
|
||||||
if (_timer is null) return;
|
|
||||||
_timer.Stop();
|
|
||||||
_timer.Tick -= Advance;
|
|
||||||
_timer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class OverlayColor
|
internal static class OverlayColor
|
||||||
{
|
{
|
||||||
public static Color ColorFromRgba(string value)
|
public static Color ColorFromRgba(string value)
|
||||||
|
|||||||
@ -25,14 +25,6 @@ public sealed class OverlayFeedEmote
|
|||||||
[JsonPropertyName("image")] public string? Image { get; set; }
|
[JsonPropertyName("image")] public string? Image { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class OverlayFeedMedia
|
|
||||||
{
|
|
||||||
[JsonPropertyName("url")] public string? Url { get; set; }
|
|
||||||
[JsonPropertyName("type")] public string Type { get; set; } = "image";
|
|
||||||
[JsonPropertyName("alt")] public string Alt { get; set; } = "";
|
|
||||||
[JsonPropertyName("preview")] public string? Preview { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class OverlayFeedMessage
|
public sealed class OverlayFeedMessage
|
||||||
{
|
{
|
||||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||||
@ -40,7 +32,6 @@ public sealed class OverlayFeedMessage
|
|||||||
[JsonPropertyName("text")] public string Text { get; set; } = "";
|
[JsonPropertyName("text")] public string Text { get; set; } = "";
|
||||||
[JsonPropertyName("author")] public OverlayFeedAuthor Author { get; set; } = new();
|
[JsonPropertyName("author")] public OverlayFeedAuthor Author { get; set; } = new();
|
||||||
[JsonPropertyName("emotes")] public List<OverlayFeedEmote> Emotes { get; set; } = [];
|
[JsonPropertyName("emotes")] public List<OverlayFeedEmote> Emotes { get; set; } = [];
|
||||||
[JsonPropertyName("media")] public List<OverlayFeedMedia> Media { get; set; } = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record OverlayRenderedEvent(string Id, string Type, string Platform, string Summary, JsonElement Payload);
|
public sealed record OverlayRenderedEvent(string Id, string Type, string Platform, string Summary, JsonElement Payload);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_overlay",
|
"id": "lumi_overlay",
|
||||||
"name": "Lumi Overlay",
|
"name": "Lumi Overlay",
|
||||||
"version": "0.1.2",
|
"version": "0.1.0",
|
||||||
"provider_api": 1,
|
"provider_api": 1,
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"network.lumi.overlay.read",
|
"network.lumi.overlay.read",
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
namespace Lumi.Companion.SongOverlay;
|
|
||||||
|
|
||||||
public sealed record MediaSourceDefinition(string Id, string Label, string Detail, bool EnabledByDefault);
|
|
||||||
|
|
||||||
public static class MediaSourceCatalog
|
|
||||||
{
|
|
||||||
public static IReadOnlyList<MediaSourceDefinition> All { get; } =
|
|
||||||
[
|
|
||||||
new("wmp", "Windows Media Player", "Windows Media Player and the modern Windows Media Player app", true),
|
|
||||||
new("youtube", "YouTube / YouTube Music", "YouTube playback exposed by Chrome or Edge", true),
|
|
||||||
new("spotify", "Spotify", "Spotify desktop playback", true),
|
|
||||||
new("vlc", "VLC", "VLC media playback", false),
|
|
||||||
new("apple_music", "iTunes / Apple Music", "iTunes and Apple Music for Windows", false),
|
|
||||||
new("soundcloud", "SoundCloud", "SoundCloud playback exposed by a browser or app", false),
|
|
||||||
new("tidal", "TIDAL", "TIDAL playback exposed by a browser or app", false),
|
|
||||||
new("bandcamp", "Bandcamp", "Bandcamp playback exposed by a browser", false),
|
|
||||||
new("qobuz", "Qobuz", "Qobuz playback exposed by a browser or app", false)
|
|
||||||
];
|
|
||||||
|
|
||||||
public static HashSet<string> Defaults() =>
|
|
||||||
All.Where(source => source.EnabledByDefault).Select(source => source.Id).ToHashSet(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
public static string? Detect(string? sourceAppId, string? title, string? artist, string? album)
|
|
||||||
{
|
|
||||||
var source = (sourceAppId ?? "").ToLowerInvariant();
|
|
||||||
var metadata = string.Join(" ", title, artist, album).ToLowerInvariant();
|
|
||||||
if (source.Contains("spotify")) return "spotify";
|
|
||||||
if (source.Contains("vlc")) return "vlc";
|
|
||||||
if (source.Contains("itunes") || source.Contains("applemusic") || source.Contains("apple music")) return "apple_music";
|
|
||||||
if (source.Contains("soundcloud")) return "soundcloud";
|
|
||||||
if (source.Contains("tidal")) return "tidal";
|
|
||||||
if (source.Contains("bandcamp")) return "bandcamp";
|
|
||||||
if (source.Contains("qobuz")) return "qobuz";
|
|
||||||
if (source.Contains("wmplayer") || source.Contains("zunemusic") || source.Contains("mediaplayer")) return "wmp";
|
|
||||||
|
|
||||||
var browser = source.Contains("chrome") || source.Contains("msedge") || source.Contains("firefox");
|
|
||||||
if (!browser) return null;
|
|
||||||
if (metadata.Contains("soundcloud")) return "soundcloud";
|
|
||||||
if (metadata.Contains("tidal")) return "tidal";
|
|
||||||
if (metadata.Contains("bandcamp")) return "bandcamp";
|
|
||||||
if (metadata.Contains("qobuz")) return "qobuz";
|
|
||||||
if (metadata.Contains("youtube") || metadata.Contains("yt music")) return "youtube";
|
|
||||||
|
|
||||||
// Browser media sessions do not consistently expose their origin. Treat
|
|
||||||
// an otherwise unidentified browser session as YouTube, the enabled
|
|
||||||
// browser default, while named services remain independently switchable.
|
|
||||||
return "youtube";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string? DetectEnabled(
|
|
||||||
string? sourceAppId,
|
|
||||||
string? title,
|
|
||||||
string? artist,
|
|
||||||
string? album,
|
|
||||||
IReadOnlySet<string> enabled)
|
|
||||||
{
|
|
||||||
var detected = Detect(sourceAppId, title, artist, album);
|
|
||||||
if (detected != "youtube" || enabled.Contains("youtube")) return detected;
|
|
||||||
var source = (sourceAppId ?? "").ToLowerInvariant();
|
|
||||||
if (!source.Contains("chrome") && !source.Contains("msedge") && !source.Contains("firefox")) return detected;
|
|
||||||
return new[] { "soundcloud", "tidal", "bandcamp", "qobuz" }.FirstOrDefault(enabled.Contains);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Label(string? id) =>
|
|
||||||
All.FirstOrDefault(source => source.Id == id)?.Label ?? "Windows media";
|
|
||||||
}
|
|
||||||
@ -7,7 +7,7 @@ using Windows.Storage.Streams;
|
|||||||
|
|
||||||
namespace Lumi.Companion.SongOverlay.Providers;
|
namespace Lumi.Companion.SongOverlay.Providers;
|
||||||
|
|
||||||
internal sealed class WindowsMediaProvider : IMediaProvider
|
internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
||||||
{
|
{
|
||||||
private const int MaxCoverInputBytes = 3 * 1024 * 1024;
|
private const int MaxCoverInputBytes = 3 * 1024 * 1024;
|
||||||
private const int MaxCoverOutputBytes = 500 * 1024;
|
private const int MaxCoverOutputBytes = 500 * 1024;
|
||||||
@ -18,19 +18,18 @@ internal sealed class WindowsMediaProvider : IMediaProvider
|
|||||||
private GlobalSystemMediaTransportControlsSession? _session;
|
private GlobalSystemMediaTransportControlsSession? _session;
|
||||||
private MediaSnapshot? _last;
|
private MediaSnapshot? _last;
|
||||||
private MediaTrack? _trackCache;
|
private MediaTrack? _trackCache;
|
||||||
private string _sourceId = "windows-media";
|
|
||||||
private bool _started;
|
private bool _started;
|
||||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||||
|
|
||||||
public WindowsMediaProvider(Func<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, string, Exception?> log)
|
public SpotifyWindowsMediaProvider(Func<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, string, Exception?> log)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_enrich = enrich;
|
_enrich = enrich;
|
||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Id => "windows-media";
|
public string Id => "spotify";
|
||||||
public string DisplayName => "Windows media discovery";
|
public string DisplayName => "Spotify";
|
||||||
public event EventHandler<ProviderStateChangedEventArgs>? StateChanged;
|
public event EventHandler<ProviderStateChangedEventArgs>? StateChanged;
|
||||||
public event EventHandler<string>? AvailabilityChanged;
|
public event EventHandler<string>? AvailabilityChanged;
|
||||||
|
|
||||||
@ -63,22 +62,22 @@ internal sealed class WindowsMediaProvider : IMediaProvider
|
|||||||
private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args)
|
private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args)
|
||||||
{
|
{
|
||||||
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
|
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
|
||||||
catch (Exception error) { _log("media_session_refresh_failed", "Could not refresh Windows media sessions", error); }
|
catch (Exception error) { _log("spotify_session_refresh_failed", "Could not refresh Spotify media sessions", error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SelectSessionAsync(CancellationToken cancellationToken)
|
private async Task SelectSessionAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var selected = await SelectEnabledSessionAsync(cancellationToken).ConfigureAwait(false);
|
var selected = _manager?.GetSessions().FirstOrDefault(IsSpotifySession);
|
||||||
if (ReferenceEquals(selected, _session))
|
if (ReferenceEquals(selected, _session))
|
||||||
{
|
{
|
||||||
if (_session is not null) await RefreshAndPublishAsync("media", true, cancellationToken).ConfigureAwait(false);
|
if (_session is not null) await RefreshAndPublishAsync("snapshot", false, cancellationToken).ConfigureAwait(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DetachSession();
|
DetachSession();
|
||||||
_session = selected;
|
_session = selected;
|
||||||
if (_session is null)
|
if (_session is null)
|
||||||
{
|
{
|
||||||
AvailabilityChanged?.Invoke(this, "No enabled source is exposing a Windows media session.");
|
AvailabilityChanged?.Invoke(this, "Spotify is not exposing a Windows media session.");
|
||||||
if (_last is not null)
|
if (_last is not null)
|
||||||
{
|
{
|
||||||
_last = null;
|
_last = null;
|
||||||
@ -89,7 +88,7 @@ internal sealed class WindowsMediaProvider : IMediaProvider
|
|||||||
_session.MediaPropertiesChanged += OnMediaPropertiesChanged;
|
_session.MediaPropertiesChanged += OnMediaPropertiesChanged;
|
||||||
_session.PlaybackInfoChanged += OnPlaybackInfoChanged;
|
_session.PlaybackInfoChanged += OnPlaybackInfoChanged;
|
||||||
_session.TimelinePropertiesChanged += OnTimelinePropertiesChanged;
|
_session.TimelinePropertiesChanged += OnTimelinePropertiesChanged;
|
||||||
AvailabilityChanged?.Invoke(this, $"{MediaSourceCatalog.Label(_sourceId)} media session connected.");
|
AvailabilityChanged?.Invoke(this, "Spotify media session connected.");
|
||||||
await RefreshAndPublishAsync("snapshot", true, cancellationToken).ConfigureAwait(false);
|
await RefreshAndPublishAsync("snapshot", true, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,20 +104,20 @@ internal sealed class WindowsMediaProvider : IMediaProvider
|
|||||||
|
|
||||||
private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
|
private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
|
||||||
{
|
{
|
||||||
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
|
try { await RefreshAndPublishAsync("media", true, CancellationToken.None).ConfigureAwait(false); }
|
||||||
catch (Exception error) { _log("media_property_update_failed", "Windows media-property update failed", error); }
|
catch (Exception error) { _log("spotify_media_property_update_failed", "Spotify media-property update failed", error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args)
|
private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args)
|
||||||
{
|
{
|
||||||
try { await RefreshAndPublishAsync("playback", false, CancellationToken.None).ConfigureAwait(false); }
|
try { await RefreshAndPublishAsync("playback", false, CancellationToken.None).ConfigureAwait(false); }
|
||||||
catch (Exception error) { _log("media_playback_update_failed", "Windows media playback update failed", error); }
|
catch (Exception error) { _log("spotify_playback_update_failed", "Spotify playback update failed", error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args)
|
private async void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args)
|
||||||
{
|
{
|
||||||
try { await RefreshAndPublishAsync("timeline", false, CancellationToken.None).ConfigureAwait(false); }
|
try { await RefreshAndPublishAsync("timeline", false, CancellationToken.None).ConfigureAwait(false); }
|
||||||
catch (Exception error) { _log("media_timeline_update_failed", "Windows media timeline update failed", error); }
|
catch (Exception error) { _log("spotify_timeline_update_failed", "Spotify timeline update failed", error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RefreshAndPublishAsync(string reason, bool enrichTrack, CancellationToken cancellationToken)
|
private async Task RefreshAndPublishAsync(string reason, bool enrichTrack, CancellationToken cancellationToken)
|
||||||
@ -163,18 +162,16 @@ internal sealed class WindowsMediaProvider : IMediaProvider
|
|||||||
var title = Clean(media.Title);
|
var title = Clean(media.Title);
|
||||||
var artist = Clean(media.Artist);
|
var artist = Clean(media.Artist);
|
||||||
var album = Clean(media.AlbumTitle);
|
var album = Clean(media.AlbumTitle);
|
||||||
_sourceId = MediaSourceCatalog.DetectEnabled(
|
|
||||||
_session.SourceAppUserModelId, title, artist, album, _settings().EnabledSources) ?? _sourceId;
|
|
||||||
if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(artist))
|
if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(artist))
|
||||||
{
|
{
|
||||||
var key = Fingerprint(title, artist, album, duration);
|
var key = Fingerprint(title, artist, album, duration);
|
||||||
var cover = _settings().SendCoverArt ? await ReadCoverAsync(media.Thumbnail, cancellationToken).ConfigureAwait(false) : null;
|
var cover = _settings().SendCoverArt ? await ReadCoverAsync(media.Thumbnail, cancellationToken).ConfigureAwait(false) : null;
|
||||||
var fallbackLink = _sourceId == "spotify" && _settings().UseSearchLinkFallback
|
var fallbackLink = _settings().UseSearchLinkFallback
|
||||||
? "https://open.spotify.com/search/" + Uri.EscapeDataString(string.Join(" ", new[] { title, artist }.Where(value => value.Length > 0)))
|
? "https://open.spotify.com/search/" + Uri.EscapeDataString(string.Join(" ", new[] { title, artist }.Where(value => value.Length > 0)))
|
||||||
: "";
|
: "";
|
||||||
var track = new MediaTrack(key, title, artist, album, "", fallbackLink, duration, cover);
|
var track = new MediaTrack(key, title, artist, album, "", fallbackLink, duration, cover);
|
||||||
_trackCache = track;
|
_trackCache = track;
|
||||||
if (enrichTrack && _sourceId == "spotify") _ = EnrichAndPublishAsync(track);
|
if (enrichTrack) _ = EnrichAndPublishAsync(track);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -186,7 +183,7 @@ internal sealed class WindowsMediaProvider : IMediaProvider
|
|||||||
_trackCache = _trackCache with { DurationMilliseconds = duration };
|
_trackCache = _trackCache with { DurationMilliseconds = duration };
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshot = new MediaSnapshot(_sourceId, status, position, duration, rate, _trackCache, capturedAt);
|
var snapshot = new MediaSnapshot(Id, status, position, duration, rate, _trackCache, capturedAt);
|
||||||
_last = snapshot;
|
_last = snapshot;
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
@ -212,34 +209,10 @@ internal sealed class WindowsMediaProvider : IMediaProvider
|
|||||||
catch (Exception error) { _log("spotify_metadata_update_failed", "Spotify metadata enrichment update failed", error); }
|
catch (Exception error) { _log("spotify_metadata_update_failed", "Spotify metadata enrichment update failed", error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<GlobalSystemMediaTransportControlsSession?> SelectEnabledSessionAsync(CancellationToken cancellationToken)
|
private static bool IsSpotifySession(GlobalSystemMediaTransportControlsSession session)
|
||||||
{
|
{
|
||||||
if (_manager is null) return null;
|
var source = session.SourceAppUserModelId ?? "";
|
||||||
GlobalSystemMediaTransportControlsSession? fallback = null;
|
return source.Contains("spotify", StringComparison.OrdinalIgnoreCase);
|
||||||
string? fallbackSource = null;
|
|
||||||
foreach (var session in _manager.GetSessions())
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var media = await session.TryGetMediaPropertiesAsync();
|
|
||||||
var sourceId = MediaSourceCatalog.DetectEnabled(
|
|
||||||
session.SourceAppUserModelId, media.Title, media.Artist, media.AlbumTitle, _settings().EnabledSources);
|
|
||||||
if (sourceId is null || !_settings().EnabledSources.Contains(sourceId)) continue;
|
|
||||||
var playing = session.GetPlaybackInfo().PlaybackStatus ==
|
|
||||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing;
|
|
||||||
if (playing)
|
|
||||||
{
|
|
||||||
_sourceId = sourceId;
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
fallback ??= session;
|
|
||||||
fallbackSource ??= sourceId;
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
if (fallbackSource is not null) _sourceId = fallbackSource;
|
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<CoverPayload?> ReadCoverAsync(IRandomAccessStreamReference? reference, CancellationToken cancellationToken)
|
private static async Task<CoverPayload?> ReadCoverAsync(IRandomAccessStreamReference? reference, CancellationToken cancellationToken)
|
||||||
@ -45,7 +45,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
public CompanionPluginDescriptor Descriptor { get; } = new(
|
public CompanionPluginDescriptor Descriptor { get; } = new(
|
||||||
PluginId,
|
PluginId,
|
||||||
"Song Overlay",
|
"Song Overlay",
|
||||||
new Version(0, 1, 2),
|
new Version(0, 1, 1),
|
||||||
"Reads provider-neutral Windows media-session events and sends minimal playback changes to Lumi.",
|
"Reads provider-neutral Windows media-session events and sends minimal playback changes to Lumi.",
|
||||||
200);
|
200);
|
||||||
|
|
||||||
@ -68,7 +68,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await _settingsStore.LoadAsync(cancellationToken).ConfigureAwait(false);
|
await _settingsStore.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
Settings.Normalize();
|
if (string.IsNullOrWhiteSpace(Settings.ProviderId)) Settings.ProviderId = "spotify";
|
||||||
// Re-save through the current schema so legacy plugin-specific Lumi host
|
// Re-save through the current schema so legacy plugin-specific Lumi host
|
||||||
// and connection-key fields are removed after the shared transport upgrade.
|
// and connection-key fields are removed after the shared transport upgrade.
|
||||||
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
|
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
|
||||||
@ -79,7 +79,9 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
|
|
||||||
public async Task SaveSettingsAsync(bool restartProvider, CancellationToken cancellationToken = default)
|
public async Task SaveSettingsAsync(bool restartProvider, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Settings.Normalize();
|
Settings.HeartbeatSeconds = Math.Clamp(Settings.HeartbeatSeconds, 15, 300);
|
||||||
|
Settings.SeekThresholdMilliseconds = Math.Clamp(Settings.SeekThresholdMilliseconds, 500, 10000);
|
||||||
|
Settings.ProviderId = string.IsNullOrWhiteSpace(Settings.ProviderId) ? "spotify" : Settings.ProviderId.Trim().ToLowerInvariant();
|
||||||
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
|
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
|
||||||
if (restartProvider) await RestartProviderAsync(cancellationToken).ConfigureAwait(false);
|
if (restartProvider) await RestartProviderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
RaiseChanged();
|
RaiseChanged();
|
||||||
@ -113,7 +115,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
if (!_transport.IsConfigured) throw new InvalidOperationException("Pair Companion with Lumi before sending song updates.");
|
if (!_transport.IsConfigured) throw new InvalidOperationException("Pair Companion with Lumi before sending song updates.");
|
||||||
if (_provider is null) throw new InvalidOperationException("No media provider is running.");
|
if (_provider is null) throw new InvalidOperationException("No media provider is running.");
|
||||||
var snapshot = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
var snapshot = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (snapshot is null) throw new InvalidOperationException("None of the enabled media sources is currently exposing playback information.");
|
if (snapshot is null) throw new InvalidOperationException("Spotify is not currently exposing playback information.");
|
||||||
await SendAsync("snapshot", snapshot, includeTrack: true, cancellationToken).ConfigureAwait(false);
|
await SendAsync("snapshot", snapshot, includeTrack: true, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,7 +130,11 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
_lastObserved = null;
|
_lastObserved = null;
|
||||||
_lastDelivered = null;
|
_lastDelivered = null;
|
||||||
_lifetime = new CancellationTokenSource();
|
_lifetime = new CancellationTokenSource();
|
||||||
_provider = new WindowsMediaProvider(() => Settings, EnrichTrackAsync, Log);
|
_provider = Settings.ProviderId switch
|
||||||
|
{
|
||||||
|
"spotify" => new SpotifyWindowsMediaProvider(() => Settings, EnrichTrackAsync, Log),
|
||||||
|
_ => throw new InvalidOperationException($"Provider '{Settings.ProviderId}' is not installed.")
|
||||||
|
};
|
||||||
_provider.StateChanged += OnProviderStateChanged;
|
_provider.StateChanged += OnProviderStateChanged;
|
||||||
_provider.AvailabilityChanged += OnAvailabilityChanged;
|
_provider.AvailabilityChanged += OnAvailabilityChanged;
|
||||||
try
|
try
|
||||||
@ -137,15 +143,15 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
StartHeartbeat();
|
StartHeartbeat();
|
||||||
var initial = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
var initial = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!_transport.IsConfigured)
|
if (!_transport.IsConfigured)
|
||||||
SetStatus(CompanionPluginHealth.Warning, "Pairing required", "Media monitoring is ready, but Companion must be paired before Lumi can receive updates.");
|
SetStatus(CompanionPluginHealth.Warning, "Pairing required", "Spotify monitoring is ready, but Companion must be paired before Lumi can receive updates.");
|
||||||
else if (initial is null)
|
else if (initial is null)
|
||||||
SetStatus(CompanionPluginHealth.Warning, "Waiting for media", "No enabled source is currently exposing a Windows media session.");
|
SetStatus(CompanionPluginHealth.Warning, "Waiting for Spotify", "Spotify is not currently exposing a Windows media session.");
|
||||||
else
|
else
|
||||||
SetStatus(CompanionPluginHealth.Healthy, "Monitoring", Describe(initial));
|
SetStatus(CompanionPluginHealth.Healthy, "Monitoring", Describe(initial));
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
SetStatus(CompanionPluginHealth.Error, "Provider failed", "Could not start Windows media discovery: " + error.Message);
|
SetStatus(CompanionPluginHealth.Error, "Provider failed", "Could not start the Spotify media provider: " + error.Message);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -198,10 +204,9 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
SetStatus(CompanionPluginHealth.Warning, "Pairing required", message);
|
SetStatus(CompanionPluginHealth.Warning, "Pairing required", message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var waiting = message.Contains("not exposing", StringComparison.OrdinalIgnoreCase) ||
|
var waiting = message.Contains("not exposing", StringComparison.OrdinalIgnoreCase);
|
||||||
message.Contains("No enabled source", StringComparison.OrdinalIgnoreCase);
|
|
||||||
SetStatus(waiting ? CompanionPluginHealth.Warning : CompanionPluginHealth.Healthy,
|
SetStatus(waiting ? CompanionPluginHealth.Warning : CompanionPluginHealth.Healthy,
|
||||||
waiting ? "Waiting for media" : "Monitoring", message);
|
waiting ? "Waiting for Spotify" : "Monitoring", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnProviderStateChanged(object? sender, ProviderStateChangedEventArgs args) =>
|
private void OnProviderStateChanged(object? sender, ProviderStateChangedEventArgs args) =>
|
||||||
@ -313,7 +318,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
|||||||
if (enriched is null) return track;
|
if (enriched is null) return track;
|
||||||
CoverPayload? cover = track.Cover;
|
CoverPayload? cover = track.Cover;
|
||||||
if (enriched.CoverBytes is { Length: > 0 } bytes && Settings.SendCoverArt)
|
if (enriched.CoverBytes is { Length: > 0 } bytes && Settings.SendCoverArt)
|
||||||
cover = WindowsMediaProvider.CreateCoverPayload(bytes, enriched.CoverMime) ?? cover;
|
cover = SpotifyWindowsMediaProvider.CreateCoverPayload(bytes, enriched.CoverMime) ?? cover;
|
||||||
return track with
|
return track with
|
||||||
{
|
{
|
||||||
Link = string.IsNullOrWhiteSpace(enriched.Link) ? track.Link : enriched.Link,
|
Link = string.IsNullOrWhiteSpace(enriched.Link) ? track.Link : enriched.Link,
|
||||||
|
|||||||
@ -4,22 +4,10 @@ public sealed class SongOverlaySettings
|
|||||||
{
|
{
|
||||||
public bool Enabled { get; set; } = true;
|
public bool Enabled { get; set; } = true;
|
||||||
public string ProviderId { get; set; } = "spotify";
|
public string ProviderId { get; set; } = "spotify";
|
||||||
public HashSet<string> EnabledSources { get; set; } = MediaSourceCatalog.Defaults();
|
|
||||||
public int HeartbeatSeconds { get; set; } = 30;
|
public int HeartbeatSeconds { get; set; } = 30;
|
||||||
public int SeekThresholdMilliseconds { get; set; } = 1500;
|
public int SeekThresholdMilliseconds { get; set; } = 1500;
|
||||||
public bool SendCoverArt { get; set; } = true;
|
public bool SendCoverArt { get; set; } = true;
|
||||||
public bool UseSearchLinkFallback { get; set; } = true;
|
public bool UseSearchLinkFallback { get; set; } = true;
|
||||||
public string SpotifyClientId { get; set; } = "";
|
public string SpotifyClientId { get; set; } = "";
|
||||||
public string ProtectedSpotifyRefreshToken { get; set; } = "";
|
public string ProtectedSpotifyRefreshToken { get; set; } = "";
|
||||||
|
|
||||||
public void Normalize()
|
|
||||||
{
|
|
||||||
ProviderId = "windows-media";
|
|
||||||
EnabledSources ??= MediaSourceCatalog.Defaults();
|
|
||||||
EnabledSources = EnabledSources
|
|
||||||
.Where(id => MediaSourceCatalog.All.Any(source => source.Id == id))
|
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
|
||||||
HeartbeatSeconds = Math.Clamp(HeartbeatSeconds, 15, 300);
|
|
||||||
SeekThresholdMilliseconds = Math.Clamp(SeekThresholdMilliseconds, 500, 10000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"name": "Song Overlay",
|
"name": "Song Overlay",
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"provider_api": 1,
|
"provider_api": 1,
|
||||||
"providers": ["windows-media", "spotify", "wmp", "youtube", "vlc", "apple-music", "soundcloud", "tidal", "bandcamp", "qobuz"],
|
"providers": ["spotify"],
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"windows.media-session.read",
|
"windows.media-session.read",
|
||||||
"network.lumi",
|
"network.lumi",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Version = "0.2.8",
|
[string]$Version = "0.2.6",
|
||||||
[string]$BridgeVersion = "0.2.5",
|
[string]$BridgeVersion = "0.2.5",
|
||||||
[string]$ObsVersion = "31.1.1"
|
[string]$ObsVersion = "31.1.1"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,17 +1,12 @@
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
$sdk = Join-Path $env:LOCALAPPDATA "Lumi\dotnet-sdk-10\dotnet.exe"
|
|
||||||
$dotnet = if (Test-Path $sdk) { $sdk } else { (Get-Command dotnet.exe -ErrorAction Stop).Source }
|
|
||||||
$node = (Get-Command node.exe -ErrorAction Stop).Source
|
|
||||||
Push-Location $repoRoot
|
Push-Location $repoRoot
|
||||||
try {
|
try {
|
||||||
Write-Host 'Building Lumi Companion and all registered Companion plugins...'
|
Write-Host 'Building Lumi Companion and all registered Companion plugins...'
|
||||||
& $dotnet build 'companion/Lumi.Companion.sln' -c Release
|
dotnet build 'companion/Lumi.Companion.sln' -c Release -p:EnableWindowsTargeting=true
|
||||||
if ($LASTEXITCODE) { throw 'Lumi Companion build failed.' }
|
|
||||||
|
|
||||||
Write-Host 'Verifying the existing Lumi Song Overlay server plugin...'
|
Write-Host 'Verifying the existing Lumi Song Overlay server plugin...'
|
||||||
& $node 'plugins/now_playing/tests/verify.js'
|
node 'plugins/now_playing/tests/verify.js'
|
||||||
if ($LASTEXITCODE) { throw 'Song Overlay server verification failed.' }
|
|
||||||
|
|
||||||
Write-Host 'Song Overlay Companion verification passed.' -ForegroundColor Green
|
Write-Host 'Song Overlay Companion verification passed.' -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,134 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
|
|
||||||
namespace Lumi.Companion.App;
|
|
||||||
|
|
||||||
public sealed class HotkeyCaptureBinding
|
|
||||||
{
|
|
||||||
private readonly Button _button;
|
|
||||||
private readonly DispatcherTimer _timeout;
|
|
||||||
private readonly HashSet<Key> _pressed = [];
|
|
||||||
private string _hotkey = "";
|
|
||||||
private string _previous = "";
|
|
||||||
private KeyModifiers _modifiers;
|
|
||||||
private Key? _primary;
|
|
||||||
private bool _capturing;
|
|
||||||
|
|
||||||
public HotkeyCaptureBinding(Button button)
|
|
||||||
{
|
|
||||||
_button = button;
|
|
||||||
_button.HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left;
|
|
||||||
_button.Focusable = true;
|
|
||||||
_timeout = new DispatcherTimer { Interval = TimeSpan.FromSeconds(7) };
|
|
||||||
_timeout.Tick += (_, _) => CancelCapture();
|
|
||||||
_button.Click += (_, _) => BeginCapture();
|
|
||||||
_button.KeyDown += OnKeyDown;
|
|
||||||
_button.KeyUp += OnKeyUp;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Hotkey
|
|
||||||
{
|
|
||||||
get => _hotkey;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_hotkey = string.IsNullOrWhiteSpace(value) ? "Unassigned" : value.Trim();
|
|
||||||
if (!_capturing) UpdateContent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (!_capturing)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Handled = true;
|
|
||||||
if (e.Key == Key.Escape)
|
|
||||||
{
|
|
||||||
CancelCapture();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_pressed.Add(e.Key);
|
|
||||||
_modifiers |= e.KeyModifiers;
|
|
||||||
if (!IsModifier(e.Key)) _primary = e.Key;
|
|
||||||
_button.Content = _primary is null ? "Hold modifiers, then press a key…" : $"{Format(_modifiers, _primary.Value)} · release to save";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnKeyUp(object? sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (!_capturing)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Handled = true;
|
|
||||||
_pressed.Remove(e.Key);
|
|
||||||
if (_primary is not null && !_pressed.Any(key => !IsModifier(key)))
|
|
||||||
{
|
|
||||||
_hotkey = Format(_modifiers, _primary.Value);
|
|
||||||
FinishCapture();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BeginCapture()
|
|
||||||
{
|
|
||||||
if (_capturing) return;
|
|
||||||
_capturing = true;
|
|
||||||
_previous = _hotkey;
|
|
||||||
_pressed.Clear();
|
|
||||||
_modifiers = KeyModifiers.None;
|
|
||||||
_primary = null;
|
|
||||||
_button.Content = "Press a shortcut…";
|
|
||||||
_button.Focus();
|
|
||||||
_timeout.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelCapture()
|
|
||||||
{
|
|
||||||
if (!_capturing) return;
|
|
||||||
_hotkey = _previous;
|
|
||||||
FinishCapture();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void FinishCapture()
|
|
||||||
{
|
|
||||||
_capturing = false;
|
|
||||||
_timeout.Stop();
|
|
||||||
_pressed.Clear();
|
|
||||||
UpdateContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateContent() => _button.Content = $"{_hotkey} (Set hotkey)";
|
|
||||||
|
|
||||||
private static bool IsModifier(Key key) => key is
|
|
||||||
Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt or
|
|
||||||
Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin;
|
|
||||||
|
|
||||||
internal static string Format(KeyModifiers modifiers, Key key)
|
|
||||||
{
|
|
||||||
var parts = new List<string>(5);
|
|
||||||
if (modifiers.HasFlag(KeyModifiers.Control)) parts.Add("Ctrl");
|
|
||||||
if (modifiers.HasFlag(KeyModifiers.Alt)) parts.Add("Alt");
|
|
||||||
if (modifiers.HasFlag(KeyModifiers.Shift)) parts.Add("Shift");
|
|
||||||
if (modifiers.HasFlag(KeyModifiers.Meta)) parts.Add("Win");
|
|
||||||
parts.Add(KeyName(key));
|
|
||||||
return string.Join("+", parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string KeyName(Key key) => key switch
|
|
||||||
{
|
|
||||||
Key.PageUp => "PageUp",
|
|
||||||
Key.PageDown => "PageDown",
|
|
||||||
Key.Left => "Left",
|
|
||||||
Key.Right => "Right",
|
|
||||||
Key.Up => "Up",
|
|
||||||
Key.Down => "Down",
|
|
||||||
Key.Return => "Enter",
|
|
||||||
Key.Space => "Space",
|
|
||||||
Key.Back => "Backspace",
|
|
||||||
_ => key.ToString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -6,8 +6,8 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
||||||
<Version>0.2.8</Version>
|
<Version>0.2.6</Version>
|
||||||
<AssemblyVersion>0.2.8.0</AssemblyVersion>
|
<AssemblyVersion>0.2.6.0</AssemblyVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
||||||
|
|||||||
@ -252,7 +252,7 @@
|
|||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
<TextBlock Text="MEDIA" Classes="eyebrow" />
|
<TextBlock Text="MEDIA" Classes="eyebrow" />
|
||||||
<TextBlock Text="Song Overlay" Classes="pageTitle" />
|
<TextBlock Text="Song Overlay" Classes="pageTitle" />
|
||||||
<TextBlock Text="Choose which Windows media sessions Lumi may discover. Song delivery continues to use the Companion's shared secure connection." Classes="muted" FontSize="15" TextWrapping="Wrap" />
|
<TextBlock Text="Spotify is the first media provider. The provider contract remains generic so later services can use the same event and overlay pipeline." Classes="muted" FontSize="15" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Border Classes="soft">
|
<Border Classes="soft">
|
||||||
@ -277,16 +277,16 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock Text="Playback sources" Classes="sectionTitle" />
|
<TextBlock Text="Playback provider" Classes="sectionTitle" />
|
||||||
<TextBlock Text="Only enabled sources are observed. Browser sessions without an exposed service name are treated as YouTube." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="10" ColumnSpacing="12">
|
||||||
<Border Classes="soft">
|
<TextBlock Text="Provider" Classes="muted" VerticalAlignment="Center" />
|
||||||
<StackPanel x:Name="SongOverlaySourcesPanel" Spacing="9" />
|
<ComboBox Grid.Column="1" x:Name="SongOverlayProviderPicker" SelectedIndex="0" IsEnabled="False">
|
||||||
</Border>
|
<ComboBoxItem Content="Spotify" Tag="spotify" />
|
||||||
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto" RowSpacing="10" ColumnSpacing="12">
|
</ComboBox>
|
||||||
<TextBlock Text="Recovery heartbeat" Classes="muted" VerticalAlignment="Center" />
|
<TextBlock Grid.Row="1" Text="Recovery heartbeat" Classes="muted" VerticalAlignment="Center" />
|
||||||
<NumericUpDown Grid.Column="1" x:Name="SongOverlayHeartbeatBox" Minimum="15" Maximum="300" Increment="5" FormatString="0 seconds" HorizontalAlignment="Left" Width="160" />
|
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="SongOverlayHeartbeatBox" Minimum="15" Maximum="300" Increment="5" FormatString="0 seconds" HorizontalAlignment="Left" Width="160" />
|
||||||
<TextBlock Grid.Row="1" Text="Seek sensitivity" Classes="muted" VerticalAlignment="Center" />
|
<TextBlock Grid.Row="2" Text="Seek sensitivity" Classes="muted" VerticalAlignment="Center" />
|
||||||
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="SongOverlaySeekBox" Minimum="500" Maximum="10000" Increment="250" FormatString="0 ms" HorizontalAlignment="Left" Width="160" />
|
<NumericUpDown Grid.Row="2" Grid.Column="1" x:Name="SongOverlaySeekBox" Minimum="500" Maximum="10000" Increment="250" FormatString="0 ms" HorizontalAlignment="Left" Width="160" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<CheckBox x:Name="SongOverlayCoverToggle" Content="Send cover art only with track metadata" />
|
<CheckBox x:Name="SongOverlayCoverToggle" Content="Send cover art only with track metadata" />
|
||||||
<CheckBox x:Name="SongOverlaySearchLinkToggle" Content="Use a Spotify search link when an exact song link is unavailable" />
|
<CheckBox x:Name="SongOverlaySearchLinkToggle" Content="Use a Spotify search link when an exact song link is unavailable" />
|
||||||
@ -383,18 +383,6 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
|
|
||||||
<StackPanel Spacing="5">
|
|
||||||
<TextBlock Text="Interactive layout editor" Classes="sectionTitle" />
|
|
||||||
<TextBlock Text="Edit the real monitor overlay in place. Drag a highlighted container to move it, or drag its lower-right corner to resize it. The preview uses the same renderer, typography, media and animations as the live overlay." Classes="muted" TextWrapping="Wrap" />
|
|
||||||
<TextBlock x:Name="LumiOverlayEditStatus" Text="Edit mode is off. Changes remain a draft until saved." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
|
||||||
</StackPanel>
|
|
||||||
<Button Grid.Column="1" x:Name="LumiOverlayEditButton" Classes="primary" Content="Start edit mode" VerticalAlignment="Center" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Expander Header="Advanced appearance & lifecycle">
|
|
||||||
<Border Classes="card" Margin="0,10,0,0">
|
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock Text="Container styling & lifecycle" Classes="sectionTitle" />
|
<TextBlock Text="Container styling & lifecycle" Classes="sectionTitle" />
|
||||||
<ComboBox x:Name="LumiOverlayContainerPicker" HorizontalAlignment="Left" Width="240">
|
<ComboBox x:Name="LumiOverlayContainerPicker" HorizontalAlignment="Left" Width="240">
|
||||||
@ -477,16 +465,15 @@
|
|||||||
<TextBlock Text="Disabled padding edges are always stored as zero. Width and height are physical pixels and clamp to the selected monitor." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
<TextBlock Text="Disabled padding edges are always stored as zero. Width and height are physical pixels and clamp to the selected monitor." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Expander>
|
|
||||||
|
|
||||||
<Border Classes="soft">
|
<Border Classes="soft">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock Text="Global hotkeys" Classes="sectionTitle" />
|
<TextBlock Text="Global hotkeys" Classes="sectionTitle" />
|
||||||
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto" RowSpacing="10">
|
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto" RowSpacing="10">
|
||||||
<TextBlock Text="Visibility override" Classes="muted" />
|
<TextBlock Text="Visibility override" Classes="muted" />
|
||||||
<Button Grid.Column="1" x:Name="LumiOverlayVisibilityHotkeyBox" Classes="secondary" />
|
<TextBox Grid.Column="1" x:Name="LumiOverlayVisibilityHotkeyBox" PlaceholderText="Ctrl+Alt+L" />
|
||||||
<TextBlock Grid.Row="1" Text="Preview override" Classes="muted" />
|
<TextBlock Grid.Row="1" Text="Preview override" Classes="muted" />
|
||||||
<Button Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayPreviewHotkeyBox" Classes="secondary" />
|
<TextBox Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayPreviewHotkeyBox" PlaceholderText="Ctrl+Alt+P" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock x:Name="LumiOverlayHotkeyStatus" Text="Hotkeys are not registered yet." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
<TextBlock x:Name="LumiOverlayHotkeyStatus" Text="Hotkeys are not registered yet." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@ -26,12 +26,8 @@ public partial class MainWindow : Window
|
|||||||
private bool _renderingSettings;
|
private bool _renderingSettings;
|
||||||
private readonly Dictionary<string, CheckBox> _overlaySourceChecks = [];
|
private readonly Dictionary<string, CheckBox> _overlaySourceChecks = [];
|
||||||
private readonly Dictionary<string, CheckBox> _overlayEventChecks = [];
|
private readonly Dictionary<string, CheckBox> _overlayEventChecks = [];
|
||||||
private readonly Dictionary<string, CheckBox> _songOverlaySourceChecks = [];
|
|
||||||
private bool _songOverlaySourcesDirty;
|
|
||||||
private string _editingOverlayContainer = "unified";
|
private string _editingOverlayContainer = "unified";
|
||||||
private LumiOverlaySettings? _lumiOverlayDraft;
|
private LumiOverlaySettings? _lumiOverlayDraft;
|
||||||
private readonly HotkeyCaptureBinding _visibilityHotkeyCapture;
|
|
||||||
private readonly HotkeyCaptureBinding _previewHotkeyCapture;
|
|
||||||
|
|
||||||
public MainWindow() : this(CreateDefaultServices()) { }
|
public MainWindow() : this(CreateDefaultServices()) { }
|
||||||
|
|
||||||
@ -46,8 +42,6 @@ public partial class MainWindow : Window
|
|||||||
_lumiOverlay = lumiOverlay;
|
_lumiOverlay = lumiOverlay;
|
||||||
_plugins = plugins;
|
_plugins = plugins;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_visibilityHotkeyCapture = new HotkeyCaptureBinding(LumiOverlayVisibilityHotkeyBox);
|
|
||||||
_previewHotkeyCapture = new HotkeyCaptureBinding(LumiOverlayPreviewHotkeyBox);
|
|
||||||
BuildPluginNavigation();
|
BuildPluginNavigation();
|
||||||
WireActions();
|
WireActions();
|
||||||
RenderState(runtime.State);
|
RenderState(runtime.State);
|
||||||
@ -178,7 +172,6 @@ public partial class MainWindow : Window
|
|||||||
TranscriptionEnabledToggle.IsCheckedChanged += async (_, _) => await SaveTranscriptionEnabledAsync();
|
TranscriptionEnabledToggle.IsCheckedChanged += async (_, _) => await SaveTranscriptionEnabledAsync();
|
||||||
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
|
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
|
||||||
SaveLumiOverlayButton.Click += async (_, _) => await SaveLumiOverlayAsync();
|
SaveLumiOverlayButton.Click += async (_, _) => await SaveLumiOverlayAsync();
|
||||||
LumiOverlayEditButton.Click += (_, _) => ToggleLumiOverlayEditMode();
|
|
||||||
LumiOverlayPreviewButton.Click += (_, _) => { _lumiOverlay.TogglePreview(); RenderLumiOverlay(); };
|
LumiOverlayPreviewButton.Click += (_, _) => { _lumiOverlay.TogglePreview(); RenderLumiOverlay(); };
|
||||||
LumiOverlayVisibilityButton.Click += (_, _) => { _lumiOverlay.ToggleVisibilityOverride(); RenderLumiOverlay(); };
|
LumiOverlayVisibilityButton.Click += (_, _) => { _lumiOverlay.ToggleVisibilityOverride(); RenderLumiOverlay(); };
|
||||||
LumiOverlayContainerPicker.SelectionChanged += (_, _) =>
|
LumiOverlayContainerPicker.SelectionChanged += (_, _) =>
|
||||||
@ -220,11 +213,6 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
public void ShowPage(CompanionPage page)
|
public void ShowPage(CompanionPage page)
|
||||||
{
|
{
|
||||||
if (page != CompanionPage.LumiOverlay && _lumiOverlay.EditModeActive)
|
|
||||||
{
|
|
||||||
_lumiOverlay.EndEdit();
|
|
||||||
RenderLumiOverlay(preserveDraft: false);
|
|
||||||
}
|
|
||||||
var pages = new Dictionary<CompanionPage, Control>
|
var pages = new Dictionary<CompanionPage, Control>
|
||||||
{
|
{
|
||||||
[CompanionPage.Overview] = OverviewPage,
|
[CompanionPage.Overview] = OverviewPage,
|
||||||
@ -306,10 +294,6 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
var settings = _songOverlay.Settings;
|
var settings = _songOverlay.Settings;
|
||||||
settings.Enabled = SongOverlayEnabledToggle.IsChecked == true;
|
settings.Enabled = SongOverlayEnabledToggle.IsChecked == true;
|
||||||
settings.EnabledSources = _songOverlaySourceChecks
|
|
||||||
.Where(item => item.Value.IsChecked == true)
|
|
||||||
.Select(item => item.Key)
|
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
|
||||||
settings.ProviderId = "spotify";
|
settings.ProviderId = "spotify";
|
||||||
settings.HeartbeatSeconds = Decimal.ToInt32(SongOverlayHeartbeatBox.Value ?? 30);
|
settings.HeartbeatSeconds = Decimal.ToInt32(SongOverlayHeartbeatBox.Value ?? 30);
|
||||||
settings.SeekThresholdMilliseconds = Decimal.ToInt32(SongOverlaySeekBox.Value ?? 1500);
|
settings.SeekThresholdMilliseconds = Decimal.ToInt32(SongOverlaySeekBox.Value ?? 1500);
|
||||||
@ -317,7 +301,6 @@ public partial class MainWindow : Window
|
|||||||
settings.UseSearchLinkFallback = SongOverlaySearchLinkToggle.IsChecked == true;
|
settings.UseSearchLinkFallback = SongOverlaySearchLinkToggle.IsChecked == true;
|
||||||
settings.SpotifyClientId = SongOverlaySpotifyClientIdBox.Text?.Trim() ?? "";
|
settings.SpotifyClientId = SongOverlaySpotifyClientIdBox.Text?.Trim() ?? "";
|
||||||
await _songOverlay.SaveSettingsAsync(restartProvider);
|
await _songOverlay.SaveSettingsAsync(restartProvider);
|
||||||
_songOverlaySourcesDirty = false;
|
|
||||||
SongOverlayFeedback.Text = "Song Overlay settings saved.";
|
SongOverlayFeedback.Text = "Song Overlay settings saved.";
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
@ -603,7 +586,7 @@ public partial class MainWindow : Window
|
|||||||
if (!_lumiOverlay.IsInitialized) return;
|
if (!_lumiOverlay.IsInitialized) return;
|
||||||
if (_lumiOverlayDraft is null)
|
if (_lumiOverlayDraft is null)
|
||||||
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
|
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
|
||||||
else if (preserveDraft && !_renderingLumiOverlay && !_lumiOverlay.EditModeActive)
|
else if (preserveDraft && !_renderingLumiOverlay)
|
||||||
CaptureLumiOverlayForm(_lumiOverlayDraft);
|
CaptureLumiOverlayForm(_lumiOverlayDraft);
|
||||||
_renderingLumiOverlay = true;
|
_renderingLumiOverlay = true;
|
||||||
try
|
try
|
||||||
@ -628,13 +611,9 @@ public partial class MainWindow : Window
|
|||||||
LumiOverlayVisibilityState.Text = $"Current display: {visibility.State}";
|
LumiOverlayVisibilityState.Text = $"Current display: {visibility.State}";
|
||||||
LumiOverlayVisibilityButton.Content = visibility.Action;
|
LumiOverlayVisibilityButton.Content = visibility.Action;
|
||||||
LumiOverlayVisibilityButton.IsEnabled = visibility.CanToggle;
|
LumiOverlayVisibilityButton.IsEnabled = visibility.CanToggle;
|
||||||
_visibilityHotkeyCapture.Hotkey = settings.ToggleVisibilityHotkey;
|
LumiOverlayVisibilityHotkeyBox.Text = settings.ToggleVisibilityHotkey;
|
||||||
_previewHotkeyCapture.Hotkey = settings.TogglePreviewHotkey;
|
LumiOverlayPreviewHotkeyBox.Text = settings.TogglePreviewHotkey;
|
||||||
LumiOverlayHotkeyStatus.Text = _lumiOverlay.HotkeyStatus;
|
LumiOverlayHotkeyStatus.Text = _lumiOverlay.HotkeyStatus;
|
||||||
LumiOverlayEditButton.Content = _lumiOverlay.EditModeActive ? "End edit mode" : "Start edit mode";
|
|
||||||
LumiOverlayEditStatus.Text = _lumiOverlay.EditModeActive
|
|
||||||
? "Edit mode is active on the selected monitor. Drag to move; use the lower-right corner to resize."
|
|
||||||
: "Edit mode is off. Changes remain a draft until saved.";
|
|
||||||
|
|
||||||
var monitorItems = _lumiOverlay.Monitors.Select(value => new OverlayPickerItem(value.Id, value.Label)).ToList();
|
var monitorItems = _lumiOverlay.Monitors.Select(value => new OverlayPickerItem(value.Id, value.Label)).ToList();
|
||||||
if (settings.MonitorId is { Length: > 0 } savedMonitor &&
|
if (settings.MonitorId is { Length: > 0 } savedMonitor &&
|
||||||
@ -777,8 +756,8 @@ public partial class MainWindow : Window
|
|||||||
settings.Layout = layout.Value;
|
settings.Layout = layout.Value;
|
||||||
settings.ChatPercentage = Decimal.ToInt32(LumiOverlayChatPercentageBox.Value ?? settings.ChatPercentage);
|
settings.ChatPercentage = Decimal.ToInt32(LumiOverlayChatPercentageBox.Value ?? settings.ChatPercentage);
|
||||||
if (LumiOverlayMonitorPicker.SelectedItem is OverlayPickerItem monitor) settings.MonitorId = monitor.Id;
|
if (LumiOverlayMonitorPicker.SelectedItem is OverlayPickerItem monitor) settings.MonitorId = monitor.Id;
|
||||||
settings.ToggleVisibilityHotkey = _visibilityHotkeyCapture.Hotkey;
|
settings.ToggleVisibilityHotkey = LumiOverlayVisibilityHotkeyBox.Text?.Trim() ?? "";
|
||||||
settings.TogglePreviewHotkey = _previewHotkeyCapture.Hotkey;
|
settings.TogglePreviewHotkey = LumiOverlayPreviewHotkeyBox.Text?.Trim() ?? "";
|
||||||
|
|
||||||
var currentSources = _lumiOverlay.Sources.ToDictionary(source => source.Id, StringComparer.Ordinal);
|
var currentSources = _lumiOverlay.Sources.ToDictionary(source => source.Id, StringComparer.Ordinal);
|
||||||
foreach (var saved in settings.Sources)
|
foreach (var saved in settings.Sources)
|
||||||
@ -842,20 +821,6 @@ public partial class MainWindow : Window
|
|||||||
picker.SelectedItem = picker.ItemsSource?.OfType<OverlayChoice<T>>().FirstOrDefault(item => EqualityComparer<T>.Default.Equals(item.Value, value));
|
picker.SelectedItem = picker.ItemsSource?.OfType<OverlayChoice<T>>().FirstOrDefault(item => EqualityComparer<T>.Default.Equals(item.Value, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleLumiOverlayEditMode()
|
|
||||||
{
|
|
||||||
if (_lumiOverlayDraft is null) _lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
|
|
||||||
if (_lumiOverlay.EditModeActive)
|
|
||||||
{
|
|
||||||
_lumiOverlay.EndEdit();
|
|
||||||
RenderLumiOverlay(preserveDraft: false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CaptureLumiOverlayForm(_lumiOverlayDraft);
|
|
||||||
_lumiOverlay.BeginEdit(_lumiOverlayDraft);
|
|
||||||
RenderLumiOverlay(preserveDraft: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveLumiOverlayAsync()
|
private async Task SaveLumiOverlayAsync()
|
||||||
{
|
{
|
||||||
if (_renderingLumiOverlay) return;
|
if (_renderingLumiOverlay) return;
|
||||||
@ -864,11 +829,6 @@ public partial class MainWindow : Window
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var settings = _lumiOverlayDraft ?? _lumiOverlay.Settings.CreateCopy();
|
var settings = _lumiOverlayDraft ?? _lumiOverlay.Settings.CreateCopy();
|
||||||
if (_lumiOverlay.EditModeActive)
|
|
||||||
{
|
|
||||||
_lumiOverlay.EndEdit();
|
|
||||||
RenderLumiOverlay(preserveDraft: false);
|
|
||||||
}
|
|
||||||
CaptureLumiOverlayForm(settings);
|
CaptureLumiOverlayForm(settings);
|
||||||
await _lumiOverlay.SaveSettingsAsync(settings);
|
await _lumiOverlay.SaveSettingsAsync(settings);
|
||||||
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
|
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
|
||||||
@ -905,30 +865,6 @@ public partial class MainWindow : Window
|
|||||||
SongOverlaySeekBox.Value = Math.Clamp(settings.SeekThresholdMilliseconds, 500, 10000);
|
SongOverlaySeekBox.Value = Math.Clamp(settings.SeekThresholdMilliseconds, 500, 10000);
|
||||||
SongOverlayCoverToggle.IsChecked = settings.SendCoverArt;
|
SongOverlayCoverToggle.IsChecked = settings.SendCoverArt;
|
||||||
SongOverlaySearchLinkToggle.IsChecked = settings.UseSearchLinkFallback;
|
SongOverlaySearchLinkToggle.IsChecked = settings.UseSearchLinkFallback;
|
||||||
if (_songOverlaySourceChecks.Count == 0)
|
|
||||||
{
|
|
||||||
foreach (var source in MediaSourceCatalog.All)
|
|
||||||
{
|
|
||||||
var check = new CheckBox
|
|
||||||
{
|
|
||||||
Content = source.Label,
|
|
||||||
IsChecked = settings.EnabledSources.Contains(source.Id)
|
|
||||||
};
|
|
||||||
ToolTip.SetTip(check, source.Detail);
|
|
||||||
check.IsCheckedChanged += (_, _) =>
|
|
||||||
{
|
|
||||||
if (!_renderingSongOverlay) _songOverlaySourcesDirty = true;
|
|
||||||
};
|
|
||||||
SongOverlaySourcesPanel.Children.Add(check);
|
|
||||||
_songOverlaySourceChecks[source.Id] = check;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (!_songOverlaySourcesDirty)
|
|
||||||
{
|
|
||||||
foreach (var source in MediaSourceCatalog.All)
|
|
||||||
if (_songOverlaySourceChecks.TryGetValue(source.Id, out var check))
|
|
||||||
check.IsChecked = settings.EnabledSources.Contains(source.Id);
|
|
||||||
}
|
|
||||||
if (!SongOverlaySpotifyClientIdBox.IsFocused) SongOverlaySpotifyClientIdBox.Text = settings.SpotifyClientId;
|
if (!SongOverlaySpotifyClientIdBox.IsFocused) SongOverlaySpotifyClientIdBox.Text = settings.SpotifyClientId;
|
||||||
SongOverlaySpotifyStatus.Text = _songOverlay.IsSpotifyEnrichmentConnected
|
SongOverlaySpotifyStatus.Text = _songOverlay.IsSpotifyEnrichmentConnected
|
||||||
? "Connected. Exact links, release year and official artwork can be enriched on song changes."
|
? "Connected. Exact links, release year and official artwork can be enriched on song changes."
|
||||||
@ -1021,7 +957,6 @@ public partial class MainWindow : Window
|
|||||||
private void OnClosing(object? sender, WindowClosingEventArgs args)
|
private void OnClosing(object? sender, WindowClosingEventArgs args)
|
||||||
{
|
{
|
||||||
if (_allowExit) return;
|
if (_allowExit) return;
|
||||||
if (_lumiOverlay.EditModeActive) _lumiOverlay.EndEdit();
|
|
||||||
args.Cancel = true;
|
args.Cancel = true;
|
||||||
Hide();
|
Hide();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,5 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/Lumi.Companion.Core/Lumi.Companion.Core.csproj" />
|
<ProjectReference Include="../../src/Lumi.Companion.Core/Lumi.Companion.Core.csproj" />
|
||||||
<ProjectReference Include="../../plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj" />
|
<ProjectReference Include="../../plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj" />
|
||||||
<ProjectReference Include="../../plugins/Lumi.Companion.SongOverlay/Lumi.Companion.SongOverlay.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
using Lumi.Companion.Abstractions;
|
using Lumi.Companion.Abstractions;
|
||||||
using Lumi.Companion.Core;
|
using Lumi.Companion.Core;
|
||||||
using Lumi.Companion.Overlay;
|
using Lumi.Companion.Overlay;
|
||||||
using Lumi.Companion.SongOverlay;
|
|
||||||
|
|
||||||
static void Assert(bool condition, string message)
|
static void Assert(bool condition, string message)
|
||||||
{
|
{
|
||||||
@ -113,17 +112,6 @@ Assert(!CompanionPluginNavigation.UsesNestedNavigation(
|
|||||||
CompanionPluginNavigation.UsesNestedNavigation(
|
CompanionPluginNavigation.UsesNestedNavigation(
|
||||||
[new CompanionPluginPage("Transcription", "Capture"), new CompanionPluginPage("Test", "Test")]),
|
[new CompanionPluginPage("Transcription", "Capture"), new CompanionPluginPage("Test", "Test")]),
|
||||||
"Single-page plugins must be direct navigation entries while multi-page plugins remain grouped.");
|
"Single-page plugins must be direct navigation entries while multi-page plugins remain grouped.");
|
||||||
var defaultMediaSources = MediaSourceCatalog.Defaults();
|
|
||||||
Assert(defaultMediaSources.SetEquals(["wmp", "youtube", "spotify"]),
|
|
||||||
"Song discovery defaults must enable only Windows Media Player, YouTube, and Spotify.");
|
|
||||||
Assert(MediaSourceCatalog.Detect("Spotify.exe", "Track", "Artist", "") == "spotify" &&
|
|
||||||
MediaSourceCatalog.Detect("chrome.exe", "Track · SoundCloud", "Artist", "") == "soundcloud" &&
|
|
||||||
MediaSourceCatalog.Detect("chrome.exe", "Unlabelled browser track", "Artist", "") == "youtube" &&
|
|
||||||
MediaSourceCatalog.Detect("vlc.exe", "Track", "", "") == "vlc",
|
|
||||||
"Windows media-session discovery did not classify desktop and browser sources independently.");
|
|
||||||
Assert(MediaSourceCatalog.DetectEnabled("chrome.exe", "Unlabelled track", "Artist", "",
|
|
||||||
new HashSet<string>(["soundcloud"], StringComparer.Ordinal)) == "soundcloud",
|
|
||||||
"An independently enabled browser source must remain discoverable when Windows withholds the tab origin.");
|
|
||||||
Assert(new CaptureExclusionStatus(CaptureExclusionState.Unsupported, "unsupported").State == CaptureExclusionState.Unsupported,
|
Assert(new CaptureExclusionStatus(CaptureExclusionState.Unsupported, "unsupported").State == CaptureExclusionState.Unsupported,
|
||||||
"Capture-exclusion unsupported state must remain distinct from failure.");
|
"Capture-exclusion unsupported state must remain distinct from failure.");
|
||||||
var visibilitySettings = new LumiOverlaySettings
|
var visibilitySettings = new LumiOverlaySettings
|
||||||
|
|||||||
@ -18,12 +18,6 @@ a connection receives live items only—no history is replayed after startup or
|
|||||||
reconnect. Missing provider scopes disable only their affected event types and
|
reconnect. Missing provider scopes disable only their affected event types and
|
||||||
are reported in feed status.
|
are reported in feed status.
|
||||||
|
|
||||||
The native renderer uses the same normalized badge, emote, and Discord media
|
|
||||||
payloads as Lumi's OBS browser overlay. HTTPS artwork is downloaded with strict
|
|
||||||
size, dimension, frame-count, and decoded-pixel limits. Animated GIF/WebP
|
|
||||||
emotes and images retain their bounded frame timing; unsupported video-only
|
|
||||||
embeds use their supplied preview image when available.
|
|
||||||
|
|
||||||
## Window behavior
|
## Window behavior
|
||||||
|
|
||||||
The selected monitor, layout, styling, sources, event types, visibility mode,
|
The selected monitor, layout, styling, sources, event types, visibility mode,
|
||||||
|
|||||||
@ -11,10 +11,6 @@ The frontend must not declare placeholder permissions, allowed plugins, or
|
|||||||
sensitivity. Editable fields reference a trusted `field_id`, and the server
|
sensitivity. Editable fields reference a trusted `field_id`, and the server
|
||||||
uses that field policy to decide which placeholders are available.
|
uses that field policy to decide which placeholders are available.
|
||||||
|
|
||||||
Platform membership and role duration placeholders, including their embedded
|
|
||||||
`{format.YY-MM-DD}` grammar, are documented in
|
|
||||||
[platform-tenure.md](platform-tenure.md).
|
|
||||||
|
|
||||||
Core and plugins can register:
|
Core and plugins can register:
|
||||||
|
|
||||||
- placeholder definitions with metadata and a resolver function
|
- placeholder definitions with metadata and a resolver function
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
# Platform tenure
|
|
||||||
|
|
||||||
Lumi records platform membership and role periods as immutable UTC intervals. The same history powers custom-command placeholders, the **Platform tenure** cards on `/stats`, the **Platform tenure** section on `/leaderboards`, and the admin diagnostics page at `/admin/platform-tenure`.
|
|
||||||
|
|
||||||
## Supported statistics
|
|
||||||
|
|
||||||
| Platform | Statistics | Start timestamp |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Twitch | Follow, subscription, moderator, editor, VIP | Platform timestamp where Twitch supplies one; otherwise first reliable observation |
|
|
||||||
| YouTube | Paid channel membership and live-chat moderator | Membership event time where available; otherwise first reliable live-chat observation |
|
|
||||||
| Discord | Server membership and server boost | Discord `joinedTimestamp` and `premiumSinceTimestamp` where available |
|
|
||||||
|
|
||||||
YouTube does not expose a reliable timestamp for a viewer's ordinary public channel subscription through the existing live-chat integration. `youtube.user.subscriber_age` therefore represents paid channel membership (sponsor/member state), not a fabricated public-subscription age. YouTube roles are observed only when the live-chat API reports them.
|
|
||||||
|
|
||||||
Temporary API failures do not close intervals. Lumi closes missing states only after a complete successful platform snapshot or a reliable end event. Unlinking a platform identity does not delete interval history.
|
|
||||||
|
|
||||||
## Placeholders
|
|
||||||
|
|
||||||
The current uninterrupted interval and cumulative recorded history are exposed as `current` and `total`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{{twitch.user.follow_age.current}}
|
|
||||||
{{twitch.user.subscriber_age.total}}
|
|
||||||
{{twitch.user.mod_age.current}}
|
|
||||||
{{twitch.user.editor_age.total}}
|
|
||||||
{{twitch.user.vip_age.current}}
|
|
||||||
{{youtube.user.subscriber_age.current}}
|
|
||||||
{{youtube.user.mod_age.total}}
|
|
||||||
{{discord.user.member_age.current}}
|
|
||||||
{{discord.user.nitro_age.total}}
|
|
||||||
```
|
|
||||||
|
|
||||||
The current command context selects the platform user and channel or guild scope. Lumi never combines values across Twitch channels, YouTube channels, or Discord servers.
|
|
||||||
|
|
||||||
Append an embedded format to select duration units:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{{twitch.user.follow_age.total}.{format.YY-MM-DD-HH-mm-ss}}
|
|
||||||
{{discord.user.member_age.current}.{format.DD-HH-mm}}
|
|
||||||
```
|
|
||||||
|
|
||||||
Units are case-sensitive: `Y`, `YY`, or `YYYY` means years; `M` or `MM` months; `D` or `DD` days; `H` or `HH` hours; `m` or `mm` minutes; and `s` or `ss` seconds. Repetition identifies the unit and does not add zero-padding. Units must be ordered largest to smallest and may appear once.
|
|
||||||
|
|
||||||
Lumi uses deterministic cumulative-duration units: one year is 365 days and one month is 30 days. Omitted time flows into the next smaller selected unit, so a format containing only `mm` returns total complete minutes. Time below the smallest selected unit is truncated. Zero units are omitted; an all-zero result is empty.
|
|
||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Lumi is the core web UI and bot runtime.
|
Lumi is the core web UI and bot runtime.
|
||||||
## Runtime
|
## Runtime
|
||||||
Package: lumi-bot
|
Package: lumi-bot
|
||||||
Version: 0.3.11
|
Version: 0.3.8
|
||||||
## Routes
|
## Routes
|
||||||
- POST /api/diagnostics/v1/run
|
- POST /api/diagnostics/v1/run
|
||||||
- GET /api/events
|
- GET /api/events
|
||||||
@ -93,7 +93,6 @@ Version: 0.3.11
|
|||||||
- POST /admin/theming/custom/:id/delete
|
- POST /admin/theming/custom/:id/delete
|
||||||
- POST /admin/theming
|
- POST /admin/theming
|
||||||
- GET /admin/diagnostics
|
- GET /admin/diagnostics
|
||||||
- GET /admin/platform-tenure
|
|
||||||
- GET /admin/stream-testing
|
- GET /admin/stream-testing
|
||||||
- POST /admin/stream-testing/reverse-proxy
|
- POST /admin/stream-testing/reverse-proxy
|
||||||
- POST /admin/stream-testing/reverse-proxy/check
|
- POST /admin/stream-testing/reverse-proxy/check
|
||||||
@ -921,15 +920,6 @@ Version: 0.3.11
|
|||||||
- Side effects: Usually read-only.
|
- Side effects: Usually read-only.
|
||||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||||
|
|
||||||
### GET /admin/platform-tenure
|
|
||||||
|
|
||||||
- Purpose: Renders the admin platform tenure WebUI page.
|
|
||||||
- Inputs: query: `platform`, `scope`, `type`, `user`
|
|
||||||
- Response format: HTML page rendered from an EJS view
|
|
||||||
- Access: admin access expected
|
|
||||||
- Side effects: Usually read-only.
|
|
||||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
|
||||||
|
|
||||||
### GET /admin/stream-testing
|
### GET /admin/stream-testing
|
||||||
|
|
||||||
- Purpose: Renders the admin stream testing WebUI page.
|
- Purpose: Renders the admin stream testing WebUI page.
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Auto-create managed voice channels from lobby rooms.
|
Auto-create managed voice channels from lobby rooms.
|
||||||
## Metadata
|
## Metadata
|
||||||
Plugin ID: auto-vc
|
Plugin ID: auto-vc
|
||||||
Version: 0.1.7
|
Version: 0.1.6
|
||||||
Default state: enabled
|
Default state: enabled
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- /plugins/auto-vc
|
- /plugins/auto-vc
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Authenticated live chat and event feed for the native Lumi Companion monitor overlay.
|
Authenticated live chat and event feed for the native Lumi Companion monitor overlay.
|
||||||
## Metadata
|
## Metadata
|
||||||
Plugin ID: lumi_overlay
|
Plugin ID: lumi_overlay
|
||||||
Version: 0.1.1
|
Version: 0.1.0
|
||||||
Default state: enabled
|
Default state: enabled
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- No plugin routes detected.
|
- No plugin routes detected.
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
||||||
## Metadata
|
## Metadata
|
||||||
Plugin ID: lumi_transcription
|
Plugin ID: lumi_transcription
|
||||||
Version: 0.2.8
|
Version: 0.2.6
|
||||||
Default state: enabled
|
Default state: enabled
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- /plugins/lumi_transcription
|
- /plugins/lumi_transcription
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.11",
|
"version": "0.3.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.11",
|
"version": "0.3.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.6.0",
|
"adm-zip": "^0.6.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.11",
|
"version": "0.3.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -25,9 +25,7 @@
|
|||||||
"verify:content": "node scripts/verify-content-library.js",
|
"verify:content": "node scripts/verify-content-library.js",
|
||||||
"verify:transcription": "node plugins/lumi_transcription/tests/verify.js",
|
"verify:transcription": "node plugins/lumi_transcription/tests/verify.js",
|
||||||
"verify:companion-overlay": "node plugins/lumi_overlay/tests/verify.js",
|
"verify:companion-overlay": "node plugins/lumi_overlay/tests/verify.js",
|
||||||
"verify:auto-vc": "node plugins/auto-vc/tests/verify.js",
|
"verify:dev-updates": "node scripts/verify-local-development-updates.js"
|
||||||
"verify:dev-updates": "node scripts/verify-local-development-updates.js",
|
|
||||||
"verify:user-age": "node scripts/verify-user-age-statistics.js"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@ -1,17 +1,5 @@
|
|||||||
# Auto VC changelog
|
# Auto VC changelog
|
||||||
|
|
||||||
## 0.1.7
|
|
||||||
|
|
||||||
- Add an admin-defined message to each new room with the
|
|
||||||
`{{plugins.auto_vc.channel.owner_username}}` placeholder.
|
|
||||||
- Add persistent lock and unlock reactions that reuse the existing room
|
|
||||||
permission behavior.
|
|
||||||
- Add a claim reaction after the owner has been absent for the configured
|
|
||||||
cleanup duration, plus concise success or failure reports that delete after
|
|
||||||
30 seconds.
|
|
||||||
- Preserve existing room and lobby data with additive control-message and
|
|
||||||
owner-absence columns.
|
|
||||||
|
|
||||||
## 0.1.6
|
## 0.1.6
|
||||||
|
|
||||||
- Connected lobby deletion to the shared timed-confirmation flow.
|
- Connected lobby deletion to the shared timed-confirmation flow.
|
||||||
|
|||||||
@ -10,12 +10,6 @@ const logger = createLogger("plugin:auto-vc", { category: "plugin" });
|
|||||||
const PLUGIN_ID = "auto-vc";
|
const PLUGIN_ID = "auto-vc";
|
||||||
const DEFAULT_TEMPLATE = "[username]'s room";
|
const DEFAULT_TEMPLATE = "[username]'s room";
|
||||||
const DEFAULT_TIMEOUT = 30;
|
const DEFAULT_TIMEOUT = 30;
|
||||||
const DEFAULT_ROOM_MESSAGE = "Welcome {{plugins.auto_vc.channel.owner_username}}! Use 🔒 to lock this room or 🔓 to unlock it. If the owner is away long enough, 👑 appears so someone in the room can claim it.";
|
|
||||||
const ROOM_MESSAGE_FIELD_ID = "plugins.auto_vc.room_message";
|
|
||||||
const LOCK_EMOJI = "🔒";
|
|
||||||
const UNLOCK_EMOJI = "🔓";
|
|
||||||
const CLAIM_EMOJI = "👑";
|
|
||||||
const REPORT_LIFETIME_MS = 30 * 1000;
|
|
||||||
const GAME_NAME_TOKEN = "[game_name]";
|
const GAME_NAME_TOKEN = "[game_name]";
|
||||||
const NAME_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
const NAME_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
const ALLOW_CONNECT_VIEW =
|
const ALLOW_CONNECT_VIEW =
|
||||||
@ -25,16 +19,12 @@ const DEFAULT_ACTION_LIMIT = { max: 8, windowSeconds: 60 };
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
id: PLUGIN_ID,
|
id: PLUGIN_ID,
|
||||||
init({ web, discordClient, db, settings, placeholders }) {
|
init({ web, discordClient, db, settings }) {
|
||||||
ensureTables(db);
|
ensureTables(db);
|
||||||
registerPlaceholderSupport(placeholders);
|
|
||||||
const state = {
|
const state = {
|
||||||
rooms: new Map(),
|
rooms: new Map(),
|
||||||
cleanupTimers: new Map(),
|
cleanupTimers: new Map(),
|
||||||
claimTimers: new Map(),
|
|
||||||
emptySince: new Map(),
|
emptySince: new Map(),
|
||||||
reactionActions: new Map(),
|
|
||||||
placeholders,
|
|
||||||
sweepTimer: null,
|
sweepTimer: null,
|
||||||
nameSweepTimer: null,
|
nameSweepTimer: null,
|
||||||
rateLimits: {
|
rateLimits: {
|
||||||
@ -76,7 +66,6 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
const config = parseConfigFromForm(req.body);
|
const config = parseConfigFromForm(req.body);
|
||||||
saveConfig(db, config);
|
saveConfig(db, config);
|
||||||
for (const channelId of state.claimTimers.keys()) clearClaimTimer(state, channelId);
|
|
||||||
state.rooms.clear();
|
state.rooms.clear();
|
||||||
req.session.flash = {
|
req.session.flash = {
|
||||||
type: "success",
|
type: "success",
|
||||||
@ -149,9 +138,6 @@ module.exports = {
|
|||||||
discordClient.on("messageCreate", (message) => {
|
discordClient.on("messageCreate", (message) => {
|
||||||
handleMessage(message, db, settings, state);
|
handleMessage(message, db, settings, state);
|
||||||
});
|
});
|
||||||
discordClient.on("messageReactionAdd", (reaction, user) => {
|
|
||||||
queueControlReaction(reaction, user, db, settings, state);
|
|
||||||
});
|
|
||||||
discordClient.on("channelDelete", (channel) => {
|
discordClient.on("channelDelete", (channel) => {
|
||||||
if (channel && channel.id) {
|
if (channel && channel.id) {
|
||||||
removeRoom(db, state, channel.id);
|
removeRoom(db, state, channel.id);
|
||||||
@ -232,9 +218,7 @@ async function buildLobbyPermissionChecks(discordClient, lobby) {
|
|||||||
"Connect to lobby",
|
"Connect to lobby",
|
||||||
"Move members",
|
"Move members",
|
||||||
"Target category visible",
|
"Target category visible",
|
||||||
"Manage rooms",
|
"Manage rooms"
|
||||||
"Post room controls",
|
|
||||||
"Manage room reactions"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!discordClient || !discordClient.user) {
|
if (!discordClient || !discordClient.user) {
|
||||||
@ -287,22 +271,6 @@ async function buildLobbyPermissionChecks(discordClient, lobby) {
|
|||||||
lobbyPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) ||
|
lobbyPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) ||
|
||||||
guildPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS)
|
guildPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS)
|
||||||
);
|
);
|
||||||
const canSendMessages = Boolean(
|
|
||||||
categoryPerms?.has(Permissions.FLAGS.SEND_MESSAGES) ||
|
|
||||||
guildPerms?.has(Permissions.FLAGS.SEND_MESSAGES)
|
|
||||||
);
|
|
||||||
const canAddReactions = Boolean(
|
|
||||||
categoryPerms?.has(Permissions.FLAGS.ADD_REACTIONS) ||
|
|
||||||
guildPerms?.has(Permissions.FLAGS.ADD_REACTIONS)
|
|
||||||
);
|
|
||||||
const canReadHistory = Boolean(
|
|
||||||
categoryPerms?.has(Permissions.FLAGS.READ_MESSAGE_HISTORY) ||
|
|
||||||
guildPerms?.has(Permissions.FLAGS.READ_MESSAGE_HISTORY)
|
|
||||||
);
|
|
||||||
const canManageMessages = Boolean(
|
|
||||||
categoryPerms?.has(Permissions.FLAGS.MANAGE_MESSAGES) ||
|
|
||||||
guildPerms?.has(Permissions.FLAGS.MANAGE_MESSAGES)
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
buildPermissionCheck("Bot in guild", true, ""),
|
buildPermissionCheck("Bot in guild", true, ""),
|
||||||
@ -339,16 +307,6 @@ async function buildLobbyPermissionChecks(discordClient, lobby) {
|
|||||||
"Manage rooms",
|
"Manage rooms",
|
||||||
canManageChannels && (categoryChannel ? canViewCategory : true),
|
canManageChannels && (categoryChannel ? canViewCategory : true),
|
||||||
"Allow Manage Channels (and View Channel) on the target category so the bot can create, rename, and delete rooms."
|
"Allow Manage Channels (and View Channel) on the target category so the bot can create, rename, and delete rooms."
|
||||||
),
|
|
||||||
buildPermissionCheck(
|
|
||||||
"Post room controls",
|
|
||||||
canSendMessages && canAddReactions && canReadHistory,
|
|
||||||
"Allow Send Messages, Add Reactions, and Read Message History on the target category."
|
|
||||||
),
|
|
||||||
buildPermissionCheck(
|
|
||||||
"Manage room reactions",
|
|
||||||
canManageMessages,
|
|
||||||
"Allow Manage Messages on the target category so reaction controls remain reusable."
|
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -403,8 +361,6 @@ function ensureTables(db) {
|
|||||||
locked INTEGER NOT NULL DEFAULT 0,
|
locked INTEGER NOT NULL DEFAULT 0,
|
||||||
allowed_user_ids TEXT NOT NULL DEFAULT '[]',
|
allowed_user_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
base_overwrites TEXT NOT NULL,
|
base_overwrites TEXT NOT NULL,
|
||||||
control_message_id TEXT,
|
|
||||||
owner_absent_since INTEGER,
|
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -420,15 +376,6 @@ function ensureTables(db) {
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
ensureColumn(db, "auto_vc_rooms", "control_message_id", "TEXT");
|
|
||||||
ensureColumn(db, "auto_vc_rooms", "owner_absent_since", "INTEGER");
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureColumn(db, table, column, definition) {
|
|
||||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
||||||
if (!columns.some((entry) => entry.name === column)) {
|
|
||||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfig(db) {
|
function getConfig(db) {
|
||||||
@ -461,7 +408,6 @@ function normalizeLobby(lobby) {
|
|||||||
lobbyChannelId: (lobby?.lobbyChannelId || "").toString().trim(),
|
lobbyChannelId: (lobby?.lobbyChannelId || "").toString().trim(),
|
||||||
categoryId: (lobby?.categoryId || "").toString().trim(),
|
categoryId: (lobby?.categoryId || "").toString().trim(),
|
||||||
nameTemplate: (lobby?.nameTemplate || DEFAULT_TEMPLATE).toString(),
|
nameTemplate: (lobby?.nameTemplate || DEFAULT_TEMPLATE).toString(),
|
||||||
roomMessage: (lobby?.roomMessage ?? DEFAULT_ROOM_MESSAGE).toString().slice(0, 2000),
|
|
||||||
emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : Math.max(5, timeout)
|
emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : Math.max(5, timeout)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -505,7 +451,6 @@ function parseConfigFromForm(body) {
|
|||||||
const lobbyChannelIds = toArray(body.lobby_channel_id);
|
const lobbyChannelIds = toArray(body.lobby_channel_id);
|
||||||
const categoryIds = toArray(body.lobby_category_id);
|
const categoryIds = toArray(body.lobby_category_id);
|
||||||
const templates = toArray(body.lobby_name_template);
|
const templates = toArray(body.lobby_name_template);
|
||||||
const roomMessages = toArray(body.lobby_room_message);
|
|
||||||
const timeouts = toArray(body.lobby_empty_timeout);
|
const timeouts = toArray(body.lobby_empty_timeout);
|
||||||
const removeIds = new Set(toArray(body.lobby_remove));
|
const removeIds = new Set(toArray(body.lobby_remove));
|
||||||
|
|
||||||
@ -516,7 +461,6 @@ function parseConfigFromForm(body) {
|
|||||||
lobbyChannelId: lobbyChannelIds[index] || "",
|
lobbyChannelId: lobbyChannelIds[index] || "",
|
||||||
categoryId: categoryIds[index] || "",
|
categoryId: categoryIds[index] || "",
|
||||||
nameTemplate: templates[index] || DEFAULT_TEMPLATE,
|
nameTemplate: templates[index] || DEFAULT_TEMPLATE,
|
||||||
roomMessage: roomMessages[index] ?? DEFAULT_ROOM_MESSAGE,
|
|
||||||
emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : timeout
|
emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : timeout
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -590,7 +534,6 @@ function isBanned(db, discordUserId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrapRooms(discordClient, db, state, settings) {
|
async function bootstrapRooms(discordClient, db, state, settings) {
|
||||||
const config = getConfig(db);
|
|
||||||
const rooms = db
|
const rooms = db
|
||||||
.prepare("SELECT * FROM auto_vc_rooms")
|
.prepare("SELECT * FROM auto_vc_rooms")
|
||||||
.all();
|
.all();
|
||||||
@ -611,9 +554,7 @@ async function bootstrapRooms(discordClient, db, state, settings) {
|
|||||||
removeRoom(db, state, room.channel_id);
|
removeRoom(db, state, room.channel_id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const normalized = normalizeRoom(room);
|
state.rooms.set(room.channel_id, normalizeRoom(room));
|
||||||
state.rooms.set(room.channel_id, normalized);
|
|
||||||
await syncOwnerAbsence(channel, normalized, db, state, config);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -630,8 +571,6 @@ function normalizeRoom(room) {
|
|||||||
locked: Boolean(room.locked),
|
locked: Boolean(room.locked),
|
||||||
allowed_user_ids: parseJsonArray(room.allowed_user_ids),
|
allowed_user_ids: parseJsonArray(room.allowed_user_ids),
|
||||||
base_overwrites: room.base_overwrites,
|
base_overwrites: room.base_overwrites,
|
||||||
control_message_id: room.control_message_id || null,
|
|
||||||
owner_absent_since: Number(room.owner_absent_since) || null,
|
|
||||||
created_at: room.created_at
|
created_at: room.created_at
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -654,9 +593,6 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) {
|
|||||||
const channel = oldState.guild.channels.cache.get(oldState.channelId);
|
const channel = oldState.guild.channels.cache.get(oldState.channelId);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
scheduleCleanup(channel, room, db, state, config);
|
scheduleCleanup(channel, room, db, state, config);
|
||||||
syncOwnerAbsence(channel, room, db, state, config).catch((error) => {
|
|
||||||
logger.warn("Auto VC owner absence update failed", error, { event: "owner_absence_sync_failed" });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -666,12 +602,6 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) {
|
|||||||
if (room) {
|
if (room) {
|
||||||
clearCleanupTimer(state, newState.channelId);
|
clearCleanupTimer(state, newState.channelId);
|
||||||
clearEmpty(state, newState.channelId);
|
clearEmpty(state, newState.channelId);
|
||||||
const channel = newState.guild.channels.cache.get(newState.channelId);
|
|
||||||
if (channel) {
|
|
||||||
syncOwnerAbsence(channel, room, db, state, config).catch((error) => {
|
|
||||||
logger.warn("Auto VC owner absence update failed", error, { event: "owner_absence_sync_failed" });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -730,14 +660,11 @@ async function createRoomFromLobby(newState, lobby, db, settings, state, config)
|
|||||||
locked: false,
|
locked: false,
|
||||||
allowed_user_ids: [],
|
allowed_user_ids: [],
|
||||||
base_overwrites: JSON.stringify(baseOverwrites),
|
base_overwrites: JSON.stringify(baseOverwrites),
|
||||||
control_message_id: null,
|
|
||||||
owner_absent_since: null,
|
|
||||||
created_at: Date.now()
|
created_at: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
saveRoom(db, room);
|
saveRoom(db, room);
|
||||||
const normalizedRoom = normalizeRoom(room);
|
state.rooms.set(channel.id, normalizeRoom(room));
|
||||||
state.rooms.set(channel.id, normalizedRoom);
|
|
||||||
incrementUserStat(db, profile.id);
|
incrementUserStat(db, profile.id);
|
||||||
|
|
||||||
const moved = await moveMemberToChannel(member, channel);
|
const moved = await moveMemberToChannel(member, channel);
|
||||||
@ -747,83 +674,6 @@ async function createRoomFromLobby(newState, lobby, db, settings, state, config)
|
|||||||
"I couldn't move you to the new VC. Please make sure the bot has Move Members permission."
|
"I couldn't move you to the new VC. Please make sure the bot has Move Members permission."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await createRoomControlMessage(channel, normalizedRoom, lobby, member, db, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerPlaceholderSupport(placeholders) {
|
|
||||||
if (!placeholders?.registerFieldPolicy || !placeholders?.registerPlaceholders) return;
|
|
||||||
placeholders.registerFieldPolicy({
|
|
||||||
field_id: ROOM_MESSAGE_FIELD_ID,
|
|
||||||
label: "Auto VC room message",
|
|
||||||
field_type: "chat_message",
|
|
||||||
output_audience: "public",
|
|
||||||
min_editor_role: "admin",
|
|
||||||
allowed_namespaces: ["plugins.auto_vc.channel"],
|
|
||||||
max_sensitivity: "public_safe"
|
|
||||||
});
|
|
||||||
placeholders.registerPlaceholders([{
|
|
||||||
id: "plugins.auto_vc.channel.owner_username",
|
|
||||||
namespace: "plugins.auto_vc.channel",
|
|
||||||
label: "Room owner username",
|
|
||||||
description: "The display name of the member who created the Auto VC.",
|
|
||||||
value_type: "string",
|
|
||||||
sensitivity: "public_safe",
|
|
||||||
min_editor_role: "admin",
|
|
||||||
min_viewer_role: "public",
|
|
||||||
allowed_field_types: ["chat_message"],
|
|
||||||
plugin_id: PLUGIN_ID,
|
|
||||||
resolver: ({ runtimeContext }) => runtimeContext?.autoVc?.owner_username || ""
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderRoomMessage(lobby, member, placeholders) {
|
|
||||||
const ownerUsername = member?.displayName || member?.user?.username || "Room owner";
|
|
||||||
const template = String(lobby?.roomMessage || DEFAULT_ROOM_MESSAGE).slice(0, 2000);
|
|
||||||
if (!placeholders?.renderTemplate) {
|
|
||||||
return template.replace(/\{\{\s*plugins\.auto_vc\.channel\.owner_username\s*\}\}/gi, ownerUsername).slice(0, 2000);
|
|
||||||
}
|
|
||||||
const result = await placeholders.renderTemplate({
|
|
||||||
fieldId: ROOM_MESSAGE_FIELD_ID,
|
|
||||||
template,
|
|
||||||
user: { isAdmin: true },
|
|
||||||
outputAudience: "public",
|
|
||||||
runtimeContext: { runtime: true, autoVc: { owner_username: ownerUsername } },
|
|
||||||
fallback: "[unavailable]"
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
logger.warn("Auto VC room message contained unavailable placeholders", {
|
|
||||||
error_count: result.errors?.length || 0
|
|
||||||
}, { event: "room_message_placeholder_failed" });
|
|
||||||
}
|
|
||||||
return String(result.rendered || DEFAULT_ROOM_MESSAGE).trim().slice(0, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createRoomControlMessage(channel, room, lobby, member, db, state) {
|
|
||||||
if (typeof channel?.send !== "function") {
|
|
||||||
logger.warn("Created Auto VC does not support room messages", {
|
|
||||||
channel_id: channel?.id || null
|
|
||||||
}, { event: "room_controls_unavailable" });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let message = null;
|
|
||||||
try {
|
|
||||||
const content = await renderRoomMessage(lobby, member, state.placeholders);
|
|
||||||
message = await channel.send(content || DEFAULT_ROOM_MESSAGE);
|
|
||||||
await message.react(LOCK_EMOJI);
|
|
||||||
await message.react(UNLOCK_EMOJI);
|
|
||||||
room.control_message_id = message.id;
|
|
||||||
room.owner_absent_since = null;
|
|
||||||
updateRoom(db, room);
|
|
||||||
state.rooms.set(room.channel_id, room);
|
|
||||||
return message;
|
|
||||||
} catch (error) {
|
|
||||||
if (message) await message.delete().catch(() => null);
|
|
||||||
logger.warn("Failed to create Auto VC room controls", error, {
|
|
||||||
event: "room_controls_create_failed",
|
|
||||||
channel_id: channel?.id || null
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRoomName(template, member, roomNumber, gameName) {
|
function buildRoomName(template, member, roomNumber, gameName) {
|
||||||
@ -912,9 +762,9 @@ function extractOverwrites(channel) {
|
|||||||
|
|
||||||
function saveRoom(db, room) {
|
function saveRoom(db, room) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO auto_vc_rooms (channel_id, guild_id, lobby_id, category_id, owner_discord_id, owner_user_id, room_number, name_template, locked, allowed_user_ids, base_overwrites, control_message_id, owner_absent_since, created_at) " +
|
"INSERT INTO auto_vc_rooms (channel_id, guild_id, lobby_id, category_id, owner_discord_id, owner_user_id, room_number, name_template, locked, allowed_user_ids, base_overwrites, created_at) " +
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||||
"ON CONFLICT(channel_id) DO UPDATE SET owner_discord_id = excluded.owner_discord_id, owner_user_id = excluded.owner_user_id, room_number = excluded.room_number, name_template = excluded.name_template, locked = excluded.locked, allowed_user_ids = excluded.allowed_user_ids, base_overwrites = excluded.base_overwrites, control_message_id = excluded.control_message_id, owner_absent_since = excluded.owner_absent_since"
|
"ON CONFLICT(channel_id) DO UPDATE SET owner_discord_id = excluded.owner_discord_id, owner_user_id = excluded.owner_user_id, room_number = excluded.room_number, name_template = excluded.name_template, locked = excluded.locked, allowed_user_ids = excluded.allowed_user_ids, base_overwrites = excluded.base_overwrites"
|
||||||
).run(
|
).run(
|
||||||
room.channel_id,
|
room.channel_id,
|
||||||
room.guild_id,
|
room.guild_id,
|
||||||
@ -927,15 +777,13 @@ function saveRoom(db, room) {
|
|||||||
room.locked ? 1 : 0,
|
room.locked ? 1 : 0,
|
||||||
JSON.stringify(room.allowed_user_ids || []),
|
JSON.stringify(room.allowed_user_ids || []),
|
||||||
room.base_overwrites,
|
room.base_overwrites,
|
||||||
room.control_message_id || null,
|
|
||||||
room.owner_absent_since || null,
|
|
||||||
room.created_at
|
room.created_at
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRoom(db, room) {
|
function updateRoom(db, room) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"UPDATE auto_vc_rooms SET owner_discord_id = ?, owner_user_id = ?, room_number = ?, name_template = ?, locked = ?, allowed_user_ids = ?, control_message_id = ?, owner_absent_since = ? WHERE channel_id = ?"
|
"UPDATE auto_vc_rooms SET owner_discord_id = ?, owner_user_id = ?, room_number = ?, name_template = ?, locked = ?, allowed_user_ids = ? WHERE channel_id = ?"
|
||||||
).run(
|
).run(
|
||||||
room.owner_discord_id,
|
room.owner_discord_id,
|
||||||
room.owner_user_id,
|
room.owner_user_id,
|
||||||
@ -943,8 +791,6 @@ function updateRoom(db, room) {
|
|||||||
room.name_template,
|
room.name_template,
|
||||||
room.locked ? 1 : 0,
|
room.locked ? 1 : 0,
|
||||||
JSON.stringify(room.allowed_user_ids || []),
|
JSON.stringify(room.allowed_user_ids || []),
|
||||||
room.control_message_id || null,
|
|
||||||
room.owner_absent_since || null,
|
|
||||||
room.channel_id
|
room.channel_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -963,7 +809,6 @@ function getRoomById(db, channelId, state) {
|
|||||||
|
|
||||||
function removeRoom(db, state, channelId) {
|
function removeRoom(db, state, channelId) {
|
||||||
clearCleanupTimer(state, channelId);
|
clearCleanupTimer(state, channelId);
|
||||||
clearClaimTimer(state, channelId);
|
|
||||||
clearEmpty(state, channelId);
|
clearEmpty(state, channelId);
|
||||||
state.rooms.delete(channelId);
|
state.rooms.delete(channelId);
|
||||||
db.prepare("DELETE FROM auto_vc_rooms WHERE channel_id = ?").run(channelId);
|
db.prepare("DELETE FROM auto_vc_rooms WHERE channel_id = ?").run(channelId);
|
||||||
@ -1077,168 +922,6 @@ function getLobbyTimeout(config, lobbyId) {
|
|||||||
return lobby ? lobby.emptyTimeoutSeconds : DEFAULT_TIMEOUT;
|
return lobby ? lobby.emptyTimeoutSeconds : DEFAULT_TIMEOUT;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearClaimTimer(state, channelId) {
|
|
||||||
const timer = state.claimTimers.get(channelId);
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
state.claimTimers.delete(channelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasClaimCandidate(channel, room) {
|
|
||||||
return [...(channel?.members?.values?.() || [])].some((member) =>
|
|
||||||
!member?.user?.bot && member.id !== room.owner_discord_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchControlMessage(channel, room) {
|
|
||||||
if (!room.control_message_id || typeof channel?.messages?.fetch !== "function") return null;
|
|
||||||
return channel.messages.fetch(room.control_message_id).catch(() => null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setClaimReaction(channel, room, available) {
|
|
||||||
const message = await fetchControlMessage(channel, room);
|
|
||||||
if (!message) return;
|
|
||||||
const reaction = message.reactions?.cache?.find((entry) => entry.emoji?.name === CLAIM_EMOJI);
|
|
||||||
if (available) {
|
|
||||||
if (!reaction?.me) await message.react(CLAIM_EMOJI);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (reaction?.me && message.client?.user?.id) {
|
|
||||||
await reaction.users.remove(message.client.user.id).catch(() => null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncOwnerAbsence(channel, room, db, state, config) {
|
|
||||||
clearClaimTimer(state, channel.id);
|
|
||||||
const ownerPresent = Boolean(channel.members?.has(room.owner_discord_id));
|
|
||||||
const claimCandidatePresent = hasClaimCandidate(channel, room);
|
|
||||||
if (ownerPresent || !claimCandidatePresent) {
|
|
||||||
if (room.owner_absent_since) {
|
|
||||||
room.owner_absent_since = null;
|
|
||||||
updateRoom(db, room);
|
|
||||||
state.rooms.set(room.channel_id, room);
|
|
||||||
}
|
|
||||||
await setClaimReaction(channel, room, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!room.owner_absent_since) {
|
|
||||||
room.owner_absent_since = Date.now();
|
|
||||||
updateRoom(db, room);
|
|
||||||
state.rooms.set(room.channel_id, room);
|
|
||||||
}
|
|
||||||
const timeoutMs = getLobbyTimeout(config, room.lobby_id) * 1000;
|
|
||||||
const remaining = timeoutMs - (Date.now() - room.owner_absent_since);
|
|
||||||
if (remaining <= 0) {
|
|
||||||
await setClaimReaction(channel, room, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await setClaimReaction(channel, room, false);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
state.claimTimers.delete(channel.id);
|
|
||||||
const refreshedRoom = state.rooms.get(channel.id) || getRoomById(db, channel.id, state);
|
|
||||||
const refreshedChannel = channel.guild?.channels?.cache?.get(channel.id);
|
|
||||||
if (!refreshedRoom || !refreshedChannel) return;
|
|
||||||
syncOwnerAbsence(refreshedChannel, refreshedRoom, db, state, getConfig(db)).catch((error) => {
|
|
||||||
logger.warn("Auto VC claim reaction update failed", error, { event: "claim_reaction_update_failed" });
|
|
||||||
});
|
|
||||||
}, remaining);
|
|
||||||
timer.unref?.();
|
|
||||||
state.claimTimers.set(channel.id, timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueControlReaction(reaction, user, db, settings, state) {
|
|
||||||
if (!reaction?.message?.id || user?.bot) return;
|
|
||||||
const key = reaction.message.id;
|
|
||||||
const previous = state.reactionActions.get(key) || Promise.resolve();
|
|
||||||
const current = previous
|
|
||||||
.catch(() => null)
|
|
||||||
.then(() => handleControlReaction(reaction, user, db, settings, state))
|
|
||||||
.catch((error) => {
|
|
||||||
logger.warn("Auto VC reaction action failed", error, { event: "room_reaction_failed" });
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (state.reactionActions.get(key) === current) state.reactionActions.delete(key);
|
|
||||||
});
|
|
||||||
state.reactionActions.set(key, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleControlReaction(reaction, user, db, settings, state) {
|
|
||||||
if (reaction.partial && typeof reaction.fetch === "function") {
|
|
||||||
reaction = await reaction.fetch().catch(() => null);
|
|
||||||
}
|
|
||||||
if (!reaction?.message) return;
|
|
||||||
let message = reaction.message;
|
|
||||||
if (message.partial && typeof message.fetch === "function") {
|
|
||||||
message = await message.fetch().catch(() => null);
|
|
||||||
}
|
|
||||||
const emoji = reaction.emoji?.name;
|
|
||||||
if (!message || ![LOCK_EMOJI, UNLOCK_EMOJI, CLAIM_EMOJI].includes(emoji)) return;
|
|
||||||
const channel = message.channel;
|
|
||||||
const room = state.rooms.get(channel?.id) || getRoomById(db, channel?.id, state);
|
|
||||||
if (!room || room.control_message_id !== message.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const member = await message.guild?.members?.fetch(user.id).catch(() => null);
|
|
||||||
let result;
|
|
||||||
if (!member) {
|
|
||||||
result = { ok: false, message: "Could not verify that member." };
|
|
||||||
} else {
|
|
||||||
const rateLimit = consumeRateLimit(state, getConfig(db), "action", member.id);
|
|
||||||
if (!rateLimit.ok) {
|
|
||||||
result = { ok: false, message: `Try again in ${formatCooldown(rateLimit.retryAfter)}.` };
|
|
||||||
} else if (emoji === LOCK_EMOJI || emoji === UNLOCK_EMOJI) {
|
|
||||||
if (member.id !== room.owner_discord_id) {
|
|
||||||
result = { ok: false, message: "Only the room owner can use that control." };
|
|
||||||
} else {
|
|
||||||
const locked = emoji === LOCK_EMOJI;
|
|
||||||
const applied = await setRoomLocked(channel, room, locked, db, settings, state);
|
|
||||||
result = applied
|
|
||||||
? { ok: true, message: locked ? "Room locked." : "Room unlocked." }
|
|
||||||
: { ok: false, message: "Discord did not allow the room permission change." };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const eligibility = claimEligibility(channel, room, member, getConfig(db), Date.now());
|
|
||||||
if (!eligibility.ok) {
|
|
||||||
result = eligibility;
|
|
||||||
} else {
|
|
||||||
const claimed = await claimRoomOwnership(channel, room, member, db, settings, state);
|
|
||||||
if (claimed) {
|
|
||||||
await syncOwnerAbsence(channel, room, db, state, getConfig(db));
|
|
||||||
result = { ok: true, message: `${member.displayName} now owns this room.` };
|
|
||||||
} else {
|
|
||||||
result = { ok: false, message: "Discord did not allow the ownership change." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await sendTemporaryReport(channel, `${result.ok ? "✓" : "Couldn’t do that:"} ${result.message}`);
|
|
||||||
} finally {
|
|
||||||
await reaction.users?.remove(user.id).catch(() => null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimEligibility(channel, room, member, config, now = Date.now()) {
|
|
||||||
if (member.id === room.owner_discord_id) return { ok: false, message: "You already own this room." };
|
|
||||||
if (!channel.members?.has(member.id)) return { ok: false, message: "Join this voice room before claiming it." };
|
|
||||||
if (channel.members?.has(room.owner_discord_id)) return { ok: false, message: "The current owner is still here." };
|
|
||||||
const absentSince = Number(room.owner_absent_since) || now;
|
|
||||||
const remaining = getLobbyTimeout(config, room.lobby_id) * 1000 - (now - absentSince);
|
|
||||||
if (remaining > 0) return { ok: false, message: `The room can be claimed in ${formatCooldown(Math.ceil(remaining / 1000))}.` };
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendTemporaryReport(channel, content) {
|
|
||||||
if (typeof channel?.send !== "function") return;
|
|
||||||
const report = await channel.send(String(content).slice(0, 500)).catch(() => null);
|
|
||||||
if (!report) return;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
report.delete().catch(() => null);
|
|
||||||
}, REPORT_LIFETIME_MS);
|
|
||||||
timer.unref?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteChannel(channel) {
|
async function deleteChannel(channel) {
|
||||||
try {
|
try {
|
||||||
await channel.delete("Auto VC cleanup");
|
await channel.delete("Auto VC cleanup");
|
||||||
@ -1381,12 +1064,13 @@ async function handleLock(message, room, db, settings, state, config) {
|
|||||||
if (!enforceActionRateLimit(message, state, config)) {
|
if (!enforceActionRateLimit(message, state, config)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (room.locked) {
|
room.locked = !room.locked;
|
||||||
message.reply("Room is already locked.").catch(() => null);
|
await applyRoomPermissions(channel, room, db, settings);
|
||||||
return;
|
updateRoom(db, room);
|
||||||
}
|
state.rooms.set(room.channel_id, room);
|
||||||
const applied = await setRoomLocked(channel, room, true, db, settings, state);
|
message
|
||||||
message.reply(applied ? "Room locked." : "I couldn't update this room's permissions.").catch(() => null);
|
.reply(room.locked ? "Room locked." : "Room unlocked.")
|
||||||
|
.catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUnlock(message, room, db, settings, state, config) {
|
async function handleUnlock(message, room, db, settings, state, config) {
|
||||||
@ -1402,20 +1086,11 @@ async function handleUnlock(message, room, db, settings, state, config) {
|
|||||||
message.reply("Room is already unlocked.").catch(() => null);
|
message.reply("Room is already unlocked.").catch(() => null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const applied = await setRoomLocked(channel, room, false, db, settings, state);
|
room.locked = false;
|
||||||
message.reply(applied ? "Room unlocked." : "I couldn't update this room's permissions.").catch(() => null);
|
await applyRoomPermissions(channel, room, db, settings);
|
||||||
}
|
|
||||||
|
|
||||||
async function setRoomLocked(channel, room, locked, db, settings, state) {
|
|
||||||
const previous = room.locked;
|
|
||||||
room.locked = Boolean(locked);
|
|
||||||
if (!await applyRoomPermissions(channel, room, db, settings)) {
|
|
||||||
room.locked = previous;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
updateRoom(db, room);
|
updateRoom(db, room);
|
||||||
state.rooms.set(room.channel_id, room);
|
state.rooms.set(room.channel_id, room);
|
||||||
return true;
|
message.reply("Room unlocked.").catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAllow(message, room, args, db, settings, state, config) {
|
async function handleAllow(message, room, args, db, settings, state, config) {
|
||||||
@ -1493,15 +1168,12 @@ async function handleTransfer(message, room, args, db, settings, state, config)
|
|||||||
displayName: target.displayName
|
displayName: target.displayName
|
||||||
});
|
});
|
||||||
room.owner_user_id = profile.id;
|
room.owner_user_id = profile.id;
|
||||||
room.owner_absent_since = null;
|
|
||||||
const channel = message.guild.channels.cache.get(room.channel_id);
|
const channel = message.guild.channels.cache.get(room.channel_id);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
await applyRoomPermissions(channel, room, db, settings);
|
await applyRoomPermissions(channel, room, db, settings);
|
||||||
}
|
}
|
||||||
updateRoom(db, room);
|
updateRoom(db, room);
|
||||||
state.rooms.set(room.channel_id, room);
|
state.rooms.set(room.channel_id, room);
|
||||||
clearClaimTimer(state, room.channel_id);
|
|
||||||
if (channel) await setClaimReaction(channel, room, false);
|
|
||||||
message.reply(`Ownership transferred to ${target.displayName}.`).catch(() => null);
|
message.reply(`Ownership transferred to ${target.displayName}.`).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1518,14 +1190,6 @@ async function handleClaim(message, room, member, db, settings, state, config) {
|
|||||||
if (!enforceActionRateLimit(message, state, config)) {
|
if (!enforceActionRateLimit(message, state, config)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const claimed = await claimRoomOwnership(channel, room, member, db, settings, state);
|
|
||||||
message.reply(claimed ? "You are now the owner of this room." : "I couldn't update this room's permissions.").catch(() => null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function claimRoomOwnership(channel, room, member, db, settings, state) {
|
|
||||||
const previousOwnerDiscordId = room.owner_discord_id;
|
|
||||||
const previousOwnerUserId = room.owner_user_id;
|
|
||||||
const previousOwnerAbsentSince = room.owner_absent_since;
|
|
||||||
room.owner_discord_id = member.id;
|
room.owner_discord_id = member.id;
|
||||||
const profile = ensureUserForIdentity({
|
const profile = ensureUserForIdentity({
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
@ -1533,18 +1197,10 @@ async function claimRoomOwnership(channel, room, member, db, settings, state) {
|
|||||||
displayName: member.displayName
|
displayName: member.displayName
|
||||||
});
|
});
|
||||||
room.owner_user_id = profile.id;
|
room.owner_user_id = profile.id;
|
||||||
room.owner_absent_since = null;
|
await applyRoomPermissions(channel, room, db, settings);
|
||||||
if (!await applyRoomPermissions(channel, room, db, settings)) {
|
|
||||||
room.owner_discord_id = previousOwnerDiscordId;
|
|
||||||
room.owner_user_id = previousOwnerUserId;
|
|
||||||
room.owner_absent_since = previousOwnerAbsentSince;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
updateRoom(db, room);
|
updateRoom(db, room);
|
||||||
state.rooms.set(room.channel_id, room);
|
state.rooms.set(room.channel_id, room);
|
||||||
clearClaimTimer(state, room.channel_id);
|
message.reply("You are now the owner of this room.").catch(() => null);
|
||||||
await setClaimReaction(channel, room, false);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyRoomPermissions(channel, room, db, settings) {
|
async function applyRoomPermissions(channel, room, db, settings) {
|
||||||
@ -1593,16 +1249,7 @@ async function applyRoomPermissions(channel, room, db, settings) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await channel.permissionOverwrites.set(Array.from(overrides.values())).catch(() => null);
|
||||||
await channel.permissionOverwrites.set(Array.from(overrides.values()));
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("Failed to update Auto VC permissions", error, {
|
|
||||||
event: "room_permissions_update_failed",
|
|
||||||
channel_id: channel?.id || null
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkIsModerator(member, settings) {
|
function checkIsModerator(member, settings) {
|
||||||
@ -1704,21 +1351,6 @@ function cryptoRandomId() {
|
|||||||
return require("crypto").randomUUID();
|
return require("crypto").randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports._test = {
|
|
||||||
CLAIM_EMOJI,
|
|
||||||
DEFAULT_ROOM_MESSAGE,
|
|
||||||
LOCK_EMOJI,
|
|
||||||
UNLOCK_EMOJI,
|
|
||||||
claimEligibility,
|
|
||||||
ensureTables,
|
|
||||||
normalizeConfig,
|
|
||||||
normalizeLobby,
|
|
||||||
parseConfigFromForm,
|
|
||||||
registerPlaceholderSupport,
|
|
||||||
renderRoomMessage,
|
|
||||||
setRoomLocked
|
|
||||||
};
|
|
||||||
|
|
||||||
async function safeNotify(member, message) {
|
async function safeNotify(member, message) {
|
||||||
try {
|
try {
|
||||||
await member.send(message);
|
await member.send(message);
|
||||||
@ -1814,7 +1446,6 @@ async function sweepRooms(discordClient, db, state) {
|
|||||||
removeRoom(db, state, room.channel_id);
|
removeRoom(db, state, room.channel_id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await syncOwnerAbsence(channel, room, db, state, config);
|
|
||||||
if (channel.members.size === 0) {
|
if (channel.members.size === 0) {
|
||||||
markEmpty(state, channel.id);
|
markEmpty(state, channel.id);
|
||||||
const timeout = getLobbyTimeout(config, room.lobby_id);
|
const timeout = getLobbyTimeout(config, room.lobby_id);
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"id": "auto-vc",
|
"id": "auto-vc",
|
||||||
"name": "Auto VC",
|
"name": "Auto VC",
|
||||||
"version": "0.1.7",
|
"version": "0.1.6",
|
||||||
"description": "Auto-create managed voice channels from lobby rooms.",
|
"description": "Auto-create managed voice channels from lobby rooms.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"compatible_from": "0.1.5",
|
"compatible_from": "0.1.5",
|
||||||
"migration_notes": "Adds persisted room-control messages and owner-absence timestamps with automatic additive database migration. Existing lobbies, rooms, owners, permissions, bans, and statistics remain preserved.",
|
"migration_notes": "No manual migration is required.",
|
||||||
"rollback_safe": true
|
"rollback_safe": true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
const assert = require("assert");
|
|
||||||
const Database = require("better-sqlite3");
|
|
||||||
const plugin = require("../index");
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const {
|
|
||||||
CLAIM_EMOJI,
|
|
||||||
DEFAULT_ROOM_MESSAGE,
|
|
||||||
LOCK_EMOJI,
|
|
||||||
UNLOCK_EMOJI,
|
|
||||||
claimEligibility,
|
|
||||||
ensureTables,
|
|
||||||
normalizeConfig,
|
|
||||||
parseConfigFromForm,
|
|
||||||
registerPlaceholderSupport,
|
|
||||||
renderRoomMessage,
|
|
||||||
setRoomLocked
|
|
||||||
} = plugin._test;
|
|
||||||
|
|
||||||
const defaults = normalizeConfig({ lobbies: [{ id: "lobby-1" }] });
|
|
||||||
assert.equal(defaults.lobbies[0].roomMessage, DEFAULT_ROOM_MESSAGE);
|
|
||||||
assert.equal(defaults.lobbies[0].emptyTimeoutSeconds, 30);
|
|
||||||
|
|
||||||
const parsed = parseConfigFromForm({
|
|
||||||
lobby_id: ["one", "two"],
|
|
||||||
lobby_channel_id: ["voice-1", "voice-2"],
|
|
||||||
lobby_category_id: ["category-1", "category-2"],
|
|
||||||
lobby_name_template: ["[username] one", "[username] two"],
|
|
||||||
lobby_room_message: ["Hello one", "Hello two"],
|
|
||||||
lobby_empty_timeout: ["45", "60"]
|
|
||||||
});
|
|
||||||
assert.deepStrictEqual(parsed.lobbies.map((lobby) => lobby.roomMessage), ["Hello one", "Hello two"]);
|
|
||||||
assert.deepStrictEqual(parsed.lobbies.map((lobby) => lobby.emptyTimeoutSeconds), [45, 60]);
|
|
||||||
|
|
||||||
const database = new Database(":memory:");
|
|
||||||
database.exec("CREATE TABLE auto_vc_rooms (channel_id TEXT PRIMARY KEY)");
|
|
||||||
ensureTables(database);
|
|
||||||
const columns = database.prepare("PRAGMA table_info(auto_vc_rooms)").all().map((column) => column.name);
|
|
||||||
assert(columns.includes("control_message_id"), "room controls must survive a restart");
|
|
||||||
assert(columns.includes("owner_absent_since"), "owner absence must survive a restart");
|
|
||||||
database.close();
|
|
||||||
|
|
||||||
let policy;
|
|
||||||
let definitions;
|
|
||||||
const placeholders = {
|
|
||||||
registerFieldPolicy(value) { policy = value; },
|
|
||||||
registerPlaceholders(value) { definitions = value; },
|
|
||||||
async renderTemplate({ template, runtimeContext }) {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
rendered: template.replace("{{plugins.auto_vc.channel.owner_username}}", runtimeContext.autoVc.owner_username)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
registerPlaceholderSupport(placeholders);
|
|
||||||
assert.equal(policy.field_id, "plugins.auto_vc.room_message");
|
|
||||||
assert(policy.allowed_namespaces.includes("plugins.auto_vc.channel"));
|
|
||||||
assert.equal(definitions[0].id, "plugins.auto_vc.channel.owner_username");
|
|
||||||
const rendered = await renderRoomMessage(
|
|
||||||
{ roomMessage: "Welcome {{plugins.auto_vc.channel.owner_username}}" },
|
|
||||||
{ displayName: "Lumi User" },
|
|
||||||
placeholders
|
|
||||||
);
|
|
||||||
assert.equal(rendered, "Welcome Lumi User");
|
|
||||||
|
|
||||||
const members = new Map([["claimant", { id: "claimant", user: { bot: false } }]]);
|
|
||||||
const channel = { members };
|
|
||||||
const config = { lobbies: [{ id: "lobby-1", emptyTimeoutSeconds: 30 }] };
|
|
||||||
const room = {
|
|
||||||
lobby_id: "lobby-1",
|
|
||||||
owner_discord_id: "owner",
|
|
||||||
owner_absent_since: 1_000
|
|
||||||
};
|
|
||||||
assert.equal(claimEligibility(channel, room, { id: "claimant" }, config, 30_999).ok, false);
|
|
||||||
assert.equal(claimEligibility(channel, room, { id: "claimant" }, config, 31_000).ok, true);
|
|
||||||
members.set("owner", { id: "owner", user: { bot: false } });
|
|
||||||
assert.equal(claimEligibility(channel, room, { id: "claimant" }, config, 60_000).ok, false);
|
|
||||||
assert.deepStrictEqual([LOCK_EMOJI, UNLOCK_EMOJI, CLAIM_EMOJI], ["🔒", "🔓", "👑"]);
|
|
||||||
|
|
||||||
const failedRoom = {
|
|
||||||
channel_id: "room",
|
|
||||||
owner_discord_id: "owner",
|
|
||||||
locked: false,
|
|
||||||
allowed_user_ids: [],
|
|
||||||
base_overwrites: "[]"
|
|
||||||
};
|
|
||||||
const applied = await setRoomLocked({
|
|
||||||
id: "room",
|
|
||||||
guild: { roles: { everyone: { id: "everyone" } } },
|
|
||||||
permissionOverwrites: { async set() { throw new Error("missing permission"); } }
|
|
||||||
}, failedRoom, true, {
|
|
||||||
prepare() { throw new Error("a failed Discord change must not be persisted"); }
|
|
||||||
}, { getSetting() { return ""; } }, { rooms: new Map() });
|
|
||||||
assert.equal(applied, false);
|
|
||||||
assert.equal(failedRoom.locked, false, "failed permission changes must not report or persist success");
|
|
||||||
|
|
||||||
console.log("Auto VC verification passed: welcome placeholders, persisted controls, and timed ownership claims.");
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
@ -275,15 +275,6 @@
|
|||||||
<label>Empty room cleanup (seconds)</label>
|
<label>Empty room cleanup (seconds)</label>
|
||||||
<input name="lobby_empty_timeout" value="<%= lobby.emptyTimeoutSeconds %>" type="number" min="5" />
|
<input name="lobby_empty_timeout" value="<%= lobby.emptyTimeoutSeconds %>" type="number" min="5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field full">
|
|
||||||
<label>New room message</label>
|
|
||||||
<textarea name="lobby_room_message" rows="4" maxlength="2000"><%= lobby.roomMessage %></textarea>
|
|
||||||
<p class="hint">
|
|
||||||
Sent in every newly created room with lock and unlock reactions.
|
|
||||||
Supports <code>{{plugins.auto_vc.channel.owner_username}}</code>.
|
|
||||||
The claim reaction appears after the owner has been away for the cleanup duration.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<% if (lobby.permissions && lobby.permissions.length) { %>
|
<% if (lobby.permissions && lobby.permissions.length) { %>
|
||||||
<% const totalPerms = lobby.permissions.length; %>
|
<% const totalPerms = lobby.permissions.length; %>
|
||||||
@ -437,13 +428,6 @@
|
|||||||
<label>Empty room cleanup (seconds)</label>
|
<label>Empty room cleanup (seconds)</label>
|
||||||
<input name="lobby_empty_timeout" value="30" type="number" min="5" />
|
<input name="lobby_empty_timeout" value="30" type="number" min="5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field full">
|
|
||||||
<label>New room message</label>
|
|
||||||
<textarea name="lobby_room_message" rows="4" maxlength="2000">Welcome {{plugins.auto_vc.channel.owner_username}}! Use 🔒 to lock this room or 🔓 to unlock it. If the owner is away long enough, 👑 appears so someone in the room can claim it.</textarea>
|
|
||||||
<p class="hint">
|
|
||||||
Sent with lock and unlock reactions. The claim reaction appears after the owner has been away for the cleanup duration.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
# Lumi Overlay Feed changelog
|
# Lumi Overlay Feed changelog
|
||||||
|
|
||||||
## 0.1.1
|
|
||||||
|
|
||||||
- Preserve Discord emotes and GIF media in the authenticated Companion feed.
|
|
||||||
- Preserve Twitch badge artwork and all normalized HTTPS emote sources while
|
|
||||||
retaining the existing bounded payload and server-side filtering rules.
|
|
||||||
|
|
||||||
## 0.1.0
|
## 0.1.0
|
||||||
|
|
||||||
- Add the paired-device `overlay.read.v1` live feed for the native Companion
|
- Add the paired-device `overlay.read.v1` live feed for the native Companion
|
||||||
|
|||||||
@ -51,18 +51,12 @@ function sanitizeChat(message) {
|
|||||||
image: safeHttps(badge?.image)
|
image: safeHttps(badge?.image)
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
emotes: (Array.isArray(message.emotes) ? message.emotes : []).slice(0, 100).map((emote) => ({
|
emotes: (message.platform === "twitch" && Array.isArray(message.emotes) ? message.emotes : []).slice(0, 100).map((emote) => ({
|
||||||
start: Math.max(0, Number(emote?.start) || 0),
|
start: Math.max(0, Number(emote?.start) || 0),
|
||||||
end: Math.max(0, Number(emote?.end) || 0),
|
end: Math.max(0, Number(emote?.end) || 0),
|
||||||
label: cleanText(emote?.label, 100),
|
label: cleanText(emote?.label, 100),
|
||||||
image: safeHttps(emote?.image)
|
image: safeHttps(emote?.image)
|
||||||
})).filter((emote) => emote.image),
|
})).filter((emote) => emote.image)
|
||||||
media: (Array.isArray(message.media) ? message.media : []).slice(0, 4).map((media) => ({
|
|
||||||
url: safeHttps(media?.url),
|
|
||||||
type: media?.type === "video" ? "video" : "image",
|
|
||||||
alt: cleanText(media?.alt, 160),
|
|
||||||
preview: safeHttps(media?.preview)
|
|
||||||
})).filter((media) => media.url)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_overlay",
|
"id": "lumi_overlay",
|
||||||
"name": "Lumi Overlay Feed",
|
"name": "Lumi Overlay Feed",
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"description": "Authenticated live chat and event feed for the native Lumi Companion monitor overlay.",
|
"description": "Authenticated live chat and event feed for the native Lumi Companion monitor overlay.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
|
|||||||
@ -112,14 +112,6 @@ function verifySanitization() {
|
|||||||
assert.equal(chat.text, "<script>plain text</script>", "content remains plain data and is not interpreted server-side");
|
assert.equal(chat.text, "<script>plain text</script>", "content remains plain data and is not interpreted server-side");
|
||||||
assert.equal(chat.author.avatar, null);
|
assert.equal(chat.author.avatar, null);
|
||||||
assert.equal(chat.emotes[0].image, "https://cdn.test/emote.png");
|
assert.equal(chat.emotes[0].image, "https://cdn.test/emote.png");
|
||||||
const discord = sanitizeChat({
|
|
||||||
id: "discord-id", platform: "discord", text: "<a:wave:1>", channel: {},
|
|
||||||
author: { id: "discord-user", name: "Discord viewer", badges: [] },
|
|
||||||
emotes: [{ start: 0, end: 9, label: ":wave:", image: "https://cdn.test/wave.gif" }],
|
|
||||||
media: [{ url: "https://cdn.test/reaction.gif", type: "image", alt: "Reaction" }]
|
|
||||||
});
|
|
||||||
assert.equal(discord.emotes[0].image, "https://cdn.test/wave.gif", "Discord emotes must reach the Companion feed");
|
|
||||||
assert.equal(discord.media[0].url, "https://cdn.test/reaction.gif", "Discord GIFs must reach the Companion feed");
|
|
||||||
const event = sanitizeEvent({ id: "event", type: "twitch.follow", payload: { user_name: "Viewer", access_token: "secret" } });
|
const event = sanitizeEvent({ id: "event", type: "twitch.follow", payload: { user_name: "Viewer", access_token: "secret" } });
|
||||||
assert.equal(event.payload.access_token, undefined);
|
assert.equal(event.payload.access_token, undefined);
|
||||||
assert.equal(event.payload.user_name, "Viewer");
|
assert.equal(event.payload.user_name, "Viewer");
|
||||||
|
|||||||
@ -1,17 +1,5 @@
|
|||||||
# Lumi Transcription changelog
|
# Lumi Transcription changelog
|
||||||
|
|
||||||
## 0.2.8
|
|
||||||
|
|
||||||
- Release Companion 0.2.8 with reusable click-to-capture hotkeys, independently switchable Windows media sources, and direct drag/resize editing through the actual native Lumi Overlay renderer.
|
|
||||||
- Preserve paired identity, transcription settings and behavior, OBS Bridge 0.2.5, Song Overlay settings, native overlay settings, and automatic update compatibility.
|
|
||||||
|
|
||||||
## 0.2.7
|
|
||||||
|
|
||||||
- Release Companion 0.2.7 with animated native overlay emotes, Discord GIF
|
|
||||||
media, and Twitch badge artwork.
|
|
||||||
- Retain the existing OBS browser overlay renderer, transcription behavior,
|
|
||||||
OBS Bridge 0.2.5, paired identity, and automatic update compatibility.
|
|
||||||
|
|
||||||
## 0.2.6
|
## 0.2.6
|
||||||
|
|
||||||
- Release Companion 0.2.6 with the native Lumi Overlay and shell-owned authenticated live feed.
|
- Release Companion 0.2.6 with the native Lumi Overlay and shell-owned authenticated live feed.
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"version": "0.2.8",
|
"version": "0.2.6",
|
||||||
"signed": false,
|
"signed": false,
|
||||||
"release_notes": "Adds click-to-capture hotkeys, independent Windows media-source discovery, and direct drag/resize editing through the actual native Lumi Overlay renderer while retaining paired identity, transcription, Stream Testing, silent reconnect, and OBS Bridge 0.2.5.",
|
"release_notes": "Adds the native Lumi Overlay, draft-safe settings, clearer visibility and layout controls, and direct navigation for single-page plugins while retaining transcription, Stream Testing, Song Overlay, silent reconnect, and OBS Bridge 0.2.5.",
|
||||||
"installer": {
|
"installer": {
|
||||||
"id": "windows-x64-installer",
|
"id": "windows-x64-installer",
|
||||||
"platform": "win32",
|
"platform": "win32",
|
||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"label": "Windows x64 per-user installer",
|
"label": "Windows x64 per-user installer",
|
||||||
"filename": "Lumi.Companion-Setup.exe",
|
"filename": "Lumi.Companion-Setup.exe",
|
||||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.8/Lumi.Companion-Setup.exe",
|
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-Setup.exe",
|
||||||
"sha256": "15d632160d29b29773e2ec692ef1061dc6d662a4a80a128ea1674ee97bdeef99",
|
"sha256": "0420cdee45d676ded1566fd8da2e25482eca258b1d7d200544e5425d22105bb8",
|
||||||
"bytes": 51926217
|
"bytes": 51922633
|
||||||
},
|
},
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
@ -20,9 +20,9 @@
|
|||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"label": "Windows x64 self-contained",
|
"label": "Windows x64 self-contained",
|
||||||
"filename": "Lumi.Companion-win-x64.zip",
|
"filename": "Lumi.Companion-win-x64.zip",
|
||||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.8/Lumi.Companion-win-x64.zip",
|
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-win-x64.zip",
|
||||||
"sha256": "df94e2cbccb54a8f002940f25099572fb92fc3448da2ec463f85446df55fb1a3",
|
"sha256": "1e216f9653d0dc0513ca119f0a8c5cf43bf47328a83cbbf183e1d4b14c111e94",
|
||||||
"bytes": 66863322,
|
"bytes": 66857759,
|
||||||
"entrypoint": "Lumi.Companion.App.exe"
|
"entrypoint": "Lumi.Companion.App.exe"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_transcription",
|
"id": "lumi_transcription",
|
||||||
"name": "Lumi Transcription",
|
"name": "Lumi Transcription",
|
||||||
"version": "0.2.8",
|
"version": "0.2.6",
|
||||||
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
|
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
|
|||||||
@ -2,105 +2,6 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releases": [
|
"releases": [
|
||||||
{
|
|
||||||
"version": "0.3.11",
|
|
||||||
"ref": "refs/tags/v0.3.11",
|
|
||||||
"released_at": "2026-07-29",
|
|
||||||
"installable": true,
|
|
||||||
"rollback_safe": true,
|
|
||||||
"replaces_versions": [
|
|
||||||
"1.2.0"
|
|
||||||
],
|
|
||||||
"data_policy": "preserve",
|
|
||||||
"dependency_policy": "sync_on_restart",
|
|
||||||
"migration_notes": "Releases Companion 0.2.8 with captured hotkeys, multi-source Windows media discovery, and an interactive native overlay editor; improves tenure presentation and adds per-user command and expression favorites. The additive command-usage table preserves all existing settings, identities, statistics, intervals, plugins, pairing records, databases, uploads, models, and secrets.",
|
|
||||||
"plugins": {
|
|
||||||
"auto-vc": "0.1.7",
|
|
||||||
"birthday": "0.1.3",
|
|
||||||
"economy-framework": "0.2.10",
|
|
||||||
"economy-games": "0.1.7",
|
|
||||||
"expression-interaction": "0.2.1",
|
|
||||||
"lumi_ai": "0.8.5",
|
|
||||||
"lumi_overlay": "0.1.1",
|
|
||||||
"lumi_transcription": "0.2.8",
|
|
||||||
"moderation": "0.1.5",
|
|
||||||
"now_playing": "0.1.3",
|
|
||||||
"okf": "0.1.2",
|
|
||||||
"quotes": "0.1.2",
|
|
||||||
"sample-plugin": "0.1.0",
|
|
||||||
"throne_wishlist": "0.1.2",
|
|
||||||
"welcome_messages": "0.1.1"
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"lumi_ai_web_search": "0.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.3.10",
|
|
||||||
"ref": "refs/tags/v0.3.10",
|
|
||||||
"released_at": "2026-07-27",
|
|
||||||
"installable": true,
|
|
||||||
"rollback_safe": true,
|
|
||||||
"replaces_versions": [
|
|
||||||
"1.2.0"
|
|
||||||
],
|
|
||||||
"data_policy": "preserve",
|
|
||||||
"dependency_policy": "sync_on_restart",
|
|
||||||
"migration_notes": "Adds reusable platform-tenure interval history for Twitch, YouTube, and Discord; contextual current and total duration placeholders with deterministic formatting; safe platform reconciliation; admin diagnostics; and Platform tenure sections on Stats and Leaderboards. Existing settings, identities, statistics, intervals, plugins, pairing records, databases, uploads, models, and secrets remain preserved.",
|
|
||||||
"plugins": {
|
|
||||||
"auto-vc": "0.1.7",
|
|
||||||
"birthday": "0.1.3",
|
|
||||||
"economy-framework": "0.2.10",
|
|
||||||
"economy-games": "0.1.7",
|
|
||||||
"expression-interaction": "0.2.1",
|
|
||||||
"lumi_ai": "0.8.5",
|
|
||||||
"lumi_overlay": "0.1.1",
|
|
||||||
"lumi_transcription": "0.2.7",
|
|
||||||
"moderation": "0.1.5",
|
|
||||||
"now_playing": "0.1.3",
|
|
||||||
"okf": "0.1.2",
|
|
||||||
"quotes": "0.1.2",
|
|
||||||
"sample-plugin": "0.1.0",
|
|
||||||
"throne_wishlist": "0.1.2",
|
|
||||||
"welcome_messages": "0.1.1"
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"lumi_ai_web_search": "0.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.3.9",
|
|
||||||
"ref": "refs/tags/v0.3.9",
|
|
||||||
"released_at": "2026-07-26",
|
|
||||||
"installable": true,
|
|
||||||
"rollback_safe": true,
|
|
||||||
"replaces_versions": [
|
|
||||||
"1.2.0"
|
|
||||||
],
|
|
||||||
"data_policy": "preserve",
|
|
||||||
"dependency_policy": "sync_on_restart",
|
|
||||||
"migration_notes": "Restores animated emotes, Twitch badge artwork, and Discord GIF media through the existing OBS and native Companion overlay paths; adds persistent Auto VC welcome controls and timed ownership claiming; and releases Companion 0.2.7. Existing settings, lobbies, rooms, owners, permissions, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.",
|
|
||||||
"plugins": {
|
|
||||||
"auto-vc": "0.1.7",
|
|
||||||
"birthday": "0.1.3",
|
|
||||||
"economy-framework": "0.2.10",
|
|
||||||
"economy-games": "0.1.7",
|
|
||||||
"expression-interaction": "0.2.1",
|
|
||||||
"lumi_ai": "0.8.5",
|
|
||||||
"lumi_overlay": "0.1.1",
|
|
||||||
"lumi_transcription": "0.2.7",
|
|
||||||
"moderation": "0.1.5",
|
|
||||||
"now_playing": "0.1.3",
|
|
||||||
"okf": "0.1.2",
|
|
||||||
"quotes": "0.1.2",
|
|
||||||
"sample-plugin": "0.1.0",
|
|
||||||
"throne_wishlist": "0.1.2",
|
|
||||||
"welcome_messages": "0.1.1"
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"lumi_ai_web_search": "0.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "0.3.8",
|
"version": "0.3.8",
|
||||||
"ref": "refs/tags/v0.3.8",
|
"ref": "refs/tags/v0.3.8",
|
||||||
|
|||||||
@ -11,8 +11,6 @@ const checks = [
|
|||||||
"scripts/verify-feedback-system.js",
|
"scripts/verify-feedback-system.js",
|
||||||
"scripts/verify-logging.js",
|
"scripts/verify-logging.js",
|
||||||
"scripts/verify-placeholders.js",
|
"scripts/verify-placeholders.js",
|
||||||
"scripts/verify-user-age-statistics.js",
|
|
||||||
"scripts/verify-user-favorites.js",
|
|
||||||
"scripts/verify-release-metadata.js",
|
"scripts/verify-release-metadata.js",
|
||||||
"scripts/verify-update-system.js",
|
"scripts/verify-update-system.js",
|
||||||
"scripts/verify-local-development-updates.js",
|
"scripts/verify-local-development-updates.js",
|
||||||
@ -26,7 +24,6 @@ const checks = [
|
|||||||
"plugins/lumi_ai_web_search/tests/verify.js",
|
"plugins/lumi_ai_web_search/tests/verify.js",
|
||||||
"plugins/lumi_transcription/tests/verify.js",
|
"plugins/lumi_transcription/tests/verify.js",
|
||||||
"plugins/lumi_overlay/tests/verify.js",
|
"plugins/lumi_overlay/tests/verify.js",
|
||||||
"plugins/auto-vc/tests/verify.js",
|
|
||||||
"scripts/verify-assistant-panels.js",
|
"scripts/verify-assistant-panels.js",
|
||||||
"scripts/verify-command-preview-confirmations.js",
|
"scripts/verify-command-preview-confirmations.js",
|
||||||
"scripts/verify-command-policies.js",
|
"scripts/verify-command-policies.js",
|
||||||
|
|||||||
@ -273,18 +273,6 @@ try {
|
|||||||
assert.strictEqual(discordContent.text, "Hello <a:party:123456>");
|
assert.strictEqual(discordContent.text, "Hello <a:party:123456>");
|
||||||
assert.strictEqual(discordContent.emotes[0].image, "https://cdn.discordapp.com/emojis/123456.gif?size=96&quality=lossless");
|
assert.strictEqual(discordContent.emotes[0].image, "https://cdn.discordapp.com/emojis/123456.gif?size=96&quality=lossless");
|
||||||
assert.strictEqual(discordContent.media[0].url, "https://cdn.example/dance.gif");
|
assert.strictEqual(discordContent.media[0].url, "https://cdn.example/dance.gif");
|
||||||
const gifvContent = discordOverlayContent({
|
|
||||||
content: "https://tenor.example/view",
|
|
||||||
attachments: [],
|
|
||||||
stickers: [],
|
|
||||||
embeds: [{
|
|
||||||
type: "gifv",
|
|
||||||
url: "https://tenor.example/view",
|
|
||||||
video: { url: "https://media.example/animation.mp4" },
|
|
||||||
thumbnail: { url: "https://media.example/animation-preview.gif" }
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
assert.strictEqual(gifvContent.media[0].preview, "https://media.example/animation-preview.gif");
|
|
||||||
const twitchChatAssets = require(path.join(serviceDir, "twitch-chat-assets.js"));
|
const twitchChatAssets = require(path.join(serviceDir, "twitch-chat-assets.js"));
|
||||||
const betterTtvCatalog = twitchChatAssets.indexBetterTtvCatalog([
|
const betterTtvCatalog = twitchChatAssets.indexBetterTtvCatalog([
|
||||||
{ id: "emote-kekw", code: "KEKW", imageType: "webp", animated: false },
|
{ id: "emote-kekw", code: "KEKW", imageType: "webp", animated: false },
|
||||||
|
|||||||
@ -4,15 +4,14 @@ const path = require("path");
|
|||||||
const { findSafeTarget } = require("../src/services/versioning");
|
const { findSafeTarget } = require("../src/services/versioning");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const releaseVersion = "0.3.11";
|
const releaseVersion = "0.3.8";
|
||||||
const previousStableVersion = "0.3.10";
|
const previousStableVersion = "0.3.7";
|
||||||
const priorStableVersion = "0.3.9";
|
const priorStableVersion = "0.3.6";
|
||||||
const earliestCompatibleCoreVersion = "0.1.9";
|
const earliestCompatibleCoreVersion = "0.1.9";
|
||||||
const introducedPlugins = {
|
const introducedPlugins = {
|
||||||
"auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" },
|
lumi_overlay: { version: "0.1.0", knowledge: "lumi-overlay" },
|
||||||
lumi_overlay: { version: "0.1.1", knowledge: "lumi-overlay", compatibleFrom: "0.1.0" },
|
lumi_transcription: { version: "0.2.6", knowledge: "lumi-transcription" },
|
||||||
lumi_transcription: { version: "0.2.8", knowledge: "lumi-transcription", compatibleFrom: "0.1.0" },
|
now_playing: { version: "0.1.3", knowledge: "now-playing" }
|
||||||
now_playing: { version: "0.1.3", knowledge: "now-playing", compatibleFrom: "0.1.0" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function readJson(relativePath) {
|
function readJson(relativePath) {
|
||||||
@ -63,7 +62,7 @@ assert.equal(safeCoreTarget.target?.version, releaseVersion);
|
|||||||
for (const [pluginId, expected] of Object.entries(introducedPlugins)) {
|
for (const [pluginId, expected] of Object.entries(introducedPlugins)) {
|
||||||
const manifest = readJson(`plugins/${pluginId}/plugin.json`);
|
const manifest = readJson(`plugins/${pluginId}/plugin.json`);
|
||||||
assert.equal(manifest.version, expected.version, `${pluginId} manifest version`);
|
assert.equal(manifest.version, expected.version, `${pluginId} manifest version`);
|
||||||
assert.equal(manifest.compatible_from, expected.compatibleFrom, `${pluginId} compatible_from`);
|
assert.equal(manifest.compatible_from, "0.1.0", `${pluginId} compatible_from`);
|
||||||
assert.equal(manifest.channel, "stable", `${pluginId} release channel`);
|
assert.equal(manifest.channel, "stable", `${pluginId} release channel`);
|
||||||
assert.equal(manifest.rollback_safe, true, `${pluginId} rollback metadata`);
|
assert.equal(manifest.rollback_safe, true, `${pluginId} rollback metadata`);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@ -84,4 +83,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
|
|||||||
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
|
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
||||||
|
|
||||||
console.log("Release metadata verification passed: stable core 0.3.11 after 0.3.10 with synchronized Companion and plugin metadata.");
|
console.log("Release metadata verification passed: stable core 0.3.8 after 0.3.7 with synchronized Companion plugin metadata.");
|
||||||
|
|||||||
@ -24,7 +24,7 @@ function readJson(relativePath) {
|
|||||||
|
|
||||||
const releaseIndex = readJson("release-index.json");
|
const releaseIndex = readJson("release-index.json");
|
||||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
||||||
assert.deepEqual(releaseVersions, ["0.3.11", "0.3.10", "0.3.9", "0.3.8", "0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
assert.deepEqual(releaseVersions, ["0.3.8", "0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
||||||
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||||
for (const release of releaseIndex.releases) {
|
for (const release of releaseIndex.releases) {
|
||||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||||
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
|
|||||||
const coreManifest = readJson("update-manifest.json");
|
const coreManifest = readJson("update-manifest.json");
|
||||||
assert.equal(packageVersion, coreManifest.version);
|
assert.equal(packageVersion, coreManifest.version);
|
||||||
assert.equal(coreManifest.channel, "stable");
|
assert.equal(coreManifest.channel, "stable");
|
||||||
assert.equal(packageVersion, "0.3.11");
|
assert.equal(packageVersion, "0.3.8");
|
||||||
assert.equal(currentRelease.version, "0.3.11");
|
assert.equal(currentRelease.version, "0.3.8");
|
||||||
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
||||||
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
||||||
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
||||||
@ -50,9 +50,6 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
|||||||
const baseTarget = {
|
const baseTarget = {
|
||||||
current_version: "0.2.4",
|
current_version: "0.2.4",
|
||||||
available_versions: [
|
available_versions: [
|
||||||
{ version: "0.3.11", ref: "refs/tags/v0.3.11", rollback_safe: true },
|
|
||||||
{ version: "0.3.10", ref: "refs/tags/v0.3.10", rollback_safe: true },
|
|
||||||
{ version: "0.3.9", ref: "refs/tags/v0.3.9", rollback_safe: true },
|
|
||||||
{ version: "0.3.8", ref: "refs/tags/v0.3.8", rollback_safe: true },
|
{ version: "0.3.8", ref: "refs/tags/v0.3.8", rollback_safe: true },
|
||||||
{ version: "0.3.7", ref: "refs/tags/v0.3.7", rollback_safe: true },
|
{ version: "0.3.7", ref: "refs/tags/v0.3.7", rollback_safe: true },
|
||||||
{ version: "0.3.6", ref: "refs/tags/v0.3.6", rollback_safe: true },
|
{ version: "0.3.6", ref: "refs/tags/v0.3.6", rollback_safe: true },
|
||||||
@ -160,7 +157,7 @@ const corrected = buildStatus({
|
|||||||
channel: "stable"
|
channel: "stable"
|
||||||
});
|
});
|
||||||
assert.equal(corrected.version_correction, true);
|
assert.equal(corrected.version_correction, true);
|
||||||
assert.equal(corrected.safe_target_version, "0.3.11");
|
assert.equal(corrected.safe_target_version, "0.3.8");
|
||||||
assert.equal(corrected.update_available, true);
|
assert.equal(corrected.update_available, true);
|
||||||
assert.equal(corrected.blocked, false);
|
assert.equal(corrected.blocked, false);
|
||||||
|
|
||||||
|
|||||||
@ -1,186 +0,0 @@
|
|||||||
const assert = require("assert");
|
|
||||||
const fs = require("fs");
|
|
||||||
const os = require("os");
|
|
||||||
const path = require("path");
|
|
||||||
const Database = require("better-sqlite3");
|
|
||||||
const isolatedDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-user-age-"));
|
|
||||||
process.env.LUMI_DATA_DIR = isolatedDataDir;
|
|
||||||
const { formatDuration, parseDurationFormat } = require("../src/services/duration-format");
|
|
||||||
const { UserAgeStatistics } = require("../src/services/user-age-statistics");
|
|
||||||
const placeholders = require("../src/services/placeholders");
|
|
||||||
const coreDatabase = require("../src/services/db");
|
|
||||||
coreDatabase.migrate();
|
|
||||||
|
|
||||||
const hour = 60 * 60 * 1000;
|
|
||||||
const day = 24 * hour;
|
|
||||||
let now = 100 * day;
|
|
||||||
const database = new Database(":memory:");
|
|
||||||
database.exec(`
|
|
||||||
CREATE TABLE user_profiles (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
internal_username TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE user_identities (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
provider TEXT NOT NULL,
|
|
||||||
provider_user_id TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
const service = new UserAgeStatistics(database, { now: () => now });
|
|
||||||
database.prepare("INSERT INTO user_profiles (id, internal_username) VALUES (?, ?)").run("profile-1", "ViewerOne");
|
|
||||||
database.prepare("INSERT INTO user_identities (user_id, provider, provider_user_id) VALUES (?, ?, ?)").run("profile-1", "twitch", "viewer-1");
|
|
||||||
|
|
||||||
const base = {
|
|
||||||
platform: "twitch",
|
|
||||||
userId: "viewer-1",
|
|
||||||
scopeId: "channel-1",
|
|
||||||
type: "follow",
|
|
||||||
source: "test-event",
|
|
||||||
authoritative: true
|
|
||||||
};
|
|
||||||
|
|
||||||
service.setState({ ...base, active: true, startAt: now - 10 * day, occurredAt: now, eventId: "follow-1" });
|
|
||||||
let rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
|
|
||||||
assert.strictEqual(rows.length, 1, "new follow should create one interval");
|
|
||||||
assert.strictEqual(rows[0].end_at, null, "new follow interval should remain open");
|
|
||||||
|
|
||||||
now += 2 * day;
|
|
||||||
service.setState({ ...base, active: false, occurredAt: now, eventId: "unfollow-1" });
|
|
||||||
rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
|
|
||||||
assert.strictEqual(rows[0].end_at, now, "unfollow should close the open interval");
|
|
||||||
const firstCompleted = { ...rows[0] };
|
|
||||||
|
|
||||||
now += day;
|
|
||||||
service.setState({ ...base, active: true, startAt: now, occurredAt: now, eventId: "follow-2" });
|
|
||||||
service.setState({ ...base, active: true, startAt: now, occurredAt: now, eventId: "follow-2" });
|
|
||||||
rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
|
|
||||||
assert.strictEqual(rows.length, 2, "refollow should create exactly one second interval");
|
|
||||||
assert.deepStrictEqual(
|
|
||||||
Object.fromEntries(Object.entries(rows.find((row) => row.id === firstCompleted.id)).filter(([key]) => !["updated_at"].includes(key))),
|
|
||||||
Object.fromEntries(Object.entries(firstCompleted).filter(([key]) => !["updated_at"].includes(key))),
|
|
||||||
"completed intervals must not be modified"
|
|
||||||
);
|
|
||||||
|
|
||||||
now += 3 * day;
|
|
||||||
let durations = service.getDurations(base);
|
|
||||||
assert.strictEqual(durations.current, 3 * day, "current should include only the open interval");
|
|
||||||
assert.strictEqual(durations.total, 15 * day, "total should combine completed and active intervals");
|
|
||||||
const leaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 });
|
|
||||||
assert.strictEqual(leaders[0].username, "ViewerOne", "leaderboards should resolve linked Lumi profiles");
|
|
||||||
assert.ok(leaders.every((entry) => !entry.label.includes("channel-1")), "leaderboards must not expose channel identifiers beside usernames");
|
|
||||||
|
|
||||||
service.setState({ ...base, active: false, occurredAt: now, eventId: "unfollow-2" });
|
|
||||||
durations = service.getDurations(base);
|
|
||||||
assert.strictEqual(durations.current, null, "inactive state should have no current duration");
|
|
||||||
|
|
||||||
service.setState({ ...base, scopeId: "channel-2", active: true, occurredAt: now, eventId: "channel-2-follow" });
|
|
||||||
assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-1" }).current, null, "channels must remain isolated");
|
|
||||||
assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-2" }).current, 0, "second channel should have its own interval");
|
|
||||||
const profileStatistics = service.getProfileStatistics("profile-1");
|
|
||||||
assert.ok(profileStatistics.every((entry) => !entry.label.includes("channel-1") && !entry.label.includes("channel-2")),
|
|
||||||
"profile statistics must use friendly channel labels without exposing IDs");
|
|
||||||
assert.ok(profileStatistics.some((entry) => entry.label.includes("Channel 1")) &&
|
|
||||||
profileStatistics.some((entry) => entry.label.includes("Channel 2")),
|
|
||||||
"multiple isolated scopes must remain distinguishable without raw IDs");
|
|
||||||
const multiScopeLeaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 });
|
|
||||||
assert.strictEqual(multiScopeLeaders.filter((entry) => entry.username === "ViewerOne").length, 1,
|
|
||||||
"leaderboards must collapse hidden channel scopes into one user row");
|
|
||||||
assert.strictEqual(multiScopeLeaders[0].numericValue, 15 * day,
|
|
||||||
"leaderboards must rank a user's longest scoped tenure without double-counting concurrent channels");
|
|
||||||
|
|
||||||
service.setState({
|
|
||||||
platform: "discord",
|
|
||||||
userId: "viewer-1",
|
|
||||||
scopeId: "guild-1",
|
|
||||||
type: "member",
|
|
||||||
active: true,
|
|
||||||
occurredAt: now,
|
|
||||||
eventId: "guild-1-member",
|
|
||||||
source: "test"
|
|
||||||
});
|
|
||||||
assert.strictEqual(service.getDurations({
|
|
||||||
platform: "discord",
|
|
||||||
userId: "viewer-1",
|
|
||||||
scopeId: "guild-2",
|
|
||||||
type: "member"
|
|
||||||
}).total, null, "Discord guilds must remain isolated");
|
|
||||||
|
|
||||||
const combined = 2 * 30 * day + 3 * day + 4 * hour + 47 * 60 * 1000;
|
|
||||||
assert.strictEqual(formatDuration(combined, "MM-DD-mm"), "2 months, 3 days, 287 minutes", "omitted hours should flow into minutes");
|
|
||||||
assert.strictEqual(formatDuration(3 * day + 2 * hour, "HH-mm"), "74 hours", "larger omitted units should flow into the largest selected unit");
|
|
||||||
assert.strictEqual(formatDuration(287 * 60 * 1000, "mm"), "287 minutes", "minutes-only format should contain total minutes");
|
|
||||||
assert.strictEqual(formatDuration(day + hour + 60 * 1000, "DD-HH-mm"), "1 day, 1 hour, 1 minute", "singular labels should be correct");
|
|
||||||
assert.strictEqual(formatDuration(2 * day + 2 * hour + 2 * 60 * 1000, "D-H-m"), "2 days, 2 hours, 2 minutes", "plural labels should be correct");
|
|
||||||
assert.strictEqual(formatDuration(500, "s"), "", "all-zero selected units should return an empty string");
|
|
||||||
assert.throws(() => parseDurationFormat("DD-YY"), /largest to smallest/, "out-of-order units should fail deterministically");
|
|
||||||
|
|
||||||
const restarted = new UserAgeStatistics(database, { now: () => now });
|
|
||||||
const beforeRestart = restarted.listDiagnostics({ platform: "twitch", scopeId: "channel-2", type: "follow" });
|
|
||||||
restarted.reconcileSnapshot({
|
|
||||||
platform: "twitch",
|
|
||||||
scopeId: "channel-2",
|
|
||||||
type: "follow",
|
|
||||||
activeRecords: [{ userId: "viewer-1" }],
|
|
||||||
observedAt: now,
|
|
||||||
source: "restart-test",
|
|
||||||
complete: true
|
|
||||||
});
|
|
||||||
const afterRestart = restarted.listDiagnostics({ platform: "twitch", scopeId: "channel-2", type: "follow" });
|
|
||||||
assert.strictEqual(afterRestart.length, beforeRestart.length, "restart reconciliation must not duplicate valid intervals");
|
|
||||||
assert.strictEqual(afterRestart[0].end_at, null, "restart reconciliation must not close valid intervals");
|
|
||||||
|
|
||||||
restarted.setState({
|
|
||||||
platform: "youtube",
|
|
||||||
userId: "viewer-2",
|
|
||||||
scopeId: "youtube-channel",
|
|
||||||
type: "subscriber",
|
|
||||||
active: true,
|
|
||||||
occurredAt: now,
|
|
||||||
eventId: "youtube-observed",
|
|
||||||
source: "youtube-first-observed",
|
|
||||||
authoritative: false
|
|
||||||
});
|
|
||||||
const observed = restarted.listDiagnostics({ platform: "youtube", userId: "viewer-2" })[0];
|
|
||||||
assert.strictEqual(observed.start_at, now, "unavailable historical starts must begin at first observation");
|
|
||||||
assert.strictEqual(observed.start_authoritative, 0, "first-observed timestamps must not be marked authoritative");
|
|
||||||
|
|
||||||
placeholders.registerCorePlaceholders({ userAgeStatistics: restarted });
|
|
||||||
const invalidFormat = placeholders.validateTemplate({
|
|
||||||
fieldId: "core.custom_commands.static_response",
|
|
||||||
template: "{{twitch.user.follow_age.current}.{format.YYY-DD}}",
|
|
||||||
outputAudience: "user",
|
|
||||||
runtimeContext: { runtime: true }
|
|
||||||
});
|
|
||||||
assert.strictEqual(invalidFormat.ok, false, "unsupported duration tokens should fail shared placeholder validation");
|
|
||||||
const existing = placeholders.renderTemplate({
|
|
||||||
fieldId: "core.custom_commands.static_response",
|
|
||||||
template: "Hello {{user.public.display_name}}",
|
|
||||||
outputAudience: "user",
|
|
||||||
runtimeContext: { runtime: true, user: { displayName: "Lumi Friend" } }
|
|
||||||
});
|
|
||||||
const formatted = placeholders.renderTemplate({
|
|
||||||
fieldId: "core.custom_commands.static_response",
|
|
||||||
template: "{{twitch.user.follow_age.current}.{format.DD-HH-mm}}",
|
|
||||||
outputAudience: "user",
|
|
||||||
runtimeContext: {
|
|
||||||
runtime: true,
|
|
||||||
platform: "twitch",
|
|
||||||
user: { platformId: "viewer-1" },
|
|
||||||
ctx: { meta: { tags: { "room-id": "channel-2" } } }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Promise.all([existing, formatted]).then(([existingResult, formattedResult]) => {
|
|
||||||
assert.strictEqual(existingResult.rendered, "Hello Lumi Friend", "existing placeholders must remain unchanged");
|
|
||||||
assert.strictEqual(formattedResult.rendered, "", "a zero-duration current interval should render empty");
|
|
||||||
database.close();
|
|
||||||
coreDatabase.db.close();
|
|
||||||
fs.rmSync(isolatedDataDir, { recursive: true, force: true });
|
|
||||||
console.log("User-age statistics verification passed.");
|
|
||||||
}).catch((error) => {
|
|
||||||
database.close();
|
|
||||||
coreDatabase.db.close();
|
|
||||||
fs.rmSync(isolatedDataDir, { recursive: true, force: true });
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
const assert = require("assert");
|
|
||||||
const Database = require("better-sqlite3");
|
|
||||||
const { getFavoriteCommand, getFavoriteExpression } = require("../src/services/user-favorites");
|
|
||||||
|
|
||||||
const db = new Database(":memory:");
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE command_user_usage (
|
|
||||||
command_id TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
count INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (command_id, user_id)
|
|
||||||
);
|
|
||||||
CREATE TABLE expression_user_stats (
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
given_count INTEGER NOT NULL,
|
|
||||||
received_count INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (action, user_id)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
db.prepare("INSERT INTO command_user_usage VALUES (?, ?, ?, ?)").run("custom:hello", "user-1", 4, 10);
|
|
||||||
db.prepare("INSERT INTO command_user_usage VALUES (?, ?, ?, ?)").run("music", "user-1", 7, 20);
|
|
||||||
db.prepare("INSERT INTO expression_user_stats VALUES (?, ?, ?, ?)").run("hug", "user-1", 3, 6);
|
|
||||||
db.prepare("INSERT INTO expression_user_stats VALUES (?, ?, ?, ?)").run("wave", "user-1", 4, 1);
|
|
||||||
|
|
||||||
assert.deepStrictEqual(getFavoriteCommand(db, "user-1", "!"), { command: "!music", count: 7 });
|
|
||||||
assert.deepStrictEqual(getFavoriteExpression(db, "user-1"), { action: "hug", count: 9 });
|
|
||||||
assert.strictEqual(getFavoriteCommand(db, "missing", "!"), null);
|
|
||||||
assert.strictEqual(getFavoriteExpression(db, "missing"), null);
|
|
||||||
db.close();
|
|
||||||
console.log("User favorites verification passed.");
|
|
||||||
@ -28,7 +28,6 @@ const { overlayConnectorManager } = require("./services/overlay-connectors");
|
|||||||
const { cleanupSnapshots } = require("./services/update-manager");
|
const { cleanupSnapshots } = require("./services/update-manager");
|
||||||
const { twitchEventSubManager } = require("./services/twitch-eventsub");
|
const { twitchEventSubManager } = require("./services/twitch-eventsub");
|
||||||
const { streamTestingService } = require("./services/stream-testing");
|
const { streamTestingService } = require("./services/stream-testing");
|
||||||
const { userAgeStatistics } = require("./services/user-age-statistics");
|
|
||||||
const {
|
const {
|
||||||
isSafeModeRequested,
|
isSafeModeRequested,
|
||||||
markStartupVerification
|
markStartupVerification
|
||||||
@ -57,8 +56,7 @@ async function main() {
|
|||||||
plugin_count: completedPluginSync.plugin_ids.length
|
plugin_count: completedPluginSync.plugin_ids.length
|
||||||
}, { event: "bundled_plugin_sync_completed" });
|
}, { event: "bundled_plugin_sync_completed" });
|
||||||
}
|
}
|
||||||
userAgeStatistics.start();
|
registerCorePlaceholders();
|
||||||
registerCorePlaceholders({ userAgeStatistics });
|
|
||||||
const logCleanup = logger.cleanupLogs({
|
const logCleanup = logger.cleanupLogs({
|
||||||
maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS),
|
maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS),
|
||||||
maxEntries: getSetting("log_retention_max_entries", logger.DEFAULT_MAX_ENTRIES)
|
maxEntries: getSetting("log_retention_max_entries", logger.DEFAULT_MAX_ENTRIES)
|
||||||
@ -207,7 +205,6 @@ async function main() {
|
|||||||
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
|
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
|
||||||
for (const service of [
|
for (const service of [
|
||||||
{ name: "stream_testing", stop: () => streamTestingService.close() },
|
{ name: "stream_testing", stop: () => streamTestingService.close() },
|
||||||
{ name: "user_age_statistics", stop: () => userAgeStatistics.stop() },
|
|
||||||
{ name: "overlay_connectors", stop: () => overlayConnectorManager.stop() },
|
{ name: "overlay_connectors", stop: () => overlayConnectorManager.stop() },
|
||||||
{ name: "twitch_eventsub", stop: () => twitchEventSubManager.stop() },
|
{ name: "twitch_eventsub", stop: () => twitchEventSubManager.stop() },
|
||||||
{ name: "plugins", stop: () => stopPlugins() },
|
{ name: "plugins", stop: () => stopPlugins() },
|
||||||
|
|||||||
@ -92,7 +92,7 @@ function buildTwitchEventAuthUrl(state, redirectOverride) {
|
|||||||
client_id: clientId || "",
|
client_id: clientId || "",
|
||||||
redirect_uri: redirectUri || "",
|
redirect_uri: redirectUri || "",
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
scope: "moderator:read:followers moderation:read channel:read:subscriptions channel:read:editors channel:read:vips bits:read channel:read:redemptions",
|
scope: "moderator:read:followers channel:read:subscriptions bits:read channel:read:redemptions",
|
||||||
state,
|
state,
|
||||||
force_verify: "true"
|
force_verify: "true"
|
||||||
});
|
});
|
||||||
|
|||||||
@ -153,7 +153,7 @@ function createCommandRouter({ settings }) {
|
|||||||
if (typeof result === "string" && result) {
|
if (typeof result === "string" && result) {
|
||||||
await safeReply(reply, result);
|
await safeReply(reply, result);
|
||||||
await lease.commit();
|
await lease.commit();
|
||||||
recordCommandUsage(handler.commandId, user.id);
|
recordCommandUsage(handler.commandId);
|
||||||
incrementCommands(user.id);
|
incrementCommands(user.id);
|
||||||
commandLog.debug("Command completed", {
|
commandLog.debug("Command completed", {
|
||||||
command_id: handler.commandId,
|
command_id: handler.commandId,
|
||||||
@ -165,7 +165,7 @@ function createCommandRouter({ settings }) {
|
|||||||
}
|
}
|
||||||
if (result === true) {
|
if (result === true) {
|
||||||
await lease.commit();
|
await lease.commit();
|
||||||
recordCommandUsage(handler.commandId, user.id);
|
recordCommandUsage(handler.commandId);
|
||||||
incrementCommands(user.id);
|
incrementCommands(user.id);
|
||||||
commandLog.debug("Command completed", {
|
commandLog.debug("Command completed", {
|
||||||
command_id: handler.commandId,
|
command_id: handler.commandId,
|
||||||
@ -289,7 +289,7 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
|
|||||||
} else {
|
} else {
|
||||||
await safeReply(reply, await renderStaticResponse(row.response, ctx, platform));
|
await safeReply(reply, await renderStaticResponse(row.response, ctx, platform));
|
||||||
}
|
}
|
||||||
recordCommandUsage(`custom:${trigger}`, ctx.user.id);
|
recordCommandUsage(`custom:${trigger}`);
|
||||||
await lease.commit();
|
await lease.commit();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -356,7 +356,7 @@ async function safeReply(reply, content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordCommandUsage(commandId, userId = null) {
|
function recordCommandUsage(commandId) {
|
||||||
if (!commandId) {
|
if (!commandId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -365,12 +365,6 @@ function recordCommandUsage(commandId, userId = null) {
|
|||||||
"INSERT INTO command_usage (command_id, count, updated_at) VALUES (?, 1, ?) " +
|
"INSERT INTO command_usage (command_id, count, updated_at) VALUES (?, 1, ?) " +
|
||||||
"ON CONFLICT(command_id) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at"
|
"ON CONFLICT(command_id) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at"
|
||||||
).run(commandId, now);
|
).run(commandId, now);
|
||||||
if (userId) {
|
|
||||||
db.prepare(
|
|
||||||
"INSERT INTO command_user_usage (command_id, user_id, count, updated_at) VALUES (?, ?, 1, ?) " +
|
|
||||||
"ON CONFLICT(command_id, user_id) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at"
|
|
||||||
).run(commandId, userId, now);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@ -103,39 +103,6 @@ function migrate() {
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_age_intervals (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
platform TEXT NOT NULL,
|
|
||||||
platform_user_id TEXT NOT NULL,
|
|
||||||
scope_id TEXT NOT NULL,
|
|
||||||
statistic_type TEXT NOT NULL,
|
|
||||||
start_at INTEGER NOT NULL,
|
|
||||||
end_at INTEGER,
|
|
||||||
start_source TEXT NOT NULL,
|
|
||||||
start_precision TEXT NOT NULL DEFAULT 'millisecond',
|
|
||||||
start_authoritative INTEGER NOT NULL DEFAULT 0,
|
|
||||||
end_source TEXT,
|
|
||||||
end_precision TEXT,
|
|
||||||
end_authoritative INTEGER,
|
|
||||||
observed_at INTEGER NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS user_age_intervals_one_open_idx
|
|
||||||
ON user_age_intervals (platform, platform_user_id, scope_id, statistic_type)
|
|
||||||
WHERE end_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS user_age_intervals_lookup_idx
|
|
||||||
ON user_age_intervals (platform, scope_id, statistic_type, platform_user_id, start_at);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_age_events (
|
|
||||||
platform TEXT NOT NULL,
|
|
||||||
event_id TEXT NOT NULL,
|
|
||||||
processed_at INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (platform, event_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS custom_pages (
|
CREATE TABLE IF NOT EXISTS custom_pages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
@ -182,17 +149,6 @@ function migrate() {
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS command_user_usage (
|
|
||||||
command_id TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (command_id, user_id),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES user_profiles(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_command_user_usage_user
|
|
||||||
ON command_user_usage(user_id, count DESC, updated_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS command_groups (
|
CREATE TABLE IF NOT EXISTS command_groups (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
|||||||
@ -10,16 +10,11 @@ function discordOverlayContent(message) {
|
|||||||
const media = [];
|
const media = [];
|
||||||
const mediaUrls = new Set();
|
const mediaUrls = new Set();
|
||||||
const removedLinks = new Set();
|
const removedLinks = new Set();
|
||||||
const addMedia = (url, type = "image", alt = "Animated image", sourceUrl = "", preview = "") => {
|
const addMedia = (url, type = "image", alt = "Animated image", sourceUrl = "") => {
|
||||||
const clean = String(url || "").trim();
|
const clean = String(url || "").trim();
|
||||||
if (!/^https?:\/\//i.test(clean) || mediaUrls.has(clean) || media.length >= 4) return;
|
if (!/^https?:\/\//i.test(clean) || mediaUrls.has(clean) || media.length >= 4) return;
|
||||||
mediaUrls.add(clean);
|
mediaUrls.add(clean);
|
||||||
media.push({
|
media.push({ url: clean, type: type === "video" ? "video" : "image", alt });
|
||||||
url: clean,
|
|
||||||
type: type === "video" ? "video" : "image",
|
|
||||||
alt,
|
|
||||||
preview: /^https?:\/\//i.test(String(preview || "").trim()) ? String(preview).trim() : null
|
|
||||||
});
|
|
||||||
if (sourceUrl && text.includes(sourceUrl)) removedLinks.add(sourceUrl);
|
if (sourceUrl && text.includes(sourceUrl)) removedLinks.add(sourceUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -36,7 +31,7 @@ function discordOverlayContent(message) {
|
|||||||
if (embedType !== "gifv" && !/\.(?:gif|gifv)(?:$|\?)/i.test(sourceUrl)) continue;
|
if (embedType !== "gifv" && !/\.(?:gif|gifv)(?:$|\?)/i.test(sourceUrl)) continue;
|
||||||
const videoUrl = embed?.video?.url || embed?.video?.proxyURL || embed?.video?.proxy_url;
|
const videoUrl = embed?.video?.url || embed?.video?.proxyURL || embed?.video?.proxy_url;
|
||||||
const imageUrl = embed?.image?.url || embed?.image?.proxyURL || embed?.image?.proxy_url || embed?.thumbnail?.url || embed?.thumbnail?.proxyURL || embed?.thumbnail?.proxy_url;
|
const imageUrl = embed?.image?.url || embed?.image?.proxyURL || embed?.image?.proxy_url || embed?.thumbnail?.url || embed?.thumbnail?.proxyURL || embed?.thumbnail?.proxy_url;
|
||||||
if (videoUrl) addMedia(videoUrl, "video", embed?.title || "Animated image", sourceUrl, imageUrl);
|
if (videoUrl) addMedia(videoUrl, "video", embed?.title || "Animated image", sourceUrl);
|
||||||
else addMedia(imageUrl || sourceUrl, "image", embed?.title || "Animated image", sourceUrl);
|
else addMedia(imageUrl || sourceUrl, "image", embed?.title || "Animated image", sourceUrl);
|
||||||
}
|
}
|
||||||
for (const sticker of collectionValues(message?.stickers)) {
|
for (const sticker of collectionValues(message?.stickers)) {
|
||||||
|
|||||||
@ -11,7 +11,6 @@ const { createLogger } = require("./logger");
|
|||||||
const { publishOverlayChatMessage } = require("./overlay-chat");
|
const { publishOverlayChatMessage } = require("./overlay-chat");
|
||||||
const { discordOverlayContent } = require("./discord-chat-content");
|
const { discordOverlayContent } = require("./discord-chat-content");
|
||||||
const { emitLumiEvent } = require("./lumi-events");
|
const { emitLumiEvent } = require("./lumi-events");
|
||||||
const { userAgeStatistics } = require("./user-age-statistics");
|
|
||||||
|
|
||||||
const discordLog = createLogger("platform:discord", { category: "integration" });
|
const discordLog = createLogger("platform:discord", { category: "integration" });
|
||||||
|
|
||||||
@ -26,10 +25,9 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
const intents = [
|
const intents = [
|
||||||
resolveIntent("Guilds", "GUILDS"),
|
resolveIntent("Guilds", "GUILDS"),
|
||||||
resolveIntent("GuildMessages", "GUILD_MESSAGES"),
|
resolveIntent("GuildMessages", "GUILD_MESSAGES"),
|
||||||
resolveIntent("GuildMembers", "GUILD_MEMBERS"),
|
resolveIntent("GuildMembers", "GUILD_MEMBERS"),
|
||||||
resolveIntent("MessageContent", "MESSAGE_CONTENT"),
|
resolveIntent("MessageContent", "MESSAGE_CONTENT"),
|
||||||
resolveIntent("GuildMessageReactions", "GUILD_MESSAGE_REACTIONS"),
|
resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"),
|
||||||
resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"),
|
|
||||||
resolveIntent("GuildPresences", "GUILD_PRESENCES")
|
resolveIntent("GuildPresences", "GUILD_PRESENCES")
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
@ -41,26 +39,18 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
intents,
|
intents,
|
||||||
guildMembers: Boolean(resolveIntent("GuildMembers", "GUILD_MEMBERS"))
|
guildMembers: Boolean(resolveIntent("GuildMembers", "GUILD_MEMBERS"))
|
||||||
}, { event: "platform_starting" });
|
}, { event: "platform_starting" });
|
||||||
options.partials = [
|
if (Partials?.Channel) {
|
||||||
resolvePartial("Channel", "CHANNEL"),
|
options.partials = [Partials.Channel];
|
||||||
resolvePartial("Message", "MESSAGE"),
|
}
|
||||||
resolvePartial("Reaction", "REACTION"),
|
|
||||||
resolvePartial("User", "USER")
|
|
||||||
].filter((value, index, values) => value !== null && values.indexOf(value) === index);
|
|
||||||
|
|
||||||
client = new Client(options);
|
client = new Client(options);
|
||||||
|
|
||||||
client.on("ready", () => {
|
client.on("ready", () => {
|
||||||
discordLog.info("Discord bot ready", { account: client.user?.tag || null }, { event: "platform_ready" });
|
discordLog.info("Discord bot ready", { account: client.user?.tag || null }, { event: "platform_ready" });
|
||||||
const avatarUrl = getBotAvatarUrl(client.user);
|
const avatarUrl = getBotAvatarUrl(client.user);
|
||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
setSetting("bot_avatar_url", avatarUrl);
|
setSetting("bot_avatar_url", avatarUrl);
|
||||||
}
|
}
|
||||||
reconcileDiscordTenure(client).catch((error) => {
|
|
||||||
discordLog.warn("Discord platform tenure could not be reconciled", {
|
|
||||||
error
|
|
||||||
}, { event: "user_age_reconciliation_failed" });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("messageCreate", async (message) => {
|
client.on("messageCreate", async (message) => {
|
||||||
@ -82,9 +72,6 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
displayName,
|
displayName,
|
||||||
avatar: avatarUrl
|
avatar: avatarUrl
|
||||||
});
|
});
|
||||||
if (!message.author.bot && message.member) {
|
|
||||||
observeDiscordMember(message.member, message.createdTimestamp || Date.now(), `discord-message:${message.id}`);
|
|
||||||
}
|
|
||||||
publishOverlayChatMessage({
|
publishOverlayChatMessage({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
platform: "discord",
|
platform: "discord",
|
||||||
@ -149,29 +136,6 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
source: "discord-gateway",
|
source: "discord-gateway",
|
||||||
occurredAt: member.joinedTimestamp || Date.now()
|
occurredAt: member.joinedTimestamp || Date.now()
|
||||||
});
|
});
|
||||||
if (member.premiumSinceTimestamp) {
|
|
||||||
emitDiscordBoostEvent(member, true, member.premiumSinceTimestamp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("guildMemberRemove", (member) => {
|
|
||||||
if (!member?.user || member.user.bot || !isConfiguredGuild(member.guild?.id)) return;
|
|
||||||
const now = Date.now();
|
|
||||||
emitLumiEvent("discord.member_leave", discordMemberPayload(member), {
|
|
||||||
id: `${member.guild?.id || "guild"}:${member.user.id}:leave:${now}`,
|
|
||||||
source: "discord-gateway",
|
|
||||||
occurredAt: now
|
|
||||||
});
|
|
||||||
if (member.premiumSinceTimestamp) emitDiscordBoostEvent(member, false, now);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("guildMemberUpdate", (previous, current) => {
|
|
||||||
if (!current?.user || current.user.bot || !isConfiguredGuild(current.guild?.id)) return;
|
|
||||||
const wasBoosting = Boolean(previous?.premiumSinceTimestamp);
|
|
||||||
const isBoosting = Boolean(current.premiumSinceTimestamp);
|
|
||||||
if (wasBoosting !== isBoosting) {
|
|
||||||
emitDiscordBoostEvent(current, isBoosting, isBoosting ? current.premiumSinceTimestamp : Date.now());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.login(token);
|
await client.login(token);
|
||||||
@ -195,7 +159,7 @@ function getBotAvatarUrl(user) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveIntent(key, legacyKey) {
|
function resolveIntent(key, legacyKey) {
|
||||||
if (GatewayIntentBits?.[key]) {
|
if (GatewayIntentBits?.[key]) {
|
||||||
return GatewayIntentBits[key];
|
return GatewayIntentBits[key];
|
||||||
}
|
}
|
||||||
@ -206,105 +170,8 @@ function resolveIntent(key, legacyKey) {
|
|||||||
return Intents.FLAGS[legacyKey];
|
return Intents.FLAGS[legacyKey];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isConfiguredGuild(guildId) {
|
|
||||||
const configuredGuildId = String(getSetting("discord_guild_id", "") || "");
|
|
||||||
return !configuredGuildId || configuredGuildId === String(guildId || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function discordMemberPayload(member) {
|
|
||||||
return {
|
|
||||||
guild_id: member.guild?.id || null,
|
|
||||||
guild_name: member.guild?.name || null,
|
|
||||||
user_id: member.user?.id || member.id || null,
|
|
||||||
user_name: member.user?.globalName || member.user?.username || member.user?.tag || null,
|
|
||||||
joined_at: member.joinedTimestamp || null,
|
|
||||||
boosted_at: member.premiumSinceTimestamp || null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitDiscordBoostEvent(member, active, occurredAt) {
|
|
||||||
emitLumiEvent(active ? "discord.boost_start" : "discord.boost_end", discordMemberPayload(member), {
|
|
||||||
id: `${member.guild?.id || "guild"}:${member.user?.id || member.id}:boost:${active ? "start" : "end"}:${occurredAt}`,
|
|
||||||
source: "discord-gateway",
|
|
||||||
occurredAt
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function observeDiscordMember(member, observedAt, eventPrefix) {
|
|
||||||
if (!member?.user || member.user.bot || !isConfiguredGuild(member.guild?.id)) return;
|
|
||||||
userAgeStatistics.setState({
|
|
||||||
platform: "discord",
|
|
||||||
userId: member.user.id,
|
|
||||||
scopeId: member.guild.id,
|
|
||||||
type: "member",
|
|
||||||
active: true,
|
|
||||||
startAt: member.joinedTimestamp || undefined,
|
|
||||||
occurredAt: observedAt,
|
|
||||||
eventId: `${eventPrefix}:member`,
|
|
||||||
source: member.joinedTimestamp ? "discord-guild-member" : "discord-first-observed",
|
|
||||||
authoritative: Boolean(member.joinedTimestamp)
|
|
||||||
});
|
|
||||||
userAgeStatistics.setState({
|
|
||||||
platform: "discord",
|
|
||||||
userId: member.user.id,
|
|
||||||
scopeId: member.guild.id,
|
|
||||||
type: "nitro",
|
|
||||||
active: Boolean(member.premiumSinceTimestamp),
|
|
||||||
startAt: member.premiumSinceTimestamp || undefined,
|
|
||||||
occurredAt: observedAt,
|
|
||||||
eventId: `${eventPrefix}:nitro`,
|
|
||||||
source: member.premiumSinceTimestamp ? "discord-premium-since" : "discord-guild-member",
|
|
||||||
authoritative: Boolean(member.premiumSinceTimestamp)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reconcileDiscordTenure(discordClient) {
|
|
||||||
const configuredGuildId = String(getSetting("discord_guild_id", "") || "");
|
|
||||||
const guilds = configuredGuildId
|
|
||||||
? [discordClient.guilds.cache.get(configuredGuildId)].filter(Boolean)
|
|
||||||
: Array.from(discordClient.guilds.cache.values());
|
|
||||||
for (const guild of guilds) {
|
|
||||||
const members = await guild.members.fetch();
|
|
||||||
const humans = Array.from(members.values()).filter((member) => !member.user?.bot);
|
|
||||||
const observedAt = Date.now();
|
|
||||||
userAgeStatistics.reconcileSnapshot({
|
|
||||||
platform: "discord",
|
|
||||||
scopeId: guild.id,
|
|
||||||
type: "member",
|
|
||||||
activeRecords: humans.map((member) => ({
|
|
||||||
userId: member.user.id,
|
|
||||||
startAt: member.joinedTimestamp,
|
|
||||||
source: member.joinedTimestamp ? "discord-guild-member" : "discord-first-observed",
|
|
||||||
authoritative: Boolean(member.joinedTimestamp)
|
|
||||||
})),
|
|
||||||
observedAt,
|
|
||||||
source: "discord-guild-reconciliation",
|
|
||||||
complete: true
|
|
||||||
});
|
|
||||||
userAgeStatistics.reconcileSnapshot({
|
|
||||||
platform: "discord",
|
|
||||||
scopeId: guild.id,
|
|
||||||
type: "nitro",
|
|
||||||
activeRecords: humans.filter((member) => member.premiumSinceTimestamp).map((member) => ({
|
|
||||||
userId: member.user.id,
|
|
||||||
startAt: member.premiumSinceTimestamp,
|
|
||||||
source: "discord-premium-since",
|
|
||||||
authoritative: true
|
|
||||||
})),
|
|
||||||
observedAt,
|
|
||||||
source: "discord-guild-reconciliation",
|
|
||||||
complete: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePartial(key, legacyKey) {
|
|
||||||
if (Partials?.[key] !== undefined) return Partials[key];
|
|
||||||
return legacyKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopBot() {
|
async function stopBot() {
|
||||||
if (client) {
|
if (client) {
|
||||||
await client.destroy();
|
await client.destroy();
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
const UNIT_DEFINITIONS = Object.freeze({
|
|
||||||
Y: { key: "years", milliseconds: 365 * 24 * 60 * 60 * 1000, singular: "year", plural: "years" },
|
|
||||||
M: { key: "months", milliseconds: 30 * 24 * 60 * 60 * 1000, singular: "month", plural: "months" },
|
|
||||||
D: { key: "days", milliseconds: 24 * 60 * 60 * 1000, singular: "day", plural: "days" },
|
|
||||||
H: { key: "hours", milliseconds: 60 * 60 * 1000, singular: "hour", plural: "hours" },
|
|
||||||
m: { key: "minutes", milliseconds: 60 * 1000, singular: "minute", plural: "minutes" },
|
|
||||||
s: { key: "seconds", milliseconds: 1000, singular: "second", plural: "seconds" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const DEFAULT_DURATION_FORMAT = "Y-M-D-H-m-s";
|
|
||||||
const TOKEN_PATTERN = /^(?:Y|YY|YYYY|M|MM|D|DD|H|HH|m|mm|s|ss)$/;
|
|
||||||
const UNIT_ORDER = ["Y", "M", "D", "H", "m", "s"];
|
|
||||||
|
|
||||||
function parseDurationFormat(value = DEFAULT_DURATION_FORMAT) {
|
|
||||||
const source = String(value || DEFAULT_DURATION_FORMAT).trim();
|
|
||||||
const tokens = source.split("-").filter(Boolean);
|
|
||||||
if (!tokens.length || tokens.some((token) => !TOKEN_PATTERN.test(token))) {
|
|
||||||
throw new Error("Invalid duration format.");
|
|
||||||
}
|
|
||||||
const units = tokens.map((token) => token[0]);
|
|
||||||
if (new Set(units).size !== units.length) {
|
|
||||||
throw new Error("A duration format may include each unit only once.");
|
|
||||||
}
|
|
||||||
for (let index = 1; index < units.length; index += 1) {
|
|
||||||
if (UNIT_ORDER.indexOf(units[index]) <= UNIT_ORDER.indexOf(units[index - 1])) {
|
|
||||||
throw new Error("Duration units must be ordered from largest to smallest.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return units.map((unit, index) => ({
|
|
||||||
...UNIT_DEFINITIONS[unit],
|
|
||||||
unit,
|
|
||||||
token: tokens[index]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(milliseconds, format = DEFAULT_DURATION_FORMAT) {
|
|
||||||
let remaining = Math.max(0, Math.floor(Number(milliseconds) || 0));
|
|
||||||
if (!remaining) return "";
|
|
||||||
const units = parseDurationFormat(format);
|
|
||||||
const parts = [];
|
|
||||||
for (const unit of units) {
|
|
||||||
const value = Math.floor(remaining / unit.milliseconds);
|
|
||||||
remaining -= value * unit.milliseconds;
|
|
||||||
if (value) parts.push(`${value} ${value === 1 ? unit.singular : unit.plural}`);
|
|
||||||
}
|
|
||||||
return parts.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
DEFAULT_DURATION_FORMAT,
|
|
||||||
UNIT_DEFINITIONS,
|
|
||||||
formatDuration,
|
|
||||||
parseDurationFormat
|
|
||||||
};
|
|
||||||
@ -62,13 +62,8 @@ function emitLumiEvent(type, payload = {}, metadata = {}) {
|
|||||||
|
|
||||||
for (const definition of [
|
for (const definition of [
|
||||||
{ id: "twitch.follow", label: "Twitch follow", description: "A viewer follows a configured Twitch channel.", platform: "twitch" },
|
{ id: "twitch.follow", label: "Twitch follow", description: "A viewer follows a configured Twitch channel.", platform: "twitch" },
|
||||||
{ id: "twitch.unsubscribe", label: "Twitch subscription ended", description: "A viewer's subscription ends in a configured Twitch channel.", platform: "twitch" },
|
|
||||||
{ id: "twitch.raid", label: "Twitch raid received", description: "A configured Twitch channel receives a raid that appears in chat.", platform: "twitch" },
|
{ id: "twitch.raid", label: "Twitch raid received", description: "A configured Twitch channel receives a raid that appears in chat.", platform: "twitch" },
|
||||||
{ id: "twitch.subscribe", label: "Twitch subscription", description: "A configured Twitch channel receives a new subscription.", platform: "twitch", supportsTier: true },
|
{ id: "twitch.subscribe", label: "Twitch subscription", description: "A configured Twitch channel receives a new subscription.", platform: "twitch", supportsTier: true },
|
||||||
{ id: "twitch.moderator_add", label: "Twitch moderator added", description: "A viewer becomes a channel moderator.", platform: "twitch" },
|
|
||||||
{ id: "twitch.moderator_remove", label: "Twitch moderator removed", description: "A viewer stops being a channel moderator.", platform: "twitch" },
|
|
||||||
{ id: "twitch.vip_add", label: "Twitch VIP added", description: "A viewer becomes a channel VIP.", platform: "twitch" },
|
|
||||||
{ id: "twitch.vip_remove", label: "Twitch VIP removed", description: "A viewer stops being a channel VIP.", platform: "twitch" },
|
|
||||||
{ id: "twitch.subscription_gift", label: "Twitch gifted subscriptions", description: "A viewer gifts one or more subscriptions in a configured Twitch channel.", platform: "twitch", supportsTier: true },
|
{ id: "twitch.subscription_gift", label: "Twitch gifted subscriptions", description: "A viewer gifts one or more subscriptions in a configured Twitch channel.", platform: "twitch", supportsTier: true },
|
||||||
{ id: "twitch.cheer", label: "Twitch cheer", description: "A viewer cheers Bits in a configured Twitch channel.", platform: "twitch" },
|
{ id: "twitch.cheer", label: "Twitch cheer", description: "A viewer cheers Bits in a configured Twitch channel.", platform: "twitch" },
|
||||||
{ id: "twitch.channel_points", label: "Twitch channel-points redemption", description: "A viewer redeems a channel-points reward.", platform: "twitch" },
|
{ id: "twitch.channel_points", label: "Twitch channel-points redemption", description: "A viewer redeems a channel-points reward.", platform: "twitch" },
|
||||||
@ -76,10 +71,7 @@ for (const definition of [
|
|||||||
{ id: "youtube.membership_gift", label: "YouTube gifted membership", description: "A YouTube viewer gifts channel memberships.", platform: "youtube" },
|
{ id: "youtube.membership_gift", label: "YouTube gifted membership", description: "A YouTube viewer gifts channel memberships.", platform: "youtube" },
|
||||||
{ id: "youtube.super_chat", label: "YouTube Super Chat", description: "A viewer sends a Super Chat.", platform: "youtube" },
|
{ id: "youtube.super_chat", label: "YouTube Super Chat", description: "A viewer sends a Super Chat.", platform: "youtube" },
|
||||||
{ id: "youtube.super_sticker", label: "YouTube Super Sticker", description: "A viewer sends a Super Sticker.", platform: "youtube" },
|
{ id: "youtube.super_sticker", label: "YouTube Super Sticker", description: "A viewer sends a Super Sticker.", platform: "youtube" },
|
||||||
{ id: "discord.member_join", label: "Discord member joined", description: "A member joins the configured Discord server.", platform: "discord" },
|
{ id: "discord.member_join", label: "Discord member joined", description: "A member joins the configured Discord server.", platform: "discord" }
|
||||||
{ id: "discord.member_leave", label: "Discord member left", description: "A member leaves the configured Discord server.", platform: "discord" },
|
|
||||||
{ id: "discord.boost_start", label: "Discord boost started", description: "A member starts boosting the configured Discord server.", platform: "discord" },
|
|
||||||
{ id: "discord.boost_end", label: "Discord boost ended", description: "A member stops boosting the configured Discord server.", platform: "discord" }
|
|
||||||
]) registerEventType(definition);
|
]) registerEventType(definition);
|
||||||
|
|
||||||
module.exports = { emitLumiEvent, listEventTypes, onLumiEvent, registerEventType };
|
module.exports = { emitLumiEvent, listEventTypes, onLumiEvent, registerEventType };
|
||||||
|
|||||||
@ -53,8 +53,7 @@ function normalizeMedia(media) {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
type: media?.type === "video" ? "video" : "image",
|
type: media?.type === "video" ? "video" : "image",
|
||||||
alt: cleanText(media?.alt || "Animated image", 160),
|
alt: cleanText(media?.alt || "Animated image", 160)
|
||||||
preview: cleanUrl(media?.preview || media?.thumbnail)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
const { db } = require("./db");
|
const { db } = require("./db");
|
||||||
const { getSetting } = require("./settings");
|
const { getSetting } = require("./settings");
|
||||||
const { hasAccess } = require("./rbac");
|
const { hasAccess } = require("./rbac");
|
||||||
const { parseDurationFormat } = require("./duration-format");
|
|
||||||
|
|
||||||
const ROLE_LEVELS = Object.freeze({
|
const ROLE_LEVELS = Object.freeze({
|
||||||
public: 0,
|
public: 0,
|
||||||
@ -24,7 +23,6 @@ const SENSITIVITY_LEVELS = Object.freeze({
|
|||||||
const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]);
|
const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]);
|
||||||
const placeholders = new Map();
|
const placeholders = new Map();
|
||||||
const fieldPolicies = new Map();
|
const fieldPolicies = new Map();
|
||||||
const PLACEHOLDER_PATTERN = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\.\{\s*format\.([A-Za-z-]+)\s*\}\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
|
|
||||||
|
|
||||||
function normalizeRole(value, fallback = "user") {
|
function normalizeRole(value, fallback = "user") {
|
||||||
const role = String(value || fallback).trim().toLowerCase();
|
const role = String(value || fallback).trim().toLowerCase();
|
||||||
@ -102,8 +100,7 @@ function normalizeDefinition(definition = {}) {
|
|||||||
example: definition.example === undefined ? null : String(definition.example),
|
example: definition.example === undefined ? null : String(definition.example),
|
||||||
plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null,
|
plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null,
|
||||||
resolver: typeof definition.resolver === "function" ? definition.resolver : () => "",
|
resolver: typeof definition.resolver === "function" ? definition.resolver : () => "",
|
||||||
available: typeof definition.available === "function" ? definition.available : null,
|
available: typeof definition.available === "function" ? definition.available : null
|
||||||
supports_duration_format: Boolean(definition.supports_duration_format)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,14 +246,13 @@ function findDefinition(token) {
|
|||||||
|
|
||||||
function parsePlaceholders(template) {
|
function parsePlaceholders(template) {
|
||||||
const found = [];
|
const found = [];
|
||||||
const matcher = new RegExp(PLACEHOLDER_PATTERN.source, PLACEHOLDER_PATTERN.flags);
|
const matcher = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
|
||||||
let match = null;
|
let match = null;
|
||||||
while ((match = matcher.exec(String(template || "")))) {
|
while ((match = matcher.exec(String(template || "")))) {
|
||||||
found.push({
|
found.push({
|
||||||
token: match[0],
|
token: match[0],
|
||||||
id: normalizeId(match[1] || match[3]),
|
id: normalizeId(match[1]),
|
||||||
index: match.index,
|
index: match.index
|
||||||
format: match[2] || null
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
@ -281,7 +277,6 @@ function catalog({ fieldId, user, outputAudience, runtimeContext } = {}) {
|
|||||||
value_type: definition.value_type,
|
value_type: definition.value_type,
|
||||||
sensitivity: definition.sensitivity,
|
sensitivity: definition.sensitivity,
|
||||||
group: definition.group,
|
group: definition.group,
|
||||||
supports_duration_format: definition.supports_duration_format,
|
|
||||||
example: definition.sensitivity === "public_safe" ? definition.example : null
|
example: definition.sensitivity === "public_safe" ? definition.example : null
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.token.localeCompare(b.token));
|
.sort((a, b) => a.token.localeCompare(b.token));
|
||||||
@ -307,19 +302,6 @@ function validateTemplate({ fieldId, template, user, outputAudience, runtimeCont
|
|||||||
}
|
}
|
||||||
for (const token of parsePlaceholders(template)) {
|
for (const token of parsePlaceholders(template)) {
|
||||||
const definition = findDefinition(token.id);
|
const definition = findDefinition(token.id);
|
||||||
if (token.format) {
|
|
||||||
try {
|
|
||||||
if (!definition?.supports_duration_format) throw new Error("unsupported");
|
|
||||||
parseDurationFormat(token.format);
|
|
||||||
} catch {
|
|
||||||
errors.push({
|
|
||||||
token: token.token,
|
|
||||||
id: token.id,
|
|
||||||
reason: definition?.supports_duration_format ? "invalid_duration_format" : "duration_format_unsupported"
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const access = checkPlaceholderAccess(definition, policy, {
|
const access = checkPlaceholderAccess(definition, policy, {
|
||||||
user,
|
user,
|
||||||
outputAudience,
|
outputAudience,
|
||||||
@ -346,8 +328,8 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const rendered = await replaceAsync(String(template || ""), PLACEHOLDER_PATTERN, async (match, formattedId, format, regularId) => {
|
const rendered = await replaceAsync(String(template || ""), /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, async (match, rawId) => {
|
||||||
const id = normalizeId(formattedId || regularId);
|
const id = normalizeId(rawId);
|
||||||
const definition = findDefinition(id);
|
const definition = findDefinition(id);
|
||||||
const access = checkPlaceholderAccess(definition, policy, {
|
const access = checkPlaceholderAccess(definition, policy, {
|
||||||
user,
|
user,
|
||||||
@ -358,16 +340,6 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
|
|||||||
errors.push({ token: match, id, reason: access.reason });
|
errors.push({ token: match, id, reason: access.reason });
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
if (format) {
|
|
||||||
try {
|
|
||||||
if (!definition.supports_duration_format) throw new Error("unsupported");
|
|
||||||
parseDurationFormat(format);
|
|
||||||
} catch {
|
|
||||||
const reason = definition.supports_duration_format ? "invalid_duration_format" : "duration_format_unsupported";
|
|
||||||
errors.push({ token: match, id, reason });
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const value = await withTimeout(Promise.resolve(definition.resolver({
|
const value = await withTimeout(Promise.resolve(definition.resolver({
|
||||||
user,
|
user,
|
||||||
@ -375,8 +347,7 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
|
|||||||
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
|
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
|
||||||
runtimeContext,
|
runtimeContext,
|
||||||
token: match,
|
token: match,
|
||||||
id,
|
id
|
||||||
format: format || null
|
|
||||||
})), runtimeContext?.placeholder_timeout_ms);
|
})), runtimeContext?.placeholder_timeout_ms);
|
||||||
return stringifyResolvedValue(value);
|
return stringifyResolvedValue(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -422,22 +393,14 @@ function withTimeout(promise, requestedMs) {
|
|||||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerCorePlaceholders({ userAgeStatistics } = {}) {
|
function registerCorePlaceholders() {
|
||||||
registerFieldPolicy({
|
registerFieldPolicy({
|
||||||
field_id: "core.custom_commands.static_response",
|
field_id: "core.custom_commands.static_response",
|
||||||
label: "Custom command response",
|
label: "Custom command response",
|
||||||
field_type: "command_response",
|
field_type: "command_response",
|
||||||
output_audience: "user",
|
output_audience: "user",
|
||||||
min_editor_role: "mod",
|
min_editor_role: "mod",
|
||||||
allowed_namespaces: [
|
allowed_namespaces: ["core.main", "core.command", "custom", "user.public"],
|
||||||
"core.main",
|
|
||||||
"core.command",
|
|
||||||
"custom",
|
|
||||||
"user.public",
|
|
||||||
"twitch.user",
|
|
||||||
"youtube.user",
|
|
||||||
"discord.user"
|
|
||||||
],
|
|
||||||
max_sensitivity: "public_safe"
|
max_sensitivity: "public_safe"
|
||||||
});
|
});
|
||||||
registerPlaceholders([
|
registerPlaceholders([
|
||||||
@ -498,9 +461,6 @@ function registerCorePlaceholders({ userAgeStatistics } = {}) {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
if (userAgeStatistics?.placeholderDefinitions) {
|
|
||||||
registerPlaceholders(userAgeStatistics.placeholderDefinitions());
|
|
||||||
}
|
|
||||||
registerCustomPlaceholders(getSetting("custom_placeholders", []));
|
registerCustomPlaceholders(getSetting("custom_placeholders", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ const { getSetting } = require("./settings");
|
|||||||
const { getEnabledPlatformIds } = require("./platforms");
|
const { getEnabledPlatformIds } = require("./platforms");
|
||||||
const { getPluginLeaderboards } = require("./plugin-stats");
|
const { getPluginLeaderboards } = require("./plugin-stats");
|
||||||
const { getPlugins, pluginsDir } = require("./plugins");
|
const { getPlugins, pluginsDir } = require("./plugins");
|
||||||
const { userAgeStatistics } = require("./user-age-statistics");
|
|
||||||
|
|
||||||
const coreProviders = new Map();
|
const coreProviders = new Map();
|
||||||
const coreOrder = [];
|
const coreOrder = [];
|
||||||
@ -98,31 +97,17 @@ function ensureCoreProviders() {
|
|||||||
description: "Most popular commands across platforms.",
|
description: "Most popular commands across platforms.",
|
||||||
getRows: ({ limit }) => buildCommandUsageRows(limit)
|
getRows: ({ limit }) => buildCommandUsageRows(limit)
|
||||||
});
|
});
|
||||||
for (const definition of [
|
registerTopProvider({
|
||||||
{ id: "followage", platform: "twitch", type: "follow", label: "Longest Twitch follows" },
|
id: "followage",
|
||||||
{ id: "twitch_subscriber_age", platform: "twitch", type: "subscriber", label: "Longest Twitch subscriptions" },
|
label: "Top followage",
|
||||||
{ id: "twitch_mod_age", platform: "twitch", type: "moderator", label: "Longest Twitch moderator tenure" },
|
section: "Platforms",
|
||||||
{ id: "twitch_editor_age", platform: "twitch", type: "editor", label: "Longest Twitch editor tenure" },
|
valueLabel: "Days",
|
||||||
{ id: "twitch_vip_age", platform: "twitch", type: "vip", label: "Longest Twitch VIP tenure" },
|
description: "Longest follower durations.",
|
||||||
{ id: "youtube_member_age", platform: "youtube", type: "subscriber", label: "Longest YouTube memberships" },
|
getRows: () => ({
|
||||||
{ id: "youtube_mod_age", platform: "youtube", type: "moderator", label: "Longest YouTube moderator tenure" },
|
rows: [],
|
||||||
{ id: "discord_member_age", platform: "discord", type: "member", label: "Longest Discord membership" },
|
emptyMessage: "Followage tracking is not configured yet."
|
||||||
{ id: "discord_nitro_age", platform: "discord", type: "nitro", label: "Longest Discord boosts" }
|
})
|
||||||
]) {
|
});
|
||||||
registerTopProvider({
|
|
||||||
...definition,
|
|
||||||
section: "Platform tenure",
|
|
||||||
valueLabel: "Recorded tenure",
|
|
||||||
description: `${definition.label} across linked Lumi profiles.`,
|
|
||||||
getRows: ({ limit }) => ({
|
|
||||||
rows: userAgeStatistics.getLeaderboard(definition.type, {
|
|
||||||
platform: definition.platform,
|
|
||||||
limit
|
|
||||||
}),
|
|
||||||
emptyMessage: "No recorded tenure is available yet."
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
registerTopProvider({
|
registerTopProvider({
|
||||||
id: "watchtime",
|
id: "watchtime",
|
||||||
label: "Top watchtime",
|
label: "Top watchtime",
|
||||||
@ -175,9 +160,7 @@ function getTopBoards({ limit = 10 } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getLeaderboardSections({ limit = 10 } = {}) {
|
function getLeaderboardSections({ limit = 10 } = {}) {
|
||||||
const boards = getTopBoards({ limit }).filter(
|
const boards = getTopBoards({ limit });
|
||||||
(board) => board.section !== "Platform tenure" || (board.rows && board.rows.length)
|
|
||||||
);
|
|
||||||
const sections = [];
|
const sections = [];
|
||||||
const sectionMap = new Map();
|
const sectionMap = new Map();
|
||||||
boards.forEach((board) => {
|
boards.forEach((board) => {
|
||||||
|
|||||||
@ -3,7 +3,6 @@ const { getSetting, setSetting } = require("./settings");
|
|||||||
const { emitLumiEvent } = require("./lumi-events");
|
const { emitLumiEvent } = require("./lumi-events");
|
||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
const { setPlatformLiveState } = require("./platform-live-state");
|
const { setPlatformLiveState } = require("./platform-live-state");
|
||||||
const { reconcileTwitchUserAges } = require("./twitch-user-age");
|
|
||||||
|
|
||||||
const EVENTSUB_URL = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=30";
|
const EVENTSUB_URL = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=30";
|
||||||
const eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
|
const eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
|
||||||
@ -71,8 +70,6 @@ class TwitchEventSubManager {
|
|||||||
this.running = false;
|
this.running = false;
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
this.reconcileTimer = null;
|
|
||||||
this.reconcileContext = null;
|
|
||||||
this.generation = 0;
|
this.generation = 0;
|
||||||
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
|
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
|
||||||
}
|
}
|
||||||
@ -101,10 +98,7 @@ class TwitchEventSubManager {
|
|||||||
this.running = false;
|
this.running = false;
|
||||||
this.generation += 1;
|
this.generation += 1;
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
clearInterval(this.reconcileTimer);
|
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
this.reconcileTimer = null;
|
|
||||||
this.reconcileContext = null;
|
|
||||||
const socket = this.socket;
|
const socket = this.socket;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Lumi shutdown");
|
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Lumi shutdown");
|
||||||
@ -210,17 +204,8 @@ class TwitchEventSubManager {
|
|||||||
}
|
}
|
||||||
if (scopes.has("channel:read:subscriptions") && broadcaster.id === credentials.validation.user_id) {
|
if (scopes.has("channel:read:subscriptions") && broadcaster.id === credentials.validation.user_id) {
|
||||||
definitions.push({ type: "channel.subscribe", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
definitions.push({ type: "channel.subscribe", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
definitions.push({ type: "channel.subscription.end", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
|
||||||
definitions.push({ type: "channel.subscription.gift", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
definitions.push({ type: "channel.subscription.gift", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
}
|
}
|
||||||
if (scopes.has("moderation:read") && broadcaster.id === credentials.validation.user_id) {
|
|
||||||
definitions.push({ type: "channel.moderator.add", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
|
||||||
definitions.push({ type: "channel.moderator.remove", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
|
||||||
}
|
|
||||||
if (broadcaster.id === credentials.validation.user_id && (scopes.has("channel:read:vips") || scopes.has("channel:manage:vips"))) {
|
|
||||||
definitions.push({ type: "channel.vip.add", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
|
||||||
definitions.push({ type: "channel.vip.remove", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
|
||||||
}
|
|
||||||
if (scopes.has("bits:read") && broadcaster.id === credentials.validation.user_id)
|
if (scopes.has("bits:read") && broadcaster.id === credentials.validation.user_id)
|
||||||
definitions.push({ type: "channel.cheer", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
definitions.push({ type: "channel.cheer", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
if ((scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions")) && broadcaster.id === credentials.validation.user_id)
|
if ((scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions")) && broadcaster.id === credentials.validation.user_id)
|
||||||
@ -250,15 +235,6 @@ class TwitchEventSubManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (generation !== this.generation) return;
|
if (generation !== this.generation) return;
|
||||||
this.reconcileContext = { ...credentials, broadcasters: users.data || [] };
|
|
||||||
await reconcileTwitchUserAges(this.reconcileContext);
|
|
||||||
clearInterval(this.reconcileTimer);
|
|
||||||
this.reconcileTimer = setInterval(() => {
|
|
||||||
if (!this.running || generation !== this.generation || !this.reconcileContext) return;
|
|
||||||
reconcileTwitchUserAges(this.reconcileContext).catch((error) => {
|
|
||||||
eventLog.warn("Scheduled Twitch tenure reconciliation failed", { error }, { event: "user_age_reconciliation_failed" });
|
|
||||||
});
|
|
||||||
}, 10 * 60 * 1000);
|
|
||||||
this.status = {
|
this.status = {
|
||||||
state: "connected",
|
state: "connected",
|
||||||
detail: active ? `Receiving ${active} Twitch event feed${active === 1 ? "" : "s"}.` : "Connected, but no configured channel events could be subscribed.",
|
detail: active ? `Receiving ${active} Twitch event feed${active === 1 ? "" : "s"}.` : "Connected, but no configured channel events could be subscribed.",
|
||||||
@ -267,10 +243,7 @@ class TwitchEventSubManager {
|
|||||||
cheers: scopes.has("bits:read") ? "available" : "missing bits:read",
|
cheers: scopes.has("bits:read") ? "available" : "missing bits:read",
|
||||||
channel_points: scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions") ? "available" : "missing channel:read:redemptions",
|
channel_points: scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions") ? "available" : "missing channel:read:redemptions",
|
||||||
subscriptions: scopes.has("channel:read:subscriptions") ? "available" : "missing channel:read:subscriptions",
|
subscriptions: scopes.has("channel:read:subscriptions") ? "available" : "missing channel:read:subscriptions",
|
||||||
follows: scopes.has("moderator:read:followers") ? "available" : "missing moderator:read:followers",
|
follows: scopes.has("moderator:read:followers") ? "available" : "missing moderator:read:followers"
|
||||||
moderators: scopes.has("moderation:read") ? "available" : "missing moderation:read",
|
|
||||||
vips: scopes.has("channel:read:vips") || scopes.has("channel:manage:vips") ? "available" : "missing channel:read:vips",
|
|
||||||
editors: scopes.has("channel:read:editors") ? "available" : "missing channel:read:editors"
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
eventLog.info("Twitch EventSub connected", { channels: users.data?.length || 0, subscriptions: active }, { event: "eventsub_ready" });
|
eventLog.info("Twitch EventSub connected", { channels: users.data?.length || 0, subscriptions: active }, { event: "eventsub_ready" });
|
||||||
@ -292,11 +265,6 @@ class TwitchEventSubManager {
|
|||||||
if (subscription.type === "channel.follow") emitLumiEvent("twitch.follow", { ...common, followed_at: event.followed_at }, metadata);
|
if (subscription.type === "channel.follow") emitLumiEvent("twitch.follow", { ...common, followed_at: event.followed_at }, metadata);
|
||||||
if (subscription.type === "channel.raid") emitLumiEvent("twitch.raid", { ...common, viewers: Number(event.viewers || 0) }, metadata);
|
if (subscription.type === "channel.raid") emitLumiEvent("twitch.raid", { ...common, viewers: Number(event.viewers || 0) }, metadata);
|
||||||
if (subscription.type === "channel.subscribe") emitLumiEvent("twitch.subscribe", { ...common, tier: event.tier, gifted: Boolean(event.is_gift) }, metadata);
|
if (subscription.type === "channel.subscribe") emitLumiEvent("twitch.subscribe", { ...common, tier: event.tier, gifted: Boolean(event.is_gift) }, metadata);
|
||||||
if (subscription.type === "channel.subscription.end") emitLumiEvent("twitch.unsubscribe", { ...common, tier: event.tier }, metadata);
|
|
||||||
if (subscription.type === "channel.moderator.add") emitLumiEvent("twitch.moderator_add", common, metadata);
|
|
||||||
if (subscription.type === "channel.moderator.remove") emitLumiEvent("twitch.moderator_remove", common, metadata);
|
|
||||||
if (subscription.type === "channel.vip.add") emitLumiEvent("twitch.vip_add", common, metadata);
|
|
||||||
if (subscription.type === "channel.vip.remove") emitLumiEvent("twitch.vip_remove", common, metadata);
|
|
||||||
if (subscription.type === "channel.subscription.gift") emitLumiEvent("twitch.subscription_gift", { ...common, tier: event.tier, total: Number(event.total || 0), anonymous: Boolean(event.is_anonymous) }, metadata);
|
if (subscription.type === "channel.subscription.gift") emitLumiEvent("twitch.subscription_gift", { ...common, tier: event.tier, total: Number(event.total || 0), anonymous: Boolean(event.is_anonymous) }, metadata);
|
||||||
if (subscription.type === "channel.cheer") emitLumiEvent("twitch.cheer", { ...common, bits: Number(event.bits || 0), message: event.message || "", anonymous: Boolean(event.is_anonymous) }, metadata);
|
if (subscription.type === "channel.cheer") emitLumiEvent("twitch.cheer", { ...common, bits: Number(event.bits || 0), message: event.message || "", anonymous: Boolean(event.is_anonymous) }, metadata);
|
||||||
if (subscription.type === "channel.channel_points_custom_reward_redemption.add") emitLumiEvent("twitch.channel_points", {
|
if (subscription.type === "channel.channel_points_custom_reward_redemption.add") emitLumiEvent("twitch.channel_points", {
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
const { userAgeStatistics } = require("./user-age-statistics");
|
|
||||||
const { createLogger } = require("./logger");
|
|
||||||
|
|
||||||
const log = createLogger("platform:twitch:user-age", { category: "integration" });
|
|
||||||
|
|
||||||
async function fetchAll(url, headers) {
|
|
||||||
const records = [];
|
|
||||||
let cursor = "";
|
|
||||||
do {
|
|
||||||
const target = new URL(url);
|
|
||||||
target.searchParams.set("first", "100");
|
|
||||||
if (cursor) target.searchParams.set("after", cursor);
|
|
||||||
const response = await fetch(target, { headers });
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = await response.text().catch(() => "");
|
|
||||||
throw new Error(`Twitch reconciliation failed (${response.status})${detail ? `: ${detail.slice(0, 160)}` : ""}`);
|
|
||||||
}
|
|
||||||
const body = await response.json();
|
|
||||||
records.push(...(Array.isArray(body.data) ? body.data : []));
|
|
||||||
cursor = String(body.pagination?.cursor || "");
|
|
||||||
} while (cursor);
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reconcileTwitchUserAges({ accessToken, clientId, validation, broadcasters = [] } = {}) {
|
|
||||||
if (!accessToken || !clientId) return;
|
|
||||||
const headers = {
|
|
||||||
"Client-Id": clientId,
|
|
||||||
Authorization: `Bearer ${accessToken}`
|
|
||||||
};
|
|
||||||
const scopes = new Set(validation?.scopes || []);
|
|
||||||
const moderatorId = validation?.user_id;
|
|
||||||
for (const broadcaster of broadcasters) {
|
|
||||||
const jobs = [];
|
|
||||||
if (scopes.has("moderator:read:followers")) {
|
|
||||||
jobs.push(reconcileList({
|
|
||||||
platform: "twitch",
|
|
||||||
scopeId: broadcaster.id,
|
|
||||||
type: "follow",
|
|
||||||
url: `https://api.twitch.tv/helix/channels/followers?broadcaster_id=${encodeURIComponent(broadcaster.id)}&moderator_id=${encodeURIComponent(moderatorId)}`,
|
|
||||||
headers,
|
|
||||||
startField: "followed_at",
|
|
||||||
source: "twitch-followers-api"
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (broadcaster.id === moderatorId && scopes.has("channel:read:subscriptions")) {
|
|
||||||
jobs.push(reconcileList({
|
|
||||||
platform: "twitch",
|
|
||||||
scopeId: broadcaster.id,
|
|
||||||
type: "subscriber",
|
|
||||||
url: `https://api.twitch.tv/helix/subscriptions?broadcaster_id=${encodeURIComponent(broadcaster.id)}`,
|
|
||||||
headers,
|
|
||||||
source: "twitch-subscriptions-api"
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (broadcaster.id === moderatorId && scopes.has("moderation:read")) {
|
|
||||||
jobs.push(reconcileList({
|
|
||||||
platform: "twitch",
|
|
||||||
scopeId: broadcaster.id,
|
|
||||||
type: "moderator",
|
|
||||||
url: `https://api.twitch.tv/helix/moderation/moderators?broadcaster_id=${encodeURIComponent(broadcaster.id)}`,
|
|
||||||
headers,
|
|
||||||
source: "twitch-moderators-api"
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (broadcaster.id === moderatorId && (scopes.has("channel:read:vips") || scopes.has("channel:manage:vips"))) {
|
|
||||||
jobs.push(reconcileList({
|
|
||||||
platform: "twitch",
|
|
||||||
scopeId: broadcaster.id,
|
|
||||||
type: "vip",
|
|
||||||
url: `https://api.twitch.tv/helix/channels/vips?broadcaster_id=${encodeURIComponent(broadcaster.id)}`,
|
|
||||||
headers,
|
|
||||||
source: "twitch-vips-api"
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (broadcaster.id === moderatorId && scopes.has("channel:read:editors")) {
|
|
||||||
jobs.push(reconcileList({
|
|
||||||
platform: "twitch",
|
|
||||||
scopeId: broadcaster.id,
|
|
||||||
type: "editor",
|
|
||||||
url: `https://api.twitch.tv/helix/channels/editors?broadcaster_id=${encodeURIComponent(broadcaster.id)}`,
|
|
||||||
headers,
|
|
||||||
startField: "created_at",
|
|
||||||
source: "twitch-editors-api"
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
await Promise.allSettled(jobs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reconcileList({ platform, scopeId, type, url, headers, startField = null, source }) {
|
|
||||||
try {
|
|
||||||
const rows = await fetchAll(url, headers);
|
|
||||||
const observedAt = Date.now();
|
|
||||||
userAgeStatistics.reconcileSnapshot({
|
|
||||||
platform,
|
|
||||||
scopeId,
|
|
||||||
type,
|
|
||||||
activeRecords: rows.map((row) => ({
|
|
||||||
userId: row.user_id,
|
|
||||||
startAt: startField ? row[startField] : undefined,
|
|
||||||
source: startField && row[startField] ? source : `${source}:first-observed`,
|
|
||||||
authoritative: Boolean(startField && row[startField])
|
|
||||||
})),
|
|
||||||
observedAt,
|
|
||||||
source,
|
|
||||||
complete: true
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// A failed or partial API read must never be interpreted as an empty state.
|
|
||||||
log.warn("Twitch platform tenure reconciliation was skipped", {
|
|
||||||
statistic_type: type,
|
|
||||||
scope_id: scopeId,
|
|
||||||
error
|
|
||||||
}, { event: "user_age_reconciliation_failed" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
fetchAll,
|
|
||||||
reconcileTwitchUserAges
|
|
||||||
};
|
|
||||||
@ -5,7 +5,6 @@ const { ensureUserForIdentity } = require("./users");
|
|||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
const { publishOverlayChatMessage } = require("./overlay-chat");
|
const { publishOverlayChatMessage } = require("./overlay-chat");
|
||||||
const { resolveBetterTtvEmotes, resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets");
|
const { resolveBetterTtvEmotes, resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets");
|
||||||
const { userAgeStatistics } = require("./user-age-statistics");
|
|
||||||
|
|
||||||
const twitchLog = createLogger("platform:twitch", { category: "integration" });
|
const twitchLog = createLogger("platform:twitch", { category: "integration" });
|
||||||
|
|
||||||
@ -58,28 +57,6 @@ async function startTwitchBot({ commandRouter } = {}) {
|
|||||||
displayName,
|
displayName,
|
||||||
avatar
|
avatar
|
||||||
});
|
});
|
||||||
if (!self && tags["room-id"]) {
|
|
||||||
const observedAt = Number(tags["tmi-sent-ts"]) || Date.now();
|
|
||||||
const badgeNames = new Set(Object.keys(tags.badges || {}));
|
|
||||||
for (const [type, active] of [
|
|
||||||
["subscriber", badgeNames.has("subscriber") || badgeNames.has("founder") || Boolean(tags.subscriber)],
|
|
||||||
["moderator", badgeNames.has("moderator") || tags.mod === true || tags.mod === "1"],
|
|
||||||
["vip", badgeNames.has("vip")]
|
|
||||||
]) {
|
|
||||||
userAgeStatistics.setState({
|
|
||||||
platform: "twitch",
|
|
||||||
userId,
|
|
||||||
scopeId: tags["room-id"],
|
|
||||||
type,
|
|
||||||
active,
|
|
||||||
occurredAt: observedAt,
|
|
||||||
eventId: `twitch-chat:${tags.id || `${tags["room-id"]}:${userId}:${observedAt}`}:${type}`,
|
|
||||||
source: "twitch-chat-tags",
|
|
||||||
precision: "millisecond",
|
|
||||||
authoritative: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
publishOverlayChatMessage({
|
publishOverlayChatMessage({
|
||||||
id: tags.id,
|
id: tags.id,
|
||||||
platform: "twitch",
|
platform: "twitch",
|
||||||
|
|||||||
@ -1,430 +0,0 @@
|
|||||||
const crypto = require("crypto");
|
|
||||||
const { db } = require("./db");
|
|
||||||
const { formatDuration } = require("./duration-format");
|
|
||||||
const { createLogger } = require("./logger");
|
|
||||||
|
|
||||||
const log = createLogger("core:user-age-statistics", { category: "integration" });
|
|
||||||
|
|
||||||
const DEFAULT_STATISTICS = Object.freeze([
|
|
||||||
{ platform: "twitch", type: "follow", placeholder: "follow_age", label: "Follow age" },
|
|
||||||
{ platform: "twitch", type: "subscriber", placeholder: "subscriber_age", label: "Subscriber age" },
|
|
||||||
{ platform: "twitch", type: "moderator", placeholder: "mod_age", label: "Moderator age" },
|
|
||||||
{ platform: "twitch", type: "editor", placeholder: "editor_age", label: "Editor age" },
|
|
||||||
{ platform: "twitch", type: "vip", placeholder: "vip_age", label: "VIP age" },
|
|
||||||
{ platform: "youtube", type: "subscriber", placeholder: "subscriber_age", label: "Channel membership age" },
|
|
||||||
{ platform: "youtube", type: "moderator", placeholder: "mod_age", label: "Moderator age" },
|
|
||||||
{ platform: "discord", type: "member", placeholder: "member_age", label: "Server member age" },
|
|
||||||
{ platform: "discord", type: "nitro", placeholder: "nitro_age", label: "Server boost age" }
|
|
||||||
]);
|
|
||||||
|
|
||||||
function normalizeText(value) {
|
|
||||||
return String(value ?? "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function timestamp(value, fallback = null) {
|
|
||||||
if (value instanceof Date) return value.getTime();
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (Number.isFinite(numeric) && numeric > 0) return Math.floor(numeric);
|
|
||||||
const parsed = Date.parse(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
class UserAgeStatistics {
|
|
||||||
constructor(database = db, { now = () => Date.now(), registerDefaults = true } = {}) {
|
|
||||||
this.db = database;
|
|
||||||
this.now = now;
|
|
||||||
this.statistics = new Map();
|
|
||||||
this.stopListening = null;
|
|
||||||
this.ensureTables();
|
|
||||||
if (registerDefaults) DEFAULT_STATISTICS.forEach((definition) => this.registerStatistic(definition));
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureTables() {
|
|
||||||
this.db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS user_age_intervals (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
platform TEXT NOT NULL,
|
|
||||||
platform_user_id TEXT NOT NULL,
|
|
||||||
scope_id TEXT NOT NULL,
|
|
||||||
statistic_type TEXT NOT NULL,
|
|
||||||
start_at INTEGER NOT NULL,
|
|
||||||
end_at INTEGER,
|
|
||||||
start_source TEXT NOT NULL,
|
|
||||||
start_precision TEXT NOT NULL DEFAULT 'millisecond',
|
|
||||||
start_authoritative INTEGER NOT NULL DEFAULT 0,
|
|
||||||
end_source TEXT,
|
|
||||||
end_precision TEXT,
|
|
||||||
end_authoritative INTEGER,
|
|
||||||
observed_at INTEGER NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS user_age_intervals_one_open_idx
|
|
||||||
ON user_age_intervals (platform, platform_user_id, scope_id, statistic_type)
|
|
||||||
WHERE end_at IS NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS user_age_intervals_lookup_idx
|
|
||||||
ON user_age_intervals (platform, scope_id, statistic_type, platform_user_id, start_at);
|
|
||||||
CREATE TABLE IF NOT EXISTS user_age_events (
|
|
||||||
platform TEXT NOT NULL,
|
|
||||||
event_id TEXT NOT NULL,
|
|
||||||
processed_at INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (platform, event_id)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
registerStatistic(definition = {}) {
|
|
||||||
const platform = normalizeText(definition.platform).toLowerCase();
|
|
||||||
const type = normalizeText(definition.type).toLowerCase();
|
|
||||||
const placeholder = normalizeText(definition.placeholder).toLowerCase();
|
|
||||||
if (!platform || !type || !placeholder) throw new Error("Platform, type, and placeholder are required.");
|
|
||||||
const normalized = Object.freeze({
|
|
||||||
platform,
|
|
||||||
type,
|
|
||||||
placeholder,
|
|
||||||
label: normalizeText(definition.label) || placeholder
|
|
||||||
});
|
|
||||||
this.statistics.set(`${platform}:${type}`, normalized);
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
listStatistics() {
|
|
||||||
return Array.from(this.statistics.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
if (this.stopListening) return;
|
|
||||||
const { onLumiEvent } = require("./lumi-events");
|
|
||||||
const mappings = {
|
|
||||||
"twitch.follow": ["follow", true, "followed_at"],
|
|
||||||
"twitch.subscribe": ["subscriber", true, null],
|
|
||||||
"twitch.unsubscribe": ["subscriber", false, null],
|
|
||||||
"twitch.moderator_add": ["moderator", true, null],
|
|
||||||
"twitch.moderator_remove": ["moderator", false, null],
|
|
||||||
"twitch.vip_add": ["vip", true, null],
|
|
||||||
"twitch.vip_remove": ["vip", false, null],
|
|
||||||
"discord.member_join": ["member", true, "joined_at"],
|
|
||||||
"discord.member_leave": ["member", false, null],
|
|
||||||
"discord.boost_start": ["nitro", true, "boosted_at"],
|
|
||||||
"discord.boost_end": ["nitro", false, null]
|
|
||||||
};
|
|
||||||
this.stopListening = onLumiEvent((event) => {
|
|
||||||
const mapping = mappings[event.type];
|
|
||||||
if (!mapping) return;
|
|
||||||
const [type, active, startField] = mapping;
|
|
||||||
const payload = event.payload || {};
|
|
||||||
const platform = event.type.split(".")[0];
|
|
||||||
const userId = payload.user_id;
|
|
||||||
const scopeId = platform === "discord" ? payload.guild_id : payload.broadcaster_id;
|
|
||||||
if (!userId || !scopeId) return;
|
|
||||||
try {
|
|
||||||
this.setState({
|
|
||||||
platform,
|
|
||||||
type,
|
|
||||||
userId,
|
|
||||||
scopeId,
|
|
||||||
active,
|
|
||||||
startAt: startField ? payload[startField] : event.occurredAt,
|
|
||||||
occurredAt: event.occurredAt,
|
|
||||||
eventId: event.id,
|
|
||||||
source: event.source,
|
|
||||||
precision: "millisecond",
|
|
||||||
authoritative: Boolean(startField && payload[startField]) || active === false
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Platform tenure event could not be recorded", {
|
|
||||||
event_type: event.type,
|
|
||||||
event_id: event.id,
|
|
||||||
error
|
|
||||||
}, { event: "user_age_event_failed" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.stopListening?.();
|
|
||||||
this.stopListening = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(input = {}) {
|
|
||||||
return this.db.transaction(() => this._setState(input, true))();
|
|
||||||
}
|
|
||||||
|
|
||||||
_setState(input, recordEvent) {
|
|
||||||
const platform = normalizeText(input.platform).toLowerCase();
|
|
||||||
const userId = normalizeText(input.userId || input.platformUserId);
|
|
||||||
const scopeId = normalizeText(input.scopeId);
|
|
||||||
const type = normalizeText(input.type).toLowerCase();
|
|
||||||
const definition = this.statistics.get(`${platform}:${type}`);
|
|
||||||
if (!definition || !userId || !scopeId) throw new Error("Unknown or incomplete user-age statistic.");
|
|
||||||
const now = this.now();
|
|
||||||
const occurredAt = timestamp(input.occurredAt, now);
|
|
||||||
const eventId = normalizeText(input.eventId);
|
|
||||||
if (recordEvent && eventId) {
|
|
||||||
const result = this.db.prepare(
|
|
||||||
"INSERT OR IGNORE INTO user_age_events (platform, event_id, processed_at) VALUES (?, ?, ?)"
|
|
||||||
).run(platform, eventId, now);
|
|
||||||
if (!result.changes) return { changed: false, duplicate: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const open = this.db.prepare(
|
|
||||||
`SELECT * FROM user_age_intervals
|
|
||||||
WHERE platform = ? AND platform_user_id = ? AND scope_id = ? AND statistic_type = ? AND end_at IS NULL`
|
|
||||||
).get(platform, userId, scopeId, type);
|
|
||||||
const active = Boolean(input.active);
|
|
||||||
const authoritative = Boolean(input.authoritative);
|
|
||||||
const source = normalizeText(input.source) || "first-observed";
|
|
||||||
const precision = normalizeText(input.precision) || "millisecond";
|
|
||||||
|
|
||||||
if (active) {
|
|
||||||
const proposedStart = Math.min(timestamp(input.startAt, occurredAt), occurredAt);
|
|
||||||
if (open) {
|
|
||||||
if (authoritative && !open.start_authoritative && proposedStart <= open.start_at) {
|
|
||||||
this.db.prepare(
|
|
||||||
`UPDATE user_age_intervals
|
|
||||||
SET start_at = ?, start_source = ?, start_precision = ?, start_authoritative = 1,
|
|
||||||
observed_at = ?, updated_at = ?
|
|
||||||
WHERE id = ? AND end_at IS NULL`
|
|
||||||
).run(proposedStart, source, precision, occurredAt, now, open.id);
|
|
||||||
return { changed: true, intervalId: open.id, backfilled: true };
|
|
||||||
}
|
|
||||||
return { changed: false, intervalId: open.id };
|
|
||||||
}
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
this.db.prepare(
|
|
||||||
`INSERT INTO user_age_intervals
|
|
||||||
(id, platform, platform_user_id, scope_id, statistic_type, start_at, end_at,
|
|
||||||
start_source, start_precision, start_authoritative, observed_at, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)`
|
|
||||||
).run(id, platform, userId, scopeId, type, proposedStart, source, precision, authoritative ? 1 : 0, occurredAt, now, now);
|
|
||||||
return { changed: true, intervalId: id, opened: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) return { changed: false };
|
|
||||||
const endAt = Math.max(open.start_at, occurredAt);
|
|
||||||
this.db.prepare(
|
|
||||||
`UPDATE user_age_intervals
|
|
||||||
SET end_at = ?, end_source = ?, end_precision = ?, end_authoritative = ?,
|
|
||||||
observed_at = ?, updated_at = ?
|
|
||||||
WHERE id = ? AND end_at IS NULL`
|
|
||||||
).run(endAt, source, precision, authoritative ? 1 : 0, occurredAt, now, open.id);
|
|
||||||
return { changed: true, intervalId: open.id, closed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
reconcileSnapshot(input = {}) {
|
|
||||||
const platform = normalizeText(input.platform).toLowerCase();
|
|
||||||
const scopeId = normalizeText(input.scopeId);
|
|
||||||
const type = normalizeText(input.type).toLowerCase();
|
|
||||||
if (!input.complete) return { changed: false, skipped: true };
|
|
||||||
const records = Array.isArray(input.activeRecords) ? input.activeRecords : [];
|
|
||||||
const observedAt = timestamp(input.observedAt, this.now());
|
|
||||||
return this.db.transaction(() => {
|
|
||||||
const activeUsers = new Set();
|
|
||||||
let changes = 0;
|
|
||||||
for (const record of records) {
|
|
||||||
const userId = normalizeText(record.userId || record.platformUserId);
|
|
||||||
if (!userId) continue;
|
|
||||||
activeUsers.add(userId);
|
|
||||||
const result = this._setState({
|
|
||||||
platform,
|
|
||||||
scopeId,
|
|
||||||
type,
|
|
||||||
userId,
|
|
||||||
active: true,
|
|
||||||
startAt: record.startAt,
|
|
||||||
occurredAt: observedAt,
|
|
||||||
source: record.source || input.source || "platform-reconciliation",
|
|
||||||
precision: record.precision || input.precision || "millisecond",
|
|
||||||
authoritative: record.authoritative ?? input.authoritative
|
|
||||||
}, false);
|
|
||||||
if (result.changed) changes += 1;
|
|
||||||
}
|
|
||||||
const openRows = this.db.prepare(
|
|
||||||
`SELECT platform_user_id FROM user_age_intervals
|
|
||||||
WHERE platform = ? AND scope_id = ? AND statistic_type = ? AND end_at IS NULL`
|
|
||||||
).all(platform, scopeId, type);
|
|
||||||
for (const row of openRows) {
|
|
||||||
if (activeUsers.has(row.platform_user_id)) continue;
|
|
||||||
const result = this._setState({
|
|
||||||
platform,
|
|
||||||
scopeId,
|
|
||||||
type,
|
|
||||||
userId: row.platform_user_id,
|
|
||||||
active: false,
|
|
||||||
occurredAt: observedAt,
|
|
||||||
source: input.source || "platform-reconciliation",
|
|
||||||
precision: input.precision || "millisecond",
|
|
||||||
authoritative: Boolean(input.authoritative)
|
|
||||||
}, false);
|
|
||||||
if (result.changed) changes += 1;
|
|
||||||
}
|
|
||||||
return { changed: Boolean(changes), changes };
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
getDurations({ platform, userId, scopeId, type, now = this.now() } = {}) {
|
|
||||||
const rows = this.db.prepare(
|
|
||||||
`SELECT start_at, end_at FROM user_age_intervals
|
|
||||||
WHERE platform = ? AND platform_user_id = ? AND scope_id = ? AND statistic_type = ?
|
|
||||||
ORDER BY start_at ASC`
|
|
||||||
).all(normalizeText(platform).toLowerCase(), normalizeText(userId), normalizeText(scopeId), normalizeText(type).toLowerCase());
|
|
||||||
if (!rows.length) return { current: null, total: null, intervals: 0 };
|
|
||||||
let current = null;
|
|
||||||
let total = 0;
|
|
||||||
for (const row of rows) {
|
|
||||||
const end = row.end_at === null ? now : row.end_at;
|
|
||||||
const duration = Math.max(0, end - row.start_at);
|
|
||||||
total += duration;
|
|
||||||
if (row.end_at === null) current = duration;
|
|
||||||
}
|
|
||||||
return { current, total, intervals: rows.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
getProfileStatistics(userId, { now = this.now() } = {}) {
|
|
||||||
const identities = this.db.prepare(
|
|
||||||
"SELECT provider, provider_user_id FROM user_identities WHERE user_id = ?"
|
|
||||||
).all(userId);
|
|
||||||
const output = [];
|
|
||||||
for (const identity of identities) {
|
|
||||||
const definitions = this.listStatistics().filter((entry) => entry.platform === identity.provider);
|
|
||||||
for (const definition of definitions) {
|
|
||||||
const rows = this.db.prepare(
|
|
||||||
`SELECT scope_id, start_at, end_at FROM user_age_intervals
|
|
||||||
WHERE platform = ? AND platform_user_id = ? AND statistic_type = ?`
|
|
||||||
).all(identity.provider, identity.provider_user_id, definition.type);
|
|
||||||
if (!rows.length) continue;
|
|
||||||
const byScope = new Map();
|
|
||||||
for (const row of rows) {
|
|
||||||
const scoped = byScope.get(row.scope_id) || [];
|
|
||||||
scoped.push(row);
|
|
||||||
byScope.set(row.scope_id, scoped);
|
|
||||||
}
|
|
||||||
const scopeEntries = [...byScope.entries()].sort(([left], [right]) => left.localeCompare(right));
|
|
||||||
for (const [scopeIndex, [scopeId, scopedRows]] of scopeEntries.entries()) {
|
|
||||||
const total = scopedRows.reduce((sum, row) => sum + Math.max(0, (row.end_at ?? now) - row.start_at), 0);
|
|
||||||
const open = scopedRows.find((row) => row.end_at === null);
|
|
||||||
const scopeLabel = definition.platform === "discord" ? "Server" : "Channel";
|
|
||||||
output.push({
|
|
||||||
platform: definition.platform,
|
|
||||||
type: definition.type,
|
|
||||||
scopeId,
|
|
||||||
label: `${capitalize(definition.platform)} ${definition.label}${scopeEntries.length > 1 ? ` · ${scopeLabel} ${scopeIndex + 1}` : ""}`,
|
|
||||||
current: open ? formatDuration(now - open.start_at) : "",
|
|
||||||
total: formatDuration(total),
|
|
||||||
value: formatDuration(total)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
getLeaderboard(type, { platform = null, limit = 25, now = this.now() } = {}) {
|
|
||||||
const conditions = ["i.statistic_type = ?"];
|
|
||||||
const params = [now, normalizeText(type).toLowerCase()];
|
|
||||||
if (platform) {
|
|
||||||
conditions.push("i.platform = ?");
|
|
||||||
params.push(normalizeText(platform).toLowerCase());
|
|
||||||
}
|
|
||||||
params.push(Math.max(1, Math.min(Number(limit) || 25, 100)));
|
|
||||||
return this.db.prepare(
|
|
||||||
`WITH scoped_tenure AS (
|
|
||||||
SELECT p.id AS profile_id, p.internal_username AS username, i.scope_id,
|
|
||||||
SUM(MAX(0, COALESCE(i.end_at, ?) - i.start_at)) AS duration_ms
|
|
||||||
FROM user_age_intervals i
|
|
||||||
JOIN user_identities identity
|
|
||||||
ON identity.provider = i.platform AND identity.provider_user_id = i.platform_user_id
|
|
||||||
JOIN user_profiles p ON p.id = identity.user_id
|
|
||||||
WHERE ${conditions.join(" AND ")}
|
|
||||||
GROUP BY p.id, p.internal_username, i.scope_id
|
|
||||||
)
|
|
||||||
SELECT username, MAX(duration_ms) AS duration_ms
|
|
||||||
FROM scoped_tenure
|
|
||||||
GROUP BY profile_id, username
|
|
||||||
ORDER BY duration_ms DESC, username ASC
|
|
||||||
LIMIT ?`
|
|
||||||
).all(...params).map((row) => ({
|
|
||||||
username: row.username,
|
|
||||||
label: row.username,
|
|
||||||
value: formatDuration(row.duration_ms, "D-H-m"),
|
|
||||||
numericValue: row.duration_ms
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
listDiagnostics({ platform = "", scopeId = "", userId = "", type = "", limit = 500 } = {}) {
|
|
||||||
const conditions = [];
|
|
||||||
const params = [];
|
|
||||||
if (platform) { conditions.push("platform = ?"); params.push(normalizeText(platform).toLowerCase()); }
|
|
||||||
if (scopeId) { conditions.push("scope_id = ?"); params.push(normalizeText(scopeId)); }
|
|
||||||
if (userId) { conditions.push("platform_user_id = ?"); params.push(normalizeText(userId)); }
|
|
||||||
if (type) { conditions.push("statistic_type = ?"); params.push(normalizeText(type).toLowerCase()); }
|
|
||||||
params.push(Math.max(1, Math.min(Number(limit) || 500, 1000)));
|
|
||||||
return this.db.prepare(
|
|
||||||
`SELECT * FROM user_age_intervals
|
|
||||||
${conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""}
|
|
||||||
ORDER BY end_at IS NULL DESC, start_at DESC LIMIT ?`
|
|
||||||
).all(...params);
|
|
||||||
}
|
|
||||||
|
|
||||||
placeholderDefinitions() {
|
|
||||||
return this.listStatistics().flatMap((definition) => ["current", "total"].map((mode) => ({
|
|
||||||
id: `${definition.platform}.user.${definition.placeholder}.${mode}`,
|
|
||||||
namespace: `${definition.platform}.user`,
|
|
||||||
group: "Platform tenure",
|
|
||||||
label: `${definition.label} (${mode})`,
|
|
||||||
description: mode === "current"
|
|
||||||
? `Current uninterrupted ${definition.label.toLowerCase()} in this platform context.`
|
|
||||||
: `Combined recorded ${definition.label.toLowerCase()} in this platform context.`,
|
|
||||||
value_type: "string",
|
|
||||||
sensitivity: "public_safe",
|
|
||||||
min_editor_role: "user",
|
|
||||||
min_viewer_role: "user",
|
|
||||||
allowed_field_types: ["command_response", "chat_message", "admin_template", "okf_markdown"],
|
|
||||||
example: "2 years, 3 months, 4 days",
|
|
||||||
supports_duration_format: true,
|
|
||||||
resolver: ({ runtimeContext, format }) => {
|
|
||||||
const identity = contextIdentity(definition.platform, runtimeContext);
|
|
||||||
if (!identity) return "";
|
|
||||||
const durations = this.getDurations({
|
|
||||||
platform: definition.platform,
|
|
||||||
userId: identity.userId,
|
|
||||||
scopeId: identity.scopeId,
|
|
||||||
type: definition.type
|
|
||||||
});
|
|
||||||
const value = durations[mode];
|
|
||||||
return value === null ? "" : formatDuration(value, format);
|
|
||||||
}
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function contextIdentity(platform, runtimeContext = {}) {
|
|
||||||
const ctx = runtimeContext.ctx || {};
|
|
||||||
const activePlatform = normalizeText(runtimeContext.platform || ctx.platform).toLowerCase();
|
|
||||||
if (activePlatform !== platform) return null;
|
|
||||||
const userId = normalizeText(
|
|
||||||
runtimeContext.platformUser?.id ||
|
|
||||||
runtimeContext.user?.platformId ||
|
|
||||||
ctx.platformUser?.id ||
|
|
||||||
ctx.user?.platformId
|
|
||||||
);
|
|
||||||
const meta = runtimeContext.meta || ctx.meta || {};
|
|
||||||
let scopeId = "";
|
|
||||||
if (platform === "twitch") scopeId = normalizeText(meta.tags?.["room-id"] || meta.broadcasterId);
|
|
||||||
if (platform === "youtube") scopeId = normalizeText(meta.broadcasterChannelId);
|
|
||||||
if (platform === "discord") scopeId = normalizeText(meta.message?.guildId || meta.message?.guild?.id || meta.guildId);
|
|
||||||
return userId && scopeId ? { userId, scopeId } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function capitalize(value) {
|
|
||||||
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const userAgeStatistics = new UserAgeStatistics();
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
DEFAULT_STATISTICS,
|
|
||||||
UserAgeStatistics,
|
|
||||||
contextIdentity,
|
|
||||||
userAgeStatistics
|
|
||||||
};
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
function tableExists(db, name) {
|
|
||||||
return Boolean(db.prepare(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
|
|
||||||
).get(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFavoriteCommand(db, userId, prefix = "!") {
|
|
||||||
if (!tableExists(db, "command_user_usage")) return null;
|
|
||||||
const row = db.prepare(
|
|
||||||
`SELECT command_id, count FROM command_user_usage
|
|
||||||
WHERE user_id = ? ORDER BY count DESC, updated_at DESC, command_id ASC LIMIT 1`
|
|
||||||
).get(userId);
|
|
||||||
if (!row) return null;
|
|
||||||
return {
|
|
||||||
command: `${prefix}${String(row.command_id || "").replace(/^custom:/, "")}`,
|
|
||||||
count: Number(row.count || 0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFavoriteExpression(db, userId) {
|
|
||||||
if (!tableExists(db, "expression_user_stats")) return null;
|
|
||||||
const row = db.prepare(
|
|
||||||
`SELECT action, given_count + received_count AS count
|
|
||||||
FROM expression_user_stats WHERE user_id = ?
|
|
||||||
ORDER BY count DESC, action ASC LIMIT 1`
|
|
||||||
).get(userId);
|
|
||||||
return row ? { action: row.action, count: Number(row.count || 0) } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getFavoriteCommand,
|
|
||||||
getFavoriteExpression
|
|
||||||
};
|
|
||||||
@ -5,7 +5,6 @@ const { createLogger } = require("./logger");
|
|||||||
const { publishOverlayChatMessage } = require("./overlay-chat");
|
const { publishOverlayChatMessage } = require("./overlay-chat");
|
||||||
const { emitLumiEvent } = require("./lumi-events");
|
const { emitLumiEvent } = require("./lumi-events");
|
||||||
const { setPlatformLiveState } = require("./platform-live-state");
|
const { setPlatformLiveState } = require("./platform-live-state");
|
||||||
const { userAgeStatistics } = require("./user-age-statistics");
|
|
||||||
|
|
||||||
const youtubeLog = createLogger("platform:youtube", { category: "integration" });
|
const youtubeLog = createLogger("platform:youtube", { category: "integration" });
|
||||||
|
|
||||||
@ -120,41 +119,14 @@ async function handleChatItem(state, liveChatId, item) {
|
|||||||
if (!snippet || !author) {
|
if (!snippet || !author) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const messageText = snippet.displayMessage || "";
|
const messageText = snippet.displayMessage;
|
||||||
|
if (!messageText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const displayName = author.displayName || "YouTube User";
|
const displayName = author.displayName || "YouTube User";
|
||||||
const avatar = author.profileImageUrl || null;
|
const avatar = author.profileImageUrl || null;
|
||||||
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
|
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
|
||||||
emitYouTubeEvent(item, state, liveChatId, displayName);
|
emitYouTubeEvent(item, state, liveChatId, displayName);
|
||||||
if (!isSelf && state.channelId && author.channelId) {
|
|
||||||
const observedAt = Date.parse(snippet.publishedAt) || Date.now();
|
|
||||||
const membershipStart = ["newSponsorEvent", "giftMembershipReceivedEvent"].includes(snippet.type);
|
|
||||||
userAgeStatistics.setState({
|
|
||||||
platform: "youtube",
|
|
||||||
userId: author.channelId,
|
|
||||||
scopeId: state.channelId,
|
|
||||||
type: "subscriber",
|
|
||||||
active: Boolean(author.isChatSponsor || membershipStart),
|
|
||||||
startAt: membershipStart ? observedAt : undefined,
|
|
||||||
occurredAt: observedAt,
|
|
||||||
eventId: `youtube-chat:${item.id}:subscriber`,
|
|
||||||
source: membershipStart ? "youtube-membership-event" : "youtube-live-chat-author",
|
|
||||||
precision: "millisecond",
|
|
||||||
authoritative: membershipStart
|
|
||||||
});
|
|
||||||
userAgeStatistics.setState({
|
|
||||||
platform: "youtube",
|
|
||||||
userId: author.channelId,
|
|
||||||
scopeId: state.channelId,
|
|
||||||
type: "moderator",
|
|
||||||
active: Boolean(author.isChatModerator),
|
|
||||||
occurredAt: observedAt,
|
|
||||||
eventId: `youtube-chat:${item.id}:moderator`,
|
|
||||||
source: "youtube-live-chat-author",
|
|
||||||
precision: "millisecond",
|
|
||||||
authoritative: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!messageText) return;
|
|
||||||
const profile = isSelf ? null : ensureUserForIdentity({
|
const profile = isSelf ? null : ensureUserForIdentity({
|
||||||
provider: "youtube",
|
provider: "youtube",
|
||||||
providerUserId: author.channelId,
|
providerUserId: author.channelId,
|
||||||
|
|||||||
@ -279,7 +279,6 @@
|
|||||||
media.loop = true;
|
media.loop = true;
|
||||||
media.muted = true;
|
media.muted = true;
|
||||||
media.playsInline = true;
|
media.playsInline = true;
|
||||||
if (mediaData.preview) media.poster = mediaData.preview;
|
|
||||||
} else media.alt = mediaData.alt || "Animated image";
|
} else media.alt = mediaData.alt || "Animated image";
|
||||||
media.addEventListener("error", () => media.remove(), { once: true });
|
media.addEventListener("error", () => media.remove(), { once: true });
|
||||||
mediaRow.appendChild(media);
|
mediaRow.appendChild(media);
|
||||||
|
|||||||
@ -1784,35 +1784,11 @@ body.stats-compare-mode .stats-compare {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
display: block;
|
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: "Space Grotesk", sans-serif;
|
font-family: "Space Grotesk", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-period-label {
|
|
||||||
display: block;
|
|
||||||
margin-top: 10px;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-period-total-label {
|
|
||||||
margin-top: 14px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-period-total {
|
|
||||||
display: block;
|
|
||||||
margin-top: 2px;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@ -80,11 +80,6 @@ const { getClient: getTwitchClient } = require("../services/twitch");
|
|||||||
const { twitchEventSubManager } = require("../services/twitch-eventsub");
|
const { twitchEventSubManager } = require("../services/twitch-eventsub");
|
||||||
const { eventHooksApi } = require("../services/overlay-event-hooks");
|
const { eventHooksApi } = require("../services/overlay-event-hooks");
|
||||||
const { streamTestingService } = require("../services/stream-testing");
|
const { streamTestingService } = require("../services/stream-testing");
|
||||||
const { userAgeStatistics } = require("../services/user-age-statistics");
|
|
||||||
const {
|
|
||||||
getFavoriteCommand,
|
|
||||||
getFavoriteExpression
|
|
||||||
} = require("../services/user-favorites");
|
|
||||||
const {
|
const {
|
||||||
getReverseProxyIngestSettings,
|
getReverseProxyIngestSettings,
|
||||||
saveReverseProxyIngestSettings
|
saveReverseProxyIngestSettings
|
||||||
@ -379,13 +374,12 @@ function getExpressionUserSummary(userId) {
|
|||||||
},
|
},
|
||||||
{ given: 0, received: 0 }
|
{ given: 0, received: 0 }
|
||||||
);
|
);
|
||||||
const favorite = getFavoriteExpression(db, userId);
|
return { totals };
|
||||||
return { totals, favorite };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUserStatsPayload(userId) {
|
function buildUserStatsPayload(userId) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { stats: null, expression: null, favoriteCommand: null, tenureStats: [], pluginStats: [] };
|
return { stats: null, expression: null, pluginStats: [] };
|
||||||
}
|
}
|
||||||
const stats = db
|
const stats = db
|
||||||
.prepare("SELECT * FROM stats WHERE user_id = ?")
|
.prepare("SELECT * FROM stats WHERE user_id = ?")
|
||||||
@ -393,8 +387,6 @@ function buildUserStatsPayload(userId) {
|
|||||||
return {
|
return {
|
||||||
stats,
|
stats,
|
||||||
expression: getExpressionUserSummary(userId),
|
expression: getExpressionUserSummary(userId),
|
||||||
favoriteCommand: getFavoriteCommand(db, userId, getSetting("command_prefix", "!")),
|
|
||||||
tenureStats: userAgeStatistics.getProfileStatistics(userId),
|
|
||||||
pluginStats: getPluginProfileStats(userId)
|
pluginStats: getPluginProfileStats(userId)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -433,20 +425,6 @@ function buildCompareRows(leftStats, rightStats) {
|
|||||||
];
|
];
|
||||||
pushSection("Community Interaction", leftCommunity, rightCommunity);
|
pushSection("Community Interaction", leftCommunity, rightCommunity);
|
||||||
|
|
||||||
if (leftStats.tenureStats?.length || rightStats.tenureStats?.length) {
|
|
||||||
pushSection(
|
|
||||||
"Platform tenure",
|
|
||||||
(leftStats.tenureStats || []).flatMap((entry) => [
|
|
||||||
{ label: `${entry.label} · Current period`, value: entry.current || "Not currently active" },
|
|
||||||
{ label: `${entry.label} · Recorded total`, value: entry.total }
|
|
||||||
]),
|
|
||||||
(rightStats.tenureStats || []).flatMap((entry) => [
|
|
||||||
{ label: `${entry.label} · Current period`, value: entry.current || "Not currently active" },
|
|
||||||
{ label: `${entry.label} · Recorded total`, value: entry.total }
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leftStats.expression || rightStats.expression) {
|
if (leftStats.expression || rightStats.expression) {
|
||||||
const leftExpression = leftStats.expression
|
const leftExpression = leftStats.expression
|
||||||
? [
|
? [
|
||||||
@ -3135,7 +3113,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
// network. Public clients remain unable to forge forwarding headers.
|
// network. Public clients remain unable to forge forwarding headers.
|
||||||
app.set("trust proxy", isTrustedProxyAddress);
|
app.set("trust proxy", isTrustedProxyAddress);
|
||||||
const webhooks = createWebhookService();
|
const webhooks = createWebhookService();
|
||||||
placeholders.registerCorePlaceholders({ userAgeStatistics });
|
placeholders.registerCorePlaceholders();
|
||||||
placeholders.registerPlatformPlaceholders({
|
placeholders.registerPlatformPlaceholders({
|
||||||
discordClient,
|
discordClient,
|
||||||
getTwitchClient,
|
getTwitchClient,
|
||||||
@ -5302,8 +5280,6 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
title: "Your stats",
|
title: "Your stats",
|
||||||
stats: payload.stats,
|
stats: payload.stats,
|
||||||
expression: payload.expression,
|
expression: payload.expression,
|
||||||
favoriteCommand: payload.favoriteCommand,
|
|
||||||
tenureStats: payload.tenureStats,
|
|
||||||
pluginStats: payload.pluginStats,
|
pluginStats: payload.pluginStats,
|
||||||
statsOwner: {
|
statsOwner: {
|
||||||
username: req.session.user.username,
|
username: req.session.user.username,
|
||||||
@ -5348,8 +5324,6 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
title: `${profile.internal_username}'s stats`,
|
title: `${profile.internal_username}'s stats`,
|
||||||
stats: payload.stats,
|
stats: payload.stats,
|
||||||
expression: payload.expression,
|
expression: payload.expression,
|
||||||
favoriteCommand: payload.favoriteCommand,
|
|
||||||
tenureStats: payload.tenureStats,
|
|
||||||
pluginStats: payload.pluginStats,
|
pluginStats: payload.pluginStats,
|
||||||
statsOwner: {
|
statsOwner: {
|
||||||
username: profile.internal_username,
|
username: profile.internal_username,
|
||||||
@ -6238,21 +6212,6 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
renderDiagnosticsAdmin(req, res);
|
renderDiagnosticsAdmin(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/admin/platform-tenure", requireRole("admin"), (req, res) => {
|
|
||||||
const filters = {
|
|
||||||
platform: String(req.query.platform || "").trim().toLowerCase(),
|
|
||||||
scopeId: String(req.query.scope || "").trim(),
|
|
||||||
userId: String(req.query.user || "").trim(),
|
|
||||||
type: String(req.query.type || "").trim().toLowerCase()
|
|
||||||
};
|
|
||||||
res.render("admin-platform-tenure", {
|
|
||||||
title: "Platform tenure diagnostics",
|
|
||||||
intervals: userAgeStatistics.listDiagnostics(filters),
|
|
||||||
statistics: userAgeStatistics.listStatistics(),
|
|
||||||
filters
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/admin/stream-testing", requireRole("admin"), (req, res) => {
|
app.get("/admin/stream-testing", requireRole("admin"), (req, res) => {
|
||||||
res.set("Cache-Control", "no-store");
|
res.set("Cache-Control", "no-store");
|
||||||
res.render("admin-stream-testing", {
|
res.render("admin-stream-testing", {
|
||||||
@ -8010,7 +7969,6 @@ function collectNavItems(user, pluginNav, currentPath) {
|
|||||||
},
|
},
|
||||||
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
|
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
|
||||||
{ label: "Stream testing", path: "/admin/stream-testing", role: "admin", section: "admin" },
|
{ label: "Stream testing", path: "/admin/stream-testing", role: "admin", section: "admin" },
|
||||||
{ label: "Platform tenure", path: "/admin/platform-tenure", role: "admin", section: "admin" },
|
|
||||||
{ label: "Diagnostics", path: "/admin/diagnostics", role: "admin", section: "admin" },
|
{ label: "Diagnostics", path: "/admin/diagnostics", role: "admin", section: "admin" },
|
||||||
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
|
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
|
||||||
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
|
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
|
||||||
@ -8279,7 +8237,6 @@ function getDefaultNavIcon(item) {
|
|||||||
if (pathName === "/admin/theming") return "theming";
|
if (pathName === "/admin/theming") return "theming";
|
||||||
if (pathName === "/admin/privileges") return "privileges";
|
if (pathName === "/admin/privileges") return "privileges";
|
||||||
if (pathName === "/admin/diagnostics") return "admin";
|
if (pathName === "/admin/diagnostics") return "admin";
|
||||||
if (pathName === "/admin/platform-tenure") return "admin";
|
|
||||||
if (pathName === "/admin/logs") return "logs";
|
if (pathName === "/admin/logs") return "logs";
|
||||||
if (pathName === "/admin/updates") return "updates";
|
if (pathName === "/admin/updates") return "updates";
|
||||||
if (pathName === "/admin/commands") return "commands";
|
if (pathName === "/admin/commands") return "commands";
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
<%- include("partials/layout-top", { title }) %>
|
|
||||||
<section class="card">
|
|
||||||
<%- include("partials/page-header", {
|
|
||||||
eyebrow: "Diagnostics",
|
|
||||||
pageTitle: "Platform tenure",
|
|
||||||
description: "Inspect the authoritative and first-observed intervals behind age placeholders, profile stats, and leaderboards."
|
|
||||||
}) %>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<details>
|
|
||||||
<summary>Filter intervals</summary>
|
|
||||||
<form method="get" action="/admin/platform-tenure" class="form-grid">
|
|
||||||
<label>
|
|
||||||
Platform
|
|
||||||
<select name="platform">
|
|
||||||
<option value="">All platforms</option>
|
|
||||||
<% ["twitch", "youtube", "discord"].forEach((platform) => { %>
|
|
||||||
<option value="<%= platform %>" <%= filters.platform === platform ? "selected" : "" %>><%= platform %></option>
|
|
||||||
<% }) %>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Statistic
|
|
||||||
<select name="type">
|
|
||||||
<option value="">All statistics</option>
|
|
||||||
<% [...new Set(statistics.map((entry) => entry.type))].forEach((type) => { %>
|
|
||||||
<option value="<%= type %>" <%= filters.type === type ? "selected" : "" %>><%= type %></option>
|
|
||||||
<% }) %>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Scope ID
|
|
||||||
<input name="scope" value="<%= filters.scopeId %>" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Platform user ID
|
|
||||||
<input name="user" value="<%= filters.userId %>" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="button" type="submit">Apply filters</button>
|
|
||||||
<a class="button subtle" href="/admin/platform-tenure">Clear</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<div class="stats-header">
|
|
||||||
<h2>Recorded intervals</h2>
|
|
||||||
<span class="pill"><%= intervals.length %> shown</span>
|
|
||||||
</div>
|
|
||||||
<% if (!intervals.length) { %>
|
|
||||||
<p>No matching intervals have been recorded.</p>
|
|
||||||
<% } else { %>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>State</th>
|
|
||||||
<th>Platform / statistic</th>
|
|
||||||
<th>User / scope</th>
|
|
||||||
<th>Start</th>
|
|
||||||
<th>End</th>
|
|
||||||
<th>Timestamp provenance</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% intervals.forEach((interval) => { %>
|
|
||||||
<tr>
|
|
||||||
<td><span class="pill <%= interval.end_at === null ? "allowed" : "" %>"><%= interval.end_at === null ? "Current" : "Previous" %></span></td>
|
|
||||||
<td><strong><%= interval.platform %></strong><br /><span class="help"><%= interval.statistic_type %></span></td>
|
|
||||||
<td><code><%= interval.platform_user_id %></code><br /><span class="help">Scope <code><%= interval.scope_id %></code></span></td>
|
|
||||||
<td><time datetime="<%= new Date(interval.start_at).toISOString() %>"><%= new Date(interval.start_at).toLocaleString() %></time></td>
|
|
||||||
<td>
|
|
||||||
<% if (interval.end_at === null) { %>
|
|
||||||
Active
|
|
||||||
<% } else { %>
|
|
||||||
<time datetime="<%= new Date(interval.end_at).toISOString() %>"><%= new Date(interval.end_at).toLocaleString() %></time>
|
|
||||||
<% } %>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<%= interval.start_source %>
|
|
||||||
<br />
|
|
||||||
<span class="help"><%= interval.start_authoritative ? "Authoritative" : "First observed" %> · <%= interval.start_precision %></span>
|
|
||||||
<% if (interval.end_at !== null) { %>
|
|
||||||
<br />
|
|
||||||
<span class="help">Ended by <%= interval.end_source || "unknown source" %> · <%= interval.end_authoritative ? "authoritative" : "observed" %></span>
|
|
||||||
<% } %>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% }) %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
</section>
|
|
||||||
<%- include("partials/layout-bottom") %>
|
|
||||||
@ -40,7 +40,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<% if (rowType === "user" && entry.username) { %>
|
<% if (rowType === "user" && entry.username) { %>
|
||||||
<a class="link" href="/stats/<%= encodeURIComponent(entry.username) %>"><%= entryLabel %></a>
|
<a class="link" href="/stats/<%= encodeURIComponent(entry.username) %>"><%= entry.username %></a>
|
||||||
<% } else if (rowType === "command") { %>
|
<% } else if (rowType === "command") { %>
|
||||||
<% if (entry.href) { %>
|
<% if (entry.href) { %>
|
||||||
<a class="link" href="<%= entry.href %>"><code><%= entryLabel %></code></a>
|
<a class="link" href="<%= entry.href %>"><code><%= entryLabel %></code></a>
|
||||||
|
|||||||
@ -35,47 +35,6 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<% if (favoriteCommand || (expression && expression.favorite)) { %>
|
|
||||||
<section class="card">
|
|
||||||
<h2>Community favorites</h2>
|
|
||||||
<div class="stat-grid">
|
|
||||||
<% if (favoriteCommand) { %>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Favorite command</span>
|
|
||||||
<span class="stat-value"><code><%= favoriteCommand.command %></code></span>
|
|
||||||
<span class="help"><%= favoriteCommand.count %> recorded use<%= favoriteCommand.count === 1 ? "" : "s" %></span>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
<% if (expression && expression.favorite) { %>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Favorite expression</span>
|
|
||||||
<span class="stat-value"><%= expression.favorite.action %></span>
|
|
||||||
<span class="help"><%= expression.favorite.count %> given or received interaction<%= expression.favorite.count === 1 ? "" : "s" %></span>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Platform tenure</h2>
|
|
||||||
<% if (!tenureStats || !tenureStats.length) { %>
|
|
||||||
<p>Follow, membership, role, and server tenure will appear here as Lumi observes it.</p>
|
|
||||||
<% } else { %>
|
|
||||||
<div class="stat-grid">
|
|
||||||
<% tenureStats.forEach((stat) => { %>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label"><%= stat.label %></span>
|
|
||||||
<span class="stat-period-label">Current period</span>
|
|
||||||
<span class="stat-value"><%= stat.current || "Not currently active" %></span>
|
|
||||||
<span class="stat-period-label stat-period-total-label">Recorded total</span>
|
|
||||||
<span class="stat-period-total"><%= stat.total %></span>
|
|
||||||
</div>
|
|
||||||
<% }) %>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Expression Interaction</h2>
|
<h2>Expression Interaction</h2>
|
||||||
<% if (!expression) { %>
|
<% if (!expression) { %>
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "Lumi Core",
|
"name": "Lumi Core",
|
||||||
"version": "0.3.11",
|
"version": "0.3.8",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"released_at": "2026-07-29",
|
"released_at": "2026-07-26",
|
||||||
"compatible_from": "0.1.9",
|
"compatible_from": "0.1.9",
|
||||||
"migration_kind": "patch",
|
"migration_kind": "patch",
|
||||||
"replaces_versions": [
|
"replaces_versions": [
|
||||||
"1.2.0"
|
"1.2.0"
|
||||||
],
|
],
|
||||||
"migration_notes": "Releases Companion 0.2.8 with captured hotkeys, multi-source Windows media discovery, and an interactive native overlay editor; improves tenure presentation and adds per-user command and expression favorites. The additive command-usage table preserves all existing settings, identities, statistics, intervals, plugins, pairing records, databases, uploads, models, and secrets.",
|
"migration_notes": "Adds the native Windows Lumi Overlay through the existing paired-device and provider pipelines, preserves active and revoked device semantics, and releases Companion 0.2.6 with draft-safe settings, direct single-page plugin navigation, and clearer overlay controls. Existing settings, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.",
|
||||||
"rollback_safe": true,
|
"rollback_safe": true,
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"Node.js 18 or newer"
|
"Node.js 18 or newer"
|
||||||
@ -445,42 +445,6 @@
|
|||||||
],
|
],
|
||||||
"rollback_safe": true,
|
"rollback_safe": true,
|
||||||
"migration_notes": "Moves production Stream Testing TLS termination to Nginx Proxy Manager while preserving RTMPS Companion destinations, plain local MediaMTX ingest, localhost development, session credentials, and all existing operator data. Adds DB-backed public and local ports plus external TLS and RTMP route validation, and removes unused Lumi ACME and DNS automation without deleting stored data."
|
"migration_notes": "Moves production Stream Testing TLS termination to Nginx Proxy Manager while preserving RTMPS Companion destinations, plain local MediaMTX ingest, localhost development, session credentials, and all existing operator data. Adds DB-backed public and local ports plus external TLS and RTMP route validation, and removes unused Lumi ACME and DNS automation without deleting stored data."
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.3.8",
|
|
||||||
"channel": "stable",
|
|
||||||
"released_at": "2026-07-26",
|
|
||||||
"compatible_from": "0.1.9",
|
|
||||||
"migration_kind": "patch",
|
|
||||||
"replaces_versions": [
|
|
||||||
"1.2.0"
|
|
||||||
],
|
|
||||||
"rollback_safe": true,
|
|
||||||
"migration_notes": "Adds the native Windows Lumi Overlay through the existing paired-device and provider pipelines, preserves active and revoked device semantics, and releases Companion 0.2.6 with draft-safe settings, direct single-page plugin navigation, and clearer overlay controls. Existing settings, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.3.9",
|
|
||||||
"channel": "stable",
|
|
||||||
"released_at": "2026-07-26",
|
|
||||||
"compatible_from": "0.1.9",
|
|
||||||
"migration_kind": "patch",
|
|
||||||
"replaces_versions": [
|
|
||||||
"1.2.0"
|
|
||||||
],
|
|
||||||
"rollback_safe": true,
|
|
||||||
"migration_notes": "Restores animated emotes, Twitch badge artwork, and Discord GIF media through the existing OBS and native Companion overlay paths; adds persistent Auto VC welcome controls and timed ownership claiming; and releases Companion 0.2.7. Existing settings, lobbies, rooms, owners, permissions, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.3.10",
|
|
||||||
"channel": "stable",
|
|
||||||
"released_at": "2026-07-27",
|
|
||||||
"compatible_from": "0.1.9",
|
|
||||||
"migration_kind": "patch",
|
|
||||||
"replaces_versions": [
|
|
||||||
"1.2.0"
|
|
||||||
],
|
|
||||||
"rollback_safe": true,
|
|
||||||
"migration_notes": "Adds reusable platform-tenure interval history for Twitch, YouTube, and Discord; contextual current and total duration placeholders with deterministic formatting; safe platform reconciliation; admin diagnostics; and Platform tenure sections on Stats and Leaderboards. Existing settings, identities, statistics, intervals, plugins, pairing records, databases, uploads, models, and secrets remain preserved."
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user