Compare commits

..

2 Commits
v0.3.9 ... main

Author SHA1 Message Date
Franz Rolfsvaag
5f05251155 release: publish Lumi 0.3.11 companion experience 2026-07-29 23:41:34 +02:00
Franz Rolfsvaag
0fb0089e1c release: publish Lumi 0.3.10 platform tenure 2026-07-27 09:42:58 +02:00
58 changed files with 2234 additions and 138 deletions

3
.gitignore vendored
View File

@ -34,4 +34,5 @@ twitch-credentials-lumi.png
DEVNOTES.md
.QWEN.md
_*_changes.md
lumi_current.zip
Lumi-current.zip
agent-task*.md

View File

@ -1,5 +1,20 @@
# 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.

View File

@ -25,6 +25,21 @@ DPAPI-protected device identity, so reinstalling does not create a duplicate
paired device. Pairing packages expire after 15 minutes, work once, and must not
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
- Companion plugins inherit the shell's paired-device authentication. Plugins do

View File

@ -1,6 +1,6 @@
# Song Overlay Companion integration
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`.
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.
## Navigation contract
@ -13,8 +13,8 @@ Plugin actions are caught at the shell boundary so a routine plugin exception do
## Song Overlay flow
1. Windows Global System Media Transport Controls exposes Spotify playback state.
2. The provider raises media, playback, timeline, and session-availability events.
1. Windows Global System Media Transport Controls exposes playback from enabled desktop and browser sources.
2. The provider identifies the source where Windows exposes enough app or metadata context, then 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.
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.

View File

@ -1,5 +1,5 @@
#ifndef AppVersion
#define AppVersion "0.2.7"
#define AppVersion "0.2.8"
#endif
#ifndef SourceRoot
#error SourceRoot must point at the self-contained Companion publish directory.

View File

@ -56,7 +56,7 @@ public sealed class GlobalHotkeyManager : IDisposable
}
}
private static bool TryParse(string chord, out uint modifiers, out uint key)
internal static bool TryParse(string chord, out uint modifiers, out uint key)
{
modifiers = 0;
key = 0;
@ -69,11 +69,21 @@ public sealed class GlobalHotkeyManager : IDisposable
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.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;
}
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()
{
Clear();

View File

@ -30,6 +30,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
_transport = transport;
_window = new NativeOverlayWindow();
_window.NativeStatusChanged += () => RaiseChanged();
_window.EditSettingsChanged += () => RaiseChanged();
_hotkeys = new GlobalHotkeyManager();
Actions =
[
@ -38,7 +39,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
];
}
public CompanionPluginDescriptor Descriptor { get; } = new(PluginId, "Lumi Overlay", new Version(0, 1, 0),
public CompanionPluginDescriptor Descriptor { get; } = new(PluginId, "Lumi Overlay", new Version(0, 1, 2),
"Native, capture-excluded monitor overlay for Lumi chat and stream events.", 150);
public IReadOnlyList<CompanionPluginPage> Pages { get; } = [new("LumiOverlay", "Overview & settings", 10)];
public IReadOnlyList<CompanionPluginAction> Actions { get; }
@ -51,6 +52,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
public string? MonitorWarning => _window.MonitorWarning;
public IReadOnlyList<(string Id, string Label)> Monitors => _window.Monitors();
public bool PreviewActive => _preview;
public bool EditModeActive { get; private set; }
public bool VisibilityOverride => _visibilityOverride.HasValue;
public bool IsInitialized => _initialized;
public string HotkeyStatus => _hotkeyStatus;
@ -113,6 +115,23 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
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)
{
_channel = channel;
@ -197,7 +216,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
private void ApplyVisibility()
{
_window.SetRequestedVisible(OverlayVisibilityPolicy.Effective(Settings, Sources, _preview, _visibilityOverride));
_window.SetRequestedVisible(EditModeActive || OverlayVisibilityPolicy.Effective(Settings, Sources, _preview, _visibilityOverride));
}
private bool AutomaticVisibility() => OverlayVisibilityPolicy.Automatic(Settings, Sources);

View File

@ -5,6 +5,7 @@ using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
@ -37,6 +38,9 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
private readonly StackPanel _unifiedPanel = new() { Spacing = 8 };
private readonly StackPanel _chatPanel = 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 DispatcherTimer _timer;
private readonly HttpsImageCache _images = new();
@ -46,6 +50,12 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
private bool _disposed;
private bool _visibleRequested;
private string? _temporaryMonitorWarning;
private bool _editing;
private Border? _dragEditor;
private OverlayContainerSettings? _dragSettings;
private Point _dragOrigin;
private PhysicalRect _dragRect;
private bool _resizing;
public NativeOverlayWindow()
{
@ -65,6 +75,12 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
_surface.Children.Add(_unifiedHolder);
_surface.Children.Add(_chatHolder);
_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"));
_events = new(() => ContainerFor("event"));
_chat.Changed += Render;
@ -85,6 +101,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
public CaptureExclusionStatus CaptureExclusion { get; private set; } = new(CaptureExclusionState.Pending, "Waiting for the native overlay window.");
public string? MonitorWarning => _temporaryMonitorWarning;
public event Action? NativeStatusChanged;
public event Action? EditSettingsChanged;
public void ApplySettings(LumiOverlaySettings settings)
{
@ -108,6 +125,35 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
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 AddEvent(OverlayRenderedEvent value) => _events.Add(value.Id, "event", value);
public void Clear() { _chat.Clear(); _events.Clear(); }
@ -161,6 +207,118 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
}
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);
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,
@ -471,7 +629,9 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
Height = screen.Bounds.Height / screen.Scaling;
var hwnd = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
if (hwnd == IntPtr.Zero) return;
var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | WsExTransparent | WsExToolWindow | WsExLayered | WsExNoActivate;
var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | WsExToolWindow | WsExLayered;
if (_editing) style &= ~(WsExTransparent | WsExNoActivate);
else style |= WsExTransparent | WsExNoActivate;
SetWindowLongPtr(hwnd, GwlExStyle, new IntPtr(style));
SetWindowPos(hwnd, HwndTopmost, screen.Bounds.X, screen.Bounds.Y, screen.Bounds.Width, screen.Bounds.Height, SwpNoActivate | SwpFrameChanged);
if (SetWindowDisplayAffinity(hwnd, WdaExcludeFromCapture))

View File

@ -1,7 +1,7 @@
{
"id": "lumi_overlay",
"name": "Lumi Overlay",
"version": "0.1.1",
"version": "0.1.2",
"provider_api": 1,
"capabilities": [
"network.lumi.overlay.read",

View File

@ -0,0 +1,66 @@
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";
}

View File

@ -7,7 +7,7 @@ using Windows.Storage.Streams;
namespace Lumi.Companion.SongOverlay.Providers;
internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
internal sealed class WindowsMediaProvider : IMediaProvider
{
private const int MaxCoverInputBytes = 3 * 1024 * 1024;
private const int MaxCoverOutputBytes = 500 * 1024;
@ -18,18 +18,19 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
private GlobalSystemMediaTransportControlsSession? _session;
private MediaSnapshot? _last;
private MediaTrack? _trackCache;
private string _sourceId = "windows-media";
private bool _started;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
public SpotifyWindowsMediaProvider(Func<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, string, Exception?> log)
public WindowsMediaProvider(Func<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, string, Exception?> log)
{
_settings = settings;
_enrich = enrich;
_log = log;
}
public string Id => "spotify";
public string DisplayName => "Spotify";
public string Id => "windows-media";
public string DisplayName => "Windows media discovery";
public event EventHandler<ProviderStateChangedEventArgs>? StateChanged;
public event EventHandler<string>? AvailabilityChanged;
@ -62,22 +63,22 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args)
{
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("spotify_session_refresh_failed", "Could not refresh Spotify media sessions", error); }
catch (Exception error) { _log("media_session_refresh_failed", "Could not refresh Windows media sessions", error); }
}
private async Task SelectSessionAsync(CancellationToken cancellationToken)
{
var selected = _manager?.GetSessions().FirstOrDefault(IsSpotifySession);
var selected = await SelectEnabledSessionAsync(cancellationToken).ConfigureAwait(false);
if (ReferenceEquals(selected, _session))
{
if (_session is not null) await RefreshAndPublishAsync("snapshot", false, cancellationToken).ConfigureAwait(false);
if (_session is not null) await RefreshAndPublishAsync("media", true, cancellationToken).ConfigureAwait(false);
return;
}
DetachSession();
_session = selected;
if (_session is null)
{
AvailabilityChanged?.Invoke(this, "Spotify is not exposing a Windows media session.");
AvailabilityChanged?.Invoke(this, "No enabled source is exposing a Windows media session.");
if (_last is not null)
{
_last = null;
@ -88,7 +89,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
_session.MediaPropertiesChanged += OnMediaPropertiesChanged;
_session.PlaybackInfoChanged += OnPlaybackInfoChanged;
_session.TimelinePropertiesChanged += OnTimelinePropertiesChanged;
AvailabilityChanged?.Invoke(this, "Spotify media session connected.");
AvailabilityChanged?.Invoke(this, $"{MediaSourceCatalog.Label(_sourceId)} media session connected.");
await RefreshAndPublishAsync("snapshot", true, cancellationToken).ConfigureAwait(false);
}
@ -104,20 +105,20 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
{
try { await RefreshAndPublishAsync("media", true, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("spotify_media_property_update_failed", "Spotify media-property update failed", error); }
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("media_property_update_failed", "Windows media-property update failed", error); }
}
private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args)
{
try { await RefreshAndPublishAsync("playback", false, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("spotify_playback_update_failed", "Spotify playback update failed", error); }
catch (Exception error) { _log("media_playback_update_failed", "Windows media playback update failed", error); }
}
private async void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args)
{
try { await RefreshAndPublishAsync("timeline", false, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("spotify_timeline_update_failed", "Spotify timeline update failed", error); }
catch (Exception error) { _log("media_timeline_update_failed", "Windows media timeline update failed", error); }
}
private async Task RefreshAndPublishAsync(string reason, bool enrichTrack, CancellationToken cancellationToken)
@ -162,16 +163,18 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
var title = Clean(media.Title);
var artist = Clean(media.Artist);
var album = Clean(media.AlbumTitle);
_sourceId = MediaSourceCatalog.DetectEnabled(
_session.SourceAppUserModelId, title, artist, album, _settings().EnabledSources) ?? _sourceId;
if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(artist))
{
var key = Fingerprint(title, artist, album, duration);
var cover = _settings().SendCoverArt ? await ReadCoverAsync(media.Thumbnail, cancellationToken).ConfigureAwait(false) : null;
var fallbackLink = _settings().UseSearchLinkFallback
var fallbackLink = _sourceId == "spotify" && _settings().UseSearchLinkFallback
? "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);
_trackCache = track;
if (enrichTrack) _ = EnrichAndPublishAsync(track);
if (enrichTrack && _sourceId == "spotify") _ = EnrichAndPublishAsync(track);
}
else
{
@ -183,7 +186,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
_trackCache = _trackCache with { DurationMilliseconds = duration };
}
var snapshot = new MediaSnapshot(Id, status, position, duration, rate, _trackCache, capturedAt);
var snapshot = new MediaSnapshot(_sourceId, status, position, duration, rate, _trackCache, capturedAt);
_last = snapshot;
return snapshot;
}
@ -209,10 +212,34 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
catch (Exception error) { _log("spotify_metadata_update_failed", "Spotify metadata enrichment update failed", error); }
}
private static bool IsSpotifySession(GlobalSystemMediaTransportControlsSession session)
private async Task<GlobalSystemMediaTransportControlsSession?> SelectEnabledSessionAsync(CancellationToken cancellationToken)
{
var source = session.SourceAppUserModelId ?? "";
return source.Contains("spotify", StringComparison.OrdinalIgnoreCase);
if (_manager is null) return null;
GlobalSystemMediaTransportControlsSession? fallback = null;
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)

View File

@ -45,7 +45,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
public CompanionPluginDescriptor Descriptor { get; } = new(
PluginId,
"Song Overlay",
new Version(0, 1, 1),
new Version(0, 1, 2),
"Reads provider-neutral Windows media-session events and sends minimal playback changes to Lumi.",
200);
@ -68,7 +68,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await _settingsStore.LoadAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(Settings.ProviderId)) Settings.ProviderId = "spotify";
Settings.Normalize();
// Re-save through the current schema so legacy plugin-specific Lumi host
// and connection-key fields are removed after the shared transport upgrade.
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
@ -79,9 +79,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
public async Task SaveSettingsAsync(bool restartProvider, CancellationToken cancellationToken = default)
{
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();
Settings.Normalize();
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
if (restartProvider) await RestartProviderAsync(cancellationToken).ConfigureAwait(false);
RaiseChanged();
@ -115,7 +113,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
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.");
var snapshot = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
if (snapshot is null) throw new InvalidOperationException("Spotify is not currently exposing playback information.");
if (snapshot is null) throw new InvalidOperationException("None of the enabled media sources is currently exposing playback information.");
await SendAsync("snapshot", snapshot, includeTrack: true, cancellationToken).ConfigureAwait(false);
}
@ -130,11 +128,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
_lastObserved = null;
_lastDelivered = null;
_lifetime = new CancellationTokenSource();
_provider = Settings.ProviderId switch
{
"spotify" => new SpotifyWindowsMediaProvider(() => Settings, EnrichTrackAsync, Log),
_ => throw new InvalidOperationException($"Provider '{Settings.ProviderId}' is not installed.")
};
_provider = new WindowsMediaProvider(() => Settings, EnrichTrackAsync, Log);
_provider.StateChanged += OnProviderStateChanged;
_provider.AvailabilityChanged += OnAvailabilityChanged;
try
@ -143,15 +137,15 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
StartHeartbeat();
var initial = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
if (!_transport.IsConfigured)
SetStatus(CompanionPluginHealth.Warning, "Pairing required", "Spotify monitoring is ready, but Companion must be paired before Lumi can receive updates.");
SetStatus(CompanionPluginHealth.Warning, "Pairing required", "Media monitoring is ready, but Companion must be paired before Lumi can receive updates.");
else if (initial is null)
SetStatus(CompanionPluginHealth.Warning, "Waiting for Spotify", "Spotify is not currently exposing a Windows media session.");
SetStatus(CompanionPluginHealth.Warning, "Waiting for media", "No enabled source is currently exposing a Windows media session.");
else
SetStatus(CompanionPluginHealth.Healthy, "Monitoring", Describe(initial));
}
catch (Exception error)
{
SetStatus(CompanionPluginHealth.Error, "Provider failed", "Could not start the Spotify media provider: " + error.Message);
SetStatus(CompanionPluginHealth.Error, "Provider failed", "Could not start Windows media discovery: " + error.Message);
throw;
}
}
@ -204,9 +198,10 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
SetStatus(CompanionPluginHealth.Warning, "Pairing required", message);
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,
waiting ? "Waiting for Spotify" : "Monitoring", message);
waiting ? "Waiting for media" : "Monitoring", message);
}
private void OnProviderStateChanged(object? sender, ProviderStateChangedEventArgs args) =>
@ -318,7 +313,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
if (enriched is null) return track;
CoverPayload? cover = track.Cover;
if (enriched.CoverBytes is { Length: > 0 } bytes && Settings.SendCoverArt)
cover = SpotifyWindowsMediaProvider.CreateCoverPayload(bytes, enriched.CoverMime) ?? cover;
cover = WindowsMediaProvider.CreateCoverPayload(bytes, enriched.CoverMime) ?? cover;
return track with
{
Link = string.IsNullOrWhiteSpace(enriched.Link) ? track.Link : enriched.Link,

View File

@ -4,10 +4,22 @@ public sealed class SongOverlaySettings
{
public bool Enabled { get; set; } = true;
public string ProviderId { get; set; } = "spotify";
public HashSet<string> EnabledSources { get; set; } = MediaSourceCatalog.Defaults();
public int HeartbeatSeconds { get; set; } = 30;
public int SeekThresholdMilliseconds { get; set; } = 1500;
public bool SendCoverArt { get; set; } = true;
public bool UseSearchLinkFallback { get; set; } = true;
public string SpotifyClientId { 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);
}
}

View File

@ -3,7 +3,7 @@
"name": "Song Overlay",
"version": "0.1.2",
"provider_api": 1,
"providers": ["spotify"],
"providers": ["windows-media", "spotify", "wmp", "youtube", "vlc", "apple-music", "soundcloud", "tidal", "bandcamp", "qobuz"],
"capabilities": [
"windows.media-session.read",
"network.lumi",

View File

@ -1,5 +1,5 @@
param(
[string]$Version = "0.2.7",
[string]$Version = "0.2.8",
[string]$BridgeVersion = "0.2.5",
[string]$ObsVersion = "31.1.1"
)

View File

@ -1,12 +1,17 @@
$ErrorActionPreference = 'Stop'
$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
try {
Write-Host 'Building Lumi Companion and all registered Companion plugins...'
dotnet build 'companion/Lumi.Companion.sln' -c Release -p:EnableWindowsTargeting=true
& $dotnet build 'companion/Lumi.Companion.sln' -c Release
if ($LASTEXITCODE) { throw 'Lumi Companion build failed.' }
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
}

View File

@ -0,0 +1,134 @@
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()
};
}

View File

@ -6,8 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
<Version>0.2.7</Version>
<AssemblyVersion>0.2.7.0</AssemblyVersion>
<Version>0.2.8</Version>
<AssemblyVersion>0.2.8.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />

View File

@ -252,7 +252,7 @@
<StackPanel Spacing="6">
<TextBlock Text="MEDIA" Classes="eyebrow" />
<TextBlock Text="Song Overlay" Classes="pageTitle" />
<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" />
<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" />
</StackPanel>
<Border Classes="soft">
@ -277,16 +277,16 @@
</StackPanel>
<StackPanel Spacing="12">
<TextBlock Text="Playback provider" Classes="sectionTitle" />
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="10" ColumnSpacing="12">
<TextBlock Text="Provider" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Column="1" x:Name="SongOverlayProviderPicker" SelectedIndex="0" IsEnabled="False">
<ComboBoxItem Content="Spotify" Tag="spotify" />
</ComboBox>
<TextBlock Grid.Row="1" Text="Recovery heartbeat" Classes="muted" VerticalAlignment="Center" />
<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="2" Text="Seek sensitivity" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="2" Grid.Column="1" x:Name="SongOverlaySeekBox" Minimum="500" Maximum="10000" Increment="250" FormatString="0 ms" HorizontalAlignment="Left" Width="160" />
<TextBlock Text="Playback sources" 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" />
<Border Classes="soft">
<StackPanel x:Name="SongOverlaySourcesPanel" Spacing="9" />
</Border>
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto" RowSpacing="10" ColumnSpacing="12">
<TextBlock 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" />
<TextBlock Grid.Row="1" 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" />
</Grid>
<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" />
@ -383,6 +383,18 @@
</Grid>
<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 &amp; lifecycle">
<Border Classes="card" Margin="0,10,0,0">
<StackPanel Spacing="14">
<TextBlock Text="Container styling &amp; lifecycle" Classes="sectionTitle" />
<ComboBox x:Name="LumiOverlayContainerPicker" HorizontalAlignment="Left" Width="240">
@ -465,15 +477,16 @@
<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>
</Border>
</Expander>
<Border Classes="soft">
<StackPanel Spacing="12">
<TextBlock Text="Global hotkeys" Classes="sectionTitle" />
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto" RowSpacing="10">
<TextBlock Text="Visibility override" Classes="muted" />
<TextBox Grid.Column="1" x:Name="LumiOverlayVisibilityHotkeyBox" PlaceholderText="Ctrl+Alt+L" />
<Button Grid.Column="1" x:Name="LumiOverlayVisibilityHotkeyBox" Classes="secondary" />
<TextBlock Grid.Row="1" Text="Preview override" Classes="muted" />
<TextBox Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayPreviewHotkeyBox" PlaceholderText="Ctrl+Alt+P" />
<Button Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayPreviewHotkeyBox" Classes="secondary" />
</Grid>
<TextBlock x:Name="LumiOverlayHotkeyStatus" Text="Hotkeys are not registered yet." Classes="muted" FontSize="12" TextWrapping="Wrap" />
</StackPanel>

View File

@ -26,8 +26,12 @@ public partial class MainWindow : Window
private bool _renderingSettings;
private readonly Dictionary<string, CheckBox> _overlaySourceChecks = [];
private readonly Dictionary<string, CheckBox> _overlayEventChecks = [];
private readonly Dictionary<string, CheckBox> _songOverlaySourceChecks = [];
private bool _songOverlaySourcesDirty;
private string _editingOverlayContainer = "unified";
private LumiOverlaySettings? _lumiOverlayDraft;
private readonly HotkeyCaptureBinding _visibilityHotkeyCapture;
private readonly HotkeyCaptureBinding _previewHotkeyCapture;
public MainWindow() : this(CreateDefaultServices()) { }
@ -42,6 +46,8 @@ public partial class MainWindow : Window
_lumiOverlay = lumiOverlay;
_plugins = plugins;
InitializeComponent();
_visibilityHotkeyCapture = new HotkeyCaptureBinding(LumiOverlayVisibilityHotkeyBox);
_previewHotkeyCapture = new HotkeyCaptureBinding(LumiOverlayPreviewHotkeyBox);
BuildPluginNavigation();
WireActions();
RenderState(runtime.State);
@ -172,6 +178,7 @@ public partial class MainWindow : Window
TranscriptionEnabledToggle.IsCheckedChanged += async (_, _) => await SaveTranscriptionEnabledAsync();
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
SaveLumiOverlayButton.Click += async (_, _) => await SaveLumiOverlayAsync();
LumiOverlayEditButton.Click += (_, _) => ToggleLumiOverlayEditMode();
LumiOverlayPreviewButton.Click += (_, _) => { _lumiOverlay.TogglePreview(); RenderLumiOverlay(); };
LumiOverlayVisibilityButton.Click += (_, _) => { _lumiOverlay.ToggleVisibilityOverride(); RenderLumiOverlay(); };
LumiOverlayContainerPicker.SelectionChanged += (_, _) =>
@ -213,6 +220,11 @@ public partial class MainWindow : Window
public void ShowPage(CompanionPage page)
{
if (page != CompanionPage.LumiOverlay && _lumiOverlay.EditModeActive)
{
_lumiOverlay.EndEdit();
RenderLumiOverlay(preserveDraft: false);
}
var pages = new Dictionary<CompanionPage, Control>
{
[CompanionPage.Overview] = OverviewPage,
@ -294,6 +306,10 @@ public partial class MainWindow : Window
{
var settings = _songOverlay.Settings;
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.HeartbeatSeconds = Decimal.ToInt32(SongOverlayHeartbeatBox.Value ?? 30);
settings.SeekThresholdMilliseconds = Decimal.ToInt32(SongOverlaySeekBox.Value ?? 1500);
@ -301,6 +317,7 @@ public partial class MainWindow : Window
settings.UseSearchLinkFallback = SongOverlaySearchLinkToggle.IsChecked == true;
settings.SpotifyClientId = SongOverlaySpotifyClientIdBox.Text?.Trim() ?? "";
await _songOverlay.SaveSettingsAsync(restartProvider);
_songOverlaySourcesDirty = false;
SongOverlayFeedback.Text = "Song Overlay settings saved.";
}
catch (Exception error)
@ -586,7 +603,7 @@ public partial class MainWindow : Window
if (!_lumiOverlay.IsInitialized) return;
if (_lumiOverlayDraft is null)
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
else if (preserveDraft && !_renderingLumiOverlay)
else if (preserveDraft && !_renderingLumiOverlay && !_lumiOverlay.EditModeActive)
CaptureLumiOverlayForm(_lumiOverlayDraft);
_renderingLumiOverlay = true;
try
@ -611,9 +628,13 @@ public partial class MainWindow : Window
LumiOverlayVisibilityState.Text = $"Current display: {visibility.State}";
LumiOverlayVisibilityButton.Content = visibility.Action;
LumiOverlayVisibilityButton.IsEnabled = visibility.CanToggle;
LumiOverlayVisibilityHotkeyBox.Text = settings.ToggleVisibilityHotkey;
LumiOverlayPreviewHotkeyBox.Text = settings.TogglePreviewHotkey;
_visibilityHotkeyCapture.Hotkey = settings.ToggleVisibilityHotkey;
_previewHotkeyCapture.Hotkey = settings.TogglePreviewHotkey;
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();
if (settings.MonitorId is { Length: > 0 } savedMonitor &&
@ -756,8 +777,8 @@ public partial class MainWindow : Window
settings.Layout = layout.Value;
settings.ChatPercentage = Decimal.ToInt32(LumiOverlayChatPercentageBox.Value ?? settings.ChatPercentage);
if (LumiOverlayMonitorPicker.SelectedItem is OverlayPickerItem monitor) settings.MonitorId = monitor.Id;
settings.ToggleVisibilityHotkey = LumiOverlayVisibilityHotkeyBox.Text?.Trim() ?? "";
settings.TogglePreviewHotkey = LumiOverlayPreviewHotkeyBox.Text?.Trim() ?? "";
settings.ToggleVisibilityHotkey = _visibilityHotkeyCapture.Hotkey;
settings.TogglePreviewHotkey = _previewHotkeyCapture.Hotkey;
var currentSources = _lumiOverlay.Sources.ToDictionary(source => source.Id, StringComparer.Ordinal);
foreach (var saved in settings.Sources)
@ -821,6 +842,20 @@ public partial class MainWindow : Window
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()
{
if (_renderingLumiOverlay) return;
@ -829,6 +864,11 @@ public partial class MainWindow : Window
try
{
var settings = _lumiOverlayDraft ?? _lumiOverlay.Settings.CreateCopy();
if (_lumiOverlay.EditModeActive)
{
_lumiOverlay.EndEdit();
RenderLumiOverlay(preserveDraft: false);
}
CaptureLumiOverlayForm(settings);
await _lumiOverlay.SaveSettingsAsync(settings);
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
@ -865,6 +905,30 @@ public partial class MainWindow : Window
SongOverlaySeekBox.Value = Math.Clamp(settings.SeekThresholdMilliseconds, 500, 10000);
SongOverlayCoverToggle.IsChecked = settings.SendCoverArt;
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;
SongOverlaySpotifyStatus.Text = _songOverlay.IsSpotifyEnrichmentConnected
? "Connected. Exact links, release year and official artwork can be enriched on song changes."
@ -957,6 +1021,7 @@ public partial class MainWindow : Window
private void OnClosing(object? sender, WindowClosingEventArgs args)
{
if (_allowExit) return;
if (_lumiOverlay.EditModeActive) _lumiOverlay.EndEdit();
args.Cancel = true;
Hide();
}

View File

@ -9,5 +9,6 @@
<ItemGroup>
<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.SongOverlay/Lumi.Companion.SongOverlay.csproj" />
</ItemGroup>
</Project>

View File

@ -1,6 +1,7 @@
using Lumi.Companion.Abstractions;
using Lumi.Companion.Core;
using Lumi.Companion.Overlay;
using Lumi.Companion.SongOverlay;
static void Assert(bool condition, string message)
{
@ -112,6 +113,17 @@ Assert(!CompanionPluginNavigation.UsesNestedNavigation(
CompanionPluginNavigation.UsesNestedNavigation(
[new CompanionPluginPage("Transcription", "Capture"), new CompanionPluginPage("Test", "Test")]),
"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,
"Capture-exclusion unsupported state must remain distinct from failure.");
var visibilitySettings = new LumiOverlaySettings

View File

@ -11,6 +11,10 @@ The frontend must not declare placeholder permissions, allowed plugins, or
sensitivity. Editable fields reference a trusted `field_id`, and the server
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:
- placeholder definitions with metadata and a resolver function

44
docs/platform-tenure.md Normal file
View File

@ -0,0 +1,44 @@
# 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.

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.3.9
Version: 0.3.11
## Routes
- POST /api/diagnostics/v1/run
- GET /api/events
@ -93,6 +93,7 @@ Version: 0.3.9
- POST /admin/theming/custom/:id/delete
- POST /admin/theming
- GET /admin/diagnostics
- GET /admin/platform-tenure
- GET /admin/stream-testing
- POST /admin/stream-testing/reverse-proxy
- POST /admin/stream-testing/reverse-proxy/check
@ -920,6 +921,15 @@ Version: 0.3.9
- Side effects: Usually read-only.
- 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
- Purpose: Renders the admin stream testing WebUI page.

View File

@ -14,7 +14,7 @@ editable: false
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
## Metadata
Plugin ID: lumi_transcription
Version: 0.2.7
Version: 0.2.8
Default state: enabled
## Web Routes
- /plugins/lumi_transcription

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.3.9",
"version": "0.3.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.3.9",
"version": "0.3.11",
"dependencies": {
"adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.3.9",
"version": "0.3.11",
"private": true,
"type": "commonjs",
"scripts": {
@ -26,7 +26,8 @@
"verify:transcription": "node plugins/lumi_transcription/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": {
"node": ">=18"

View File

@ -1,5 +1,10 @@
# 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

View File

@ -1,17 +1,17 @@
{
"schema_version": 1,
"version": "0.2.7",
"version": "0.2.8",
"signed": false,
"release_notes": "Restores animated Twitch, BetterTTV, and Discord emotes, Discord GIF media, and Twitch badge artwork in the native Lumi Overlay while retaining the existing OBS browser renderer, transcription, Stream Testing, Song Overlay, silent reconnect, and OBS Bridge 0.2.5.",
"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.",
"installer": {
"id": "windows-x64-installer",
"platform": "win32",
"architecture": "x64",
"label": "Windows x64 per-user installer",
"filename": "Lumi.Companion-Setup.exe",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.7/Lumi.Companion-Setup.exe",
"sha256": "b23686d0e8ecd0fbc89034f9dc6a37350bee17212d60f385eeaf2e4de634fab6",
"bytes": 51930364
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.8/Lumi.Companion-Setup.exe",
"sha256": "15d632160d29b29773e2ec692ef1061dc6d662a4a80a128ea1674ee97bdeef99",
"bytes": 51926217
},
"artifacts": [
{
@ -20,9 +20,9 @@
"architecture": "x64",
"label": "Windows x64 self-contained",
"filename": "Lumi.Companion-win-x64.zip",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.7/Lumi.Companion-win-x64.zip",
"sha256": "c36983e1f83a9f278475db31819be0acaa6af72d5d03e1a1c5d3ba62b556e81c",
"bytes": 66860208,
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.8/Lumi.Companion-win-x64.zip",
"sha256": "df94e2cbccb54a8f002940f25099572fb92fc3448da2ec463f85446df55fb1a3",
"bytes": 66863322,
"entrypoint": "Lumi.Companion.App.exe"
}
]

View File

@ -1,7 +1,7 @@
{
"id": "lumi_transcription",
"name": "Lumi Transcription",
"version": "0.2.7",
"version": "0.2.8",
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
"main": "index.js",
"channel": "stable",

View File

@ -2,6 +2,72 @@
"schema_version": 1,
"channel": "stable",
"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",

View File

@ -11,6 +11,8 @@ const checks = [
"scripts/verify-feedback-system.js",
"scripts/verify-logging.js",
"scripts/verify-placeholders.js",
"scripts/verify-user-age-statistics.js",
"scripts/verify-user-favorites.js",
"scripts/verify-release-metadata.js",
"scripts/verify-update-system.js",
"scripts/verify-local-development-updates.js",

View File

@ -4,14 +4,14 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.3.9";
const previousStableVersion = "0.3.8";
const priorStableVersion = "0.3.7";
const releaseVersion = "0.3.11";
const previousStableVersion = "0.3.10";
const priorStableVersion = "0.3.9";
const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = {
"auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" },
lumi_overlay: { version: "0.1.1", knowledge: "lumi-overlay", compatibleFrom: "0.1.0" },
lumi_transcription: { version: "0.2.7", knowledge: "lumi-transcription", compatibleFrom: "0.1.0" },
lumi_transcription: { version: "0.2.8", knowledge: "lumi-transcription", compatibleFrom: "0.1.0" },
now_playing: { version: "0.1.3", knowledge: "now-playing", compatibleFrom: "0.1.0" }
};
@ -84,4 +84,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: stable core 0.3.9 after 0.3.8 with synchronized Companion and plugin metadata.");
console.log("Release metadata verification passed: stable core 0.3.11 after 0.3.10 with synchronized Companion and plugin metadata.");

View File

@ -24,7 +24,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["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.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.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
const coreManifest = readJson("update-manifest.json");
assert.equal(packageVersion, coreManifest.version);
assert.equal(coreManifest.channel, "stable");
assert.equal(packageVersion, "0.3.9");
assert.equal(currentRelease.version, "0.3.9");
assert.equal(packageVersion, "0.3.11");
assert.equal(currentRelease.version, "0.3.11");
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
@ -50,6 +50,8 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
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.7", ref: "refs/tags/v0.3.7", rollback_safe: true },
@ -158,7 +160,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.3.9");
assert.equal(corrected.safe_target_version, "0.3.11");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);

View File

@ -0,0 +1,186 @@
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);
});

View File

@ -0,0 +1,32 @@
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.");

View File

@ -28,6 +28,7 @@ const { overlayConnectorManager } = require("./services/overlay-connectors");
const { cleanupSnapshots } = require("./services/update-manager");
const { twitchEventSubManager } = require("./services/twitch-eventsub");
const { streamTestingService } = require("./services/stream-testing");
const { userAgeStatistics } = require("./services/user-age-statistics");
const {
isSafeModeRequested,
markStartupVerification
@ -56,7 +57,8 @@ async function main() {
plugin_count: completedPluginSync.plugin_ids.length
}, { event: "bundled_plugin_sync_completed" });
}
registerCorePlaceholders();
userAgeStatistics.start();
registerCorePlaceholders({ userAgeStatistics });
const logCleanup = logger.cleanupLogs({
maxAgeDays: getSetting("log_retention_days", logger.DEFAULT_MAX_AGE_DAYS),
maxEntries: getSetting("log_retention_max_entries", logger.DEFAULT_MAX_ENTRIES)
@ -205,6 +207,7 @@ async function main() {
const closeWebServer = new Promise((resolve) => webServer.close(resolve));
for (const service of [
{ name: "stream_testing", stop: () => streamTestingService.close() },
{ name: "user_age_statistics", stop: () => userAgeStatistics.stop() },
{ name: "overlay_connectors", stop: () => overlayConnectorManager.stop() },
{ name: "twitch_eventsub", stop: () => twitchEventSubManager.stop() },
{ name: "plugins", stop: () => stopPlugins() },

View File

@ -92,7 +92,7 @@ function buildTwitchEventAuthUrl(state, redirectOverride) {
client_id: clientId || "",
redirect_uri: redirectUri || "",
response_type: "code",
scope: "moderator:read:followers channel:read:subscriptions bits:read channel:read:redemptions",
scope: "moderator:read:followers moderation:read channel:read:subscriptions channel:read:editors channel:read:vips bits:read channel:read:redemptions",
state,
force_verify: "true"
});

View File

@ -153,7 +153,7 @@ function createCommandRouter({ settings }) {
if (typeof result === "string" && result) {
await safeReply(reply, result);
await lease.commit();
recordCommandUsage(handler.commandId);
recordCommandUsage(handler.commandId, user.id);
incrementCommands(user.id);
commandLog.debug("Command completed", {
command_id: handler.commandId,
@ -165,7 +165,7 @@ function createCommandRouter({ settings }) {
}
if (result === true) {
await lease.commit();
recordCommandUsage(handler.commandId);
recordCommandUsage(handler.commandId, user.id);
incrementCommands(user.id);
commandLog.debug("Command completed", {
command_id: handler.commandId,
@ -289,7 +289,7 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
} else {
await safeReply(reply, await renderStaticResponse(row.response, ctx, platform));
}
recordCommandUsage(`custom:${trigger}`);
recordCommandUsage(`custom:${trigger}`, ctx.user.id);
await lease.commit();
return true;
} catch (error) {
@ -356,7 +356,7 @@ async function safeReply(reply, content) {
}
}
function recordCommandUsage(commandId) {
function recordCommandUsage(commandId, userId = null) {
if (!commandId) {
return;
}
@ -365,6 +365,12 @@ function recordCommandUsage(commandId) {
"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"
).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 = {

View File

@ -103,6 +103,39 @@ function migrate() {
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
@ -149,6 +182,17 @@ function migrate() {
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 (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,

View File

@ -11,6 +11,7 @@ const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const { discordOverlayContent } = require("./discord-chat-content");
const { emitLumiEvent } = require("./lumi-events");
const { userAgeStatistics } = require("./user-age-statistics");
const discordLog = createLogger("platform:discord", { category: "integration" });
@ -55,6 +56,11 @@ async function startBot({ commandRouter } = {}) {
if (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) => {
@ -76,6 +82,9 @@ async function startBot({ commandRouter } = {}) {
displayName,
avatar: avatarUrl
});
if (!message.author.bot && message.member) {
observeDiscordMember(message.member, message.createdTimestamp || Date.now(), `discord-message:${message.id}`);
}
publishOverlayChatMessage({
id: message.id,
platform: "discord",
@ -140,6 +149,29 @@ async function startBot({ commandRouter } = {}) {
source: "discord-gateway",
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);
@ -176,6 +208,98 @@ function resolveIntent(key, legacyKey) {
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;

View File

@ -0,0 +1,54 @@
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
};

View File

@ -62,8 +62,13 @@ function emitLumiEvent(type, payload = {}, metadata = {}) {
for (const definition of [
{ 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.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.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" },
@ -71,7 +76,10 @@ for (const definition of [
{ 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_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);
module.exports = { emitLumiEvent, listEventTypes, onLumiEvent, registerEventType };

View File

@ -1,6 +1,7 @@
const { db } = require("./db");
const { getSetting } = require("./settings");
const { hasAccess } = require("./rbac");
const { parseDurationFormat } = require("./duration-format");
const ROLE_LEVELS = Object.freeze({
public: 0,
@ -23,6 +24,7 @@ const SENSITIVITY_LEVELS = Object.freeze({
const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]);
const placeholders = 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") {
const role = String(value || fallback).trim().toLowerCase();
@ -100,7 +102,8 @@ function normalizeDefinition(definition = {}) {
example: definition.example === undefined ? null : String(definition.example),
plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null,
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)
};
}
@ -246,13 +249,14 @@ function findDefinition(token) {
function parsePlaceholders(template) {
const found = [];
const matcher = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
const matcher = new RegExp(PLACEHOLDER_PATTERN.source, PLACEHOLDER_PATTERN.flags);
let match = null;
while ((match = matcher.exec(String(template || "")))) {
found.push({
token: match[0],
id: normalizeId(match[1]),
index: match.index
id: normalizeId(match[1] || match[3]),
index: match.index,
format: match[2] || null
});
}
return found;
@ -277,6 +281,7 @@ function catalog({ fieldId, user, outputAudience, runtimeContext } = {}) {
value_type: definition.value_type,
sensitivity: definition.sensitivity,
group: definition.group,
supports_duration_format: definition.supports_duration_format,
example: definition.sensitivity === "public_safe" ? definition.example : null
}))
.sort((a, b) => a.token.localeCompare(b.token));
@ -302,6 +307,19 @@ function validateTemplate({ fieldId, template, user, outputAudience, runtimeCont
}
for (const token of parsePlaceholders(template)) {
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, {
user,
outputAudience,
@ -328,8 +346,8 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
};
}
const errors = [];
const rendered = await replaceAsync(String(template || ""), /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, async (match, rawId) => {
const id = normalizeId(rawId);
const rendered = await replaceAsync(String(template || ""), PLACEHOLDER_PATTERN, async (match, formattedId, format, regularId) => {
const id = normalizeId(formattedId || regularId);
const definition = findDefinition(id);
const access = checkPlaceholderAccess(definition, policy, {
user,
@ -340,6 +358,16 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
errors.push({ token: match, id, reason: access.reason });
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 {
const value = await withTimeout(Promise.resolve(definition.resolver({
user,
@ -347,7 +375,8 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
runtimeContext,
token: match,
id
id,
format: format || null
})), runtimeContext?.placeholder_timeout_ms);
return stringifyResolvedValue(value);
} catch (error) {
@ -393,14 +422,22 @@ function withTimeout(promise, requestedMs) {
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
function registerCorePlaceholders() {
function registerCorePlaceholders({ userAgeStatistics } = {}) {
registerFieldPolicy({
field_id: "core.custom_commands.static_response",
label: "Custom command response",
field_type: "command_response",
output_audience: "user",
min_editor_role: "mod",
allowed_namespaces: ["core.main", "core.command", "custom", "user.public"],
allowed_namespaces: [
"core.main",
"core.command",
"custom",
"user.public",
"twitch.user",
"youtube.user",
"discord.user"
],
max_sensitivity: "public_safe"
});
registerPlaceholders([
@ -461,6 +498,9 @@ function registerCorePlaceholders() {
""
}
]);
if (userAgeStatistics?.placeholderDefinitions) {
registerPlaceholders(userAgeStatistics.placeholderDefinitions());
}
registerCustomPlaceholders(getSetting("custom_placeholders", []));
}

View File

@ -5,6 +5,7 @@ const { getSetting } = require("./settings");
const { getEnabledPlatformIds } = require("./platforms");
const { getPluginLeaderboards } = require("./plugin-stats");
const { getPlugins, pluginsDir } = require("./plugins");
const { userAgeStatistics } = require("./user-age-statistics");
const coreProviders = new Map();
const coreOrder = [];
@ -97,17 +98,31 @@ function ensureCoreProviders() {
description: "Most popular commands across platforms.",
getRows: ({ limit }) => buildCommandUsageRows(limit)
});
for (const definition of [
{ id: "followage", platform: "twitch", type: "follow", label: "Longest Twitch follows" },
{ id: "twitch_subscriber_age", platform: "twitch", type: "subscriber", label: "Longest Twitch subscriptions" },
{ id: "twitch_mod_age", platform: "twitch", type: "moderator", label: "Longest Twitch moderator tenure" },
{ id: "twitch_editor_age", platform: "twitch", type: "editor", label: "Longest Twitch editor tenure" },
{ id: "twitch_vip_age", platform: "twitch", type: "vip", label: "Longest Twitch VIP tenure" },
{ id: "youtube_member_age", platform: "youtube", type: "subscriber", label: "Longest YouTube memberships" },
{ id: "youtube_mod_age", platform: "youtube", type: "moderator", label: "Longest YouTube moderator tenure" },
{ id: "discord_member_age", platform: "discord", type: "member", label: "Longest Discord membership" },
{ id: "discord_nitro_age", platform: "discord", type: "nitro", label: "Longest Discord boosts" }
]) {
registerTopProvider({
id: "followage",
label: "Top followage",
section: "Platforms",
valueLabel: "Days",
description: "Longest follower durations.",
getRows: () => ({
rows: [],
emptyMessage: "Followage tracking is not configured yet."
...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({
id: "watchtime",
label: "Top watchtime",
@ -160,7 +175,9 @@ function getTopBoards({ limit = 10 } = {}) {
}
function getLeaderboardSections({ limit = 10 } = {}) {
const boards = getTopBoards({ limit });
const boards = getTopBoards({ limit }).filter(
(board) => board.section !== "Platform tenure" || (board.rows && board.rows.length)
);
const sections = [];
const sectionMap = new Map();
boards.forEach((board) => {

View File

@ -3,6 +3,7 @@ const { getSetting, setSetting } = require("./settings");
const { emitLumiEvent } = require("./lumi-events");
const { createLogger } = require("./logger");
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 eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
@ -70,6 +71,8 @@ class TwitchEventSubManager {
this.running = false;
this.reconnectTimer = null;
this.reconnectAttempt = 0;
this.reconcileTimer = null;
this.reconcileContext = null;
this.generation = 0;
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
}
@ -98,7 +101,10 @@ class TwitchEventSubManager {
this.running = false;
this.generation += 1;
clearTimeout(this.reconnectTimer);
clearInterval(this.reconcileTimer);
this.reconnectTimer = null;
this.reconcileTimer = null;
this.reconcileContext = null;
const socket = this.socket;
this.socket = null;
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Lumi shutdown");
@ -204,8 +210,17 @@ class TwitchEventSubManager {
}
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.subscription.end", 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)
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)
@ -235,6 +250,15 @@ class TwitchEventSubManager {
}
}
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 = {
state: "connected",
detail: active ? `Receiving ${active} Twitch event feed${active === 1 ? "" : "s"}.` : "Connected, but no configured channel events could be subscribed.",
@ -243,7 +267,10 @@ class TwitchEventSubManager {
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",
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" });
@ -265,6 +292,11 @@ class TwitchEventSubManager {
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.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.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", {

View File

@ -0,0 +1,122 @@
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
};

View File

@ -5,6 +5,7 @@ const { ensureUserForIdentity } = require("./users");
const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const { resolveBetterTtvEmotes, resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets");
const { userAgeStatistics } = require("./user-age-statistics");
const twitchLog = createLogger("platform:twitch", { category: "integration" });
@ -57,6 +58,28 @@ async function startTwitchBot({ commandRouter } = {}) {
displayName,
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({
id: tags.id,
platform: "twitch",

View File

@ -0,0 +1,430 @@
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
};

View File

@ -0,0 +1,33 @@
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
};

View File

@ -5,6 +5,7 @@ const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const { emitLumiEvent } = require("./lumi-events");
const { setPlatformLiveState } = require("./platform-live-state");
const { userAgeStatistics } = require("./user-age-statistics");
const youtubeLog = createLogger("platform:youtube", { category: "integration" });
@ -119,14 +120,41 @@ async function handleChatItem(state, liveChatId, item) {
if (!snippet || !author) {
return;
}
const messageText = snippet.displayMessage;
if (!messageText) {
return;
}
const messageText = snippet.displayMessage || "";
const displayName = author.displayName || "YouTube User";
const avatar = author.profileImageUrl || null;
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
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({
provider: "youtube",
providerUserId: author.channelId,

View File

@ -1784,11 +1784,35 @@ body.stats-compare-mode .stats-compare {
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: 700;
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 {
color: var(--ink-soft);
font-size: 0.95rem;

View File

@ -80,6 +80,11 @@ const { getClient: getTwitchClient } = require("../services/twitch");
const { twitchEventSubManager } = require("../services/twitch-eventsub");
const { eventHooksApi } = require("../services/overlay-event-hooks");
const { streamTestingService } = require("../services/stream-testing");
const { userAgeStatistics } = require("../services/user-age-statistics");
const {
getFavoriteCommand,
getFavoriteExpression
} = require("../services/user-favorites");
const {
getReverseProxyIngestSettings,
saveReverseProxyIngestSettings
@ -374,12 +379,13 @@ function getExpressionUserSummary(userId) {
},
{ given: 0, received: 0 }
);
return { totals };
const favorite = getFavoriteExpression(db, userId);
return { totals, favorite };
}
function buildUserStatsPayload(userId) {
if (!userId) {
return { stats: null, expression: null, pluginStats: [] };
return { stats: null, expression: null, favoriteCommand: null, tenureStats: [], pluginStats: [] };
}
const stats = db
.prepare("SELECT * FROM stats WHERE user_id = ?")
@ -387,6 +393,8 @@ function buildUserStatsPayload(userId) {
return {
stats,
expression: getExpressionUserSummary(userId),
favoriteCommand: getFavoriteCommand(db, userId, getSetting("command_prefix", "!")),
tenureStats: userAgeStatistics.getProfileStatistics(userId),
pluginStats: getPluginProfileStats(userId)
};
}
@ -425,6 +433,20 @@ function buildCompareRows(leftStats, rightStats) {
];
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) {
const leftExpression = leftStats.expression
? [
@ -3113,7 +3135,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
// network. Public clients remain unable to forge forwarding headers.
app.set("trust proxy", isTrustedProxyAddress);
const webhooks = createWebhookService();
placeholders.registerCorePlaceholders();
placeholders.registerCorePlaceholders({ userAgeStatistics });
placeholders.registerPlatformPlaceholders({
discordClient,
getTwitchClient,
@ -5280,6 +5302,8 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
title: "Your stats",
stats: payload.stats,
expression: payload.expression,
favoriteCommand: payload.favoriteCommand,
tenureStats: payload.tenureStats,
pluginStats: payload.pluginStats,
statsOwner: {
username: req.session.user.username,
@ -5324,6 +5348,8 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
title: `${profile.internal_username}'s stats`,
stats: payload.stats,
expression: payload.expression,
favoriteCommand: payload.favoriteCommand,
tenureStats: payload.tenureStats,
pluginStats: payload.pluginStats,
statsOwner: {
username: profile.internal_username,
@ -6212,6 +6238,21 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
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) => {
res.set("Cache-Control", "no-store");
res.render("admin-stream-testing", {
@ -7969,6 +8010,7 @@ function collectNavItems(user, pluginNav, currentPath) {
},
{ label: "Feedback review", path: "/admin/feedback", 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: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
@ -8237,6 +8279,7 @@ function getDefaultNavIcon(item) {
if (pathName === "/admin/theming") return "theming";
if (pathName === "/admin/privileges") return "privileges";
if (pathName === "/admin/diagnostics") return "admin";
if (pathName === "/admin/platform-tenure") return "admin";
if (pathName === "/admin/logs") return "logs";
if (pathName === "/admin/updates") return "updates";
if (pathName === "/admin/commands") return "commands";

View File

@ -0,0 +1,98 @@
<%- 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") %>

View File

@ -40,7 +40,7 @@
<tr>
<td>
<% if (rowType === "user" && entry.username) { %>
<a class="link" href="/stats/<%= encodeURIComponent(entry.username) %>"><%= entry.username %></a>
<a class="link" href="/stats/<%= encodeURIComponent(entry.username) %>"><%= entryLabel %></a>
<% } else if (rowType === "command") { %>
<% if (entry.href) { %>
<a class="link" href="<%= entry.href %>"><code><%= entryLabel %></code></a>

View File

@ -35,6 +35,47 @@
<% } %>
</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">
<h2>Expression Interaction</h2>
<% if (!expression) { %>

View File

@ -1,14 +1,14 @@
{
"name": "Lumi Core",
"version": "0.3.9",
"version": "0.3.11",
"channel": "stable",
"released_at": "2026-07-26",
"released_at": "2026-07-29",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"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.",
"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.",
"rollback_safe": true,
"requirements": [
"Node.js 18 or newer"
@ -457,6 +457,30 @@
],
"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."
}
]
}