diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index 606cb3b5bb1..21baeb9a33b 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -2708,17 +2708,6 @@ timeFactory->getTime()]]> - - - - - - - - - - - diff --git a/build/stubs/php-polyfill.php b/build/stubs/php-polyfill.php new file mode 100644 index 00000000000..606a21f8dfe --- /dev/null +++ b/build/stubs/php-polyfill.php @@ -0,0 +1,9 @@ +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 $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 $previewFiles - */ - $previewFiles = []; + /** + * @var list $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 { diff --git a/core/Command/Preview/Repair.php b/core/Command/Preview/Repair.php deleted file mode 100644 index a92a4cf8ed0..00000000000 --- a/core/Command/Preview/Repair.php +++ /dev/null @@ -1,293 +0,0 @@ -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('Should the migration be started? (y/[n]) ', 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; - } -} diff --git a/core/Command/Preview/ResetRenderedTexts.php b/core/Command/Preview/ResetRenderedTexts.php index ce874e15406..fb7fb9fa620 100644 --- a/core/Command/Preview/ResetRenderedTexts.php +++ b/core/Command/Preview/ResetRenderedTexts.php @@ -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 + */ 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(); diff --git a/core/Migrations/Version33000Date20250819110529.php b/core/Migrations/Version33000Date20250819110529.php index 5e607024ec7..27bf9ab89b7 100644 --- a/core/Migrations/Version33000Date20250819110529.php +++ b/core/Migrations/Version33000Date20250819110529.php @@ -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; diff --git a/core/register_command.php b/core/register_command.php index 9fd5b9b611e..f6c0b9466b5 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -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)); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 5c168a25de3..4c2e473240b 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -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', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 423b4cc6b57..2cb14099c01 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -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', diff --git a/lib/private/BackgroundJob/JobList.php b/lib/private/BackgroundJob/JobList.php index 302bee22cd5..c00a51e3851 100644 --- a/lib/private/BackgroundJob/JobList.php +++ b/lib/private/BackgroundJob/JobList.php @@ -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(); diff --git a/lib/private/Preview/BackgroundCleanupJob.php b/lib/private/Preview/BackgroundCleanupJob.php index d65b740745d..257bebb1d5d 100644 --- a/lib/private/Preview/BackgroundCleanupJob.php +++ b/lib/private/Preview/BackgroundCleanupJob.php @@ -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 - */ - 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[] diff --git a/lib/private/Preview/Db/Preview.php b/lib/private/Preview/Db/Preview.php index b4ebe8b4805..b6f2ef2942e 100644 --- a/lib/private/Preview/Db/Preview.php +++ b/lib/private/Preview/Db/Preview.php @@ -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'; } diff --git a/lib/private/Preview/Db/PreviewMapper.php b/lib/private/Preview/Db/PreviewMapper.php index dba62c5a163..ec3a0fa7e1f 100644 --- a/lib/private/Preview/Db/PreviewMapper.php +++ b/lib/private/Preview/Db/PreviewMapper.php @@ -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 + */ + 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); + + } } diff --git a/lib/private/Preview/Generator.php b/lib/private/Preview/Generator.php index 6d3626bb2fc..c6e49a1c6ce 100644 --- a/lib/private/Preview/Generator.php +++ b/lib/private/Preview/Generator.php @@ -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); diff --git a/lib/private/Preview/PreviewService.php b/lib/private/Preview/PreviewService.php new file mode 100644 index 00000000000..67d6b011416 --- /dev/null +++ b/lib/private/Preview/PreviewService.php @@ -0,0 +1,101 @@ +storageFactory->deletePreview($preview); + $this->previewMapper->delete($preview); + } + + /** + * Get storageId and fileIds for which we have at least one preview. + * + * @return \Generator + */ + 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 + */ + 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 + */ + public function getAvailablePreviews(array $fileIds): array { + return $this->previewMapper->getAvailablePreviews($fileIds); + } +} diff --git a/lib/private/Preview/Storage/IPreviewStorage.php b/lib/private/Preview/Storage/IPreviewStorage.php index 787518e53a4..933fc2850b0 100644 --- a/lib/private/Preview/Storage/IPreviewStorage.php +++ b/lib/private/Preview/Storage/IPreviewStorage.php @@ -1,5 +1,13 @@ 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)); } diff --git a/lib/private/Preview/Storage/ObjectStorePreviewStorage.php b/lib/private/Preview/Storage/ObjectStorePreviewStorage.php index 7963c870dd8..0a735e5a912 100644 --- a/lib/private/Preview/Storage/ObjectStorePreviewStorage.php +++ b/lib/private/Preview/Storage/ObjectStorePreviewStorage.php @@ -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> + * @var array> */ 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 { diff --git a/lib/private/Preview/Storage/StorageFactory.php b/lib/private/Preview/Storage/StorageFactory.php index 0755bacd0b9..3051ad5869f 100644 --- a/lib/private/Preview/Storage/StorageFactory.php +++ b/lib/private/Preview/Storage/StorageFactory.php @@ -1,5 +1,13 @@ getBackend()->writePreview($preview, $stream); } - public function readPreview(Preview $preview) { + public function readPreview(Preview $preview): mixed { return $this->getBackend()->readPreview($preview); } diff --git a/lib/private/Preview/Watcher.php b/lib/private/Preview/Watcher.php index bbbbcb835fa..ea0f72796ae 100644 --- a/lib/private/Preview/Watcher.php +++ b/lib/private/Preview/Watcher.php @@ -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); } diff --git a/lib/private/PreviewManager.php b/lib/private/PreviewManager.php index 912d2b3fe5b..fb884092872 100644 --- a/lib/private/PreviewManager.php +++ b/lib/private/PreviewManager.php @@ -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; diff --git a/psalm.xml b/psalm.xml index 648b250c38b..abea7c5a443 100644 --- a/psalm.xml +++ b/psalm.xml @@ -92,6 +92,7 @@ + diff --git a/tests/Core/Command/Preview/RepairTest.php b/tests/Core/Command/Preview/RepairTest.php deleted file mode 100644 index 9b9cde6dd95..00000000000 --- a/tests/Core/Command/Preview/RepairTest.php +++ /dev/null @@ -1,153 +0,0 @@ -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); - } -} diff --git a/tests/lib/Files/ObjectStore/PrimaryObjectStoreConfigTest.php b/tests/lib/Files/ObjectStore/PrimaryObjectStoreConfigTest.php index c13b170ca18..07e58d56f11 100644 --- a/tests/lib/Files/ObjectStore/PrimaryObjectStoreConfigTest.php +++ b/tests/lib/Files/ObjectStore/PrimaryObjectStoreConfigTest.php @@ -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' => [ diff --git a/tests/lib/Preview/BackgroundCleanupJobTest.php b/tests/lib/Preview/BackgroundCleanupJobTest.php index ea08b58955d..a2c72cbad57 100644 --- a/tests/lib/Preview/BackgroundCleanupJobTest.php +++ b/tests/lib/Preview/BackgroundCleanupJobTest.php @@ -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)); } } diff --git a/tests/lib/Preview/GeneratorTest.php b/tests/lib/Preview/GeneratorTest.php index 91fa3537468..54d28747cf3 100644 --- a/tests/lib/Preview/GeneratorTest.php +++ b/tests/lib/Preview/GeneratorTest.php @@ -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); diff --git a/tests/lib/Preview/MovePreviewJobTest.php b/tests/lib/Preview/MovePreviewJobTest.php index a226b611c5a..cd9f3253654 100644 --- a/tests/lib/Preview/MovePreviewJobTest.php +++ b/tests/lib/Preview/MovePreviewJobTest.php @@ -1,17 +1,26 @@ 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); + } } diff --git a/tests/lib/Preview/PreviewMapperTest.php b/tests/lib/Preview/PreviewMapperTest.php index d6925641d4e..6f59c6f8802 100644 --- a/tests/lib/Preview/PreviewMapperTest.php +++ b/tests/lib/Preview/PreviewMapperTest.php @@ -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); diff --git a/tests/lib/Preview/PreviewServiceTest.php b/tests/lib/Preview/PreviewServiceTest.php new file mode 100644 index 00000000000..01bcff29a5a --- /dev/null +++ b/tests/lib/Preview/PreviewServiceTest.php @@ -0,0 +1,59 @@ +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']); + } +}