Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e40dd2a
fix(FileListener): Process file events asynchronously
marcelklehr Dec 15, 2025
c0d40dd
fix: run cs:fix and fix psalm issues
marcelklehr Dec 15, 2025
8ca821e
fix: Fix psalm issues
marcelklehr Dec 15, 2025
d6e4f25
fix: Choose shorter pk index name
marcelklehr Dec 15, 2025
0e5f2e5
refactor: Process all fs events asynchronously
marcelklehr Dec 16, 2025
a8fb342
fix: Fix psalm issues and run cs:fix
marcelklehr Dec 16, 2025
19f4aeb
fix: Fix file listener tests
marcelklehr Dec 16, 2025
200a469
fix: Fix FsActionmapper
marcelklehr Dec 17, 2025
6e442b2
fix: Fix phpunit
marcelklehr Dec 17, 2025
69429c3
Fix: Remove old event listeners
marcelklehr Dec 17, 2025
82f7850
Fix: Update test bootstrap code
marcelklehr Dec 17, 2025
3b2f850
Fix: Fix tests by re-registering fs hooks
marcelklehr Dec 18, 2025
4411763
tests: Try to fix tests by enabling files_external app
marcelklehr Dec 18, 2025
82d555b
tests: add debug logs
marcelklehr Dec 18, 2025
693994f
tests: Clear trashbin before running tests
marcelklehr Dec 18, 2025
8de39a0
fix(FileListener): Do not consider events in trashbin
marcelklehr Dec 18, 2025
88fc3ce
fix: Appease linters
marcelklehr Dec 18, 2025
bac8ee2
fix: Update psalm baseline
marcelklehr Dec 18, 2025
8684d31
tests: Add --verbose flag to cron run and make sure deletion is actua…
marcelklehr Dec 18, 2025
e1b1377
fix(FsActionService): Pass userId to bg job to allow finding nodes vi…
marcelklehr Dec 18, 2025
481b0d1
fix: Update psalm baseline
marcelklehr Dec 18, 2025
d710e38
tests(cluster-faces): Add `occ upgrade` command in case it's needed
marcelklehr Dec 18, 2025
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
61 changes: 61 additions & 0 deletions lib/BackgroundJobs/ProcessAccessUpdatesJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/*
* Copyright (c) 2021-2022 The Recognize contributors.
* This file is licensed under the Affero General Public License version 3 or later. See the COPYING file.
*/
declare(strict_types=1);
namespace OCA\Recognize\BackgroundJobs;

use OCA\Recognize\Classifiers\Audio\MusicnnClassifier;
use OCA\Recognize\Classifiers\Images\ClusteringFaceClassifier;
use OCA\Recognize\Classifiers\Images\ImagenetClassifier;
use OCA\Recognize\Classifiers\Images\LandmarksClassifier;
use OCA\Recognize\Classifiers\Video\MovinetClassifier;
use OCA\Recognize\Db\AccessUpdateMapper;
use OCA\Recognize\Db\QueueFile;
use OCA\Recognize\Service\AccessUpdateService;
use OCA\Recognize\Service\Logger;
use OCA\Recognize\Service\QueueService;
use OCA\Recognize\Service\StorageService;
use OCA\Recognize\Service\TagManager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\BackgroundJob\QueuedJob;
use OCP\DB\Exception;
use Psr\Log\LoggerInterface;

final class ProcessAccessUpdatesJob extends QueuedJob {

public function __construct(
ITimeFactory $timeFactory,
private AccessUpdateService $accessUpdateService,
private IJobList $jobList,
private AccessUpdateMapper $accessUpdateMapper,
private LoggerInterface $logger,
) {
parent::__construct($timeFactory);
}

/**
* @param array{storage_id:int} $argument
* @return void
*/
protected function run($argument): void {
$storageId = $argument['storage_id'];

$this->accessUpdateService->processAccessUpdates($storageId);
try {
$count = $this->accessUpdateMapper->countByStorageId($storageId);
} catch (Exception $e) {
$this->logger->error('Failed to count access updates' . $e->getMessage(), ['exception' => $e]);
$count = 1;
}
if ($count > 0) {
// Schedule next iteration
$this->jobList->add(self::class, [
'storage_id' => $storageId,
]);
}
}
}
49 changes: 49 additions & 0 deletions lib/Db/AccessUpdate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/*
* Copyright (c) 2022 The Recognize contributors.
* This file is licensed under the Affero General Public License version 3 or later. See the COPYING file.
*/
declare(strict_types=1);
namespace OCA\Recognize\Db;

use OCP\AppFramework\Db\Entity;

/**
* Class AccessUpdate
*
* @package OCA\Recognize\Db
* @method int getStorageId()
* @method setStorageId(int $storageId)
* @method int getRootId()
* @method setRootId(int $rootId)
*/
final class AccessUpdate extends Entity {
protected $storageId;

Check failure on line 22 in lib/Db/AccessUpdate.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MissingPropertyType

lib/Db/AccessUpdate.php:22:12: MissingPropertyType: Property OCA\Recognize\Db\AccessUpdate::$storageId does not have a declared type (see https://psalm.dev/045)
protected $rootId;

Check failure on line 23 in lib/Db/AccessUpdate.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MissingPropertyType

lib/Db/AccessUpdate.php:23:12: MissingPropertyType: Property OCA\Recognize\Db\AccessUpdate::$rootId does not have a declared type (see https://psalm.dev/045)

/**
* @var string[]
*/
public static array $columns = ['id', 'storage_id', 'root_id'];

/**
* @var string[]
*/
public static array $fields = ['id', 'storageId', 'rootId'];

public function __construct() {
// add types in constructor
$this->addType('id', 'integer');
$this->addType('storageId', 'integer');
$this->addType('rootId', 'integer');
}

public function toArray(): array {
$array = [];
foreach (self::$fields as $field) {
$array[$field] = $this->{$field};

Check failure on line 45 in lib/Db/AccessUpdate.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MixedAssignment

lib/Db/AccessUpdate.php:45:4: MixedAssignment: Unable to determine the type of this assignment (see https://psalm.dev/032)
}
return $array;
}
}
103 changes: 103 additions & 0 deletions lib/Db/AccessUpdateMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

/*
* Copyright (c) 2022 The Recognize contributors.
* This file is licensed under the Affero General Public License version 3 or later. See the COPYING file.
*/
declare(strict_types=1);
namespace OCA\Recognize\Db;

use OCA\Recognize\BackgroundJobs\ProcessAccessUpdatesJob;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
use OCP\BackgroundJob\IJobList;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;

/**
* @psalm-extends QBMapper<AccessUpdate>
*/
final class AccessUpdateMapper extends QBMapper {

Check failure on line 22 in lib/Db/AccessUpdateMapper.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MissingTemplateParam

lib/Db/AccessUpdateMapper.php:22:13: MissingTemplateParam: OCA\Recognize\Db\AccessUpdateMapper has missing template params when extending OCP\AppFramework\Db\QBMapper, expecting 1 (see https://psalm.dev/182)

Check failure on line 22 in lib/Db/AccessUpdateMapper.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidDocblock

lib/Db/AccessUpdateMapper.php:22:13: InvalidDocblock: Unrecognised annotation @psalm-extends in docblock for OCA\Recognize\Db\AccessUpdateMapper (see https://psalm.dev/008)

Check failure on line 22 in lib/Db/AccessUpdateMapper.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidDocblock

lib/Db/AccessUpdateMapper.php:22:1: InvalidDocblock: Unrecognised annotation @psalm-extends (see https://psalm.dev/008)
public function __construct(
IDBConnection $db,
private IJobList $jobList,
) {
parent::__construct($db, 'recognize_access_updates', AccessUpdate::class);
$this->db = $db;
}

/**
* @throws \OCP\DB\Exception
* @return list<\OCA\Recognize\Db\AccessUpdate>

Check failure on line 33 in lib/Db/AccessUpdateMapper.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MoreSpecificReturnType

lib/Db/AccessUpdateMapper.php:33:13: MoreSpecificReturnType: The declared return type 'list<OCA\Recognize\Db\AccessUpdate>' for OCA\Recognize\Db\AccessUpdateMapper::findByStorageId is more specific than the inferred return type 'list<OCP\AppFramework\Db\Entity>' (see https://psalm.dev/070)
*/
public function findByStorageId(int $storageId, int $limit = 0): array {
$qb = $this->db->getQueryBuilder();
$qb->selectDistinct(AccessUpdate::$columns)
->from($this->getTableName())
->where($qb->expr()->eq('storage_id', $qb->createPositionalParameter($storageId, IQueryBuilder::PARAM_INT)));
if ($limit > 0) {
$qb->setMaxResults($limit);
}
return $this->findEntities($qb);
}

/**
* @param int $storageId
* @return int
* @throws Exception
*/
public function countByStorageId(int $storageId): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('id'))
->from($this->getTableName())
->where($qb->expr()->eq('storage_id', $qb->createPositionalParameter($storageId, IQueryBuilder::PARAM_INT)));
$result = $qb->executeQuery();
$count = $result->fetchOne();
$result->closeCursor();
if ($count === false) {
return 0;
}
return $count;
}

/**
* @param int $storageId
* @param int $rootId
* @return AccessUpdate
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws Exception
*/
public function findByStorageIdAndRootId(int $storageId, int $rootId): AccessUpdate {
$qb = $this->db->getQueryBuilder();
$qb->selectDistinct(AccessUpdate::$columns)
->from($this->getTableName())
->where($qb->expr()->eq('storage_id', $qb->createPositionalParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('root_id', $qb->createPositionalParameter($rootId, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}

/**
* @param int $storageId
* @param int $rootId
* @return AccessUpdate
* @throws Exception
* @throws MultipleObjectsReturnedException
*/
public function insertAccessUpdate(int $storageId, int $rootId): AccessUpdate {
try {
$accessUpdate = $this->findByStorageIdAndRootId($storageId, $rootId);
} catch (DoesNotExistException $e) {
$accessUpdate = new AccessUpdate();
$accessUpdate->setStorageId($storageId);
$accessUpdate->setRootId($rootId);
$this->insert($accessUpdate);
if (!$this->jobList->has(ProcessAccessUpdatesJob::class, [ 'storage_id' => $storageId ])) {
$this->jobList->add(self::class, [ 'storage_id' => $storageId ]);
}
}
return $accessUpdate;
}
}
96 changes: 6 additions & 90 deletions lib/Hooks/FileListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@
use OCA\Recognize\Classifiers\Images\ImagenetClassifier;
use OCA\Recognize\Classifiers\Video\MovinetClassifier;
use OCA\Recognize\Constants;
use OCA\Recognize\Db\AccessUpdateMapper;
use OCA\Recognize\Db\FaceDetectionMapper;
use OCA\Recognize\Db\QueueFile;
use OCA\Recognize\Service\IgnoreService;
use OCA\Recognize\Service\QueueService;
use OCA\Recognize\Service\StorageService;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\BackgroundJob\IJobList;
use OCP\DB\Exception;
use OCP\EventDispatcher\Event;
Expand All @@ -38,8 +40,6 @@
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\IGroupManager;
use OCP\Share\Events\ShareCreatedEvent;
use OCP\Share\Events\ShareDeletedEvent;
use OCP\Share\IManager;
use Psr\Log\LoggerInterface;

Expand All @@ -61,12 +61,10 @@ public function __construct(
private LoggerInterface $logger,
private QueueService $queue,
private IgnoreService $ignoreService,
private StorageService $storageService,
private IRootFolder $rootFolder,
private IUserMountCache $userMountCache,
private IManager $shareManager,
private IGroupManager $groupManager,
private IJobList $jobList,
private AccessUpdateMapper $accessUpdateMapper,
) {
$this->movingFromIgnoredTerritory = null;
$this->movingDirFromIgnoredTerritory = null;
Expand Down Expand Up @@ -110,60 +108,7 @@ public function handle(Event $event): void {
// Asynchronous, because we potentially recurse and this event needs to be handled fast
$this->onAccessUpdate($event->mountPoint->getStorageId(), $rootId);
}

if ($event instanceof ShareCreatedEvent) {
$share = $event->getShare();
$ownerId = $share->getShareOwner();
$node = $share->getNode();
$userIds = $this->getUsersWithFileAccess($node->getId());

if ($node->getType() === FileInfo::TYPE_FOLDER) {
$mount = $node->getMountPoint();
if ($mount->getNumericStorageId() === null) {
return;
}
$files = $this->storageService->getFilesInMount($mount->getNumericStorageId(), $node->getId(), [ClusteringFaceClassifier::MODEL_NAME], 0, 0);
foreach ($files as $fileInfo) {
foreach ($userIds as $userId) {
if ($userId === $ownerId) {
continue;
}
if (count($this->faceDetectionMapper->findByFileIdAndUser($node->getId(), $userId)) > 0) {
continue;
}
$this->faceDetectionMapper->copyDetectionsForFileFromUserToUser($fileInfo['fileid'], $ownerId, $userId);
}
}
} else {
foreach ($userIds as $userId) {
if ($userId === $ownerId) {
continue;
}
if (count($this->faceDetectionMapper->findByFileIdAndUser($node->getId(), $userId)) > 0) {
continue;
}
$this->faceDetectionMapper->copyDetectionsForFileFromUserToUser($node->getId(), $ownerId, $userId);
}
}
}
if ($event instanceof ShareDeletedEvent) {
$share = $event->getShare();
$node = $share->getNode();
$userIds = $this->getUsersWithFileAccess($node->getId());

if ($node->getType() === FileInfo::TYPE_FOLDER) {
$mount = $node->getMountPoint();
if ($mount->getNumericStorageId() === null) {
return;
}
$files = $this->storageService->getFilesInMount($mount->getNumericStorageId(), $node->getId(), [ClusteringFaceClassifier::MODEL_NAME], 0, 0);
foreach ($files as $fileInfo) {
$this->faceDetectionMapper->removeDetectionsForFileFromUsersNotInList($fileInfo['fileid'], $userIds);
}
} else {
$this->faceDetectionMapper->removeDetectionsForFileFromUsersNotInList($node->getId(), $userIds);
}
}

if ($event instanceof BeforeNodeRenamedEvent) {
$this->movingFromIgnoredTerritory = null;
$this->movingDirFromIgnoredTerritory = [];
Expand Down Expand Up @@ -537,39 +482,10 @@ private function resetIgnoreCache(Node $node) : void {
}

/**
* @throws NotFoundException
* @throws InvalidPathException
* @throws Exception
* @throws MultipleObjectsReturnedException
*/
private function onAccessUpdate(int $storageId, int $rootId): void {
$userIds = $this->getUsersWithFileAccess($rootId);
$files = $this->storageService->getFilesInMount($storageId, $rootId, [ClusteringFaceClassifier::MODEL_NAME], 0, 0);
$userIdsToScheduleClustering = [];
foreach ($files as $fileInfo) {
$node = current($this->rootFolder->getById($fileInfo['fileid'])) ?: null;
$ownerId = $node?->getOwner()?->getUID();
if ($ownerId === null) {
continue;
}
$detectionsForFile = $this->faceDetectionMapper->findByFileId($fileInfo['fileid']);
$userHasDetectionForFile = [];
foreach ($detectionsForFile as $detection) {
$userHasDetectionForFile[$detection->getUserId()] = true;
}
foreach ($userIds as $userId) {
if ($userId === $ownerId) {
continue;
}
if ($userHasDetectionForFile[$userId] ?? false) {
continue;
}
$this->faceDetectionMapper->copyDetectionsForFileFromUserToUser($fileInfo['fileid'], $ownerId, $userId);
$userIdsToScheduleClustering[$userId] = true;
}
$this->faceDetectionMapper->removeDetectionsForFileFromUsersNotInList($fileInfo['fileid'], $userIds);
}
foreach (array_keys($userIdsToScheduleClustering) as $userId) {
$this->jobList->add(ClusterFacesJob::class, ['userId' => $userId]);
}
$this->accessUpdateMapper->insertAccessUpdate($storageId, $rootId);
}
}
42 changes: 42 additions & 0 deletions lib/Migration/Version011000001Date20251215094821.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

/*
* Copyright (c) 2020-2025 The Recognize contributors.
* This file is licensed under the Affero General Public License version 3 or later. See the COPYING file.
*/
declare(strict_types=1);
namespace OCA\Recognize\Migration;

use Closure;
use Doctrine\DBAL\Schema\SchemaException;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

final class Version011000001Date20251215094821 extends SimpleMigrationStep {

/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*
* @return ?ISchemaWrapper
* @throws SchemaException
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

$changed = false;
if (!$schema->hasTable('recognize_access_updates')) {
$table = $schema->createTable('recognize_access_updates');
$table->addColumn('id', Types::BIGINT, ['autoincrement' => true]);
$table->addColumn('storage_id', Types::BIGINT, ['notnull' => true]);
$table->addColumn('root_id', Types::BIGINT, ['notnull' => true]);
$table->addUniqueIndex(['storage_id', 'root_id'], 'recognize_au_unique');
$changed = true;
}
return $changed ? $schema : null;
}
}
Loading
Loading