nextcloud/lib/private/PreviewManager.php
Carl Schwan 6149168129
feat(preview): On demand preview migration
When requesting previews, which we don't find in oc_previews, search in
IAppData first before creating them.

Move the logic from MovepreviewJob to PreviewMigrationService and reuse
that in the Preview Generator.

At the same time rename MovePreviewJob to PreviewMigrationJob as it is a
better name.

Signed-off-by: Carl Schwan <carl.schwan@nextcloud.com>
2026-01-12 14:20:09 +01:00

427 lines
14 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OC;
use Closure;
use OC\AppFramework\Bootstrap\Coordinator;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\Generator;
use OC\Preview\GeneratorHelper;
use OC\Preview\IMagickSupport;
use OC\Preview\PreviewMigrationService;
use OC\Preview\Storage\StorageFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\IAppConfig;
use OCP\IBinaryFinder;
use OCP\IConfig;
use OCP\IPreview;
use OCP\Preview\IProviderV2;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;
use function array_key_exists;
/**
* @psalm-import-type ProviderClosure from IPreview
*/
class PreviewManager implements IPreview {
protected IConfig $config;
protected IRootFolder $rootFolder;
protected IEventDispatcher $eventDispatcher;
private ?Generator $generator = null;
private GeneratorHelper $helper;
protected bool $providerListDirty = false;
protected bool $registeredCoreProviders = false;
/**
* @var array<string, list<ProviderClosure>> $providers
*/
protected array $providers = [];
/** @var array mime type => support status */
protected array $mimeTypeSupportMap = [];
/** @var ?list<class-string<IProviderV2>> $defaultProviders */
protected ?array $defaultProviders = null;
protected ?string $userId;
private Coordinator $bootstrapCoordinator;
/**
* Hash map (without value) of loaded bootstrap providers
* @psalm-var array<string, null>
*/
private array $loadedBootstrapProviders = [];
private ContainerInterface $container;
private IBinaryFinder $binaryFinder;
private IMagickSupport $imagickSupport;
private bool $enablePreviews;
public function __construct(
IConfig $config,
IRootFolder $rootFolder,
IEventDispatcher $eventDispatcher,
GeneratorHelper $helper,
?string $userId,
Coordinator $bootstrapCoordinator,
ContainerInterface $container,
IBinaryFinder $binaryFinder,
IMagickSupport $imagickSupport,
) {
$this->config = $config;
$this->rootFolder = $rootFolder;
$this->eventDispatcher = $eventDispatcher;
$this->helper = $helper;
$this->userId = $userId;
$this->bootstrapCoordinator = $bootstrapCoordinator;
$this->container = $container;
$this->binaryFinder = $binaryFinder;
$this->imagickSupport = $imagickSupport;
$this->enablePreviews = $config->getSystemValueBool('enable_previews', true);
}
/**
* In order to improve lazy loading a closure can be registered which will be
* called in case preview providers are actually requested
*
* @param string $mimeTypeRegex Regex with the mime types that are supported by this provider
* @param ProviderClosure $callable
*/
public function registerProvider(string $mimeTypeRegex, Closure $callable): void {
if (!$this->enablePreviews) {
return;
}
if (!isset($this->providers[$mimeTypeRegex])) {
$this->providers[$mimeTypeRegex] = [];
}
$this->providers[$mimeTypeRegex][] = $callable;
$this->providerListDirty = true;
}
/**
* Get all providers
*/
public function getProviders(): array {
if (!$this->enablePreviews) {
return [];
}
$this->registerCoreProviders();
$this->registerBootstrapProviders();
if ($this->providerListDirty) {
$keys = array_map('strlen', array_keys($this->providers));
array_multisort($keys, SORT_DESC, $this->providers);
$this->providerListDirty = false;
}
return $this->providers;
}
/**
* Does the manager have any providers
*/
public function hasProviders(): bool {
$this->registerCoreProviders();
return !empty($this->providers);
}
private function getGenerator(): Generator {
if ($this->generator === null) {
$this->generator = new Generator(
$this->config,
$this->container->get(IAppConfig::class),
$this,
new GeneratorHelper(),
$this->eventDispatcher,
$this->container->get(LoggerInterface::class),
$this->container->get(PreviewMapper::class),
$this->container->get(StorageFactory::class),
$this->container->get(PreviewMigrationService::class),
);
}
return $this->generator;
}
public function getPreview(
File $file,
int $width = -1,
int $height = -1,
bool $crop = false,
string $mode = IPreview::MODE_FILL,
?string $mimeType = null,
bool $cacheResult = true,
): ISimpleFile {
$this->throwIfPreviewsDisabled($file, $mimeType);
$previewConcurrency = $this->getGenerator()->getNumConcurrentPreviews('preview_concurrency_all');
$sem = Generator::guardWithSemaphore(Generator::SEMAPHORE_ID_ALL, $previewConcurrency);
try {
$preview = $this->getGenerator()->getPreview($file, $width, $height, $crop, $mode, $mimeType, $cacheResult);
} finally {
Generator::unguardWithSemaphore($sem);
}
return $preview;
}
/**
* Generates previews of a file
*
* @param array $specifications
* @return ISimpleFile the last preview that was generated
* @throws NotFoundException
* @throws \InvalidArgumentException if the preview would be invalid (in case the original image is invalid)
* @since 19.0.0
*/
public function generatePreviews(File $file, array $specifications, ?string $mimeType = null): ISimpleFile {
$this->throwIfPreviewsDisabled($file, $mimeType);
return $this->getGenerator()->generatePreviews($file, $specifications, $mimeType);
}
public function isMimeSupported(string $mimeType = '*'): bool {
if (!$this->enablePreviews) {
return false;
}
if (isset($this->mimeTypeSupportMap[$mimeType])) {
return $this->mimeTypeSupportMap[$mimeType];
}
$this->registerCoreProviders();
$this->registerBootstrapProviders();
$providerMimeTypes = array_keys($this->providers);
foreach ($providerMimeTypes as $supportedMimeType) {
if (preg_match($supportedMimeType, $mimeType)) {
$this->mimeTypeSupportMap[$mimeType] = true;
return true;
}
}
$this->mimeTypeSupportMap[$mimeType] = false;
return false;
}
public function isAvailable(\OCP\Files\FileInfo $file, ?string $mimeType = null): bool {
if (!$this->enablePreviews) {
return false;
}
$fileMimeType = $mimeType ?? $file->getMimeType();
$this->registerCoreProviders();
if (!$this->isMimeSupported($fileMimeType)) {
return false;
}
$mount = $file->getMountPoint();
if ($mount && !$mount->getOption('previews', true)) {
return false;
}
foreach ($this->providers as $supportedMimeType => $providers) {
if (preg_match($supportedMimeType, $fileMimeType)) {
foreach ($providers as $providerClosure) {
$provider = $this->helper->getProvider($providerClosure);
if (!$provider) {
continue;
}
if ($provider->isAvailable($file)) {
return true;
}
}
}
}
return false;
}
/**
* List of enabled default providers
*
* @return list<class-string<IProviderV2>>
*/
protected function getEnabledDefaultProvider(): array {
if ($this->defaultProviders !== null) {
return $this->defaultProviders;
}
$imageProviders = [
Preview\PNG::class,
Preview\JPEG::class,
Preview\GIF::class,
Preview\BMP::class,
Preview\XBitmap::class,
Preview\Krita::class,
Preview\WebP::class,
];
$this->defaultProviders = $this->config->getSystemValue('enabledPreviewProviders', array_merge([
Preview\MarkDown::class,
Preview\TXT::class,
Preview\OpenDocument::class,
], $imageProviders));
if (in_array(Preview\Image::class, $this->defaultProviders)) {
$this->defaultProviders = array_merge($this->defaultProviders, $imageProviders);
}
$this->defaultProviders = array_values(array_unique($this->defaultProviders));
/** @var list<class-string<IProviderV2>> $providers */
$providers = $this->defaultProviders;
return $providers;
}
/**
* Register the default providers (if enabled)
*/
protected function registerCoreProvider(string $class, string $mimeType, array $options = []): void {
if (in_array(trim($class, '\\'), $this->getEnabledDefaultProvider())) {
$this->registerProvider($mimeType, function () use ($class, $options) {
return new $class($options);
});
}
}
/**
* Register the default providers (if enabled)
*/
protected function registerCoreProviders(): void {
if ($this->registeredCoreProviders) {
return;
}
$this->registeredCoreProviders = true;
$this->registerCoreProvider(Preview\TXT::class, '/text\/plain/');
$this->registerCoreProvider(Preview\MarkDown::class, '/text\/(x-)?markdown/');
$this->registerCoreProvider(Preview\PNG::class, '/image\/png/');
$this->registerCoreProvider(Preview\JPEG::class, '/image\/jpeg/');
$this->registerCoreProvider(Preview\GIF::class, '/image\/gif/');
$this->registerCoreProvider(Preview\BMP::class, '/image\/bmp/');
$this->registerCoreProvider(Preview\XBitmap::class, '/image\/x-xbitmap/');
$this->registerCoreProvider(Preview\WebP::class, '/image\/webp/');
$this->registerCoreProvider(Preview\Krita::class, '/application\/x-krita/');
$this->registerCoreProvider(Preview\MP3::class, '/audio\/mpeg$/');
$this->registerCoreProvider(Preview\OpenDocument::class, '/application\/vnd.oasis.opendocument.*/');
$this->registerCoreProvider(Preview\Imaginary::class, Preview\Imaginary::supportedMimeTypes());
$this->registerCoreProvider(Preview\ImaginaryPDF::class, Preview\ImaginaryPDF::supportedMimeTypes());
// SVG and Bitmap require imagick
if ($this->imagickSupport->hasExtension()) {
$imagickProviders = [
'SVG' => ['mimetype' => '/image\/svg\+xml/', 'class' => Preview\SVG::class],
'TIFF' => ['mimetype' => '/image\/tiff/', 'class' => Preview\TIFF::class],
'PDF' => ['mimetype' => '/application\/pdf/', 'class' => Preview\PDF::class],
'AI' => ['mimetype' => '/application\/illustrator/', 'class' => Preview\Illustrator::class],
'PSD' => ['mimetype' => '/application\/x-photoshop/', 'class' => Preview\Photoshop::class],
'EPS' => ['mimetype' => '/application\/postscript/', 'class' => Preview\Postscript::class],
'TTF' => ['mimetype' => '/application\/(?:font-sfnt|x-font$)/', 'class' => Preview\Font::class],
'HEIC' => ['mimetype' => '/image\/(x-)?hei(f|c)/', 'class' => Preview\HEIC::class],
'TGA' => ['mimetype' => '/image\/(x-)?t(ar)?ga/', 'class' => Preview\TGA::class],
'SGI' => ['mimetype' => '/image\/(x-)?sgi/', 'class' => Preview\SGI::class],
];
foreach ($imagickProviders as $queryFormat => $provider) {
$class = $provider['class'];
if (!in_array(trim($class, '\\'), $this->getEnabledDefaultProvider())) {
continue;
}
if ($this->imagickSupport->supportsFormat($queryFormat)) {
$this->registerCoreProvider($class, $provider['mimetype']);
}
}
}
$this->registerCoreProvidersOffice();
// Video requires ffmpeg
if (in_array(Preview\Movie::class, $this->getEnabledDefaultProvider())) {
$movieBinary = $this->config->getSystemValue('preview_ffmpeg_path', null);
if (!is_string($movieBinary)) {
$movieBinary = $this->binaryFinder->findBinaryPath('ffmpeg');
}
if (is_string($movieBinary)) {
$this->registerCoreProvider(Preview\Movie::class, '/video\/.*/', ['movieBinary' => $movieBinary]);
}
}
}
private function registerCoreProvidersOffice(): void {
$officeProviders = [
['mimetype' => '/application\/msword/', 'class' => Preview\MSOfficeDoc::class],
['mimetype' => '/application\/vnd.ms-.*/', 'class' => Preview\MSOffice2003::class],
['mimetype' => '/application\/vnd.openxmlformats-officedocument.*/', 'class' => Preview\MSOffice2007::class],
['mimetype' => '/application\/vnd.oasis.opendocument.*/', 'class' => Preview\OpenDocument::class],
['mimetype' => '/application\/vnd.sun.xml.*/', 'class' => Preview\StarOffice::class],
['mimetype' => '/image\/emf/', 'class' => Preview\EMF::class],
];
$findBinary = true;
$officeBinary = false;
foreach ($officeProviders as $provider) {
$class = $provider['class'];
if (!in_array(trim($class, '\\'), $this->getEnabledDefaultProvider())) {
continue;
}
if ($findBinary) {
// Office requires openoffice or libreoffice
$officeBinary = $this->config->getSystemValue('preview_libreoffice_path', false);
if ($officeBinary === false) {
$officeBinary = $this->binaryFinder->findBinaryPath('libreoffice');
}
if ($officeBinary === false) {
$officeBinary = $this->binaryFinder->findBinaryPath('openoffice');
}
$findBinary = false;
}
if ($officeBinary) {
$this->registerCoreProvider($class, $provider['mimetype'], ['officeBinary' => $officeBinary]);
}
}
}
private function registerBootstrapProviders(): void {
$context = $this->bootstrapCoordinator->getRegistrationContext();
if ($context === null) {
// Just ignore for now
return;
}
$providers = $context->getPreviewProviders();
foreach ($providers as $provider) {
$key = $provider->getMimeTypeRegex() . '-' . $provider->getService();
if (array_key_exists($key, $this->loadedBootstrapProviders)) {
// Do not load the provider more than once
continue;
}
$this->loadedBootstrapProviders[$key] = null;
$this->registerProvider($provider->getMimeTypeRegex(), function () use ($provider): IProviderV2|false {
try {
return $this->container->get($provider->getService());
} catch (NotFoundExceptionInterface) {
return false;
}
});
}
}
/**
* @throws NotFoundException if preview generation is disabled
*/
private function throwIfPreviewsDisabled(File $file, ?string $mimeType = null): void {
if (!$this->isAvailable($file, $mimeType)) {
throw new NotFoundException('Previews disabled');
}
}
}