<?php

define( 'MOV_TFHD_BASE_DATA_OFFSET',         0x01 );
define( 'MOV_TFHD_STSD_ID',                  0x02 );
define( 'MOV_TFHD_DEFAULT_DURATION',         0x08 );
define( 'MOV_TFHD_DEFAULT_SIZE',             0x10 );
define( 'MOV_TFHD_DEFAULT_FLAGS',            0x20 );
define( 'MOV_TFHD_DURATION_IS_EMPTY',    0x010000 );
define( 'MOV_TFHD_DEFAULT_BASE_IS_MOOF', 0x020000 );

define( 'MOV_TRUN_DATA_OFFSET',        0x01 );
define( 'MOV_TRUN_FIRST_SAMPLE_FLAGS', 0x04 );
define( 'MOV_TRUN_SAMPLE_DURATION',   0x100 );
define( 'MOV_TRUN_SAMPLE_SIZE',       0x200 );
define( 'MOV_TRUN_SAMPLE_FLAGS',      0x400 );
define( 'MOV_TRUN_SAMPLE_CTS',        0x800 );

class MP4Reader {

    /**
     * @var resource $file
     */
    protected $file;

    public function __construct( $file ) {
        $this->file = $file;
    }

    /**
     * @return int
     */
    public function pos() {
        $pos = ftell( $this->file );
        if ( $pos === false ) {
            throw new Exception( 'Failed to read position in MP4 file' );
        }
        return $pos;
    }

    /**
     * @param int $length number of bytes to read
     * @return string|false raw bytes or false on eof
     */
    public function readBytes( $length ) {
        $bytes = fread( $this->file, $length );
        if ( feof( $this->file ) ) {
            return false;
        }
        if ( $bytes === false || strlen( $bytes ) < $length ) {

            throw new Exception( "Failed to read $length bytes from MP4 file" );
        }
        return $bytes;
    }

    /**
     * @param int $length number of bytes to skip over
     */
    private function skipBytes( $length ) {
        $retval = fseek( $this->file, $length, SEEK_CUR );
        if ( $retval < 0 ) {
            throw new Exception( "Failed to skip ahead $length bytes in MP4 file" );
        }
    }

    /**
     * @return int|false 64-bit integer value or false on eof
     */
    public function read64() {
        $bytes = $this->readBytes( 8 );
        if ( $bytes === false ) {
            return false;
        }
        $data = unpack( 'Jval', $bytes );
        return $data['val'];
    }

    /**
     * @return int|false 32-bit integer value or false on eof
     */
    public function read32() {
        $bytes = $this->readBytes( 4 );
        if ( $bytes === false ) {
            return false;
        }
        $data = unpack( 'Nval', $bytes );
        return $data['val'];
    }

    /**
     * @return int|false 24-bit integer value or false on eof
     */
    public function read24() {
        $bytes = $this->readBytes( 3 );
        if ( $bytes === false ) {
            return false;
        }
        $data = unpack( 'Nval', "\x00$bytes" );
        return $data['val'];
    }

    /**
     * @return int|false 16-bit integer value or false on eof
     */
    public function read16() {
        $bytes = $this->readBytes( 2 );
        if ( $bytes === false ) {
            return false;
        }
        $data = unpack( 'nval', $bytes );
        return $data['val'];
    }

    /**
     * @return int|false 8-bit integer value or false on eof
     */
    public function read8() {
        $bytes = $this->readBytes( 1 );
        if ( $bytes === false ) {
            return false;
        }
        return ord( $bytes );
    }

    /**
     * @return string|false 4-byte type code or false on eof
     */
    public function readType() {
        return $this->readBytes( 4 );
    }

    /**
     * @param callable $callback in the form function(MP4Box)
     * @return bool true on success, or false on eof
     */
    public function readBox( $callback ) {
        $start = $this->pos();
        $size = $this->read32();
        if ( $size === false ) {
            return false;
        }
        $end = $start + $size;

        $type = $this->readType();
        if ( $type === false ) {
            return false;
        }

        $box = new MP4Box( $this->file, $start, $size, $type );
        $retval = call_user_func( $callback, $box );

        $remaining = $end - $this->pos();
        if ( $remaining > 0 ) {
            $this->skipBytes( $remaining );
        }
        return true;
    }

    /**
     * Scan a series of boxes and pass control based on type.
     * Unrecognized boxes will be skipped over.
     * @param callable[] $map array of callables keyed by fourCC type code
     */
    public function boxes( $map ) {
        $ok = true;
        while ( $ok ) {
            $ok = $this->readBox( function ( $box ) use ( $map ) {
                $handler = $map[$box->type] ?? false;
                if ( is_callable( $handler ) ) {
                    call_user_func( $handler, $box );
                } else if ( is_array( $handler ) ) {
                    $box->boxes( $handler );
                } else if ( $handler === false ) {
                    // no-op
                } else {
                    throw new Exception( "Unexpected callback or map type for type $box->type" );
                }
            } );
        }
    }
}

class MP4FileReader extends MP4Reader {
    /**
     * @param string $filename
     */
    public function __construct( $filename ) {
        $file = fopen( $filename, 'rb' );
        if ( !$file ) {
            throw new Exception( 'Failed to open MP4 input file' );
        }
        parent::__construct( $file );
    }

    function __destruct() {
        if ( $this->file ) {
            fclose( $this->file );
            $this->file = null;
        }
    }
}

class MP4Box extends MP4Reader {
    public $start;
    public $size;
    public $type;

    public function __construct( $file, $start, $size, $type ) {
        parent::__construct( $file );
        $this->start = $start;
        $this->size = $size;
        $this->type = $type;
    }

    public function end() {
        return $this->start + $this->size;
    }
    
    public function remaining() {
        return $this->end() - $this->pos();
    }

    public function readBytes( $length ) {
        if ( $length > $this->remaining() ) {
            return false;
        }
        return parent::readBytes( $length );
    }

}

function hexdump( $str ) {
    $out = '';
    $len = strlen( $str );
    for ( $i = 0; $i < $len; $i++ ) {
        $char = $str[$i];
        $byte = ord( $char );
        // really?
        $digits = str_pad( dechex( $byte ), 2, '0', STR_PAD_LEFT );
        $out .= $digits;
    }
    return $out;
}

function safestr( $str ) {
    $out = '';
    $len = strlen( $str );
    for ( $i = 0; $i < $len; $i++ ) {
        $char = $str[$i];
        $byte = ord( $char );
        if ( $byte >= 32 && $byte <= 126 ) {
            $out .= $char;
        } else {
            $out .= '.';
        }
    }
    return $out;
}

/**
 * @param string $filename - input MP4 file to read
 * @return array[] - list of segment info
 */
function extractFragmentedMP4( $filename ) {
    $segments = [];

    $mp4 = new MP4FileReader( $filename );
    $eof = false;
    $moof = false;
    $timestamp = 0.0;
    $duration = 0.0;
    $timescale = 0;
    $dts = 0;
    $first_pts = 0;
    $max_pts = 0;
    $init = false;

    /*
    Need to:
    - find the end of the moov; everything up to that is the initialization segment
    - https://www.w3.org/TR/mse-byte-stream-format-isobmff/#iso-init-segments
    - find the start of each styp+moof fragment
    - https://www.w3.org/TR/mse-byte-stream-format-isobmff/#iso-media-segments
    - find the start timestamp of each moof fragment
    - find the duration of each moof fragment

    moov.trak.mdia.mdhd.timescale - looks useful 12288 for 24fps?
    moov.trak.mdia.mdhd.duration - 0 on the moov
    moov.mvex.trex.default_sample_duration - 0 on the moov

    moof.traf.tfhd.default_sample_duration is 512 on these
    moof.traf.tfdt.baseMediaDecodeTime is 0 on the first, 122880 on the second frag (10 seconds in?)
    moof.mdat.sample_duration is empty here (uses default I guess?)
    moof.mdat.sample_composition_time_offset also empty


    opus has timescale 48000 in moov.trak.mdia.mdhd
    */
    $mp4->boxes( [
        'moov' => [
            'trak' => [
                'mdia' => [
                    'mdhd' => function ( $box ) use ( &$timescale ) {
                        $version = $box->read8();
                        $flags = $box->read24();
                        if ( $version == 1 ) {
                            $box->read64();
                            $box->read64();
                        } else {
                            $box->read32();
                            $box->read32();
                        }
                        $timescale = $box->read32();
                    }
                ],
            ],
        ],
        'moof' => function ( $box ) use  ( &$segments, &$moof, &$init, &$timestamp, &$duration, &$dts, &$first_pts, &$max_pts, &$timescale ) {
            if ( !$init ) {
                $init = [
                    'start' => 0,
                    'size' => $box->start,
                    'timestamp' => 0.0,
                    'duration' => 0.0,
                ];
                $segments['init'] = $init;
            }
            $moof = $box->start;
            $default_sample_duration = 0;
            $first_pts = 0;
            $max_pts = 0;
            $box->boxes( [
                'traf' => [
                    'tfhd' => function( $box ) use ( &$default_sample_duration ) {
                        $version = $box->read8();
                        $flags = $box->read24();

                        $track_id = $box->read32();
                        if ( $flags & MOV_TFHD_BASE_DATA_OFFSET ) {
                            $box->read64();
                        }
                        if ( $flags & MOV_TFHD_STSD_ID ) {
                            $box->read32();
                        }
                        if ( $flags & MOV_TFHD_DEFAULT_DURATION ) {
                            $default_sample_duration = $box->read32();
                        }
                    },
                    'trun' => function( $box ) use ( &$default_sample_duration, &$dts, &$first_pts, &$max_pts ) {
                        $version = $box->read8();
                        $flags = $box->read24();
                        $entries = $box->read32();
                        if ( $flags & MOV_TRUN_DATA_OFFSET ) {
                            $box->read32();
                        }
                        if ( $flags & MOV_TRUN_FIRST_SAMPLE_FLAGS ) {
                            $box->read32();
                        }
                        for ( $i = 0; $i < $entries; $i++ ) {
                            $pts = $dts;
                            $sample_duration = $default_sample_duration;
                            if ( $flags & MOV_TRUN_SAMPLE_DURATION ) {
                                $sample_duration = $box->read32();
                            }
                            if ( $flags & MOV_TRUN_SAMPLE_SIZE ) {
                                $box->read32();
                            }
                            if ( $flags & MOV_TRUN_SAMPLE_FLAGS ) {
                                $box->read32();
                            }
                            if ( $flags & MOV_TRUN_SAMPLE_CTS ) {
                                $pts += $box->read32();
                            }
                            if ( $i == 0 ) {
                                $first_pts = $pts;
                            }
                            $max_pts = max( $max_pts, $pts + $sample_duration );
                            $dts += $sample_duration;
                        }
                    }
                ],
            ] );
        },
        'mdat' => function ( $box ) use ( &$segments, &$moof, &$first_pts, &$max_pts, &$timescale ) {
            array_push( $segments, [
                'start' => $moof,
                'size' => $box->end() - $moof,
                'timestamp' => $first_pts / $timescale,
                'duration' => ( $max_pts - $first_pts ) / $timescale,
            ] );
        }
    ] );

    return $segments;
}

// http://www.mp3-tech.org/programmer/frame_header.html

class MP3FrameHeader {
    private $header;

    public $valid = false;
    public $size = 0;
    public $samples = 0;
    public $duration = 0.0;

    public $sync;
    public $mpeg;
    public $layer;
    public $protection;
    public $bitrate;
    public $sampleRate;
    public $padding;

    private static $bits = [
        'sync'        => [ 21, 11 ],
        'mpeg'        => [ 19,  2 ],
        'layer'       => [ 17,  2 ],
        'protection'  => [ 16,  1 ],
        'bitrate'     => [ 12,  4 ],
        'sampleRate'  => [ 10,  2 ],
        'padding'     => [  9,  1 ],
        'private'     => [  8,  1 ], // not needed below this
        'channelMode' => [  6,  2 ],
        'modeExt'     => [  4,  2 ],
        'copyright'   => [  3,  1 ],
        'original'    => [  2,  1 ],
        'emphasis'    => [  0,  2 ],
    ];

    private const SYNC_MASK = 0x7ff;

    private static $versions = [
        'MPEG-2.5',
        'reserved',
        'MPEG-2',
        'MPEG-1',
    ];

    private static $layers = [
        'reserved',
        'III',
        'II',
        'I',
    ];

    private static $samplesPerFrame = [
        // invalid / layer 3 / 2 / 1

        // MPEG-2.5
        [ 0, 576, 1152, 384 ],
        // Reserved
        [ 0, 0, 0, 0 ],
        // MPEG-2
        [ 0, 576, 1152, 384 ],
        // MPEG-1
        [ 0, 1152, 384, 384 ],
    ];

    // 1s used for reserved slots to avoid exploding
    // in case of invalid input
    private static $sampleRates = [
        // MPEG-2.5
        [ 11025, 12000, 8000, 1 ],
        // Reserved
        [ 1, 1, 1, 1 ],
        // MPEG-2
        [ 22050, 24000, 16000, 1 ],
        // MPEG-1
        [ 44100, 48000, 32000, 1 ],
    ];

    private static $bitrates = [
        // MPEG-2
        [
            // invalid layer
            [ 0, 0, 0, 0,  0, 0, 0, 0,  0, 0, 0, 0,  0, 0, 0, 0, ],
            // layer 3
            [ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 ],
            // layer 2
            [ 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 ],
            // layer 1
            [ 0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0 ],
        ],
        // MPEG-1
        [
            // invalid layer
            [ 0, 0, 0, 0,  0, 0, 0, 0,  0, 0, 0, 0,  0, 0, 0, 0, ],
            // layer 3
            [ 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 192, 224, 256, 320, 0 ],
            // layer 2
            [ 0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0 ],
            // layer 1
            [ 0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 316, 448, 0 ],
        ]
    ];

    private function field( $name ) {
        [ $shift, $bits ] = self::$bits[$name];
        $mask = ( 1 << $bits ) - 1;
        return ( $this->header >> $shift ) & $mask;
    }

    public function __construct( $header ) {
        $this->header = $header;

        $sync = $this->field( 'sync' );
        $this->sync = $sync == self::SYNC_MASK;
        if ( $this->sync ) {
            $mpeg = $this->field( 'mpeg' );
            $this->mpeg = self::$versions[$mpeg];

            $layer = $this->field( 'layer' );
            $this->layer = self::$layers[$layer];

            $protection = $this->field( 'protection' );
            $this->protection = !$protection;

            $br = $this->field( 'bitrate' );
            $this->bitrate = 1000 * self::$bitrates[$mpeg & 1][$layer][$br];

            $sr = $this->field( 'sampleRate' );
            $this->sampleRate = self::$sampleRates[$mpeg][$sr];

            $this->padding = $this->field( 'padding' );

            if ( $this->sync ) {
                if ( $this->bitrate == 0 ) {
                    $this->valid = false;
                    throw new Exception( "Invalid bitrate" );
                }
                if ( $this->sampleRate == 1 ) {
                    $this->valid = false;
                    throw new Exception( "Invalid sample rate" );
                }
                $this->valid = true;
            }

            $this->samples = self::$samplesPerFrame[$mpeg][$layer];
            $this->duration = $this->samples / $this->sampleRate;
            $nbits = $this->duration * $this->bitrate;
            $nbytes = $nbits / 8;
            $this->size = intval( $nbytes );
            if ( $this->protection ) {
                $this->size += 2;
            }
            if ( $this->padding ) {
                $this->size++;
            }
        }
    }
}

class MP3Reader {

    private $file;
    private $timestamp = 0.0;

    public function __construct( $file ) {
        $this->file = $file;
    }

    public function pos() {
        return ftell( $this->file );
    }

    public function readSegment() {
        while ( true ) {
            $start = $this->pos();
            $lookahead = 10;
            $bytes = fread( $this->file, $lookahead );
            if ( $bytes === false || strlen( $bytes ) < $lookahead ) {
                // end of file
                return false;
            }

            // Check for MP3 frame header sync pattern
            $data = unpack( "Nval", $bytes );
            $header = new MP3FrameHeader( $data['val'] );
            if ( $header->sync ) {
                // Note we don't need the data at this time.
                fseek( $this->file, $start + $header->size, SEEK_SET );
                $this->timestamp += $header->duration;
                return [
                    'start' => $start,
                    'size' => $header->size,
                    'timestamp' => $this->timestamp,
                    'duration' => $header->duration,
                ];
            }

            // ID3v2.3
            // https://web.archive.org/web/20081008034714/http://www.id3.org/id3v2.3.0
            // ID3v2/file identifier   "ID3" 
            // ID3v2 version           $03 00
            // ID3v2 flags             %abc00000
            // ID3v2 size              4 * %0xxxxxxx
            $id3 = unpack( "a3tag/nversion/Cflags/C4size", $bytes );
            if ( $id3['tag'] === 'ID3' ) {
                $size = $lookahead +
                    ( $id3['size4'] |
                        ( $id3['size3'] << 7) |
                        ( $id3['size2'] << 14) |
                        ( $id3['size1'] << 21) );
                // For byte range purposes; count as zero duration
                fseek( $this->file, $start + $size, SEEK_SET );
                return [
                    'start' => $start,
                    'size' => $size,
                    'timestamp' => $this->timestamp,
                    'duration' => 0.0,
                ];
            }
        
            $hex = hexdump( $bytes );
            $safe = safestr ( $bytes );
            throw new Exception("Invalid packet at $start? $hex $safe");

            // back up and try again
            fseek( $this->file, $start + 1, SEEK_SET );
        }
    }
}

function extractMP3( $filename ) {
    $file = fopen( $filename, 'rb' );
    if ( !$file ) {
        throw new Exception( 'Error opening MP3 file' );
    }
    try {
        $reader = new MP3Reader( $file );
        $segments = [];
        $timestamp = 0.0;
        while ( true ) {
            $start = $reader->pos();
            $segment = $reader->readSegment();
            if ( !$segment ) {
                break;
            }
            $segments[] = $segment;
        }
        return $segments;
    } finally {
        fclose( $file );
    }
}

function consolidate( $target, $segments ) {
    $out = [];
    if ( isset( $segments['init'] ) ) {
        $out['init'] = $segments['init'];
    }
    if ( count( $segments ) < 2 ) {
        return $segments;
    }

    $n = count( $segments );
    if ( isset( $segments['init'] ) ) {
        $n--;
    }
    $start = $segments[0]['start'];
    $size = $segments[0]['size'];
    $timestamp = $segments[0]['timestamp'];
    $duration = $segments[0]['duration'];
    //$nextTarget = $timestamp + $target;
    $nextDuration = $target;
    $i = 1;
    while ( $i < $n ) {
        // Append segments until we get close
        while ( $i < $n - 1 && $duration < $nextDuration ) {
            $total = $duration + $segments[$i]['duration'];
            if ( $total >= $nextDuration ) {
                $after = $total - $nextDuration;
                $before = $nextDuration - $duration;
                if ( $before < $after ) {
                    // Break segment early
                    break;
                }
            }
            $duration += $segments[$i]['duration'];
            $size += $segments[$i]['size'];
            $i++;
        }

        // Save out a segment
        $out[] = [
            'start' => $start,
            'size' => $size,
            'timestamp' => $timestamp,
            'duration' => $duration,
        ];
        //$nextTarget += $target;
        //$nextDuration = $nextTarget - $timestamp - $duration;

        if ( $i < $n ) {
            $segment = $segments[$i];
            $start = $segment['start'];
            $size = $segment['size'];
            $timestamp = $segment['timestamp'];
            $duration = $segment['duration'];
            $i++;
        }
    }
    $out[] = [
        'start' => $start,
        'size' => $size,
        'timestamp' => $timestamp,
        'duration' => $duration,
    ];
    return $out;
}

function playlist( $filename, $segments ) {
    /*
    #EXTM3U
    #EXT-X-VERSION:7
    #EXT-X-TARGETDURATION:10
    #EXT-X-MEDIA-SEQUENCE:0
    #EXT-X-PLAYLIST-TYPE:VOD
    #EXT-X-MAP:URI="new-vp9.mp4",BYTERANGE="811@0"
    #EXTINF:10.000000,
    #EXT-X-BYTERANGE:1058384@811
    new-vp9.mp4
    #EXTINF:10.000000,
    #EXT-X-BYTERANGE:1085979@1059195
    new-vp9.mp4
    #EXTINF:10.000000,
    #EXT-X-BYTERANGE:1268619@2145174
    new-vp9.mp4
    #EXTINF:10.000000,
    #EXT-X-BYTERANGE:1418664@3413793
    new-vp9.mp4
    #EXTINF:10.000000,
    #EXT-X-BYTERANGE:1129265@4832457
    new-vp9.mp4
    ...
    #EXT-X-ENDLIST
    */
    $lines = [];
    $lines[] = "#EXTM3U";
    $lines[] = "#EXT-X-VERSION:7";
    $lines[] = "#EXT-X-TARGETDURATION:10";
    $lines[] = "#EXT-MEDIA-SEQUENCE:0";
    $lines[] = "#EXT-PLAYLIST-TYPE:VOD";

    $init = $segments['init'] ?? false;
    if ( $init ) {
        $lines[] = "#EXT-X-MAP:URI=\"$filename\",BYTERANGE=\"{$init['size']}@{$init['start']}\"";
    }

    $n = count( $segments ) - 1;
    for ( $i = 0; $i < $n; $i++ ) {
        $segment = $segments[$i];
        $lines[] = "#EXTINF:{$segment['duration']},";
        $lines[] = "#EXT-X-BYTERANGE:{$segment['size']}@{$segment['start']}";
        $lines[] = $filename;
    }
    $lines[] = "#EXT-X-ENDLIST";

    return implode( "\n", $lines );
}

$argv = $_SERVER['argv'];
$self = array_shift( $argv );
$filename = array_shift( $argv );
$target = 10;

$ext = substr( $filename, strrpos( $filename, '.' ) );
if ( $ext === '.mp3' ) {
    $segments = extractMP3( $filename );
} elseif ( $ext === '.mp4' ) {
    $segments = extractFragmentedMP4( $filename );
} else {
    die( "Unexpected file extension $ext\n" );
}
$segments = consolidate( $target, $segments );

/*
foreach ( $segments as $key => $segment ) {
    if ( $key === 'init' ) {
        print "$key {$segment['start']},{$segment['size']}\n";
    } else {
        print "$key {$segment['timestamp']},{$segment['duration']} @ {$segment['start']},{$segment['size']}\n";
    }
}
*/
$m3u8 = playlist( urlencode( $filename ), $segments );
print $m3u8 . "\n";