Skip to content

Commit f494739

Browse files
committed
Fix YouTube Sonos metadata fallback and queue display
1 parent e6d9057 commit f494739

9 files changed

Lines changed: 489 additions & 9 deletions

File tree

SonosControl.DAL/Interfaces/IYouTubePlaybackService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Task<YouTubePlaybackSession> PreparePlaybackAsync(
1010
int? preferredQueueLength = null,
1111
CancellationToken cancellationToken = default);
1212
Task ActivateSessionAsync(string sessionId, string speakerIp, CancellationToken cancellationToken = default);
13+
Task<YouTubePlaybackQueueItem?> GetQueueItemAsync(string sessionId, int itemIndex, CancellationToken cancellationToken = default);
1314
Task<YouTubePlaybackOpenResult?> OpenPlaybackAsync(string sessionId, int itemIndex = 0, CancellationToken cancellationToken = default);
1415
Task MaintainSessionsAsync(CancellationToken cancellationToken = default);
1516
Task CleanupExpiredSessionsAsync(CancellationToken cancellationToken = default);

SonosControl.DAL/Models/SonosQueueItem.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ public sealed record SonosQueueItem(int Index, string Title, string? Artist, str
66
{
77
public string DisplayTitle => string.IsNullOrWhiteSpace(Artist)
88
? Title
9-
: $"{Artist}{Title}";
9+
: YouTubeQueueMetadataBuilder.StartsWithArtist(Title, Artist)
10+
? Title
11+
: $"{Artist}{Title}";
1012
}

SonosControl.DAL/Models/SonosTrackInfo.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ public string GetDisplayString()
1919
{
2020
var title = string.IsNullOrWhiteSpace(Title) ? "Unknown Title" : Title;
2121
var artist = string.IsNullOrWhiteSpace(Artist) ? "Unknown Artist" : Artist;
22+
if (YouTubeQueueMetadataBuilder.StartsWithArtist(title, artist))
23+
{
24+
return title;
25+
}
26+
2227
return $"{title}{artist}";
2328
}
2429

SonosControl.DAL/Models/YouTubeQueueMetadataBuilder.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ public static string FormatStreamContent(string? title, string? artist)
5151

5252
if (!string.IsNullOrWhiteSpace(trimmedArtist) && !string.IsNullOrWhiteSpace(trimmedTitle))
5353
{
54+
if (StartsWithArtist(trimmedTitle, trimmedArtist))
55+
{
56+
return trimmedTitle;
57+
}
58+
5459
return $"{trimmedArtist} - {trimmedTitle}";
5560
}
5661

@@ -62,6 +67,20 @@ public static string FormatStreamContent(string? title, string? artist)
6267
return string.IsNullOrWhiteSpace(trimmedArtist) ? "YouTube Audio" : trimmedArtist;
6368
}
6469

70+
public static bool StartsWithArtist(string? title, string? artist)
71+
{
72+
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
73+
{
74+
return false;
75+
}
76+
77+
var trimmedTitle = title.Trim();
78+
var trimmedArtist = artist.Trim();
79+
return trimmedTitle.StartsWith($"{trimmedArtist} - ", StringComparison.OrdinalIgnoreCase)
80+
|| trimmedTitle.StartsWith($"{trimmedArtist} – ", StringComparison.OrdinalIgnoreCase)
81+
|| trimmedTitle.StartsWith($"{trimmedArtist}: ", StringComparison.OrdinalIgnoreCase);
82+
}
83+
6584
private static string EscapeOrFallback(string? value, string fallback)
6685
=> SecurityElement.Escape(string.IsNullOrWhiteSpace(value) ? fallback : value.Trim()) ?? fallback;
6786
}

SonosControl.DAL/Repos/SonosConnectorRepo.cs

Lines changed: 225 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ public Task<YouTubePlaybackSession> PreparePlaybackAsync(
5757
public Task ActivateSessionAsync(string sessionId, string speakerIp, CancellationToken cancellationToken = default)
5858
=> Task.CompletedTask;
5959

60+
public Task<YouTubePlaybackQueueItem?> GetQueueItemAsync(string sessionId, int itemIndex, CancellationToken cancellationToken = default)
61+
=> Task.FromResult<YouTubePlaybackQueueItem?>(null);
62+
6063
public Task<YouTubePlaybackOpenResult?> OpenPlaybackAsync(string sessionId, int itemIndex = 0, CancellationToken cancellationToken = default)
6164
=> Task.FromResult<YouTubePlaybackOpenResult?>(null);
6265

@@ -200,6 +203,7 @@ public async Task<string> GetCurrentTrackInfoAsync(string ip, CancellationToken
200203
response.EnsureSuccessStatusCode();
201204

202205
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
206+
var trackNumber = TryParseTrackNumber(xml);
203207

204208
// Extract TrackMetaData block
205209
var match = Regex.Match(xml, @"<TrackMetaData>(.*?)</TrackMetaData>", RegexOptions.Singleline);
@@ -262,6 +266,7 @@ public async Task<string> GetCurrentTrackAsync(string ip, CancellationToken canc
262266
response.EnsureSuccessStatusCode();
263267

264268
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
269+
var trackNumber = TryParseTrackNumber(xml);
265270

266271
// Extract <TrackMetaData> content
267272
var match = Regex.Match(xml, @"<TrackMetaData>(.*?)</TrackMetaData>", RegexOptions.Singleline);
@@ -276,6 +281,8 @@ public async Task<string> GetCurrentTrackAsync(string ip, CancellationToken canc
276281
var albumMatch = Regex.Match(metadataXml, @"<upnp:album>(.*?)</upnp:album>");
277282
var streamContentMatch = Regex.Match(metadataXml, @"<r:streamContent>(.*?)</r:streamContent>");
278283
var albumArtMatch = Regex.Match(metadataXml, @"<upnp:albumArtURI>(.*?)</upnp:albumArtURI>");
284+
var trackUriMatch = Regex.Match(xml, @"<TrackURI>(.*?)</TrackURI>", RegexOptions.Singleline);
285+
var trackUri = trackUriMatch.Success ? DecodeMetadataText(trackUriMatch.Groups[1].Value) : null;
279286

280287
var trackInfo = new SonosTrackInfo
281288
{
@@ -304,6 +311,18 @@ public async Task<string> GetCurrentTrackAsync(string ip, CancellationToken canc
304311
}
305312
}
306313

314+
var youTubeSessionFallback = await TryGetYouTubeSessionTrackInfoFallbackAsync(trackInfo, trackUri, cancellationToken);
315+
if (youTubeSessionFallback is not null)
316+
{
317+
return youTubeSessionFallback;
318+
}
319+
320+
var queueFallback = await TryGetQueueTrackInfoFallbackAsync(ip, trackInfo, trackNumber, cancellationToken);
321+
if (queueFallback is not null)
322+
{
323+
return queueFallback;
324+
}
325+
307326
return trackInfo;
308327
}
309328

@@ -321,6 +340,100 @@ private static string DecodeMetadataText(string? value)
321340
return WebUtility.HtmlDecode(value)?.Trim() ?? string.Empty;
322341
}
323342

343+
private async Task<SonosTrackInfo?> TryGetYouTubeSessionTrackInfoFallbackAsync(
344+
SonosTrackInfo currentInfo,
345+
string? resourceUri,
346+
CancellationToken cancellationToken)
347+
{
348+
if (currentInfo.IsValidMetadata()
349+
&& !string.Equals(currentInfo.Title, "0", StringComparison.OrdinalIgnoreCase)
350+
&& !string.Equals(currentInfo.Artist, "Unknown Artist", StringComparison.OrdinalIgnoreCase))
351+
{
352+
return null;
353+
}
354+
355+
if (!TryParseYouTubePlaybackUri(resourceUri, out var sessionId, out var itemIndex))
356+
{
357+
return null;
358+
}
359+
360+
var queueItem = await _youTubePlaybackService.GetQueueItemAsync(sessionId, itemIndex, cancellationToken);
361+
if (queueItem is null)
362+
{
363+
return null;
364+
}
365+
366+
var title = string.IsNullOrWhiteSpace(queueItem.Title) ? currentInfo.Title : queueItem.Title;
367+
var artist = string.IsNullOrWhiteSpace(queueItem.Artist) ? currentInfo.Artist : queueItem.Artist;
368+
369+
return new SonosTrackInfo
370+
{
371+
Title = title,
372+
Artist = artist,
373+
Album = currentInfo.Album,
374+
AlbumArtUri = string.IsNullOrWhiteSpace(queueItem.AlbumArtUrl) ? currentInfo.AlbumArtUri : queueItem.AlbumArtUrl,
375+
StreamContent = string.IsNullOrWhiteSpace(queueItem.StreamContent)
376+
? YouTubeQueueMetadataBuilder.FormatStreamContent(title, artist)
377+
: queueItem.StreamContent
378+
};
379+
}
380+
381+
private async Task<SonosTrackInfo?> TryGetQueueTrackInfoFallbackAsync(
382+
string ip,
383+
SonosTrackInfo currentInfo,
384+
int? trackNumber,
385+
CancellationToken cancellationToken)
386+
{
387+
if (currentInfo.IsValidMetadata()
388+
&& !string.Equals(currentInfo.Title, "0", StringComparison.OrdinalIgnoreCase))
389+
{
390+
return null;
391+
}
392+
393+
var currentStation = await GetCurrentStationAsync(ip, cancellationToken);
394+
if (!currentStation.Contains("x-rincon-queue:", StringComparison.OrdinalIgnoreCase))
395+
{
396+
return null;
397+
}
398+
399+
var queueIndex = Math.Max(0, (trackNumber ?? 1) - 1);
400+
var queuePage = await GetQueue(ip, queueIndex, 1, cancellationToken);
401+
var queueItem = queuePage.Items.FirstOrDefault();
402+
if (queueItem is null)
403+
{
404+
return null;
405+
}
406+
407+
var title = string.IsNullOrWhiteSpace(queueItem.Title) ? currentInfo.Title : queueItem.Title;
408+
var artist = string.IsNullOrWhiteSpace(queueItem.Artist) ? currentInfo.Artist : queueItem.Artist;
409+
var album = string.IsNullOrWhiteSpace(queueItem.Album) ? currentInfo.Album : queueItem.Album;
410+
411+
return new SonosTrackInfo
412+
{
413+
Title = title,
414+
Artist = artist,
415+
Album = album,
416+
AlbumArtUri = currentInfo.AlbumArtUri,
417+
StreamContent = YouTubeQueueMetadataBuilder.FormatStreamContent(title, artist)
418+
};
419+
}
420+
421+
private static int? TryParseTrackNumber(string xml)
422+
{
423+
try
424+
{
425+
var doc = new XmlDocument();
426+
doc.LoadXml(xml);
427+
return int.TryParse(doc.GetElementsByTagName("Track").Item(0)?.InnerText, out var trackNumber)
428+
? trackNumber
429+
: null;
430+
}
431+
catch
432+
{
433+
return null;
434+
}
435+
}
436+
324437
public async Task<(TimeSpan Position, TimeSpan Duration)> GetTrackProgressAsync(string ip, CancellationToken cancellationToken = default)
325438
{
326439
try
@@ -720,6 +833,7 @@ public async Task PlayYouTubeAudioAsync(
720833
}
721834

722835
await SetQueueTransportAsync(ip, cancellationToken);
836+
await SeekToTrackAsync(ip, 1, cancellationToken);
723837
await _youTubePlaybackService.ActivateSessionAsync(session.SessionId, ip, cancellationToken);
724838
await StartPlaying(ip);
725839
}
@@ -852,6 +966,33 @@ private async Task SetQueueTransportAsync(string ip, CancellationToken cancellat
852966
response.EnsureSuccessStatusCode();
853967
}
854968

969+
private async Task SeekToTrackAsync(string ip, int trackNumber, CancellationToken cancellationToken)
970+
{
971+
if (trackNumber <= 0)
972+
{
973+
throw new ArgumentOutOfRangeException(nameof(trackNumber), "Track number must be >= 1.");
974+
}
975+
976+
var soapRequest = $@"
977+
<s:Envelope xmlns:s=""http://schemas.xmlsoap.org/soap/envelope/""
978+
s:encodingStyle=""http://schemas.xmlsoap.org/soap/encoding/"">
979+
<s:Body>
980+
<u:Seek xmlns:u=""urn:schemas-upnp-org:service:AVTransport:1"">
981+
<InstanceID>0</InstanceID>
982+
<Unit>TRACK_NR</Unit>
983+
<Target>{trackNumber}</Target>
984+
</u:Seek>
985+
</s:Body>
986+
</s:Envelope>";
987+
988+
using var content = new StringContent(soapRequest, Encoding.UTF8, "text/xml");
989+
content.Headers.Add("SOAPACTION", "\"urn:schemas-upnp-org:service:AVTransport:1#Seek\"");
990+
991+
var client = CreateClient();
992+
var response = await client.PostAsync($"http://{ip}:1400/MediaRenderer/AVTransport/Control", content, cancellationToken);
993+
response.EnsureSuccessStatusCode();
994+
}
995+
855996

856997
public async Task<SonosQueuePage> GetQueue(string ip, int startIndex = 0, int count = 100, CancellationToken cancellationToken = default)
857998
{
@@ -892,7 +1033,8 @@ public async Task<SonosQueuePage> GetQueue(string ip, int startIndex = 0, int co
8921033
response.EnsureSuccessStatusCode();
8931034

8941035
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
895-
return ParseQueueResponse(responseBody, startIndex, count);
1036+
var queuePage = ParseQueueResponse(responseBody, startIndex, count);
1037+
return await EnrichQueuePageWithYouTubeSessionMetadataAsync(queuePage, cancellationToken);
8961038
}
8971039
catch (OperationCanceledException)
8981040
{
@@ -905,6 +1047,48 @@ public async Task<SonosQueuePage> GetQueue(string ip, int startIndex = 0, int co
9051047
}
9061048
}
9071049

1050+
private async Task<SonosQueuePage> EnrichQueuePageWithYouTubeSessionMetadataAsync(SonosQueuePage queuePage, CancellationToken cancellationToken)
1051+
{
1052+
if (queuePage.Items.Count == 0)
1053+
{
1054+
return queuePage;
1055+
}
1056+
1057+
var enrichedItems = new List<SonosQueueItem>(queuePage.Items.Count);
1058+
foreach (var item in queuePage.Items)
1059+
{
1060+
if (!TryParseYouTubePlaybackUri(item.ResourceUri, out var sessionId, out var itemIndex))
1061+
{
1062+
enrichedItems.Add(item);
1063+
continue;
1064+
}
1065+
1066+
if (!string.IsNullOrWhiteSpace(item.Title)
1067+
&& !string.Equals(item.Title, "0", StringComparison.OrdinalIgnoreCase)
1068+
&& !string.IsNullOrWhiteSpace(item.Artist))
1069+
{
1070+
enrichedItems.Add(item);
1071+
continue;
1072+
}
1073+
1074+
var queueItem = await _youTubePlaybackService.GetQueueItemAsync(sessionId, itemIndex, cancellationToken);
1075+
if (queueItem is null)
1076+
{
1077+
enrichedItems.Add(item);
1078+
continue;
1079+
}
1080+
1081+
enrichedItems.Add(new SonosQueueItem(
1082+
item.Index,
1083+
string.IsNullOrWhiteSpace(queueItem.Title) ? item.Title : queueItem.Title,
1084+
string.IsNullOrWhiteSpace(queueItem.Artist) ? item.Artist : queueItem.Artist,
1085+
item.Album,
1086+
item.ResourceUri));
1087+
}
1088+
1089+
return new SonosQueuePage(enrichedItems, queuePage.StartIndex, queuePage.NumberReturned, queuePage.TotalMatches);
1090+
}
1091+
9081092
private static SonosQueuePage ParseQueueResponse(string responseBody, int startIndex, int requestedCount)
9091093
{
9101094
var items = new List<SonosQueueItem>();
@@ -932,18 +1116,28 @@ private static SonosQueuePage ParseQueueResponse(string responseBody, int startI
9321116
return new SonosQueuePage(items, startIndex, numberReturned, totalMatches);
9331117
}
9341118

935-
var decoded = WebUtility.HtmlDecode(resultElement.Value);
936-
if (string.IsNullOrWhiteSpace(decoded))
1119+
var didlPayload = resultElement.Value;
1120+
if (string.IsNullOrWhiteSpace(didlPayload))
9371121
{
938-
decoded = resultElement.Value;
1122+
return new SonosQueuePage(items, startIndex, numberReturned, totalMatches);
9391123
}
9401124

941-
if (string.IsNullOrWhiteSpace(decoded))
1125+
XDocument didl;
1126+
try
9421127
{
943-
return new SonosQueuePage(items, startIndex, numberReturned, totalMatches);
1128+
didl = XDocument.Parse(didlPayload);
1129+
}
1130+
catch (XmlException)
1131+
{
1132+
var decoded = WebUtility.HtmlDecode(didlPayload);
1133+
if (string.IsNullOrWhiteSpace(decoded))
1134+
{
1135+
return new SonosQueuePage(items, startIndex, numberReturned, totalMatches);
1136+
}
1137+
1138+
didl = XDocument.Parse(decoded);
9441139
}
9451140

946-
var didl = XDocument.Parse(decoded);
9471141
var itemElements = didl.Root?
9481142
.Elements()
9491143
.Where(e => e.Name.LocalName == "item")
@@ -1017,6 +1211,30 @@ private static SonosQueuePage ParseQueueResponse(string responseBody, int startI
10171211
return new SonosQueueItem(index, title.Trim(), artist?.Trim(), album?.Trim(), resourceUri?.Trim());
10181212
}
10191213

1214+
private static bool TryParseYouTubePlaybackUri(string? resourceUri, out string sessionId, out int itemIndex)
1215+
{
1216+
sessionId = string.Empty;
1217+
itemIndex = 0;
1218+
1219+
if (string.IsNullOrWhiteSpace(resourceUri))
1220+
{
1221+
return false;
1222+
}
1223+
1224+
var match = Regex.Match(
1225+
resourceUri,
1226+
@"/api/youtube-audio/(?<sessionId>[A-Za-z0-9]+)/(?<itemIndex>\d+)",
1227+
RegexOptions.IgnoreCase);
1228+
1229+
if (!match.Success)
1230+
{
1231+
return false;
1232+
}
1233+
1234+
sessionId = match.Groups["sessionId"].Value;
1235+
return int.TryParse(match.Groups["itemIndex"].Value, out itemIndex);
1236+
}
1237+
10201238
private static void ApplyStreamContentFallback(XElement element, ref string title, ref string? artist)
10211239
{
10221240
var streamContent = element.Elements().FirstOrDefault(e => e.Name.LocalName == "streamContent")?.Value;

0 commit comments

Comments
 (0)