Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.50% covered (success)
92.50%
74 / 80
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Segmenter
92.50% covered (success)
92.50%
74 / 80
20.00% covered (danger)
20.00%
1 / 5
23.22
0.00% covered (danger)
0.00%
0 / 1
 __construct
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 parse
n/a
0 / 0
n/a
0 / 0
0
 consolidate
95.65% covered (success)
95.65%
44 / 46
0.00% covered (danger)
0.00%
0 / 1
9
 rewrite
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 playlist
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 segment
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
8.51
1<?php
2/**
3 * Base class for streaming segment readers
4 *
5 * @file
6 * @ingroup HLS
7 */
8
9namespace MediaWiki\TimedMediaHandler\HLS;
10
11use LogicException;
12
13/**
14 * Base class for reading a media file, segmenting it, and writing
15 * out an HLS playlist for that specific track file.
16 *
17 * The HLS extended .m3u8 playlist format is described in
18 * informative RFC 8216.
19 */
20abstract class Segmenter {
21
22    protected string $filename;
23    protected array $segments;
24
25    public function __construct( string $filename, ?array $segments = null ) {
26        $this->filename = $filename;
27        if ( $segments ) {
28            $this->segments = $segments;
29        } else {
30            $this->segments = [];
31            $this->parse();
32        }
33    }
34
35    /**
36     * Fill the segments from the underlying file
37     */
38    abstract protected function parse(): void;
39
40    /**
41     * Consolidate adjacent segments to approach the target segment length.
42     */
43    public function consolidate( float $target ): void {
44        $out = [];
45        $n = count( $this->segments );
46        $init = $this->segments['init'] ?? false;
47        if ( $init ) {
48            $n--;
49            $out['init'] = $init;
50        }
51        if ( $n < 2 ) {
52            return;
53        }
54
55        $first = $this->segments[0];
56        $start = $first['start'];
57        $size = $first['size'];
58        $timestamp = $first['timestamp'];
59        $duration = $first['duration'];
60
61        $i = 1;
62        while ( $i < $n ) {
63            // Append segments until we get close
64            while ( $i < $n - 1 && $duration < $target ) {
65                $segment = $this->segments[$i];
66                $total = $duration + $segment['duration'];
67                if ( $total >= $target ) {
68                    $after = $total - $target;
69                    $before = $target - $duration;
70                    if ( $before < $after ) {
71                        // Break segment early
72                        break;
73                    }
74                }
75                $duration += $segment['duration'];
76                $size += $segment['size'];
77                $i++;
78            }
79
80            // Save out a segment
81            $out[] = [
82                'start' => $start,
83                'size' => $size,
84                'timestamp' => $timestamp,
85                'duration' => $duration,
86            ];
87
88            if ( $i < $n ) {
89                $segment = $this->segments[$i];
90                $start = $segment['start'];
91                $size = $segment['size'];
92                $timestamp = $segment['timestamp'];
93                $duration = $segment['duration'];
94                $i++;
95            }
96        }
97        $out[] = [
98            'start' => $start,
99            'size' => $size,
100            'timestamp' => $timestamp,
101            'duration' => $duration,
102        ];
103        $this->segments = $out;
104    }
105
106    /**
107     * Modify the media file and segments in-place to insert any
108     * tweaks needed for the file to stream correctly.
109     *
110     * This is used by MP3Segmenter to insert ID3 timestamps.
111     */
112    public function rewrite(): void {
113        // no-op in default; fragmented .mp4 can be left as-is
114    }
115
116    public function playlist( float $target, string $filename ): string {
117        $lines = [];
118        $lines[] = "#EXTM3U";
119        $lines[] = "#EXT-X-VERSION:7";
120        $lines[] = "#EXT-X-TARGETDURATION:$target";
121        $lines[] = "#EXT-MEDIA-SEQUENCE:0";
122        $lines[] = "#EXT-PLAYLIST-TYPE:VOD";
123
124        $url = wfUrlencode( $filename );
125
126        $init = $this->segments['init'] ?? false;
127        if ( $init ) {
128            $lines[] = "#EXT-X-MAP:URI=\"$url\",BYTERANGE=\"{$init['size']}@{$init['start']}\"";
129        }
130
131        $n = count( $this->segments ) - 1;
132        for ( $i = 0; $i < $n; $i++ ) {
133            $segment = $this->segments[$i];
134            $lines[] = "#EXTINF:{$segment['duration']},";
135            $lines[] = "#EXT-X-BYTERANGE:{$segment['size']}@{$segment['start']}";
136            $lines[] = $url;
137        }
138
139        $lines[] = "#EXT-X-ENDLIST";
140
141        return implode( "\n", $lines );
142    }
143
144    public static function segment( string $filename ): Segmenter {
145        $ext = strtolower( substr( $filename, strrpos( $filename, '.' ) ) );
146        switch ( $ext ) {
147            case '.mp3':
148                return new MP3Segmenter( $filename );
149            case '.mp4':
150            case '.m4v':
151            case '.m4a':
152            case '.mov':
153            case '.3gp':
154                return new MP4Segmenter( $filename );
155            default:
156                throw new LogicException( "Unexpected streaming file extension $ext" );
157        }
158    }
159}