1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* Class ID3 |
4
|
|
|
* |
5
|
|
|
* @created 22.09.2018 |
6
|
|
|
* @author smiley <[email protected]> |
7
|
|
|
* @copyright 2018 smiley |
8
|
|
|
* @license MIT |
9
|
|
|
*/ |
10
|
|
|
|
11
|
|
|
namespace chillerlan\ID3Tag; |
12
|
|
|
|
13
|
|
|
use stdClass; |
14
|
|
|
|
15
|
|
|
use function fclose, file_exists, floor, fopen, fread, fseek, ftell, in_array, is_file, |
16
|
|
|
is_readable, is_resource, ord, realpath, round, sprintf, strlen, strtolower, substr, unpack; |
17
|
|
|
|
18
|
|
|
use const PHP_INT_MAX, SEEK_END, SEEK_SET; |
19
|
|
|
|
20
|
|
|
/** |
21
|
|
|
* @link http://id3.org/id3guide |
22
|
|
|
* @link http://www.mp3-tech.org/ |
23
|
|
|
* @link https://en.wikipedia.org/wiki/ID3 |
24
|
|
|
* @link https://docs.microsoft.com/windows/desktop/wmformat/id3-tag-support |
25
|
|
|
* @link https://github.com/brokencube/id3 |
26
|
|
|
* @link http://www.zedwood.com/article/php-calculate-duration-of-mp3 |
27
|
|
|
* @link https://www.mp3tag.de/en/ |
28
|
|
|
* @link https://github.com/taglib/taglib/tree/master/tests |
29
|
|
|
*/ |
30
|
|
|
final class ID3{ |
31
|
|
|
|
32
|
|
|
private const BITRATES = [ |
33
|
|
|
0b0000 => [ 0, 0, 0, 0, 0], |
34
|
|
|
0b0001 => [ 32, 32, 32, 32, 8], |
35
|
|
|
0b0010 => [ 64, 48, 40, 48, 16], |
36
|
|
|
0b0011 => [ 96, 56, 48, 56, 24], |
37
|
|
|
0b0100 => [128, 64, 56, 64, 32], |
38
|
|
|
0b0101 => [160, 80, 64, 80, 40], |
39
|
|
|
0b0110 => [192, 96, 80, 96, 48], |
40
|
|
|
0b0111 => [224, 112, 96, 112, 56], |
41
|
|
|
0b1000 => [256, 128, 112, 128, 64], |
42
|
|
|
0b1001 => [288, 160, 128, 144, 80], |
43
|
|
|
0b1010 => [320, 192, 160, 160, 96], |
44
|
|
|
0b1011 => [352, 224, 192, 176, 112], |
45
|
|
|
0b1100 => [384, 256, 224, 192, 128], |
46
|
|
|
0b1101 => [416, 320, 256, 224, 144], |
47
|
|
|
0b1110 => [448, 384, 320, 256, 160], |
48
|
|
|
0b1111 => [ -1, -1, -1, -1, -1], |
49
|
|
|
]; |
50
|
|
|
|
51
|
|
|
private const SAMPLE_RATES = [ |
52
|
|
|
0b00 => [ |
53
|
|
|
0b00 => 11025, |
54
|
|
|
0b01 => 12000, |
55
|
|
|
0b10 => 8000, |
56
|
|
|
0b11 => 0, |
57
|
|
|
], |
58
|
|
|
0b10 => [ |
59
|
|
|
0b00 => 22050, |
60
|
|
|
0b01 => 24000, |
61
|
|
|
0b10 => 16000, |
62
|
|
|
0b11 => 0, |
63
|
|
|
], |
64
|
|
|
0b11 => [ |
65
|
|
|
0b00 => 44100, |
66
|
|
|
0b01 => 48000, |
67
|
|
|
0b10 => 32000, |
68
|
|
|
0b11 => 0, |
69
|
|
|
], |
70
|
|
|
]; |
71
|
|
|
|
72
|
|
|
/** |
73
|
|
|
* "Xing"/"Info" identification string at 0x0D (13), 0x15 (21) or 0x24 (36) |
74
|
|
|
*/ |
75
|
|
|
private const VBR_ID_STRING_POS = [ |
76
|
|
|
// v2.5 |
77
|
|
|
0b00 => [ |
78
|
|
|
0b00 => 21, // stereo |
79
|
|
|
0b01 => 21, // jntstereo |
80
|
|
|
0b10 => 21, // dual channel |
81
|
|
|
0b11 => 21, // mono |
82
|
|
|
], |
83
|
|
|
// v2 |
84
|
|
|
0b10 => [ |
85
|
|
|
0b00 => 21, |
86
|
|
|
0b01 => 21, |
87
|
|
|
0b10 => 21, |
88
|
|
|
0b11 => 13, |
89
|
|
|
], |
90
|
|
|
// v1 |
91
|
|
|
0b11 => [ |
92
|
|
|
0b00 => 36, |
93
|
|
|
0b01 => 36, |
94
|
|
|
0b10 => 36, |
95
|
|
|
0b11 => 21, |
96
|
|
|
], |
97
|
|
|
]; |
98
|
|
|
|
99
|
|
|
/** |
100
|
|
|
* @var resource |
101
|
|
|
*/ |
102
|
|
|
private $fh; |
103
|
|
|
|
104
|
|
|
/** |
105
|
|
|
* @return void |
106
|
|
|
*/ |
107
|
|
|
public function __destruct(){ |
108
|
|
|
|
109
|
|
|
if(is_resource($this->fh)){ |
110
|
|
|
fclose($this->fh); // @codeCoverageIgnore |
111
|
|
|
} |
112
|
|
|
|
113
|
|
|
} |
114
|
|
|
|
115
|
|
|
/** |
116
|
|
|
* @throws \chillerlan\ID3Tag\ID3Exception |
117
|
|
|
*/ |
118
|
|
|
public function read(string $filename):ID3Data{ |
119
|
|
|
$file = realpath($filename); |
120
|
|
|
|
121
|
|
|
if(!$file || !file_exists($file) || !is_file($file) || !is_readable($file)){ |
122
|
|
|
throw new ID3Exception(sprintf('invalid file: %s', $filename)); |
123
|
|
|
} |
124
|
|
|
|
125
|
|
|
$data = new ID3Data($file); |
126
|
|
|
$this->fh = fopen($file, 'rb'); |
127
|
|
|
|
128
|
|
|
// invalid resource or 2GB limit on 32-bit |
129
|
|
|
if(!$this->fh || $data->filesize > PHP_INT_MAX){ |
130
|
|
|
return $data; // @codeCoverageIgnore |
131
|
|
|
} |
132
|
|
|
|
133
|
|
|
// check for an ID3v1 tag |
134
|
|
|
fseek($this->fh, -128, SEEK_END); |
135
|
|
|
|
136
|
|
|
if(fread($this->fh, 3) === 'TAG'){ |
137
|
|
|
fseek($this->fh, -256, SEEK_END); |
138
|
|
|
|
139
|
|
|
$properties = [ |
140
|
|
|
'id3v1' => (new ID3v1)->parse(fread($this->fh, 256)), |
141
|
|
|
'v1tagsize' => 256, |
142
|
|
|
]; |
143
|
|
|
|
144
|
|
|
$data->setProperties($properties); |
145
|
|
|
} |
146
|
|
|
|
147
|
|
|
// check for an id3v2 tag |
148
|
|
|
fseek($this->fh, 0, SEEK_SET); |
149
|
|
|
|
150
|
|
|
if(fread($this->fh, 3) === 'ID3'){ |
151
|
|
|
fseek($this->fh, 6, SEEK_SET); |
152
|
|
|
$tagsize = ID3Helpers::syncSafeInteger(unpack('N', fread($this->fh, 4))[1]); |
153
|
|
|
fseek($this->fh, 0, SEEK_SET); |
154
|
|
|
|
155
|
|
|
$properties = [ |
156
|
|
|
'id3v2' => $this->readID3v2Tag(fread($this->fh, $tagsize)), |
157
|
|
|
'v2tagsize' => $tagsize + 10, |
158
|
|
|
]; |
159
|
|
|
|
160
|
|
|
$data->setProperties($properties); |
161
|
|
|
} |
162
|
|
|
|
163
|
|
|
$data->setProperties($this->getMP3Stats($data->filesize, $data->v1tagsize, $data->v2tagsize)); |
164
|
|
|
|
165
|
|
|
fclose($this->fh); |
166
|
|
|
|
167
|
|
|
return $data; |
168
|
|
|
} |
169
|
|
|
|
170
|
|
|
/** |
171
|
|
|
* |
172
|
|
|
* @noinspection PhpUnusedLocalVariableInspection |
173
|
|
|
*/ |
174
|
|
|
private function readID3v2Tag(string $rawdata):?array{ |
175
|
|
|
|
176
|
|
|
/** |
177
|
|
|
* 3.1. ID3v2 header |
178
|
|
|
* |
179
|
|
|
* The ID3v2 tag header, which should be the first information in the |
180
|
|
|
* file, is 10 bytes as follows: |
181
|
|
|
* |
182
|
|
|
* ID3v2/file identifier "ID3" |
183
|
|
|
* ID3v2 version $03 00 |
184
|
|
|
* ID3v2 flags %abc00000 |
185
|
|
|
* ID3v2 size 4 * %0xxxxxxx |
186
|
|
|
* |
187
|
|
|
* @link http://id3.org/id3v2.3.0#ID3v2_header |
188
|
|
|
*/ |
189
|
|
|
$id3version = ord(substr($rawdata, 3, 1)); |
190
|
|
|
|
191
|
|
|
if(!in_array($id3version, [2, 3, 4], true)){ |
192
|
|
|
# throw new ID3Exception('invalid id3 version'); |
193
|
|
|
return null; |
194
|
|
|
} |
195
|
|
|
|
196
|
|
|
$flags = ord(substr($rawdata, 5, 1)); |
197
|
|
|
|
198
|
|
|
$compression = false; |
|
|
|
|
199
|
|
|
$exthead = false; |
200
|
|
|
$experimental = false; |
|
|
|
|
201
|
|
|
$hasfooter = false; |
|
|
|
|
202
|
|
|
|
203
|
|
|
/** |
204
|
|
|
* a - Unsynchronisation |
205
|
|
|
* |
206
|
|
|
* Bit 7 in the 'ID3v2 flags' indicates whether or not |
207
|
|
|
* unsynchronisation is used (see section 5 for details); a set bit |
208
|
|
|
* indicates usage. |
209
|
|
|
*/ |
210
|
|
|
$unsync = (bool)($flags & 0b10000000); |
211
|
|
|
|
212
|
|
|
if($id3version === 2){ |
213
|
|
|
|
214
|
|
|
/** |
215
|
|
|
* b - Compression |
216
|
|
|
* |
217
|
|
|
* Bit 6 is indicating whether or not compression is used; |
218
|
|
|
* a set bit indicates usage. Since no compression scheme has been |
219
|
|
|
* decided yet, the ID3 decoder (for now) should just ignore the entire |
220
|
|
|
* tag if the compression bit is set. |
221
|
|
|
*/ |
222
|
|
|
$compression = (bool)($flags & 0b01000000); |
223
|
|
|
} |
224
|
|
|
|
225
|
|
|
if($id3version === 3 || $id3version === 4){ |
226
|
|
|
|
227
|
|
|
/** |
228
|
|
|
* b - Extended header |
229
|
|
|
* |
230
|
|
|
* Bit 6 indicates whether or not the header is followed by an |
231
|
|
|
* extended header. The extended header is described in section 3.2. |
232
|
|
|
*/ |
233
|
|
|
$exthead = (bool)($flags & 0b01000000); |
234
|
|
|
|
235
|
|
|
/** |
236
|
|
|
* c - Experimental indicator |
237
|
|
|
* |
238
|
|
|
* Bit 5 should be used as an 'experimental indicator'. |
239
|
|
|
* This flag should always be set when the tag is in an experimental stage. |
240
|
|
|
*/ |
241
|
|
|
$experimental = (bool)($flags & 0b00100000); |
242
|
|
|
} |
243
|
|
|
|
244
|
|
|
if($id3version === 4){ |
245
|
|
|
|
246
|
|
|
/** |
247
|
|
|
* d - Footer present |
248
|
|
|
* |
249
|
|
|
* Bit 4 indicates that a footer (section 3.4) is present at the very |
250
|
|
|
* end of the tag. A set bit indicates the presence of a footer. |
251
|
|
|
*/ |
252
|
|
|
$hasfooter = (bool)($flags & 0b00010000); |
253
|
|
|
} |
254
|
|
|
|
255
|
|
|
$start = 10; |
256
|
|
|
|
257
|
|
|
/** |
258
|
|
|
* 3.2. ID3v2.3 extended header |
259
|
|
|
* |
260
|
|
|
* @link http://id3.org/id3v2.3.0#ID3v2_extended_header |
261
|
|
|
* |
262
|
|
|
* Extended header size $xx xx xx xx |
263
|
|
|
* Extended Flags $xx xx |
264
|
|
|
* Size of padding $xx xx xx xx |
265
|
|
|
*/ |
266
|
|
|
if($exthead){ |
267
|
|
|
$extHeaderSize = ID3Helpers::syncSafeInteger(unpack('N', substr($rawdata, $start, 4))[1]); |
268
|
|
|
$start += 4; |
269
|
|
|
// @todo (skipping the extended header for now...) |
270
|
|
|
/** @noinspection PhpUnusedLocalVariableInspection */ |
271
|
|
|
$extHeader = substr($rawdata, $start, $extHeaderSize); |
|
|
|
|
272
|
|
|
$start += $extHeaderSize; |
273
|
|
|
} |
274
|
|
|
|
275
|
|
|
$tagdata = substr($rawdata, $start); |
276
|
|
|
|
277
|
|
|
if($unsync && $id3version <= 3){ |
278
|
|
|
$tagdata = ID3Helpers::unsyncString($tagdata); |
279
|
|
|
} |
280
|
|
|
|
281
|
|
|
$parser = __NAMESPACE__.'\\ID3v2'.$id3version; |
282
|
|
|
|
283
|
|
|
return (new $parser)->parse($tagdata); |
284
|
|
|
} |
285
|
|
|
|
286
|
|
|
/** |
287
|
|
|
* |
288
|
|
|
*/ |
289
|
|
|
private function getMP3Stats(int $filesize, int $v1Tagsize, int $v2Tagsize):array{ |
290
|
|
|
$offset = $this->getMP3FrameStart($filesize, $v1Tagsize, $v2Tagsize); |
291
|
|
|
$framecount = 0; |
292
|
|
|
$duration = 0.0; |
293
|
|
|
$bitrate = 0; |
294
|
|
|
|
295
|
|
|
if($offset === null){ |
296
|
|
|
return []; |
297
|
|
|
} |
298
|
|
|
|
299
|
|
|
while($offset < $filesize){ |
300
|
|
|
$frame = $this->parseMP3FrameHeader($offset); |
301
|
|
|
|
302
|
|
|
if($frame === null){ |
303
|
|
|
$offset = $this->getMP3FrameStart($filesize, $v1Tagsize, $offset); |
304
|
|
|
|
305
|
|
|
if($offset !== null){ |
306
|
|
|
continue; |
307
|
|
|
} |
308
|
|
|
|
309
|
|
|
break; |
310
|
|
|
} |
311
|
|
|
|
312
|
|
|
/** |
313
|
|
|
* In the Info Tag, the "Xing" identification string at 0x0D (13), |
314
|
|
|
* 0x15 (21) or 0x24 (36) (depending on MPEG layer and number of channels) |
315
|
|
|
* of the header is replaced by "Info" in case of a CBR file. |
316
|
|
|
* |
317
|
|
|
* This was done to avoid CBR files to be recognized as traditional |
318
|
|
|
* Xing VBR files by some decoders. Although the two identification |
319
|
|
|
* strings "Xing" and "Info" are both valid, it is suggested that you |
320
|
|
|
* keep the identification string "Xing" in case of VBR bistream |
321
|
|
|
* in order to keep compatibility. |
322
|
|
|
* |
323
|
|
|
* @link http://gabriel.mp3-tech.org/mp3infotag.html |
324
|
|
|
*/ |
325
|
|
|
if($framecount === 0){ |
326
|
|
|
$pos = $this::VBR_ID_STRING_POS[$frame->version][$frame->channelmode] ?? 36; |
327
|
|
|
fseek($this->fh, $offset + $pos, SEEK_SET); |
328
|
|
|
|
329
|
|
|
if(strtolower(fread($this->fh, 4)) !== 'xing'){ // lower just in case someone thinks they're special |
330
|
|
|
|
331
|
|
|
return [ |
332
|
|
|
'duration' => (int)round(($filesize - $v1Tagsize - $v2Tagsize) / (($frame->bitrate * 1000) / 8)), |
333
|
|
|
'bitrate' => $frame->bitrate, |
334
|
|
|
]; |
335
|
|
|
} |
336
|
|
|
|
337
|
|
|
} |
338
|
|
|
|
339
|
|
|
$duration += $frame->duration; |
340
|
|
|
$offset += $frame->length; |
341
|
|
|
$bitrate += $frame->bitrate; |
342
|
|
|
|
343
|
|
|
$framecount++; |
344
|
|
|
} |
345
|
|
|
|
346
|
|
|
return [ |
347
|
|
|
'duration' => (int)round($duration), |
348
|
|
|
'bitrate' => (int)round($bitrate / $framecount), |
349
|
|
|
'framecount' => $framecount, |
350
|
|
|
]; |
351
|
|
|
} |
352
|
|
|
|
353
|
|
|
/** |
354
|
|
|
* |
355
|
|
|
*/ |
356
|
|
|
private function getMP3FrameStart(int $filesize, int $v1Tagsize, int $offset):?int{ |
357
|
|
|
fseek($this->fh, $offset, SEEK_SET); |
358
|
|
|
$size = $filesize - $v1Tagsize; |
359
|
|
|
|
360
|
|
|
while($offset < $size){ |
361
|
|
|
$offset++; |
362
|
|
|
|
363
|
|
|
$byte = fread($this->fh, 1); |
364
|
|
|
|
365
|
|
|
if($byte === false){ |
366
|
|
|
return null; |
367
|
|
|
} |
368
|
|
|
|
369
|
|
|
if($byte === "\xff"){ |
370
|
|
|
$byte = fread($this->fh, 1); |
371
|
|
|
|
372
|
|
|
if($byte === "\xf3" ||$byte === "\xfa" || $byte === "\xfb"){// |
373
|
|
|
$offset = ftell($this->fh) - 2; |
374
|
|
|
fseek($this->fh, $offset, SEEK_SET); |
375
|
|
|
|
376
|
|
|
return $offset; |
377
|
|
|
} |
378
|
|
|
|
379
|
|
|
} |
380
|
|
|
|
381
|
|
|
} |
382
|
|
|
|
383
|
|
|
return null; |
384
|
|
|
} |
385
|
|
|
|
386
|
|
|
/** |
387
|
|
|
* |
388
|
|
|
*/ |
389
|
|
|
private function parseMP3FrameHeader(int $offset):?stdClass{ |
390
|
|
|
fseek($this->fh, $offset, SEEK_SET); |
391
|
|
|
|
392
|
|
|
$headerBytes = fread($this->fh, 4); |
393
|
|
|
|
394
|
|
|
if(strlen($headerBytes) !== 4 || !($headerBytes[0] === "\xff" && (ord($headerBytes[1]) & 0xe0))){ |
395
|
|
|
return null; |
396
|
|
|
} |
397
|
|
|
|
398
|
|
|
$b1 = ord($headerBytes[1]); |
399
|
|
|
$b2 = ord($headerBytes[2]); |
400
|
|
|
$b3 = ord($headerBytes[3]); |
401
|
|
|
|
402
|
|
|
$info = new stdClass; |
403
|
|
|
|
404
|
|
|
/** |
405
|
|
|
* http://www.mp3-tech.org/programmer/frame_header.html |
406
|
|
|
* AAAA AAAA AAAB BCCD EEEE FFGH IIJJ KLMM |
407
|
|
|
* A - Frame sync (all bits set) |
408
|
|
|
* B - MPEG Audio version ID |
409
|
|
|
* C - Layer description |
410
|
|
|
* D - Protection bit |
411
|
|
|
* E - Bitrate index |
412
|
|
|
* F - Sampling rate frequency index |
413
|
|
|
* G - Padding bit |
414
|
|
|
* H - Private bit |
415
|
|
|
* I - Channel Mode |
416
|
|
|
* J - Mode extension (Only if Joint stereo) |
417
|
|
|
* K - Copyright |
418
|
|
|
* L - Original |
419
|
|
|
* M - Emphasis |
420
|
|
|
*/ |
421
|
|
|
# $info->sync = (bigEndian2Int(substr($headerBytes, 0, 2)) & 0xFFE0) >> 4; |
422
|
|
|
$info->version = ($b1 & 0x18) >> 3; // BB |
423
|
|
|
$info->layer = ($b1 & 0x06) >> 1; // CC |
424
|
|
|
# $info->protection = (bool)($b1 & 0x01); // D |
425
|
|
|
$info->bitrateIndex = ($b2 & 0xF0) >> 4; // EEEE |
426
|
|
|
$info->samplerate = ($b2 & 0x0C) >> 2; // FF |
427
|
|
|
$info->padding = (bool)(($b2 & 0x02) >> 1); // G |
428
|
|
|
# $info->private = (bool)($b2 & 0x01); // H |
429
|
|
|
$info->channelmode = ($b3 & 0xC0) >> 6; // II |
430
|
|
|
# $info->modeextension = ($b3 & 0x30) >> 4; // JJ |
431
|
|
|
# $info->copyright = (bool)(($b3 & 0x08) >> 3); // K |
432
|
|
|
# $info->original = (bool)(($b3 & 0x04) >> 2); // L |
433
|
|
|
# $info->emphasis = ($b3 & 0x03); // MM |
434
|
|
|
|
435
|
|
|
if(!in_array($info->version, [0b00, 0b10, 0b11]) || !in_array($info->layer, [0b01, 0b10, 0b11])){ |
436
|
|
|
return null; |
437
|
|
|
} |
438
|
|
|
|
439
|
|
|
$sampleRate = $this::SAMPLE_RATES[$info->version][$info->samplerate]; |
440
|
|
|
|
441
|
|
|
if($sampleRate <= 0){ |
442
|
|
|
// Invalid sample rate value |
443
|
|
|
return null; |
444
|
|
|
} |
445
|
|
|
|
446
|
|
|
$bitRate = 0; |
447
|
|
|
|
448
|
|
|
if($info->version === 0b11){ |
449
|
|
|
switch($info->layer){ |
450
|
|
|
case 0b11: |
451
|
|
|
$bitRate = $this::BITRATES[$info->bitrateIndex][0]; |
452
|
|
|
break; |
453
|
|
|
case 0b10: |
454
|
|
|
$bitRate = $this::BITRATES[$info->bitrateIndex][1]; |
455
|
|
|
break; |
456
|
|
|
case 0b01: |
457
|
|
|
$bitRate = $this::BITRATES[$info->bitrateIndex][2]; |
458
|
|
|
break; |
459
|
|
|
} |
460
|
|
|
} |
461
|
|
|
else{ |
462
|
|
|
switch($info->layer){ |
463
|
|
|
case 0b11: |
464
|
|
|
$bitRate = $this::BITRATES[$info->bitrateIndex][3]; |
465
|
|
|
break; |
466
|
|
|
case 0b10: |
467
|
|
|
case 0b01: |
468
|
|
|
$bitRate = $this::BITRATES[$info->bitrateIndex][4]; |
469
|
|
|
break; |
470
|
|
|
} |
471
|
|
|
} |
472
|
|
|
|
473
|
|
|
if($bitRate <= 0){ |
474
|
|
|
return null; // bitrate "free" |
475
|
|
|
} |
476
|
|
|
|
477
|
|
|
$info->bitrate = $bitRate; |
478
|
|
|
$bitRate *= 1000; |
479
|
|
|
|
480
|
|
|
if($info->layer === 0b11){ |
481
|
|
|
$info->length = (((12 * $bitRate) / $sampleRate) + (int)$info->padding) * 4; |
482
|
|
|
} |
483
|
|
|
elseif($info->layer === 0b10 || $info->layer === 0b01){ |
484
|
|
|
$info->length = ((144 * $bitRate) / $sampleRate) + (int)$info->padding; |
485
|
|
|
} |
486
|
|
|
|
487
|
|
|
$info->length = floor($info->length); |
488
|
|
|
|
489
|
|
|
if($info->length <= 0){ |
490
|
|
|
return null; |
491
|
|
|
} |
492
|
|
|
|
493
|
|
|
$info->duration = $info->length * 8 / $bitRate; |
494
|
|
|
|
495
|
|
|
return $info; |
496
|
|
|
} |
497
|
|
|
|
498
|
|
|
/** |
499
|
|
|
* @todo |
500
|
|
|
*/ |
501
|
|
|
# public function write(iterable $data):bool{ |
502
|
|
|
# return false; |
503
|
|
|
# } |
504
|
|
|
|
505
|
|
|
} |
506
|
|
|
|