M3U8 playlists and .ts segments

Converting a M3U8 playlist to an mp3 file

  1. Master Playlist
  2. Media Playlist
  3. Encryption
  4. Demux
  5. Merging

I was recently working on my NHK-Easy web crawler project, since the site got an update with a number of breaking changes. One of these changes was concerning the audio of the articles.

Previously, each article had a corresponding static .mp3 resource. The update introduced a new HLS (HTTP Live Streaming) media player, accompanied by a M3U8 playlist. Furthermore, I observed that the audio was now split into several ts file segments, which were loaded on demand.

I had never worked with these file types before, so off I went to google. However, it wasn’t exactly easy to find answers to my questions, which is why I decided to summarize my findings here, in case other people have similar issues. Also, I wanted to have a go at writing my first post.

Given a master M3U8 playlist, the goal is to end up with a single mp3 file.

Master Playlist

The first step when dealing with M3U8, is parsing the master playlist.

I found the open-m3u8 library by iHeartRadio works best. The alternative is Comcast’s hlsparserj, but it lacks features and requires more manual work.

Once the master playlist is parsed, one can read its content, which consists of one or more media playlists.

Media Playlist

A typical media playlist could look like the following.

#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-ALLOW-CACHE:YES
#EXT-X-KEY:METHOD=AES-128,URI="https://site.net/path/to/resource.mp4/crypt.key?id=somekey"
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:1
#EXTINF:6.000,
https://site.net/path/to/resource.mp4/segment1_0_a.ts
#EXTINF:6.000,
https://site.net/path/to/resource.mp4/segment2_0_a.ts
#EXTINF:6.000,
https://site.net/path/to/resource.mp4/segment3_0_a.ts
#EXTINF:1.992,
https://site.net/path/to/resource.mp4/segment4_0_a.ts
#EXT-X-ENDLIST

It contains an url for each segment that the stream is made up of. Additionally, there are a number of meta tags, of which one is particularly important.

Encryption

What took me the longest to figure out was that the segments were encrypted. According to specification, MPEG-TS files (.ts) start with a BOM (Byte Order Mark) in the form of the ASCII char G. What confused me was, that there was no G, since the files were encrypted. Lesson learned, go read the specification.

Here is how I handle the encryption.

fun getCipher(data: EncryptionData): Cipher {
    val bytes = URL(data.uri).readBytes()
    val chainmode = "CBC"
    val method = when (data.method) {
        EncryptionMethod.AES -> "AES/$chainmode/NoPadding"
        else -> data.method.name
    }
    val keySpec = SecretKeySpec(bytes, data.method.name)
    logger.trace("Decrypting using method ${data.method} ($method)")
    return Cipher
        .getInstance(method)
        .apply { init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(ByteArray(16))) }
}

EncryptionData can be obtained from any track of the media playlist.

Critical for the success of decryption were the chainmode and the NoPadding argument.

  • Without any of those, e.g. just AES, the decryption would throw.
  • With NoPadding, but a different chainmode, decryption would succeed, but the files were corrupted.

I simply used trial and error until I found the right combination.

Demux

MPEG-TS files are not the most ordinary media format and are therefore more difficult to work with. Instead of .ts files, it would be nice if we could just work with the audio as wav files. I have tried to find a way to do this in java, but ended up using FFMPEG because there was no suitable library for the job. There is JCodec, but the api is awkward and it doesn’t seem like the required format is supported. FFMPEG is pretty easy to use via cli and supports a range of formats.

To demux a .ts segment to a .wav file, simply use the below code, where the target is a .wav File.

ffmpeg -i ${source.absolutePath} ${target.absolutePath}

Converting wav to mp3 is similarly easy:

ffmpeg -i ${file.absolutePath} -acodec libmp3lame ${target.absolutePath}

Merging

Last step: merging the individual segments into one file.

I use the following to merge audio streams.

fun joinAudioStreams(streams: Collection<AudioInputStream>): AudioInputStream {
    val vector = Vector<InputStream>(streams).elements()
    val format = streams.first().format
    return AudioInputStream(
        SequenceInputStream(vector),
        format,
        streams
            .map { it.frameLength }
            .reduce(Long::plus)
    )
}

AudioInputStreams are obtained from files using AudioSystem.getAudioInputStream and writing to file is done with AudioSystem.write.

And that’s basically how I went from M3U8 playlists and encrypted ts segments to a single mp3.


© 2024. All rights reserved.