Lumi/companion/plugins/Lumi.Companion.SongOverlay/SongOverlayRuntime.cs
2026-07-24 14:44:27 +02:00

384 lines
18 KiB
C#

using Lumi.Companion.Abstractions;
using Lumi.Companion.SongOverlay.Providers;
using Lumi.Companion.SongOverlay.Spotify;
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<string> _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<CompanionPluginPage> Pages { get; } =
[
new CompanionPluginPage("SongOverlay", "Overview & settings", 10)
];
public IReadOnlyList<CompanionPluginAction> 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("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("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<MediaTrack> 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 message, Exception? error = null)
{
try
{
Directory.CreateDirectory(_logDirectory);
File.AppendAllText(Path.Combine(_logDirectory, $"song-overlay-{DateTime.UtcNow:yyyyMMdd}.log"),
$"{DateTimeOffset.Now:O}\t{message}{(error is null ? "" : "\t" + error)}{Environment.NewLine}");
}
catch { }
}
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();
}
}