@@ -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