refactor(preview): Cleanup the implementation of the new preview backend

Signed-off-by: Carl Schwan <carl.schwan@nextcloud.com>
This commit is contained in:
Carl Schwan 2025-09-16 11:34:41 +02:00
parent 6f56dcf73e
commit 324b54b863
29 changed files with 565 additions and 788 deletions

View file

@ -2708,17 +2708,6 @@
<code><![CDATA[$this->timeFactory->getTime()]]></code>
</InvalidScalarArgument>
</file>
<file src="core/Command/Preview/Repair.php">
<UndefinedInterfaceMethod>
<code><![CDATA[section]]></code>
<code><![CDATA[section]]></code>
</UndefinedInterfaceMethod>
</file>
<file src="core/Command/Preview/ResetRenderedTexts.php">
<InvalidReturnStatement>
<code><![CDATA[[]]]></code>
</InvalidReturnStatement>
</file>
<file src="core/Command/Security/BruteforceAttempts.php">
<DeprecatedMethod>
<code><![CDATA[getAttempts]]></code>

View file

@ -0,0 +1,9 @@
<?php
/*
* SPDX-FileCopyrightText: None
* SPDX-License-Identifier: CC0-1.0
*/
// PHP 8.4
function array_find(array $array, callable $callback) {}

View file

@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@ -17,7 +19,8 @@ use OCP\BackgroundJob\TimedJob;
use OCP\DB\Exception;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IAppConfig;
use OCP\IDBConnection;
@ -28,10 +31,11 @@ class MovePreviewJob extends TimedJob {
public function __construct(
ITimeFactory $time,
private IAppConfig $appConfig,
private PreviewMapper $previewMapper,
private StorageFactory $storageFactory,
private IDBConnection $connection,
private readonly IAppConfig $appConfig,
private readonly PreviewMapper $previewMapper,
private readonly StorageFactory $storageFactory,
private readonly IDBConnection $connection,
private readonly IRootFolder $rootFolder,
IAppDataFactory $appDataFactory,
) {
parent::__construct($time);
@ -42,15 +46,6 @@ class MovePreviewJob extends TimedJob {
}
protected function run(mixed $argument): void {
try {
$this->doRun($argument);
} catch (\Throwable $exception) {
echo $exception->getMessage();
throw $exception;
}
}
private function doRun($argument): void {
if ($this->appConfig->getValueBool('core', 'previewMovedDone')) {
return;
}
@ -59,14 +54,13 @@ class MovePreviewJob extends TimedJob {
$startTime = time();
while (true) {
$previewFolders = [];
// Check new hierarchical preview folders first
if (!$emptyHierarchicalPreviewFolders) {
$qb = $this->connection->getQueryBuilder();
$qb->select('*')
->from('filecache')
->where($qb->expr()->like('path', $qb->createNamedParameter('appdata_%/preview/%/%/%/%/%/%/%/%/%')))
->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId())
->setMaxResults(100);
$result = $qb->executeQuery();
@ -74,12 +68,7 @@ class MovePreviewJob extends TimedJob {
$pathSplit = explode('/', $row['path']);
assert(count($pathSplit) >= 2);
$fileId = $pathSplit[count($pathSplit) - 2];
$previewFolders[$fileId][] = $row['path'];
}
if (!empty($previewFolders)) {
$this->processPreviews($previewFolders, false);
continue;
$this->processPreviews($fileId, false);
}
}
@ -89,6 +78,7 @@ class MovePreviewJob extends TimedJob {
$qb->select('*')
->from('filecache')
->where($qb->expr()->like('path', $qb->createNamedParameter('appdata_%/preview/%/%.%')))
->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId())
->setMaxResults(100);
$result = $qb->executeQuery();
@ -98,18 +88,7 @@ class MovePreviewJob extends TimedJob {
$fileId = $pathSplit[count($pathSplit) - 2];
array_pop($pathSplit);
$path = implode('/', $pathSplit);
if (!isset($previewFolders[$fileId])) {
$previewFolders[$fileId] = [];
}
if (!in_array($path, $previewFolders[$fileId])) {
$previewFolders[$fileId][] = $path;
}
}
if (empty($previewFolders)) {
break;
} else {
$this->processPreviews($previewFolders, true);
$this->processPreviews($fileId, true);
}
// Stop if execution time is more than one hour.
@ -118,97 +97,117 @@ class MovePreviewJob extends TimedJob {
}
}
// Delete any leftover preview directory
$this->appData->getFolder('.')->delete();
try {
// Delete any leftover preview directory
$this->appData->getFolder('.')->delete();
} catch (NotFoundException) {
// ignore
}
$this->appConfig->setValueBool('core', 'previewMovedDone', true);
}
/**
* @param array<string|int, string[]> $previewFolders
*/
private function processPreviews(array $previewFolders, bool $simplePaths): void {
foreach ($previewFolders as $fileId => $previewFolder) {
$internalPath = $this->getInternalFolder((string)$fileId, $simplePaths);
$folder = $this->appData->getFolder($internalPath);
private function processPreviews(int|string $fileId, bool $simplePaths): void {
$internalPath = $this->getInternalFolder((string)$fileId, $simplePaths);
$folder = $this->appData->getFolder($internalPath);
/**
* @var list<array{file: SimpleFile, width: int, height: int, crop: bool, max: bool, extension: string, mtime: int, size: int}> $previewFiles
*/
$previewFiles = [];
/**
* @var list<array{
* file: SimpleFile, width: int, height: int, crop: bool, max: bool, extension: string, mtime: int, size: int, version: ?int
* }> $previewFiles
*/
$previewFiles = [];
foreach ($folder->getDirectoryListing() as $previewFile) {
/** @var SimpleFile $previewFile */
[0 => $baseName, 1 => $extension] = explode('.', $previewFile->getName());
$nameSplit = explode('-', $baseName);
foreach ($folder->getDirectoryListing() as $previewFile) {
/** @var SimpleFile $previewFile */
[0 => $baseName, 1 => $extension] = explode('.', $previewFile->getName());
$nameSplit = explode('-', $baseName);
// TODO VERSION/PREFIX extraction
$width = $nameSplit[0];
$height = $nameSplit[1];
if (isset($nameSplit[2])) {
$crop = $nameSplit[2] === 'crop';
$max = $nameSplit[2] === 'max';
}
$previewFiles[] = [
'file' => $previewFile,
'width' => $width,
'height' => $height,
'crop' => $crop,
'max' => $max,
'extension' => $extension,
'size' => $previewFile->getSize(),
'mtime' => $previewFile->getMTime(),
];
$offset = 0;
$version = null;
if (count($nameSplit) === 4 || (count($nameSplit) === 3 && is_numeric($nameSplit[2]))) {
$offset = 1;
$version = (int)$nameSplit[0];
}
$qb = $this->connection->getQueryBuilder();
$qb->select('*')
->from('filecache')
->where($qb->expr()->like('fileid', $qb->createNamedParameter($fileId)));
$width = (int)$nameSplit[$offset + 0];
$height = (int)$nameSplit[$offset + 1];
$result = $qb->executeQuery();
$result = $result->fetchAll();
if (count($result) > 0) {
foreach ($previewFiles as $previewFile) {
$preview = new Preview();
$preview->setFileId((int)$fileId);
$preview->setOldFileId($previewFile['file']->getId());
$preview->setEtag($result[0]['etag']);
$preview->setMtime($previewFile['mtime']);
$preview->setWidth($previewFile['width']);
$preview->setHeight($previewFile['height']);
$preview->setCrop($previewFile['crop']);
$preview->setIsMax($previewFile['max']);
$preview->setMimetype(match ($previewFile['extension']) {
'png' => IPreview::MIMETYPE_PNG,
'webp' => IPreview::MIMETYPE_WEBP,
'gif' => IPreview::MIMETYPE_GIF,
default => IPreview::MIMETYPE_JPEG,
});
$preview->setSize($previewFile['size']);
try {
$preview = $this->previewMapper->insert($preview);
} catch (Exception $e) {
// We already have this preview in the preview table, skip
continue;
}
try {
$this->storageFactory->migratePreview($preview, $previewFile['file']);
$previewFile['file']->delete();
} catch (\Exception $e) {
$this->previewMapper->delete($preview);
throw $e;
}
}
$crop = false;
$max = false;
if (isset($nameSplit[$offset + 2])) {
$crop = $nameSplit[$offset + 2] === 'crop';
$max = $nameSplit[$offset + 2] === 'max';
}
$this->deleteFolder($internalPath, $folder);
$previewFiles[] = [
'file' => $previewFile,
'width' => $width,
'height' => $height,
'crop' => $crop,
'version' => $version,
'max' => $max,
'extension' => $extension,
'size' => $previewFile->getSize(),
'mtime' => $previewFile->getMTime(),
];
}
$qb = $this->connection->getQueryBuilder();
$qb->select('*')
->from('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($fileId)))
->setMaxResults(1);
$result = $qb->executeQuery();
$result = $result->fetchAll();
if (count($result) > 0) {
foreach ($previewFiles as $previewFile) {
$preview = new Preview();
$preview->setFileId((int)$fileId);
/** @var SimpleFile $file */
$file = $previewFile['file'];
$preview->setOldFileId($file->getId());
$preview->setStorageId($result[0]['storage']);
$preview->setEtag($result[0]['etag']);
$preview->setMtime($previewFile['mtime']);
$preview->setWidth($previewFile['width']);
$preview->setHeight($previewFile['height']);
$preview->setCropped($previewFile['crop']);
$preview->setVersion($previewFile['version']);
$preview->setMax($previewFile['max']);
$preview->setEncrypted(false);
$preview->setMimetype(match ($previewFile['extension']) {
'png' => IPreview::MIMETYPE_PNG,
'webp' => IPreview::MIMETYPE_WEBP,
'gif' => IPreview::MIMETYPE_GIF,
default => IPreview::MIMETYPE_JPEG,
});
$preview->setSize($previewFile['size']);
try {
$preview = $this->previewMapper->insert($preview);
} catch (Exception $e) {
// We already have this preview in the preview table, skip
continue;
}
try {
$this->storageFactory->migratePreview($preview, $file);
$qb->delete('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($file->getId())))
->executeStatement();
// Do not call $file->delete() as this will also delete the file from the file system
} catch (\Exception $e) {
$this->previewMapper->delete($preview);
throw $e;
}
}
}
$this->deleteFolder($internalPath, $folder);
}
public static function getInternalFolder(string $name, bool $simplePaths): string {

View file

@ -1,293 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Command\Preview;
use bantu\IniGetWrapper\IniGetWrapper;
use OC\Preview\Storage\Root;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IConfig;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use function pcntl_signal;
class Repair extends Command {
private bool $stopSignalReceived = false;
private int $memoryLimit;
private int $memoryTreshold;
public function __construct(
protected IConfig $config,
private IRootFolder $rootFolder,
private LoggerInterface $logger,
IniGetWrapper $phpIni,
private ILockingProvider $lockingProvider,
) {
$this->memoryLimit = (int)$phpIni->getBytes('memory_limit');
$this->memoryTreshold = $this->memoryLimit - 25 * 1024 * 1024;
parent::__construct();
}
protected function configure() {
$this
->setName('preview:repair')
->setDescription('distributes the existing previews into subfolders')
->addOption('batch', 'b', InputOption::VALUE_NONE, 'Batch mode - will not ask to start the migration and start it right away.')
->addOption('dry', 'd', InputOption::VALUE_NONE, 'Dry mode - will not create, move or delete any files - in combination with the verbose mode one could check the operations.')
->addOption('delete', null, InputOption::VALUE_NONE, 'Delete instead of migrating them. Usefull if too many entries to migrate.');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
if ($this->memoryLimit !== -1) {
$limitInMiB = round($this->memoryLimit / 1024 / 1024, 1);
$thresholdInMiB = round($this->memoryTreshold / 1024 / 1024, 1);
$output->writeln("Memory limit is $limitInMiB MiB");
$output->writeln("Memory threshold is $thresholdInMiB MiB");
$output->writeln('');
$memoryCheckEnabled = true;
} else {
$output->writeln('No memory limit in place - disabled memory check. Set a PHP memory limit to automatically stop the execution of this migration script once memory consumption is close to this limit.');
$output->writeln('');
$memoryCheckEnabled = false;
}
$dryMode = $input->getOption('dry');
$deleteMode = $input->getOption('delete');
if ($dryMode) {
$output->writeln('INFO: The migration is run in dry mode and will not modify anything.');
$output->writeln('');
} elseif ($deleteMode) {
$output->writeln('WARN: The migration will _DELETE_ old previews.');
$output->writeln('');
}
$instanceId = $this->config->getSystemValueString('instanceid');
$output->writeln('This will migrate all previews from the old preview location to the new one.');
$output->writeln('');
$output->writeln('Fetching previews that need to be migrated …');
/** @var Folder $currentPreviewFolder */
$currentPreviewFolder = $this->rootFolder->get("appdata_$instanceId/preview");
$directoryListing = $currentPreviewFolder->getDirectoryListing();
$total = count($directoryListing);
/**
* by default there could be 0-9 a-f and the old-multibucket folder which are all fine
*/
if ($total < 18) {
$directoryListing = array_filter($directoryListing, function ($dir) {
if ($dir->getName() === 'old-multibucket') {
return false;
}
// a-f can't be a file ID -> removing from migration
if (preg_match('!^[a-f]$!', $dir->getName())) {
return false;
}
if (preg_match('!^[0-9]$!', $dir->getName())) {
// ignore folders that only has folders in them
if ($dir instanceof Folder) {
foreach ($dir->getDirectoryListing() as $entry) {
if (!$entry instanceof Folder) {
return true;
}
}
return false;
}
}
return true;
});
$total = count($directoryListing);
}
if ($total === 0) {
$output->writeln('All previews are already migrated.');
return 0;
}
$output->writeln("A total of $total preview files need to be migrated.");
$output->writeln('');
$output->writeln('The migration will always migrate all previews of a single file in a batch. After each batch the process can be canceled by pressing CTRL-C. This will finish the current batch and then stop the migration. This migration can then just be started and it will continue.');
if ($input->getOption('batch')) {
$output->writeln('Batch mode active: migration is started right away.');
} else {
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion('<info>Should the migration be started? (y/[n]) </info>', false);
if (!$helper->ask($input, $output, $question)) {
return 0;
}
}
// register the SIGINT listener late in here to be able to exit in the early process of this command
pcntl_signal(SIGINT, [$this, 'sigIntHandler']);
$output->writeln('');
$output->writeln('');
$section1 = $output->section();
$section2 = $output->section();
$progressBar = new ProgressBar($section2, $total);
$progressBar->setFormat('%current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% Used Memory: %memory:6s%');
$time = (new \DateTime())->format('H:i:s');
$progressBar->setMessage("$time Starting …");
$progressBar->maxSecondsBetweenRedraws(0.2);
$progressBar->start();
foreach ($directoryListing as $oldPreviewFolder) {
pcntl_signal_dispatch();
$name = $oldPreviewFolder->getName();
$time = (new \DateTime())->format('H:i:s');
$section1->writeln("$time Migrating previews of file with fileId $name");
$progressBar->display();
if ($this->stopSignalReceived) {
$section1->writeln("$time Stopping migration …");
return 0;
}
if (!$oldPreviewFolder instanceof Folder) {
$section1->writeln(" Skipping non-folder $name");
$progressBar->advance();
continue;
}
if ($name === 'old-multibucket') {
$section1->writeln(" Skipping fallback mount point $name");
$progressBar->advance();
continue;
}
if (in_array($name, ['a', 'b', 'c', 'd', 'e', 'f'])) {
$section1->writeln(" Skipping hex-digit folder $name");
$progressBar->advance();
continue;
}
if (!preg_match('!^\d+$!', $name)) {
$section1->writeln(" Skipping non-numeric folder $name");
$progressBar->advance();
continue;
}
$newFoldername = Root::getInternalFolder($name);
$memoryUsage = memory_get_usage();
if ($memoryCheckEnabled && $memoryUsage > $this->memoryTreshold) {
$section1->writeln('');
$section1->writeln('');
$section1->writeln('');
$section1->writeln(' Stopped process 25 MB before reaching the memory limit to avoid a hard crash.');
$time = (new \DateTime())->format('H:i:s');
$section1->writeln("$time Reached memory limit and stopped to avoid hard crash.");
return 1;
}
$lockName = 'occ preview:repair lock ' . $oldPreviewFolder->getId();
try {
$section1->writeln(" Locking \"$lockName\" ", OutputInterface::VERBOSITY_VERBOSE);
$this->lockingProvider->acquireLock($lockName, ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException $e) {
$section1->writeln(' Skipping because it is locked - another process seems to work on this …');
continue;
}
$previews = $oldPreviewFolder->getDirectoryListing();
if ($previews !== []) {
try {
$this->rootFolder->get("appdata_$instanceId/preview/$newFoldername");
} catch (NotFoundException $e) {
$section1->writeln(" Create folder preview/$newFoldername", OutputInterface::VERBOSITY_VERBOSE);
if (!$dryMode) {
$this->rootFolder->newFolder("appdata_$instanceId/preview/$newFoldername");
}
}
foreach ($previews as $preview) {
pcntl_signal_dispatch();
$previewName = $preview->getName();
if ($preview instanceof Folder) {
$section1->writeln(" Skipping folder $name/$previewName");
$progressBar->advance();
continue;
}
// Execute process
if (!$dryMode) {
// Delete preview instead of moving
if ($deleteMode) {
try {
$section1->writeln(" Delete preview/$name/$previewName", OutputInterface::VERBOSITY_VERBOSE);
$preview->delete();
} catch (\Exception $e) {
$this->logger->error("Failed to delete preview at preview/$name/$previewName", [
'app' => 'core',
'exception' => $e,
]);
}
} else {
try {
$section1->writeln(" Move preview/$name/$previewName to preview/$newFoldername", OutputInterface::VERBOSITY_VERBOSE);
$preview->move("appdata_$instanceId/preview/$newFoldername/$previewName");
} catch (\Exception $e) {
$this->logger->error("Failed to move preview from preview/$name/$previewName to preview/$newFoldername", [
'app' => 'core',
'exception' => $e,
]);
}
}
}
}
}
if ($oldPreviewFolder->getDirectoryListing() === []) {
$section1->writeln(" Delete empty folder preview/$name", OutputInterface::VERBOSITY_VERBOSE);
if (!$dryMode) {
try {
$oldPreviewFolder->delete();
} catch (\Exception $e) {
$this->logger->error("Failed to delete empty folder preview/$name", [
'app' => 'core',
'exception' => $e,
]);
}
}
}
$this->lockingProvider->releaseLock($lockName, ILockingProvider::LOCK_EXCLUSIVE);
$section1->writeln(' Unlocked', OutputInterface::VERBOSITY_VERBOSE);
$section1->writeln(" Finished migrating previews of file with fileId $name");
$progressBar->advance();
}
$progressBar->finish();
$output->writeln('');
return 0;
}
protected function sigIntHandler() {
echo "\nSignal received - will finish the step and then stop the migration.\n\n\n";
$this->stopSignalReceived = true;
}
}

View file

@ -8,8 +8,8 @@ declare(strict_types=1);
*/
namespace OC\Core\Command\Preview;
use OC\Preview\Storage\Root;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OC\Preview\Db\Preview;
use OC\Preview\PreviewService;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
@ -23,16 +23,16 @@ use Symfony\Component\Console\Output\OutputInterface;
class ResetRenderedTexts extends Command {
public function __construct(
protected IDBConnection $connection,
protected IUserManager $userManager,
protected IAvatarManager $avatarManager,
private Root $previewFolder,
private IMimeTypeLoader $mimeTypeLoader,
protected readonly IDBConnection $connection,
protected readonly IUserManager $userManager,
protected readonly IAvatarManager $avatarManager,
private readonly PreviewService $previewService,
private readonly IMimeTypeLoader $mimeTypeLoader,
) {
parent::__construct();
}
protected function configure() {
protected function configure(): void {
$this
->setName('preview:reset-rendered-texts')
->setDescription('Deletes all generated avatars and previews of text and md files')
@ -91,7 +91,7 @@ class ResetRenderedTexts extends Command {
private function deletePreviews(OutputInterface $output, bool $dryMode): void {
$previewsToDeleteCount = 0;
foreach ($this->getPreviewsToDelete() as ['name' => $previewFileId, 'path' => $filePath]) {
foreach ($this->getPreviewsToDelete() as ['path' => $filePath, 'preview' => $preview]) {
$output->writeln('Deleting previews for ' . $filePath, OutputInterface::VERBOSITY_VERBOSE);
$previewsToDeleteCount++;
@ -100,63 +100,33 @@ class ResetRenderedTexts extends Command {
continue;
}
try {
$preview = $this->previewFolder->getFolder((string)$previewFileId);
$preview->delete();
} catch (NotFoundException $e) {
// continue
} catch (NotPermittedException $e) {
// continue
}
$this->previewService->deletePreview($preview);
}
$output->writeln('Deleted ' . $previewsToDeleteCount . ' previews');
}
// Copy pasted and adjusted from
// "lib/private/Preview/BackgroundCleanupJob.php".
/**
* @return \Iterator<array{path: string, preview: Preview}>
*/
private function getPreviewsToDelete(): \Iterator {
$qb = $this->connection->getQueryBuilder();
$qb->select('path', 'mimetype')
$qb->select('fileid', 'path')
->from('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($this->previewFolder->getId())));
$cursor = $qb->executeQuery();
$data = $cursor->fetch();
$cursor->closeCursor();
if ($data === null) {
return [];
}
/*
* This lovely like is the result of the way the new previews are stored
* We take the md5 of the name (fileid) and split the first 7 chars. That way
* there are not a gazillion files in the root of the preview appdata.
*/
$like = $this->connection->escapeLikeParameter($data['path']) . '/_/_/_/_/_/_/_/%';
$qb = $this->connection->getQueryBuilder();
$qb->select('a.name', 'b.path')
->from('filecache', 'a')
->leftJoin('a', 'filecache', 'b', $qb->expr()->eq(
$qb->expr()->castColumn('a.name', IQueryBuilder::PARAM_INT), 'b.fileid'
))
->where(
$qb->expr()->andX(
$qb->expr()->like('a.path', $qb->createNamedParameter($like)),
$qb->expr()->eq('a.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('httpd/unix-directory'))),
$qb->expr()->orX(
$qb->expr()->eq('b.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('text/plain'))),
$qb->expr()->eq('b.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('text/markdown'))),
$qb->expr()->eq('b.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('text/x-markdown')))
)
$qb->expr()->orX(
$qb->expr()->eq('mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('text/plain'))),
$qb->expr()->eq('mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('text/markdown'))),
$qb->expr()->eq('mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('text/x-markdown')))
)
);
$cursor = $qb->executeQuery();
while ($row = $cursor->fetch()) {
yield $row;
foreach ($this->previewService->getAvailablePreviewForFile($row['fileid']) as $preview) {
yield ['path' => $row['path'], 'preview' => $preview];
}
}
$cursor->closeCursor();

View file

@ -41,6 +41,7 @@ class Version33000Date20250819110529 extends SimpleMigrationStep {
$table = $schema->createTable('previews');
$table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20, 'unsigned' => true]);
$table->addColumn('file_id', Types::BIGINT, ['notnull' => true, 'length' => 20, 'unsigned' => true]);
$table->addColumn('storage_id', Types::BIGINT, ['notnull' => true, 'length' => 20, 'unsigned' => true]);
$table->addColumn('old_file_id', Types::BIGINT, ['notnull' => false, 'length' => 20, 'unsigned' => true]);
$table->addColumn('location_id', Types::BIGINT, ['notnull' => false, 'length' => 20, 'unsigned' => true]);
$table->addColumn('width', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
@ -56,7 +57,7 @@ class Version33000Date20250819110529 extends SimpleMigrationStep {
$table->addColumn('version', Types::BIGINT, ['notnull' => true, 'default' => -1]); // can not be null otherwise unique index doesn't work
$table->setPrimaryKey(['id']);
$table->addIndex(['file_id']);
$table->addUniqueIndex(['file_id', 'width', 'height', 'mimetype', 'crop', 'version'], 'previews_file_uniq_idx');
$table->addUniqueIndex(['file_id', 'width', 'height', 'mimetype', 'cropped', 'version'], 'previews_file_uniq_idx');
}
return $schema;

View file

@ -206,7 +206,6 @@ if ($config->getSystemValueBool('installed', false)) {
$application->add(Server::get(Command\Preview\Cleanup::class));
$application->add(Server::get(Generate::class));
$application->add(Server::get(Command\Preview\Repair::class));
$application->add(Server::get(ResetRenderedTexts::class));
$application->add(Server::get(Add::class));

View file

@ -1344,7 +1344,6 @@ return array(
'OC\\Core\\Command\\Memcache\\RedisCommand' => $baseDir . '/core/Command/Memcache/RedisCommand.php',
'OC\\Core\\Command\\Preview\\Cleanup' => $baseDir . '/core/Command/Preview/Cleanup.php',
'OC\\Core\\Command\\Preview\\Generate' => $baseDir . '/core/Command/Preview/Generate.php',
'OC\\Core\\Command\\Preview\\Repair' => $baseDir . '/core/Command/Preview/Repair.php',
'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => $baseDir . '/core/Command/Preview/ResetRenderedTexts.php',
'OC\\Core\\Command\\Router\\ListRoutes' => $baseDir . '/core/Command/Router/ListRoutes.php',
'OC\\Core\\Command\\Router\\MatchRoute' => $baseDir . '/core/Command/Router/MatchRoute.php',
@ -1909,6 +1908,7 @@ return array(
'OC\\Preview\\PNG' => $baseDir . '/lib/private/Preview/PNG.php',
'OC\\Preview\\Photoshop' => $baseDir . '/lib/private/Preview/Photoshop.php',
'OC\\Preview\\Postscript' => $baseDir . '/lib/private/Preview/Postscript.php',
'OC\\Preview\\PreviewService' => $baseDir . '/lib/private/Preview/PreviewService.php',
'OC\\Preview\\Provider' => $baseDir . '/lib/private/Preview/Provider.php',
'OC\\Preview\\ProviderV1Adapter' => $baseDir . '/lib/private/Preview/ProviderV1Adapter.php',
'OC\\Preview\\ProviderV2' => $baseDir . '/lib/private/Preview/ProviderV2.php',
@ -1919,7 +1919,6 @@ return array(
'OC\\Preview\\Storage\\LocalPreviewStorage' => $baseDir . '/lib/private/Preview/Storage/LocalPreviewStorage.php',
'OC\\Preview\\Storage\\ObjectStorePreviewStorage' => $baseDir . '/lib/private/Preview/Storage/ObjectStorePreviewStorage.php',
'OC\\Preview\\Storage\\PreviewFile' => $baseDir . '/lib/private/Preview/Storage/PreviewFile.php',
'OC\\Preview\\Storage\\Root' => $baseDir . '/lib/private/Preview/Storage/Root.php',
'OC\\Preview\\Storage\\StorageFactory' => $baseDir . '/lib/private/Preview/Storage/StorageFactory.php',
'OC\\Preview\\TGA' => $baseDir . '/lib/private/Preview/TGA.php',
'OC\\Preview\\TIFF' => $baseDir . '/lib/private/Preview/TIFF.php',

View file

@ -11,32 +11,32 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
);
public static $prefixLengthsPsr4 = array (
'O' =>
'O' =>
array (
'OC\\Core\\' => 8,
'OC\\' => 3,
'OCP\\' => 4,
),
'N' =>
'N' =>
array (
'NCU\\' => 4,
),
);
public static $prefixDirsPsr4 = array (
'OC\\Core\\' =>
'OC\\Core\\' =>
array (
0 => __DIR__ . '/../../..' . '/core',
),
'OC\\' =>
'OC\\' =>
array (
0 => __DIR__ . '/../../..' . '/lib/private',
),
'OCP\\' =>
'OCP\\' =>
array (
0 => __DIR__ . '/../../..' . '/lib/public',
),
'NCU\\' =>
'NCU\\' =>
array (
0 => __DIR__ . '/../../..' . '/lib/unstable',
),
@ -1385,7 +1385,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\Command\\Memcache\\RedisCommand' => __DIR__ . '/../../..' . '/core/Command/Memcache/RedisCommand.php',
'OC\\Core\\Command\\Preview\\Cleanup' => __DIR__ . '/../../..' . '/core/Command/Preview/Cleanup.php',
'OC\\Core\\Command\\Preview\\Generate' => __DIR__ . '/../../..' . '/core/Command/Preview/Generate.php',
'OC\\Core\\Command\\Preview\\Repair' => __DIR__ . '/../../..' . '/core/Command/Preview/Repair.php',
'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => __DIR__ . '/../../..' . '/core/Command/Preview/ResetRenderedTexts.php',
'OC\\Core\\Command\\Router\\ListRoutes' => __DIR__ . '/../../..' . '/core/Command/Router/ListRoutes.php',
'OC\\Core\\Command\\Router\\MatchRoute' => __DIR__ . '/../../..' . '/core/Command/Router/MatchRoute.php',
@ -1950,6 +1949,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Preview\\PNG' => __DIR__ . '/../../..' . '/lib/private/Preview/PNG.php',
'OC\\Preview\\Photoshop' => __DIR__ . '/../../..' . '/lib/private/Preview/Photoshop.php',
'OC\\Preview\\Postscript' => __DIR__ . '/../../..' . '/lib/private/Preview/Postscript.php',
'OC\\Preview\\PreviewService' => __DIR__ . '/../../..' . '/lib/private/Preview/PreviewService.php',
'OC\\Preview\\Provider' => __DIR__ . '/../../..' . '/lib/private/Preview/Provider.php',
'OC\\Preview\\ProviderV1Adapter' => __DIR__ . '/../../..' . '/lib/private/Preview/ProviderV1Adapter.php',
'OC\\Preview\\ProviderV2' => __DIR__ . '/../../..' . '/lib/private/Preview/ProviderV2.php',
@ -1960,7 +1960,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Preview\\Storage\\LocalPreviewStorage' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/LocalPreviewStorage.php',
'OC\\Preview\\Storage\\ObjectStorePreviewStorage' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/ObjectStorePreviewStorage.php',
'OC\\Preview\\Storage\\PreviewFile' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/PreviewFile.php',
'OC\\Preview\\Storage\\Root' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/Root.php',
'OC\\Preview\\Storage\\StorageFactory' => __DIR__ . '/../../..' . '/lib/private/Preview/Storage/StorageFactory.php',
'OC\\Preview\\TGA' => __DIR__ . '/../../..' . '/lib/private/Preview/TGA.php',
'OC\\Preview\\TIFF' => __DIR__ . '/../../..' . '/lib/private/Preview/TIFF.php',

View file

@ -321,7 +321,6 @@ class JobList implements IJobList {
/** @var IJob $job */
$job = \OCP\Server::get($row['class']);
} catch (QueryException $e) {
echo $e->getMessage();
if (class_exists($row['class'])) {
$class = $row['class'];
$job = new $class();

View file

@ -8,8 +8,7 @@ declare(strict_types=1);
*/
namespace OC\Preview;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\Storage\StorageFactory;
use OC\Preview\Db\Preview;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use OCP\DB\QueryBuilder\IQueryBuilder;
@ -24,8 +23,7 @@ class BackgroundCleanupJob extends TimedJob {
public function __construct(
ITimeFactory $timeFactory,
readonly private IDBConnection $connection,
readonly private PreviewMapper $previewMapper,
readonly private StorageFactory $storageFactory,
readonly private PreviewService $previewService,
readonly private bool $isCLI,
) {
parent::__construct($timeFactory);
@ -37,11 +35,9 @@ class BackgroundCleanupJob extends TimedJob {
public function run($argument): void {
foreach ($this->getDeletedFiles() as $fileId) {
$previewIds = [];
foreach ($this->previewMapper->getByFileId($fileId) as $preview) {
$previewIds[] = $preview->getId();
$this->storageFactory->deletePreview($preview);
foreach ($this->previewService->getAvailablePreviewForFile($fileId) as $preview) {
$this->previewService->deletePreview($preview);
}
$this->previewMapper->deleteByIds($previewIds);
}
}
@ -50,13 +46,12 @@ class BackgroundCleanupJob extends TimedJob {
*/
private function getDeletedFiles(): \Iterator {
if ($this->connection->getShardDefinition('filecache')) {
$chunks = $this->getAllPreviewIds(1000);
foreach ($chunks as $chunk) {
foreach ($chunk as $storage => $preview) {
yield [$storage => $this->findMissingSources($storage, $preview)];
foreach ($this->previewService->getAvailableFileIds() as $availableFileIdGroup) {
$fileIds = $this->findMissingSources($availableFileIdGroup['storageId'], $availableFileIdGroup['fileIds']);
foreach ($fileIds as $fileId) {
yield $fileId;
}
}
return;
}
@ -89,35 +84,11 @@ class BackgroundCleanupJob extends TimedJob {
$cursor = $qb->executeQuery();
while ($row = $cursor->fetch()) {
yield $row['file_id'];
yield (int)$row['file_id'];
}
$cursor->closeCursor();
}
/**
* @return \Iterator<FileId>
*/
private function getAllPreviewIds(int $chunkSize): \Iterator {
$qb = $this->connection->getQueryBuilder();
$qb->select('id', 'file_id', 'storage_id')
->from('previews')
->where(
$qb->expr()->gt('id', $qb->createParameter('min_id')),
)
->orderBy('id', 'ASC')
->setMaxResults($chunkSize);
$minId = 0;
while (true) {
$qb->setParameter('min_id', $minId);
$cursor = $qb->executeQuery();
while ($row = $cursor->fetch()) {
yield $row['file_id'];
}
$cursor->closeCursor();
}
}
/**
* @param FileId[] $ids
* @return FileId[]

View file

@ -15,64 +15,63 @@ use OCP\DB\Types;
use OCP\IPreview;
/**
* @method \int getFileId()
* Preview entity mapped to the oc_previews and oc_preview_locations table.
*
* @method int getFileId() Get the file id of the original file.
* @method void setFileId(int $fileId)
* @method \int getOldFileId() // Old location in the file-cache table, for legacy compatibility
* @method void setOldFileId(int $fileId)
* @method \int getLocationId()
* @method int getStorageId() Get the storage id of the original file.
* @method void setStorageId(int $fileId)
* @method int getOldFileId() Get the old location in the file-cache table, for legacy compatibility.
* @method void setOldFileId(int $oldFileId)
* @method int getLocationId() Get the location id in the preview_locations table. Only set when using an object store as primary storage.
* @method void setLocationId(int $locationId)
* @method \string getBucketName()
* @method \string getObjectStoreName()
* @method \int getWidth()
* @method string getBucketName() Get the bucket name where the preview is stored. This is stored in the preview_locations table.
* @method string getObjectStoreName() Get the object store name where the preview is stored. This is stored in the preview_locations table.
* @method int getWidth() Get the width of the preview.
* @method void setWidth(int $width)
* @method \int getHeight()
* @method int getHeight() Get the height of the preview.
* @method void setHeight(int $height)
* @method \int getMode()
* @method void setMode(int $mode)
* @method \bool getCrop()
* @method void setCrop(bool $crop)
* @method void setMimetype(int $mimetype)
* @method IPreview::MIMETYPE_* getMimetype()
* @method \int getMtime()
* @method bool isCropped() Get whether the preview is cropped or not.
* @method void setCropped(bool $cropped)
* @method void setMimetype(int $mimetype) Set the mimetype of the preview.
* @method int getMimetype() Get the mimetype of the preview.
* @method int getMtime() Get the modification time of the preview.
* @method void setMtime(int $mtime)
* @method \int getSize()
* @method int getSize() Get the size of the preview.
* @method void setSize(int $size)
* @method \bool getIsMax()
* @method void setIsMax(bool $max)
* @method \string getEtag()
* @method bool isMax() Get whether the preview is the biggest one which is then used to generate the smaller previews.
* @method void setMax(bool $max)
* @method string getEtag() Get the etag of the preview.
* @method void setEtag(string $etag)
* @method ?\int getVersion()
* @method int|null getVersion() Get the version for files_versions_s3
* @method void setVersion(?int $version)
* @method bool|null getIs() Get the version for files_versions_s3
* @method bool isEncrypted() Get whether the preview is encrypted. At the moment every preview is unencrypted.
* @method void setEncrypted(bool $encrypted)
*
* @see PreviewMapper
*/
class Preview extends Entity {
protected ?int $fileId = null;
protected ?int $oldFileId = null;
protected ?int $storageId = null;
protected ?int $locationId = null;
protected ?string $bucketName = null;
protected ?string $objectStoreName = null;
protected ?int $width = null;
protected ?int $height = null;
protected ?int $mimetype = null;
protected ?int $mtime = null;
protected ?int $size = null;
protected ?bool $isMax = null;
protected ?bool $crop = null;
protected ?bool $max = null;
protected ?bool $cropped = null;
protected ?string $etag = null;
protected ?int $version = null;
protected ?bool $encrypted = null;
public function __construct() {
$this->addType('fileId', Types::BIGINT);
$this->addType('storageId', Types::BIGINT);
$this->addType('oldFileId', Types::BIGINT);
$this->addType('locationId', Types::BIGINT);
$this->addType('width', Types::INTEGER);
@ -80,18 +79,19 @@ class Preview extends Entity {
$this->addType('mimetype', Types::INTEGER);
$this->addType('mtime', Types::INTEGER);
$this->addType('size', Types::INTEGER);
$this->addType('isMax', Types::BOOLEAN);
$this->addType('crop', Types::BOOLEAN);
$this->addType('max', Types::BOOLEAN);
$this->addType('cropped', Types::BOOLEAN);
$this->addType('encrypted', Types::BOOLEAN);
$this->addType('etag', Types::STRING);
$this->addType('version', Types::BIGINT);
}
public function getName(): string {
$path = ($this->getVersion() > -1 ? $this->getVersion() . '-' : '') . $this->getWidth() . '-' . $this->getHeight();
if ($this->getCrop()) {
if ($this->isCropped()) {
$path .= '-crop';
}
if ($this->getIsMax()) {
if ($this->isMax()) {
$path .= '-max';
}

View file

@ -9,7 +9,6 @@ declare(strict_types=1);
namespace OC\Preview\Db;
use OC\Preview\Generator;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
@ -123,4 +122,21 @@ class PreviewMapper extends QBMapper {
return $qb->getLastInsertId();
}
}
public function deleteAll(): void {
$delete = $this->db->getQueryBuilder();
$delete->delete($this->getTableName());
}
/**
* @return \Generator<Preview>
*/
public function getPreviews(int $lastId, int $limit = 1000): \Generator {
$qb = $this->db->getQueryBuilder();
$this->joinLocation($qb)
->where($qb->expr()->gt('p.id', $qb->createNamedParameter($lastId, IQueryBuilder::PARAM_INT)))
->setMaxResults($limit);
return $this->yieldEntities($qb);
}
}

View file

@ -158,7 +158,7 @@ class Generator {
try {
$preview = array_find($previews, fn (Preview $preview): bool => $preview->getWidth() === $width
&& $preview->getHeight() === $height && $preview->getMimetype() === $maxPreview->getMimetype()
&& $preview->getVersion() === $previewVersion && $preview->getCrop() === $crop);
&& $preview->getVersion() === $previewVersion && $preview->isCropped() === $crop);
if ($preview) {
$previewFile = new PreviewFile($preview, $this->storageFactory, $this->previewMapper);
@ -300,7 +300,7 @@ class Generator {
// We don't know the max preview size, so we can't use getCachedPreview.
// It might have been generated with a higher resolution than the current value.
foreach ($previews as $preview) {
if ($preview->getIsMax() && ($version == $preview->getVersion())) {
if ($preview->isMax() && ($version === $preview->getVersion())) {
return $preview;
}
}
@ -539,11 +539,13 @@ class Generator {
public function savePreview(File $file, int $width, int $height, bool $crop, bool $max, IImage $preview, int $version): Preview {
$previewEntry = new Preview();
$previewEntry->setFileId($file->getId());
$previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId());
$previewEntry->setWidth($width);
$previewEntry->setHeight($height);
$previewEntry->setVersion($version);
$previewEntry->setIsMax($max);
$previewEntry->setCrop($crop);
$previewEntry->setMax($max);
$previewEntry->setCropped($crop);
$previewEntry->setEncrypted(false);
switch ($preview->dataMimeType()) {
case 'image/jpeg':
$previewEntry->setMimetype(IPreview::MIMETYPE_JPEG);

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\Storage\StorageFactory;
use OCP\IDBConnection;
class PreviewService {
public function __construct(
private readonly StorageFactory $storageFactory,
private readonly PreviewMapper $previewMapper,
private readonly IDBConnection $connection,
) {
}
public function deletePreview(Preview $preview): void {
$this->storageFactory->deletePreview($preview);
$this->previewMapper->delete($preview);
}
/**
* Get storageId and fileIds for which we have at least one preview.
*
* @return \Generator<array{storageId: int, fileIds: int[]}>
*/
public function getAvailableFileIds(): \Generator {
$maxQb = $this->connection->getQueryBuilder();
$maxQb->select($maxQb->func()->max('id'))
->from($this->previewMapper->getTableName())
->groupBy('file_id');
$qb = $this->connection->getQueryBuilder();
$qb->select('file_id', 'storage_id')
->from($this->previewMapper->getTableName())
->where($qb->expr()->in('id', $qb->createFunction($maxQb->getSQL())));
$result = $qb->executeQuery();
$lastStorageId = -1;
/** @var int[] $fileIds */
$fileIds = [];
// Previews next to each others in the database are likely in the same storage, so group them
while ($row = $result->fetch()) {
if ($lastStorageId !== $row['storage_id']) {
if ($lastStorageId !== -1) {
yield ['storageId' => $lastStorageId, 'fileIds' => $fileIds];
$fileIds = [];
}
$lastStorageId = (int)$row['storage_id'];
}
$fileIds[] = (int)$row['file_id'];
}
if (count($fileIds) > 0) {
yield ['storageId' => $lastStorageId, 'fileIds' => $fileIds];
}
}
/**
* @return \Generator<Preview>
*/
public function getAvailablePreviewForFile(int $fileId): \Generator {
yield from $this->previewMapper->getAvailablePreviewForFile($fileId);
}
public function deleteAll(): void {
$lastId = 0;
while (true) {
$previews = $this->previewMapper->getPreviews($lastId, 1000);
$i = 0;
foreach ($previews as $preview) {
$this->deletePreview($preview);
$i++;
$lastId = $preview->getId();
}
if ($i !== 1000) {
break;
}
}
}
/**
* @param int[] $fileIds
* @return array<int, Preview[]>
*/
public function getAvailablePreviews(array $fileIds): array {
return $this->previewMapper->getAvailablePreviews($fileIds);
}
}

View file

@ -1,5 +1,13 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview\Storage;
use OC\Files\SimpleFS\SimpleFile;
@ -11,15 +19,15 @@ interface IPreviewStorage {
* @param resource|string $stream
* @throws NotPermittedException
*/
public function writePreview(Preview $preview, $stream): false|int;
public function writePreview(Preview $preview, mixed $stream): false|int;
/**
* @param Preview $preview
* @return resource|false
*/
public function readPreview(Preview $preview);
public function readPreview(Preview $preview): mixed;
public function deletePreview(Preview $preview);
public function deletePreview(Preview $preview): void;
/**
* Migration helper

View file

@ -27,7 +27,7 @@ class LocalPreviewStorage implements IPreviewStorage {
$this->rootFolder = $this->config->getSystemValue('datadirectory', OC::$SERVERROOT . '/data');
}
public function writePreview(Preview $preview, $stream): false|int {
public function writePreview(Preview $preview, mixed $stream): false|int {
$previewPath = $this->constructPath($preview);
if (!$this->createParentFiles($previewPath)) {
return false;
@ -35,11 +35,11 @@ class LocalPreviewStorage implements IPreviewStorage {
return file_put_contents($previewPath, $stream);
}
public function readPreview(Preview $preview) {
public function readPreview(Preview $preview): mixed {
return @fopen($this->constructPath($preview), 'r');
}
public function deletePreview(Preview $preview) {
public function deletePreview(Preview $preview): void {
@unlink($this->constructPath($preview));
}

View file

@ -15,7 +15,6 @@ use OC\Files\ObjectStore\PrimaryObjectStoreConfig;
use OC\Files\SimpleFS\SimpleFile;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OCP\Files\NotFoundException;
use OCP\Files\ObjectStore\IObjectStore;
use OCP\IConfig;
@ -26,7 +25,7 @@ use OCP\IConfig;
class ObjectStorePreviewStorage implements IPreviewStorage {
/**
* @var array<string, array<int, ObjectStoreDefinition>>
* @var array<string, array<string, ObjectStoreDefinition>>
*/
private array $objectStoreCache = [];
@ -40,7 +39,7 @@ class ObjectStorePreviewStorage implements IPreviewStorage {
$this->isMultibucketPreviewDistributionEnabled = $config->getSystemValueBool('objectstore.multibucket.preview-distribution');
}
public function writePreview(Preview $preview, $stream): false|int {
public function writePreview(Preview $preview, mixed $stream): false|int {
if (!is_resource($stream)) {
$fh = fopen('php://temp', 'w+');
fwrite($fh, $stream);
@ -64,7 +63,7 @@ class ObjectStorePreviewStorage implements IPreviewStorage {
return $size;
}
public function readPreview(Preview $preview) {
public function readPreview(Preview $preview): mixed {
[
'objectPrefix' => $objectPrefix,
'store' => $store,
@ -72,12 +71,12 @@ class ObjectStorePreviewStorage implements IPreviewStorage {
return $store->readObject($this->constructUrn($objectPrefix, $preview->getId()));
}
public function deletePreview(Preview $preview) {
public function deletePreview(Preview $preview): void {
[
'objectPrefix' => $objectPrefix,
'store' => $store,
] = $this->getObjectStoreForPreview($preview);
return $store->deleteObject($this->constructUrn($objectPrefix, $preview->getId()));
$store->deleteObject($this->constructUrn($objectPrefix, $preview->getId()));
}
public function migratePreview(Preview $preview, SimpleFile $file): void {

View file

@ -1,5 +1,13 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Preview\Storage;
use OC\Files\ObjectStore\PrimaryObjectStoreConfig;
@ -18,11 +26,11 @@ class StorageFactory implements IPreviewStorage {
) {
}
public function writePreview(Preview $preview, $stream): false|int {
public function writePreview(Preview $preview, mixed $stream): false|int {
return $this->getBackend()->writePreview($preview, $stream);
}
public function readPreview(Preview $preview) {
public function readPreview(Preview $preview): mixed {
return $this->getBackend()->readPreview($preview);
}

View file

@ -41,11 +41,12 @@ class Watcher {
return;
}
if (is_null($node->getId())) {
$nodeId = $node->getId();
if (is_null($nodeId)) {
return;
}
[$node->getId() => $previews] = $this->previewMapper->getAvailablePreviews([$node->getId()]);
[$node->getId() => $previews] = $this->previewMapper->getAvailablePreviews([$nodeId]);
foreach ($previews as $preview) {
$this->storageFactory->deletePreview($preview);
}

View file

@ -16,7 +16,6 @@ use OC\Preview\Storage\StorageFactory;
use OCP\AppFramework\QueryException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\IAppData;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;

View file

@ -92,6 +92,7 @@
<file name="build/stubs/psr_container.php"/>
<file name="3rdparty/sabre/uri/lib/functions.php" />
<file name="build/stubs/app_api.php" />
<file name="build/stubs/php-polyfill.php" />
</stubs>
<issueHandlers>
<LessSpecificReturnStatement errorLevel="error"/>

View file

@ -1,153 +0,0 @@
<?php
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Tests\Core\Command\Preview;
use bantu\IniGetWrapper\IniGetWrapper;
use OC\Core\Command\Preview\Repair;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\IConfig;
use OCP\Lock\ILockingProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Test\TestCase;
class RepairTest extends TestCase {
/** @var IConfig|MockObject */
private $config;
/** @var IRootFolder|MockObject */
private $rootFolder;
/** @var LoggerInterface|MockObject */
private $logger;
/** @var IniGetWrapper|MockObject */
private $iniGetWrapper;
/** @var InputInterface|MockObject */
private $input;
/** @var OutputInterface|MockObject */
private $output;
/** @var string */
private $outputLines = '';
/** @var Repair */
private $repair;
protected function setUp(): void {
parent::setUp();
$this->config = $this->getMockBuilder(IConfig::class)
->getMock();
$this->rootFolder = $this->getMockBuilder(IRootFolder::class)
->getMock();
$this->logger = $this->getMockBuilder(LoggerInterface::class)
->getMock();
$this->iniGetWrapper = $this->getMockBuilder(IniGetWrapper::class)
->getMock();
$this->repair = new Repair(
$this->config,
$this->rootFolder,
$this->logger,
$this->iniGetWrapper,
$this->createMock(ILockingProvider::class)
);
$this->input = $this->createMock(InputInterface::class);
$this->input->expects($this->any())
->method('getOption')
->willReturnCallback(function ($parameter) {
if ($parameter === 'batch') {
return true;
}
return null;
});
$this->output = $this->getMockBuilder(ConsoleOutput::class)
->onlyMethods(['section', 'writeln', 'getFormatter'])
->getMock();
$self = $this;
/* We need format method to return a string */
$outputFormatter = $this->createMock(OutputFormatterInterface::class);
$outputFormatter->method('isDecorated')->willReturn(false);
$outputFormatter->method('format')->willReturnArgument(0);
$this->output->expects($this->any())
->method('getFormatter')
->willReturn($outputFormatter);
$this->output->expects($this->any())
->method('writeln')
->willReturnCallback(function ($line) use ($self): void {
$self->outputLines .= $line . "\n";
});
}
public static function dataEmptyTest(): array {
/** directoryNames, expectedOutput */
return [
[
[],
'All previews are already migrated.'
],
[
[['name' => 'a'], ['name' => 'b'], ['name' => 'c']],
'All previews are already migrated.'
],
[
[['name' => '0', 'content' => ['folder', 'folder']], ['name' => 'b'], ['name' => 'c']],
'All previews are already migrated.'
],
[
[['name' => '0', 'content' => ['file', 'folder', 'folder']], ['name' => 'b'], ['name' => 'c']],
'A total of 1 preview files need to be migrated.'
],
[
[['name' => '23'], ['name' => 'b'], ['name' => 'c']],
'A total of 1 preview files need to be migrated.'
],
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataEmptyTest')]
public function testEmptyExecute($directoryNames, $expectedOutput): void {
$previewFolder = $this->getMockBuilder(Folder::class)
->getMock();
$directories = array_map(function ($element) {
$dir = $this->getMockBuilder(Folder::class)
->getMock();
$dir->expects($this->any())
->method('getName')
->willReturn($element['name']);
if (isset($element['content'])) {
$list = [];
foreach ($element['content'] as $item) {
if ($item === 'file') {
$list[] = $this->getMockBuilder(Node::class)
->getMock();
} elseif ($item === 'folder') {
$list[] = $this->getMockBuilder(Folder::class)
->getMock();
}
}
$dir->expects($this->once())
->method('getDirectoryListing')
->willReturn($list);
}
return $dir;
}, $directoryNames);
$previewFolder->expects($this->once())
->method('getDirectoryListing')
->willReturn($directories);
$this->rootFolder->expects($this->once())
->method('get')
->with('appdata_/preview')
->willReturn($previewFolder);
$this->repair->run($this->input, $this->output);
$this->assertStringContainsString($expectedOutput, $this->outputLines);
}
}

View file

@ -212,6 +212,7 @@ class PrimaryObjectStoreConfigTest extends TestCase {
$this->assertEquals([
'default' => 'server1',
'root' => 'server1',
'preview' => 'server1',
'server1' => [
'class' => StorageObjectStore::class,
'arguments' => [
@ -235,6 +236,7 @@ class PrimaryObjectStoreConfigTest extends TestCase {
$this->assertEquals([
'default' => 'server1',
'root' => 'server1',
'preview' => 'server1',
'server1' => [
'class' => StorageObjectStore::class,
'arguments' => [
@ -270,6 +272,7 @@ class PrimaryObjectStoreConfigTest extends TestCase {
$this->setConfig('objectstore', [
'default' => 'server1',
'root' => 'server2',
'preview' => 'server1',
'server1' => [
'class' => StorageObjectStore::class,
'arguments' => [

View file

@ -9,19 +9,13 @@ namespace Test\Preview;
use OC\Files\Storage\Temporary;
use OC\Preview\BackgroundCleanupJob;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\Storage\Root;
use OC\Preview\Storage\StorageFactory;
use OC\Preview\PreviewService;
use OC\PreviewManager;
use OC\SystemConfig;
use OCP\App\IAppManager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\File;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IDBConnection;
use OCP\IPreview;
use OCP\Server;
@ -45,8 +39,7 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
private IRootFolder $rootFolder;
private IMimeTypeLoader $mimeTypeLoader;
private ITimeFactory $timeFactory;
private PreviewMapper $previewMapper;
private StorageFactory $previewStorageFactory;
private PreviewService $previewService;
protected function setUp(): void {
parent::setUp();
@ -70,8 +63,7 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
$this->rootFolder = Server::get(IRootFolder::class);
$this->mimeTypeLoader = Server::get(IMimeTypeLoader::class);
$this->timeFactory = Server::get(ITimeFactory::class);
$this->previewMapper = Server::get(PreviewMapper::class);
$this->previewStorageFactory = Server::get(StorageFactory::class);
$this->previewService = Server::get(PreviewService::class);
}
protected function tearDown(): void {
@ -82,9 +74,8 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
$this->logout();
foreach ($this->previewMapper->getAvailablePreviews(5) as $preview) {
$this->previewStorageFactory->deletePreview($preview);
$this->previewMapper->delete($preview);
foreach ($this->previewService->getAvailablePreviewForFile(5) as $preview) {
$this->previewService->deletePreview($preview);
}
parent::tearDown();
@ -104,8 +95,8 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
return $files;
}
private function countPreviews(PreviewMapper $previewMapper, array $fileIds): int {
$previews = $previewMapper->getAvailablePreviews($fileIds);
private function countPreviews(PreviewService $previewService, array $fileIds): int {
$previews = $previewService->getAvailablePreviews($fileIds);
return array_reduce($previews, fn (int $result, array $previews) => $result + count($previews), 0);
}
@ -113,18 +104,18 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
$files = $this->setup11Previews();
$fileIds = array_map(fn (File $f): int => $f->getId(), $files);
$this->assertSame(11, $this->countPreviews($this->previewMapper, $fileIds));
$job = new BackgroundCleanupJob($this->timeFactory, $this->connection, $this->previewMapper, $this->previewStorageFactory, true);
$this->assertSame(11, $this->countPreviews($this->previewService, $fileIds));
$job = new BackgroundCleanupJob($this->timeFactory, $this->connection, $this->previewService, true);
$job->run([]);
foreach ($files as $file) {
$file->delete();
}
$this->assertSame(11, $this->countPreviews($this->previewMapper, $fileIds));
$this->assertSame(11, $this->countPreviews($this->previewService, $fileIds));
$job->run([]);
$this->assertSame(0, $this->countPreviews($this->previewMapper, $fileIds));
$this->assertSame(0, $this->countPreviews($this->previewService, $fileIds));
}
public function testCleanupAjax(): void {
@ -134,20 +125,20 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
$files = $this->setup11Previews();
$fileIds = array_map(fn (File $f): int => $f->getId(), $files);
$this->assertSame(11, $this->countPreviews($this->previewMapper, $fileIds));
$job = new BackgroundCleanupJob($this->timeFactory, $this->connection, $this->previewMapper, $this->previewStorageFactory, false);
$this->assertSame(11, $this->countPreviews($this->previewService, $fileIds));
$job = new BackgroundCleanupJob($this->timeFactory, $this->connection, $this->previewService, false);
$job->run([]);
foreach ($files as $file) {
$file->delete();
}
$this->assertSame(11, $this->countPreviews($this->previewMapper, $fileIds));
$this->assertSame(11, $this->countPreviews($this->previewService, $fileIds));
$job->run([]);
$this->assertSame(1, $this->countPreviews($this->previewMapper, $fileIds));
$this->assertSame(1, $this->countPreviews($this->previewService, $fileIds));
$job->run([]);
$this->assertSame(0, $this->countPreviews($this->previewMapper, $fileIds));
$this->assertSame(0, $this->countPreviews($this->previewService, $fileIds));
}
}

View file

@ -16,8 +16,6 @@ use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\Mount\IMountPoint;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IConfig;
use OCP\IImage;
use OCP\IPreview;
@ -84,19 +82,21 @@ class GeneratorTest extends TestCase {
$maxPreview = new Preview();
$maxPreview->setWidth(1000);
$maxPreview->setHeight(1000);
$maxPreview->setIsMax(true);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(-1);
$maxPreview->setCrop(false);
$maxPreview->setCropped(false);
$maxPreview->setStorageId(1);
$maxPreview->setMimetype(IPreview::MIMETYPE_PNG);
$previewFile = new Preview();
$previewFile->setWidth(256);
$previewFile->setHeight(256);
$previewFile->setIsMax(false);
$previewFile->setMax(false);
$previewFile->setSize(1000);
$previewFile->setVersion(-1);
$previewFile->setCrop(false);
$previewFile->setCropped(false);
$previewFile->setStorageId(1);
$previewFile->setMimetype(IPreview::MIMETYPE_PNG);
$this->previewMapper->method('getAvailablePreviews')
@ -190,7 +190,7 @@ class GeneratorTest extends TestCase {
$maxPreview = new Preview();
$maxPreview->setWidth(2048);
$maxPreview->setHeight(2048);
$maxPreview->setIsMax(true);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setMimetype(IPreview::MIMETYPE_PNG);
@ -210,7 +210,7 @@ class GeneratorTest extends TestCase {
$this->assertSame('my resized data', $data);
return 1000;
}
$this->fail("file name is wrong:". $preview->getName());
$this->fail('file name is wrong:' . $preview->getName());
});
$image = $this->getMockImage(2048, 2048, 'my resized data');
@ -238,7 +238,7 @@ class GeneratorTest extends TestCase {
$maxPreview = new Preview();
$maxPreview->setWidth(2048);
$maxPreview->setHeight(2048);
$maxPreview->setIsMax(true);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(-1);
$maxPreview->setMimetype(IPreview::MIMETYPE_PNG);
@ -262,7 +262,7 @@ class GeneratorTest extends TestCase {
$maxPreview = new Preview();
$maxPreview->setWidth(2048);
$maxPreview->setHeight(2048);
$maxPreview->setIsMax(true);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(-1);
$maxPreview->setMimetype(IPreview::MIMETYPE_PNG);
@ -270,9 +270,9 @@ class GeneratorTest extends TestCase {
$previewFile = new Preview();
$previewFile->setWidth(1024);
$previewFile->setHeight(512);
$previewFile->setIsMax(false);
$previewFile->setMax(false);
$previewFile->setSize(1000);
$previewFile->setCrop(true);
$previewFile->setCropped(true);
$previewFile->setVersion(-1);
$previewFile->setMimetype(IPreview::MIMETYPE_PNG);
@ -380,7 +380,7 @@ class GeneratorTest extends TestCase {
$maxPreview = new Preview();
$maxPreview->setWidth($maxX);
$maxPreview->setHeight($maxY);
$maxPreview->setIsMax(true);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(-1);
$maxPreview->setMimetype(IPreview::MIMETYPE_PNG);

View file

@ -1,17 +1,26 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace lib\Preview;
use OC\Core\BackgroundJobs\MovePreviewJob;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\PreviewService;
use OC\Preview\Storage\StorageFactory;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
use OCP\Files\IRootFolder;
use OCP\IAppConfig;
use OCP\IDBConnection;
use OCP\Server;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
@ -19,12 +28,13 @@ use Test\TestCase;
/**
* @group DB
*/
#[CoversClass(MovePreviewJob::class)]
class MovePreviewJobTest extends TestCase {
private IAppData $previewAppData;
private PreviewMapper $previewMapper;
private IAppConfig&MockObject $appConfig;
private StorageFactory $storageFactory;
private PreviewService $previewService;
private IDBConnection $db;
public function setUp(): void {
parent::setUp();
@ -34,23 +44,54 @@ class MovePreviewJobTest extends TestCase {
$this->appConfig->expects($this->any())
->method('getValueBool')
->willReturn(false);
$this->appConfig->expects($this->any())
->method('setValueBool')
->willReturn(true);
$this->storageFactory = Server::get(StorageFactory::class);
$this->previewService = Server::get(PreviewService::class);
$this->db = Server::get(IDBConnection::class);
$qb = $this->db->getQueryBuilder();
$qb->delete('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter(5)))
->executeStatement();
$qb = $this->db->getQueryBuilder();
$qb->insert('filecache')
->values([
'fileid' => $qb->createNamedParameter(5),
'storage' => $qb->createNamedParameter(1),
'path' => $qb->createNamedParameter('test/abc'),
'path_hash' => $qb->createNamedParameter(md5('test')),
'parent' => $qb->createNamedParameter(0),
'name' => $qb->createNamedParameter('abc'),
'mimetype' => $qb->createNamedParameter(0),
'size' => $qb->createNamedParameter(1000),
'mtime' => $qb->createNamedParameter(1000),
'storage_mtime' => $qb->createNamedParameter(1000),
'encrypted' => $qb->createNamedParameter(0),
'unencrypted_size' => $qb->createNamedParameter(0),
'etag' => $qb->createNamedParameter('abcdefg'),
'permissions' => $qb->createNamedParameter(0),
'checksum' => $qb->createNamedParameter('abcdefg'),
])->executeStatement();
}
public function tearDown(): void {
foreach ($this->previewMapper->getAvailablePreviewForFile(5) as $preview) {
$this->storageFactory->deletePreview($preview);
$this->previewMapper->delete($preview);
}
foreach ($this->previewAppData->getDirectoryListing() as $folder) {
$folder->delete();
}
$this->previewService->deleteAll();
$qb = $this->db->getQueryBuilder();
$qb->delete('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter(5)))
->executeStatement();
}
#[TestDox("Test the migration from the legacy flat hierarchy to the new database format")]
function testMigrationLegacyPath(): void {
$folder = $this->previewAppData->newFolder(5);
#[TestDox('Test the migration from the legacy flat hierarchy to the new database format')]
public function testMigrationLegacyPath(): void {
$folder = $this->previewAppData->newFolder('5');
$folder->newFile('64-64-crop.jpg', 'abcdefg');
$folder->newFile('128-128-crop.png', 'abcdefg');
$this->assertEquals(1, count($this->previewAppData->getDirectoryListing()));
@ -63,6 +104,7 @@ class MovePreviewJobTest extends TestCase {
$this->previewMapper,
$this->storageFactory,
Server::get(IDBConnection::class),
Server::get(IRootFolder::class),
Server::get(IAppDataFactory::class)
);
$this->invokePrivate($job, 'run', [[]]);
@ -75,12 +117,12 @@ class MovePreviewJobTest extends TestCase {
}
#[TestDox("Test the migration from the 'new' nested hierarchy to the database format")]
function testMigrationPath(): void {
$folder = $this->previewAppData->newFolder(self::getInternalFolder(5));
public function testMigrationPath(): void {
$folder = $this->previewAppData->newFolder(self::getInternalFolder((string)5));
$folder->newFile('64-64-crop.jpg', 'abcdefg');
$folder->newFile('128-128-crop.png', 'abcdefg');
$folder = $this->previewAppData->getFolder(self::getInternalFolder(5));
$folder = $this->previewAppData->getFolder(self::getInternalFolder((string)5));
$this->assertEquals(2, count($folder->getDirectoryListing()));
$this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5))));
@ -90,10 +132,65 @@ class MovePreviewJobTest extends TestCase {
$this->previewMapper,
$this->storageFactory,
Server::get(IDBConnection::class),
Server::get(IRootFolder::class),
Server::get(IAppDataFactory::class)
);
$this->invokePrivate($job, 'run', [[]]);
$this->assertEquals(0, count($this->previewAppData->getDirectoryListing()));
$this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5))));
}
#[TestDox("Test the migration from the 'new' nested hierarchy to the database format")]
public function testMigrationPathWithVersion(): void {
$folder = $this->previewAppData->newFolder(self::getInternalFolder((string)5));
// No version
$folder->newFile('128-128-crop.png', 'abcdefg');
$folder->newFile('256-256-max.png', 'abcdefg');
$folder->newFile('128-128.png', 'abcdefg');
// Version 1000
$folder->newFile('1000-128-128-crop.png', 'abcdefg');
$folder->newFile('1000-256-256-max.png', 'abcdefg');
$folder->newFile('1000-128-128.png', 'abcdefg');
// Version 1001
$folder->newFile('1001-128-128-crop.png', 'abcdefg');
$folder->newFile('1001-256-256-max.png', 'abcdefg');
$folder->newFile('1001-128-128.png', 'abcdefg');
$folder = $this->previewAppData->getFolder(self::getInternalFolder((string)5));
$this->assertEquals(9, count($folder->getDirectoryListing()));
$this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5))));
$job = new MovePreviewJob(
Server::get(ITimeFactory::class),
$this->appConfig,
$this->previewMapper,
$this->storageFactory,
Server::get(IDBConnection::class),
Server::get(IRootFolder::class),
Server::get(IAppDataFactory::class)
);
$this->invokePrivate($job, 'run', [[]]);
$this->assertEquals(0, count($this->previewAppData->getDirectoryListing()));
$previews = iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5));
$this->assertEquals(9, count($previews));
$nameVersionMapping = [];
foreach ($previews as $preview) {
$nameVersionMapping[$preview->getName()] = $preview->getVersion();
}
$this->assertEquals([
'1000-128-128.png' => 1000,
'1000-128-128-crop.png' => 1000,
'1000-256-256-max.png' => 1000,
'1001-128-128.png' => 1001,
'1001-128-128-crop.png' => 1001,
'1001-256-256-max.png' => 1001,
'128-128.png' => -1,
'128-128-crop.png' => -1,
'256-256-max.png' => -1,
], $nameVersionMapping);
}
}

View file

@ -29,7 +29,7 @@ class PreviewMapperTest extends TestCase {
$this->connection = Server::get(IDBConnection::class);
}
public function testGetAvailablePreviews() {
public function testGetAvailablePreviews(): void {
// Empty
$this->assertEquals([], $this->previewMapper->getAvailablePreviews([]));
@ -50,7 +50,8 @@ class PreviewMapperTest extends TestCase {
$this->assertEquals('default', $previews[43][0]->getObjectStoreName());
}
private function createPreviewForFileId(int $fileId, ?int $bucket = null) {
private function createPreviewForFileId(int $fileId, ?int $bucket = null): void {
$locationId = null;
if ($bucket) {
$qb = $this->connection->getQueryBuilder();
$qb->insert('preview_locations')
@ -58,20 +59,22 @@ class PreviewMapperTest extends TestCase {
'bucket_name' => $qb->createNamedParameter('preview-' . $bucket),
'object_store_name' => $qb->createNamedParameter('default'),
]);
$locationId = $qb->executeStatement();
$qb->executeStatement();
$locationId = $qb->getLastInsertId();
}
$preview = new Preview();
$preview->setFileId($fileId);
$preview->setCrop(true);
$preview->setIsMax(true);
$preview->setStorageId(1);
$preview->setCropped(true);
$preview->setMax(true);
$preview->setWidth(100);
$preview->setHeight(100);
$preview->setSize(100);
$preview->setMtime(time());
$preview->setMimetype(IPreview::MIMETYPE_PNG);
$preview->setEtag("abcdefg");
$preview->setEtag('abcdefg');
if ($locationId) {
if ($locationId !== null) {
$preview->setLocationId($locationId);
}
$this->previewMapper->insert($preview);

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Preview;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OC\Preview\PreviewService;
use OCP\IPreview;
use OCP\Server;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
/**
* @group DB
*/
#[CoversClass(PreviewService::class)]
class PreviewServiceTest extends TestCase {
private PreviewService $previewService;
protected function setUp(): void {
$this->previewService = Server::get(PreviewService::class);
$this->previewMapper = Server::get(PreviewMapper::class);
$this->previewService->deleteAll();
}
public function tearDown(): void {
$this->previewService->deleteAll();
}
public function testGetAvailableFileIds(): void {
foreach (range(1, 20) as $i) {
$preview = new Preview();
$preview->setFileId($i % 10);
$preview->setStorageId(1);
$preview->setWidth($i);
$preview->setHeight($i);
$preview->setMax(true);
$preview->setCropped(true);
$preview->setEncrypted(false);
$preview->setMimetype(IPreview::MIMETYPE_JPEG);
$preview->setEtag('abc');
$preview->setMtime((new \DateTime())->getTimestamp());
$preview->setSize(0);
$this->previewMapper->insert($preview);
}
$files = iterator_to_array($this->previewService->getAvailableFileIds());
$this->assertCount(1, $files);
$this->assertCount(10, $files[0]['fileIds']);
}
}