* @author Ryan Butterfield * @copyright Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com) * @license http://framework.zend.com/license/new-bsd New BSD License * @version $Id$ */ final class Zend_Media_Id3v2 extends Zend_Media_Id3_Object { /** @var Zend_Media_Id3_Header */ private $_header; /** @var Zend_Media_Id3_ExtendedHeader */ private $_extendedHeader; /** @var Zend_Media_Id3_Header */ private $_footer; /** @var Array */ private $_frames = array(); /** @var string */ private $_filename = null; /** * Constructs the Zend_Media_Id3v2 class with given file and options. The * options array may also be given as the only parameter. * * The following options are currently recognized: * o encoding -- Indicates the encoding that all the texts are presented * with. See the documentation of iconv for supported values. Please * note that write operations do not convert string and thus encodings * are limited to those supported by the {@link Zend_Media_Id3_Encoding} * interface. * o version -- The ID3v2 tag version to use in write operation. This * option is automatically set when a tag is read from a file and * defaults to version 4.0 for tag write. * o compat -- Normally unsynchronization is handled automatically behind * the scenes. However, current versions of Windows operating system and * Windows Media Player, just to name a few, do not support ID3v2.4 tags * nor ID3v2.3 tags with unsynchronization. Hence, for compatibility * reasons, this option is made available to disable automatic tag level * unsynchronization scheme that version 3.0 supports. * o readonly -- Indicates that the tag is read from a temporary file or * another source it cannot be written back to. The tag can, however, * still be written to another file. * * @todo Only limited subset of flags are processed. * @todo Utilize the SEEK frame and search for a footer to find the tag * @todo Utilize the LINK frame to fetch frames from other sources * @param string|resource|Zend_Io_Reader $filename The path to the file, * file descriptor of an opened file, or a {@link Zend_Io_Reader} instance. * @param Array $options The options array. * @throws Zend_Media_Id3_Exception if given file descriptor is not valid */ public function __construct($filename = null, $options = array()) { parent::__construct(null, $options); if (is_array($filename)) { $options = $filename; $filename = null; } if ($filename === null) { $this->_header = new Zend_Media_Id3_Header(null, $options); return; } if ($filename instanceof Zend_Io_Reader) { $this->_reader = &$filename; } else { require_once 'Zend/Io/FileReader.php'; try { $this->_reader = new Zend_Io_FileReader($filename); } catch (Zend_Io_Exception $e) { $this->_reader = null; require_once 'Zend/Media/Id3/Exception.php'; throw new Zend_Media_Id3_Exception($e->getMessage()); } if (is_string($filename) && !isset($options['readonly'])) { $this->_filename = $filename; } } $startOffset = $this->_reader->getOffset(); if ($this->_reader->read(3) != 'ID3') { require_once 'Zend/Media/Id3/Exception.php'; throw new Zend_Media_Id3_Exception ('File does not contain ID3v2 tag'); } $this->_header = new Zend_Media_Id3_Header($this->_reader, $options); $tagSize = $this->_header->getSize(); if ($this->_header->getVersion() < 3 || $this->_header->getVersion() > 4) { require_once 'Zend/Media/Id3/Exception.php'; throw new Zend_Media_Id3_Exception ('File does not contain ID3v2 tag of supported version: v2.' . $this->_header->getVersion()); } if ($this->_header->getVersion() < 4 && $this->_header->hasFlag(Zend_Media_Id3_Header::UNSYNCHRONISATION)) { $data = $this->_reader->read($this->_header->getSize()); require_once 'Zend/Io/StringReader.php'; $this->_reader = new Zend_Io_StringReader ($this->_decodeUnsynchronisation($data)); $tagSize = $this->_reader->getSize(); } $this->clearOption('unsynchronisation'); if ($this->_header->hasFlag(Zend_Media_Id3_Header::UNSYNCHRONISATION)) { $this->setOption('unsynchronisation', true); } if ($this->_header->hasFlag(Zend_Media_Id3_Header::EXTENDED_HEADER)) { require_once 'Zend/Media/Id3/ExtendedHeader.php'; $this->_extendedHeader = new Zend_Media_Id3_ExtendedHeader($this->_reader, $options); } if ($this->_header->hasFlag(Zend_Media_Id3_Header::FOOTER)) { // skip footer, and rather copy header $this->_footer = &$this->_header; } while (true) { $offset = $this->_reader->getOffset(); // Jump off the loop if we reached the end of the tag if ($offset - $startOffset - 10 >= $tagSize - ($this->hasFooter() ? 10 : 0) - 10 /* header */) { break; } // Jump off the loop if we reached padding if (ord($identifier = $this->_reader->read(1)) === 0) { break; } $identifier .= $this->_reader->read(3); // Jump off the loop if we reached invalid entities. This fix is // just to make things work. Utility called MP3ext does not seem // to know what it is doing as it uses padding to write its // version information there. if ($identifier == 'MP3e') { break; } $this->_reader->setOffset($offset); if (@fopen($file = 'Zend/Media/Id3/Frame/' . ucfirst(strtolower($identifier)) . '.php', 'r', true) !== false) { require_once($file); } if (class_exists ($classname = 'Zend_Media_Id3_Frame_' . ucfirst(strtolower($identifier)))) { $frame = new $classname($this->_reader, $options); } else { require_once 'Zend/Media/Id3/Frame/Unknown.php'; $frame = new Zend_Media_Id3_Frame_Unknown($this->_reader, $options); } if (!isset($this->_frames[$frame->getIdentifier()])) { $this->_frames[$frame->getIdentifier()] = array(); } $this->_frames[$frame->getIdentifier()][] = $frame; } } /** * Returns the header object. * * @return Zend_Media_Id3_Header */ public function getHeader() { return $this->_header; } /** * Checks whether there is an extended header present in the tag. Returns * true if the header is present, false otherwise. * * @return boolean */ public function hasExtendedHeader() { if ($this->_header) { return $this->_header->hasFlag (Zend_Media_Id3_Header::EXTENDED_HEADER); } return false; } /** * Returns the extended header object if present, or false * otherwise. * * @return Zend_Media_Id3_ExtendedHeader|false */ public function getExtendedHeader() { if ($this->hasExtendedHeader()) { return $this->_extendedHeader; } return false; } /** * Sets the extended header object. * * @param Zend_Media_Id3_ExtendedHeader $extendedHeader The header object */ public function setExtendedHeader($extendedHeader) { if (is_subclass_of($extendedHeader, 'Zend_Media_Id3_ExtendedHeader')) { $this->_header->flags = $this->_header->flags | Zend_Media_Id3_Header::EXTENDED_HEADER; $this->_extendedHeader->setOptions($this->getOptions()); $this->_extendedHeader = $extendedHeader; } else { require_once 'Zend/Media/Id3/Exception.php'; throw new Zend_Media_Id3_Exception('Invalid argument'); } } /** * Checks whether there is a frame given as an argument defined in the tag. * Returns true if one ore more frames are present, * false otherwise. * * @param string $identifier The frame name. * @return boolean */ public function hasFrame($identifier) { return isset($this->_frames[$identifier]); } /** * Returns all the frames the tag contains as an associate array. The frame * identifiers work as keys having an array of frames as associated value. * * @return Array */ public function getFrames() { return $this->_frames; } /** * Returns an array of frames matching the given identifier or an empty * array if no frames matched the identifier. * * The identifier may contain wildcard characters '*' and '?'. The asterisk * matches against zero or more characters, and the question mark matches * any single character. * * Please note that one may also use the shorthand $obj->identifier to * access the first frame with the identifier given. Wildcards cannot be * used with the shorthand method. * * @param string $identifier The frame name. * @return Array */ public function getFramesByIdentifier($identifier) { $matches = array(); $searchPattern = '/^' . str_replace(array('*', '?'), array('.*', '.'), $identifier) . '$/i'; foreach ($this->_frames as $identifier => $frames) { if (preg_match($searchPattern, $identifier)) { foreach ($frames as $frame) { $matches[] = $frame; } } } return $matches; } /** * Removes any frames matching the given object identifier. * * The identifier may contain wildcard characters '*' and '?'. The asterisk * matches against zero or more characters, and the question mark matches * any single character. * * One may also use the shorthand unset($obj->identifier) to achieve the * same result. Wildcards cannot be used with the shorthand method. * * @param string $identifier The frame name. */ public final function removeFramesByIdentifier($identifier) { $searchPattern = '/^' . str_replace(array('*', '?'), array('.*', '.'), $identifier) . '$/i'; foreach ($this->_frames as $identifier => $frames) { if (preg_match($searchPattern, $identifier)) { foreach ($frames as $key => $value) { unset($this->_frames[$identifier][$key]); } } } } /** * Adds a new frame to the tag and returns it. * * @param Zend_Media_Id3_Frame $frame The frame to add. * @return Zend_Media_Id3_Frame */ public function addFrame($frame) { $frame->setOptions($this->getOptions()); $frame->setEncoding ($this->getOption('encoding', $this->getOption('version', 4) < 4 ? Zend_Media_Id3_Encoding::ISO88591 : Zend_Media_Id3_Encoding::UTF)); if (!$this->hasFrame($frame->getIdentifier())) { $this->_frames[$frame->getIdentifier()] = array(); } return $this->_frames[$frame->getIdentifier()][] = $frame; } /** * Remove the given frame from the tag. * * @param Zend_Media_Id3_Frame $frame The frame to remove. */ public function removeFrame($frame) { if (!$this->hasFrame($frame->getIdentifier())) { return; } foreach ($this->_frames[$frame->getIdentifier()] as $key => $value) { if ($frame === $value) { unset($this->_frames[$frame->getIdentifier()][$key]); } } } /** * Checks whether there is a footer present in the tag. Returns * true if the footer is present, false otherwise. * * @return boolean */ public function hasFooter() { return $this->_header->hasFlag(Zend_Media_Id3_Header::FOOTER); } /** * Returns the footer object if present, or false otherwise. * * @return Zend_Media_Id3_Header|false */ public function getFooter() { if ($this->hasFooter()) { return $this->_footer; } return false; } /** * Sets whether the tag should have a footer defined. * * @param boolean $useFooter Whether the tag should have a footer */ public function setFooter($useFooter) { if ($useFooter) { $this->_header->setFlags ($this->_header->getFlags() | Zend_Media_Id3_Header::FOOTER); $this->_footer = &$this->_header; } else { /* Count footer bytes towards the tag size, so it gets removed or overridden upon re-write */ if ($this->hasFooter()) { $this->_header->setSize($this->_header->getSize() + 10); } $this->_header->setFlags ($this->_header->getFlags() & ~Zend_Media_Id3_Header::FOOTER); $this->_footer = null; } } /** * Writes the possibly altered ID3v2 tag back to the file where it was read. * If the class was constructed without a file name, one can be provided * here as an argument. Regardless, the write operation will override * previous tag information, if found. * * If write is called on a tag without any frames to it, current tag is * removed from the file altogether. * * @param string|Zend_Io_Writer $filename The optional path to the file, use * null to save to the same file. */ public function write($filename) { if ($filename === null && ($filename = $this->_filename) === null) { require_once 'Zend/Media/Id3/Exception.php'; throw new Zend_Media_Id3_Exception('No file given to write to'); } else if ($filename !== null && $filename instanceof Zend_Io_Writer) { require_once 'Zend/Io/Writer.php'; $this->_writeData($filename); return; } else if ($filename !== null && $this->_filename !== null && realpath($filename) != realpath($this->_filename) && !copy($this->_filename, $filename)) { require_once 'Zend/Media/Id3/Exception.php'; throw new Zend_Media_Id3_Exception ('Unable to copy source to destination: ' . realpath($this->_filename) . '->' . realpath($filename)); } if (($fd = fopen ($filename, file_exists($filename) ? 'r+b' : 'wb')) === false) { require_once 'Zend/Media/Id3/Exception.php'; throw new Zend_Media_Id3_Exception ('Unable to open file for writing: ' . $filename); } $hasNoFrames = true; foreach ($this->_frames as $identifier => $instances) { if (count($instances) > 0) { $hasNoFrames = false; break; } } if ($hasNoFrames === true) { $this->remove(new Zend_Io_Reader($fd)); return; } if ($this->_reader !== null) { $oldTagSize = 10 /* header */ + $this->_header->getSize(); } else { $reader = new Zend_Io_Reader($fd); if ($reader->read(3) == 'ID3') { $header = new Zend_Media_Id3_Header($reader); $oldTagSize = 10 /* header */ + $header->getSize(); } else { $oldTagSize = 0; } } require_once 'Zend/Io/StringWriter.php'; $tag = new Zend_Io_StringWriter(); $this->_writeData($tag); $tagSize = $tag->getSize(); if ($tagSize > $oldTagSize) { fseek($fd, 0, SEEK_END); $oldFileSize = ftell($fd); ftruncate ($fd, $newFileSize = $tagSize - $oldTagSize + $oldFileSize); for ($i = 1, $cur = $oldFileSize; $cur > 0; $cur -= 1024, $i++) { if ($cur >= 1024) { fseek($fd, -(($i * 1024) + ($newFileSize - $oldFileSize)), SEEK_END); $buffer = fread($fd, 1024); fseek($fd, -($i * 1024), SEEK_END); $bytes = fwrite($fd, $buffer, 1024); } else { fseek($fd, 0); $buffer = fread($fd, $cur); fseek($fd, $newFileSize - $oldFileSize); $bytes = fwrite($fd, $buffer, $cur); } } if (($remaining = $oldFileSize % 1024) != 0) { // huh? } fseek($fd, 0, SEEK_END); } fseek($fd, 0); for ($i = 0; $i < $tagSize; $i += 1024) { fseek($tag->getFileDescriptor(), $i); $bytes = fwrite($fd, fread($tag->getFileDescriptor(), 1024)); } fclose($fd); $this->_filename = $filename; } /** * Writes the tag data. * * @param Zend_Io_Writer $writer The writer object. * @return void */ private function _writeData($writer) { $this->clearOption('unsynchronisation'); $buffer = new Zend_Io_StringWriter(); foreach ($this->_frames as $frames) { foreach ($frames as $frame) { $frame->write($buffer); } } $frameData = $buffer->toString(); $frameDataLength = strlen($frameData); $paddingLength = 0; // ID3v2.4.0 supports frame level unsynchronisation while // ID3v2.3.0 supports only tag level unsynchronisation. if ($this->getOption('version', 4) < 4 && $this->getOption('compat', false) !== true) { $frameData = $this->_encodeUnsynchronisation($frameData); if (($len = strlen($frameData)) != $frameDataLength) { $frameDataLength = $len; $this->_header->setFlags ($this->_header->getFlags() | Zend_Media_Id3_Header::UNSYNCHRONISATION); } else { $this->_header->setFlags ($this->_header->getFlags() & ~Zend_Media_Id3_Header::UNSYNCHRONISATION); } } // The tag padding is calculated as follows. If the tag can be written // in the space of the previous tag, the remaining space is used for // padding. If there is no previous tag or the new tag is bigger than // the space taken by the previous tag, the padding is a constant // 4096 bytes. if ($this->hasFooter() === false) { if ($this->_reader !== null && $frameDataLength < $this->_header->getSize()) { $paddingLength = $this->_header->getSize() - $frameDataLength; } else { $paddingLength = 4096; } } /* ID3v2.4.0 CRC calculated w/ padding */ if ($this->getOption('version', 4) >= 4) { $frameData = str_pad($frameData, $frameDataLength += $paddingLength, "\0"); } $extendedHeaderData = ''; $extendedHeaderDataLength = 0; if ($this->hasExtendedHeader()) { $this->_extendedHeader->setPadding($paddingLength); if ($this->_extendedHeader->hasFlag (Zend_Media_Id3_ExtendedHeader::CRC32)) { $crc = crc32($frameData); if ($crc & 0x80000000) { $crc = -(($crc ^ 0xffffffff) + 1); } $this->_extendedHeader->setCrc($crc); } $buffer = new Zend_Io_StringWriter(); $this->_extendedHeader->write($buffer); $extendedHeaderData = $buffer->toString(); $extendedHeaderDataLength = strlen($extendedHeaderData); } /* ID3v2.3.0 CRC calculated w/o padding */ if ($this->getOption('version', 4) < 4) { $frameData = str_pad($frameData, $frameDataLength += $paddingLength, "\0"); } $this->_header->setSize($extendedHeaderDataLength + $frameDataLength); $writer->write('ID3'); $this->_header->write($writer); $writer->write($extendedHeaderData); $writer->write($frameData); if ($this->hasFooter()) { $writer->write('3DI'); $this->_footer->write($writer); } } /** * Removes the ID3v2 tag altogether. * * @param string $filename The path to the file. */ public static function remove($filename) { if ($filename instanceof Zend_Io_Reader) { $reader = &$filename; } else { require_once 'Zend/Io/FileReader.php'; $reader = new Zend_Io_FileReader($filename, 'r+b'); } $fileSize = $reader->getSize(); if ($reader->read(3) == 'ID3') { $header = new Zend_Media_Id3_Header($reader); $tagSize = 10 /* header */ + $header->getSize(); } else return; $fd = $reader->getFileDescriptor(); for ($i = 0; $tagSize + ($i * 1024) < $fileSize; $i++) { fseek($fd, $tagSize + ($i * 1024)); $buffer = fread($fd, 1024); fseek($fd, ($i * 1024)); $bytes = fwrite($fd, $buffer, 1024); } ftruncate($fd, $fileSize - $tagSize); } /** * Magic function so that $obj->value will work. The method will attempt to * return the first frame that matches the identifier. * * If there is no frame or field with given name, the method will attempt to * create a frame with given identifier. * * If none of these work, an exception is thrown. * * @param string $name The frame or field name. * @return mixed */ public function __get($name) { if (!empty($this->_frames[strtoupper($name)])) { return $this->_frames[strtoupper($name)][0]; } if (method_exists($this, 'get' . ucfirst($name))) { return call_user_func (array($this, 'get' . ucfirst($name))); } if (@fopen($filename = 'Zend/Media/Id3/Frame/' . ucfirst($name) . '.php', 'r', true) !== false) { require_once $filename; } if (class_exists ($classname = 'Zend_Media_Id3_Frame_' . ucfirst($name))) { return $this->addFrame(new $classname()); } require_once 'Zend/Media/Id3/Exception.php'; throw new Zend_Media_Id3_Exception('Unknown frame/field: ' . $name); } /** * Magic function so that isset($obj->value) will work. This method checks * whether the frame matching the identifier exists. * * @param string $name The frame identifier. * @return boolean */ public function __isset($name) { return isset($this->_frames[strtoupper($name)]); } /** * Magic function so that unset($obj->value) will work. This method removes * all the frames matching the identifier. * * @param string $name The frame identifier. */ public function __unset($name) { unset($this->_frames[strtoupper($name)]); } }