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 GenreMap = new Dictionary(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 cache = new Dictionary(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(Task 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); } } } }