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

MPEG DASH support #53

Open
JuniorJPDJ opened this issue Apr 30, 2021 · 11 comments · May be fixed by #82
Open

MPEG DASH support #53

JuniorJPDJ opened this issue Apr 30, 2021 · 11 comments · May be fixed by #82

Comments

@JuniorJPDJ
Copy link
Contributor

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:

$ ffmpeg -i tidal.mpd -c:v copy -c:a copy output.flac
[..]
Input #0, dash, from 'flac.mpd':
  Duration: 00:06:06.00, start: 0.000000, bitrate: 0 kb/s
  Program 0 
  Stream #0:0: Audio: flac (fLaC / 0x43614C66), 44100 Hz, stereo, s16, 505 kb/s (default)
    Metadata:
      variant_bitrate : 918113
      id              : 0
Output #0, flac, to 'output.flac':
  Metadata:
    encoder         : Lavf58.76.100
  Stream #0:0: Audio: flac (fLaC / 0x43614C66), 44100 Hz, stereo, s16, 505 kb/s (default)
    Metadata:
      variant_bitrate : 918113
      id              : 0
Stream mapping:
  Stream #0:0 -> #0:0 (copy)
[..]
@JuniorJPDJ
Copy link
Contributor Author

JuniorJPDJ commented Oct 3, 2021

Partially fixed in #77

We still need to figure out better solution.

Any ideas?

Maybe @TpmKranz (he authored #77) have some cool idea?

@JuniorJPDJ
Copy link
Contributor Author

JuniorJPDJ commented Oct 3, 2021

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.

@TpmKranz
Copy link
Contributor

TpmKranz commented Oct 4, 2021

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?

@JuniorJPDJ
Copy link
Contributor Author

JuniorJPDJ commented Oct 4, 2021

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.

@TpmKranz
Copy link
Contributor

TpmKranz commented Oct 4, 2021

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

@JuniorJPDJ
Copy link
Contributor Author

JuniorJPDJ commented Oct 4, 2021

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.

@JuniorJPDJ
Copy link
Contributor Author

JuniorJPDJ commented Oct 6, 2021

Tidal Android v2.37.1 (1025) is latest version allowing direct downloading. Later versions are responding with dash manifests. (Just checked)

@JuniorJPDJ JuniorJPDJ linked a pull request Oct 8, 2021 that will close this issue
@TpmKranz
Copy link
Contributor

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}'

@JuniorJPDJ
Copy link
Contributor Author

JuniorJPDJ commented Oct 13, 2021

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?

@TpmKranz
Copy link
Contributor

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.

@JuniorJPDJ
Copy link
Contributor Author

JuniorJPDJ commented Oct 20, 2021

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.

<streaming_service>-api is also ment to be replacable as I'm planning working on Deezer, Apple Music, Qobuz, Funkwhale and maybe something else.
That's why there's generic interface provided.
It's ment to be implemented by various streaming services libraries ;D

On the top of tidal-api there will go something for automated tagging using https://github.com/beetbox/mediafile and maybe other various post-processing parts.

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

Successfully merging a pull request may close this issue.

2 participants