Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor(preview): Cleanup the implementation of the new preview backend
Signed-off-by: Carl Schwan <[email protected]>
  • Loading branch information
CarlSchwan committed Oct 6, 2025
commit 324b54b863ac07f93e0553462608089b7240c618
11 changes: 0 additions & 11 deletions build/psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
9 changes: 9 additions & 0 deletions build/stubs/php-polyfill.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php
/*
* SPDX-FileCopyrightText: None
* SPDX-License-Identifier: CC0-1.0
*/

// PHP 8.4
function array_find(array $array, callable $callback) {}

223 changes: 111 additions & 112 deletions core/BackgroundJobs/MovePreviewJob.php
Original file line number Diff line number Diff line change
@@ -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
*/

Expand All @@ -17,7 +19,8 @@
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;
Expand All @@ -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);
Expand All @@ -42,15 +46,6 @@ public function __construct(
}

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;
}
Expand All @@ -59,27 +54,21 @@ private function doRun($argument): void {

$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();
while ($row = $result->fetch()) {
$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);
}
}

Expand All @@ -89,6 +78,7 @@ private function doRun($argument): void {
$qb->select('*')
->from('filecache')
->where($qb->expr()->like('path', $qb->createNamedParameter('appdata_%/preview/%/%.%')))
->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId())
->setMaxResults(100);

$result = $qb->executeQuery();
Expand All @@ -98,18 +88,7 @@ private function doRun($argument): void {
$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.
Expand All @@ -118,97 +97,117 @@ private function doRun($argument): void {
}
}

// 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);

/**
* @var list<array{file: SimpleFile, width: int, height: int, crop: bool, max: bool, extension: string, mtime: int, size: int}> $previewFiles
*/
$previewFiles = [];

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];
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, version: ?int
* }> $previewFiles
*/
$previewFiles = [];

foreach ($folder->getDirectoryListing() as $previewFile) {
/** @var SimpleFile $previewFile */
[0 => $baseName, 1 => $extension] = explode('.', $previewFile->getName());
$nameSplit = explode('-', $baseName);

$offset = 0;
$version = null;
if (count($nameSplit) === 4 || (count($nameSplit) === 3 && is_numeric($nameSplit[2]))) {
$offset = 1;
$version = (int)$nameSplit[0];
}

if (isset($nameSplit[2])) {
$crop = $nameSplit[2] === 'crop';
$max = $nameSplit[2] === 'max';
}
$width = (int)$nameSplit[$offset + 0];
$height = (int)$nameSplit[$offset + 1];

$previewFiles[] = [
'file' => $previewFile,
'width' => $width,
'height' => $height,
'crop' => $crop,
'max' => $max,
'extension' => $extension,
'size' => $previewFile->getSize(),
'mtime' => $previewFile->getMTime(),
];
$crop = false;
$max = false;
if (isset($nameSplit[$offset + 2])) {
$crop = $nameSplit[$offset + 2] === 'crop';
$max = $nameSplit[$offset + 2] === 'max';
}

$qb = $this->connection->getQueryBuilder();
$qb->select('*')
->from('filecache')
->where($qb->expr()->like('fileid', $qb->createNamedParameter($fileId)));
$previewFiles[] = [
'file' => $previewFile,
'width' => $width,
'height' => $height,
'crop' => $crop,
'version' => $version,
'max' => $max,
'extension' => $extension,
'size' => $previewFile->getSize(),
'mtime' => $previewFile->getMTime(),
];
}

$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;
}
$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);
}

$this->deleteFolder($internalPath, $folder);
}

public static function getInternalFolder(string $name, bool $simplePaths): string {
Expand Down
Loading