fix(preview): Make version column a string

And move it to a different table so that we don't have to pay the
storage cost when not using it (most of the times).

Signed-off-by: Carl Schwan <carl.schwan@nextcloud.com>
pull/54543/head
Carl Schwan 2025-09-30 15:56:31 +07:00
parent 66f50bd585
commit bef3996c3e
23 changed files with 404 additions and 246 deletions

@ -17,24 +17,26 @@ use OC\Preview\Storage\StorageFactory;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use OCP\DB\Exception;
use OCP\DB\IResult;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IDBConnection;
use Override;
use Psr\Log\LoggerInterface;
class MovePreviewJob extends TimedJob {
private IAppData $appData;
private string $previewRootPath;
public function __construct(
ITimeFactory $time,
private readonly IAppConfig $appConfig,
private readonly IConfig $config,
private readonly PreviewMapper $previewMapper,
private readonly StorageFactory $storageFactory,
private readonly IDBConnection $connection,
@ -49,6 +51,7 @@ class MovePreviewJob extends TimedJob {
$this->appData = $appDataFactory->get('preview');
$this->setTimeSensitivity(self::TIME_INSENSITIVE);
$this->setInterval(24 * 60 * 60);
$this->previewRootPath = 'appdata_' . $this->config->getSystemValueString('instanceid') . '/preview/';
}
#[Override]
@ -57,49 +60,22 @@ class MovePreviewJob extends TimedJob {
return;
}
$emptyHierarchicalPreviewFolders = false;
$startTime = time();
while (true) {
// 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];
$this->processPreviews($fileId, false);
}
}
// And then the flat preview folder (legacy)
$emptyHierarchicalPreviewFolders = true;
$qb = $this->connection->getQueryBuilder();
$qb->select('*')
$qb->select('path')
->from('filecache')
->where($qb->expr()->like('path', $qb->createNamedParameter('appdata_%/preview/%/%.%')))
// Hierarchical preview folder structure
->where($qb->expr()->like('path', $qb->createNamedParameter($this->previewRootPath . '%/%/%/%/%/%/%/%/%')))
// Legacy flat preview folder structure
->orWhere($qb->expr()->like('path', $qb->createNamedParameter($this->previewRootPath . '%/%.%')))
->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId())
->setMaxResults(100);
$result = $qb->executeQuery();
$foundOldPreview = false;
while ($row = $result->fetch()) {
$pathSplit = explode('/', $row['path']);
assert(count($pathSplit) >= 2);
$fileId = $pathSplit[count($pathSplit) - 2];
array_pop($pathSplit);
$this->processPreviews($fileId, true);
$foundOldPreview = true;
}
$foundPreviews = $this->processQueryResult($result);
if (!$foundOldPreview) {
if (!$foundPreviews) {
break;
}
@ -109,20 +85,46 @@ class MovePreviewJob extends TimedJob {
}
}
try {
// Delete any leftover preview directory
$this->appData->getFolder('.')->delete();
} catch (NotFoundException) {
// ignore
}
$this->appConfig->setValueBool('core', 'previewMovedDone', true);
}
private function processQueryResult(IResult $result): bool {
$foundPreview = false;
$fileIds = [];
$flatFileIds = [];
while ($row = $result->fetch()) {
$pathSplit = explode('/', $row['path']);
assert(count($pathSplit) >= 2);
$fileId = (int)$pathSplit[count($pathSplit) - 2];
if (count($pathSplit) === 11) {
// Hierarchical structure
if (!in_array($fileId, $fileIds)) {
$fileIds[] = $fileId;
}
} else {
// Flat structure
if (!in_array($fileId, $flatFileIds)) {
$flatFileIds[] = $fileId;
}
}
$foundPreview = true;
}
foreach ($fileIds as $fileId) {
$this->processPreviews($fileId, flatPath: false);
}
foreach ($flatFileIds as $fileId) {
$this->processPreviews($fileId, flatPath: true);
}
return $foundPreview;
}
/**
* @param array<string|int, string[]> $previewFolders
*/
private function processPreviews(int|string $fileId, bool $simplePaths): void {
$internalPath = $this->getInternalFolder((string)$fileId, $simplePaths);
private function processPreviews(int $fileId, bool $flatPath): void {
$internalPath = $this->getInternalFolder((string)$fileId, $flatPath);
$folder = $this->appData->getFolder($internalPath);
/**
@ -133,7 +135,7 @@ class MovePreviewJob extends TimedJob {
foreach ($folder->getDirectoryListing() as $previewFile) {
$path = $fileId . '/' . $previewFile->getName();
/** @var SimpleFile $previewFile */
$preview = Preview::fromPath($path, $this->mimeTypeDetector, $this->mimeTypeLoader);
$preview = Preview::fromPath($path, $this->mimeTypeDetector);
if (!$preview) {
$this->logger->error('Unable to import old preview at path.');
continue;
@ -160,23 +162,30 @@ class MovePreviewJob extends TimedJob {
if (count($result) > 0) {
foreach ($previewFiles as $previewFile) {
/** @var Preview $preview */
$preview = $previewFile['preview'];
/** @var SimpleFile $file */
$file = $previewFile['file'];
$preview->setStorageId($result[0]['storage']);
$preview->setEtag($result[0]['etag']);
$preview->setSourceMimetype($result[0]['mimetype']);
$preview->setSourceMimeType($this->mimeTypeLoader->getMimetypeById((int)$result[0]['mimetype']));
try {
$preview = $this->previewMapper->insert($preview);
} catch (Exception $e) {
} catch (Exception) {
// We already have this preview in the preview table, skip
$qb->delete('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($file->getId())))
->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId())
->executeStatement();
continue;
}
try {
$this->storageFactory->migratePreview($preview, $file);
$qb = $this->connection->getQueryBuilder();
$qb->delete('filecache')
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($file->getId())))
->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId())
->executeStatement();
// Do not call $file->delete() as this will also delete the file from the file system
} catch (\Exception $e) {
@ -184,35 +193,51 @@ class MovePreviewJob extends TimedJob {
throw $e;
}
}
} else {
// No matching fileId, delete preview
try {
$this->connection->beginTransaction();
foreach ($previewFiles as $previewFile) {
/** @var SimpleFile $file */
$file = $previewFile['file'];
$file->delete();
}
$this->connection->commit();
} catch (Exception) {
$this->connection->rollback();
}
}
$this->deleteFolder($internalPath, $folder);
$this->deleteFolder($internalPath);
}
public static function getInternalFolder(string $name, bool $simplePaths): string {
if ($simplePaths) {
return '/' . $name;
public static function getInternalFolder(string $name, bool $flatPath): string {
if ($flatPath) {
return $name;
}
return implode('/', str_split(substr(md5($name), 0, 7))) . '/' . $name;
}
private function deleteFolder(string $path, ISimpleFolder $folder): void {
$folder->delete();
private function deleteFolder(string $path): void {
$current = $path;
while (true) {
$appDataPath = $this->previewRootPath . $current;
$qb = $this->connection->getQueryBuilder();
$qb->delete('filecache')
->where($qb->expr()->eq('path_hash', $qb->createNamedParameter(md5($appDataPath))))
->hintShardKey('storage', $this->rootFolder->getMountPoint()->getNumericStorageId())
->executeStatement();
$current = dirname($current);
if ($current === '/' || $current === '.' || $current === '') {
break;
}
$folder = $this->appData->getFolder($current);
if (count($folder->getDirectoryListing()) !== 0) {
break;
}
$folder->delete();
}
}
}

@ -10,7 +10,6 @@ namespace OC\Core\Command\Preview;
use OC\Preview\Db\Preview;
use OC\Preview\PreviewService;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IAvatarManager;
@ -28,7 +27,6 @@ class ResetRenderedTexts extends Command {
protected readonly IUserManager $userManager,
protected readonly IAvatarManager $avatarManager,
private readonly PreviewService $previewService,
private readonly IMimeTypeLoader $mimeTypeLoader,
) {
parent::__construct();
}
@ -93,7 +91,7 @@ class ResetRenderedTexts extends Command {
$previewsToDeleteCount = 0;
foreach ($this->getPreviewsToDelete() as $preview) {
$output->writeln('Deleting preview ' . $preview->getName($this->mimeTypeLoader) . ' for fileId ' . $preview->getFileId(), OutputInterface::VERBOSITY_VERBOSE);
$output->writeln('Deleting preview ' . $preview->getName() . ' for fileId ' . $preview->getFileId(), OutputInterface::VERBOSITY_VERBOSE);
$previewsToDeleteCount++;
@ -112,9 +110,9 @@ class ResetRenderedTexts extends Command {
*/
private function getPreviewsToDelete(): \Generator {
return $this->previewService->getPreviewsForMimeTypes([
$this->mimeTypeLoader->getId('text/plain'),
$this->mimeTypeLoader->getId('text/markdown'),
$this->mimeTypeLoader->getId('text/x-markdown'),
'text/plain',
'text/markdown',
'text/x-markdown'
]);
}
}

@ -37,6 +37,14 @@ class Version33000Date20250819110529 extends SimpleMigrationStep {
$table->setPrimaryKey(['id']);
}
if (!$schema->hasTable('preview_versions')) {
$table = $schema->createTable('preview_versions');
$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('version', Types::STRING, ['notnull' => true, 'default' => '', 'length' => 1024]);
$table->setPrimaryKey(['id']);
}
if (!$schema->hasTable('previews')) {
$table = $schema->createTable('previews');
$table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'length' => 20, 'unsigned' => true]);
@ -46,18 +54,18 @@ class Version33000Date20250819110529 extends SimpleMigrationStep {
$table->addColumn('location_id', Types::BIGINT, ['notnull' => false, 'length' => 20, 'unsigned' => true]);
$table->addColumn('width', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
$table->addColumn('height', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
$table->addColumn('mimetype', Types::INTEGER, ['notnull' => true]);
$table->addColumn('source_mimetype', Types::INTEGER, ['notnull' => true]);
$table->addColumn('mimetype_id', Types::INTEGER, ['notnull' => true]);
$table->addColumn('source_mimetype_id', Types::INTEGER, ['notnull' => true]);
$table->addColumn('max', Types::BOOLEAN, ['notnull' => true, 'default' => false]);
$table->addColumn('cropped', Types::BOOLEAN, ['notnull' => true, 'default' => false]);
$table->addColumn('encrypted', Types::BOOLEAN, ['notnull' => true, 'default' => false]);
$table->addColumn('etag', Types::STRING, ['notnull' => true, 'length' => 40, 'fixed' => true]);
$table->addColumn('mtime', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
$table->addColumn('size', Types::INTEGER, ['notnull' => true, 'unsigned' => true]);
$table->addColumn('version', Types::BIGINT, ['notnull' => true, 'default' => -1]); // can not be null otherwise unique index doesn't work
$table->addColumn('version_id', Types::BIGINT, ['notnull' => true, 'default' => -1]);
$table->setPrimaryKey(['id']);
$table->addIndex(['file_id']);
$table->addUniqueIndex(['file_id', 'width', 'height', 'mimetype', 'cropped', 'version'], 'previews_file_uniq_idx');
$table->addUniqueIndex(['file_id', 'width', 'height', 'mimetype_id', 'cropped', 'version_id'], 'previews_file_uniq_idx');
}
return $schema;

@ -10,6 +10,7 @@ namespace OC\Files\Cache;
use OCP\IConfig;
use OCP\Server;
use Override;
class LocalRootScanner extends Scanner {
private string $previewFolder;
@ -20,6 +21,7 @@ class LocalRootScanner extends Scanner {
$this->previewFolder = 'appdata_' . $config->getSystemValueString('instanceid', '') . '/preview';
}
#[Override]
public function scanFile($file, $reuseExisting = 0, $parentId = -1, $cacheData = null, $lock = true, $data = null) {
if ($this->shouldScanPath($file)) {
return parent::scanFile($file, $reuseExisting, $parentId, $cacheData, $lock, $data);
@ -28,6 +30,7 @@ class LocalRootScanner extends Scanner {
}
}
#[Override]
public function scan($path, $recursive = self::SCAN_RECURSIVE, $reuse = -1, $lock = true) {
if ($this->shouldScanPath($path)) {
return parent::scan($path, $recursive, $reuse, $lock);
@ -36,11 +39,16 @@ class LocalRootScanner extends Scanner {
}
}
private function shouldScanPath(string $path): bool {
$path = trim($path, '/');
#[Override]
protected function scanChildren(string $path, $recursive, int $reuse, int $folderId, bool $lock, int|float $oldSize, &$etagChanged = false) {
if (str_starts_with($path, $this->previewFolder)) {
return false;
return 0;
}
return parent::scanChildren($path, $recursive, $reuse, $folderId, $lock, $oldSize, $etagChanged);
}
private function shouldScanPath(string $path): bool {
$path = trim($path, '/');
return $path === '' || str_starts_with($path, 'appdata_') || str_starts_with($path, '__groupfolders');
}
}

@ -35,7 +35,7 @@ class BackgroundCleanupJob extends TimedJob {
public function run($argument): void {
foreach ($this->getDeletedFiles() as $fileId) {
$previewIds = [];
foreach ($this->previewService->getAvailablePreviewForFile($fileId) as $preview) {
foreach ($this->previewService->getAvailablePreviewsForFile($fileId) as $preview) {
$this->previewService->deletePreview($preview);
}
}

@ -13,8 +13,6 @@ namespace OC\Preview\Db;
use OCP\AppFramework\Db\Entity;
use OCP\DB\Types;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\IMimeTypeLoader;
use OCP\Server;
/**
* Preview entity mapped to the oc_previews and oc_preview_locations table.
@ -35,10 +33,10 @@ use OCP\Server;
* @method void setHeight(int $height)
* @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 void setSourceMimetype(int $sourceMimetype) Set the mimetype of the source file.
* @method int getSourceMimetype() Get the mimetype of the source file.
* @method void setMimetypeId(int $mimetype) Set the mimetype of the preview.
* @method int getMimetypeId() Get the mimetype of the preview.
* @method void setSourceMimetypeId(int $sourceMimetype) Set the mimetype of the source file.
* @method int getSourceMimetypeId() Get the mimetype of the source file.
* @method int getMtime() Get the modification time of the preview.
* @method void setMtime(int $mtime)
* @method int getSize() Get the size of the preview.
@ -47,8 +45,8 @@ use OCP\Server;
* @method void setMax(bool $max)
* @method string getEtag() Get the etag of the preview.
* @method void setEtag(string $etag)
* @method int|null getVersion() Get the version for files_versions_s3
* @method void setVersion(?int $version)
* @method string|null getVersion() Get the version for files_versions_s3
* @method void setVersionId(int $versionId)
* @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)
@ -64,15 +62,17 @@ class Preview extends Entity {
protected ?string $objectStoreName = null;
protected ?int $width = null;
protected ?int $height = null;
protected ?int $mimetype = null;
protected ?int $sourceMimetype = null;
protected ?int $mimetypeId = null;
protected ?int $sourceMimetypeId = null;
protected string $mimetype = 'application/octet-stream';
protected string $sourceMimetype = 'application/octet-stream';
protected ?int $mtime = null;
protected ?int $size = null;
protected ?bool $max = null;
protected ?bool $cropped = null;
protected ?string $etag = null;
protected ?int $version = null;
protected ?string $version = null;
protected ?int $versionId = null;
protected ?bool $encrypted = null;
public function __construct() {
@ -82,23 +82,23 @@ class Preview extends Entity {
$this->addType('locationId', Types::BIGINT);
$this->addType('width', Types::INTEGER);
$this->addType('height', Types::INTEGER);
$this->addType('mimetype', Types::INTEGER);
$this->addType('sourceMimetype', Types::INTEGER);
$this->addType('mimetypeId', Types::INTEGER);
$this->addType('sourceMimetypeId', Types::INTEGER);
$this->addType('mtime', Types::INTEGER);
$this->addType('size', Types::INTEGER);
$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);
$this->addType('versionId', Types::STRING);
}
public static function fromPath(string $path, IMimeTypeDetector $mimeTypeDetector, IMimeTypeLoader $mimeTypeLoader): Preview|false {
public static function fromPath(string $path, IMimeTypeDetector $mimeTypeDetector): Preview|false {
$preview = new self();
$preview->setFileId((int)basename(dirname($path)));
$fileName = pathinfo($path, PATHINFO_FILENAME) . '.' . pathinfo($path, PATHINFO_EXTENSION);
$ok = preg_match('/(([0-9]+)-)?([0-9]+)-([0-9]+)(-(max))?(-(crop))?\.([a-z]{3,4})/', $fileName, $matches);
$ok = preg_match('/(([A-Za-z0-9\+\/]+)-)?([0-9]+)-([0-9]+)(-(max))?(-(crop))?\.([a-z]{3,4})/', $fileName, $matches);
if ($ok !== 1) {
return false;
@ -108,11 +108,11 @@ class Preview extends Entity {
2 => $version,
3 => $width,
4 => $height,
6 => $crop,
8 => $max,
6 => $max,
8 => $crop,
] = $matches;
$preview->setMimetype($mimeTypeLoader->getId($mimeTypeDetector->detectPath($fileName)));
$preview->setMimeType($mimeTypeDetector->detectPath($fileName));
$preview->setWidth((int)$width);
$preview->setHeight((int)$height);
@ -120,12 +120,12 @@ class Preview extends Entity {
$preview->setMax($max === 'max');
if (!empty($version)) {
$preview->setVersion((int)$version);
$preview->setVersion($version);
}
return $preview;
}
public function getName(IMimeTypeLoader $mimeTypeLoader): string {
public function getName(): string {
$path = ($this->getVersion() > -1 ? $this->getVersion() . '-' : '') . $this->getWidth() . '-' . $this->getHeight();
if ($this->isCropped()) {
$path .= '-crop';
@ -134,13 +134,13 @@ class Preview extends Entity {
$path .= '-max';
}
$ext = $this->getExtension($mimeTypeLoader);
$ext = $this->getExtension();
$path .= '.' . $ext;
return $path;
}
public function getExtension(IMimeTypeLoader $mimeTypeLoader): string {
return match ($this->getMimetypeValue($mimeTypeLoader)) {
public function getExtension(): string {
return match ($this->getMimeType()) {
'image/png' => 'png',
'image/gif' => 'gif',
'image/jpeg' => 'jpg',
@ -149,10 +149,6 @@ class Preview extends Entity {
};
}
public function getMimetypeValue(IMimeTypeLoader $mimeTypeLoader): string {
return $mimeTypeLoader->getMimetypeById($this->mimetype) ?? 'image/jpeg';
}
public function setBucketName(string $bucketName): void {
$this->bucketName = $bucketName;
}
@ -160,4 +156,24 @@ class Preview extends Entity {
public function setObjectStoreName(string $objectStoreName): void {
$this->objectStoreName = $objectStoreName;
}
public function setVersion(?string $version): void {
$this->version = $version;
}
public function getMimeType(): string {
return $this->mimetype;
}
public function setMimeType(string $mimeType): void {
$this->mimetype = $mimeType;
}
public function getSourceMimeType(): string {
return $this->sourceMimetype;
}
public function setSourceMimeType(string $mimeType): void {
$this->sourceMimetype = $mimeType;
}
}

@ -9,13 +9,13 @@ declare(strict_types=1);
namespace OC\Preview\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\IMimeTypeLoader;
use OCP\IDBConnection;
use OCP\IPreview;
use Override;
/**
* @template-extends QBMapper<Preview>
@ -24,18 +24,74 @@ class PreviewMapper extends QBMapper {
private const TABLE_NAME = 'previews';
private const LOCATION_TABLE_NAME = 'preview_locations';
private const VERSION_TABLE_NAME = 'preview_versions';
public function __construct(
IDBConnection $db,
private readonly IMimeTypeLoader $mimeTypeLoader,
) {
parent::__construct($db, self::TABLE_NAME, Preview::class);
}
protected function mapRowToEntity(array $row): Entity {
$row['mimetype'] = $this->mimeTypeLoader->getMimetypeById((int)$row['mimetype_id']);
$row['source_mimetype'] = $this->mimeTypeLoader->getMimetypeById((int)$row['source_mimetype_id']);
return parent::mapRowToEntity($row);
}
#[Override]
public function insert(Entity $entity): Entity {
/** @var Preview $preview */
$preview = $entity;
$preview->setMimetypeId($this->mimeTypeLoader->getId($preview->getMimeType()));
$preview->setSourceMimetypeId($this->mimeTypeLoader->getId($preview->getSourceMimeType()));
if ($preview->getVersion() !== null && $preview->getVersion() !== '') {
$qb = $this->db->getQueryBuilder();
$qb->insert(self::VERSION_TABLE_NAME)
->values([
'version' => $preview->getVersion(),
'file_id' => $preview->getFileId(),
])
->executeStatement();
$entity->setVersionId($qb->getLastInsertId());
}
return parent::insert($preview);
}
#[Override]
public function update(Entity $entity): Entity {
/** @var Preview $preview */
$preview = $entity;
$preview->setMimetypeId($this->mimeTypeLoader->getId($preview->getMimeType()));
$preview->setSourceMimetypeId($this->mimeTypeLoader->getId($preview->getSourceMimeType()));
return parent::update($preview);
}
#[Override]
public function delete(Entity $entity): Entity {
/** @var Preview $preview */
$preview = $entity;
if ($preview->getVersion() !== null && $preview->getVersion() !== '') {
$qb = $this->db->getQueryBuilder();
$qb->delete(self::VERSION_TABLE_NAME)
->where($qb->expr()->eq('file_id', $qb->createNamedParameter($preview->getFileId())))
->andWhere($qb->expr()->eq('version', $qb->createNamedParameter($preview->getVersion())))
->executeStatement();
}
return parent::delete($entity);
}
/**
* @return \Generator<Preview>
* @throws Exception
*/
public function getAvailablePreviewForFile(int $fileId): \Generator {
public function getAvailablePreviewsForFile(int $fileId): \Generator {
$selectQb = $this->db->getQueryBuilder();
$this->joinLocation($selectQb)
->where($selectQb->expr()->eq('p.file_id', $selectQb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
@ -82,10 +138,13 @@ class PreviewMapper extends QBMapper {
}
protected function joinLocation(IQueryBuilder $qb): IQueryBuilder {
return $qb->select('p.*', 'l.bucket_name', 'l.object_store_name')
return $qb->select('p.*', 'l.bucket_name', 'l.object_store_name', 'v.version')
->from(self::TABLE_NAME, 'p')
->leftJoin('p', 'preview_locations', 'l', $qb->expr()->eq(
->leftJoin('p', self::LOCATION_TABLE_NAME, 'l', $qb->expr()->eq(
'p.location_id', 'l.id'
))
->leftJoin('p', self::VERSION_TABLE_NAME, 'v', $qb->expr()->eq(
'p.version_id', 'v.id'
));
}
@ -127,15 +186,15 @@ class PreviewMapper extends QBMapper {
}
/**
* @param int[] $mimeTypes
* @param string[] $mimeTypes
* @return \Generator<Preview>
*/
public function getPreviewsForMimeTypes(array $mimeTypes): \Generator {
$qb = $this->db->getQueryBuilder();
$this->joinLocation($qb)
->where($qb->expr()->orX(
...array_map(function (int $mimeType) use ($qb) {
return $qb->expr()->eq('source_mimetype', $qb->createNamedParameter($mimeType, IQueryBuilder::PARAM_INT));
...array_map(function (string $mimeType) use ($qb): string {
return $qb->expr()->eq('source_mimetype_id', $qb->createNamedParameter($this->mimeTypeLoader->getId($mimeType), IQueryBuilder::PARAM_INT));
}, $mimeTypes)
));
return $this->yieldEntities($qb);

@ -12,7 +12,6 @@ use OC\Preview\Storage\PreviewFile;
use OC\Preview\Storage\StorageFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
@ -39,7 +38,6 @@ class Generator {
private LoggerInterface $logger,
private PreviewMapper $previewMapper,
private StorageFactory $storageFactory,
private IMimeTypeLoader $mimeTypeLoader,
) {
}
@ -111,9 +109,9 @@ class Generator {
[$file->getId() => $previews] = $this->previewMapper->getAvailablePreviews([$file->getId()]);
$previewVersion = -1;
$previewVersion = null;
if ($file instanceof IVersionedPreviewFile) {
$previewVersion = (int)$file->getPreviewVersion();
$previewVersion = $file->getPreviewVersion();
}
// Get the max preview and infer the max preview sizes from that
@ -152,7 +150,7 @@ class Generator {
// No need to generate a preview that is just the max preview
if ($width === $maxWidth && $height === $maxHeight) {
// ensure correct return value if this was the last one
$previewFile = new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper, $this->mimeTypeLoader);
$previewFile = new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper);
continue;
}
@ -163,14 +161,14 @@ class Generator {
&& $preview->getVersion() === $previewVersion && $preview->isCropped() === $crop);
if ($preview) {
$previewFile = new PreviewFile($preview, $this->storageFactory, $this->previewMapper, $this->mimeTypeLoader);
$previewFile = new PreviewFile($preview, $this->storageFactory, $this->previewMapper);
} else {
if (!$this->previewManager->isMimeSupported($mimeType)) {
throw new NotFoundException();
}
if ($maxPreviewImage === null) {
$maxPreviewImage = $this->helper->getImage(new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper, $this->mimeTypeLoader));
$maxPreviewImage = $this->helper->getImage(new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper));
}
$this->logger->debug('Cached preview not found for file {path}, generating a new preview.', ['path' => $file->getPath()]);
@ -298,7 +296,7 @@ class Generator {
* @param Preview[] $previews
* @throws NotFoundException
*/
private function getMaxPreview(array $previews, File $file, string $mimeType, int $version): Preview {
private function getMaxPreview(array $previews, File $file, string $mimeType, ?string $version): Preview {
// 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) {
@ -313,7 +311,7 @@ class Generator {
return $this->generateProviderPreview($file, $maxWidth, $maxHeight, false, true, $mimeType, $version);
}
private function generateProviderPreview(File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, int $version): Preview {
private function generateProviderPreview(File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, ?string $version): Preview {
$previewProviders = $this->previewManager->getProviders();
foreach ($previewProviders as $supportedMimeType => $providers) {
// Filter out providers that does not support this mime
@ -353,14 +351,14 @@ class Generator {
$previewEntry = new Preview();
$previewEntry->setFileId($file->getId());
$previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId());
$previewEntry->setSourceMimetype($this->mimeTypeLoader->getId($file->getMimeType()));
$previewEntry->setSourceMimeType($file->getMimeType());
$previewEntry->setWidth($preview->width());
$previewEntry->setHeight($preview->height());
$previewEntry->setVersion($version);
$previewEntry->setMax($max);
$previewEntry->setCropped($crop);
$previewEntry->setEncrypted(false);
$previewEntry->setMimetype($this->mimeTypeLoader->getId($preview->dataMimeType()));
$previewEntry->setMimetype($preview->dataMimeType());
$previewEntry->setEtag($file->getEtag());
$previewEntry->setMtime((new \DateTime())->getTimestamp());
$previewEntry->setSize(0);
@ -468,7 +466,7 @@ class Generator {
bool $crop,
int $maxWidth,
int $maxHeight,
?int $version,
?string $version,
bool $cacheResult,
): ISimpleFile {
$preview = $maxPreview;
@ -508,21 +506,21 @@ class Generator {
$previewEntry->setFileId($file->getId());
$previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId());
$previewEntry->setWidth($width);
$previewEntry->setSourceMimetype($this->mimeTypeLoader->getId($file->getMimeType()));
$previewEntry->setSourceMimeType($file->getMimeType());
$previewEntry->setHeight($height);
$previewEntry->setVersion($version);
$previewEntry->setMax(false);
$previewEntry->setCropped($crop);
$previewEntry->setEncrypted(false);
$previewEntry->setMimetype($this->mimeTypeLoader->getId($preview->dataMimeType()));
$previewEntry->setMimeType($preview->dataMimeType());
$previewEntry->setEtag($file->getEtag());
$previewEntry->setMtime((new \DateTime())->getTimestamp());
$previewEntry->setSize(0);
if ($cacheResult) {
$previewEntry = $this->savePreview($previewEntry, $preview);
return new PreviewFile($previewEntry, $this->storageFactory, $this->previewMapper, $this->mimeTypeLoader);
return new PreviewFile($previewEntry, $this->storageFactory, $this->previewMapper);
} else {
return new InMemoryFile($previewEntry->getName($this->mimeTypeLoader), $preview->data());
return new InMemoryFile($previewEntry->getName(), $preview->data());
}
}
@ -540,7 +538,10 @@ class Generator {
if ($preview instanceof IStreamImage) {
$size = $this->storageFactory->writePreview($previewEntry, $preview->resource());
} else {
$size = $this->storageFactory->writePreview($previewEntry, $preview->data());
$stream = fopen('php://temp', 'w+');
fwrite($stream, $preview->data());
rewind($stream);
$size = $this->storageFactory->writePreview($previewEntry, $stream);
}
if (!$size) {
throw new \RuntimeException('Unable to write preview file');

@ -70,12 +70,12 @@ class PreviewService {
/**
* @return \Generator<Preview>
*/
public function getAvailablePreviewForFile(int $fileId): \Generator {
return $this->previewMapper->getAvailablePreviewForFile($fileId);
public function getAvailablePreviewsForFile(int $fileId): \Generator {
return $this->previewMapper->getAvailablePreviewsForFile($fileId);
}
/**
* @param int[] $mimeTypes
* @param string[] $mimeTypes
* @return \Generator<Preview>
*/
public function getPreviewsForMimeTypes(array $mimeTypes): \Generator {

@ -10,32 +10,44 @@ declare(strict_types=1);
namespace OC\Preview\Storage;
use Exception;
use OC\Files\SimpleFS\SimpleFile;
use OC\Preview\Db\Preview;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
interface IPreviewStorage {
/**
* @param resource|string $stream
* @param resource $stream
* @throws NotPermittedException
* @throws NotFoundException
*/
public function writePreview(Preview $preview, mixed $stream): false|int;
public function writePreview(Preview $preview, mixed $stream): int;
/**
* @param Preview $preview
* @return resource|false
* @return resource
* @throws NotPermittedException
* @throws NotFoundException
*/
public function readPreview(Preview $preview): mixed;
/**
* @throws NotPermittedException
*/
public function deletePreview(Preview $preview): void;
/**
* Migration helper
*
* To remove at some point
* @throw \Exception
* @throws Exception
*/
public function migratePreview(Preview $preview, SimpleFile $file): void;
/**
* @throws NotPermittedException
* @throws NotFoundException
*/
public function scan(): int;
}

@ -17,7 +17,8 @@ use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OCP\DB\Exception;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IDBConnection;
@ -33,10 +34,8 @@ class LocalPreviewStorage implements IPreviewStorage {
public function __construct(
private readonly IConfig $config,
private readonly PreviewMapper $previewMapper,
private readonly StorageFactory $previewStorage,
private readonly IAppConfig $appConfig,
private readonly IDBConnection $connection,
private readonly IMimeTypeLoader $mimeTypeLoader,
private readonly IMimeTypeDetector $mimeTypeDetector,
private readonly LoggerInterface $logger,
) {
@ -45,22 +44,28 @@ class LocalPreviewStorage implements IPreviewStorage {
}
#[Override]
public function writePreview(Preview $preview, mixed $stream): false|int {
public function writePreview(Preview $preview, mixed $stream): int {
$previewPath = $this->constructPath($preview);
if (!$this->createParentFiles($previewPath)) {
return false;
}
$this->createParentFiles($previewPath);
return file_put_contents($previewPath, $stream);
}
#[Override]
public function readPreview(Preview $preview): mixed {
return @fopen($this->constructPath($preview), 'r');
$previewPath = $this->constructPath($preview);
$resource = @fopen($previewPath, 'r');
if ($resource === false) {
throw new NotFoundException('Unable to open preview stream at ' . $previewPath);
}
return $resource;
}
#[Override]
public function deletePreview(Preview $preview): void {
@unlink($this->constructPath($preview));
$previewPath = $this->constructPath($preview);
if (!@unlink($previewPath) && is_file($previewPath)) {
throw new NotPermittedException('Unable to delete preview at ' . $previewPath);
}
}
public function getPreviewRootFolder(): string {
@ -68,19 +73,21 @@ class LocalPreviewStorage implements IPreviewStorage {
}
private function constructPath(Preview $preview): string {
return $this->getPreviewRootFolder() . implode('/', str_split(substr(md5((string)$preview->getFileId()), 0, 7))) . '/' . $preview->getFileId() . '/' . $preview->getName($this->mimeTypeLoader);
return $this->getPreviewRootFolder() . implode('/', str_split(substr(md5((string)$preview->getFileId()), 0, 7))) . '/' . $preview->getFileId() . '/' . $preview->getName();
}
private function createParentFiles(string $path): bool {
private function createParentFiles(string $path): void {
$dirname = dirname($path);
@mkdir($dirname, recursive: true);
return is_dir($dirname);
if (!is_dir($dirname)) {
throw new NotPermittedException("Unable to create directory '$dirname'");
}
}
#[Override]
public function migratePreview(Preview $preview, SimpleFile $file): void {
// legacy flat directory
$sourcePath = $this->getPreviewRootFolder() . $preview->getFileId() . '/' . $preview->getName($this->mimeTypeLoader);
$sourcePath = $this->getPreviewRootFolder() . $preview->getFileId() . '/' . $preview->getName();
if (!file_exists($sourcePath)) {
return;
}
@ -106,7 +113,7 @@ class LocalPreviewStorage implements IPreviewStorage {
$previewsFound = 0;
foreach (new RecursiveIteratorIterator($scanner) as $file) {
if ($file->isFile()) {
$preview = Preview::fromPath((string)$file, $this->mimeTypeDetector, $this->mimeTypeLoader);
$preview = Preview::fromPath((string)$file, $this->mimeTypeDetector);
if ($preview === false) {
$this->logger->error('Unable to parse preview information for ' . $file->getRealPath());
continue;

@ -15,6 +15,7 @@ use OC\Files\ObjectStore\PrimaryObjectStoreConfig;
use OC\Files\SimpleFS\SimpleFile;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OCP\Files\NotPermittedException;
use OCP\Files\ObjectStore\IObjectStore;
use OCP\IConfig;
use Override;
@ -41,15 +42,7 @@ class ObjectStorePreviewStorage implements IPreviewStorage {
}
#[Override]
public function writePreview(Preview $preview, mixed $stream): false|int {
if (!is_resource($stream)) {
$fh = fopen('php://temp', 'w+');
fwrite($fh, $stream);
rewind($fh);
$stream = $fh;
}
public function writePreview(Preview $preview, mixed $stream): int {
$size = 0;
$countStream = CountWrapper::wrap($stream, function (int $writtenSize) use (&$size): void {
$size = $writtenSize;
@ -61,7 +54,11 @@ class ObjectStorePreviewStorage implements IPreviewStorage {
'config' => $config,
] = $this->getObjectStoreForPreview($preview);
$store->writeObject($this->constructUrn($objectPrefix, $preview->getId()), $countStream);
try {
$store->writeObject($this->constructUrn($objectPrefix, $preview->getId()), $countStream);
} catch (\Exception $exception) {
throw new NotPermittedException('Unable to save preview to object store', previous: $exception);
}
return $size;
}
@ -71,7 +68,11 @@ class ObjectStorePreviewStorage implements IPreviewStorage {
'objectPrefix' => $objectPrefix,
'store' => $store,
] = $this->getObjectStoreForPreview($preview);
return $store->readObject($this->constructUrn($objectPrefix, $preview->getId()));
try {
return $store->readObject($this->constructUrn($objectPrefix, $preview->getId()));
} catch (\Exception $exception) {
throw new NotPermittedException('Unable to read preview from object store', previous: $exception);
}
}
#[Override]
@ -80,7 +81,11 @@ class ObjectStorePreviewStorage implements IPreviewStorage {
'objectPrefix' => $objectPrefix,
'store' => $store,
] = $this->getObjectStoreForPreview($preview);
$store->deleteObject($this->constructUrn($objectPrefix, $preview->getId()));
try {
$store->deleteObject($this->constructUrn($objectPrefix, $preview->getId()));
} catch (\Exception $exception) {
throw new NotPermittedException('Unable to delete preview from object store', previous: $exception);
}
}
#[Override]

@ -12,7 +12,6 @@ namespace OC\Preview\Storage;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\SimpleFS\ISimpleFile;
use Override;
@ -21,13 +20,12 @@ class PreviewFile implements ISimpleFile {
private readonly Preview $preview,
private readonly IPreviewStorage $storage,
private readonly PreviewMapper $previewMapper,
private readonly IMimeTypeLoader $mimeTypeLoader,
) {
}
#[Override]
public function getName(): string {
return $this->preview->getName($this->mimeTypeLoader);
return $this->preview->getName();
}
#[Override]
@ -63,12 +61,12 @@ class PreviewFile implements ISimpleFile {
#[Override]
public function getMimeType(): string {
return $this->preview->getMimetypeValue($this->mimeTypeLoader);
return $this->preview->getMimetype();
}
#[Override]
public function getExtension(): string {
return $this->preview->getExtension($this->mimeTypeLoader);
return $this->preview->getExtension();
}
#[Override]

@ -25,7 +25,7 @@ class StorageFactory implements IPreviewStorage {
}
#[Override]
public function writePreview(Preview $preview, mixed $stream): false|int {
public function writePreview(Preview $preview, mixed $stream): int {
return $this->getBackend()->writePreview($preview, $stream);
}

@ -13,6 +13,7 @@ use OC\Preview\Storage\StorageFactory;
use OCP\Files\FileInfo;
use OCP\Files\Folder;
use OCP\Files\Node;
use OCP\IDBConnection;
/**
* Class Watcher
@ -26,8 +27,9 @@ class Watcher {
* Watcher constructor.
*/
public function __construct(
readonly private StorageFactory $storageFactory,
readonly private PreviewMapper $previewMapper,
private readonly StorageFactory $storageFactory,
private readonly PreviewMapper $previewMapper,
private readonly IDBConnection $connection,
) {
}
@ -47,8 +49,14 @@ class Watcher {
}
[$node->getId() => $previews] = $this->previewMapper->getAvailablePreviews([$nodeId]);
foreach ($previews as $preview) {
$this->storageFactory->deletePreview($preview);
$this->connection->beginTransaction();
try {
foreach ($previews as $preview) {
$this->storageFactory->deletePreview($preview);
$this->previewMapper->delete($preview);
}
} finally {
$this->connection->commit();
}
}

@ -16,7 +16,6 @@ use OC\Preview\Storage\StorageFactory;
use OCP\AppFramework\QueryException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
@ -140,7 +139,6 @@ class PreviewManager implements IPreview {
$this->container->get(LoggerInterface::class),
$this->container->get(PreviewMapper::class),
$this->container->get(StorageFactory::class),
$this->container->get(IMimeTypeLoader::class),
);
}
return $this->generator;

@ -308,6 +308,7 @@ class Server extends ServerContainer implements IServerContainer {
return new Watcher(
$c->get(\OC\Preview\Storage\StorageFactory::class),
$c->get(PreviewMapper::class),
$c->get(IDBConnection::class),
);
});

@ -18,8 +18,7 @@ namespace OCP\Preview;
*/
interface IVersionedPreviewFile {
/**
* @return numeric
* @since 17.0.0
*/
public function getPreviewVersion();
public function getPreviewVersion(): string;
}

@ -74,7 +74,7 @@ class BackgroundCleanupJobTest extends \Test\TestCase {
$this->logout();
foreach ($this->previewService->getAvailablePreviewForFile(5) as $preview) {
foreach ($this->previewService->getAvailablePreviewsForFile(5) as $preview) {
$this->previewService->deletePreview($preview);
}

@ -14,7 +14,6 @@ use OC\Preview\GeneratorHelper;
use OC\Preview\Storage\StorageFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\Mount\IMountPoint;
use OCP\Files\NotFoundException;
use OCP\IConfig;
@ -22,10 +21,17 @@ use OCP\IImage;
use OCP\IPreview;
use OCP\Preview\BeforePreviewFetchedEvent;
use OCP\Preview\IProviderV2;
use OCP\Preview\IVersionedPreviewFile;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
abstract class VersionedPreviewFile implements IVersionedPreviewFile, File {
}
class GeneratorTest extends TestCase {
private IConfig&MockObject $config;
private IPreview&MockObject $previewManager;
@ -35,7 +41,6 @@ class GeneratorTest extends TestCase {
private LoggerInterface&MockObject $logger;
private StorageFactory&MockObject $storageFactory;
private PreviewMapper&MockObject $previewMapper;
private IMimeTypeLoader&MockObject $mimeTypeLoader;
protected function setUp(): void {
parent::setUp();
@ -47,12 +52,6 @@ class GeneratorTest extends TestCase {
$this->logger = $this->createMock(LoggerInterface::class);
$this->previewMapper = $this->createMock(PreviewMapper::class);
$this->storageFactory = $this->createMock(StorageFactory::class);
$this->mimeTypeLoader = $this->createMock(IMimeTypeLoader::class);
$this->mimeTypeLoader->method('getId')
->willReturnCallback(fn ($mimeType) => $mimeType === 'image/png' ? 42 : 43);
$this->mimeTypeLoader->method('getMimetypeById')
->with(42)
->willReturn('image/png');
$this->generator = new Generator(
$this->config,
@ -62,14 +61,18 @@ class GeneratorTest extends TestCase {
$this->logger,
$this->previewMapper,
$this->storageFactory,
$this->mimeTypeLoader,
);
}
private function getFile(int $fileId, string $mimeType): File {
private function getFile(int $fileId, string $mimeType, bool $hasVersion = false): File {
$mountPoint = $this->createMock(IMountPoint::class);
$mountPoint->method('getNumericStorageId')->willReturn(42);
$file = $this->createMock(File::class);
if ($hasVersion) {
$file = $this->createMock(VersionedPreviewFile::class);
$file->method('getPreviewVersion')->willReturn('abc');
} else {
$file = $this->createMock(File::class);
}
$file->method('isReadable')
->willReturn(true);
$file->method('getMimeType')
@ -81,8 +84,10 @@ class GeneratorTest extends TestCase {
return $file;
}
public function testGetCachedPreview(): void {
$file = $this->getFile(42, 'myMimeType');
#[TestWith([true])]
#[TestWith([false])]
public function testGetCachedPreview(bool $hasPreview): void {
$file = $this->getFile(42, 'myMimeType', $hasPreview);
$this->previewManager->method('isMimeSupported')
->with($this->equalTo('myMimeType'))
@ -93,20 +98,20 @@ class GeneratorTest extends TestCase {
$maxPreview->setHeight(1000);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(-1);
$maxPreview->setCropped(false);
$maxPreview->setStorageId(1);
$maxPreview->setMimetype($this->mimeTypeLoader->getId('image/png'));
$maxPreview->setVersion($hasPreview ? 'abc' : null);
$maxPreview->setMimeType('image/png');
$previewFile = new Preview();
$previewFile->setWidth(256);
$previewFile->setHeight(256);
$previewFile->setMax(false);
$previewFile->setSize(1000);
$previewFile->setVersion(-1);
$previewFile->setVersion($hasPreview ? 'abc' : null);
$previewFile->setCropped(false);
$previewFile->setStorageId(1);
$previewFile->setMimetype($this->mimeTypeLoader->getId('image/png'));
$previewFile->setMimeType('image/png');
$this->previewMapper->method('getAvailablePreviews')
->with($this->equalTo([42]))
@ -120,12 +125,14 @@ class GeneratorTest extends TestCase {
->with(new BeforePreviewFetchedEvent($file, 100, 100, false, IPreview::MODE_FILL, null));
$result = $this->generator->getPreview($file, 100, 100);
$this->assertSame('256-256.png', $result->getName());
$this->assertSame($hasPreview ? 'abc-256-256.png' : '256-256.png', $result->getName());
$this->assertSame(1000, $result->getSize());
}
public function testGetNewPreview(): void {
$file = $this->getFile(42, 'myMimeType');
#[TestWith([true])]
#[TestWith([false])]
public function testGetNewPreview(bool $hasVersion): void {
$file = $this->getFile(42, 'myMimeType', $hasVersion);
$this->previewManager->method('isMimeSupported')
->with($this->equalTo('myMimeType'))
@ -196,13 +203,6 @@ class GeneratorTest extends TestCase {
$image->method('data')
->willReturn('my data');
$maxPreview = new Preview();
$maxPreview->setWidth(2048);
$maxPreview->setHeight(2048);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setMimetype($this->mimeTypeLoader->getId('image/png'));
$this->previewMapper->method('insert')
->willReturnCallback(fn (Preview $preview): Preview => $preview);
@ -210,16 +210,28 @@ class GeneratorTest extends TestCase {
->willReturnCallback(fn (Preview $preview): Preview => $preview);
$this->storageFactory->method('writePreview')
->willReturnCallback(function (Preview $preview, string $data): int {
switch ($preview->getName($this->mimeTypeLoader)) {
case '2048-2048-max.png':
$this->assertSame('my data', $data);
return 1000;
case '256-256.png':
$this->assertSame('my resized data', $data);
return 1000;
->willReturnCallback(function (Preview $preview, mixed $data) use ($hasVersion): int {
$data = stream_get_contents($data);
if ($hasVersion) {
switch ($preview->getName()) {
case 'abc-2048-2048-max.png':
$this->assertSame('my data', $data);
return 1000;
case 'abc-256-256.png':
$this->assertSame('my resized data', $data);
return 1000;
}
} else {
switch ($preview->getName()) {
case '2048-2048-max.png':
$this->assertSame('my data', $data);
return 1000;
case '256-256.png':
$this->assertSame('my resized data', $data);
return 1000;
}
}
$this->fail('file name is wrong:' . $preview->getName($this->mimeTypeLoader));
$this->fail('file name is wrong:' . $preview->getName());
});
$image = $this->getMockImage(2048, 2048, 'my resized data');
@ -231,7 +243,7 @@ class GeneratorTest extends TestCase {
->with(new BeforePreviewFetchedEvent($file, 100, 100, false, IPreview::MODE_FILL, null));
$result = $this->generator->getPreview($file, 100, 100);
$this->assertSame('256-256.png', $result->getName());
$this->assertSame($hasVersion ? 'abc-256-256.png' : '256-256.png', $result->getName());
$this->assertSame(1000, $result->getSize());
}
@ -249,8 +261,8 @@ class GeneratorTest extends TestCase {
$maxPreview->setHeight(2048);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(-1);
$maxPreview->setMimetype(42);
$maxPreview->setVersion(null);
$maxPreview->setMimetype('image/png');
$this->previewMapper->method('getAvailablePreviews')
->with($this->equalTo([42]))
@ -273,8 +285,8 @@ class GeneratorTest extends TestCase {
$maxPreview->setHeight(2048);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(-1);
$maxPreview->setMimetype($this->mimeTypeLoader->getId('image/png'));
$maxPreview->setVersion(null);
$maxPreview->setMimeType('image/png');
$previewFile = new Preview();
$previewFile->setWidth(1024);
@ -282,8 +294,8 @@ class GeneratorTest extends TestCase {
$previewFile->setMax(false);
$previewFile->setSize(1000);
$previewFile->setCropped(true);
$previewFile->setVersion(-1);
$previewFile->setMimetype($this->mimeTypeLoader->getId('image/png'));
$previewFile->setVersion(null);
$previewFile->setMimeType('image/png');
$this->previewMapper->method('getAvailablePreviews')
->with($this->equalTo([42]))
@ -321,7 +333,7 @@ class GeneratorTest extends TestCase {
$this->generator->getPreview($file, 100, 100);
}
private function getMockImage(int $width, int $height, $data = null) {
private function getMockImage(int $width, int $height, string $data = '') {
$image = $this->createMock(IImage::class);
$image->method('height')->willReturn($width);
$image->method('width')->willReturn($height);
@ -378,7 +390,7 @@ class GeneratorTest extends TestCase {
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataSize')]
#[DataProvider('dataSize')]
public function testCorrectSize(int $maxX, int $maxY, int $reqX, int $reqY, bool $crop, string $mode, int $expectedX, int $expectedY): void {
$file = $this->getFile(42, 'myMimeType');
@ -391,11 +403,11 @@ class GeneratorTest extends TestCase {
$maxPreview->setHeight($maxY);
$maxPreview->setMax(true);
$maxPreview->setSize(1000);
$maxPreview->setVersion(-1);
$maxPreview->setMimetype(42);
$maxPreview->setVersion(null);
$maxPreview->setMimeType('image/png');
$this->assertSame($maxPreview->getName($this->mimeTypeLoader), $maxX . '-' . $maxY . '-max.png');
$this->assertSame($maxPreview->getMimetypeValue($this->mimeTypeLoader), 'image/png');
$this->assertSame($maxPreview->getName(), $maxX . '-' . $maxY . '-max.png');
$this->assertSame($maxPreview->getMimeType(), 'image/png');
$this->previewMapper->method('getAvailablePreviews')
->with($this->equalTo([42]))
@ -415,7 +427,7 @@ class GeneratorTest extends TestCase {
$this->previewMapper->method('insert')
->willReturnCallback(function (Preview $preview) use ($filename): Preview {
$this->assertSame($preview->getName($this->mimeTypeLoader), $filename);
$this->assertSame($preview->getName(), $filename);
return $preview;
});
@ -431,7 +443,7 @@ class GeneratorTest extends TestCase {
$result = $this->generator->getPreview($file, $reqX, $reqY, $crop, $mode);
if ($expectedX === $maxX && $expectedY === $maxY) {
$this->assertSame($maxPreview->getName($this->mimeTypeLoader), $result->getName());
$this->assertSame($maxPreview->getName(), $result->getName());
} else {
$this->assertSame($filename, $result->getName());
}

@ -21,6 +21,7 @@ use OCP\Files\IMimeTypeDetector;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\IRootFolder;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\Server;
use PHPUnit\Framework\Attributes\TestDox;
@ -35,6 +36,7 @@ class MovePreviewJobTest extends TestCase {
private IAppData $previewAppData;
private PreviewMapper $previewMapper;
private IAppConfig&MockObject $appConfig;
private IConfig $config;
private StorageFactory $storageFactory;
private PreviewService $previewService;
private IDBConnection $db;
@ -46,6 +48,7 @@ class MovePreviewJobTest extends TestCase {
parent::setUp();
$this->previewAppData = Server::get(IAppDataFactory::class)->get('preview');
$this->previewMapper = Server::get(PreviewMapper::class);
$this->config = Server::get(IConfig::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->appConfig->expects($this->any())
->method('getValueBool')
@ -71,7 +74,7 @@ class MovePreviewJobTest extends TestCase {
'path_hash' => $qb->createNamedParameter(md5('test')),
'parent' => $qb->createNamedParameter(0),
'name' => $qb->createNamedParameter('abc'),
'mimetype' => $qb->createNamedParameter(0),
'mimetype' => $qb->createNamedParameter(42),
'size' => $qb->createNamedParameter(1000),
'mtime' => $qb->createNamedParameter(1000),
'storage_mtime' => $qb->createNamedParameter(1000),
@ -86,6 +89,7 @@ class MovePreviewJobTest extends TestCase {
$this->mimeTypeDetector->method('detectPath')->willReturn('image/png');
$this->mimeTypeLoader = $this->createMock(IMimeTypeLoader::class);
$this->mimeTypeLoader->method('getId')->with('image/png')->willReturn(42);
$this->mimeTypeLoader->method('getMimetypeById')->with(42)->willReturn('image/png');
$this->logger = $this->createMock(LoggerInterface::class);
}
@ -108,11 +112,12 @@ class MovePreviewJobTest extends TestCase {
$folder->newFile('128-128-crop.png', 'abcdefg');
$this->assertEquals(1, count($this->previewAppData->getDirectoryListing()));
$this->assertEquals(2, count($folder->getDirectoryListing()));
$this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5))));
$this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5))));
$job = new MovePreviewJob(
Server::get(ITimeFactory::class),
$this->appConfig,
$this->config,
$this->previewMapper,
$this->storageFactory,
Server::get(IDBConnection::class),
@ -124,7 +129,7 @@ class MovePreviewJobTest extends TestCase {
);
$this->invokePrivate($job, 'run', [[]]);
$this->assertEquals(0, count($this->previewAppData->getDirectoryListing()));
$this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5))));
$this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5))));
}
private static function getInternalFolder(string $name): string {
@ -139,11 +144,12 @@ class MovePreviewJobTest extends TestCase {
$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))));
$this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5))));
$job = new MovePreviewJob(
Server::get(ITimeFactory::class),
$this->appConfig,
$this->config,
$this->previewMapper,
$this->storageFactory,
Server::get(IDBConnection::class),
@ -155,7 +161,7 @@ class MovePreviewJobTest extends TestCase {
);
$this->invokePrivate($job, 'run', [[]]);
$this->assertEquals(0, count($this->previewAppData->getDirectoryListing()));
$this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5))));
$this->assertEquals(2, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5))));
}
#[TestDox("Test the migration from the 'new' nested hierarchy to the database format")]
@ -178,11 +184,12 @@ class MovePreviewJobTest extends TestCase {
$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))));
$this->assertEquals(0, count(iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5))));
$job = new MovePreviewJob(
Server::get(ITimeFactory::class),
$this->appConfig,
$this->config,
$this->previewMapper,
$this->storageFactory,
Server::get(IDBConnection::class),
@ -193,25 +200,25 @@ class MovePreviewJobTest extends TestCase {
Server::get(IAppDataFactory::class)
);
$this->invokePrivate($job, 'run', [[]]);
$this->assertEquals(0, count($this->previewAppData->getDirectoryListing()));
$previews = iterator_to_array($this->previewMapper->getAvailablePreviewForFile(5));
$previews = iterator_to_array($this->previewMapper->getAvailablePreviewsForFile(5));
$this->assertEquals(9, count($previews));
$this->assertEquals(0, count($this->previewAppData->getDirectoryListing()));
$nameVersionMapping = [];
foreach ($previews as $preview) {
$nameVersionMapping[$preview->getName()] = $preview->getVersion();
$nameVersionMapping[$preview->getName($this->mimeTypeLoader)] = $preview->getVersion();
}
$this->assertEquals([
'1000-128-128.png' => 1000,
'1000-128-128-crop.png' => 1000,
'1000-128-128.png' => 1000,
'1000-256-256-max.png' => 1000,
'1001-128-128.png' => 1001,
'1001-128-128-crop.png' => 1001,
'1001-128-128.png' => 1001,
'1001-256-256-max.png' => 1001,
'128-128.png' => -1,
'128-128-crop.png' => -1,
'256-256-max.png' => -1,
'128-128-crop.png' => null,
'128-128.png' => null,
'256-256-max.png' => null,
], $nameVersionMapping);
}
}

@ -12,9 +12,7 @@ namespace Test\Preview;
use OC\Preview\Db\Preview;
use OC\Preview\Db\PreviewMapper;
use OCP\Files\IMimeTypeLoader;
use OCP\IDBConnection;
use OCP\IPreview;
use OCP\Server;
use Test\TestCase;
@ -24,12 +22,10 @@ use Test\TestCase;
class PreviewMapperTest extends TestCase {
private PreviewMapper $previewMapper;
private IDBConnection $connection;
private IMimeTypeLoader $mimeTypeLoader;
public function setUp(): void {
$this->previewMapper = Server::get(PreviewMapper::class);
$this->connection = Server::get(IDBConnection::class);
$this->mimeTypeLoader = Server::get(IMimeTypeLoader::class);
}
public function testGetAvailablePreviews(): void {
@ -71,11 +67,11 @@ class PreviewMapperTest extends TestCase {
$preview->setCropped(true);
$preview->setMax(true);
$preview->setWidth(100);
$preview->setSourceMimetype(1);
$preview->setSourceMimeType('image/jpeg');
$preview->setHeight(100);
$preview->setSize(100);
$preview->setMtime(time());
$preview->setMimetype($this->mimeTypeLoader->getId('image/jpeg'));
$preview->setMimetype('image/jpeg');
$preview->setEtag('abcdefg');
if ($locationId !== null) {

@ -13,7 +13,6 @@ 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;
@ -24,6 +23,7 @@ use PHPUnit\Framework\TestCase;
#[CoversClass(PreviewService::class)]
class PreviewServiceTest extends TestCase {
private PreviewService $previewService;
private PreviewMapper $previewMapper;
protected function setUp(): void {
$this->previewService = Server::get(PreviewService::class);
@ -43,10 +43,10 @@ class PreviewServiceTest extends TestCase {
$preview->setWidth($i);
$preview->setHeight($i);
$preview->setMax(true);
$preview->setSourceMimetype(1);
$preview->setSourceMimeType('image/jpeg');
$preview->setCropped(true);
$preview->setEncrypted(false);
$preview->setMimetype(42);
$preview->setMimetype('image/jpeg');
$preview->setEtag('abc');
$preview->setMtime((new \DateTime())->getTimestamp());
$preview->setSize(0);