commit 2812bab85d06de8675d528ba50217c43e96d6dbc Author: Vitaliy Filippov Date: Sun Sep 9 15:16:31 2018 +0300 File upload layer extracted & evolved from numerous projects diff --git a/DBObjectFile.php b/DBObjectFile.php new file mode 100644 index 0000000..a447a9f --- /dev/null +++ b/DBObjectFile.php @@ -0,0 +1,124 @@ + false, + 'user_id' => false, + 'sha1' => false, + 'format' => false, + 'mimetype' => false, + 'size' => true, + 'width' => true, + 'height' => true, + 'added' => true, + 'props' => true, + ]; + public static $joins = [ + 'user' => 'User', + ]; + + protected function get_disk_path() + { + return FileHandler::getPath(false, $this->data); + } + + protected function get_raw_url() + { + return FileHandler::getPath(true, $this->data); + } + + protected function get_fsize_ru() + { + return FileUtils::sizeString($this->data['size'], 'ru'); + } + + protected function get_fsize_en() + { + return FileUtils::sizeString($this->data['size'], 'en'); + } + + protected function get_url() + { + return App::url('api', [ 'action' => 'Files.thumb', 'sha1' => $this->data['sha1'] ]); + } + + protected function get_gps() + { + return FileHandler::getGPS($this->data); + } + + public function getThumb($width, $height, $force = false, $crop = false, $alignY = 0.5) + { + return FileHandler::getThumb($this->data, $width, $height, $force, $crop, $alignY); + } + + public function cropThumb($width, $height, $alignY = 0.5) + { + return FileHandler::getThumb($this->data, $width, $height, false, self::CROP_XY, $alignY); + } + + public function cropYThumb($width, $max_height, $alignY = 0.5) + { + return FileHandler::getThumb($this->data, $width, $max_height, false, self::CROP_Y, $alignY); + } + + public function cropXThumb($max_width, $height) + { + return FileHandler::getThumb($this->data, $max_width, $height, false, self::CROP_X); + } + + public static function upload(LocalFile $localFile, $allowedFormats = File::ANYTHING) + { + $file = new File(); + $file->data = FileHandler::upload($localFile, $allowedFormats); + return $file->data ? $file : NULL; + } + + public static function uploadUrl($url, $allowedFormats = File::ONLY_IMAGES, $curl_options = []) + { + $file = new File(); + $file->data = FileHandler::uploadUrl($url, $allowedFormats, $curl_options); + return $file->data ? $file : NULL; + } + + public function delete() + { + return FileHandler::deleteFiles([ 'id' => $this->data['id'] ]); + } + + public static function newFromRow($row) + { + $obj = parent::newFromRow($row); + if ($obj) + { + $obj->data['props'] = json_decode($obj->data['props'], true); + } + return $obj; + } + + public function saveMe() + { + throw new Exception('File objects are immutable'); + } +} diff --git a/File.php b/File.php new file mode 100644 index 0000000..df95a3c --- /dev/null +++ b/File.php @@ -0,0 +1,84 @@ +getLocalPath(); + $props = FileUtils::getProps($allowedFormats, $tmp_name, $localFile->getFileName()); + $row = [ + 'id' => NULL, + 'added' => time(), + 'user_id' => App::$user['id'] ?: NULL, + ] + $props + [ 'props' => [] ]; + $exist = App::$db->select(File::$table, '*', [ 'sha1' => $row['sha1'] ], NULL, MS_ROW); + if ($exist) + { + $exist['props'] = json_decode($exist['props'], true); + return $exist; + } + $row['id'] = App::$db->insert_row(File::$table, [ + 'props' => json_encode($row['props'], JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES), + ] + $row); + $fn = FileHandler::getPath(false, $row); + FileUtils::mkpath(dirname($fn), true); + $m = $localFile->shouldMove ? 'rename' : 'copy'; + if (!@$m($tmp_name, $fn)) + { + $error = error_get_last(); + throw new Exception($error['message']); + } + chmod($fn, 0666 & ~umask()); + return $row; + } + + static $uploadCurl; + + public static function uploadUrl($url, $flags = File::ONLY_IMAGES, $curl_options = []) + { + if (!$url) + { + return NULL; + } + $file = NULL; + if (substr($url, 0, 2) == '//') + { + $url = "http:$url"; + } + if (!self::$uploadCurl) + { + // Reuse handle to use keepalive when possible + self::$uploadCurl = curl_init(); + } + curl_setopt_array(self::$uploadCurl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + ] + $curl_options); + $s = curl_exec(self::$uploadCurl); + if ($s) + { + $tmp = tempnam(sys_get_temp_dir(), 'upl'); + file_put_contents($tmp, $s); + unset($s); + $file = File::upload(new LocalFile($tmp, true), $flags); + @unlink($tmp); + } + else + { + // Log it as E_USER_NOTICE + trigger_error(curl_error(self::$uploadCurl)); + } + return $file; + } + + public static function deleteFiles($where) + { + $files = App::$db->select(File::$table, '*', $where); + foreach ($existing as $e) + { + $disk_name = FileHandler::getPath(false, $e); + if (file_exists($disk_name)) + { + // Remove old file + // FIXME unlink thumbnails + unlink($disk_name); + } + } + } + + /** + * Get or generate a thumbnail and return its URL + */ + public static function getThumb($file, $width, $height, $force = false, $crop = false, $alignY = 0.5) + { + $size = FileUtils::getThumbSize($file, $width, $height, $crop); + if (!$size) + { + return FileHandler::getPath(true, $file); + } + list($width, $height) = $size; + if (!$crop) + { + $type = intval($width); + } + else + { + $p = $crop == File::CROP_Y ? 'cy' : ($crop == File::CROP_X ? 'cx' : 'c'); + $type = intval($width).'x'.intval($height).'_'.$alignY; + } + $fn = FileHandler::getThumbPath(false, $file, $type); + if (!file_exists($fn) || $force) + { + if (substr($file['mimetype'], 0, 6) === 'video/') + { + $sourcefn = FileHandler::getThumbPath(false, $file, 'src'); + } + else + { + $sourcefn = FileHandler::getPath(false, $file); + } + try + { + $im = FileUtils::magick(); + $im->readImage($sourcefn); + $props = $file['props']; + if (!empty($props['Orientation']) && $props['Orientation'] > 5) + { + /* swap width & height */ + $t = $width; + $width = $height; + $height = $t; + if ($crop == File::CROP_X || $crop == File::CROP_Y) + $crop = 5-$crop; + } + FileUtils::makeThumb($im, $width, $height, $crop, isset($props['Orientation']) ? $props['Orientation'] : NULL, $alignY); + $im->setCompressionQuality(FileUtils::$quality); + FileUtils::mkpath(dirname($fn)); + $im->writeImage($fn); + } + catch (Exception $e) + { + trigger_error("$e"); + return false; + } + } + return FileHandler::getThumbPath(true, $file, $type); + } +} diff --git a/FileUtils.php b/FileUtils.php new file mode 100644 index 0000000..5fd10bd --- /dev/null +++ b/FileUtils.php @@ -0,0 +1,427 @@ + $mime, + 'sha1' => sha1_file($fs_path), + 'size' => filesize($fs_path), + 'format' => '', + 'width' => 0, + 'height' => 0, + ]; + $flag = File::ONLY_BINARY; + $props = NULL; + if ($mime == 'application/x-shockwave-flash') + { + $props = self::getSWFProps($fs_path); + if ($props) + $flag = File::ONLY_SWF; + } + elseif (substr($mime, 0, 6) == 'image/') + { + $props = self::getImageProps($fs_path); + if ($props) + $flag = File::ONLY_IMAGES; + } + elseif (substr($mime, 0, 6) == 'video/') + { + $props = VideoUtils::getVideoProps($fs_path); + if ($props) + $flag = File::ONLY_VIDEO; + } + if (!($allowed_types & $flag)) + { + throw new UserException('file-type-denied', [ 'allowed' => $allowed_types, 'type' => $flag ]); + } + $props = ($props ?: []) + $stdProps; + if (!$props['format']) + { + if (!empty($props['filename'])) + $orig_name = preg_replace('#^.*/#is', '', $props['filename']); + $p = strrpos($orig_name, '.'); + if ($p) + $props['format'] = strtolower(substr($orig_name, $p+1)); + } + unset($props['filename']); + return $props; + } + + /** + * Get image properties + compress uploaded BMP images to JPEG + */ + protected static function getImageProps($fn) + { + $format = NULL; + $props = []; + try + { + $exif = @exif_read_data($fn); + if ($exif) + { + foreach (self::$exifProps as $p) + { + if (isset($exif[$p])) + { + $props[$p] = $exif[$p]; + } + } + } + // FIXME Сначала проверять ширину/высоту, не читая саму картинку в память + $im = static::magick(); + $im->readImage($fn); + $format = strtolower($im->getImageFormat()); + if ($format == 'jpeg') + { + $format = 'jpg'; + } + if ($format != 'png' && + $format != 'gif' && + $format != 'jpeg' && + $format != 'jpg' && + $format != 'pdf' && + $format != 'djvu' && + $format != 'djv' && + ($format != 'ico' || $im->getImageWidth() > 48 || $im->getImageHeight() > 48)) // bmp, tiff, psd... + { + $im->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN); + if (!empty($props['Orientation'])) + { + self::fixOrientation($im, $props['Orientation']); + unset($props['Orientation']); + } + $im->setCompressionQuality(static::$quality); + $im->setImageFormat('jpeg'); + $format = 'jpg'; + $fn = tempnam(sys_get_temp_dir(), 'imguniq'); + $im->writeImage($fn); + } + $width = $im->getImageWidth(); + $height = $im->getImageHeight(); + if (!empty($props['Orientation']) && $props['Orientation'] >= 5) + { + $t = $width; + $width = $height; + $height = $t; + } + } + catch (Exception $e) + { + // Not an image + return NULL; + } + return [ + 'filename' => $fn, + 'width' => $width, + 'height' => $height, + 'format' => $format, + 'props' => $props, + ]; + } + + /** + * Get MIME type of a file + */ + protected static function checkMime($filename) + { + $mime = false; + if (class_exists('finfo')) + { + static $finfo; + if (!$finfo) + { + $finfo = new finfo(FILEINFO_MIME_TYPE); + } + if ($finfo) + { + $mime = $finfo->file($filename); + } + } + elseif (function_exists('mime_content_type')) + { + $mime = mime_content_type($filename); + } + else + { + $mime = shell_exec("file ".escapeshellarg($filename)); + } + return $mime; + } + + protected static function getSWFProps($fn) + { + $size = shell_exec("swfdump -X -Y ".escapeshellarg($fn)); + if (preg_match('/-X (\d+) -Y (\d+)/', $size, $m)) + { + return [ + 'format' => 'swf', + 'width' => $m[1], + 'height' => $m[2], + ]; + } + return NULL; + } + + /** + * Fix ImageMagick image $im orientation based on EXIF orientation $orient + */ + public static function fixOrientation($im, $orient) + { + $orient = intval($orient); + if ($orient > 1 && $orient <= 8) + { + if ($orient == 2) + { + $im->flopImage(); + } + elseif ($orient == 3) + { + $im->rotateImage($im instanceof Imagick ? new ImagickPixel() : new GmagickPixel(), 180); + } + elseif ($orient == 4) + { + $im->flipImage(); + } + elseif ($orient == 5) + { + if (method_exists($im, 'transposeImage')) + { + $im->transposeImage(); + } + else + { + // GraphicsMagick doesn't have Transpose + $im->rotateImage($im instanceof Imagick ? new ImagickPixel() : new GmagickPixel(), 90); + $im->flopImage(); + } + } + elseif ($orient == 6) + { + $im->rotateImage($im instanceof Imagick ? new ImagickPixel() : new GmagickPixel(), 90); + } + elseif ($orient == 7) + { + if (method_exists($im, 'transverseImage')) + { + $im->transverseImage(); + } + else + { + // GraphicsMagick doesn't have Transverse + $im->rotateImage($im instanceof Imagick ? new ImagickPixel() : new GmagickPixel(), 270); + $im->flopImage(); + } + } + elseif ($orient == 8) + { + $im->rotateImage($im instanceof Imagick ? new ImagickPixel() : new GmagickPixel(), 270); + } + } + } + + /** + * Calculate thumbnail image width and height + */ + public static function thumbnailSize($cw, $ch, $maxw, $maxh) + { + if ($maxw <= 0 && $maxh <= 0 || $cw <= 0 || $ch <= 0) + { + return [ $cw, $ch ]; + } + if ($maxw > 0 && ($maxh <= 0 || $maxh/$ch >= $maxw/$cw)) + { + $nw = $maxw; + $nh = $ch * $maxw / $cw; + } + elseif ($maxh > 0 && ($maxw <= 0 || $maxh/$ch < $maxw/$cw)) + { + $nw = $cw * $maxh / $ch; + $nh = $maxh; + } + return [ intval($nw), intval($nh) ]; + } + + /** + * Return file size description + */ + static $sizeSuffix = [ + 'Кб' => [ + 'bytes' => 'bytes', + 'Kb' => 'Кб', + 'Mb' => 'Мб', + 'Gb' => 'Гб', + ], + ]; + public static function sizeString($bytes, $lang = 'ru') + { + $r = $bytes; + if (is_numeric($r)) + { + if ($r >= 0 && $r < 1024) + $r = $r . ' ' . (isset(self::$sizeString[$lang]['bytes']) ? self::$sizeString[$lang]['bytes'] : 'bytes'); + elseif ($r >= 1024 && $r < 1024*1024) + $r = sprintf('%.2f ', $r/1024) . (isset(self::$sizeString[$lang]['Kb']) ? self::$sizeString[$lang]['Kb'] : 'Kb'); + elseif ($r >= 1024*1024 && $r < 1024*1024*1024) + $r = sprintf('%.2f ', $r/1024/1024) . (isset(self::$sizeString[$lang]['Mb']) ? self::$sizeString[$lang]['Mb'] : 'Mb'); + elseif ($r >= 1024*1024*1024) + $r = sprintf('%.2f ', $r/1024/1024/1024) . (isset(self::$sizeString[$lang]['Gb']) ? self::$sizeString[$lang]['Gb'] : 'Gb'); + elseif ($r < 0) + $r = sprintf('%.2f ', 2-($r/1024/1024/1024)) . (isset(self::$sizeString[$lang]['Gb']) ? self::$sizeString[$lang]['Gb'] : 'Gb'); + } + return $r; + } + + /** + * Recursively create a directory + */ + public static function mkpath($path, $throw_error = true) + { + if (is_dir($path) || @mkdir($path, 0777, true)) + { + return true; + } + if ($throw_error) + { + $error = error_get_last(); + throw new Exception("Failed to create path $path: ".$error['message']); + } + return false; + } + + /** + * Get thumbnail size for $file + */ + public static function getThumbSize($file, $width, $height, $crop = false) + { + if (empty($file['id'])) + return NULL; + if ($width < 0) + $width = 0; + if ($height < 0) + $height = 0; + if (!$width || !$height) + $crop = false; + if (!$file['width'] || !$width && !$height || + (!$width || $file['width'] <= $width) && + (!$height || $file['height'] < $height) && !$crop || + ($file['format'] == 'swf' || $file['format'] == 'video')) + return NULL; + if (!$crop) + return self::thumbnailSize($file['width'], $file['height'], $width, $height); + return [ $width, $height ]; + } + + /** + * Convert ImageMagick image $im to a thumbnail + */ + public static function makeThumb($im, $width, $height, $crop, $orientation, $alignY) + { + $cl = get_class($im); + if ($crop) + { + $iw = $im->getImageWidth(); + $ih = $im->getImageHeight(); + if ($crop == File::CROP_Y && $ih*$width/$iw < $height) + { + $height = $ih*$width/$iw; + } + elseif ($crop == File::CROP_X && $iw*$height/$ih < $width) + { + $width = $iw*$height/$ih; + } + if ($width/$height < $iw/$ih) + { + $cw = intval($width*$ih/$height); + $im->cropImage($cw, $ih, intval(($iw-$cw)/2), 0); + } + elseif ($width/$height > $iw/$ih) + { + $ch = intval($height*$iw/$width); + $im->cropImage($iw, $ch, 0, intval(($ih-$ch)*$alignY)); + } + $im->resizeImage($width, $height, $cl::FILTER_LANCZOS, 1); + $im->setImagePage($width, $height, 0, 0); /* for gif cropping */ + $iw = $im->getImageWidth(); + $ih = $im->getImageHeight(); + } + else + { + $im->resizeImage($width, $height, $cl::FILTER_LANCZOS, 1); + } + $im->stripImage(); + if ($orientation !== NULL) + { + self::fixOrientation($im, $orientation); + } + } +} diff --git a/LocalFile.php b/LocalFile.php new file mode 100644 index 0000000..378ec6e --- /dev/null +++ b/LocalFile.php @@ -0,0 +1,23 @@ +name = $name; + $this->shouldMove = $shouldMove; + } + + function getLocalPath() + { + return $this->name; + } + + function getFileName() + { + return basename($this->name); + } +} diff --git a/PostedFile.php b/PostedFile.php new file mode 100644 index 0000000..6d9afbf --- /dev/null +++ b/PostedFile.php @@ -0,0 +1,50 @@ +name = $name; + if (!is_uploaded_file(@$_FILES[$this->name]['tmp_name'])) + { + throw new Exception("No POSTed file with name $name"); + } + } + + static function newFromName($name) + { + if (is_uploaded_file(@$_FILES[$name]['tmp_name'])) + { + return new self($name); + } + return false; + } + + function __destruct() + { + $tmp_name = $_FILES[$this->name]['tmp_name']; + if (@is_uploaded_file($tmp_name)) + { + // Unlink temporary upload + @unlink($tmp_name); + } + } + + function getLocalPath() + { + return $_FILES[$this->name]['tmp_name']; + } + + function getFileName() + { + if (isset($_FILES[$this->name]['name'])) + { + $name = trim($_FILES[$this->name]['name']); + $name = preg_replace('#^.*[/\\\\]#is', '', $name); + return $name; + } + return ''; + } +} diff --git a/VideoUtils.php b/VideoUtils.php new file mode 100644 index 0000000..07b6e35 --- /dev/null +++ b/VideoUtils.php @@ -0,0 +1,180 @@ + 1) + { + $dur = 0; + for ($mul = 1, $i = 0; $i < count($colon); $i++, $mul *= 60) + $dur += $colon[count($colon)-1-$i] * $mul; + } + // Пустой формат видео означает, что файл ещё нужно обработать (минимум qt-faststart и вытащить тамбик) + return [ + 'format' => 'video', + 'width' => $data['size'][1][0], + 'height' => $data['size'][2][0], + 'props' => [ + 'duration' => $dur, + 'video_format' => @$data['video_format'][1], + 'audio_format' => @$data['audio_format'][1], + ], + ]; + } + return NULL; + } + + protected static function extractVideoPreview($file, $redirect_output = '') + { + $conv = App::$config['video_converter']; + $ss = $file['props']['duration'] * $conv['preview_moment']; + $fn = FileHandler::getPath(false, $file); + $out = FileHandler::getThumbPath(false, $file, 'src'); + FileUtils::mkpath(dirname($out), true); + $cmd = $conv['extract_frame']; + $cmd = str_replace([ '$input', '$output', '$position' ], [ escapeshellarg($fn), escapeshellarg($out), ceil($ss) ], $cmd); + system($cmd . ($redirect_output ? ' &> '.$redirect_output : '')); + if (!file_exists($out) || !filesize($out)) + { + throw new Exception('Failed to extract video frame for preview'); + } + } + + public static function runConvertJobDaemon($redirect_output = '') + { + while (1) + { + $file = App::$db->selectRow(File::$table, '*', [ 'format' => 'video' ], [ 'LIMIT' => 1 ]); + if ($file) + { + self::convertVideoAndUpdate($file, $redirect_output); + } + App::$db->commitAll(); + sleep(5); + } + } + + public static function convertVideoAndUpdate($file, $redirect_output = '') + { + $props = self::convertVideo($file, $redirect_output); + if (!$props) + { + return; + } + self::extractVideoPreview($props + $file, $redirect_output); + $update = $props; + if (isset($update['props'])) + { + $update['props'] = json_encode($update['props']); + } + App::$db->update(File::$table, $update, [ 'id' => $file['id'] ]); + if (isset($props['sha1']) && $props['sha1'] != $file['sha1']) + { + @unlink(FileHandler::getPath(false, $file)); + } + elseif (isset($props['format']) && $props['format'] != $file['format']) + { + @rename(FileHandler::getPath(false, $file), FileHandler::getPath(false, $props + $file)); + } + return $props + $file; + } + + protected static function convertVideo($file, $redirect_output = '') + { + // Краткая сводка: + // Flash понимает форматы файлов MP4/FLV с видео+аудио FLV+MP3 или H.264+AAC + // HTML5 кроссбраузерно понимает форматы MP4 с видео H.264 и аудио MP3/AAC, либо WebM с видео VP8 и аудио Vorbis + // Для MP4 надо делать qt-faststart, если MOOV ATOM не находится в начале видеофайла + // + // Итак, если формат WebM+VP8+Vorbis или FLV+FLV+MP3 или FLV+H.264+AAC, видео можно вообще не конвертировать + // Если формат MP4+H.264+MP3/AAC, надо проверить/сделать qt-faststart + // Любой другой формат надо перегнать в MP4+H.264+AAC + if ($file['format'] != 'video') + { + return NULL; + } + if (empty(App::$config['video_converter'])) + { + throw new Exception('Video converter is not configured on the server'); + } + $conv = App::$config['video_converter']; + $vf = $file['props']['video_format'][0]; + $af = $file['props']['audio_format'][0]; + if ($file['mimetype'] == 'video/x-flv' && ($vf == 'flv1' && $af == 'mp3' || $vf == 'h264' && $af == 'aac')) + { + return [ + 'format' => 'flv', + ]; + } + elseif ($file['mimetype'] == 'video/webm' && $vf == 'vp8' && $af == 'vorbis') + { + return [ + 'format' => 'webm', + ]; + } + $fn = FileHandler::getPath(false, $file); + $out = tempnam(sys_get_temp_dir(), 'ffc'); + if ($file['mimetype'] == 'video/mp4' && $vf == 'h264' && ($af == 'mp3' || $af == 'aac')) + { + $cmd = $conv['qt_faststart'].' '.escapeshellarg($fn).' '.escapeshellarg($out); + system($cmd . ($redirect_output ? ' &> '.$redirect_output : '')); + if (!file_exists($out)) + { + // Уже было faststart, всё ок + return [ + 'format' => 'mp4', + ]; + } + } + else + { + $cmd = $conv['convert']; + $cmd = str_replace([ '$input', '$output' ], [ escapeshellarg($fn), escapeshellarg($out) ], $cmd); + system($cmd . ($redirect_output ? ' &> '.$redirect_output : '')); + if (!file_exists($out)) + { + // Не сконвертировалось, плохо + throw new Exception('Failed to convert video to MP4'); + } + } + // Сконвертированный файл + $update = FileUtils::getProps(File::ONLY_VIDEO, $out, $out); + $update['format'] = 'mp4'; + $newfn = FileHandler::getPath(false, $update + $file); + FileUtils::mkpath(dirname($newfn), true); + if (!@rename($out, $newfn)) + { + $error = error_get_last(); + throw new Exception($error['message']); + } + // FIXME Теоретически может получиться так, что полученный файл будет дубликатом, и запрос свалится + // (Может ли ffmpeg дважды выдать одинаковый файл? Тогда будет проблемка) + return $update; + } +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..9bc6977 --- /dev/null +++ b/config.php @@ -0,0 +1,29 @@ + dirname(dirname(__DIR__)), + 'files_path' => '/files/', + 'mime_blacklist' => '#'. + // HTML may contain cookie-stealing JavaScript and web bugs + '^text/(html|(x-)?javascript)$|^application/x-shellscript$'. + // PHP/Perl/Bash/etc scripts may execute arbitrary code on the server + '|php|perl|python|bash|x-c?sh(e|$)'. + // Client-side hazards on Internet Explorer + '|^text/scriptlet$|^application/x-msdownload$'. + // Windows metafile, client-side vulnerability on some systems + '|^application/x-msmetafile$'. + '#is', + 'video_converter' => [ + 'extract_frame' => '/usr/bin/ffmpeg -ss \'$position\' -i $input -vframes 1 -f image2 -y $output 2>&1', + 'preview_moment' => '0.05', + 'probe' => '/usr/bin/ffmpeg -i $input 2>&1', + 'probe_format' => '/Input #\d+, (\S+), from/is', + 'format_aliases' => [ 'mov,mp4,m4a,3gp,3g2,mj2' => 'mp4', 'matroska' => 'mkv', 'matroska,webm' => 'mkv' ], + 'probe_size' => '/Stream.*Video.*,\s+(\d+)x(\d+)/is', // [1] == width, [2] == height + 'probe_duration' => '/Duration: ([\d:]+)/is', + 'probe_video_format' => '/Stream.*Video:\s*(\S+)/is', + 'probe_audio_format' => '/Stream.*Audio:\s*(\S+)/is', + 'qt_faststart' => '/usr/bin/qt-faststart', + 'convert' => '/usr/bin/ffmpeg -i $input -vcodec h264 -qmax 28 -acodec aac -movflags faststart -y $output', + ], +]; diff --git a/files.sql b/files.sql new file mode 100644 index 0000000..fef7d1c --- /dev/null +++ b/files.sql @@ -0,0 +1,29 @@ +-- Файлы +create table if not exists files ( + id serial not null primary key, + added bigint not null, + user_id int, + sha1 varchar(40) not null, + format varchar(40) not null, + mimetype varchar(1024) not null, + size bigint not null, + width int not null default 0, + height int not null default 0, + props jsonb not null default '{}'::jsonb, + foreign key (user_id) references users (id) on delete set null on update cascade +); + +create unique index on files (sha1); +create index on files (format); +create index on files (user_id); + +comment on table files is 'Файлы'; +comment on column files.added is 'UNIX время загрузки'; +comment on column files.user_id is 'ID пользователя-владельца'; +comment on column files.sha1 is 'SHA1 хеш'; +comment on column files.format is 'Формат (расширение)'; +comment on column files.mimetype is 'MIME-тип (реальный формат)'; +comment on column files.size is 'Размер файла'; +comment on column files.width is 'Ширина'; +comment on column files.height is 'Высота'; +comment on column files.propdata is 'Дополнительные свойства (EXIF, параметры видео)';