tamland / python-tidal

Python API for TIDAL music streaming service
GNU Lesser General Public License v3.0
402 stars 108 forks source link

[Question] Build url #230

Closed GioF71 closed 6 months ago

GioF71 commented 7 months ago

Hello, I am upgrading the Tidal plugin for upmpdcli to the latest version 0.7.4 of your library. Authentication mostly works, the navigation works perfectly just like with oauth2, but playback to the renderer fails. My renderer is another instance of upmpdcli with mpd of course.

I don't know if this is related to this issue of yours.

What I am currently doing is trying to decode the mpd (not MusicPD this time) manifest, and I get a list of url. But none of those actually play, neither in MPD or in any other player I tried, including VLC.

AFAIK MPD should be able to play MPEG-DASH (see here) so I am probably doing something wrong.

Do you have any suggestion for this issue?

Thank you

tehkillerbee commented 7 months ago

I have not yet looked into this but from my limited understanding, it is necessary to parse the MPEG-DASH manifest to create a playback URL suitable for your player. However, some players (ffmpeg) should be able to play directly.

Have you tried a different player?

https://stackoverflow.com/questions/47056177/how-to-play-mpeg-dash-audio-streams-from-console

GioF71 commented 7 months ago

No I only tried MPD and VLC, but I will try it asap. But how do you create that url which ends in .mpd? Thanks a lot

tehkillerbee commented 7 months ago

I did just have success with this simple example, using the tidalapi.Stream as the input and then getting the URLs from the manifest.

    try:
        manifest = json.loads(base64.b64decode(stream.manifest))
    except json.decoder.JSONDecodeError:
        #return f'data:application/dash+xml;charset=utf-8;base64,{stream.manifest}'
        return base64.b64decode(stream.manifest).decode('utf-8')
    return manifest["urls"][0]

However, only one URL is returned and it's quality is not HI_RES so not sure whats causing that.

EDIT: The above returned a single URL simply because my loaded session was not a PKCE session after all.

Added some more details. My previous example worked simply because the manifest did not contain any dash+xml (i.e. the mpd file) media. So the above example should handle both cases. Parsing the manifest with python mpegdash also appears to work as expected.

GioF71 commented 7 months ago

Hello, thank you again, but it seems to me that s.manifest once b64decoded is xml, not json, am I doing something wrong?

tehkillerbee commented 7 months ago

The output should be an MPD string with an XML declaration header? That is what I got.

GioF71 commented 7 months ago

Hello, this is the code I am trying. It is derived from the simple pkce login file. I have put your code in a get_url method, but it goes in the "except" and does not return a URL. Can you take a look? Thank you!

import tidalapi
from tidalapi import Quality
from pathlib import Path
import os
import json
import base64

from mpegdash.parser import MPEGDASHParser

if os.path.exists("tidal-session-pkce.json"):
    print("file exists")
else:
    print("file does not exist")

def get_url(stream):
    try:
        manifest = json.loads(base64.b64decode(stream.manifest))
    except json.decoder.JSONDecodeError:
        return f'data:application/dash+xml;base64,{base64.b64decode(stream.manifest)}'
    return manifest["urls"][0]

session_file1 = Path("tidal-session-pkce.json")

session = tidalapi.Session()
# Load session from file; create a new session if necessary
session.login_session_file(session_file1, do_pkce=True)

# Override the required playback quality, if necessary
# Note: Set the quality according to your subscription.
# Low: Quality.low_96k
# Normal: Quality.low_320k
# HiFi: Quality.high_lossless
# HiFi+ Quality.hi_res_lossless
session.audio_quality = Quality.hi_res_lossless.value

album = session.album("110827651") # Let's Rock // The Black Keys
tracks = album.tracks()
# list album tracks
for track in tracks:
    print(track.name)
    # MPEG-DASH Stream is only supported when HiRes mode is used!
    s = track.get_stream()
    url = get_url(s)
    print(f"URL=[{url}]")
GioF71 commented 7 months ago

Of course I have the json file with the credentials, so the login_session_file has success

tehkillerbee commented 7 months ago

Sorry, I edited my earlier example a while back.

You should change your get_url function to be the following

def get_url(stream):
    try:
        manifest = json.loads(base64.b64decode(stream.manifest))
    except json.decoder.JSONDecodeError:
        return base64.b64decode(stream.manifest).decode('utf-8')
    return manifest["urls"][0]

FYI, I am already working on extending the Stream class with this functionality and adding MPEG-DASH parsing.

GioF71 commented 7 months ago

Hello, thank you very much, this code works. The upmpdcli author tells me that I should serve the mpd url to MusicPD, but I don't know how to get that URL. Is that available through the library?

tehkillerbee commented 7 months ago

You should be able to feed music player daemon the manifest file for MPEG DASH streaming, i.e. the MPD XML, if your player supports it. Have you tried saving it as an mpd file?

This should also work if your ffmpeg installation has been built with the correct support:

ffmpeg -i tidal.mpd

If MPD expects a stream, you need to parse it further, eg to HLS format so it can be passed as an m3u8 file stream to your player. I got the HLS parsing working yesterday, based on some code snippets online, but it looks like there is a python library that can take care of it as well:

https://github.com/hyugogirubato/pydash2hls/tree/main
GioF71 commented 7 months ago

Hello, I will try saving the mpd to a file and play it, anyway Music Player Daemon expects a stream, I cannot assume it to be on the same host as the upmpdcli instance or that it can anyway access the file

GioF71 commented 7 months ago

Hello, I have successfully played the saved mpd file with ffmpeg, which is good!

However I couldn't generate the stream using the library. It fails when I load the xml manifest. Do you have a snippet of this conversion?

tehkillerbee commented 7 months ago

@GioF71 Good to hear you had success.

Sorry, I have not had time to look at it yet due to work. But I will try to get my conversion code added on a separate tidalapi branch so you can test with it.

GioF71 commented 7 months ago

Hello @tehkillerbee thank you. The success is partial but it seems to be showing that it might be possible to stream hires also. Thank you in advance for any help. I am also looking forward to see hires working on your mopidy-tidal as well, that would be nice Tidal Connect alternative!

tehkillerbee commented 7 months ago

Yes, what I have now is a M3U8 File for the HLS Stream. It can be loaded fine using ffmpeg (mp4 container with flac) and the bitrate is indeed 192khz. I would assume this HLS media playlist should work for you.

Ill clean up my code asap and get you a branch you can try to test with. I still need to figure out how to integrate all this with Mopidy :)

GioF71 commented 7 months ago

Hello, thank you for sharing your progress. I look forward to enable the tidal plugin for upmpdcli to work with hires files!

tehkillerbee commented 7 months ago

@GioF71 I have added my latest changes in this branch. This is still a work in progress but I think that is enough to get you started: python-tidal

For mopidy-tidal, I decided to create a local HLS m3u8 playlist (file://xx) and pass it to mopidy instead of the file URL. Everything works as expected so far when testing. Ofc. it requires the correct GStreamer plugins, i.e. sudo apt-get install gstreamer1.0-plugins-bad. It ain't pretty, as it is also still a WIP: mopidy-tidal

Of course there is still the challenge of generating the pkce enabled login file, without copying it in manually as I have done now.

GioF71 commented 7 months ago

Hello, thank you. I tried the pkce_login.py, but the resulting files are all empty. Am I doing something wrong?

tehkillerbee commented 7 months ago

@GioF71 Same album as in the example? I will check it later today and get back to you. I did some last minute changes that perhaps broke the example code...

GioF71 commented 7 months ago

Hello, yes, I did not change anything other than the path of my credentials file! Thank you!

tehkillerbee commented 7 months ago

@GioF71 Looks like the token refresh code was broken (I did get the same issue when the pkce session was somehow not valid anymore and I got a non MPEG-DASH stream). So this is the reason you get no playlist - there is no MPEG-DASH MPD available.

I have fixed this on the before mentioned branch. Maybe you'll need to login again to make sure the correct access token is used.

GioF71 commented 7 months ago

ok I have tried again, now the playlist are generated. However, both mpd and ffplay report errors:

MPD:

ffmpeg/mov,mp4,m4a,3gp,3g2,mj2: could not find corresponding trex (id 1)
ffmpeg/mov,mp4,m4a,3gp,3g2,mj2: could not find corresponding track id 0
ffmpeg/mov,mp4,m4a,3gp,3g2,mj2: trun track id unknown, no tfhd was found
ffmpeg/mov,mp4,m4a,3gp,3g2,mj2: error reading header
exception: Failed to decode https://sp-ad-fa.audio.tidal.com/mediatracks/some_chars/10.mp4?token=some_chars; avformat_open_input() failed: Invalid data found when processing input

ffplay:

[mov,mp4,m4a,3gp,3g2,mj2 @ 0xd6500630] could not find corresponding trex (id 1)
[mov,mp4,m4a,3gp,3g2,mj2 @ 0xd6500630] could not find corresponding track id 0
[mov,mp4,m4a,3gp,3g2,mj2 @ 0xd6500630] trun track id unknown, no tfhd was found
[mov,mp4,m4a,3gp,3g2,mj2 @ 0xd6500630] error reading header
https://sp-ad-fa.audio.tidal.com/mediatracks/some_chars/10.mp4?token=some_chars: Invalid data found when processing input
GioF71 commented 7 months ago

If I try the playlist with ffplay, I get:

[hls @ 0xd6400630] Skip ('#EXT-X-VERSION:3')  0KB sq=    0B f=0/0
[hls @ 0xd6400630] Opening 'https://sp-ad-fa.audio.tidal.com/mediatracks/some_chars/0.mp4?token=some_chars' for reading
[https @ 0xd641d5a0] Protocol 'https' not on whitelist 'file,crypto,data'!
[hls @ 0xd6400630] Failed to open segment 0 of playlist 0

How do you play those playlists?

GioF71 commented 7 months ago

tried to install but there is no difference :-(

GioF71 commented 7 months ago

HA, I added -protocol_whitelist file,http,https,tcp,tls,crypto to ffplay command line, and now it plays. Is any of your selected tracks 24/192?

GioF71 commented 7 months ago

set ffplay to play with alsa and it plays @192 kHz, that's really good progress. No clue how to convince mpd to play though Thank you!

tehkillerbee commented 7 months ago

Yep, that must be added as well for that error to go away. Yep 192kHz/24bit playback works for me.

Input #0, hls, from 'dash_77646169_77646173.m3u8':sq=    0B f=0/0   
  Duration: 00:05:41.55, start: 0.000000, bitrate: 0 kb/s
...
  Stream #0:0: Audio: flac (fLaC / 0x43614C66), 192000 Hz, stereo, s32 (24 bit), 4645 kb/s (default)
    Metadata
    ....
GioF71 commented 7 months ago

For playback to mpd, the author of upmpdcli told me I had to pass the url of the "MPD" (not music player daemon). Infact that played in ffmpeg. Is there a way to get that as a URL from your library?

tehkillerbee commented 7 months ago

Problem is you do not get an actual URL in the response from Tidal. In the response (json), you get a manifest that must be decoded to get you the MPD (MPEG-DASH) XML. From that, the HLS media playlist can be constructed for playback. Some players can process this string directly, it seems.

But I assume MPD (the daemon) can work with playlists too? So perhaps you can just generate a local playlist and pass that instead? That is what I did with mopidy and that worked just fine.

GioF71 commented 7 months ago

The problem is that I cannot write anything to mpd from upmpdcli (as a media server). I might ask the author if I can serve a playlist. But even if this is possible, the problem would then be that for one song I would have a playlist (so multiple tracks). This breaks every assumption upmpdcli (as a renderer) is based on.

GioF71 commented 7 months ago

Anyway mpd does not decode the individual URLs anyway, so at the moment this is the first hurdle

GioF71 commented 7 months ago

When I use BubbleUPnP own "Local and Cloud" with Tidal, the resulting URL for mpd is a plain flac. These is some processing on the BubbleUPnP app and/or BubbleUPnP server, that makes this possible. And of course the mpd+upmpdcli (renderer) stack in this case plays the track without any issue. It just 'sees' a regular flac over http. I wonder if this is something I can implement in the tidal plugin on upmpdcli.

tehkillerbee commented 7 months ago

When I use BubbleUPnP own "Local and Cloud" with Tidal, the resulting URL for mpd is a plain flac.

I assume BubbleUPnP only works for lower qualities - not HiRes?

The tidalapi plugin can already give you a direct flac URL for playback, but only for <HiRes. For HiRes, only MPEG-DASH stream is available. If you try calling .get_url() when configured for HiRes playback, you will not get any URL. But when using low/high/lossless, you will get MP4 or FLAC (non HiRes of course).

GioF71 commented 7 months ago

When I use BubbleUPnP own "Local and Cloud" with Tidal, the resulting URL for mpd is a plain flac.

I assume BubbleUPnP only works for lower qualities - not HiRes?

It works for hires since november of last year, see here

The tidalapi plugin can already give you a direct flac URL for playback, but only for <HiRes. For HiRes, only MPEG-DASH stream is available. If you try calling .get_url() when configured for HiRes playback, you will not get any URL. But when using low/high/lossless, you will get MP4 or FLAC (non HiRes of course).

An alternative options come to my mind, I could leverage those urls, cache joined files, and serve them, that should work, what do you think?

tehkillerbee commented 7 months ago

cache joined files

I would expect this requires buffering before playback can be started so maybe that's not ideal. But I don't see why not. I think that is the same way it's done with some of the tidal downloader tools so perhaps you could take a look at them.

Or perhaps look at gapless upnp? https://forum.wiimhome.com/threads/gapless-mode-setnexturi-issues-for-upnp-interface.478/

GioF71 commented 7 months ago

MPD already tries to acquire the next song to play in advance, iirc 20 seconds. So it can play gaplessly. But for this to work properly, I would need to build the combined files in time, that might be challenging for long tracks.

tehkillerbee commented 7 months ago

What I mean is you already have a list of URLs for each track so perhaps they could be queued for (gapless) playback. Then you avoid buffering the whole track.

Out of curiosity, how does MPD/upmpdcli deal with live radio m3u8 (HLS) playlists with multiple segments? Surely they can be played back with MPD without any issues?

It is the same principle here used by the playlist presented from tidalapi. It consists of segments, not individual tracks, so it should be handled as such by MPD (daemon), exactly as when playing back a radio stream. I am surprised if this is not possible to do with MPD, perhaps ask the developer for advice?

GioF71 commented 7 months ago

Hello, I have tried 'serving' the m3u8 file via http, and VLC plays the whole track without a problem. However, when I try to open the url using mpd, multiple items are enqueued instead of one, and anyway they won't play, because of a "unsupported uri scheme error".

MPD has a playlist plugin here but m3u8 does not seem to be among the supported formats.

Maybe that could be the issue, because if I try to add the first url in VLC, it doesn't work as well. Is there a chance to support any of the playlist formats that mpd supports?

GioF71 commented 7 months ago

Sorry I was wrong, the m3u8 files work perfectly. I was using Cantata to add the playlist, but it was probably doing something instead of feeding the m3u8 directly to mpd. I tried with mpc and it worked!

pi@pi-dac-player:~ $ MPD_PORT=6601 mpc add http://192.168.1.173:8299/dash_77646169_77646170.m3u8
pi@pi-dac-player:~ $ MPD_PORT=6601 mpc play
http://192.168.1.173:8299/dash_77646169_77646170.m3u8
[playing] #1/1   0:00/0:00 (0%)
volume:100%   repeat: off   random: off   single: off   consume: off
pi@pi-dac-player:~ $ 
GioF71 commented 7 months ago

Hello, looks like the latest version on the branch you prepared fails with this error:

77646170: 'The Golden Age' by 'Beck'
MimeType:application/dash+xml
Traceback (most recent call last):
  File "/home/power/git/audio/python-tidal/examples/pkce_login.py", line 53, in <module>
    stream.bit_depth,
AttributeError: 'Stream' object has no attribute 'bit_depth'

I am using the version from a few commits before (c03864a117c3c309b1f1988d8da8c735aa2a9293, says "Added link to documentation" in the message, and this works. Am I wrong? Thank you!

BTW I currently have one dev version of upmpdcli which works perfectly. I am not providing metadata like sample rate and bitrate, but it plays just fine. Also it works on Logitech Media Server

tehkillerbee commented 7 months ago

@GioF71 Great to hear you got it working!

Hmm I cannot replicate this. Are you sure you are not using an older version of tidalapi but with the latest script?

I added the metadata in cf42bc so the error hints that an older version is used.

GioF71 commented 7 months ago

Yes it's definitely possibile... I double checked but I must have done something wrong. Anyway, is a release coming soon? :-)

GioF71 commented 7 months ago

I confirm I was using a previous version in my python environment, this is solved now, thank you again

GioF71 commented 7 months ago

Another thing, the upmpdcli author asks me if we can try and save (then serve) the mpd file instead of the m3u8 files, does it sound possible to you? Thank you again!

tehkillerbee commented 7 months ago

@GioF71 Sure, it's already possible.

stream = track.get_stream()
manifest = stream.get_stream_manifest()
data = manifest.get_manifest_data()
with open("{}_{}.mpd".format(album_id, track.id), "w") as my_file:
    my_file.write(data)

But I will probably move the get_stream_manifest function into the Stream class instead since that makes more sense. So eventually, this would be a way to get the mpd file:

stream = track.get_stream()
data = stream.get_manifest_data()
with open("{}_{}.mpd".format(album_id, track.id), "w") as my_file:
    my_file.write(data)
GioF71 commented 7 months ago

Hello, thank you. I tried serving the mpd file instead of hls, and mpd (the player) works much better, allowing to seek the track, which did not work when using hls. I also see the bitdepth, sample rate and even bitrate (this is probably calculated by mpd because I did not provide it yet).

On the other hand, now Logitech Media Server refuses to play the stream. I can't find any log there. I see on the gui that the type is "application/octet-stream". Do I need to set a different mimetype when serving the mpd?

tehkillerbee commented 7 months ago

Good to hear. Yes I switched to using the mpd manifest directly, gstreamer doesn't mind and it works well in mopidy-tidal.

Regarding logitech media server, I am not sure. A possible explanation could be that the mimetype is not dash+xml for that album, resulting in you passing on a non mpd manifest to LMS. I added an extra check to mopidy-tidal to avoid that problem, in case the user is using oauth authentication.

What type of authentication are you using? Pkce?

GioF71 commented 7 months ago

Yes, PKCE of course. With oauth2, LMS works. I also tried a WiiM pro as a UPnP renderer, and it's the same: it works with oauth2, not with pkce

tehkillerbee commented 7 months ago

@GioF71 Odd. Since it works with oauth2, it must be related to the MPD manifest. Have you tried stripping the XML header before passing on the manifest?

GioF71 commented 7 months ago

I thought it was related to the ability to play a mpeg-dash stream... but I didn't try to strip xml header. But do you mean to remove just the following part?

<?xml version='1.0' encoding='UTF-8'?>