Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,21 @@ requested before it is pre-generated it will still be shown.

## How does the app work

1. Listen to events that a file has been written or modified and store it in the database
2. On cron run request previews for the files that have been written or modified
1. Listen to events that a file has been written or modified and store it in the database.
2. Generates previews for the files that have been written or modified in a background job.
3. Optional: Dedicated occ command to generate previews using a custom schedule (for example, in a
separate system cron job).

If a preview already exists at step 2 then requesting it is really cheap. If not
If a preview already exists at step 2 (or 3) then requesting it is really cheap. If not
it will be generated. Depending on the sizes of the files and the hardware you
are running on the time this takes can vary.

By default, the background job to generate previews for modified files is limited to a maximum
execution time of five minutes. Additionally, it requires using the cron background job mode.
Webcron and AJAX modes are not supported. The background job is limited to prevent stalling the PHP
process. The limits are configurable via app configs (see below) or admins can configure a dedicated
system cron job which runs the `occ preview:pre-generate` command.

## Commands

#### `preview:generate-all [--workers=WORKERS] [--path=PATH ...] [user_id ...]`
Expand Down Expand Up @@ -101,6 +109,19 @@ the aspect ratio.
Will retain the aspect ratio and use the specified height. The width will be scaled according to
the aspect ratio.

#### `occ config:app:set --value=false --type=bool previewgenerator job_disabled`
Set to true to disable the background job that generates previews by default without having to
configure a manual system cron job. It is recommended to disable the default background job in case
a custom system cron entry with `occ preview:pre-generate` is configured (set this config to true).

#### `occ config:app:set --value=600 --type=int previewgenerator job_max_execution_time`
Limits the maximum execution time in seconds of the preview background job. (A value of zero means
unlimited.)

#### `occ config:app:set --value=0 --type=int previewgenerator job_max_previews`
Limits the count of previews to be generated in each execution of the preview background job. (A
value of zero means unlimited.) Configure one, both or no limit (not recommended!). In case both
limits are configured, the more restrictive one takes precedence.

## FAQ

Expand Down
6 changes: 4 additions & 2 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The first time you install this app, before using a cron job, you properly want

**Important**: To enable pre-generation of previews you must add **php /var/www/nextcloud/occ preview:pre-generate** to a system cron job that runs at times of your choosing.]]>
</description>
<version>5.12.0-dev.2</version>
<version>5.12.0-dev.3</version>
<licence>agpl</licence>
<author>Richard Steinmetz</author>
<namespace>PreviewGenerator</namespace>
Expand All @@ -30,7 +30,9 @@ The first time you install this app, before using a cron job, you properly want
<php min-version="8.1" max-version="8.4" />
<nextcloud min-version="30" max-version="33" />
</dependencies>

<background-jobs>
<job>OCA\PreviewGenerator\BackgroundJob\PreviewJob</job>
</background-jobs>
<commands>
<command>OCA\PreviewGenerator\Command\Generate</command>
<command>OCA\PreviewGenerator\Command\PreGenerate</command>
Expand Down
54 changes: 54 additions & 0 deletions lib/BackgroundJob/PreviewJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\PreviewGenerator\BackgroundJob;

use OCA\PreviewGenerator\Service\ConfigService;
use OCA\PreviewGenerator\Service\PreGenerateService;
use OCA\PreviewGenerator\Support\OutputInterfaceLoggerAdapter;
use OCA\PreviewGenerator\Support\PreviewLimiter\CountLimiter;
use OCA\PreviewGenerator\Support\PreviewLimiter\ExecutionTimeLimiter;
use OCA\PreviewGenerator\Support\PreviewLimiter\MultiLimiter;
use OCA\PreviewGenerator\Support\PreviewLimiter\PreviewLimiter;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;

class PreviewJob extends TimedJob {
private readonly PreviewLimiter $limiter;

public function __construct(
ITimeFactory $time,
private readonly PreGenerateService $preGenerateService,
private readonly OutputInterfaceLoggerAdapter $outputInterface,
private readonly ConfigService $configService,
) {
parent::__construct($time);

$this->limiter = new MultiLimiter([
new CountLimiter($this->configService->getMaxBackgroundJobPreviews()),
new ExecutionTimeLimiter(
$time,
$this->configService->getMaxBackgroundJobExecutionTime(),
),
]);

$this->setInterval(5 * 60);
$this->setTimeSensitivity(self::TIME_SENSITIVE);
}

protected function run($argument) {
if ($this->configService->isBackgroundJobDisabled()
|| !$this->configService->usesCronDaemon()) {
return;
}

$this->preGenerateService->setLimiter($this->limiter);
$this->preGenerateService->preGenerate($this->outputInterface);
}
}
2 changes: 1 addition & 1 deletion lib/Command/Generate.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ protected function configure(): void {

protected function execute(InputInterface $input, OutputInterface $output): int {
if ($this->encryptionManager->isEnabled()) {
$output->writeln('Encryption is enabled. Aborted.');
$output->writeln('<error>Encryption is enabled. Aborted.</error>');
return 1;
}

Expand Down
181 changes: 8 additions & 173 deletions lib/Command/PreGenerate.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,73 +9,17 @@

namespace OCA\PreviewGenerator\Command;

use OCA\PreviewGenerator\Service\NoMediaService;
use OCA\PreviewGenerator\SizeHelper;
use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Encryption\IManager;
use OCP\Files\File;
use OCP\Files\GenericFileException;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IPreview;
use OCP\IUserManager;
use OCA\PreviewGenerator\Exceptions\EncryptionEnabledException;
use OCA\PreviewGenerator\Service\PreGenerateService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class PreGenerate extends Command {
use TTransactional;

/* @return array{width: int, height: int, crop: bool} */
protected array $specifications;

protected string $appName;
protected IUserManager $userManager;
protected IRootFolder $rootFolder;
protected IPreview $previewGenerator;
protected IConfig $config;
protected IDBConnection $connection;
protected OutputInterface $output;
protected IManager $encryptionManager;
protected ITimeFactory $time;
protected NoMediaService $noMediaService;
protected SizeHelper $sizeHelper;

/**
* @param string $appName
* @param IRootFolder $rootFolder
* @param IUserManager $userManager
* @param IPreview $previewGenerator
* @param IConfig $config
* @param IDBConnection $connection
* @param IManager $encryptionManager
* @param ITimeFactory $time
*/
public function __construct(string $appName,
IRootFolder $rootFolder,
IUserManager $userManager,
IPreview $previewGenerator,
IConfig $config,
IDBConnection $connection,
IManager $encryptionManager,
ITimeFactory $time,
NoMediaService $noMediaService,
SizeHelper $sizeHelper) {
public function __construct(
private readonly PreGenerateService $preGenerateService,
) {
parent::__construct();

$this->appName = $appName;
$this->userManager = $userManager;
$this->rootFolder = $rootFolder;
$this->previewGenerator = $previewGenerator;
$this->config = $config;
$this->connection = $connection;
$this->encryptionManager = $encryptionManager;
$this->time = $time;
$this->noMediaService = $noMediaService;
$this->sizeHelper = $sizeHelper;
}

protected function configure(): void {
Expand All @@ -85,119 +29,10 @@ protected function configure(): void {
}

protected function execute(InputInterface $input, OutputInterface $output): int {
if ($this->encryptionManager->isEnabled()) {
$output->writeln('Encryption is enabled. Aborted.');
return 1;
}

// Set timestamp output
$formatter = new TimestampFormatter($this->config, $output->getFormatter());
$output->setFormatter($formatter);
$this->output = $output;

$this->specifications = $this->sizeHelper->generateSpecifications();
if ($this->output->getVerbosity() > OutputInterface::VERBOSITY_VERY_VERBOSE) {
$output->writeln('Specifications: ' . json_encode($this->specifications));
}
$this->startProcessing();

return 0;
}

private function startProcessing(): void {
while (true) {
/*
* Get and delete the row so that if preview generation fails for some reason the next
* run can just continue. Wrap in transaction to make sure that one row is handled by
* one process only.
*/
$row = $this->atomic(function () {
$qb = $this->connection->getQueryBuilder();
$qb->select('*')
->from('preview_generation')
->orderBy('id')
->setMaxResults(1);
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();

if (!$row) {
return null;
}

$qb = $this->connection->getQueryBuilder();
$qb->delete('preview_generation')
->where($qb->expr()->eq('id', $qb->createNamedParameter($row['id'])));
$qb->executeStatement();

return $row;
}, $this->connection);


if (!$row) {
break;
}

$this->processRow($row);
}
}

private function processRow($row): void {
//Get user
$user = $this->userManager->get($row['uid']);

if ($user === null) {
return;
}

\OC_Util::tearDownFS();
\OC_Util::setupFS($row['uid']);

try {
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$userRoot = $userFolder->getParent();
} catch (NotFoundException $e) {
return;
}

//Get node
$nodes = $userRoot->getById($row['file_id']);

if ($nodes === []) {
return;
}

$node = $nodes[0];
if ($node instanceof File) {
$this->processFile($node);
}
}

private function processFile(File $file): void {
$absPath = ltrim($file->getPath(), '/');
$pathComponents = explode('/', $absPath);
if (isset($pathComponents[1]) && $pathComponents[1] === 'files_trashbin') {
return;
}

if ($this->noMediaService->hasNoMediaFile($file)) {
return;
}

if ($this->previewGenerator->isMimeSupported($file->getMimeType())) {
if ($this->output->getVerbosity() > OutputInterface::VERBOSITY_VERBOSE) {
$this->output->writeln('Generating previews for ' . $file->getPath());
}

try {
$this->previewGenerator->generatePreviews($file, $this->specifications);
} catch (NotFoundException $e) {
// Maybe log that previews could not be generated?
} catch (\InvalidArgumentException|GenericFileException $e) {
$class = $e::class;
$error = $e->getMessage();
$this->output->writeln("<error>{$class}: {$error}</error>");
}
$this->preGenerateService->preGenerate($output);
} catch (EncryptionEnabledException $e) {
$output->writeln('<error>Encryption is enabled. Aborted.</error>');
}
}
}
21 changes: 21 additions & 0 deletions lib/Exceptions/EncryptionEnabledException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\PreviewGenerator\Exceptions;

use Exception;
use Throwable;

class EncryptionEnabledException extends Exception {
public const DEFAULT_MESSAGE = 'Encryption is enabled';

public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) {
parent::__construct($message ?? self::DEFAULT_MESSAGE, $code, $previous);
}
}
45 changes: 45 additions & 0 deletions lib/Service/ConfigService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\PreviewGenerator\Service;

use OCP\AppFramework\Services\IAppConfig;
use OCP\IConfig;

class ConfigService {
public function __construct(
private readonly IConfig $config,
private readonly IAppConfig $appConfig,
) {
}

public function getPreviewMaxX(): int {
return (int)$this->config->getSystemValue('preview_max_x', 4096);
}

public function getPreviewMaxY(): int {
return (int)$this->config->getSystemValue('preview_max_y', 4096);
}

public function isBackgroundJobDisabled(): bool {
return $this->appConfig->getAppValueBool('job_disabled');
}

public function getMaxBackgroundJobExecutionTime(): int {
return $this->appConfig->getAppValueInt('job_max_execution_time', 5 * 60);
}

public function getMaxBackgroundJobPreviews(): int {
return $this->appConfig->getAppValueInt('job_max_previews', 0);
}

public function usesCronDaemon(): bool {
return $this->config->getAppValue('core', 'backgroundjobs_mode') === 'cron';
}
}
Loading
Loading