426 lines
20 KiB
C#
426 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Net.Http;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Windows.Media.Control;
|
|
|
|
namespace LoopbackCapture
|
|
{
|
|
// Only this background worker accesses GSMTC/network/cache. The audio thread reads
|
|
// one immutable snapshot, without locks, COM calls, network or waits.
|
|
sealed class GenreMetadataProvider
|
|
{
|
|
// Explicit mapping: unmapped genres (including Soundtrack) are not guessed.
|
|
static readonly Dictionary<string, string> GenreMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
{ "Latin", "latin" }, { "Reggaeton", "latin" }, { "Salsa", "latin" }, { "Bachata", "latin" },
|
|
{ "Electronic", "electronic" }, { "Dance", "electronic" }, { "House", "electronic" },
|
|
{ "Techno", "electronic" }, { "Trance", "electronic" },
|
|
{ "Hip-Hop/Rap", "hiphop" }, { "Rap", "hiphop" },
|
|
{ "Rock", "rock" }, { "Metal", "rock" }, { "Hard Rock", "rock" },
|
|
{ "Alternative", "rock" }, { "Punk", "rock" },
|
|
{ "Classical", "classical" }, { "Opera", "classical" }
|
|
};
|
|
const int PollMs = 5000, SnapshotTtlMs = 10000, WinRtTimeoutMs = 3000, CacheLimit = 512;
|
|
const long PositiveCacheMs = 86400000, NegativeCacheMs = 300000;
|
|
sealed class Snapshot
|
|
{
|
|
internal readonly string Label;
|
|
internal readonly string Detail;
|
|
internal readonly float Bpm;
|
|
internal readonly long Expires;
|
|
internal Snapshot(string label, string detail, float bpm, long expires) { Label = label; Detail = detail; Bpm = bpm; Expires = expires; }
|
|
}
|
|
sealed class CacheEntry
|
|
{
|
|
internal string Label;
|
|
internal string Detail;
|
|
internal float Bpm;
|
|
internal long Expires;
|
|
internal string ItunesStatus;
|
|
internal string DeezerStatus;
|
|
}
|
|
sealed class Track
|
|
{
|
|
internal GlobalSystemMediaTransportControlsSession Session;
|
|
internal string Artist, Title, Key;
|
|
}
|
|
// Lazy initialization stays inside the guarded worker, including HttpClient failures.
|
|
static class Http
|
|
{
|
|
internal static readonly HttpClient Client = Create();
|
|
static HttpClient Create()
|
|
{
|
|
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
|
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("ldcc/1.0");
|
|
return client;
|
|
}
|
|
}
|
|
readonly Dictionary<string, CacheEntry> cache = new Dictionary<string, CacheEntry>(StringComparer.Ordinal);
|
|
Snapshot current;
|
|
string currentKey;
|
|
// True when the genre from metadata is actually consumed (EnableMetadataGenre).
|
|
// When false the provider only resolves the BPM: iTunes is skipped, Deezer still runs.
|
|
readonly bool wantGenre;
|
|
// Diagnostic state is never sent over UDP; useful for read-only verification.
|
|
internal string LastStatus { get; private set; } = "starting";
|
|
|
|
// Per-source diagnostic state (concise strings, no spaces). Updated where the
|
|
// fact happens (Worker, Lookup), read by the DIAG: packet without locks: a
|
|
// reference assignment of an immutable string is atomic on .NET.
|
|
string metaStatus = "starting";
|
|
string itunesStatus = "skipped";
|
|
string deezerStatus = "skipped";
|
|
string trackText = "";
|
|
|
|
internal GenreMetadataProvider(bool wantGenre)
|
|
{
|
|
this.wantGenre = wantGenre;
|
|
if (!wantGenre)
|
|
{
|
|
metaStatus = "bpm-only";
|
|
itunesStatus = "off";
|
|
}
|
|
}
|
|
|
|
internal string MetaStatus { get { return Volatile.Read(ref metaStatus); } }
|
|
internal string ItunesStatus { get { return Volatile.Read(ref itunesStatus); } }
|
|
internal string DeezerStatus { get { return Volatile.Read(ref deezerStatus); } }
|
|
internal string TrackText { get { return Volatile.Read(ref trackText); } }
|
|
|
|
void SetMeta(string s) { Volatile.Write(ref metaStatus, s); }
|
|
void SetItunes(string s) { Volatile.Write(ref itunesStatus, s); }
|
|
void SetDeezer(string s) { Volatile.Write(ref deezerStatus, s); }
|
|
void SetTrack(string s) { Volatile.Write(ref trackText, s); }
|
|
|
|
internal void Start()
|
|
{
|
|
try
|
|
{
|
|
var thread = new Thread(Worker) { IsBackground = true, Name = "Genre metadata" };
|
|
thread.SetApartmentState(ApartmentState.MTA);
|
|
thread.Start();
|
|
}
|
|
catch { Clear("unavailable"); SetMeta("error"); }
|
|
}
|
|
|
|
internal string GetLabel()
|
|
{
|
|
try
|
|
{
|
|
Snapshot value = Volatile.Read(ref current);
|
|
return value != null && Environment.TickCount64 < value.Expires ? value.Label : null;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
// Genere specifico del catalogo ("Hard Rock", "Hip-Hop/Rap"). Solo descrittivo:
|
|
// la classe grossolana resta il contratto per il gameplay.
|
|
internal string GetDetail()
|
|
{
|
|
try
|
|
{
|
|
Snapshot value = Volatile.Read(ref current);
|
|
return value != null && Environment.TickCount64 < value.Expires ? value.Detail : null;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
internal bool TryGetBpm(out float bpm)
|
|
{
|
|
bpm = 0;
|
|
try
|
|
{
|
|
Snapshot value = Volatile.Read(ref current);
|
|
if (value == null || Environment.TickCount64 >= value.Expires) return false;
|
|
if (value.Bpm <= 0) return false;
|
|
bpm = value.Bpm;
|
|
return true;
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
void Clear(string status)
|
|
{
|
|
Volatile.Write(ref current, null);
|
|
currentKey = null;
|
|
LastStatus = status;
|
|
}
|
|
|
|
static T AwaitWinRt<T>(Task<T> task)
|
|
{
|
|
return task.WaitAsync(TimeSpan.FromMilliseconds(WinRtTimeoutMs)).GetAwaiter().GetResult();
|
|
}
|
|
|
|
static Track ReadPlayingTrack()
|
|
{
|
|
var manager = AwaitWinRt(GlobalSystemMediaTransportControlsSessionManager.RequestAsync().AsTask());
|
|
foreach (var session in manager.GetSessions())
|
|
{
|
|
try
|
|
{
|
|
if (session.GetPlaybackInfo()?.PlaybackStatus != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing) continue;
|
|
var media = AwaitWinRt(session.TryGetMediaPropertiesAsync().AsTask());
|
|
string artist = Normalize(media.Artist), title = Normalize(media.Title);
|
|
if (artist.Length == 0 || title.Length == 0) continue;
|
|
return new Track { Session = session, Artist = media.Artist, Title = media.Title, Key = artist + "\n" + title };
|
|
}
|
|
catch { }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void Worker()
|
|
{
|
|
// No async-void callback: every failure, including WinRT startup, is contained here.
|
|
while (true)
|
|
{
|
|
long start = Environment.TickCount64;
|
|
try
|
|
{
|
|
Track track = ReadPlayingTrack();
|
|
if (track == null)
|
|
{
|
|
Clear("no-playing-track");
|
|
SetMeta(wantGenre ? "no-track" : "bpm-only");
|
|
SetTrack("");
|
|
SetItunes(wantGenre ? "skipped" : "off");
|
|
SetDeezer("skipped");
|
|
}
|
|
else
|
|
{
|
|
SetTrack(SanitizeTrack(track.Artist + " - " + track.Title));
|
|
if (track.Key != currentKey) Clear("lookup");
|
|
currentKey = track.Key;
|
|
if (wantGenre) SetMeta("starting");
|
|
(string label, string detail, float bpm) = Lookup(track);
|
|
// A track may change while HTTP is in flight. Never publish that stale result.
|
|
var media = AwaitWinRt(track.Session.TryGetMediaPropertiesAsync().AsTask());
|
|
string key = Normalize(media.Artist) + "\n" + Normalize(media.Title);
|
|
if (key != track.Key || track.Session.GetPlaybackInfo()?.PlaybackStatus != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing)
|
|
{
|
|
Clear("track-changed");
|
|
if (wantGenre) SetMeta("starting");
|
|
}
|
|
else if (wantGenre && label == null)
|
|
{
|
|
Clear("no-match-or-lookup-unavailable");
|
|
SetMeta("no-match");
|
|
}
|
|
else if (!wantGenre && bpm <= 0)
|
|
{
|
|
// BPM-only with no BPM: nothing to publish.
|
|
Clear("no-bpm");
|
|
}
|
|
else
|
|
{
|
|
currentKey = track.Key;
|
|
Volatile.Write(ref current, new Snapshot(label, detail, bpm, Environment.TickCount64 + SnapshotTtlMs));
|
|
LastStatus = wantGenre ? "metadata" : "bpm";
|
|
SetMeta(wantGenre ? "ok" : "bpm-only");
|
|
}
|
|
}
|
|
}
|
|
catch { Clear("unavailable"); SetMeta("error"); }
|
|
try { Thread.Sleep((int)Math.Max(1, PollMs - (Environment.TickCount64 - start))); }
|
|
catch { return; }
|
|
}
|
|
}
|
|
|
|
(string Label, string Detail, float Bpm) Lookup(Track track)
|
|
{
|
|
long now = Environment.TickCount64;
|
|
if (cache.TryGetValue(track.Key, out CacheEntry value) && now < value.Expires)
|
|
{
|
|
// Replay the per-source status captured at lookup time, so a negative
|
|
// cache does not hide "no-match"/"error" behind "skipped" for 5 minutes.
|
|
SetItunes(value.ItunesStatus ?? (wantGenre ? "skipped" : "off"));
|
|
SetDeezer(value.DeezerStatus ?? "skipped");
|
|
return (value.Label, value.Detail, value.Bpm);
|
|
}
|
|
string label = null, detail = null, itunesState;
|
|
if (wantGenre)
|
|
{
|
|
(label, detail, itunesState, _) = LookupItunesDetailed(track.Artist, track.Title);
|
|
}
|
|
else
|
|
{
|
|
// BPM-only: iTunes is not needed, skip the extra network call.
|
|
itunesState = "off";
|
|
}
|
|
var (bpm, deezerState, _) = LookupDeezerDetailed(track.Artist, track.Title);
|
|
SetItunes(itunesState);
|
|
SetDeezer(deezerState);
|
|
if (cache.Count >= CacheLimit) cache.Clear();
|
|
cache[track.Key] = new CacheEntry
|
|
{
|
|
Label = label,
|
|
Detail = detail,
|
|
Bpm = bpm,
|
|
Expires = now + (label == null ? NegativeCacheMs : PositiveCacheMs),
|
|
ItunesStatus = itunesState,
|
|
DeezerStatus = deezerState
|
|
};
|
|
return (label, detail, bpm);
|
|
}
|
|
|
|
// One iTunes lookup (search + genre mapping) with a per-source status and a
|
|
// human-readable reason. Used by both the worker (status only) and --check-sources.
|
|
internal static (string Label, string Detail, string Status, string Reason) LookupItunesDetailed(string artist, string title)
|
|
{
|
|
try
|
|
{
|
|
string url = "https://itunes.apple.com/search?term=" + Uri.EscapeDataString(artist + " " + title) + "&entity=song&limit=5&country=IT";
|
|
string json = Http.Client.GetStringAsync(url).GetAwaiter().GetResult();
|
|
(string label, string detail) = MatchResponse(json, artist, title);
|
|
if (label == null) return (null, null, "no-match", "nessun match iTunes (artista/titolo non corrispondenti o genere non mappato)");
|
|
return (label, detail, "ok", "match");
|
|
}
|
|
catch (Exception ex) { return (null, null, "error", ClassifyHttp(ex)); }
|
|
}
|
|
|
|
// One Deezer lookup (search -> track id -> bpm) with status and reason.
|
|
internal static (float Bpm, string Status, string Reason) LookupDeezerDetailed(string artist, string title)
|
|
{
|
|
try
|
|
{
|
|
string searchUrl = "https://api.deezer.com/search?q=" + Uri.EscapeDataString(artist + " " + title) + "&limit=1";
|
|
string searchJson = Http.Client.GetStringAsync(searchUrl).GetAwaiter().GetResult();
|
|
long id = DeezerSearchId(searchJson);
|
|
if (id <= 0) return (0, "no-match", "nessun risultato di ricerca Deezer");
|
|
string trackUrl = "https://api.deezer.com/track/" + id;
|
|
string trackJson = Http.Client.GetStringAsync(trackUrl).GetAwaiter().GetResult();
|
|
(float bpm, string status) = DeezerTrackBpmStatus(trackJson, artist, title);
|
|
return (bpm, status, status == "ok" ? "match" : status == "no-bpm" ? "BPM assente" : "artista/titolo non corrispondenti");
|
|
}
|
|
catch (Exception ex) { return (0, "error", ClassifyHttp(ex)); }
|
|
}
|
|
|
|
// Exact reason for a failed lookup: HTTP status, timeout or invalid JSON.
|
|
static string ClassifyHttp(Exception ex)
|
|
{
|
|
if (ex is HttpRequestException hre && hre.StatusCode != null) return "HTTP " + (int)hre.StatusCode;
|
|
if (ex is TaskCanceledException) return "timeout (3s)";
|
|
if (ex is JsonException) return "JSON invalido";
|
|
return ex.GetType().Name + ": " + ex.Message;
|
|
}
|
|
|
|
// GSMTC track for --check-sources. Null when no session is playing or GSMTC fails.
|
|
internal static (string Artist, string Title) ReadCurrentTrack()
|
|
{
|
|
try
|
|
{
|
|
Track track = ReadPlayingTrack();
|
|
return track == null ? (null, null) : (track.Artist, track.Title);
|
|
}
|
|
catch { return (null, null); }
|
|
}
|
|
|
|
static string SanitizeTrack(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value)) return "";
|
|
StringBuilder text = new StringBuilder(value.Length);
|
|
foreach (char c in value)
|
|
{
|
|
if (c == ';' || c == '=' || c == '\r' || c == '\n') text.Append(' ');
|
|
else text.Append(c);
|
|
}
|
|
return text.ToString().Trim();
|
|
}
|
|
|
|
static long DeezerSearchId(string json)
|
|
{
|
|
using (JsonDocument document = JsonDocument.Parse(json))
|
|
{
|
|
if (!document.RootElement.TryGetProperty("data", out JsonElement data) || data.ValueKind != JsonValueKind.Array) return 0;
|
|
foreach (JsonElement item in data.EnumerateArray())
|
|
{
|
|
if (item.TryGetProperty("id", out JsonElement id) && id.ValueKind == JsonValueKind.Number)
|
|
return id.GetInt64();
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Distinguishes "no match" (artist/title differ) from "no bpm" (matched but
|
|
// the field is absent or zero), which the DIAG states expose separately.
|
|
static (float Bpm, string Status) DeezerTrackBpmStatus(string json, string artist, string title)
|
|
{
|
|
using (JsonDocument document = JsonDocument.Parse(json))
|
|
{
|
|
JsonElement root = document.RootElement;
|
|
if (!root.TryGetProperty("title", out JsonElement t) || t.ValueKind != JsonValueKind.String) return (0, "no-match");
|
|
string foundTitle = t.GetString();
|
|
if (!root.TryGetProperty("artist", out JsonElement art) || art.ValueKind != JsonValueKind.Object) return (0, "no-match");
|
|
if (!art.TryGetProperty("name", out JsonElement an) || an.ValueKind != JsonValueKind.String) return (0, "no-match");
|
|
string foundArtist = an.GetString();
|
|
if (!MatchesTrack(foundArtist, foundTitle, artist, title)) return (0, "no-match");
|
|
if (!root.TryGetProperty("bpm", out JsonElement bpm) || bpm.ValueKind != JsonValueKind.Number) return (0, "no-bpm");
|
|
float v = bpm.GetSingle();
|
|
return (v > 0 ? v : 0, v > 0 ? "ok" : "no-bpm");
|
|
}
|
|
}
|
|
|
|
// Same normalization/word-boundary match used for iTunes: artist and title must
|
|
// both correspond, otherwise the Deezer result is discarded.
|
|
static bool MatchesTrack(string foundArtist, string foundTitle, string wantedArtist, string wantedTitle)
|
|
{
|
|
string fA = Normalize(foundArtist), fT = Normalize(foundTitle);
|
|
string wA = Normalize(wantedArtist), wT = Normalize(wantedTitle);
|
|
if (fA.Length == 0 || fT.Length == 0 || wA.Length == 0 || wT.Length == 0) return false;
|
|
if (fT != wT) return false;
|
|
if (!(" " + fA + " ").Contains(" " + wA + " ", StringComparison.Ordinal) &&
|
|
!(" " + wA + " ").Contains(" " + fA + " ", StringComparison.Ordinal)) return false;
|
|
return true;
|
|
}
|
|
|
|
internal static string Normalize(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return "";
|
|
// Remove parenthesized edition qualifiers; punctuation becomes whitespace.
|
|
value = Regex.Replace(value, @"\([^)]*\)|\[[^\]]*\]", " ").ToLowerInvariant();
|
|
var text = new StringBuilder();
|
|
bool space = false;
|
|
foreach (char c in value)
|
|
{
|
|
if (char.IsLetterOrDigit(c)) { if (space && text.Length > 0) text.Append(' '); text.Append(c); space = false; }
|
|
else space = true;
|
|
}
|
|
return text.ToString();
|
|
}
|
|
|
|
internal static (string Label, string Detail) MatchResponse(string json, string artist, string title)
|
|
{
|
|
string wantedArtist = Normalize(artist), wantedTitle = Normalize(title);
|
|
if (wantedArtist.Length == 0 || wantedTitle.Length == 0) return (null, null);
|
|
using (JsonDocument document = JsonDocument.Parse(json))
|
|
{
|
|
if (!document.RootElement.TryGetProperty("results", out JsonElement results) || results.ValueKind != JsonValueKind.Array) return (null, null);
|
|
string match = null, detail = null;
|
|
foreach (JsonElement item in results.EnumerateArray())
|
|
{
|
|
if (!item.TryGetProperty("artistName", out JsonElement a) || a.ValueKind != JsonValueKind.String ||
|
|
!item.TryGetProperty("trackName", out JsonElement t) || t.ValueKind != JsonValueKind.String ||
|
|
!item.TryGetProperty("primaryGenreName", out JsonElement g) || g.ValueKind != JsonValueKind.String) continue;
|
|
string foundArtist = Normalize(a.GetString()), foundTitle = Normalize(t.GetString());
|
|
// Word boundaries avoid e.g. "Cher" matching "Cherry". Title must also match.
|
|
if (foundArtist.Length == 0 || foundTitle != wantedTitle) continue;
|
|
if (!(" " + foundArtist + " ").Contains(" " + wantedArtist + " ", StringComparison.Ordinal) &&
|
|
!(" " + wantedArtist + " ").Contains(" " + foundArtist + " ", StringComparison.Ordinal)) continue;
|
|
string raw = g.GetString().Trim();
|
|
if (!GenreMap.TryGetValue(raw, out string label)) continue;
|
|
if (match != null && match != label) return (null, null); // Ambiguous editions: prefer rules.
|
|
match = label;
|
|
if (detail == null) detail = raw; // Genere specifico del catalogo, es. "Hard Rock".
|
|
}
|
|
return (match, detail);
|
|
}
|
|
}
|
|
}
|
|
}
|