23 Commits

Author SHA1 Message Date
e64c30e569 Turn returned HTTP headers lowercase before parsing
NGINX for some reason returns "content-length" instead of
"Content-Length".
2025-06-15 01:36:12 +01:00
7a8dede10b Move ID3v2 flags parsing into method
Signed-off-by: Markus Birth <markus@birth-online.de>
2025-06-14 22:19:49 +01:00
ad8183b41f Add error handling if block can't be downloaded 2024-05-29 01:28:59 +01:00
aaaba0e2f1 Fix Protection bit inverted meaning 2024-05-29 01:28:43 +01:00
3e1288a68c Added support for informational bits in Mpeg frame header
Copyright, Original, Private, CRC protected, Padding
2024-05-28 01:24:46 +01:00
4b2cbfb384 Cosmetics 2024-05-28 01:23:52 +01:00
2a98464bbe Put id3v1 genre_id in brackets as seen with id3v2 2024-05-27 23:53:08 +01:00
c54e75a899 Show error for invalid LAYER/CODEC versions 2024-05-27 23:46:04 +01:00
9b92776607 Differentiate btw id3v1.0 and id3v1.1 2024-05-27 23:45:35 +01:00
0ef2575001 Fix Mpeg frame verification 2024-05-27 23:44:54 +01:00
5b8e15a1e0 Fix Mpeg frame sync detection 2024-05-27 23:44:22 +01:00
c387953040 Optimise parsing of id3v2 flags 2024-05-27 20:56:20 +01:00
3184973867 Optimise searching for Mpeg frame 2024-05-27 20:55:51 +01:00
2a16b836b7 Return payload size for ID3v2 tags 2024-05-27 20:55:22 +01:00
38a962cbdc Style changes 2024-05-27 20:54:44 +01:00
3ff00fde72 Rename Syncsafe to Synchsafe according to spec 2024-05-27 20:53:50 +01:00
c98606734a Set filename and size in Mp3Info object 2024-05-27 20:53:03 +01:00
a3fddb495d Optimise id3v2.4 comment parsing 2024-05-27 20:04:43 +01:00
37bc9d5ec6 Optimise id3v2 header parsing 2024-05-27 20:04:23 +01:00
d474f40403 Helper for Syncsafe size values 2024-05-27 20:03:14 +01:00
c0d64e81aa Minor style optimisations 2024-05-27 20:01:29 +01:00
4de63bd94b Proper TXXX tag handling
TXXX consists of description and value. Descriptions should be unique, but rarely are. This now parses them into TXXX:<description> and turns it into an array if multiple tags are found.

Signed-off-by: Markus Birth <markus@birth-online.de>
2024-05-27 16:20:49 +01:00
d6977ef4dc Fix ID3v2 frame size calculation
Frame size uses 7-out-of-8-Bits notation.

Signed-off-by: Markus Birth <markus@birth-online.de>
2024-05-27 15:28:22 +01:00
2 changed files with 300 additions and 225 deletions

View File

@ -2,6 +2,8 @@
namespace wapmorgan\Mp3Info;
use \Exception;
class Mp3FileRemote
{
public string $fileName;
@ -50,7 +52,8 @@ class Mp3FileRemote
],
]);
$result = get_headers($this->fileName, true, $context);
return $result['Content-Length'];
$result = array_change_key_case($result, CASE_LOWER);
return $result['content-length'];
}
/**
@ -68,7 +71,10 @@ class Mp3FileRemote
$output = [];
do {
$this->downloadBlock($blockId); // make sure we have this block
// make sure we have this block
if (!$this->downloadBlock($blockId)) {
throw new Exception('Error downloading block ' . $blockId . ' (starting at pos. ' . ($blockId * $this->blockSize) . ')!');
}
if ($blockPos + $numBytes >= $this->blockSize) {
// length of request is more than this block has, truncate to block len
$subLen = $this->blockSize - $blockPos;
@ -152,6 +158,9 @@ class Mp3FileRemote
],
]);
$filePtr = fopen($this->fileName, 'rb', false, $context);
if ($filePtr === false) {
return false;
}
$this->buffer[$blockNo] = fread($filePtr, $this->blockSize);
$status = stream_get_meta_data($filePtr);
$httpStatus = explode(' ', $status['wrapper_data'][0])[1];

View File

@ -29,7 +29,8 @@ use \RuntimeException;
* * {@link https://multimedia.cx/mp3extensions.txt Descripion of VBR header "Xing"}
* * {@link http://gabriel.mp3-tech.org/mp3infotag.html Xing, Info and Lame tags specifications}
*/
class Mp3Info {
class Mp3Info
{
const TAG1_SYNC = 'TAG';
const TAG2_SYNC = 'ID3';
const VBR_SYNC = 'Xing';
@ -77,6 +78,15 @@ class Mp3Info {
self::MPEG_2 => [13, 21],
self::MPEG_25 => [13, 21],
];
/**
* @var array
*/
private static $_id3v2HeaderFlags = [
2 => ['unsynchronisation', 'compression'],
3 => ['unsynchronisation', 'extended_header', 'experimental_indicator'],
4 => ['unsynchronisation', 'extended_header', 'experimental_indicator', 'footer_present'],
];
/**
* @var int Limit in bytes for seeking a mpeg header in file
@ -120,6 +130,31 @@ class Mp3Info {
*/
public $sampleRate;
/**
* @var bool Header protection by 16 bit CRC
*/
public $isProtected;
/**
* @var bool Frame data is padded with one slot
*/
public $isPadded;
/**
* @var bool Private bit (only informative)
*/
public $isPrivate;
/**
* @var bool Copyright bit (only informative)
*/
public $isCopyright;
/**
* @var bool Original bit (only informative)
*/
public $isOriginal;
/**
* @var boolean Contains true if audio has variable bit rate
*/
@ -164,17 +199,20 @@ class Mp3Info {
/**
* @var int Major version of id3v2 tag (if id3v2 present) (2 or 3 or 4)
*/
public $id3v2MajorVersion;
public $id3v2MajorVersion; // @deprecated
public $id3v2Version;
/**
* @var int Minor version of id3v2 tag (if id3v2 present)
*/
public $id3v2MinorVersion;
public $id3v2MinorVersion; // @deprecated
public $id3v2Revision;
/**
* @var array List of id3v2 header flags (if id3v2 present)
*/
public $id3v2Flags = [];
public $id3v2HeaderFlags = [];
/**
* @var array List of id3v2 tags flags (if id3v2 present)
@ -222,15 +260,17 @@ class Mp3Info {
public function __construct(string $filename, bool $parseTags = false)
{
if (self::$_bitRateTable === null)
self::$_bitRateTable = require dirname(__FILE__).'/../data/bitRateTable.php';
self::$_bitRateTable = require __DIR__.'/../data/bitRateTable.php';
if (self::$_sampleRateTable === null)
self::$_sampleRateTable = require dirname(__FILE__).'/../data/sampleRateTable.php';
self::$_sampleRateTable = require __DIR__.'/../data/sampleRateTable.php';
$this->_fileName = $filename;
if (str_contains($filename, '://')) {
$this->fileObj = new Mp3FileRemote($filename);
} else {
$this->fileObj = new Mp3FileLocal($filename);
}
$this->_fileSize = $this->fileObj->getFileSize();
$mode = $parseTags ? self::META | self::TAGS : self::META;
$this->audioSize = $this->parseAudio($mode);
@ -253,6 +293,13 @@ class Mp3Info {
return $data;
}
protected function getSynchsafeSize(string $rawBytes): int
{
$sizeBytes = unpack('C4', $rawBytes);
$size = $sizeBytes[1] << 21 | $sizeBytes[2] << 14 | $sizeBytes[3] << 7 | $sizeBytes[4];
return $size;
}
/**
* Reads audio file in binary mode.
* mpeg audio file structure:
@ -263,30 +310,36 @@ class Mp3Info {
* @return float|int
* @throws \Exception
*/
private function parseAudio($mode) {
private function parseAudio($mode)
{
$time = microtime(true);
/** @var int Size of audio data (exclude tags size) */
$audioSize = $this->fileObj->getFileSize();
// parse tags
// try ID3v2 parsing
$audioSize -= ($this->_id3Size = $this->_parseId3v2Header(!($mode & self::TAGS)));
/*/ parse tags
if ($this->fileObj->getBytes(3) == self::TAG2_SYNC) {
if ($mode & self::TAGS) {
$audioSize -= ($this->_id3Size = $this->readId3v2Body());
} else {
$this->fileObj->seekForward(2); // 2 bytes of tag version
$this->fileObj->seekForward(2); // 2 bytes of tag version+revision
$this->fileObj->seekForward(1); // 1 byte of tag flags
$sizeBytes = unpack('C4', $this->fileObj->getBytes(4));
$size = $sizeBytes[1] << 21 | $sizeBytes[2] << 14 | $sizeBytes[3] << 7 | $sizeBytes[4];
$size = $this->getSynchsafeSize($this->fileObj->getBytes(4));
$size += 10; // add header size
$audioSize -= ($this->_id3Size = $size);
}
}
}*/
$this->fileObj->seekTo($this->fileObj->getFileSize() - 128);
if ($this->fileObj->getBytes(3) == self::TAG1_SYNC) {
if ($mode & self::TAGS) $audioSize -= $this->readId3v1Body($fp);
else $audioSize -= 128;
if ($mode & self::TAGS) {
$audioSize -= $this->_readId3v1();
} else {
$audioSize -= 128;
}
}
if ($mode & self::TAGS) {
@ -303,7 +356,7 @@ class Mp3Info {
* Read first N frames
*/
for ($i = 0; $i < self::$framesCountRead; $i++) {
$framesCount = $this->readMpegFrame();
$framesCount = $this->_readMpegFrame();
}
$this->_framesCount = $framesCount !== null
@ -331,59 +384,58 @@ class Mp3Info {
return $audioSize;
}
private function _findNextMpegFrame(int $headerSeekLimit): ?string
{
// find frame sync
$headerSeekMax = $this->fileObj->getFilePos() + $headerSeekLimit;
$headerBytes = $this->fileObj->getBytes(3); // preload with 3 Bytes
do {
$headerBytes .= $this->fileObj->getBytes(1); // load next Byte
$headerBytes = substr($headerBytes, -4); // limit to 4 Bytes
if ((unpack('n', $headerBytes)[1] & self::FRAME_SYNC) === self::FRAME_SYNC) {
return $headerBytes;
}
} while ($this->fileObj->getFilePos() <= $headerSeekMax);
return null;
}
/**
* Read first frame information.
*
* @link https://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header
* @return int Number of frames (if present if first frame of VBR-file)
* @throws \Exception
*/
private function readMpegFrame() {
$header_seek_pos = $this->fileObj->getFilePos() + self::$headerSeekLimit;
do {
$pos = $this->fileObj->getFilePos();
$first_header_byte = $this->fileObj->getBytes(1);
if (ord($first_header_byte[0]) === 0xFF) {
$second_header_byte = $this->fileObj->getBytes(1);
if (((ord($second_header_byte[0]) >> 5) & 0b111) == 0b111) {
$this->fileObj->seekTo($pos);
$header_bytes = $this->fileObj->getBytes(4);
break;
} else {
$this->fileObj->seekForward(-1);
}
} else {
$this->fileObj->seekForward(-1);
}
$this->fileObj->seekForward(1);
} while ($this->fileObj->getFilePos() <= $header_seek_pos);
if (!isset($header_bytes) || ord($header_bytes[0]) !== 0xFF || ((ord($header_bytes[1]) >> 5) & 0b111) != 0b111) {
throw new \Exception('At '.$pos
.'(0x'.dechex($pos).') should be a frame header!');
private function _readMpegFrame()
{
$headerBytes = $this->_findNextMpegFrame(self::$headerSeekLimit);
$pos = $this->fileObj->getFilePos() - 4;
if (is_null($headerBytes)) {
throw new Exception('No Mpeg frame header found up until pos ' . $pos . '(0x' . dechex($pos) . ')!');
}
switch (ord($header_bytes[1]) >> 3 & 0b11) {
case 0b00: $this->codecVersion = self::MPEG_25; break;
case 0b01: return null; break;
case 0b10: $this->codecVersion = self::MPEG_2; break;
case 0b11: $this->codecVersion = self::MPEG_1; break;
}
// 2nd Byte: (rest of frame sync), Version, Layer, Protection
$this->codecVersion = [self::MPEG_25, null, self::MPEG_2, self::MPEG_1][ord($headerBytes[1]) >> 3 & 0b11];
$this->layerVersion = [null, self::LAYER_3, self::LAYER_2, self::LAYER_1][ord($headerBytes[1]) >> 1 & 0b11];
$this->isProtected = !((ord($headerBytes[1]) & 0b1) == 0b1); // inverted as 0=protected, 1=not protected
switch (ord($header_bytes[1]) >> 1 & 0b11) {
case 0b01: $this->layerVersion = self::LAYER_3; break;
case 0b10: $this->layerVersion = self::LAYER_2; break;
case 0b11: $this->layerVersion = self::LAYER_1; break;
// 3rd Byte: Bitrate, Sampling rate, Padding, Private
$this->bitRate = self::$_bitRateTable[$this->codecVersion][$this->layerVersion][ord($headerBytes[2]) >> 4];
$this->sampleRate = self::$_sampleRateTable[$this->codecVersion][(ord($headerBytes[2]) >> 2) & 0b11];
if ($this->sampleRate === false) {
return null;
}
$this->isPadded = ((ord($headerBytes[2]) & 0b10) == 0b10);
$this->isPrivate = ((ord($headerBytes[2]) & 0b1) == 0b1);
$this->bitRate = self::$_bitRateTable[$this->codecVersion][$this->layerVersion][ord($header_bytes[2]) >> 4];
$this->sampleRate = self::$_sampleRateTable[$this->codecVersion][(ord($header_bytes[2]) >> 2) & 0b11];
if ($this->sampleRate === false) return null;
switch (ord($header_bytes[3]) >> 6) {
case 0b00: $this->channel = self::STEREO; break;
case 0b01: $this->channel = self::JOINT_STEREO; break;
case 0b10: $this->channel = self::DUAL_MONO; break;
case 0b11: $this->channel = self::MONO; break;
}
// 4th Byte: Channels, Mode extension, Copyright, Original, Emphasis
$this->channel = [self::STEREO, self::JOINT_STEREO, self::DUAL_MONO, self::MONO][ord($headerBytes[3]) >> 6];
// TODO: Mode extension (2 bits)
$this->isCopyright = ((ord($headerBytes[3]) & 0b1000) == 0b1000);
$this->isOriginal = ((ord($headerBytes[3]) & 0b100) == 0b100);
// TODO: Emphasis (2 bits)
$vbr_offset = self::$_vbrOffsets[$this->codecVersion][$this->channel == self::MONO ? 0 : 1];
@ -413,151 +465,143 @@ class Mp3Info {
// go to the end of frame
if ($this->layerVersion == self::LAYER_1) {
$this->_cbrFrameSize = floor((12 * $this->bitRate / $this->sampleRate + (ord($header_bytes[2]) >> 1 & 0b1)) * 4);
$this->_cbrFrameSize = floor((12 * $this->bitRate / $this->sampleRate + (ord($headerBytes[2]) >> 1 & 0b1)) * 4);
} else {
$this->_cbrFrameSize = floor(144 * $this->bitRate / $this->sampleRate + (ord($header_bytes[2]) >> 1 & 0b1));
$this->_cbrFrameSize = floor(144 * $this->bitRate / $this->sampleRate + (ord($headerBytes[2]) >> 1 & 0b1));
}
$this->fileObj->seekTo($pos + $this->_cbrFrameSize);
return isset($this->vbrProperties['frames']) ? $this->vbrProperties['frames'] : null;
return $this->vbrProperties['frames'] ?? null;
}
/**
* Reads id3v1 tag.
*
* @link https://id3.org/ID3v1
* @return int Returns length of id3v1 tag.
*/
private function readId3v1Body() {
$this->tags1['song'] = trim($this->fileObj->getBytes(30));
private function _readId3v1(): int
{
$this->tags1['song'] = trim($this->fileObj->getBytes(30));
$this->tags1['artist'] = trim($this->fileObj->getBytes(30));
$this->tags1['album'] = trim($this->fileObj->getBytes(30));
$this->tags1['year'] = trim($this->fileObj->getBytes(4));
$this->tags1['comment'] = trim($this->fileObj->getBytes(28));
$this->fileObj->seekForward(1);
$this->tags1['track'] = ord($this->fileObj->getBytes(1));
$this->tags1['genre'] = ord($this->fileObj->getBytes(1));
$this->tags1['album'] = trim($this->fileObj->getBytes(30));
$this->tags1['year'] = trim($this->fileObj->getBytes(4));
$comment = $this->fileObj->getBytes(30);
if ($comment[28] == "\x00" && $comment[29] != "\x00") {
// id3v1.1 - last Byte of comment is trackNo
$this->tags1['track'] = ord($comment[29]);
$this->tags1['comment'] = trim(substr($comment, 0, 28));
} else {
// id3v1.0
$this->tags1['comment'] = trim($comment);
}
$this->tags1['genre'] = '(' . ord($this->fileObj->getBytes(1)) . ')';
return 128;
}
private function _parseId3v2Flags(int $version, int $flags): array
{
$result = array();
if ($version >= 2) {
// parse id3v2.2.0 header flags
$result['unsynchronisation'] = ($flags & 128 == 128);
$result['compression'] = ($flags & 64 == 64);
}
if ($version >= 3) {
// id3v2.3 changes second bit from compression to extended_header
$result['extended_header'] = &$result['compression'];
unset($result['compression']);
// parse additional id3v2.3.0 header flags
$result['experimental_indicator'] = ($flags & 32 == 32);
}
if ($version >= 4) {
// parse additional id3v2.4.0 header flags
$result['footer_present'] = ($flags & 16 == 16);
}
return $result;
}
/**
* Reads id3v2 tag.
* -----------------------------------
* Overall tag header structure (10 bytes)
* ID3v2/file identifier "ID3" (3 bytes)
* ID3v2 version (2 bytes)
* ID3v2 flags (1 byte)
* ID3v2 size 4 * %0xxxxxxx (4 bytes)
* -----------------------------------
* id3v2.2.0 tag header (10 bytes)
* ID3/file identifier "ID3" (3 bytes)
* ID3 version $02 00 (2 bytes)
* ID3 flags %xx000000 (1 byte)
* ID3 size 4 * %0xxxxxxx (4 bytes)
* Flags:
* x (bit 7) - unsynchronisation
* x (bit 6) - compression
* -----------------------------------
* id3v2.3.0 tag header (10 bytes)
* ID3v2/file identifier "ID3" (3 bytes)
* ID3v2 version $03 00 (2 bytes)
* ID3v2 flags %abc00000 (1 byte)
* ID3v2 size 4 * %0xxxxxxx (4 bytes)
* Flags:
* a - Unsynchronisation
* b - Extended header
* c - Experimental indicator
* Extended header structure (10 bytes)
* Extended header size $xx xx xx xx
* Extended Flags $xx xx
* Size of padding $xx xx xx xx
* Extended flags:
* %x0000000 00000000
* x - CRC data present
* -----------------------------------
* id3v2.4.0 tag header (10 bytes)
* ID3v2/file identifier "ID3" (3 bytes)
* ID3v2 version $04 00 (2 bytes)
* ID3v2 flags %abcd0000 (1 byte)
* ID3v2 size 4 * %0xxxxxxx (4 bytes)
* Flags:
* a - Unsynchronisation
* b - Extended header
* c - Experimental indicator
* d - Footer present
* @param resource $fp
* @return int Returns length of id3v2 tag.
* @throws \Exception
* Reads ID3v2 header and returns the ID3v2 tag size. 0 if no header was found.
*
* @param bool $sizeOnly Only parse ID3v2 size
*
* @return int Size of ID3v2 struct
*/
private function readId3v2Body()
private function _parseId3v2Header(bool $sizeOnly = true): int
{
// read the rest of the id3v2 header
$raw = $this->fileObj->getBytes(7);
$data = unpack('cmajor_version/cminor_version/H*', $raw);
$this->id3v2MajorVersion = $data['major_version'];
$this->id3v2MinorVersion = $data['minor_version'];
$data = str_pad(base_convert($data[1], 16, 2), 40, 0, STR_PAD_LEFT);
$flags = substr($data, 0, 8);
if ($this->id3v2MajorVersion == 2) { // parse id3v2.2.0 header flags
$this->id3v2Flags = array(
'unsynchronisation' => (bool)substr($flags, 0, 1),
'compression' => (bool)substr($flags, 1, 1),
);
} else if ($this->id3v2MajorVersion == 3) { // parse id3v2.3.0 header flags
$this->id3v2Flags = array(
'unsynchronisation' => (bool)substr($flags, 0, 1),
'extended_header' => (bool)substr($flags, 1, 1),
'experimental_indicator' => (bool)substr($flags, 2, 1),
);
if ($this->id3v2Flags['extended_header'])
throw new \Exception('NEED TO PARSE EXTENDED HEADER!');
} else if ($this->id3v2MajorVersion == 4) { // parse id3v2.4.0 header flags
$this->id3v2Flags = array(
'unsynchronisation' => (bool)substr($flags, 0, 1),
'extended_header' => (bool)substr($flags, 1, 1),
'experimental_indicator' => (bool)substr($flags, 2, 1),
'footer_present' => (bool)substr($flags, 3, 1),
);
if ($this->id3v2Flags['extended_header'])
throw new \Exception('NEED TO PARSE EXTENDED HEADER!');
if ($this->id3v2Flags['footer_present'])
throw new \Exception('NEED TO PARSE id3v2.4 FOOTER!');
// check for "ID3" marker
if ($this->fileObj->getBytes(3) != self::TAG2_SYNC) {
// No ID3V2 tag found
return 0;
}
$size = substr($data, 8, 32);
$headerSize = 10;
$footerSize = 0;
// some fucking shit
// getting only 7 of 8 bits of size bytes
$sizes = str_split($size, 8);
array_walk($sizes, function (&$value) {
$value = substr($value, 1);
});
$size = implode($sizes);
$size = bindec($size);
// read the rest of the id3v2 header
$raw = $this->fileObj->getBytes(3);
$size = $this->getSynchsafeSize($this->fileObj->getBytes(4));
if ($sizeOnly) {
// just return the size
// NOTE: $footerSize unclear at this point, might be inaccurate
return $headerSize + $footerSize + $size;
}
if ($this->id3v2MajorVersion == 2) {
// parse version and flags
$data = unpack('Cversion/Crevision/Cflags', $raw);
$this->id3v2Version = $data['version'];
$this->id3v2Revision = $data['revision'];
// backwards compatibility:
$this->id3v2MajorVersion = $data['version'];
$this->id3v2MinorVersion = $data['revision'];
$flagsList = self::$_id3v2HeaderFlags[$this->id3v2Version];
// TODO: CONTINUE HERE - PARSE DIFFERENTLY
$this->id3v2Flags = $this->_parseId3v2Flags($this->id3v2Version, $data['flags']);
if ($this->id3v2Flags['extended_header']) {
throw new Exception('NEED TO PARSE EXTENDED HEADER!');
}
if ($this->id3v2Flags['footer_present']) {
// footer is a copy of header - so can be ignored
// (10 Bytes not included in $size)
$footerSize = 10;
}
// Now parse the frames
if ($this->id3v2Version == 2) {
// parse id3v2.2.0 body
/*throw new \Exception('NEED TO PARSE id3v2.2.0 flags!');*/
} else if ($this->id3v2MajorVersion == 3) {
} elseif ($this->id3v2Version == 3) {
// parse id3v2.3.0 body
$this->parseId3v23Body(10 + $size);
} else if ($this->id3v2MajorVersion == 4) {
} elseif ($this->id3v2Version == 4) {
// parse id3v2.4.0 body
$this->parseId3v24Body(10 + $size);
}
return 10 + $size; // 10 bytes - header, rest - body
return $headerSize + $footerSize + $size;
}
/**
* Parses id3v2.3.0 tag body.
* @todo Complete.
*/
protected function parseId3v23Body($lastByte) {
protected function parseId3v23Body($lastByte)
{
// TODO: Change $lastByte into $payloadLength so it works independent of starting location
while ($this->fileObj->getFilePos() < $lastByte) {
$raw = $this->fileObj->getBytes(10);
$frame_id = substr($raw, 0, 4);
if ($frame_id == str_repeat(chr(0), 4)) {
fseek($fp, $lastByte);
$this->fileObj->seekTo($lastByte);
break;
}
@ -565,6 +609,7 @@ class Mp3Info {
$frame_size = $data['frame_size'];
$flags = base_convert($data['flags'], 16, 2);
$this->id3v2TagsFlags[$frame_id] = array(
'payload_size' => $frame_size,
'flags' => array(
'tag_alter_preservation' => (bool)substr($flags, 0, 1),
'file_alter_preservation' => (bool)substr($flags, 1, 1),
@ -697,15 +742,15 @@ class Mp3Info {
// break;
case 'APIC': # Attached picture
$this->hasCover = true;
$last_byte = $this->fileObj->getFilePos() + $frame_size;
$dataEnd = $this->fileObj->getFilePos() + $frame_size;
$this->coverProperties = ['text_encoding' => ord($this->fileObj->getBytes(1))];
// fseek($fp, $frame_size - 4, SEEK_CUR);
$this->coverProperties['mime_type'] = $this->readTextUntilNull($last_byte);
$this->coverProperties['mime_type'] = $this->readTextUntilNull($dataEnd);
$this->coverProperties['picture_type'] = ord($this->fileObj->getBytes(1));
$this->coverProperties['description'] = $this->readTextUntilNull($last_byte);
$this->coverProperties['description'] = $this->readTextUntilNull($dataEnd);
$this->coverProperties['offset'] = $this->fileObj->getFilePos();
$this->coverProperties['size'] = $last_byte - $this->fileObj->getFilePos();
$this->fileObj->seekTo($last_byte);
$this->coverProperties['size'] = $dataEnd - $this->fileObj->getFilePos();
$this->fileObj->seekTo($dataEnd);
break;
// case 'GEOB': # General encapsulated object
// break;
@ -744,24 +789,26 @@ class Mp3Info {
/**
* Parses id3v2.4.0 tag body.
* @param $fp
*
* @param $lastByte
*/
protected function parseId3v24Body($lastByte)
{
// TODO: Change $lastByte into $payloadLength so it works independent of starting location
while ($this->fileObj->getFilePos() < $lastByte) {
$raw = $this->fileObj->getBytes(10);
$frame_id = substr($raw, 0, 4);
$frame_id = $this->fileObj->getBytes(4);
if ($frame_id == str_repeat(chr(0), 4)) {
$this->fileObj->seekTo($lastByte);
break;
}
$data = unpack('Nframe_size/H2flags', substr($raw, 4));
$frame_size = $data['frame_size'];
$frame_size = $this->getSynchsafeSize($this->fileObj->getBytes(4));
$data = unpack('H2flags', $this->fileObj->getBytes(2));
$flags = base_convert($data['flags'], 16, 2);
$this->id3v2TagsFlags[$frame_id] = array(
'payload_size' => $frame_size,
'flags' => array(
'tag_alter_preservation' => (bool)substr($flags, 1, 1),
'file_alter_preservation' => (bool)substr($flags, 2, 1),
@ -782,7 +829,6 @@ class Mp3Info {
case 'TALB': # Album/Movie/Show title
case 'TCON': # Content type
case 'TYER': # Year
case 'TXXX': # User defined text information frame
case 'TRCK': # Track number/Position in set
case 'TIT2': # Title/songname/content description
case 'TPE1': # Lead performer(s)/Soloist(s)
@ -790,6 +836,7 @@ class Mp3Info {
case 'TCOM': # Composer
case 'TCOP': # Copyright message
case 'TDAT': # Date
case 'TDRC': # Recording time
case 'TDLY': # Playlist delay
case 'TENC': # Encoded by
case 'TEXT': # Lyricist/Text writer
@ -821,6 +868,24 @@ class Mp3Info {
$this->tags2[$frame_id] = $this->handleTextFrame($frame_size, $this->fileObj->getBytes($frame_size));
break;
case 'TXXX': # User defined text information frame
$dataEnd = $this->fileObj->getFilePos() + $frame_size;
$encoding = ord($this->fileObj->getBytes(1));
$description_raw = $this->readTextUntilNull($dataEnd);
$description = $this->_getUtf8Text($encoding, $description_raw);
$value = $this->fileObj->getBytes($dataEnd - $this->fileObj->getFilePos());
$tagName = $frame_id . ':' . $description;
if (key_exists($tagName, $this->tags2)) {
// this should never happen! TXXX-description must be unique.
if (!is_array($this->tags2[$tagName])) {
$this->tags2[$tagName] = array($this->tags2[$tagName]);
}
$this->tags2[$tagName][] = $value;
} else {
$this->tags2[$tagName] = $value;
}
break;
################# Text information frames
################# URL link frames
@ -860,33 +925,16 @@ class Mp3Info {
// break;
case 'COMM': # Comments
$dataEnd = $this->fileObj->getFilePos() + $frame_size;
$raw = $this->fileObj->getBytes(4);
$data = unpack('C1encoding/A3language', $raw);
// read until \null character
$short_description = null;
$last_null = false;
$actual_text = false;
while ($this->fileObj->getFilePos() < $dataEnd) {
$char = fgetc($fp);
if ($char == "\00" && $actual_text === false) {
if ($data['encoding'] == 0x1) { # two null-bytes for utf-16
if ($last_null)
$actual_text = null;
else
$last_null = true;
} else # no condition for iso-8859-1
$actual_text = null;
$encoding = unpack('C', $this->fileObj->getBytes(1))[1];
$language = $this->fileObj->getBytes(3);
$allText_raw = $this->fileObj->getBytes($dataEnd - $this->fileObj->getFilePos());
$allText = $this->_getUtf8Text($encoding, $allText_raw);
}
else if ($actual_text !== false) $actual_text .= $char;
else $short_description .= $char;
}
if ($actual_text === false) $actual_text = $short_description;
// list($short_description, $actual_text) = sscanf("s".chr(0)."s", $data['texts']);
// list($short_description, $actual_text) = explode(chr(0), $data['texts']);
$this->tags2[$frame_id][$data['language']] = array(
'short' => (bool)($data['encoding'] == 0x00) ? mb_convert_encoding($short_description, 'utf-8', 'iso-8859-1') : mb_convert_encoding($short_description, 'utf-8', 'utf-16'),
'actual' => (bool)($data['encoding'] == 0x00) ? mb_convert_encoding($actual_text, 'utf-8', 'iso-8859-1') : mb_convert_encoding($actual_text, 'utf-8', 'utf-16'),
list($short_description, $actual_text) = explode("\0", $allText, 2);
$this->tags2[$frame_id][$language] = array(
'short' => $short_description,
'actual' => $actual_text,
);
break;
// case 'RVAD': # Relative volume adjustment
@ -897,15 +945,15 @@ class Mp3Info {
// break;
case 'APIC': # Attached picture
$this->hasCover = true;
$last_byte = $this->fileObj->getFilePos() + $frame_size;
$dataEnd = $this->fileObj->getFilePos() + $frame_size;
$this->coverProperties = ['text_encoding' => ord($this->fileObj->getBytes(1))];
// $this->fileObj->seekForward($frame_size - 4);
$this->coverProperties['mime_type'] = $this->readTextUntilNull($fp, $last_byte);
$this->coverProperties['mime_type'] = $this->readTextUntilNull($dataEnd);
$this->coverProperties['picture_type'] = ord($this->fileObj->getBytes(1));
$this->coverProperties['description'] = $this->readTextUntilNull($fp, $last_byte);
$this->coverProperties['description'] = $this->readTextUntilNull($dataEnd);
$this->coverProperties['offset'] = $this->fileObj->getFilePos();
$this->coverProperties['size'] = $last_byte - $this->fileObj->getFilePos();
$this->fileObj->seekTo($last_byte);
$this->coverProperties['size'] = $dataEnd - $this->fileObj->getFilePos();
$this->fileObj->seekTo($dataEnd);
break;
// case 'GEOB': # General encapsulated object
// break;
@ -942,6 +990,38 @@ class Mp3Info {
}
}
/**
* Converts text encoding according to ID3 indicator
*
* @param int $encoding Encoding ID from ID3 frame
* @param string $rawText Raw text from ID3 frame
*
* @return string
*/
private function _getUtf8Text(int $encoding, ?string $rawText): string
{
if (is_null($rawText)) {
$rawText = '';
}
switch ($encoding) {
case 0x00: // ISO-8859-1
return mb_convert_encoding($rawText, 'utf-8', 'iso-8859-1');
case 0x01: // UTF-16 with BOM
return mb_convert_encoding($rawText . "\00", 'utf-8', 'utf-16');
// Following is for id3v2.4.x only
case 0x02: // UTF-16 without BOM
return mb_convert_encoding($rawText . "\00", 'utf-8', 'utf-16');
case 0x03: // UTF-8
return $rawText;
default:
throw new RuntimeException('Unknown text encoding type: ' . $encoding);
}
}
/**
* @param $frameSize
* @param $raw
@ -951,22 +1031,7 @@ class Mp3Info {
private function handleTextFrame($frameSize, $raw)
{
$data = unpack('C1encoding/A' . ($frameSize - 1) . 'information', $raw);
switch($data['encoding']) {
case 0x00: # ISO-8859-1
return mb_convert_encoding($data['information'], 'utf-8', 'iso-8859-1');
case 0x01: # utf-16 with BOM
return mb_convert_encoding($data['information'] . "\00", 'utf-8', 'utf-16');
# Following is for id3v2.4.x only
case 0x02: # utf-16 without BOM
return mb_convert_encoding($data['information'] . "\00", 'utf-8', 'utf-16');
case 0x03: # utf-8
return $data['information'];
default:
throw new RuntimeException('Unknown text encoding type: '.$data['encoding']);
}
return $this->_getUtf8Text($data['encoding'], $data['information']);
}
/**
@ -1020,7 +1085,8 @@ class Mp3Info {
* @return boolean True if file looks that correct mpeg audio, False otherwise.
* @throws \Exception
*/
public static function isValidAudio($filename) {
public static function isValidAudio($filename)
{
if (str_contains($filename, '://')) {
$fileObj = new Mp3FileRemote($filename);
} else {
@ -1037,7 +1103,7 @@ class Mp3Info {
// id3v2 tag
return true;
}
if (self::FRAME_SYNC === (unpack('n*', $raw)[1] & self::FRAME_SYNC)) {
if ((unpack('n', $raw)[1] & self::FRAME_SYNC) === self::FRAME_SYNC) {
// mpeg header tag
return true;
}
@ -1050,4 +1116,4 @@ class Mp3Info {
}
return false;
}
}
}