Open JuniorJPDJ opened 3 years ago
Partially fixed in #77
We still need to figure out better solution.
Any ideas?
Maybe @TpmKranz (he authored #77) have some cool idea?
Also other thing - do someone have list of tracks loading as DASH?
We are using client id from Android Tidal with version code 1003 to avoid DASH at the moment. Would be cool to have some example returning DASH even on this client id for tests.
Oops, sorry. I should've looked for such an issue and referenced it in my PR/commit message.
We still need to figure out better solution.
What's the expectation here? ffplay $(something-that-returns-get_file_url.py)
already works -- if it's an actual URL, ffplay will look at that location and play the file, if it's a data URI encoding a DASH manifest, ffplay will decode that and play the manifest.
If needed, programs using the library can distinguish between manifest and URL from the scheme and take appropriate action, as in a quick and dirty quart app I use to play music:
@app.route("/file/<int:tid>.<string:ext>")
async def file(tid,ext):
if ext == "flac":
q=ti.AudioQuality.HiFi
elif ext == "aac":
q=ti.AudioQuality.High
else:
abort(404)
uri=await (await track(tid)).get_file_url(q, q)
if uri.startswith('data:'):
return urlopen(uri)
else:
return redirect(uri)
Also other thing - do someone have list of tracks loading as DASH?
We are using client id from Android Tidal with version code 1003 to avoid DASH at the moment. Would be cool to have some example returning DASH even on this client id for tests.
I've never received an actual URL with the client ID I got from a recent version of the APK, which was my initial motivation for #77. I didn't even know that DASH was tied to client ID version. That would also explain why I still had seven failing tests after #78. Maybe you could just run two test suites with distinct client ID versions to cover all cases?
Failed tests seems to be because our secrets are not propagated to forks even in pull request context. I tried to look for solution but it seems github changed something. I'll need to play with it a bit later.
Separate test suite for various client IDs seems to be good idea.
About dash itself - I was thinking about adding some function always returning streamable filelike. Now we have get_async_filelike, but it seems to be bad idea, as it's not always possible to gather one. What do you think about changing this function to provide stream decoded with ffmpeg in case of DASH and just streamable filelike from url in case of non-DASH data? We would leave your idea about file url and just postprocess it.
Also I wonder if it's easy to implement this without ffmpeg.
My expectation is to be able to easily save music as a file on disk or use it as file-like object in python software.
My expectation is to be able to easily save music as a file on disk or use it as file-like object in python software.
Thanks for clarifying. Please don't remove get_file_url
altogether, though. Even with file-like handling of tracks within python implemented, the raw URL/MPD manifest would still be useful for people who prefer to process the streams directly.
Also I wonder if it's easy to implement this without ffmpeg.
AFAICT, Tidal's DASH manifests are very simple, being comprised of only one SegmentTemplate. I've just tried downloading all the segments of a manifest with curl and concatenating them and I've gotten a perfectly playable file out of it.
Using ffmpeg, you would never have to worry about all that and need not even make a distinction between DASH and URL, though. Also, you could bake metadata right into the track, as at least the DASH streams contain only the raw audio. Maybe the following could be used? The third example looks like it could be adapted into a stream object: https://kkroening.github.io/ffmpeg-python/#ffmpeg.run_async
Thanks for clarifying. Please don't remove get_file_url altogether, though. Even with file-like handling of tracks within python implemented, the raw URL/MPD manifest would still be useful for people who prefer to process the streams directly.
Yup. Not gonna happen. It's useful ;D
AFAICT, Tidal's DASH manifests are very simple, being comprised of only one SegmentTemplate. I've just tried downloading all the segments of a manifest with curl and concatenating them and I've gotten a perfectly playable file out of it.
I tried concating it and I was unable to play the output file. I will probably need to play with it a bit more.
Using ffmpeg, you would never have to worry about all that and need not even make a distinction between DASH and URL, though. Also, you could bake metadata right into the track, as at least the DASH streams contain only the raw audio. Maybe the following could be used?
Problem with ffmpeg is that it's pretty big dependency and eg. on windows it's hard to get and make it working with python.
Tidal Android v2.37.1 (1025) is latest version allowing direct downloading. Later versions are responding with dash manifests. (Just checked)
Here are some ideas that may be useful.
This is an async generator that downloads and yields the raw file from get_file_url
that contains either the flac or aac stream (still untested with an actual URL, but MPD works):
async def raw_generator(file_uri, limit=io.DEFAULT_BUFFER_SIZE):
if file_uri.startswith("data:"):
mpd=dom.parse(urlopen(file_uri))
print(mpd.toprettyxml())
reps=mpd.getElementsByTagName("Representation")
rep=sorted(reps, reverse=True, key=lambda r: int(r.getAttribute("bandwidth")))[0]
tmpl=rep.getElementsByTagName("SegmentTemplate")[0]
timel=tmpl.getElementsByTagName("SegmentTimeline")[0]
segments=sum(1+int(e.getAttribute("r") or 0) for e in timel.getElementsByTagName("S"))
firstSegment=int(tmpl.getAttribute("startNumber"))
segmentTmpl=tmpl.getAttribute("media")
urls=chain([tmpl.getAttribute("initialization")],(segmentTmpl.replace("$Number$", f"{i}") for i in range(firstSegment, firstSegment+segments)))
else:
urls=[file_uri]
async with aiohttp.ClientSession() as session:
for u in urls:
print(u)
async with session.get(u) as response:
while True:
buffer=await response.content.read(limit)
if not buffer:
break
else:
yield buffer
This is an async generator that uses ffmpeg to remux the file into the proper container with metadata on the fly:
async def ffmpeg_generator(file_uri, container, metadata, cover, realtime=False, limit=io.DEFAULT_BUFFER_SIZE):
ffargs=([ '-re' ] if realtime else [] ) + ['-i', file_uri]
if cover is not None and container == 'flac':
ffargs.extend([
'-i', cover.get_url(size=(1280,1280)),
'-map', '0',
'-map', '1',
'-metadata:s:v', 'comment=Cover (front)',
'-disposition:v', 'attached_pic'
])
if container == 'ismv':
ffargs.extend(['-frag_duration', '1000'])
ffargs.extend(list(metadata_gen(metadata, aliases=container == 'flac'))+[
'-c', 'copy',
'-f', container,
'pipe:'
])
ff=None
try:
ff=await asyncio.create_subprocess_exec('ffmpeg', *ffargs, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, limit=limit)
while True:
buffer=await ff.stdout.read(limit)
if not buffer:
break
else:
yield buffer
except asyncio.CancelledError:
pass
finally:
if ff is not None:
try:
ff.terminate()
except ProcessLookupError:
pass
def metadata_gen(md, aliases=True):
keys=[
("title", None),
("album", None),
("artist", None),
("albumartist", None),
("track", "TRACKNUMBER"),
("copyright", None),
("date", None),
("isrc", None),
("rg_track_gain", "REPLAYGAIN_TRACK_GAIN"),
("rg_track_peak", "REPLAYGAIN_TRACK_PEAK"),
("artists", "ENSEMBLE"),
("barcode", "EAN/UPN")
]
kvgen=(
zip(
((key if not aliases else (alias if alias is not None else key.upper())) for _ in iter(int,1)),
md[key] if isinstance(md[key], list) else ([md[key]] if key in md else [])
) for (key, alias) in keys
)
for values in kvgen:
for (key,value) in values:
yield '-metadata'
yield f'{key}={value}'
Wow! Thanks! Amazing! I was already working on something similar behind the scenes but this can be helpful :D I'm trying to provide normal (but async) file-like object based on segments from dash manifest.
It's on finish line but I'm loaded with other things to do now so maybe I could push it at the weekend.
Two new separate libraries would be created: ConcatenatedSeekableFile
and another one directly related to DASH and parsing.
I'd like to be able to make it generic enough to use it with other services as FUMR is not gonna end on Tidal.
I'll probably not extract FLAC from MP4/M4A container as it would need ffmpeg dependency or loads of work with mp4 parsing.
Do you think FLAC inside MP4 is acceptable output?
If the goal is to just have a stream that contains the audio, then sure, any container will do (cf. raw_generator
).
ffmpeg_generator
had a different design goal, though. I wanted something that would deliver a complete ready-to-play-or-archive file, including metadata.
At the moment, I'm mainly listening to Tidal streams using XSPF playlists describing the metadata for a stream and the stream itself as readable by Strawberry, i.e. in a tidal:<track id>
pseudo-URI, so as to have a visually pleasing and informative listening experience. In the future, I'd like to switch to plain m3u playlists that point to self-contained streams, though.
I personally don't mind having to write generators like ffmpeg_generator
myself since tidal-async
's goal doesn't seem to be to provide a ready-to-use program, anyway. I've never used FUMR, though, so maybe that would give me what I'm looking for without writing the quart
glue myself.
Regardless, it may be useful for some people to provide self-contained streams. Those people would then have to have ffmpeg
installed if they wanted to use this hassle-free functionality, but everyone else could keep using tidal-async
as before – ffmpeg
wouldn't be a hard dependency for those people, just like androguard
isn't a hard dependency for people who don't need to extract a client id.
Bigger plan is providing more blocks to deliver very various things with exchangable parts.
Like I'd like to write telegram bot for downloading music already tagged and packed to zips in the fly, same with filesystem thing and maybe some sort of UI or mopidy plugin.
Tidal seems to migrate to DASH for their music streaming. Instead of JSON manifest file encoded into Base64 there's .mpd XML file which seems to play just fine using ffplay. Copying this into container-less FLAC format will be neccessary. ffmpeg is doing this just fine with this cmdline: