From 1a1ba661126beab51fa400517104e6bf683818a8 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 13 May 2026 09:59:27 +0200 Subject: [PATCH 01/24] feat(TaskProcessing): Create task types Signed-off-by: Marcel Klehr --- .../AudioClassificationTaskType.php | 72 +++++++++++++++++++ .../ImageClassificationTaskType.php | 72 +++++++++++++++++++ .../ImageFaceRecognitionTaskType.php | 72 +++++++++++++++++++ .../VideoClassificationTaskType.php | 72 +++++++++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 lib/TaskProcessing/AudioClassificationTaskType.php create mode 100644 lib/TaskProcessing/ImageClassificationTaskType.php create mode 100644 lib/TaskProcessing/ImageFaceRecognitionTaskType.php create mode 100644 lib/TaskProcessing/VideoClassificationTaskType.php diff --git a/lib/TaskProcessing/AudioClassificationTaskType.php b/lib/TaskProcessing/AudioClassificationTaskType.php new file mode 100644 index 00000000..2a816204 --- /dev/null +++ b/lib/TaskProcessing/AudioClassificationTaskType.php @@ -0,0 +1,72 @@ +l->t('Audio classification'); + } + + /** + * @inheritDoc + */ + public function getDescription(): string { + return $this->l->t('Classify audios into categories.'); + } + + /** + * @return string + */ + public function getId(): string { + return self::ID; + } + + /** + * @return ShapeDescriptor[] + */ + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Audios'), + $this->l->t('Provide audios to classify'), + EShapeType::ListOfAudios, + ), + ]; + } + + /** + * @return ShapeDescriptor[] + */ + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Categories'), + $this->l->t('The classified categories. Each input audio is mapped to a text containing a comma separated list of categories.'), + EShapeType::ListOfTexts, + ), + ]; + } +} diff --git a/lib/TaskProcessing/ImageClassificationTaskType.php b/lib/TaskProcessing/ImageClassificationTaskType.php new file mode 100644 index 00000000..17c7cab4 --- /dev/null +++ b/lib/TaskProcessing/ImageClassificationTaskType.php @@ -0,0 +1,72 @@ +l->t('Image classification'); + } + + /** + * @inheritDoc + */ + public function getDescription(): string { + return $this->l->t('Classify images into categories.'); + } + + /** + * @return string + */ + public function getId(): string { + return self::ID; + } + + /** + * @return ShapeDescriptor[] + */ + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Images'), + $this->l->t('Provide images to classify'), + EShapeType::ListOfImages, + ), + ]; + } + + /** + * @return ShapeDescriptor[] + */ + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Categories'), + $this->l->t('The classified categories. Each input image is mapped to a text containing a comma separated list of categories.'), + EShapeType::ListOfTexts, + ), + ]; + } +} diff --git a/lib/TaskProcessing/ImageFaceRecognitionTaskType.php b/lib/TaskProcessing/ImageFaceRecognitionTaskType.php new file mode 100644 index 00000000..1cb71b01 --- /dev/null +++ b/lib/TaskProcessing/ImageFaceRecognitionTaskType.php @@ -0,0 +1,72 @@ +l->t('Image face recognition'); + } + + /** + * @inheritDoc + */ + public function getDescription(): string { + return $this->l->t('Recognize faces in images and return embedding vectors for each face.'); + } + + /** + * @return string + */ + public function getId(): string { + return self::ID; + } + + /** + * @return ShapeDescriptor[] + */ + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Images'), + $this->l->t('Provide images to recognize faces in'), + EShapeType::ListOfImages, + ), + ]; + } + + /** + * @return ShapeDescriptor[] + */ + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Faces'), + $this->l->t('The detected faces. Each input image is mapped to a text containing JSON-encoded embedding vectors separated by line breaks.'), + EShapeType::ListOfTexts, + ), + ]; + } +} diff --git a/lib/TaskProcessing/VideoClassificationTaskType.php b/lib/TaskProcessing/VideoClassificationTaskType.php new file mode 100644 index 00000000..ea01d38d --- /dev/null +++ b/lib/TaskProcessing/VideoClassificationTaskType.php @@ -0,0 +1,72 @@ +l->t('Video classification'); + } + + /** + * @inheritDoc + */ + public function getDescription(): string { + return $this->l->t('Classify videos into categories.'); + } + + /** + * @return string + */ + public function getId(): string { + return self::ID; + } + + /** + * @return ShapeDescriptor[] + */ + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Videos'), + $this->l->t('Provide videos to classify'), + EShapeType::ListOfVideos, + ), + ]; + } + + /** + * @return ShapeDescriptor[] + */ + public function getOutputShape(): array { + return [ + 'output' => new ShapeDescriptor( + $this->l->t('Categories'), + $this->l->t('The classified categories. Each input video is mapped to a text containing a comma separated list of categories.'), + EShapeType::ListOfTexts, + ), + ]; + } +} From 8f81d8dbb8fe1d2270b3947eef8860a135e5b1e6 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 13 May 2026 11:18:40 +0200 Subject: [PATCH 02/24] fix: Fix psalm issue Signed-off-by: Marcel Klehr --- lib/TaskProcessing/AudioClassificationTaskType.php | 2 +- lib/TaskProcessing/ImageClassificationTaskType.php | 2 +- lib/TaskProcessing/ImageFaceRecognitionTaskType.php | 2 +- lib/TaskProcessing/VideoClassificationTaskType.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/TaskProcessing/AudioClassificationTaskType.php b/lib/TaskProcessing/AudioClassificationTaskType.php index 2a816204..8d348c92 100644 --- a/lib/TaskProcessing/AudioClassificationTaskType.php +++ b/lib/TaskProcessing/AudioClassificationTaskType.php @@ -15,7 +15,7 @@ use OCP\TaskProcessing\ITaskType; use OCP\TaskProcessing\ShapeDescriptor; -class AudioClassificationTaskType implements ITaskType { +final class AudioClassificationTaskType implements ITaskType { public const ID = Application::APP_ID . ':audio:classification'; public function __construct( diff --git a/lib/TaskProcessing/ImageClassificationTaskType.php b/lib/TaskProcessing/ImageClassificationTaskType.php index 17c7cab4..83bc938b 100644 --- a/lib/TaskProcessing/ImageClassificationTaskType.php +++ b/lib/TaskProcessing/ImageClassificationTaskType.php @@ -15,7 +15,7 @@ use OCP\TaskProcessing\ITaskType; use OCP\TaskProcessing\ShapeDescriptor; -class ImageClassificationTaskType implements ITaskType { +final class ImageClassificationTaskType implements ITaskType { public const ID = Application::APP_ID . ':image:classification'; public function __construct( diff --git a/lib/TaskProcessing/ImageFaceRecognitionTaskType.php b/lib/TaskProcessing/ImageFaceRecognitionTaskType.php index 1cb71b01..b018380f 100644 --- a/lib/TaskProcessing/ImageFaceRecognitionTaskType.php +++ b/lib/TaskProcessing/ImageFaceRecognitionTaskType.php @@ -15,7 +15,7 @@ use OCP\TaskProcessing\ITaskType; use OCP\TaskProcessing\ShapeDescriptor; -class ImageFaceRecognitionTaskType implements ITaskType { +final class ImageFaceRecognitionTaskType implements ITaskType { public const ID = Application::APP_ID . ':image:facerecognition'; public function __construct( diff --git a/lib/TaskProcessing/VideoClassificationTaskType.php b/lib/TaskProcessing/VideoClassificationTaskType.php index ea01d38d..0f820309 100644 --- a/lib/TaskProcessing/VideoClassificationTaskType.php +++ b/lib/TaskProcessing/VideoClassificationTaskType.php @@ -15,7 +15,7 @@ use OCP\TaskProcessing\ITaskType; use OCP\TaskProcessing\ShapeDescriptor; -class VideoClassificationTaskType implements ITaskType { +final class VideoClassificationTaskType implements ITaskType { public const ID = Application::APP_ID . ':video:classification'; public function __construct( From aaee5c8c33fd3ae1b109561f4994534bff0a44dc Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 22 Jun 2026 15:40:00 +0200 Subject: [PATCH 03/24] feat: Implement TaskProcessing glue code Assisted-by: ClaudeCode:claude-opus-4-7 Signed-off-by: Marcel Klehr --- lib/AppInfo/Application.php | 6 + .../Classifiers/AbstractClassifier.php | 110 ++++++++ .../Classifiers/AudioClassifier.php | 23 ++ .../Classifiers/ImageClassifier.php | 23 ++ .../ImageFaceRecognitionClassifier.php | 23 ++ .../Classifiers/VideoClassifier.php | 23 ++ .../ImageFaceRecognitionTaskType.php | 2 +- lib/TaskProcessing/TaskResultListener.php | 267 ++++++++++++++++++ 8 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 lib/TaskProcessing/Classifiers/AbstractClassifier.php create mode 100644 lib/TaskProcessing/Classifiers/AudioClassifier.php create mode 100644 lib/TaskProcessing/Classifiers/ImageClassifier.php create mode 100644 lib/TaskProcessing/Classifiers/ImageFaceRecognitionClassifier.php create mode 100644 lib/TaskProcessing/Classifiers/VideoClassifier.php create mode 100644 lib/TaskProcessing/TaskResultListener.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index ff23f55e..3106ea12 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -11,6 +11,7 @@ use OCA\DAV\Events\SabrePluginAddEvent; use OCA\Recognize\Dav\Faces\PropFindPlugin; use OCA\Recognize\Hooks\FileListener; +use OCA\Recognize\TaskProcessing\TaskResultListener; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -23,6 +24,8 @@ use OCP\Files\Events\Node\NodeDeletedEvent; use OCP\Files\Events\Node\NodeRenamedEvent; use OCP\Files\Events\NodeRemovedFromCache; +use OCP\TaskProcessing\Events\TaskFailedEvent; +use OCP\TaskProcessing\Events\TaskSuccessfulEvent; final class Application extends App implements IBootstrap { public const APP_ID = 'recognize'; @@ -44,6 +47,9 @@ public function __construct() { $dispatcher->addServiceListener('OCP\Files\Config\Event\UserMountRemovedEvent', FileListener::class); // it is not fired as of now, Added and Removed events are fired instead in that order // $context->addServiceListener('OCP\Files\Config\Event\UserMountUpdatedEvent', FileListener::class); + + $dispatcher->addServiceListener(TaskSuccessfulEvent::class, TaskResultListener::class); + $dispatcher->addServiceListener(TaskFailedEvent::class, TaskResultListener::class); } public function register(IRegistrationContext $context): void { diff --git a/lib/TaskProcessing/Classifiers/AbstractClassifier.php b/lib/TaskProcessing/Classifiers/AbstractClassifier.php new file mode 100644 index 00000000..81df7253 --- /dev/null +++ b/lib/TaskProcessing/Classifiers/AbstractClassifier.php @@ -0,0 +1,110 @@ + $queueFiles + */ + public function classify(array $queueFiles): void { + if (count($queueFiles) === 0) { + return; + } + + $storageId = $queueFiles[0]->getStorageId(); + $rootId = $queueFiles[0]->getRootId(); + $userId = $this->findUserForStorage($storageId, $rootId); + if ($userId === null) { + $this->logger->warning('No user with access for storage ' . $storageId . '/' . $rootId . ' found; dropping ' . count($queueFiles) . ' files from ' . $this->getModelName() . ' queue'); + $this->dropFromQueue($queueFiles); + return; + } + + $fileIds = array_values(array_unique(array_map(static fn (QueueFile $qf): int => $qf->getFileId(), $queueFiles))); + + $task = new Task( + $this->getTaskTypeId(), + ['input' => $fileIds], + Application::APP_ID, + $userId, + $this->getModelName(), + ); + + try { + $this->taskProcessingManager->scheduleTask($task); + } catch (\Throwable $e) { + // Leave files in the queue so they can be retried on the next job run + $this->logger->error('Failed to schedule ' . $this->getTaskTypeId() . ' task', ['exception' => $e]); + throw new \RuntimeException('Could not schedule ' . $this->getTaskTypeId() . ' task', 0, $e); + } + + $this->logger->debug('Scheduled ' . $this->getTaskTypeId() . ' task #' . $task->getId() . ' for ' . count($fileIds) . ' files'); + + // Once scheduled, files leave the queue. The TaskResultListener applies results when the task completes. + $this->dropFromQueue($queueFiles); + } + + private function findUserForStorage(int $storageId, int $rootId): ?string { + $mounts = array_values(array_filter( + $this->userMountCache->getMountsForStorageId($storageId), + static fn (ICachedMountInfo $m): bool => $m->getRootId() === $rootId, + )); + if (count($mounts) === 0) { + return null; + } + return $mounts[0]->getUser()->getUID(); + } + + /** + * @param list $queueFiles + */ + private function dropFromQueue(array $queueFiles): void { + foreach ($queueFiles as $qf) { + try { + $this->queue->removeFromQueue($this->getModelName(), $qf); + } catch (Exception $e) { + $this->logger->warning('Could not remove file ' . $qf->getFileId() . ' from ' . $this->getModelName() . ' queue', ['exception' => $e]); + } + } + } +} \ No newline at end of file diff --git a/lib/TaskProcessing/Classifiers/AudioClassifier.php b/lib/TaskProcessing/Classifiers/AudioClassifier.php new file mode 100644 index 00000000..02e4741f --- /dev/null +++ b/lib/TaskProcessing/Classifiers/AudioClassifier.php @@ -0,0 +1,23 @@ + new ShapeDescriptor( $this->l->t('Faces'), - $this->l->t('The detected faces. Each input image is mapped to a text containing JSON-encoded embedding vectors separated by line breaks.'), + $this->l->t('The detected faces. Each input image is mapped to a text containing JSON-encoded face descriptions ({x,y,width,height,score,vector,angle} ) separated by line breaks.'), EShapeType::ListOfTexts, ), ]; diff --git a/lib/TaskProcessing/TaskResultListener.php b/lib/TaskProcessing/TaskResultListener.php new file mode 100644 index 00000000..35bafd62 --- /dev/null +++ b/lib/TaskProcessing/TaskResultListener.php @@ -0,0 +1,267 @@ + + */ +final class TaskResultListener implements IEventListener { + public function __construct( + private LoggerInterface $logger, + private TagManager $tagManager, + private FaceDetectionMapper $faceDetections, + private IUserMountCache $userMountCache, + private IAppConfig $config, + private IJobList $jobList, + private QueueService $queue, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof TaskFailedEvent) { + $this->handleFailure($event); + return; + } + if ($event instanceof TaskSuccessfulEvent) { + $this->handleSuccess($event); + } + } + + private function handleFailure(TaskFailedEvent $event): void { + $task = $event->getTask(); + if (!$this->isOwnTask($task)) { + return; + } + $model = $this->modelForTaskType($task->getTaskTypeId()); + $this->logger->warning('TaskProcessing task ' . $task->getTaskTypeId() . ' (id=' . $task->getId() . ') failed: ' . $event->getErrorMessage()); + if ($model !== null) { + $this->config->setAppValueString($model . '.status', 'false'); + } + } + + private function handleSuccess(TaskSuccessfulEvent $event): void { + $task = $event->getTask(); + if (!$this->isOwnTask($task)) { + return; + } + + $input = $task->getInput()['input'] ?? null; + $output = ($task->getOutput() ?? [])['output'] ?? null; + if (!is_array($input) || !is_array($output)) { + $this->logger->warning('TaskProcessing task ' . $task->getTaskTypeId() . ' (id=' . $task->getId() . ') has unexpected input/output shape'); + return; + } + + $fileIds = array_map('intval', array_values($input)); + $results = array_values($output); + + switch ($task->getTaskTypeId()) { + case ImageClassificationTaskType::ID: + $this->applyTagResults($fileIds, $results, ImagenetClassifier::MODEL_NAME, false); + break; + case VideoClassificationTaskType::ID: + $this->applyTagResults($fileIds, $results, MovinetClassifier::MODEL_NAME, false); + break; + case AudioClassificationTaskType::ID: + $this->applyTagResults($fileIds, $results, MusicnnClassifier::MODEL_NAME, false); + break; + case ImageFaceRecognitionTaskType::ID: + $this->applyFaceResults($fileIds, $results); + break; + default: + return; + } + } + + private function isOwnTask(Task $task): bool { + return $task->getAppId() === Application::APP_ID + && in_array($task->getTaskTypeId(), [ + ImageClassificationTaskType::ID, + VideoClassificationTaskType::ID, + AudioClassificationTaskType::ID, + ImageFaceRecognitionTaskType::ID, + ], true); + } + + private function modelForTaskType(string $taskTypeId): ?string { + return match ($taskTypeId) { + ImageClassificationTaskType::ID => ImagenetClassifier::MODEL_NAME, + VideoClassificationTaskType::ID => MovinetClassifier::MODEL_NAME, + AudioClassificationTaskType::ID => MusicnnClassifier::MODEL_NAME, + ImageFaceRecognitionTaskType::ID => ClusteringFaceClassifier::MODEL_NAME, + default => null, + }; + } + + /** + * @param list $fileIds + * @param list $results + */ + private function applyTagResults(array $fileIds, array $results, string $model, bool $forwardToLandmarks): void { + foreach ($fileIds as $i => $fileId) { + if (!isset($results[$i])) { + continue; + } + $raw = (string)$results[$i]; + $tags = array_values(array_filter(array_map('trim', explode(',', $raw)), static fn (string $t): bool => $t !== '')); + if (count($tags) === 0) { + $this->logger->debug('No tags returned for file ' . $fileId . ' from ' . $model); + continue; + } + try { + $this->tagManager->assignTags($fileId, $tags); + } catch (\Throwable $e) { + $this->logger->warning('Could not assign ' . $model . ' tags for file ' . $fileId, ['exception' => $e]); + continue; + } + $this->config->setAppValueString($model . '.status', 'true', lazy: true); + $this->config->setAppValueString($model . '.lastFile', (string)time(), lazy: true); + + if ($forwardToLandmarks) { + $landmarkTags = array_values(array_filter($tags, static fn (string $tag): bool => in_array($tag, LandmarksClassifier::PRECONDITION_TAGS, true))); + if (count($landmarkTags) > 0) { + $this->enqueueForLandmarks($fileId); + } + } + } + } + + /** + * @param list $fileIds + * @param list $results + */ + private function applyFaceResults(array $fileIds, array $results): void { + $model = ClusteringFaceClassifier::MODEL_NAME; + $scheduledClusterJobsFor = []; + foreach ($fileIds as $i => $fileId) { + if (!isset($results[$i])) { + continue; + } + $raw = (string)$results[$i]; + $userIds = $this->getUsersWithFileAccess($fileId); + foreach (explode("\n", $raw) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + try { + $face = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logger->warning('Invalid face JSON for file ' . $fileId, ['exception' => $e]); + continue; + } + if (!is_array($face)) { + continue; + } + if (isset($face['score']) && (float)$face['score'] < ClusteringFaceClassifier::MIN_FACE_RECOGNITION_SCORE) { + continue; + } + if (isset($face['angle']['roll'], $face['angle']['yaw']) + && (abs((float)$face['angle']['roll']) > ClusteringFaceClassifier::MAX_FACE_ROLL + || abs((float)$face['angle']['yaw']) > ClusteringFaceClassifier::MAX_FACE_YAW) + ) { + continue; + } + // Accept either a full face object {x,y,width,height,score,vector,angle} + // or a bare embedding vector (list of numbers). + $isBareVector = array_is_list($face) && count($face) > 0 && is_numeric($face[0]); + $vector = $isBareVector ? $face : ($face['vector'] ?? null); + if (!is_array($vector)) { + $this->logger->warning('Face entry without embedding vector for file ' . $fileId); + continue; + } + foreach ($userIds as $userId) { + $detection = new FaceDetection(); + $detection->setFileId($fileId); + $detection->setUserId($userId); + $detection->setX((float)($face['x'] ?? 0)); + $detection->setY((float)($face['y'] ?? 0)); + $detection->setWidth((float)($face['width'] ?? 0)); + $detection->setHeight((float)($face['height'] ?? 0)); + $detection->setVector($vector); + try { + $this->faceDetections->insert($detection); + } catch (\Throwable $e) { + $this->logger->error('Could not store face detection for file ' . $fileId, ['exception' => $e]); + continue; + } + if (!isset($scheduledClusterJobsFor[$userId])) { + $this->jobList->add(ClusterFacesJob::class, ['userId' => $userId]); + $scheduledClusterJobsFor[$userId] = true; + } + } + } + $this->config->setAppValueString($model . '.status', 'true', lazy: true); + $this->config->setAppValueString($model . '.lastFile', (string)time(), lazy: true); + } + } + + private function enqueueForLandmarks(int $fileId): void { + $mounts = $this->userMountCache->getMountsForFileId($fileId); + if (count($mounts) === 0) { + return; + } + $mount = $mounts[0]; + $queueFile = new QueueFile(); + $queueFile->setFileId($fileId); + $queueFile->setStorageId($mount->getStorageId()); + $queueFile->setRootId($mount->getRootId()); + $queueFile->setUpdate(false); + try { + $this->queue->insertIntoQueue(LandmarksClassifier::MODEL_NAME, $queueFile); + } catch (\Throwable $e) { + $this->logger->warning('Could not enqueue file ' . $fileId . ' for landmark detection', ['exception' => $e]); + } + } + + /** + * @return list + */ + private function getUsersWithFileAccess(int $fileId): array { + try { + $mountInfos = $this->userMountCache->getMountsForFileId($fileId); + } catch (\Throwable $e) { + $this->logger->warning('Could not look up users with access for file ' . $fileId, ['exception' => $e]); + return []; + } + return array_values(array_unique(array_map( + static fn (ICachedMountInfo $m): string => $m->getUser()->getUID(), + $mountInfos, + ))); + } +} From 02d1cca7fa0debcf0ee95ef761bc225b24c1ba8c Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Mon, 22 Jun 2026 15:42:19 +0200 Subject: [PATCH 04/24] chore: Better namespacing Signed-off-by: Marcel Klehr --- .../AbstractTaskProcessingClassifier.php} | 6 +++--- .../TaskProcessing}/AudioClassifier.php | 5 +++-- .../TaskProcessing}/ImageClassifier.php | 7 ++++--- .../TaskProcessing}/ImageFaceRecognitionClassifier.php | 5 +++-- .../TaskProcessing}/VideoClassifier.php | 5 +++-- 5 files changed, 16 insertions(+), 12 deletions(-) rename lib/{TaskProcessing/Classifiers/AbstractClassifier.php => Classifiers/AbstractTaskProcessingClassifier.php} (97%) rename lib/{TaskProcessing/Classifiers => Classifiers/TaskProcessing}/AudioClassifier.php (71%) rename lib/{TaskProcessing/Classifiers => Classifiers/TaskProcessing}/ImageClassifier.php (71%) rename lib/{TaskProcessing/Classifiers => Classifiers/TaskProcessing}/ImageFaceRecognitionClassifier.php (70%) rename lib/{TaskProcessing/Classifiers => Classifiers/TaskProcessing}/VideoClassifier.php (71%) diff --git a/lib/TaskProcessing/Classifiers/AbstractClassifier.php b/lib/Classifiers/AbstractTaskProcessingClassifier.php similarity index 97% rename from lib/TaskProcessing/Classifiers/AbstractClassifier.php rename to lib/Classifiers/AbstractTaskProcessingClassifier.php index 81df7253..520057fb 100644 --- a/lib/TaskProcessing/Classifiers/AbstractClassifier.php +++ b/lib/Classifiers/AbstractTaskProcessingClassifier.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Recognize\TaskProcessing\Classifiers; +namespace OCA\Recognize\Classifiers; use OCA\Recognize\AppInfo\Application; use OCA\Recognize\Db\QueueFile; @@ -19,7 +19,7 @@ use OCP\TaskProcessing\Task; use Psr\Log\LoggerInterface; -abstract class AbstractClassifier { +abstract class AbstractTaskProcessingClassifier { public function __construct( protected LoggerInterface $logger, protected ITaskProcessingManager $taskProcessingManager, @@ -107,4 +107,4 @@ private function dropFromQueue(array $queueFiles): void { } } } -} \ No newline at end of file +} diff --git a/lib/TaskProcessing/Classifiers/AudioClassifier.php b/lib/Classifiers/TaskProcessing/AudioClassifier.php similarity index 71% rename from lib/TaskProcessing/Classifiers/AudioClassifier.php rename to lib/Classifiers/TaskProcessing/AudioClassifier.php index 02e4741f..71c53a0c 100644 --- a/lib/TaskProcessing/Classifiers/AudioClassifier.php +++ b/lib/Classifiers/TaskProcessing/AudioClassifier.php @@ -7,12 +7,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Recognize\TaskProcessing\Classifiers; +namespace OCA\Recognize\Classifiers\TaskProcessing; +use OCA\Recognize\Classifiers\AbstractTaskProcessingClassifier; use OCA\Recognize\Classifiers\Audio\MusicnnClassifier; use OCA\Recognize\TaskProcessing\AudioClassificationTaskType; -final class AudioClassifier extends AbstractClassifier { +final class AudioClassifier extends AbstractTaskProcessingClassifier { protected function getTaskTypeId(): string { return AudioClassificationTaskType::ID; } diff --git a/lib/TaskProcessing/Classifiers/ImageClassifier.php b/lib/Classifiers/TaskProcessing/ImageClassifier.php similarity index 71% rename from lib/TaskProcessing/Classifiers/ImageClassifier.php rename to lib/Classifiers/TaskProcessing/ImageClassifier.php index ebd58821..770986ea 100644 --- a/lib/TaskProcessing/Classifiers/ImageClassifier.php +++ b/lib/Classifiers/TaskProcessing/ImageClassifier.php @@ -7,12 +7,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Recognize\TaskProcessing\Classifiers; +namespace OCA\Recognize\Classifiers\TaskProcessing; +use OCA\Recognize\Classifiers\AbstractTaskProcessingClassifier; use OCA\Recognize\Classifiers\Images\ImagenetClassifier; use OCA\Recognize\TaskProcessing\ImageClassificationTaskType; -final class ImageClassifier extends AbstractClassifier { +final class ImageClassifier extends AbstractTaskProcessingClassifier { protected function getTaskTypeId(): string { return ImageClassificationTaskType::ID; } @@ -20,4 +21,4 @@ protected function getTaskTypeId(): string { protected function getModelName(): string { return ImagenetClassifier::MODEL_NAME; } -} \ No newline at end of file +} diff --git a/lib/TaskProcessing/Classifiers/ImageFaceRecognitionClassifier.php b/lib/Classifiers/TaskProcessing/ImageFaceRecognitionClassifier.php similarity index 70% rename from lib/TaskProcessing/Classifiers/ImageFaceRecognitionClassifier.php rename to lib/Classifiers/TaskProcessing/ImageFaceRecognitionClassifier.php index 3cc3c52d..5dd1733a 100644 --- a/lib/TaskProcessing/Classifiers/ImageFaceRecognitionClassifier.php +++ b/lib/Classifiers/TaskProcessing/ImageFaceRecognitionClassifier.php @@ -7,12 +7,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Recognize\TaskProcessing\Classifiers; +namespace OCA\Recognize\Classifiers\TaskProcessing; +use OCA\Recognize\Classifiers\AbstractTaskProcessingClassifier; use OCA\Recognize\Classifiers\Images\ClusteringFaceClassifier; use OCA\Recognize\TaskProcessing\ImageFaceRecognitionTaskType; -final class ImageFaceRecognitionClassifier extends AbstractClassifier { +final class ImageFaceRecognitionClassifier extends AbstractTaskProcessingClassifier { protected function getTaskTypeId(): string { return ImageFaceRecognitionTaskType::ID; } diff --git a/lib/TaskProcessing/Classifiers/VideoClassifier.php b/lib/Classifiers/TaskProcessing/VideoClassifier.php similarity index 71% rename from lib/TaskProcessing/Classifiers/VideoClassifier.php rename to lib/Classifiers/TaskProcessing/VideoClassifier.php index a7e5e3a3..2ad81562 100644 --- a/lib/TaskProcessing/Classifiers/VideoClassifier.php +++ b/lib/Classifiers/TaskProcessing/VideoClassifier.php @@ -7,12 +7,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Recognize\TaskProcessing\Classifiers; +namespace OCA\Recognize\Classifiers\TaskProcessing; +use OCA\Recognize\Classifiers\AbstractTaskProcessingClassifier; use OCA\Recognize\Classifiers\Video\MovinetClassifier; use OCA\Recognize\TaskProcessing\VideoClassificationTaskType; -final class VideoClassifier extends AbstractClassifier { +final class VideoClassifier extends AbstractTaskProcessingClassifier { protected function getTaskTypeId(): string { return VideoClassificationTaskType::ID; } From 4e0da9285db8e94b2382941b93e3b3269e1a6a39 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 23 Jun 2026 10:17:56 +0200 Subject: [PATCH 05/24] feat: Add a settings switch to enable taskprocessing mode Signed-off-by: Marcel Klehr --- lib/BackgroundJobs/ClassifierJob.php | 13 +++--- lib/BackgroundJobs/ClassifyFacesJob.php | 9 +++- lib/BackgroundJobs/ClassifyImagenetJob.php | 9 +++- lib/BackgroundJobs/ClassifyMovinetJob.php | 9 +++- lib/BackgroundJobs/ClassifyMusicnnJob.php | 9 +++- lib/Service/SettingsService.php | 32 ++++++++++++++ lib/Settings/AdminSettings.php | 2 + src/components/ViewAdmin.vue | 49 +++++++++++++++++----- 8 files changed, 112 insertions(+), 20 deletions(-) diff --git a/lib/BackgroundJobs/ClassifierJob.php b/lib/BackgroundJobs/ClassifierJob.php index 3551b9f1..97fa2991 100644 --- a/lib/BackgroundJobs/ClassifierJob.php +++ b/lib/BackgroundJobs/ClassifierJob.php @@ -29,7 +29,7 @@ public function __construct( private SettingsService $settingsService, ) { parent::__construct($time); - $this->setInterval(60 * 5); + $this->setInterval(60); $this->setTimeSensitivity(self::TIME_INSENSITIVE); $this->setAllowParallelRuns($settingsService->getSetting('concurrency.enabled') === 'true'); } @@ -38,10 +38,13 @@ public function __construct( * @param array{storageId: int, rootId: int} $argument */ protected function runClassifier(string $model, array $argument): void { - sleep(10); - if ($this->settingsService->getSetting('concurrency.enabled') !== 'true' && $this->anyOtherClassifierJobsRunning()) { - $this->logger->debug('Stalling job '.static::class.' with argument ' . var_export($argument, true) . ' because other classifiers are already reserved'); - return; + $taskProcessingMode = $this->settingsService->getSetting('taskprocessing.enabled') === 'true'; + if (!$taskProcessingMode) { + sleep(10); + if ($this->settingsService->getSetting('concurrency.enabled') !== 'true' && $this->anyOtherClassifierJobsRunning()) { + $this->logger->debug('Stalling job '.static::class.' with argument ' . var_export($argument, true) . ' because other classifiers are already reserved'); + return; + } } $storageId = $argument['storageId']; diff --git a/lib/BackgroundJobs/ClassifyFacesJob.php b/lib/BackgroundJobs/ClassifyFacesJob.php index 7684df78..bc7b168c 100644 --- a/lib/BackgroundJobs/ClassifyFacesJob.php +++ b/lib/BackgroundJobs/ClassifyFacesJob.php @@ -8,6 +8,7 @@ namespace OCA\Recognize\BackgroundJobs; use OCA\Recognize\Classifiers\Images\ClusteringFaceClassifier; +use OCA\Recognize\Classifiers\TaskProcessing\ImageFaceRecognitionClassifier as TaskProcessingFaceClassifier; use OCA\Recognize\Service\Logger; use OCA\Recognize\Service\QueueService; use OCA\Recognize\Service\SettingsService; @@ -20,11 +21,13 @@ final class ClassifyFacesJob extends ClassifierJob { private SettingsService $settingsService; private ClusteringFaceClassifier $faces; + private TaskProcessingFaceClassifier $tpFaces; - public function __construct(ITimeFactory $time, Logger $logger, QueueService $queue, SettingsService $settingsService, ClusteringFaceClassifier $faceClassifier, IUserMountCache $mountCache, IJobList $jobList) { + public function __construct(ITimeFactory $time, Logger $logger, QueueService $queue, SettingsService $settingsService, ClusteringFaceClassifier $faceClassifier, TaskProcessingFaceClassifier $tpFaces, IUserMountCache $mountCache, IJobList $jobList) { parent::__construct($time, $logger, $queue, $mountCache, $jobList, $settingsService); $this->settingsService = $settingsService; $this->faces = $faceClassifier; + $this->tpFaces = $tpFaces; } /** @@ -39,6 +42,10 @@ protected function run($argument): void { * @return void */ protected function classify(array $files) : void { + if ($this->settingsService->getSetting('taskprocessing.enabled') === 'true') { + $this->tpFaces->classify($files); + return; + } $this->faces->classify($files); } diff --git a/lib/BackgroundJobs/ClassifyImagenetJob.php b/lib/BackgroundJobs/ClassifyImagenetJob.php index a3d6b291..a9b84ec4 100644 --- a/lib/BackgroundJobs/ClassifyImagenetJob.php +++ b/lib/BackgroundJobs/ClassifyImagenetJob.php @@ -8,6 +8,7 @@ namespace OCA\Recognize\BackgroundJobs; use OCA\Recognize\Classifiers\Images\ImagenetClassifier; +use OCA\Recognize\Classifiers\TaskProcessing\ImageClassifier as TaskProcessingImageClassifier; use OCA\Recognize\Service\Logger; use OCA\Recognize\Service\QueueService; use OCA\Recognize\Service\SettingsService; @@ -20,11 +21,13 @@ final class ClassifyImagenetJob extends ClassifierJob { private SettingsService $settingsService; private ImagenetClassifier $imagenet; + private TaskProcessingImageClassifier $tpImage; - public function __construct(ITimeFactory $time, Logger $logger, QueueService $queue, SettingsService $settingsService, ImagenetClassifier $imagenet, IUserMountCache $mountCache, IJobList $jobList) { + public function __construct(ITimeFactory $time, Logger $logger, QueueService $queue, SettingsService $settingsService, ImagenetClassifier $imagenet, TaskProcessingImageClassifier $tpImage, IUserMountCache $mountCache, IJobList $jobList) { parent::__construct($time, $logger, $queue, $mountCache, $jobList, $settingsService); $this->settingsService = $settingsService; $this->imagenet = $imagenet; + $this->tpImage = $tpImage; } /** @@ -39,6 +42,10 @@ protected function run($argument): void { * @return void */ protected function classify(array $files) : void { + if ($this->settingsService->getSetting('taskprocessing.enabled') === 'true') { + $this->tpImage->classify($files); + return; + } $this->imagenet->classify($files); } diff --git a/lib/BackgroundJobs/ClassifyMovinetJob.php b/lib/BackgroundJobs/ClassifyMovinetJob.php index 5c60f657..f9d0bfee 100644 --- a/lib/BackgroundJobs/ClassifyMovinetJob.php +++ b/lib/BackgroundJobs/ClassifyMovinetJob.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace OCA\Recognize\BackgroundJobs; +use OCA\Recognize\Classifiers\TaskProcessing\VideoClassifier as TaskProcessingVideoClassifier; use OCA\Recognize\Classifiers\Video\MovinetClassifier; use OCA\Recognize\Service\Logger; use OCA\Recognize\Service\QueueService; @@ -20,11 +21,13 @@ final class ClassifyMovinetJob extends ClassifierJob { private SettingsService $settingsService; private MovinetClassifier $movinet; + private TaskProcessingVideoClassifier $tpVideo; - public function __construct(ITimeFactory $time, Logger $logger, QueueService $queue, SettingsService $settingsService, MovinetClassifier $movinet, IUserMountCache $mountCache, IJobList $jobList) { + public function __construct(ITimeFactory $time, Logger $logger, QueueService $queue, SettingsService $settingsService, MovinetClassifier $movinet, TaskProcessingVideoClassifier $tpVideo, IUserMountCache $mountCache, IJobList $jobList) { parent::__construct($time, $logger, $queue, $mountCache, $jobList, $settingsService); $this->settingsService = $settingsService; $this->movinet = $movinet; + $this->tpVideo = $tpVideo; } /** @@ -40,6 +43,10 @@ protected function run($argument): void { * @throws \OCA\Recognize\Exception\Exception */ protected function classify(array $files) : void { + if ($this->settingsService->getSetting('taskprocessing.enabled') === 'true') { + $this->tpVideo->classify($files); + return; + } $this->movinet->classify($files); } diff --git a/lib/BackgroundJobs/ClassifyMusicnnJob.php b/lib/BackgroundJobs/ClassifyMusicnnJob.php index 73a00c4a..2522d96e 100644 --- a/lib/BackgroundJobs/ClassifyMusicnnJob.php +++ b/lib/BackgroundJobs/ClassifyMusicnnJob.php @@ -8,6 +8,7 @@ namespace OCA\Recognize\BackgroundJobs; use OCA\Recognize\Classifiers\Audio\MusicnnClassifier; +use OCA\Recognize\Classifiers\TaskProcessing\AudioClassifier as TaskProcessingAudioClassifier; use OCA\Recognize\Service\Logger; use OCA\Recognize\Service\QueueService; use OCA\Recognize\Service\SettingsService; @@ -20,11 +21,13 @@ final class ClassifyMusicnnJob extends ClassifierJob { private SettingsService $settingsService; private MusicnnClassifier $musicnn; + private TaskProcessingAudioClassifier $tpAudio; - public function __construct(ITimeFactory $time, Logger $logger, QueueService $queue, SettingsService $settingsService, MusicnnClassifier $musicnn, IUserMountCache $mountCache, IJobList $jobList) { + public function __construct(ITimeFactory $time, Logger $logger, QueueService $queue, SettingsService $settingsService, MusicnnClassifier $musicnn, TaskProcessingAudioClassifier $tpAudio, IUserMountCache $mountCache, IJobList $jobList) { parent::__construct($time, $logger, $queue, $mountCache, $jobList, $settingsService); $this->settingsService = $settingsService; $this->musicnn = $musicnn; + $this->tpAudio = $tpAudio; } /** @@ -39,6 +42,10 @@ protected function run($argument): void { * @return void */ protected function classify(array $files) : void { + if ($this->settingsService->getSetting('taskprocessing.enabled') === 'true') { + $this->tpAudio->classify($files); + return; + } $this->musicnn->classify($files); } diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 93c6eac2..c2a87654 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -18,8 +18,15 @@ use OCA\Recognize\Exception\Exception; use OCP\AppFramework\Services\IAppConfig; use OCP\BackgroundJob\IJobList; +use OCP\Server; final class SettingsService { + /** + * App id of the ExApp that provides TaskProcessing classifiers; when installed + * and enabled, taskprocessing mode is on by default. + */ + public const RECOGNIZE_BACKEND_APP_ID = 'recognize_backend'; + /** @var array */ private const DEFAULTS = [ 'tensorflow.cores' => '0', @@ -31,6 +38,7 @@ final class SettingsService { 'faces.enabled' => 'false', 'musicnn.enabled' => 'false', 'movinet.enabled' => 'false', + 'taskprocessing.enabled' => '', 'node_binary' => '', 'clusterFaces.status' => 'null', 'faces.status' => 'null', @@ -105,6 +113,10 @@ public function getSetting(string $key): string { if (strpos($key, 'batchSize') !== false) { return $this->config->getAppValueString($key, $this->getSetting('tensorflow.purejs') === 'false' ? self::DEFAULTS[$key] : self::PUREJS_DEFAULTS[$key]); } + if ($key === 'taskprocessing.enabled') { + $default = $this->isRecognizeBackendInstalled() ? 'true' : 'false'; + return $this->config->getAppValueString($key, $default); + } $lazy = false; if (in_array($key, self::LAZY_SETTINGS, true)) { $lazy = true; @@ -112,6 +124,26 @@ public function getSetting(string $key): string { return $this->config->getAppValueString($key, self::DEFAULTS[$key], lazy: $lazy); } + /** + * Whether the recognize_backend ExApp is installed and enabled. The lookup + * goes through app_api's PublicFunctions service so we don't impose a hard + * dependency on app_api: if it isn't installed, this returns false. + */ + public function isRecognizeBackendInstalled(): bool { + try { + /** @var \OCA\AppAPI\PublicFunctions $publicFunctions */ + $publicFunctions = Server::get(\OCA\AppAPI\PublicFunctions::class); + } catch (\Throwable $e) { + return false; + } + try { + $exApp = $publicFunctions->getExApp(self::RECOGNIZE_BACKEND_APP_ID); + } catch (\Throwable $e) { + return false; + } + return $exApp !== null && (bool)($exApp['enabled'] ?? false); + } + /** * @param string $key * @param string $value diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index 83f8a765..31f13370 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -35,6 +35,8 @@ public function getForm(): TemplateResponse { $tagsEnabled = $this->appManager->isEnabledForAnyone('systemtags'); $this->initialState->provideInitialState('tagsEnabled', $tagsEnabled); + $this->initialState->provideInitialState('recognizeBackendInstalled', $this->settingsService->isRecognizeBackendInstalled()); + return new TemplateResponse('recognize', 'admin'); } diff --git a/src/components/ViewAdmin.vue b/src/components/ViewAdmin.vue index 55895fba..684c8a63 100644 --- a/src/components/ViewAdmin.vue +++ b/src/components/ViewAdmin.vue @@ -21,13 +21,13 @@ {{ t('recognize', 'The systemtags app is currently disabled. Some features of this app will not work.') }} - + {{ t('recognize', 'Could not execute the Node.js binary. You may need to set the path to a working binary manually.') }} {{ t('recognize', 'Background Jobs are not executed via cron. Recognize requires background jobs to be executed via cron.') }} -