Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: SiriusXM provider #989

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

scottt732
Copy link

An attempt at a provider for SiriusXM based on sxm-client (source, docs, example player)

This requires a valid SiriusXM streaming account (https://www.siriusxm.com/plans) and doesn't attempt to get around any restrictions they put in place. You can get 1 stream per account at a time.

Browsing is working, but I'm having trouble getting the audio to play properly. My python is rusty and I'm pretty new to async/await/async generators in python so... go easy on me or feel free to add commits ;-)

return prov


# noinspection PyUnusedLocal
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

none of our linters should require this ? what editor are you using for Python ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyCharm Professional. It showed mass, instance_id, action, values as unused params w/warnings. I wasn't sure if I could just remove them or should just ignore.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may never remove them but instead you inform the linter that it may be ignored.
We use the linters that are configured in the pre-commit config

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We all use VSCode btw ;-)

audio_format=AudioFormat(
sample_rate=44100,
channels=2,
bit_depth=32,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is very, very unlikely that the bit_depth is 32 bits (that is only used in recording studios)
I think you meant 16 bits here ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I thought the same thing. I pulled this from VLC media/codec info. I had never seen 32-bit + 44.1KHz in the wild before. Usually it would be like 96KHz or 192KHz when people are going overboard on bit depth.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, you would hear mangled audio if its set to the wrong bit depth so you will know soon enough :-)

yield await self._client.get_segment(playlist_path)

async def get_audio_stream(self, stream_details: StreamDetails, seek_position: int = 0) -> AsyncGenerator[bytes, None]:
async for chunk in self.get_playlist_items_generator(stream_details):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not going to work because as far as I can see the "get_playlist_items_generator" returns audio file URLs and not raw audio bytes... You will have to fetch the segment through the http client and then stream the content.

It could be as simple as this:

from music_assistant.server.helpers.audio import get_http_stream

async for playlist_chunk_url in self.get_playlist_items_generator(stream_details):
   async for chunk in get_http_stream(
                self.mass, playlist_chunk_url, streamdetails
            ):
                yield chunk

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way the sxm-client works (when you're not relying on their HTTP proxy endpoint) is that you request a playlist for a channel which returns an m3u8 file as a string. Here's a fragment of a random one I grabbed:

#EXTM3U
#EXT-X-VERSION:1
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:104665
#EXT-X-ALLOW-CACHE:NO
#EXT-X-KEY:METHOD=AES-128,URI="key/1"
#EXT-X-PROGRAM-DATE-TIME:2023-12-23T14:02:23.561+00:00
#EXTINF:10,
AAC_Data/cnn/HLS_cnn_256k_v3/cnn_256k_1_122350543561_00104665_v3.aac
#EXT-X-PROGRAM-DATE-TIME:2023-12-23T14:02:33.312+00:00
#EXTINF:10,
AAC_Data/cnn/HLS_cnn_256k_v3/cnn_256k_1_122350553312_00104666_v3.aac
#EXT-X-PROGRAM-DATE-TIME:2023-12-23T14:02:43.064+00:00
#EXTINF:10,
AAC_Data/cnn/HLS_cnn_256k_v3/cnn_256k_1_122350563064_00104667_v3.aac
...etc

While you can get the root URL from the client (a CDN or something), the HTTP requests that you'd make require session tokens and a bunch of other stuff. Their client handles that for you. So you make a request like this:

bytes = await sxm_client.get_segment('AAC_Data/cnn/HLS_cnn_256k_v3/cnn_256k_1_122350543561_00104665_v3.aac')

(via https://github.com/AngellusMortis/sxm-client/blob/dd25b56d7a8a7c33399b25e72a7c2e136fb30e6a/sxm/client.py#L382)

and you get back the bytes from the http response ... where the request had all of the session-related magic taken care of behind the scenes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yes, that sounds like exactly what you need then!

Copy link
Author

@scottt732 scottt732 Jan 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I was trying to achieve here is to get music assistant to basically pull the next segment as needed but not try to download them all. Like can I yield the bytes of file 1, then yield the bytes of file 2 when music assistant needs them, etc.? This is what I was trying to do here. Also do you know if I would need to break these segments down into smaller chunks & yield those?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you just need to make sure to grab each segment, yield chunks and then grab next segment yield chunks and so on. We have some helpers in the audio.py module to help with parsing the m3u file if you want that.

and yes, you need to yield the smaller chunks otherwise it will all be loaded in memory
Basically you just reimplement the "get_segment" function from the lib but instead of returning the resp.content, you just yield chunks from the resp.iter_any or resp.iter_chunked

So something along these lines within the get_audio_stream:

playlist_file = get_sirius_playlist_m3u8_file
playlist_segments = parse_playlist_into_the segment_parts

for playlist_segment in playlist_segments:
    url = urllib.parse.urljoin(await self.get_hls_root(), path)
    res = await self._session.get(url, params=self._token_params())

        if res.status_code == 403:
            raise SegmentRetrievalException(
                "Received status code 403 on segment, renew session"
            )

        if res.is_error:
            self._log.warn(f"Received status code {res.status_code} on segment")
            return None

        for chunk in res.iter_any():
            yield chunk

You could try first by just using the entire contents from the get_segment function btw, maybe its not that big

@scottt732
Copy link
Author

It turns out I needed to setup that http endpoint to get the sxm-client ~working. I was running into ffmpeg errors trying to calculate loudness and it looks like those segments I thought I was getting back as aac or m4a were actually hls or something along those lines. I verified with ffprobe that the segment data I was getting back was invalid.

With the latest commit, by passing the m3u8 path as direct=f"http://127.0.0.1:{self._bind_port}/{channel_id}.m3u8, I can get it to play the first minute or so (VLC is able to play the whole stream). I assume it's handling the first segment only. I see this warning coming from code outside my provider:

2024-01-04 17:19:55.285 WARNING (MainThread) [py.warnings] /home/scottt732/code/music-assistant/server/music_assistant/server/helpers/audio.py:435: RuntimeWarning: coroutine 'MusicProvider.get_audio_stream' was never awaited
  async for audio_chunk in music_prov.get_audio_stream(streamdetails, seek_pos):

I haven't had any luck with getting get_audio_stream to work with a generator yielding chunks of bytes. For some reason, it looks like the get_playlist generator called by get_audio_stream ends up getting called more than once for some reason (maybe get_audio_stream is getting called more than once). This is tricky b/c of the DRM restrictions server-side. I really need to somehow ensure that I'm only asking the client about 1 channel at a time and that I'm not trying to load the segments in parallel or out-of-order. I left this implementation commented out in case you have any pointers.

One other quick question... There is a callback from the sxm-client where I can be notified when the currently playing track changes. Is there any way I can update the footer of the web UI? I assumed this would be in/around self.mass.players but I wasn't sure how to find the player_id's that happen to be tuned in to this provider.

@marcelveldt
Copy link
Member

Exceptions for tasks can sometimes bubble up in a somewhat strange fashion with asyncio. In your case you can see the error MusicProvider.get_audio_stream' was never awaited which means something crashed. The code was porbbaly still calling the get_audio_stream in your provider Sure that happened too when you had set the "direct" attribute of the streamdetails.

if you fill streamdetails with the "direct" attribute it means we're not going to intercept with the stream but just feed the url into ffmpeg directly and let that deal with it. we do that for mpeg dash streams for example and also for local files.

You can set the streamdetails.streamtitle if you want for the updated track details from that callback. But those are small finetune details later. First focus on getting playback working. I have no way to test this to know what is going on but its getting harder to follow now that we're also spinning up a webserver to grab the audio bits. I still dont get why we cant just replicate what that webserver is doing and do that directly but for now its maybe good to use this at starting point.

@marcelveldt
Copy link
Member

marcelveldt commented Jan 21, 2024

OK, so I had a quick look and it looks like there's a lot more needed to get this working due to the whole HLS stuff.
Basically you want to replicate what the xsm-player is doing: https://github.com/AngellusMortis/sxm-player/blob/master/sxm_player/workers/base.py

That is, if you want to feed the audio chunks yourself (in the get_audio_stream) but for now I think its totally fine to use that webserver wrapper for it.

Unfortunately I cant test this myself (maybe I can test with a VPN?!) otherwise I could maybe help you trace the issue.

From what I can see it should work fine, if that m3u8 playlist is also working in VLC so maybe this is ffmpeg related.
Can you test the streaming with the loglevel for the server set to debug ? That will also print all traces from ffmpeg to the console

@marcelveldt
Copy link
Member

Sorry for the mega late reply! We had some serious bugs that needed to taken care of.
In the meanwhile I've built a HLS playlist parser into the code so we can now give this another test again.

@OzGav
Copy link
Contributor

OzGav commented May 6, 2024

hey @scottt732 just wondering if you have made any more progress?

@bs42
Copy link

bs42 commented Jul 17, 2024

Here is a different project that I use with my AmpliPi, it's a more recently written interface to SiriusXM, perhaps there's something useful in it that would help with this:

https://github.com/vszander/Amplipi/blob/main/python/sxm.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants