diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1706f3e
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,11 @@
+* text=auto
+* eol=lf
+
+.git export-ignore
+.gitattributes export-ignore
+.gitignore export-ignore
+vendor export-ignore
+makefile export-ignore
+phpunit.xml export-ignore
+
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8180267
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/vendor/
+composer.lock
+
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..2431853
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,28 @@
+{
+ "name": "ajur-media/fsnews.media",
+ "description": "FSNews Engine Media common class",
+ "type": "library",
+ "license": "MIT",
+ "autoload": {
+ "psr-4": {
+ "AJUR\\FSNews\\": "src/"
+ }
+ },
+ "authors": [
+ {
+ "name": "Karel Wintersky",
+ "email": "karel.wintersky@yandex.ru"
+ }
+ ],
+ "require": {
+ "ext-json": "*",
+ "ext-fileinfo": "*",
+ "psr/log": "^1.1",
+ "karelwintersky/arris": "^2.4",
+ "karelwintersky/arris.toolkit.mimetypes": "^1.0",
+ "ajur-media/php_gdwrapper": "^1.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5"
+ }
+}
diff --git a/makefile b/makefile
new file mode 100644
index 0000000..eeb3b1b
--- /dev/null
+++ b/makefile
@@ -0,0 +1,22 @@
+#!/usr/bin/make
+
+help:
+ @perl -e '$(HELP_ACTION)' $(MAKEFILE_LIST)
+
+test: ##@test PHPUnit tests
+ @php ./vendor/bin/phpunit --bootstrap vendor/autoload.php --testdox tests
+
+# ------------------------------------------------
+# Add the following 'help' target to your makefile, add help text after each target name starting with '\#\#'
+# A category can be added with @category
+GREEN := $(shell tput -Txterm setaf 2)
+YELLOW := $(shell tput -Txterm setaf 3)
+WHITE := $(shell tput -Txterm setaf 7)
+RESET := $(shell tput -Txterm sgr0)
+HELP_ACTION = \
+ %help; while(<>) { push @{$$help{$$2 // 'options'}}, [$$1, $$3] if /^([a-zA-Z\-_]+)\s*:.*\#\#(?:@([a-zA-Z\-]+))?\s(.*)$$/ }; \
+ print "usage: make [target]\n\n"; for (sort keys %help) { print "${WHITE}$$_:${RESET}\n"; \
+ for (@{$$help{$$_}}) { $$sep = " " x (32 - length $$_->[0]); print " ${YELLOW}$$_->[0]${RESET}$$sep${GREEN}$$_->[1]${RESET}\n"; }; \
+ print "\n"; }
+
+# -eof-
\ No newline at end of file
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..89a0c9e
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,9 @@
+
+
+
+ ./tests
+
+
diff --git a/src/Constants/AllowedMimeTypes.php b/src/Constants/AllowedMimeTypes.php
new file mode 100644
index 0000000..dea13c5
--- /dev/null
+++ b/src/Constants/AllowedMimeTypes.php
@@ -0,0 +1,25 @@
+ "titles",
+ "titles" => "titles",
+ "photos" => "photos",
+ "videos" => "videos",
+ "audios" => "audios",
+ "youtube" => "youtube",
+ "files" => "files",
+ "_" => ""
+ ];
+
+ public static function getContentDir($type = 'photos'):string
+ {
+ return array_key_exists($type, self::$content_dirs)
+ ? self::$content_dirs[$type]
+ : self::$content_dirs['_'];
+ }
+
+}
\ No newline at end of file
diff --git a/src/Constants/ConvertSizes.php b/src/Constants/ConvertSizes.php
new file mode 100644
index 0000000..83ab3bc
--- /dev/null
+++ b/src/Constants/ConvertSizes.php
@@ -0,0 +1,166 @@
+ [
+ /*
+ * Иконка превью в списке фотографий, входящих в фоторепортаж (десктоп и мобильный)
+ */
+ "100x100" => [
+ 'maxWidth' => 100,
+ 'maxHeight' => 100,
+ 'method' => "getfixedpicture",
+ 'prefix' => '100x100_',
+ 'quality' => 80,
+ ],
+ /*
+ * sizes_full - для десктопного фоторепортажа в RSS-лентах
+ * базовый размер для фото, вставляемого в МОБИЛЬНУЮ статью через [media id=] -- (считается, что 440 - это базовая ширина мобилки)
+ */
+ "440x300" => [
+ 'maxWidth' => 440,
+ 'maxHeight' => 999,
+ 'method' => "resizepictureaspect",
+ 'wmFile' => "l.png",
+ 'wmMargin' => 10,
+ 'prefix' => '440x300_',
+ 'quality' => 80,
+ ],
+ /*
+ * базовый размер для фото в фоторепортаже (десктоп и мобильный)
+ * базовый размер для фото, вставляемого в десктопную статью через [media id=]
+ */
+ "630x465" => [
+ 'maxWidth' => 630,
+ 'maxHeight' => 465,
+ 'method' => "resizepictureaspect",
+ 'wmFile' => "l.png",
+ 'wmMargin' => 30,
+ 'prefix' => '630x465_',
+ 'quality' => 80,
+ ],
+ /*
+ * sizes_full - полноразмерная картинка, всплывающая при клике на вставленную в статью/страницу фото размера 'sizes' (630x465)
+ * sizes_full для репортажей на мобиле
+ * sizes_large для всех фото
+ * + упоминается в шаблоне site/reports/reports_list.tpl
+ */
+ "1280x1024" => [
+ 'maxWidth' => 1280,
+ 'maxHeight' => 1024,
+ 'method' => "resizeimageaspect",
+ 'wmFile' => "l.png",
+ 'wmMargin' => 30,
+ 'prefix' => '1280x1024_',
+ 'quality' => 90,
+ ],
+ ],
+ "videos" => [
+ "100x100" => [
+ /*
+ * Превью-иконка видео в админке
+ */
+ 'maxWidth' => 100,
+ 'maxHeight' => 100,
+ 'method' => "getfixedpicture",
+ 'prefix' => '100x100_',
+ 'quality' => 80,
+ ],
+ /*
+ * Превью видео, используется
+ */
+ "640x352" => [
+ 'maxWidth' => 640,
+ 'maxHeight' => 360,
+ 'method' => "getfixedpicture",
+ 'prefix' => '640x352_',
+ 'quality' => 80,
+ ],
+ ],
+
+ "audios" => [
+ "_" => [
+ 'prefix' => '',
+ ],
+ ],
+
+ "files" => [
+ "_" => [
+ 'prefix' => '',
+ ],
+ ],
+
+ "youtube" => [
+ /*
+ * Превью в админке
+ */
+ "100x100" => [
+ 'maxWidth' => 100,
+ 'maxHeight' => 100,
+ 'method' => "getfixedpicture",
+ 'prefix' => '100x100_',
+ 'quality' => 80
+ ],
+ /*
+ * Превью видео, используется
+ */
+ "640x352" => [
+ 'maxWidth' => 640,
+ 'maxHeight' => 360,
+ 'method' => "getfixedpicture",
+ 'prefix' => '640x325_',
+ 'quality' => 80
+ ],
+ ],
+
+ "titles" => [
+ /*
+ * основное title изображение, (article.tpl)
+ * Еще оно используется в админке, в редакторе статей
+ * Нехай качество будет 92, разница между 90 и 92 по размеру около 2%, а качество должно различаться заметно
+ */
+ '608x406' => [
+ 'maxWidth' => 608,
+ 'maxHeight' => 406,
+ 'method' => '',
+ 'prefix' => '',
+ 'quality' => 92
+ ],
+ /*
+ * "квадратные" превью тайтлов статей на главной (widget.tpl, index_tres.tpl)
+ */
+ '300x266' => [
+ 'maxWidth' => 300,
+ 'maxHeight' => 266,
+ 'method' => 'getFixedPicture',
+ 'prefix' => 'resize_',
+ 'quality' => 80
+ ],
+ /*
+ * маленькие превью тайтлов статей на главной (3 в топе)
+ * картинки в фиде "авторский материал" на главной
+ */
+ '205x150' => [
+ 'maxWidth' => 205,
+ 'maxHeight' => 150,
+ 'method' => 'getFixedPicture',
+ 'prefix' => 'small_',
+ 'quality' => 70
+ ],
+ ]
+ ];
+
+}
\ No newline at end of file
diff --git a/src/Helpers/DTHelper.php b/src/Helpers/DTHelper.php
new file mode 100644
index 0000000..0727844
--- /dev/null
+++ b/src/Helpers/DTHelper.php
@@ -0,0 +1,50 @@
+ 'января', 2 => 'февраля',
+ 3 => 'марта', 4 => 'апреля', 5 => 'мая',
+ 6 => 'июня', 7 => 'июля', 8 => 'августа',
+ 9 => 'сентября', 10 => 'октября', 11 => 'ноября',
+ 12 => 'декабря'
+ );
+
+ const yearSuffux = 'г.';
+
+ /**
+ * Преобразует переданную дату в русифицированную дату
+ *
+ * @param string $datetime - заполненную нулями возвращает как "-", "today"|"NOW()"
+ * @param bool $is_show_time
+ * @param null $year_suffix - можно передать пустую строчку, чтобы подавить вывод годового суффикса
+ * @return string
+ */
+ public static function convertDate(string $datetime, bool $is_show_time = false, $year_suffix = null):string
+ {
+ $datetime = strtoupper($datetime);
+ if ($datetime === "0000-00-00 00:00:00" || $datetime === "0000-00-00") {
+ return "-";
+ }
+
+ if ($datetime === 'TODAY' || $datetime === 'NOW()') {
+ $datetime = date("Y-m-d H:i:s");
+ }
+
+ if (is_null($year_suffix)) {
+ $year_suffix = self::yearSuffux;
+ }
+
+ list( $y, $m, $d, $h, $i, $s ) = sscanf( $datetime, "%d-%d-%d %d:%d:%d" );
+
+ $rusdate = sprintf("%s %s %s", $d, self::ruMonths[$m], $y ? "{$y} {$year_suffix}" : "");
+
+ if ($is_show_time) {
+ $rusdate .= sprintf(" %02d:%02d", $h, $i);
+ }
+ return $rusdate;
+ }
+
+}
\ No newline at end of file
diff --git a/src/Helpers/MediaHelpers.php b/src/Helpers/MediaHelpers.php
new file mode 100644
index 0000000..670460b
--- /dev/null
+++ b/src/Helpers/MediaHelpers.php
@@ -0,0 +1,267 @@
+join( self::getContentDir($type) )
+ ->join( date('Y', $creation_date) )
+ ->join( date('m', $creation_date) )
+ ->setOptions(['isAbsolute'=>true]);
+
+ return $stringify_path ? $path->toString(true) : $path;
+ }
+
+ /**
+ * Возвращает путь к ресурсу относительно корня STORAGE (и только путь). Начинается с /, заканчивается на /
+ *
+ * Для определения каталога к типу контента используется mapping
+ *
+ * @param $type
+ * @param $creation_date
+ * @param $stringify_path
+ * @return Path|string
+ */
+ public static function getRelativeResourcePath($type = 'photos', $creation_date = 'now', $stringify_path = true)
+ {
+ $creation_date = $creation_date == 'now' ? time() : strtotime($creation_date);
+
+ $path = Path::create( self::getContentDir($type), true )
+ ->join( date('Y', $creation_date) )
+ ->join( date('m', $creation_date) );
+
+ return $stringify_path ? $path->toString(true) : $path;
+ }
+
+ /**
+ * Возвращает абсолютный URL к ресурсу
+ *
+ * @param $type
+ * @param $creation_date
+ * @param $stringify
+ * @return void
+ */
+ public static function getAbsoluteResourceURI($type = 'photos', $creation_date = 'now', $stringify = true)
+ {
+ $creation_date = $creation_date == 'now' ? time() : strtotime($creation_date);
+
+ //@todo ...
+
+
+ }
+
+ /**
+ * Проверяет существование пути
+ *
+ * @param $path
+ * @return bool
+ */
+ public static function validatePath($path)
+ {
+ if ($path instanceof Path) {
+ $path = $path->toString();
+ }
+
+ if (!is_dir($path) && ( !mkdir($path, 0777, true) && !is_dir($path)) ) {
+ throw new \RuntimeException( sprintf( 'Directory "%s" can\'t be created', $path ) );
+ }
+
+ return true;
+ }
+
+ /**
+ * @throws \Exception
+ */
+ public static function getRandomFilename(int $length = 20, string $suffix = '', $prefix_format = 'Ymd'):string
+ {
+ $dictionary = MediaInterface::DICTIONARY;
+ $dictionary_len = MediaInterface::DICTIONARY_LENGTH;
+
+ // если суффикс не NULL, то _суффикс иначе пустая строка
+ $suffix = !empty($suffix) ? '_' . $suffix : '';
+
+ $salt = '';
+ for ($i = 0; $i < $length; $i++) {
+ $salt .= $dictionary[random_int(0, $dictionary_len - 1)];
+ }
+
+ return (date_format(date_create(), $prefix_format)) . '_' . $salt . $suffix;
+ }
+
+ /**
+ * Генерирует имя для нового (еще точно не существующего) файла
+ *
+ * @param $path
+ * @param int $length
+ * @param string $extension
+ * @return string
+ * @throws \Exception
+ */
+ public static function generateNewFile($path, int $length = 20, string $extension = '.jpg'): string
+ {
+ MediaHelpers::validatePath($path); // проверяем существование пути и создаем при необходимости
+ do {
+ $newfname = MediaHelpers::getRandomFilename( $length ) . $extension;
+ } while (is_file( "{$path}/{$newfname}" ));
+ return $newfname;
+ }
+
+ /**
+ * Генерирует картинку из видео по временнОй метке.
+ *
+ * @param $source - исходное видео, имя файла с путём
+ * @param $target - сгенерированное имя файда с путём для превью
+ * @param $timestamp - деление на 2 делается вне функции
+ * @param $sizes - параметры
+ * @param $logger
+ * @return string
+ */
+ public static function makePreviewFromVideo($source, $target, $timestamp, $sizes, $logger):string
+ {
+ $tn_timestamp = sprintf( "%02d:%02d:%02d", $timestamp / 3600, ($timestamp / 60) % 60, $timestamp % 60 );
+
+ $logger->debug("[VIDEO] Таймштамп для превью:", [ $tn_timestamp ]);
+
+ $w = $sizes['maxWidth'] ?? 1;
+ $h = $sizes['maxHeight'] ?? 1;
+
+ $vfscale = "-vf \"scale=iw*min({$w}/iw\,{$h}/ih):ih*min({$w}/iw\,{$h}/ih), pad={$w}:{$h}:({$w}-iw*min({$w}/iw\,{$h}/ih))/2:({$h}-ih*min({$w}/iw\,{$h}/ih))/2\" ";
+
+ $cmd = [
+ 'bin' => self::$paths['exec.ffmpeg'],
+ '-hide_banner',
+ '-y',
+ 'source' => "-i {$source}",
+ "-an",
+ 'ss' => "-ss {$tn_timestamp}",
+ "-r 1",
+ "vframes 1",
+ 'sizes' => "-s {$w}x{$h}",
+ 'scale' => $vfscale,
+ 'imagetype' => "-f mjpeg",
+ 'target' => $target,
+ 'verbose' => '2>/dev/null 1>/dev/null'
+ ];
+
+ $logger->debug("[VIDEO] Файл превью: {$target}");
+ $cmd = implode(' ', $cmd);
+
+ $logger->debug("[VIDEO] FFMpeg команда для генерации превью: ", [ $cmd ]);
+
+ shell_exec( $cmd );
+
+ while (!is_file( $target )) {
+ sleep( 1 );
+ $logger->debug("[VIDEO] Секунду спустя превью не готово");
+ }
+
+ return $target;
+ }
+
+ /**
+ * @param $source
+ * @param $target
+ * @param $params
+ * @param $logger
+ * @return \AJUR\Wrappers\GDImageInfo|false
+ */
+ public static function resizePreview($source, $target, $params = [], $logger = null)
+ {
+ if (empty($params)) {
+ return false;
+ }
+
+ $logger->debug("[VIDEO] Генерируем превью {$params['prefix']} ({$target}) на основе {$source}", [ $params ]);
+
+ $generate_result = GDWrapper::getFixedPicture(
+ $source,
+ $target,
+ $params['maxWidth'],
+ $params['maxHeight'],
+ $params['quality']
+ )->valid;
+ $logger->debug("[VIDEO] Результат генерации превью {$params['prefix']}: ", [ $generate_result ]);
+
+ return $generate_result;
+ }
+
+ /**
+ * Deserialize JSON data with default value
+ *
+ * @param $data
+ * @param array $default
+ * @return array|mixed
+ */
+ public static function deserialize($data, array $default = [])
+ {
+ if (empty($data)) {
+ return $default;
+ }
+
+ $decoded = json_decode($data, true);
+ if ($decoded === null) {
+ return $default;
+ }
+ return $decoded;
+ }
+
+ /**
+ * LEGACY!!!
+ * Получает MIME-тип файла
+ *
+ * @param string $filepath
+ * @return string
+ */
+ public static function getMimeType(string $filepath)
+ {
+ return mime_content_type($filepath);
+ }
+
+ /**
+ * LEGACY!!!
+ * Получает расширение по MIME-типу
+ * (без точки)
+ *
+ * @param $mime
+ * @return mixed|string|null
+ */
+ public static function getFileExtension($mime)
+ {
+ return MimeTypes::getExtension($mime);
+ }
+
+ /**
+ * LEGACY!!!
+ * Вычисляет расширение файла по MIME-типу
+ * (без точки)
+ *
+ * @param $filepath
+ * @return mixed|string|null
+ */
+ public static function detectFileExtension($filepath)
+ {
+ return MimeTypes::getExtension( mime_content_type($filepath) );
+ }
+
+
+
+}
\ No newline at end of file
diff --git a/src/Media.php b/src/Media.php
new file mode 100644
index 0000000..a7900b0
--- /dev/null
+++ b/src/Media.php
@@ -0,0 +1,500 @@
+ Path::create( getenv('PATH.INSTALL'), true )->join('www')->join('i')->toString(true),
+ * 'path.watermarks' => Path::create( getenv('PATH.INSTALL'), true)->join('www/frontend/images/watermarks/')->toString(true)
+ * ], [], $logger)
+ */
+ public static function init(array $options = [], array $content_dirs = [], LoggerInterface $logger = null)
+ {
+ if (!empty($content_dirs)) {
+ foreach ($content_dirs as $from => $to) {
+ self::$content_dirs[ $from ] = $to;
+ }
+ }
+
+ self::$options['storage.root'] = $options['path.storage'] ?? '/'; //@required
+ self::$options['watermarks'] = $options['path.watermarks'] ?? ''; //@required
+ self::$options['exec.ffprobe'] = $options['exec.ffprobe'] ?? 'ffprobe';
+ self::$options['exec.ffmpeg'] = $options['exec.ffmpeg'] ?? 'ffmpeg';
+ self::$options['domain.storage'] = $options['domains.storage'] ?? ''; //@required
+
+
+ self::$logger = is_null($logger) ? new NullLogger() : $logger;
+ }
+
+ /**
+ * upload & create thumbnails for Embedded Photo
+ *
+ * @todo: return Result
+ *
+ * @param string|Path $fn_source
+ * @param $watermark_corner
+ * @param LoggerInterface $logger
+ * @return string
+ * @throws \Exception
+ */
+ public static function uploadImage($fn_source, $watermark_corner, LoggerInterface $logger)
+ {
+ $logger->debug('[PHOTO] Обрабатываем как фото (image/*)');
+
+ $path = self::getAbsoluteResourcePath('photos', 'now');
+
+ self::validatePath($path);
+
+ $resource_name = self::getRandomFilename(20);
+
+ $source_extension = MimeTypes::fromExtension( MimeTypes::fromFilename($fn_source) );
+
+ $radix = "{$resource_name}.{$source_extension}";
+
+ $logger->debug("[PHOTO] Загруженное изображение будет иметь корень имени:", [ $radix ]);
+
+ $available_photo_sizes = self::$convert_sizes['photos'];
+
+ foreach ($available_photo_sizes as $size => $params) {
+ $method = $params['method'];
+ $max_width = $params['maxWidth'];
+ $max_height = $params['maxHeight'];
+ $quality = $params['quality'];
+ $prefix = $params['prefix'];
+
+ $fn_target = Path::create($path)->joinName("{$prefix}{$radix}")->toString(); // ПРЕФИКС УЖЕ СОДЕРЖИТ `_`
+
+ if (!call_user_func_array($method, [ $fn_source, $fn_target, $max_width, $max_height, $quality ])) {
+ foreach ($available_photo_sizes as $inner_size => $inner_params) {
+ @unlink( Path::create($path)->joinName("{$inner_params['prefix']}{$radix}"));
+ }
+ $logger->error('[PHOTO] Не удалось сгенерировать превью с параметрами: ', [ $method, $max_width, $max_height, $quality, $fn_target ]);
+ throw new RuntimeException("Ошибка конвертации загруженного изображения в размер [$prefix]", -1);
+ }
+
+ $logger->debug('[PHOTO] Сгенерировано превью: ', [ $method, $max_width, $max_height, $quality, $fn_target ]);
+
+ if (!is_null($watermark_corner) && isset($params['wmFile']) && $watermark_corner > 0) {
+ $fn_watermark = Path::create( self::$options['watermarks'] )->joinName($params['wmFile'])->toString();
+
+ GDWrapper::addWaterMark($fn_target, [
+ 'watermark' => $fn_watermark,
+ 'margin' => $params['wmMargin'] ?? 10
+ ], $watermark_corner);
+
+ $logger->debug("[PHOTO] Сгенерирована вотермарка в {$watermark_corner} углу для файла {$fn_target}");
+ }
+ }
+
+ // сохраняем оригинал (в конфиг?)
+ $fn_origin = Path::create($path)->joinName("origin_{$radix}")->toString();
+ if (!move_uploaded_file($fn_source, $fn_origin)) {
+ $logger->error("[PHOTO] Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_origin}", [ $fn_source, $fn_origin ]);
+
+ // но тогда нужно удалить и все превьюшки
+ foreach ($available_photo_sizes as $inner_size => $inner_params) {
+ @unlink( Path::create($path)->joinName("{$inner_params['prefix']}{$radix}"));
+ }
+
+ throw new MediaException("Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_origin}", -1);
+ }
+
+ $logger->debug("[PHOTO] Загруженный файл {$fn_source} сохранён как оригинал в файл {$fn_origin}: ", [ $fn_source, $fn_origin ]);
+
+ return $radix;
+ }
+
+ /**
+ * Upload Audio
+ *
+ * @todo: return Result
+ *
+ * @param $fn_source
+ * @param LoggerInterface $logger
+ * @return string
+ * @throws \Exception
+ */
+ public static function uploadAudio($fn_source, LoggerInterface $logger)
+ {
+ $logger->debug('[AUDIO] Обрабатываем как аудио (audio/*)');
+
+ $path = self::getAbsoluteResourcePath('audios', 'now');
+
+ self::validatePath($path);
+
+ $resource_name = self::getRandomFilename(20);
+
+ $source_extension = MimeTypes::fromExtension( MimeTypes::fromFilename($fn_source) );
+
+ $radix = "{$resource_name}.{$source_extension}";
+
+ $logger->debug("[AUDIO] Загруженный аудиофайл будет иметь корень имени:", [ $radix ]);
+
+ $prefix = current(self::$convert_sizes['audios'])['prefix'];
+
+ // ничего не конвертируем, этим займется крон-скрипт
+ $fn_target = Path::create($path)->joinName("{$prefix}{$radix}")->toString(); // ПРЕФИКС УЖЕ СОДЕРЖИТ `_`
+
+ if (!move_uploaded_file($fn_source, $fn_target)) {
+ $logger->error("[AUDIO] Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_target}", [ $fn_source, $fn_target ]);
+ throw new MediaException("Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_target}", -1);
+ }
+
+ $logger->debug("[AUDIO] Загруженный файл {$fn_source} сохранён в файл {$fn_target}: ", [ $fn_source, $fn_target ]);
+
+ $logger->debug('[AUDIO] Stored as', [ $fn_target ]);
+ $logger->debug('[AUDIO] Returned', [ $fn_target ]);
+
+ return $radix;
+ }
+
+ /**
+ * Upload Abstract File
+ *
+ * @todo: return Result
+ *
+ * @param $fn_source
+ * @param LoggerInterface $logger
+ * @return void
+ * @throws \Exception
+ */
+ public static function uploadAnyFile($fn_source, LoggerInterface $logger)
+ {
+ $logger->debug('[FILE] Обрабатываем как аудио (audio/*)');
+
+ $path = self::getAbsoluteResourcePath('audios', 'now');
+
+ self::validatePath($path);
+
+ $resource_name = self::getRandomFilename(20);
+
+ $source_extension = MimeTypes::fromExtension( MimeTypes::fromFilename($fn_source) );
+
+ $radix = "{$resource_name}.{$source_extension}";
+
+ $logger->debug("[FILE] Загруженный аудиофайл будет иметь корень имени:", [ $radix ]);
+
+ $prefix = current(self::$convert_sizes['audios'])['prefix'];
+
+ // ничего не конвертируем, этим займется крон-скрипт
+ $fn_target = Path::create($path)->joinName("{$prefix}{$radix}")->toString(); // ПРЕФИКС УЖЕ СОДЕРЖИТ `_`
+
+
+ if (!move_uploaded_file($fn_source, $fn_target)) {
+ $logger->error("[FILE] Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_target}", [ $fn_source, $fn_target ]);
+ throw new MediaException("Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_target}", -1);
+ }
+ $logger->debug("[FILE] Загруженный файл {$fn_source} сохранён как оригинал в файл {$fn_target}: ", [ $fn_source, $fn_target ]);
+
+ $logger->debug('[FILE] Stored as', [ $fn_target ]);
+ $logger->debug('[FILE] Returned', [ $fn_target]);
+ }
+
+ /**
+ * Загружает видео и строит превью
+ *
+ * Можно использовать https://packagist.org/packages/php-ffmpeg/php-ffmpeg , но я предпочел нативный метод, через
+ * прямые вызовы shell_exec()
+ *
+ * @param $fn_source
+ * @param LoggerInterface $logger
+ * @return Result
+ * @throws \Exception
+ */
+ public static function uploadVideo($fn_source, LoggerInterface $logger)
+ {
+ $logger->debug('[VIDEO] Обрабатываем как видео (video/*)');
+
+ $ffprobe = self::$options['exec.ffprobe'];
+
+ $json = shell_exec("{$ffprobe} -v quiet -print_format json -show_format -show_streams {$fn_source} 2>&1");
+ $json = json_decode($json, true);
+
+ if (!array_key_exists('format', $json)) {
+ $message = "[VIDEO] Это не видеофайл: отсутствует секция FORMAT";
+ $logger->debug($message);
+ throw new MediaException($message);
+ }
+
+ $json_format = $json['format'];
+
+ if (!array_key_exists('streams', $json)) {
+ $message = "[VIDEO] Это не видеофайл: отсутствует секция STREAMS";
+ $logger->debug($message);
+ throw new MediaException($message);
+ }
+
+ $json_stream_video = current(array_filter($json['streams'], function ($v) {
+ return ($v['codec_type'] == 'video');
+ }));
+
+ if (empty($json_stream_video)) {
+ $message = "[VIDEO] Это не видеофайл: отсутствует видеопоток";
+ $logger->debug($message);
+ throw new MediaException($message);
+ }
+
+ $video_duration = round($json_stream_video['duration']) ?: round($json_format['duration']);
+ $video_bitrate = round($json_stream_video['bit_rate']) ?: round($json_format['bit_rate']);
+
+ if ($video_duration <= 0) {
+ throw new RuntimeException("[VIDEO] Видеофайл не содержит видеопоток или видеопоток имеет нулевую длительность");
+ }
+
+ $logger->debug("[VIDEO] Длина потока видео {$video_duration}");
+
+ // готовим имя основного файла
+
+ $path = self::getAbsoluteResourcePath('videos', 'now');
+
+ self::validatePath($path);
+
+ $radix = self::getRandomFilename(20);
+
+ $source_extension = MimeTypes::fromExtension( MimeTypes::fromFilename($fn_source) );
+
+ $fn_original = Path::create( $path )->joinName("{$radix}.{$source_extension}")->toString();
+
+ if (!move_uploaded_file($fn_source, $fn_original)) {
+ $logger->error("[VIDEO] Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_original}", [ $fn_source, $fn_original ]);
+ throw new RuntimeException("Не удалось сохранить сохранить загруженный файл {$fn_source} как файл оригинала {$fn_original}", -1);
+ }
+
+ $logger->debug("[VIDEO] Загруженный файл {$fn_source} сохранён как оригинал в файл {$fn_original}", [ $fn_original ]);
+
+ // теперь генерируем файл-превью
+ $params_640x352 = ConvertSizes::$convert_sizes['videos']['640x352'];
+
+ if (!empty($params_640x352)) {
+ $prefix_640x352 = $params_640x352['prefix'];
+ $fn_preview_640x352 = Path::create($path)->joinName("{$prefix_640x352}{$radix}.jpg")->toString();
+
+ MediaHelpers::makePreviewFromVideo(
+ $fn_original,
+ $fn_preview_640x352,
+ round($video_duration / 2),
+ $params_640x352,
+ $logger
+ );
+ }
+
+ // генерируем малые превьюшки. Для наглядности я развернул цикл из двух итераций на два вызова:
+
+ // 100x100
+ $params = ConvertSizes::$convert_sizes['videos']['100x100'];
+ MediaHelpers::resizePreview(
+ $fn_preview_640x352,
+ Path::create($path)->joinName("{$params['prefix']}{$radix}.jpg")->toString(),
+ $params,
+ $logger
+ );
+
+ // 440x248
+ $params = self::$convert_sizes['videos']['440x248'];
+ MediaHelpers::resizePreview(
+ $fn_preview_640x352,
+ Path::create($path)->joinName("{$params['prefix']}{$radix}.jpg")->toString(),
+ $params,
+ $logger
+ );
+
+ $logger->debug('[VIDEO] Превью сделаны, файл видео сохранён');
+
+ return new Result([
+ 'filename' => "{$radix}.{$source_extension}",
+ 'bitrate' => $video_bitrate,
+ 'duration' => $video_duration,
+ 'status' => 'pending',
+ 'type' => self::MEDIA_TYPE_VIDEO
+ ]);
+ } // uploadVideo
+
+ /**
+ * Загружает с ютуба название видео. Точно работает с видео, с shorts не проверялось.
+ *
+ * @param string $video_id
+ * @param string $default
+ * @return string
+ */
+ public static function getYoutubeVideoTitle(string $video_id, string $default = ''):string
+ {
+ //@todo: curl?
+ $video_info = @file_get_contents("http://youtube.com/get_video_info?video_id={$video_id}");
+
+ if (!$video_info) {
+ return $default;
+ }
+
+ parse_str($video_info, $vi_array);
+
+ if (!array_key_exists('player_response', $vi_array)) {
+ return $default;
+ }
+
+ $video_info = json_decode($vi_array['player_response']);
+
+ if (is_null($video_info)) {
+ return $default;
+ }
+
+ return $video_info->videoDetails->title ?: $default;
+ }
+
+ /**
+ * Удаляет тайтловое изображение и все его превьюшки
+ *
+ * @param $radix
+ * @param $cdate
+ * @param LoggerInterface $logger
+ * @return int
+ */
+ public static function unlinkStoredTitleImages($filename, $cdate, LoggerInterface $logger):int
+ {
+ $path = MediaHelpers::getAbsoluteResourcePath('titles', $cdate, false);
+
+ $prefixes = array_map(static function($v) {
+ return $v['prefix'];
+ }, ConvertSizes::$convert_sizes['titles']);
+
+ $deleted_count = 0;
+
+ foreach ($prefixes as $prefix) {
+ $fn = $path->joinName($prefix . $filename)->toString();
+ $logger->debug("[unlinkStoredTitleImage] Удалятся файл:", [ $fn, @unlink($fn) ]);
+ $deleted_count++;
+ }
+
+ return $deleted_count;
+ }
+
+ /**
+ * @param $row
+ * @param $target_is_mobile - замена строки `LegacyTemplate::$use_mobile_template` или `$CONFIG['AREA'] === "m"`
+ * @param $is_report
+ * @param $prepend_domain
+ * @param $domain_prefix - домен - `config('domains.storage.default')` или `global $CONFIG['domains']['storage']['default']`
+ * заменено на self::$options['domain.storage']
+ * @return mixed
+ */
+ public static function prepareMediaProperties($row, $target_is_mobile = false, $is_report = false, $prepend_domain = false, $domain_prefix = '')
+ {
+ if (empty($domain_prefix)) {
+ $domain_prefix = self::$options['domain.storage'];
+ }
+ $type = $row['type'];
+
+ $path = self::getRelativeResourcePath($type, $row['cdate']);
+ if ($prepend_domain === true) {
+ $path = $domain_prefix . $path;
+ }
+ $row['path'] = $path;
+
+ if (isset($row['cdate'])) {
+ $row['cdate_rus'] = DTHelper::convertDate($row['cdate']);
+ }
+
+ $row['prefix'] = $is_report ? "440x300" : "100x100";
+ $row['report_prefix'] = "590x440";
+
+ if ($row['type'] === "photos") {
+ // $this->report NEVER DECLARED (в админке уж точно)
+ if ($is_report) {
+ if ($target_is_mobile) {
+ $row['sizes'] = [590, 440]; // "590x440" -- mobile_report_tn
+ $row['sizes_full'] = [1280, 1024]; // "1280x1024" -- mobile_report_full
+ } else {
+ $row['sizes'] = [100, 100]; // "100x100" -- desktop_report_tn
+ $row['sizes_full'] = [440, 300]; // "440x300" -- desktop_report_full
+ }
+ } else {
+ if ($target_is_mobile) {
+ $row['sizes'] = [440, 300]; // "440x300"
+ $row['sizes_full'] = [1280, 1024]; // "1280x1024"
+ } else {
+ $row['sizes'] = [630, 465]; // "630x465"
+ $row['sizes_full'] = [1280, 1024]; // "1280x1024"
+ }
+ }
+
+ $row['sizes_prev'] = [150, 100]; // "150x100"
+ $row['sizes_large'] = [1280, 1024]; // "1280x1024"
+ $row['orig_file'] = "590x440_" . $row['file']; // "590x440_"
+ }
+
+ if ($row['type'] === "videos") {
+ $row['thumb'] = substr($row['file'], 0, -4) . ".jpg"; //отрезаем расширение и добавляем .jpg (@todo: переделать?)
+ $row['sizes'] = [640, 352];
+ $row['sizes_thumb'] = "640x352"; // $k[2]
+ $row['sizes_video'] = array(640, 352 + 25); //minWidth, minHeight
+ $row['orig_file'] = "440x248_" . $row['thumb'];
+ }
+
+ if ($row['type'] === "audios") {
+ $row['sizes'] = [440, 24];
+ $row['orig_file'] = $row['file'];
+ }
+
+ if ($row['type'] === "files") {
+ $row['orig_file'] = $row['file'];
+ }
+
+ if ($row['type'] === "youtube") {
+ $row['thumb'] = $row['file'];
+ $row['orig_file'] = $row['href'];
+ $row['sizes'] = [640, 352];
+ $matches = parse_url($row['href']);
+ if (preg_match("/v=([A-Za-z0-9\_\-]{11})/i", $matches["query"], $res)) {
+ $row['yt'] = $res[1];
+ }
+ $row['sizes_youtube'] = [640, 360]; // да, именно так, несмотря на то, что ключ - 640x352, то есть значимы параметры, а не ключ
+ }
+
+ $row['tags'] = empty($row['tags']) ? [] : MediaHelpers::deserialize($row['tags']);
+
+ if (is_null($row['descr'])) {
+ $row['descr'] = '';
+ }
+
+ return $row;
+ }
+
+ /* == HELPERS == */
+
+
+
+
+}
\ No newline at end of file
diff --git a/src/MediaException.php b/src/MediaException.php
new file mode 100644
index 0000000..2d38b10
--- /dev/null
+++ b/src/MediaException.php
@@ -0,0 +1,5 @@
+ '/srv/storage/',
+ 'path.watermarks' => '/var/www/47news/www/frontend/images/watermarks/'
+ ], []);
+ }
+
+ /**
+ * @testdox get Absolute Resource Path
+ * @return void
+ */
+ public function testGetAbsoluteResourcePath()
+ {
+ $this->assertEquals( '/srv/storage/videos/2023/05/', Media::getAbsoluteResourcePath('videos', '2023-05-05 17:11:44') );
+ }
+
+ /**
+ * @return void
+ * @testdox Get Relative Resource Path
+ */
+ public function testGetRelativeResourcePath()
+ {
+ $this->assertEquals('/videos/2023/05/', Media::getRelativeResourcePath('videos', '2023-05-05 17:17:44'));
+ }
+
+ /**
+ * @return void
+ * @testdox Deserialize correct
+ */
+ public function testDeserialize1()
+ {
+ $this->assertEquals(['a' => 1 ], \AJUR\FSNews\Helpers\MediaHelpers::deserialize('{"a": 1}', []));
+ }
+
+}
\ No newline at end of file