412 lines
18 KiB
C#
412 lines
18 KiB
C#
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<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, 2),
|
|
"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);
|
|
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);
|
|
_spotify = new SpotifyWebApiEnricher(Settings, _secrets, SaveSettingsSync, Log);
|
|
_initialized = true;
|
|
await RestartProviderAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task SaveSettingsAsync(bool restartProvider, CancellationToken cancellationToken = default)
|
|
{
|
|
Settings.Normalize();
|
|
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("None of the enabled media sources is 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 = new WindowsMediaProvider(() => Settings, EnrichTrackAsync, Log);
|
|
_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", "Media monitoring is ready, but Companion must be paired before Lumi can receive updates.");
|
|
else if (initial is null)
|
|
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 Windows media discovery: " + 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) ||
|
|
message.Contains("No enabled source", StringComparison.OrdinalIgnoreCase);
|
|
SetStatus(waiting ? CompanionPluginHealth.Warning : CompanionPluginHealth.Healthy,
|
|
waiting ? "Waiting for media" : "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<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 = WindowsMediaProvider.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();
|
|
}
|
|
}
|