index.js

const fs = require('fs')
const path = require('path')
const events = require('node:events')
const ffmpeg = require('ffmpeg-static')

const error = require('./src/error')
const parse = require('./src/parse')
const segments = require('./src/segments')
const merge = require('./src/merge')
const transmux = require('./src/transmux')

class M3U8 extends events {
    /**
    * Create an M3U8 Instance Downloader.
    * @constructor
    * @param {object} opt - Options for instance
    */
    constructor(opt = {
        streamUrl: '',
        outputFile: '',
        quality: '',
        mergedPath: '',
        cache: '',
        concurrency: 20,
        ffmpegPath: ffmpeg,
        cb: function(event,data){}
    }) {
        super()

        let { streamUrl, outputFile, quality, mergedPath, cache, concurrency, ffmpegPath, cb } = opt
        let options = {
            streamUrl,
            output: outputFile,
            quality: quality || 'highest',
            mergedPath,
            cache: cache || path.join(require('os').tmpdir(), 'm3u8dl'),
            concurrency: concurrency || 20,
            captions: [],
            ffmpegPath: ffmpegPath || ffmpeg,
            cb: cb || function(){}
        }

        if(!options.streamUrl) throw new error('NO STREAM URL');
        if(!options.output) throw new error('PLEASE PROVIDE AN OUTPUT PATH');

        options.mergedPath = options.mergedPath || path.join(options.cache, 'merged.ts')

        this._options = options
        this.instance = this

        this.oldEmit = this.emit
        this.emit = function(one, ...args) {
            this._options.cb(one, ...args)
            return this.oldEmit(one, ...args)
        }
    }

    /**
    * Add a caption file.
    * @function
    * @param {string} uri - URI or Path of the caption
    * @param {string} lang - Language of the caption
    */
    addCaption(uri = null, lang = 'english') {
        this._options.captions.push({
            uri,
            lang
        })
    }

    /**
    * Starts the download
    * @function
    */
    startDownload() {
        let master = this
        let captions = this._options.captions

        master.emit('start')

        return new Promise(async(resolve) => {
            master.emit('parsing')

            let options = master._options
            let parsedSegments = await parse(options.streamUrl, options.quality, options.cache)

            if(!Array.isArray(parsedSegments)) {
                master.emit('error', parsedSegments)
                return resolve(new error(parsedSegments))
            }

            master.emit('segments_download:build')

            let data = await segments(parsedSegments, options.streamUrl, options.cache, options.concurrency, (event, data) => {
                return master.emit(`segments_download:${event}`, data)
            })
            if(!data || typeof data !== 'object' || !data.totalSegments || !Array.isArray(data.segments)) {
                master.emit('error', data)
                return resolve(new error(data))
            }

            master.emit('merging:start')

            let merged = await merge(data, options.mergedPath)
            if(merged !== 100) {
                master.emit('error', merged)
                return resolve(new error(merged))
            }

            master.emit('merging:end')
            master.emit('conversion:start')

            let to_mp4 = await transmux(options.mergedPath, options.output, options.ffmpegPath, captions, options.cache)
            if(to_mp4 !== 100) {
                master.emit('error', to_mp4)
                return resolve(new error(to_mp4))
            }

            master.emit('conversion:end')
            master.emit('end')

            if(fs.existsSync(options.cache)) await require('fs/promises').rm(options.cache, { recursive: true, force: true })

            return resolve(100)
        })
    }
}

module.exports = M3U8