938625a855f82119c34549c96d0b60f2aa0ec647
[lhc/web/www.git] / www / plugins-dist / medias / lib / getid3 / module.tag.apetag.php
1 <?php
2 /////////////////////////////////////////////////////////////////
3 /// getID3() by James Heinrich <info@getid3.org> //
4 // available at http://getid3.sourceforge.net //
5 // or http://www.getid3.org //
6 // also https://github.com/JamesHeinrich/getID3 //
7 /////////////////////////////////////////////////////////////////
8 // See readme.txt for more details //
9 /////////////////////////////////////////////////////////////////
10 // //
11 // module.tag.apetag.php //
12 // module for analyzing APE tags //
13 // dependencies: NONE //
14 // ///
15 /////////////////////////////////////////////////////////////////
16
17 class getid3_apetag extends getid3_handler
18 {
19 public $inline_attachments = true; // true: return full data for all attachments; false: return no data for all attachments; integer: return data for attachments <= than this; string: save as file to this directory
20 public $overrideendoffset = 0;
21
22 public function Analyze() {
23 $info = &$this->getid3->info;
24
25 if (!getid3_lib::intValueSupported($info['filesize'])) {
26 $this->warning('Unable to check for APEtags because file is larger than '.round(PHP_INT_MAX / 1073741824).'GB');
27 return false;
28 }
29
30 $id3v1tagsize = 128;
31 $apetagheadersize = 32;
32 $lyrics3tagsize = 10;
33
34 if ($this->overrideendoffset == 0) {
35
36 $this->fseek(0 - $id3v1tagsize - $apetagheadersize - $lyrics3tagsize, SEEK_END);
37 $APEfooterID3v1 = $this->fread($id3v1tagsize + $apetagheadersize + $lyrics3tagsize);
38
39 //if (preg_match('/APETAGEX.{24}TAG.{125}$/i', $APEfooterID3v1)) {
40 if (substr($APEfooterID3v1, strlen($APEfooterID3v1) - $id3v1tagsize - $apetagheadersize, 8) == 'APETAGEX') {
41
42 // APE tag found before ID3v1
43 $info['ape']['tag_offset_end'] = $info['filesize'] - $id3v1tagsize;
44
45 //} elseif (preg_match('/APETAGEX.{24}$/i', $APEfooterID3v1)) {
46 } elseif (substr($APEfooterID3v1, strlen($APEfooterID3v1) - $apetagheadersize, 8) == 'APETAGEX') {
47
48 // APE tag found, no ID3v1
49 $info['ape']['tag_offset_end'] = $info['filesize'];
50
51 }
52
53 } else {
54
55 $this->fseek($this->overrideendoffset - $apetagheadersize);
56 if ($this->fread(8) == 'APETAGEX') {
57 $info['ape']['tag_offset_end'] = $this->overrideendoffset;
58 }
59
60 }
61 if (!isset($info['ape']['tag_offset_end'])) {
62
63 // APE tag not found
64 unset($info['ape']);
65 return false;
66
67 }
68
69 // shortcut
70 $thisfile_ape = &$info['ape'];
71
72 $this->fseek($thisfile_ape['tag_offset_end'] - $apetagheadersize);
73 $APEfooterData = $this->fread(32);
74 if (!($thisfile_ape['footer'] = $this->parseAPEheaderFooter($APEfooterData))) {
75 $this->error('Error parsing APE footer at offset '.$thisfile_ape['tag_offset_end']);
76 return false;
77 }
78
79 if (isset($thisfile_ape['footer']['flags']['header']) && $thisfile_ape['footer']['flags']['header']) {
80 $this->fseek($thisfile_ape['tag_offset_end'] - $thisfile_ape['footer']['raw']['tagsize'] - $apetagheadersize);
81 $thisfile_ape['tag_offset_start'] = $this->ftell();
82 $APEtagData = $this->fread($thisfile_ape['footer']['raw']['tagsize'] + $apetagheadersize);
83 } else {
84 $thisfile_ape['tag_offset_start'] = $thisfile_ape['tag_offset_end'] - $thisfile_ape['footer']['raw']['tagsize'];
85 $this->fseek($thisfile_ape['tag_offset_start']);
86 $APEtagData = $this->fread($thisfile_ape['footer']['raw']['tagsize']);
87 }
88 $info['avdataend'] = $thisfile_ape['tag_offset_start'];
89
90 if (isset($info['id3v1']['tag_offset_start']) && ($info['id3v1']['tag_offset_start'] < $thisfile_ape['tag_offset_end'])) {
91 $this->warning('ID3v1 tag information ignored since it appears to be a false synch in APEtag data');
92 unset($info['id3v1']);
93 foreach ($info['warning'] as $key => $value) {
94 if ($value == 'Some ID3v1 fields do not use NULL characters for padding') {
95 unset($info['warning'][$key]);
96 sort($info['warning']);
97 break;
98 }
99 }
100 }
101
102 $offset = 0;
103 if (isset($thisfile_ape['footer']['flags']['header']) && $thisfile_ape['footer']['flags']['header']) {
104 if ($thisfile_ape['header'] = $this->parseAPEheaderFooter(substr($APEtagData, 0, $apetagheadersize))) {
105 $offset += $apetagheadersize;
106 } else {
107 $this->error('Error parsing APE header at offset '.$thisfile_ape['tag_offset_start']);
108 return false;
109 }
110 }
111
112 // shortcut
113 $info['replay_gain'] = array();
114 $thisfile_replaygain = &$info['replay_gain'];
115
116 for ($i = 0; $i < $thisfile_ape['footer']['raw']['tag_items']; $i++) {
117 $value_size = getid3_lib::LittleEndian2Int(substr($APEtagData, $offset, 4));
118 $offset += 4;
119 $item_flags = getid3_lib::LittleEndian2Int(substr($APEtagData, $offset, 4));
120 $offset += 4;
121 if (strstr(substr($APEtagData, $offset), "\x00") === false) {
122 $this->error('Cannot find null-byte (0x00) separator between ItemKey #'.$i.' and value. ItemKey starts '.$offset.' bytes into the APE tag, at file offset '.($thisfile_ape['tag_offset_start'] + $offset));
123 return false;
124 }
125 $ItemKeyLength = strpos($APEtagData, "\x00", $offset) - $offset;
126 $item_key = strtolower(substr($APEtagData, $offset, $ItemKeyLength));
127
128 // shortcut
129 $thisfile_ape['items'][$item_key] = array();
130 $thisfile_ape_items_current = &$thisfile_ape['items'][$item_key];
131
132 $thisfile_ape_items_current['offset'] = $thisfile_ape['tag_offset_start'] + $offset;
133
134 $offset += ($ItemKeyLength + 1); // skip 0x00 terminator
135 $thisfile_ape_items_current['data'] = substr($APEtagData, $offset, $value_size);
136 $offset += $value_size;
137
138 $thisfile_ape_items_current['flags'] = $this->parseAPEtagFlags($item_flags);
139 switch ($thisfile_ape_items_current['flags']['item_contents_raw']) {
140 case 0: // UTF-8
141 case 2: // Locator (URL, filename, etc), UTF-8 encoded
142 $thisfile_ape_items_current['data'] = explode("\x00", $thisfile_ape_items_current['data']);
143 break;
144
145 case 1: // binary data
146 default:
147 break;
148 }
149
150 switch (strtolower($item_key)) {
151 // http://wiki.hydrogenaud.io/index.php?title=ReplayGain#MP3Gain
152 case 'replaygain_track_gain':
153 if (preg_match('#^[\\-\\+][0-9\\.,]{8}$#', $thisfile_ape_items_current['data'][0])) {
154 $thisfile_replaygain['track']['adjustment'] = (float) str_replace(',', '.', $thisfile_ape_items_current['data'][0]); // float casting will see "0,95" as zero!
155 $thisfile_replaygain['track']['originator'] = 'unspecified';
156 } else {
157 $this->warning('MP3gainTrackGain value in APEtag appears invalid: "'.$thisfile_ape_items_current['data'][0].'"');
158 }
159 break;
160
161 case 'replaygain_track_peak':
162 if (preg_match('#^[0-9\\.,]{8}$#', $thisfile_ape_items_current['data'][0])) {
163 $thisfile_replaygain['track']['peak'] = (float) str_replace(',', '.', $thisfile_ape_items_current['data'][0]); // float casting will see "0,95" as zero!
164 $thisfile_replaygain['track']['originator'] = 'unspecified';
165 if ($thisfile_replaygain['track']['peak'] <= 0) {
166 $this->warning('ReplayGain Track peak from APEtag appears invalid: '.$thisfile_replaygain['track']['peak'].' (original value = "'.$thisfile_ape_items_current['data'][0].'")');
167 }
168 } else {
169 $this->warning('MP3gainTrackPeak value in APEtag appears invalid: "'.$thisfile_ape_items_current['data'][0].'"');
170 }
171 break;
172
173 case 'replaygain_album_gain':
174 if (preg_match('#^[\\-\\+][0-9\\.,]{8}$#', $thisfile_ape_items_current['data'][0])) {
175 $thisfile_replaygain['album']['adjustment'] = (float) str_replace(',', '.', $thisfile_ape_items_current['data'][0]); // float casting will see "0,95" as zero!
176 $thisfile_replaygain['album']['originator'] = 'unspecified';
177 } else {
178 $this->warning('MP3gainAlbumGain value in APEtag appears invalid: "'.$thisfile_ape_items_current['data'][0].'"');
179 }
180 break;
181
182 case 'replaygain_album_peak':
183 if (preg_match('#^[0-9\\.,]{8}$#', $thisfile_ape_items_current['data'][0])) {
184 $thisfile_replaygain['album']['peak'] = (float) str_replace(',', '.', $thisfile_ape_items_current['data'][0]); // float casting will see "0,95" as zero!
185 $thisfile_replaygain['album']['originator'] = 'unspecified';
186 if ($thisfile_replaygain['album']['peak'] <= 0) {
187 $this->warning('ReplayGain Album peak from APEtag appears invalid: '.$thisfile_replaygain['album']['peak'].' (original value = "'.$thisfile_ape_items_current['data'][0].'")');
188 }
189 } else {
190 $this->warning('MP3gainAlbumPeak value in APEtag appears invalid: "'.$thisfile_ape_items_current['data'][0].'"');
191 }
192 break;
193
194 case 'mp3gain_undo':
195 if (preg_match('#^[\\-\\+][0-9]{3},[\\-\\+][0-9]{3},[NW]$#', $thisfile_ape_items_current['data'][0])) {
196 list($mp3gain_undo_left, $mp3gain_undo_right, $mp3gain_undo_wrap) = explode(',', $thisfile_ape_items_current['data'][0]);
197 $thisfile_replaygain['mp3gain']['undo_left'] = intval($mp3gain_undo_left);
198 $thisfile_replaygain['mp3gain']['undo_right'] = intval($mp3gain_undo_right);
199 $thisfile_replaygain['mp3gain']['undo_wrap'] = (($mp3gain_undo_wrap == 'Y') ? true : false);
200 } else {
201 $this->warning('MP3gainUndo value in APEtag appears invalid: "'.$thisfile_ape_items_current['data'][0].'"');
202 }
203 break;
204
205 case 'mp3gain_minmax':
206 if (preg_match('#^[0-9]{3},[0-9]{3}$#', $thisfile_ape_items_current['data'][0])) {
207 list($mp3gain_globalgain_min, $mp3gain_globalgain_max) = explode(',', $thisfile_ape_items_current['data'][0]);
208 $thisfile_replaygain['mp3gain']['globalgain_track_min'] = intval($mp3gain_globalgain_min);
209 $thisfile_replaygain['mp3gain']['globalgain_track_max'] = intval($mp3gain_globalgain_max);
210 } else {
211 $this->warning('MP3gainMinMax value in APEtag appears invalid: "'.$thisfile_ape_items_current['data'][0].'"');
212 }
213 break;
214
215 case 'mp3gain_album_minmax':
216 if (preg_match('#^[0-9]{3},[0-9]{3}$#', $thisfile_ape_items_current['data'][0])) {
217 list($mp3gain_globalgain_album_min, $mp3gain_globalgain_album_max) = explode(',', $thisfile_ape_items_current['data'][0]);
218 $thisfile_replaygain['mp3gain']['globalgain_album_min'] = intval($mp3gain_globalgain_album_min);
219 $thisfile_replaygain['mp3gain']['globalgain_album_max'] = intval($mp3gain_globalgain_album_max);
220 } else {
221 $this->warning('MP3gainAlbumMinMax value in APEtag appears invalid: "'.$thisfile_ape_items_current['data'][0].'"');
222 }
223 break;
224
225 case 'tracknumber':
226 if (is_array($thisfile_ape_items_current['data'])) {
227 foreach ($thisfile_ape_items_current['data'] as $comment) {
228 $thisfile_ape['comments']['track'][] = $comment;
229 }
230 }
231 break;
232
233 case 'cover art (artist)':
234 case 'cover art (back)':
235 case 'cover art (band logo)':
236 case 'cover art (band)':
237 case 'cover art (colored fish)':
238 case 'cover art (composer)':
239 case 'cover art (conductor)':
240 case 'cover art (front)':
241 case 'cover art (icon)':
242 case 'cover art (illustration)':
243 case 'cover art (lead)':
244 case 'cover art (leaflet)':
245 case 'cover art (lyricist)':
246 case 'cover art (media)':
247 case 'cover art (movie scene)':
248 case 'cover art (other icon)':
249 case 'cover art (other)':
250 case 'cover art (performance)':
251 case 'cover art (publisher logo)':
252 case 'cover art (recording)':
253 case 'cover art (studio)':
254 // list of possible cover arts from http://taglib-sharp.sourcearchive.com/documentation/2.0.3.0-2/Ape_2Tag_8cs-source.html
255 if (is_array($thisfile_ape_items_current['data'])) {
256 $this->warning('APEtag "'.$item_key.'" should be flagged as Binary data, but was incorrectly flagged as UTF-8');
257 $thisfile_ape_items_current['data'] = implode("\x00", $thisfile_ape_items_current['data']);
258 }
259 list($thisfile_ape_items_current['filename'], $thisfile_ape_items_current['data']) = explode("\x00", $thisfile_ape_items_current['data'], 2);
260 $thisfile_ape_items_current['data_offset'] = $thisfile_ape_items_current['offset'] + strlen($thisfile_ape_items_current['filename']."\x00");
261 $thisfile_ape_items_current['data_length'] = strlen($thisfile_ape_items_current['data']);
262
263 do {
264 $thisfile_ape_items_current['image_mime'] = '';
265 $imageinfo = array();
266 $imagechunkcheck = getid3_lib::GetDataImageSize($thisfile_ape_items_current['data'], $imageinfo);
267 if (($imagechunkcheck === false) || !isset($imagechunkcheck[2])) {
268 $this->warning('APEtag "'.$item_key.'" contains invalid image data');
269 break;
270 }
271 $thisfile_ape_items_current['image_mime'] = image_type_to_mime_type($imagechunkcheck[2]);
272
273 if ($this->inline_attachments === false) {
274 // skip entirely
275 unset($thisfile_ape_items_current['data']);
276 break;
277 }
278 if ($this->inline_attachments === true) {
279 // great
280 } elseif (is_int($this->inline_attachments)) {
281 if ($this->inline_attachments < $thisfile_ape_items_current['data_length']) {
282 // too big, skip
283 $this->warning('attachment at '.$thisfile_ape_items_current['offset'].' is too large to process inline ('.number_format($thisfile_ape_items_current['data_length']).' bytes)');
284 unset($thisfile_ape_items_current['data']);
285 break;
286 }
287 } elseif (is_string($this->inline_attachments)) {
288 $this->inline_attachments = rtrim(str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $this->inline_attachments), DIRECTORY_SEPARATOR);
289 if (!is_dir($this->inline_attachments) || !is_writable($this->inline_attachments)) {
290 // cannot write, skip
291 $this->warning('attachment at '.$thisfile_ape_items_current['offset'].' cannot be saved to "'.$this->inline_attachments.'" (not writable)');
292 unset($thisfile_ape_items_current['data']);
293 break;
294 }
295 }
296 // if we get this far, must be OK
297 if (is_string($this->inline_attachments)) {
298 $destination_filename = $this->inline_attachments.DIRECTORY_SEPARATOR.md5($info['filenamepath']).'_'.$thisfile_ape_items_current['data_offset'];
299 if (!file_exists($destination_filename) || is_writable($destination_filename)) {
300 file_put_contents($destination_filename, $thisfile_ape_items_current['data']);
301 } else {
302 $this->warning('attachment at '.$thisfile_ape_items_current['offset'].' cannot be saved to "'.$destination_filename.'" (not writable)');
303 }
304 $thisfile_ape_items_current['data_filename'] = $destination_filename;
305 unset($thisfile_ape_items_current['data']);
306 } else {
307 if (!isset($info['ape']['comments']['picture'])) {
308 $info['ape']['comments']['picture'] = array();
309 }
310 $comments_picture_data = array();
311 foreach (array('data', 'image_mime', 'image_width', 'image_height', 'imagetype', 'picturetype', 'description', 'datalength') as $picture_key) {
312 if (isset($thisfile_ape_items_current[$picture_key])) {
313 $comments_picture_data[$picture_key] = $thisfile_ape_items_current[$picture_key];
314 }
315 }
316 $info['ape']['comments']['picture'][] = $comments_picture_data;
317 unset($comments_picture_data);
318 }
319 } while (false);
320 break;
321
322 default:
323 if (is_array($thisfile_ape_items_current['data'])) {
324 foreach ($thisfile_ape_items_current['data'] as $comment) {
325 $thisfile_ape['comments'][strtolower($item_key)][] = $comment;
326 }
327 }
328 break;
329 }
330
331 }
332 if (empty($thisfile_replaygain)) {
333 unset($info['replay_gain']);
334 }
335 return true;
336 }
337
338 public function parseAPEheaderFooter($APEheaderFooterData) {
339 // http://www.uni-jena.de/~pfk/mpp/sv8/apeheader.html
340
341 // shortcut
342 $headerfooterinfo['raw'] = array();
343 $headerfooterinfo_raw = &$headerfooterinfo['raw'];
344
345 $headerfooterinfo_raw['footer_tag'] = substr($APEheaderFooterData, 0, 8);
346 if ($headerfooterinfo_raw['footer_tag'] != 'APETAGEX') {
347 return false;
348 }
349 $headerfooterinfo_raw['version'] = getid3_lib::LittleEndian2Int(substr($APEheaderFooterData, 8, 4));
350 $headerfooterinfo_raw['tagsize'] = getid3_lib::LittleEndian2Int(substr($APEheaderFooterData, 12, 4));
351 $headerfooterinfo_raw['tag_items'] = getid3_lib::LittleEndian2Int(substr($APEheaderFooterData, 16, 4));
352 $headerfooterinfo_raw['global_flags'] = getid3_lib::LittleEndian2Int(substr($APEheaderFooterData, 20, 4));
353 $headerfooterinfo_raw['reserved'] = substr($APEheaderFooterData, 24, 8);
354
355 $headerfooterinfo['tag_version'] = $headerfooterinfo_raw['version'] / 1000;
356 if ($headerfooterinfo['tag_version'] >= 2) {
357 $headerfooterinfo['flags'] = $this->parseAPEtagFlags($headerfooterinfo_raw['global_flags']);
358 }
359 return $headerfooterinfo;
360 }
361
362 public function parseAPEtagFlags($rawflagint) {
363 // "Note: APE Tags 1.0 do not use any of the APE Tag flags.
364 // All are set to zero on creation and ignored on reading."
365 // http://wiki.hydrogenaud.io/index.php?title=Ape_Tags_Flags
366 $flags['header'] = (bool) ($rawflagint & 0x80000000);
367 $flags['footer'] = (bool) ($rawflagint & 0x40000000);
368 $flags['this_is_header'] = (bool) ($rawflagint & 0x20000000);
369 $flags['item_contents_raw'] = ($rawflagint & 0x00000006) >> 1;
370 $flags['read_only'] = (bool) ($rawflagint & 0x00000001);
371
372 $flags['item_contents'] = $this->APEcontentTypeFlagLookup($flags['item_contents_raw']);
373
374 return $flags;
375 }
376
377 public function APEcontentTypeFlagLookup($contenttypeid) {
378 static $APEcontentTypeFlagLookup = array(
379 0 => 'utf-8',
380 1 => 'binary',
381 2 => 'external',
382 3 => 'reserved'
383 );
384 return (isset($APEcontentTypeFlagLookup[$contenttypeid]) ? $APEcontentTypeFlagLookup[$contenttypeid] : 'invalid');
385 }
386
387 public function APEtagItemIsUTF8Lookup($itemkey) {
388 static $APEtagItemIsUTF8Lookup = array(
389 'title',
390 'subtitle',
391 'artist',
392 'album',
393 'debut album',
394 'publisher',
395 'conductor',
396 'track',
397 'composer',
398 'comment',
399 'copyright',
400 'publicationright',
401 'file',
402 'year',
403 'record date',
404 'record location',
405 'genre',
406 'media',
407 'related',
408 'isrc',
409 'abstract',
410 'language',
411 'bibliography'
412 );
413 return in_array(strtolower($itemkey), $APEtagItemIsUTF8Lookup);
414 }
415
416 }