using Lumi.Companion.Abstractions; using Lumi.Companion.SongOverlay.Providers; using Lumi.Companion.SongOverlay.Spotify; using System.Text.Json; namespace Lumi.Companion.SongOverlay; public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDisposable { private const string PluginId = "now_playing"; private readonly SongOverlaySettingsStore _settingsStore; private readonly SongOverlaySecretProtector _secrets = new(); private readonly SongOverlayTransport _transport; private readonly string _logDirectory; private IMediaProvider? _provider; private SpotifyWebApiEnricher? _spotify; private CancellationTokenSource? _lifetime; private PeriodicTimer? _heartbeat; private Task? _heartbeatTask; private readonly SemaphoreSlim _eventLock = new(1, 1); private MediaSnapshot? _lastObserved; private MediaSnapshot? _lastDelivered; private readonly List _trackHistory = []; private int _historyIndex = -1; private long _sequence; private readonly string _sessionId = Guid.NewGuid().ToString("N"); private bool _initialized; public SongOverlayRuntime( string pluginDataDirectory, string logDirectory, ICompanionPluginTransport hostTransport) { Directory.CreateDirectory(pluginDataDirectory); _logDirectory = logDirectory; _settingsStore = new SongOverlaySettingsStore(Path.Combine(pluginDataDirectory, "settings.json")); _transport = new SongOverlayTransport(hostTransport); Actions = [ new CompanionPluginAction("send", () => "Send current song now", SendSnapshotAsync, () => _initialized && Settings.Enabled, 10), new CompanionPluginAction("toggle", () => Settings.Enabled ? "Disable monitoring" : "Enable monitoring", ToggleEnabledAsync, () => _initialized, 20) ]; } public CompanionPluginDescriptor Descriptor { get; } = new( PluginId, "Song Overlay", new Version(0, 1, 1), "Reads provider-neutral Windows media-session events and sends minimal playback changes to Lumi.", 200); public IReadOnlyList Pages { get; } = [ new CompanionPluginPage("SongOverlay", "Overview & settings", 10) ]; public IReadOnlyList Actions { get; } public CompanionPluginStatus Status { get; private set; } = new(CompanionPluginHealth.Ready, "Starting", "Waiting for initialization."); public SongOverlaySettings Settings => _settingsStore.Current; public bool IsInitialized => _initialized; public bool IsSpotifyEnrichmentConnected => _spotify?.IsConfigured == true; public bool UsesCompanionAuthentication => _transport.IsConfigured; public string ProviderStatus => Status.Detail ?? Status.Summary; public string? CurrentTrack => _lastObserved?.Track is null ? null : $"{_lastObserved.Track.Title} — {_lastObserved.Track.Artist}"; public Uri? EffectiveLumiBaseUri => _transport.BaseUri; public event Action? Changed; public async Task InitializeAsync(CancellationToken cancellationToken = default) { await _settingsStore.LoadAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(Settings.ProviderId)) Settings.ProviderId = "spotify"; // 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); _spotify = new SpotifyWebApiEnricher(Settings, _secrets, SaveSettingsSync, Log); _initialized = true; await RestartProviderAsync(cancellationToken).ConfigureAwait(false); } 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(); await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false); if (restartProvider) await RestartProviderAsync(cancellationToken).ConfigureAwait(false); RaiseChanged(); } public async Task ToggleEnabledAsync(CancellationToken cancellationToken = default) { Settings.Enabled = !Settings.Enabled; await SaveSettingsAsync(restartProvider: true, cancellationToken).ConfigureAwait(false); } public async Task ConnectSpotifyAsync(CancellationToken cancellationToken = default) { EnsureInitialized(); await _spotify!.AuthorizeAsync(cancellationToken).ConfigureAwait(false); SaveSettingsSync(); RaiseChanged(); if (_transport.IsConfigured) await SendSnapshotAsync(cancellationToken).ConfigureAwait(false); } public void DisconnectSpotify() { _spotify?.Disconnect(); RaiseChanged(); } public async Task SendSnapshotAsync(CancellationToken cancellationToken = default) { EnsureInitialized(); 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."); await SendAsync("snapshot", snapshot, includeTrack: true, cancellationToken).ConfigureAwait(false); } private async Task RestartProviderAsync(CancellationToken cancellationToken) { await StopProviderAsync().ConfigureAwait(false); if (!Settings.Enabled) { SetStatus(CompanionPluginHealth.Ready, "Disabled", "Song Overlay monitoring is disabled."); return; } _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.StateChanged += OnProviderStateChanged; _provider.AvailabilityChanged += OnAvailabilityChanged; try { await _provider.StartAsync(cancellationToken).ConfigureAwait(false); 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."); else if (initial is null) SetStatus(CompanionPluginHealth.Warning, "Waiting for Spotify", "Spotify is not 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); throw; } } private async Task StopProviderAsync() { _lifetime?.Cancel(); _heartbeat?.Dispose(); if (_heartbeatTask is not null) { try { await _heartbeatTask.ConfigureAwait(false); } catch { } } _heartbeat = null; _heartbeatTask = null; if (_provider is not null) { _provider.StateChanged -= OnProviderStateChanged; _provider.AvailabilityChanged -= OnAvailabilityChanged; await _provider.DisposeAsync().ConfigureAwait(false); } _provider = null; _lifetime?.Dispose(); _lifetime = null; } private void StartHeartbeat() { _heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(Settings.HeartbeatSeconds, 15, 300))); var token = _lifetime?.Token ?? CancellationToken.None; _heartbeatTask = Task.Run(async () => { try { while (_heartbeat is not null && await _heartbeat.WaitForNextTickAsync(token).ConfigureAwait(false)) { var current = _lastObserved; if (current is null || !_transport.IsConfigured) continue; await SendAsync("heartbeat", current, includeTrack: _lastDelivered?.Track?.Key != current.Track?.Key, token).ConfigureAwait(false); } } catch (OperationCanceledException) { } catch (Exception error) { Log("heartbeat_failed", "Song Overlay heartbeat failed", error); } }, token); } private void OnAvailabilityChanged(object? sender, string message) { if (!_transport.IsConfigured) { SetStatus(CompanionPluginHealth.Warning, "Pairing required", message); return; } var waiting = message.Contains("not exposing", StringComparison.OrdinalIgnoreCase); SetStatus(waiting ? CompanionPluginHealth.Warning : CompanionPluginHealth.Healthy, waiting ? "Waiting for Spotify" : "Monitoring", message); } private void OnProviderStateChanged(object? sender, ProviderStateChangedEventArgs args) => _ = HandleStateChangeAsync(args.Reason, args.Snapshot, _lifetime?.Token ?? CancellationToken.None); private async Task HandleStateChangeAsync(string reason, MediaSnapshot? snapshot, CancellationToken cancellationToken) { await _eventLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (snapshot is null) { if (_lastObserved is null) return; var stopped = _lastObserved with { Status = "stopped", PositionMilliseconds = 0, Track = null, CapturedAt = DateTimeOffset.UtcNow }; _lastObserved = null; if (_transport.IsConfigured) await SendAsync("stop", stopped, false, cancellationToken).ConfigureAwait(false); else RaiseChanged(); return; } var previous = _lastObserved; _lastObserved = snapshot; RaiseChanged(); if (!_transport.IsConfigured) { SetStatus(CompanionPluginHealth.Warning, "Pairing required", Describe(snapshot)); return; } var currentTrackKey = snapshot.Track?.Key; var trackChanged = !string.IsNullOrWhiteSpace(currentTrackKey) && !string.Equals(currentTrackKey, previous?.Track?.Key, StringComparison.Ordinal); if (trackChanged) { await SendAsync(ClassifyTrackDirection(currentTrackKey!), snapshot, true, cancellationToken).ConfigureAwait(false); return; } if (previous is null) { await SendAsync("snapshot", snapshot, true, cancellationToken).ConfigureAwait(false); return; } if (reason == "metadata") { await SendAsync("snapshot", snapshot, true, cancellationToken).ConfigureAwait(false); return; } if (!string.Equals(previous.Status, snapshot.Status, StringComparison.Ordinal)) { var playbackEvent = snapshot.Status switch { "paused" => "pause", "stopped" or "closed" => "stop", "playing" when previous.Status == "paused" => "resume", "playing" => "play", _ => "snapshot" }; await SendAsync(playbackEvent, snapshot, false, cancellationToken).ConfigureAwait(false); return; } if (reason == "timeline") { var expected = ProjectPosition(previous, snapshot.CapturedAt); if (Math.Abs(snapshot.PositionMilliseconds - expected) >= Math.Clamp(Settings.SeekThresholdMilliseconds, 500, 10000)) await SendAsync("seek", snapshot, false, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) { } catch (Exception error) { Log("event_processing_failed", "Could not process a Song Overlay event", error); SetStatus(CompanionPluginHealth.Warning, "Delivery delayed", "Playback was detected, but Lumi could not be updated: " + error.Message); } finally { _eventLock.Release(); } } private async Task SendAsync(string eventName, MediaSnapshot snapshot, bool includeTrack, CancellationToken cancellationToken) { var track = includeTrack && snapshot.Track is not null ? new TrackPayload(snapshot.Track.Key, snapshot.Track.Title, snapshot.Track.Artist, snapshot.Track.Album, snapshot.Track.ReleaseYear, snapshot.Track.Link, snapshot.Track.Cover) : null; var payload = new NowPlayingEventPayload( 1, snapshot.Provider, _sessionId, Interlocked.Increment(ref _sequence), eventName, snapshot.CapturedAt.ToUnixTimeMilliseconds(), new PlaybackPayload(snapshot.Status, snapshot.PositionMilliseconds, snapshot.DurationMilliseconds, snapshot.PlaybackRate), track); var response = await _transport.SendJsonAsync("/plugins/now_playing/api/companion/state", payload, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { throw new InvalidOperationException($"Lumi rejected the update with HTTP {response.StatusCode}: {Trim(response.Body, 240)}"); } _lastDelivered = snapshot; SetStatus(CompanionPluginHealth.Healthy, snapshot.Status == "playing" ? "Playing" : "Connected", Describe(snapshot)); } private async Task EnrichTrackAsync(MediaTrack track, CancellationToken cancellationToken) { if (_spotify?.IsConfigured != true) return track; SpotifyEnrichment? enriched = null; foreach (var delay in new[] { 0, 500, 1000 }) { if (delay > 0) await Task.Delay(delay, cancellationToken).ConfigureAwait(false); enriched = await _spotify.EnrichAsync(track, cancellationToken).ConfigureAwait(false); if (enriched is not null) break; } 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; return track with { Link = string.IsNullOrWhiteSpace(enriched.Link) ? track.Link : enriched.Link, ReleaseYear = string.IsNullOrWhiteSpace(enriched.ReleaseYear) ? track.ReleaseYear : enriched.ReleaseYear, Cover = cover }; } private string ClassifyTrackDirection(string key) { if (_historyIndex > 0 && _trackHistory[_historyIndex - 1] == key) { _historyIndex--; return "previous"; } if (_historyIndex >= 0 && _historyIndex + 1 < _trackHistory.Count && _trackHistory[_historyIndex + 1] == key) { _historyIndex++; return "next"; } if (_historyIndex + 1 < _trackHistory.Count) _trackHistory.RemoveRange(_historyIndex + 1, _trackHistory.Count - _historyIndex - 1); _trackHistory.Add(key); if (_trackHistory.Count > 50) _trackHistory.RemoveAt(0); _historyIndex = _trackHistory.Count - 1; return "track_changed"; } private static long ProjectPosition(MediaSnapshot snapshot, DateTimeOffset at) { if (snapshot.Status != "playing") return snapshot.PositionMilliseconds; var elapsed = Math.Max(0, (at - snapshot.CapturedAt).TotalMilliseconds); return Math.Min(snapshot.DurationMilliseconds > 0 ? snapshot.DurationMilliseconds : long.MaxValue, snapshot.PositionMilliseconds + (long)(elapsed * snapshot.PlaybackRate)); } private void SaveSettingsSync() => _settingsStore.Save(Settings); private void EnsureInitialized() { if (!_initialized) throw new InvalidOperationException("The Song Overlay companion plugin has not initialized yet."); } private void SetStatus(CompanionPluginHealth health, string summary, string detail) { Status = new CompanionPluginStatus(health, summary, detail); RaiseChanged(); } private void RaiseChanged() => Changed?.Invoke(); private void Log(string eventId, string message, Exception? error = null) { try { Directory.CreateDirectory(_logDirectory); PruneLogs(); var normalizedEvent = CompanionLogSanitizer.NormalizeEvent(eventId); var line = JsonSerializer.Serialize(new { timestamp = DateTimeOffset.UtcNow, level = CompanionLogSanitizer.LevelForEvent(normalizedEvent), source = $"plugin:{PluginId}", category = "plugin", @event = normalizedEvent, message = CompanionLogSanitizer.Sanitize(message), error = error is null ? null : CompanionLogSanitizer.Sanitize(error.ToString()) }); File.AppendAllText( Path.Combine(_logDirectory, $"song-overlay-{DateTime.UtcNow:yyyy-MM-dd}.jsonl"), line + Environment.NewLine ); } catch { } } private void PruneLogs() { var files = new DirectoryInfo(_logDirectory) .GetFiles("song-overlay-*.*") .Where(file => file.Extension is ".jsonl" or ".log") .OrderBy(file => file.CreationTimeUtc) .ToList(); foreach (var file in files.Where(file => file.CreationTimeUtc < DateTime.UtcNow.AddDays(-7))) file.Delete(); const long cap = 32L * 1024 * 1024; var total = files.Where(file => file.Exists).Sum(file => file.Length); foreach (var file in files.Where(file => file.Exists)) { if (total <= cap) break; total -= file.Length; file.Delete(); } } private static string Describe(MediaSnapshot snapshot) => snapshot.Track is null ? "Connected; no active song." : $"{snapshot.Status}: {snapshot.Track.Title} — {snapshot.Track.Artist}"; private static string Trim(string value, int max) => value.Length <= max ? value : value[..max]; public async ValueTask DisposeAsync() { await StopProviderAsync().ConfigureAwait(false); _spotify?.Dispose(); _eventLock.Dispose(); } }