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.') }}
-
+
{{ t('recognize', 'The app is installed and will automatically classify files in background processes.') }}
@@ -38,6 +38,22 @@
+
+
+ {{ t('recognize', 'The recognize_backend ExApp is installed; TaskProcessing mode is recommended.') }}
+
+
+ {{ t('recognize', 'TaskProcessing mode is enabled, but no recognize_backend ExApp was detected. Make sure a TaskProcessing provider for the recognize task types is installed and enabled.') }}
+
+
+
+ {{ t('recognize', 'Use Nextcloud TaskProcessing for classification') }}
+
+
+
+ {{ t('recognize', 'When enabled, Recognize hands files off to a Nextcloud TaskProcessing provider (typically the recognize_backend ExApp) instead of running TensorFlow locally. Hardware checks and Node.js / FFmpeg requirements no longer apply in this mode.') }}
+
+
@@ -186,7 +202,7 @@
{{ t('recognize', 'Enable human action recognition (e.g. arm wrestling, dribbling basketball, hula hooping)') }}
@@ -219,7 +235,7 @@
{{ t('recognize', 'Clear queues and background jobs') }}
-
+
{{ t('recognize', 'By default all available CPU cores will be used which may put your system under considerable load. To avoid this, you can limit the amount of CPU Cores used. (Note: In WASM mode, currently only 1 core can be used at all times.)') }}
-
+
{{ t('recognize', 'Checking CPU') }}
@@ -270,7 +286,7 @@
{{ t('recognize', 'Recognize uses Tensorflow for running the machine learning models. Not all installations support Tensorflow, either because the CPU does not support AVX instructions, or because the platform is not x86 (ie. on a Raspberry Pi, which is ARM), or because the Operating System that your nextcloud runs on (when using docker, then that is the OS within the docker image) does not come with GNU lib C (for example Alpine Linux, which is also used by Nextcloud AIO). In most cases, even if your installation does not support native Tensorflow operation, you can still run Tensorflow using WebAssembly (WASM) in Node.js. This is somewhat slower but still works.') }}
-
+
{{ t('recognize', 'Learn how to setup GPU mode with Recognize') }}
-
+
{{ t('recognize', 'Checking Node.js') }}
@@ -329,7 +345,7 @@
{{ t('recognize', 'For Nextcloud Snap users, you need to adjust this path to point to the snap\'s "current" directory as the pre-configured path will change with each update. For example, set it to "/var/snap/nextcloud/current/nextcloud/extra-apps/recognize/bin/node" instead of "/var/snap/nextcloud/9337974/nextcloud/extra-apps/recognize/bin/node"') }}
-
+
{{ t('recognize', 'Checking FFmpeg') }}
@@ -346,7 +362,7 @@
-
+
{{ t('recognize', 'Checking Nice binary') }}
@@ -417,9 +433,9 @@ import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import humanizeDuration from 'humanize-duration'
-const SETTINGS = ['tensorflow.cores', 'tensorflow.gpu', 'tensorflow.purejs', 'imagenet.enabled', 'landmarks.enabled', 'faces.enabled', 'musicnn.enabled', 'movinet.enabled', 'node_binary', 'ffmpeg_binary', 'faces.status', 'imagenet.status', 'landmarks.status', 'movinet.status', 'musicnn.status', 'faces.lastFile', 'imagenet.lastFile', 'landmarks.lastFile', 'movinet.lastFile', 'musicnn.lastFile', 'faces.batchSize', 'imagenet.batchSize', 'landmarks.batchSize', 'movinet.batchSize', 'musicnn.batchSize', 'clusterFaces.status', 'clusterFaces.lastRun', 'nice_binary', 'nice_value', 'concurrency.enabled']
+const SETTINGS = ['tensorflow.cores', 'tensorflow.gpu', 'tensorflow.purejs', 'imagenet.enabled', 'landmarks.enabled', 'faces.enabled', 'musicnn.enabled', 'movinet.enabled', 'node_binary', 'ffmpeg_binary', 'faces.status', 'imagenet.status', 'landmarks.status', 'movinet.status', 'musicnn.status', 'faces.lastFile', 'imagenet.lastFile', 'landmarks.lastFile', 'movinet.lastFile', 'musicnn.lastFile', 'faces.batchSize', 'imagenet.batchSize', 'landmarks.batchSize', 'movinet.batchSize', 'musicnn.batchSize', 'clusterFaces.status', 'clusterFaces.lastRun', 'nice_binary', 'nice_value', 'concurrency.enabled', 'taskprocessing.enabled']
-const BOOLEAN_SETTINGS = ['tensorflow.gpu', 'tensorflow.purejs', 'imagenet.enabled', 'landmarks.enabled', 'faces.enabled', 'musicnn.enabled', 'movinet.enabled', 'faces.status', 'imagenet.status', 'landmarks.status', 'movinet.status', 'musicnn.status', 'faces.lastFile', 'imagenet.lastFile', 'landmarks.lastFile', 'movinet.lastFile', 'musicnn.lastFile', 'clusterFaces.status', 'concurrency.enabled']
+const BOOLEAN_SETTINGS = ['tensorflow.gpu', 'tensorflow.purejs', 'imagenet.enabled', 'landmarks.enabled', 'faces.enabled', 'musicnn.enabled', 'movinet.enabled', 'faces.status', 'imagenet.status', 'landmarks.status', 'movinet.status', 'musicnn.status', 'faces.lastFile', 'imagenet.lastFile', 'landmarks.lastFile', 'movinet.lastFile', 'musicnn.lastFile', 'clusterFaces.status', 'concurrency.enabled', 'taskprocessing.enabled']
const MAX_RELATIVE_DATE = 1000 * 60 * 60 * 24 * 7 // one week
@@ -454,10 +470,20 @@ export default {
musicnnJobs: null,
clusterFacesJobs: null,
tagsEnabled: null,
+ recognizeBackendInstalled: false,
}
},
computed: {
+ readyToClassify() {
+ if (this.cron !== 'cron') {
+ return false
+ }
+ if (this.settings['taskprocessing.enabled']) {
+ return true
+ }
+ return Boolean(this.nodejs && (this.libtensorflow || this.wasmtensorflow))
+ },
pureJSReasons() {
const reasons = []
if (!this.avx) {
@@ -482,6 +508,7 @@ export default {
async created() {
this.modelsDownloaded = loadState('recognize', 'modelsDownloaded')
this.tagsEnabled = loadState('recognize', 'tagsEnabled')
+ this.recognizeBackendInstalled = loadState('recognize', 'recognizeBackendInstalled', false)
this.getCount()
this.getAVX()
this.getPlatform()
From 1ee30aa5ddec866947f6af7ccc12c8e0820e567c Mon Sep 17 00:00:00 2001
From: Marcel Klehr
Date: Tue, 23 Jun 2026 10:18:42 +0200
Subject: [PATCH 06/24] fix: Register task types
Signed-off-by: Marcel Klehr
---
lib/AppInfo/Application.php | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 3106ea12..e4be8f75 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -11,7 +11,11 @@
use OCA\DAV\Events\SabrePluginAddEvent;
use OCA\Recognize\Dav\Faces\PropFindPlugin;
use OCA\Recognize\Hooks\FileListener;
+use OCA\Recognize\TaskProcessing\AudioClassificationTaskType;
+use OCA\Recognize\TaskProcessing\ImageClassificationTaskType;
+use OCA\Recognize\TaskProcessing\ImageFaceRecognitionTaskType;
use OCA\Recognize\TaskProcessing\TaskResultListener;
+use OCA\Recognize\TaskProcessing\VideoClassificationTaskType;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -59,6 +63,11 @@ public function register(IRegistrationContext $context): void {
/** Register $principalBackend for the DAV collection */
$context->registerServiceAlias('principalBackend', Principal::class);
+
+ $context->registerTaskProcessingTaskType(ImageClassificationTaskType::class);
+ $context->registerTaskProcessingTaskType(VideoClassificationTaskType::class);
+ $context->registerTaskProcessingTaskType(AudioClassificationTaskType::class);
+ $context->registerTaskProcessingTaskType(ImageFaceRecognitionTaskType::class);
}
/**
From 574dd0a5a3f02491c22a324cc905fd71bdfe407f Mon Sep 17 00:00:00 2001
From: Marcel Klehr
Date: Tue, 23 Jun 2026 15:51:17 +0200
Subject: [PATCH 07/24] fix: Set new constants for clustering vectors from the
new backend app
Signed-off-by: Marcel Klehr
---
composer.json | 1 +
.../ImageFaceRecognitionClassifier.php | 10 ++++
lib/Db/FaceDetectionMapper.php | 23 ++++++--
lib/Service/FaceClusterAnalyzer.php | 58 ++++++++++++++-----
lib/TaskProcessing/TaskResultListener.php | 15 ++---
5 files changed, 79 insertions(+), 28 deletions(-)
diff --git a/composer.json b/composer.json
index a9d120a8..7cd26956 100644
--- a/composer.json
+++ b/composer.json
@@ -42,6 +42,7 @@
"php": "8.2.0"
},
"allow-plugins": {
+ "bamarni/composer-bin-plugin": true,
"composer/package-versions-deprecated": true
},
"autoloader-suffix": "Recognize",
diff --git a/lib/Classifiers/TaskProcessing/ImageFaceRecognitionClassifier.php b/lib/Classifiers/TaskProcessing/ImageFaceRecognitionClassifier.php
index 5dd1733a..734ad293 100644
--- a/lib/Classifiers/TaskProcessing/ImageFaceRecognitionClassifier.php
+++ b/lib/Classifiers/TaskProcessing/ImageFaceRecognitionClassifier.php
@@ -14,6 +14,16 @@
use OCA\Recognize\TaskProcessing\ImageFaceRecognitionTaskType;
final class ImageFaceRecognitionClassifier extends AbstractTaskProcessingClassifier {
+ const MIN_FACE_RECOGNITION_SCORE = 0.6;
+
+ public const MIN_DATASET_SIZE = 120;
+ public const MIN_DETECTION_SIZE = 0.03;
+ public const MIN_CLUSTER_SEPARATION = 0.7;
+ public const MAX_CLUSTER_EDGE_LENGTH = 1.0;
+ public const DIMENSIONS = 512;
+ public const MAX_OVERLAP_NEW_CLUSTER = 0.1;
+ public const MIN_OVERLAP_EXISTING_CLUSTER = 0.5;
+
protected function getTaskTypeId(): string {
return ImageFaceRecognitionTaskType::ID;
}
diff --git a/lib/Db/FaceDetectionMapper.php b/lib/Db/FaceDetectionMapper.php
index 93920542..75e93b64 100644
--- a/lib/Db/FaceDetectionMapper.php
+++ b/lib/Db/FaceDetectionMapper.php
@@ -7,7 +7,9 @@
declare(strict_types=1);
namespace OCA\Recognize\Db;
+use OCA\Recognize\Classifiers\TaskProcessing\ImageFaceRecognitionClassifier;
use OCA\Recognize\Service\FaceClusterAnalyzer;
+use OCA\Recognize\Service\SettingsService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\QBMapper;
@@ -21,11 +23,20 @@
*/
final class FaceDetectionMapper extends QBMapper {
private IConfig $config;
+ private SettingsService $settingsService;
- public function __construct(IDBConnection $db, IConfig $config) {
+ public function __construct(IDBConnection $db, IConfig $config, SettingsService $settingsService) {
parent::__construct($db, 'recognize_face_detections', FaceDetection::class);
$this->db = $db;
$this->config = $config;
+ $this->settingsService = $settingsService;
+ }
+
+ private function getMinDetectionSize(): float {
+ if ($this->settingsService->getSetting('taskprocessing.enabled') === 'true') {
+ return ImageFaceRecognitionClassifier::MIN_DETECTION_SIZE;
+ }
+ return FaceClusterAnalyzer::MIN_DETECTION_SIZE;
}
/**
@@ -329,12 +340,13 @@ public function findDetectionForPreviewImageByClusterId(int $clusterId) : FaceDe
}
public function countUnclustered(): int {
+ $minDetectionSize = $this->getMinDetectionSize();
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('id'))
->from('recognize_face_detections')
->where($qb->expr()->isNull('cluster_id'))
- ->andWhere($qb->expr()->gte('height', $qb->createPositionalParameter(FaceClusterAnalyzer::MIN_DETECTION_SIZE)))
- ->andWhere($qb->expr()->gte('width', $qb->createPositionalParameter(FaceClusterAnalyzer::MIN_DETECTION_SIZE)));
+ ->andWhere($qb->expr()->gte('height', $qb->createPositionalParameter($minDetectionSize)))
+ ->andWhere($qb->expr()->gte('width', $qb->createPositionalParameter($minDetectionSize)));
$result = $qb->executeQuery();
/** @var int|string $count */
$count = $result->fetch(\PDO::FETCH_COLUMN);
@@ -347,12 +359,13 @@ public function countUnclustered(): int {
* @throws \OCP\DB\Exception
*/
public function getUsersForUnclustered(): array {
+ $minDetectionSize = $this->getMinDetectionSize();
$qb = $this->db->getQueryBuilder();
$qb->selectDistinct('user_id')
->from('recognize_face_detections')
->where($qb->expr()->isNull('cluster_id'))
- ->andWhere($qb->expr()->gte('height', $qb->createPositionalParameter(FaceClusterAnalyzer::MIN_DETECTION_SIZE)))
- ->andWhere($qb->expr()->gte('width', $qb->createPositionalParameter(FaceClusterAnalyzer::MIN_DETECTION_SIZE)));
+ ->andWhere($qb->expr()->gte('height', $qb->createPositionalParameter($minDetectionSize)))
+ ->andWhere($qb->expr()->gte('width', $qb->createPositionalParameter($minDetectionSize)));
$result = $qb->executeQuery();
/** @var array $users */
$users = $result->fetchAll(\PDO::FETCH_COLUMN);
diff --git a/lib/Service/FaceClusterAnalyzer.php b/lib/Service/FaceClusterAnalyzer.php
index 5c8e0105..9d1b9073 100644
--- a/lib/Service/FaceClusterAnalyzer.php
+++ b/lib/Service/FaceClusterAnalyzer.php
@@ -9,6 +9,7 @@
use \OCA\Recognize\Vendor\Rubix\ML\Datasets\Labeled;
use \OCA\Recognize\Vendor\Rubix\ML\Kernels\Distance\Euclidean;
+use OCA\Recognize\Classifiers\TaskProcessing\ImageFaceRecognitionClassifier;
use OCA\Recognize\Clustering\HDBSCAN;
use OCA\Recognize\Db\FaceCluster;
use OCA\Recognize\Db\FaceClusterMapper;
@@ -27,7 +28,12 @@ final class FaceClusterAnalyzer {
private FaceDetectionMapper $faceDetections;
private FaceClusterMapper $faceClusters;
private Logger $logger;
- private int $minDatasetSize = self::MIN_DATASET_SIZE;
+ private int $minDatasetSize;
+ private float $minDetectionSize;
+ private float $minClusterSeparation;
+ private float $maxClusterEdgeLength;
+ private float $maxOverlapNewCluster;
+ private float $minOverlapExistingCluster;
private SettingsService $settingsService;
public function __construct(FaceDetectionMapper $faceDetections, FaceClusterMapper $faceClusters, Logger $logger, SettingsService $settingsService) {
@@ -35,6 +41,22 @@ public function __construct(FaceDetectionMapper $faceDetections, FaceClusterMapp
$this->faceClusters = $faceClusters;
$this->logger = $logger;
$this->settingsService = $settingsService;
+
+ if ($this->settingsService->getSetting('taskprocessing.enabled') === 'true') {
+ $this->minDatasetSize = ImageFaceRecognitionClassifier::MIN_DATASET_SIZE;
+ $this->minDetectionSize = ImageFaceRecognitionClassifier::MIN_DETECTION_SIZE;
+ $this->minClusterSeparation = ImageFaceRecognitionClassifier::MIN_CLUSTER_SEPARATION;
+ $this->maxClusterEdgeLength = ImageFaceRecognitionClassifier::MAX_CLUSTER_EDGE_LENGTH;
+ $this->maxOverlapNewCluster = ImageFaceRecognitionClassifier::MAX_OVERLAP_NEW_CLUSTER;
+ $this->minOverlapExistingCluster = ImageFaceRecognitionClassifier::MIN_OVERLAP_EXISTING_CLUSTER;
+ } else {
+ $this->minDatasetSize = self::MIN_DATASET_SIZE;
+ $this->minDetectionSize = self::MIN_DETECTION_SIZE;
+ $this->minClusterSeparation = self::MIN_CLUSTER_SEPARATION;
+ $this->maxClusterEdgeLength = self::MAX_CLUSTER_EDGE_LENGTH;
+ $this->maxOverlapNewCluster = self::MAX_OVERLAP_NEW_CLUSTER;
+ $this->minOverlapExistingCluster = self::MIN_OVERLAP_EXISTING_CLUSTER;
+ }
}
public function setMinDatasetSize(int $minSize) : void {
@@ -64,12 +86,12 @@ public function calculateClusters(string $userId, int $batchSize = 0): void {
}
if ($batchSize > 0) {
- $rejectedDetections = $this->faceDetections->sampleRejectedDetectionsByUserId($userId, $this->getRejectSampleSize($batchSize), self::MIN_DETECTION_SIZE, self::MIN_DETECTION_SIZE);
+ $rejectedDetections = $this->faceDetections->sampleRejectedDetectionsByUserId($userId, $this->getRejectSampleSize($batchSize), $this->minDetectionSize, $this->minDetectionSize);
$requestedFreshDetectionCount = max($batchSize - count($rejectedDetections) - count($sampledDetections), 500);
- $freshDetections = $this->faceDetections->findUnclusteredByUserId($userId, $requestedFreshDetectionCount, self::MIN_DETECTION_SIZE, self::MIN_DETECTION_SIZE);
+ $freshDetections = $this->faceDetections->findUnclusteredByUserId($userId, $requestedFreshDetectionCount, $this->minDetectionSize, $this->minDetectionSize);
} else {
- $freshDetections = $this->faceDetections->findUnclusteredByUserId($userId, 0, self::MIN_DETECTION_SIZE, self::MIN_DETECTION_SIZE);
- $rejectedDetections = $this->faceDetections->sampleRejectedDetectionsByUserId($userId, $this->getRejectSampleSize(count($freshDetections)), self::MIN_DETECTION_SIZE, self::MIN_DETECTION_SIZE);
+ $freshDetections = $this->faceDetections->findUnclusteredByUserId($userId, 0, $this->minDetectionSize, $this->minDetectionSize);
+ $rejectedDetections = $this->faceDetections->sampleRejectedDetectionsByUserId($userId, $this->getRejectSampleSize(count($freshDetections)), $this->minDetectionSize, $this->minDetectionSize);
}
@@ -94,7 +116,7 @@ public function calculateClusters(string $userId, int $batchSize = 0): void {
$hdbscan = new HDBSCAN($dataset, $this->getMinClusterSize($n), $this->getMinSampleSize($n));
$numberOfClusteredDetections = 0;
- $clusters = $hdbscan->predict(self::MIN_CLUSTER_SEPARATION, self::MAX_CLUSTER_EDGE_LENGTH);
+ $clusters = $hdbscan->predict($this->minClusterSeparation, $this->maxClusterEdgeLength);
foreach ($clusters as $flatCluster) {
/** @var int[] $detectionKeys */
@@ -132,10 +154,10 @@ public function calculateClusters(string $userId, int $batchSize = 0): void {
}
// If more than X% of already clustered detections are for this, we keep it
- if ($overlap > self::MIN_OVERLAP_EXISTING_CLUSTER) {
+ if ($overlap > $this->minOverlapExistingCluster) {
$clusterId = $oldClusterId;
$cluster = $this->faceClusters->find($clusterId);
- } elseif ($overlap < self::MAX_OVERLAP_NEW_CLUSTER) {
+ } elseif ($overlap < $this->maxOverlapNewCluster) {
// otherwise we create a new cluster
$cluster = new FaceCluster();
@@ -187,17 +209,21 @@ public function calculateClusters(string $userId, int $batchSize = 0): void {
* @return list
*/
public static function calculateCentroidOfDetections(array $detections): array {
- // init 128 dimensional vector
- /** @var list $sum */
- $sum = [];
- for ($i = 0; $i < self::DIMENSIONS; $i++) {
- $sum[] = 0.0;
- }
-
if (count($detections) === 0) {
- return $sum;
+ /** @var list $empty */
+ $empty = [];
+ for ($i = 0; $i < self::DIMENSIONS; $i++) {
+ $empty[] = 0.0;
+ }
+ return $empty;
}
+ // Size the accumulator from the first detection so both 128-dim (legacy) and
+ // 512-dim (buffalo_l/taskprocessing) embeddings work without a runtime switch.
+ $dimensions = count(reset($detections)->getVector());
+ /** @var list $sum */
+ $sum = array_fill(0, $dimensions, 0.0);
+
foreach ($detections as $detection) {
$sum = array_map(static function (float $el, float $el2): float {
return $el + $el2;
diff --git a/lib/TaskProcessing/TaskResultListener.php b/lib/TaskProcessing/TaskResultListener.php
index 35bafd62..691e6a6a 100644
--- a/lib/TaskProcessing/TaskResultListener.php
+++ b/lib/TaskProcessing/TaskResultListener.php
@@ -15,6 +15,7 @@
use OCA\Recognize\Classifiers\Images\ClusteringFaceClassifier;
use OCA\Recognize\Classifiers\Images\ImagenetClassifier;
use OCA\Recognize\Classifiers\Images\LandmarksClassifier;
+use OCA\Recognize\Classifiers\TaskProcessing\ImageFaceRecognitionClassifier;
use OCA\Recognize\Classifiers\Video\MovinetClassifier;
use OCA\Recognize\Db\FaceDetection;
use OCA\Recognize\Db\FaceDetectionMapper;
@@ -27,9 +28,11 @@
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\Config\IUserMountCache;
+use OCP\IUserManager;
use OCP\TaskProcessing\Events\TaskFailedEvent;
use OCP\TaskProcessing\Events\TaskSuccessfulEvent;
use OCP\TaskProcessing\Task;
+use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
@@ -49,6 +52,8 @@ public function __construct(
private IAppConfig $config,
private IJobList $jobList,
private QueueService $queue,
+ private IUserSession $userSession,
+ private IUserManager $userManager,
) {
}
@@ -90,6 +95,8 @@ private function handleSuccess(TaskSuccessfulEvent $event): void {
$fileIds = array_map('intval', array_values($input));
$results = array_values($output);
+ $this->userSession->setUser($this->userManager->get($task->getUserId()));
+
switch ($task->getTaskTypeId()) {
case ImageClassificationTaskType::ID:
$this->applyTagResults($fileIds, $results, ImagenetClassifier::MODEL_NAME, false);
@@ -188,13 +195,7 @@ private function applyFaceResults(array $fileIds, array $results): void {
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)
- ) {
+ if (isset($face['score']) && (float)$face['score'] < ImageFaceRecognitionClassifier::MIN_FACE_RECOGNITION_SCORE) {
continue;
}
// Accept either a full face object {x,y,width,height,score,vector,angle}
From 33352944e65ada65e6796f995cbe0af1791f5933 Mon Sep 17 00:00:00 2001
From: Marcel Klehr
Date: Mon, 29 Jun 2026 10:08:21 +0200
Subject: [PATCH 08/24] fix: Use a constant for TaskProcessing mode batch size
and don't show batch size settings
Signed-off-by: Marcel Klehr
---
lib/BackgroundJobs/ClassifierJob.php | 5 +++--
src/components/ViewAdmin.vue | 15 ++++++++++-----
2 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/lib/BackgroundJobs/ClassifierJob.php b/lib/BackgroundJobs/ClassifierJob.php
index 97fa2991..94adfe54 100644
--- a/lib/BackgroundJobs/ClassifierJob.php
+++ b/lib/BackgroundJobs/ClassifierJob.php
@@ -56,9 +56,10 @@ protected function runClassifier(string $model, array $argument): void {
return;
}
$this->logger->debug('Classifying files of storage '.$storageId. ' using '.$model);
+ $batchSize = $taskProcessingMode ? 500 : $this->getBatchSize();
try {
- $this->logger->debug('fetching '.$this->getBatchSize().' files from '.$model.' queue');
- $files = $this->queue->getFromQueue($model, $storageId, $rootId, $this->getBatchSize());
+ $this->logger->debug('fetching '.$batchSize.' files from '.$model.' queue');
+ $files = $this->queue->getFromQueue($model, $storageId, $rootId, $batchSize);
} catch (Exception $e) {
$this->settingsService->setSetting($model.'.status', 'false');
$this->logger->error('Cannot retrieve items from '.$model.' queue', ['exception' => $e]);
diff --git a/src/components/ViewAdmin.vue b/src/components/ViewAdmin.vue
index 684c8a63..1b5365b1 100644
--- a/src/components/ViewAdmin.vue
+++ b/src/components/ViewAdmin.vue
@@ -80,7 +80,8 @@
{{ t('recognize', 'Enable face recognition (groups photos by faces that appear in them; UI is in the photos app)') }}
-
{{ t('recognize', 'Enable object recognition (e.g. food, vehicles, landscapes)') }}
-
{{ t('recognize', 'Enable landmark recognition (e.g. Eiffel Tower, Golden Gate Bridge)') }}
-
{{ t('recognize', 'Enable music genre recognition (e.g. pop, rock, folk, metal, new age)') }}
-
{{ t('recognize', 'Enable human action recognition (e.g. arm wrestling, dribbling basketball, hula hooping)') }}
-
Date: Mon, 29 Jun 2026 11:47:22 +0200
Subject: [PATCH 09/24] fix: show task processing status in admin settings
Signed-off-by: Marcel Klehr
---
src/components/ViewAdmin.vue | 49 ++++++++++++++++++++++++++++++++----
1 file changed, 44 insertions(+), 5 deletions(-)
diff --git a/src/components/ViewAdmin.vue b/src/components/ViewAdmin.vue
index 1b5365b1..8b0e48ad 100644
--- a/src/components/ViewAdmin.vue
+++ b/src/components/ViewAdmin.vue
@@ -66,7 +66,7 @@
{{ t('recognize', 'Waiting for status reports on face recognition. If this message persists beyond 15 minutes, please check the Nextcloud logs.') }}
- {{ t('recognize', 'Face recognition:') }} {{ countQueued.faces }} {{ t('recognize', 'Queued files') }}, {{ t('recognize', 'Last classification: ') }} {{ showDate(settings['faces.lastFile']) }}, {{ t('recognize', 'Scheduled background jobs: ') }} {{ facesJobs.scheduled }}, {{ facesJobs.lastRun ? t('recognize', 'Last background job execution: ') + showDate(facesJobs.lastRun) : '' }}
+ {{ t('recognize', 'Face recognition:') }} {{ countQueued.faces }} {{ t('recognize', 'Queued files') }}, {{ t('recognize', 'Last classification: ') }} {{ showDate(settings['faces.lastFile']) }}, {{ t('recognize', 'Scheduled background jobs: ') }} {{ facesJobs.scheduled }}, {{ facesJobs.lastRun ? t('recognize', 'Last background job execution: ') + showDate(facesJobs.lastRun) : '' }}, {{ t('recognize', 'TaskProcessing tasks: ') }} {{ facesTpStats.scheduled }} {{ t('recognize', 'scheduled') }}, {{ facesTpStats.running }} {{ t('recognize', 'running') }}
{{ t('recognize', 'There are queued files in the face recognition queue but no background job is scheduled to process them.') }}
@@ -101,7 +101,7 @@
{{ t('recognize', 'Waiting for status reports on object recognition. If this message persists beyond 15 minutes, please check the Nextcloud logs.') }}
- {{ t('recognize', 'Object recognition:') }} {{ countQueued.imagenet }} {{ t('recognize', 'Queued files') }}, {{ t('recognize', 'Last classification: ') }} {{ showDate(settings['imagenet.lastFile']) }}, {{ t('recognize', 'Scheduled background jobs: ') }} {{ imagenetJobs.scheduled }}, {{ imagenetJobs.lastRun ? t('recognize', 'Last background job execution: ') + showDate(imagenetJobs.lastRun) : '' }}
+ {{ t('recognize', 'Object recognition:') }} {{ countQueued.imagenet }} {{ t('recognize', 'Queued files') }}, {{ t('recognize', 'Last classification: ') }} {{ showDate(settings['imagenet.lastFile']) }}, {{ t('recognize', 'Scheduled background jobs: ') }} {{ imagenetJobs.scheduled }}, {{ imagenetJobs.lastRun ? t('recognize', 'Last background job execution: ') + showDate(imagenetJobs.lastRun) : '' }}, {{ t('recognize', 'TaskProcessing tasks: ') }} {{ imagenetTpStats.scheduled }} {{ t('recognize', 'scheduled') }}, {{ imagenetTpStats.running }} {{ t('recognize', 'running') }}
{{ t('recognize', 'There are queued files in the object detection queue but no background job is scheduled to process them.') }}
@@ -166,7 +166,7 @@
{{ t('recognize', 'Waiting for status reports on audio recognition. If this message persists beyond 15 minutes, please check the Nextcloud logs.') }}
- {{ t('recognize', 'Music genre recognition:') }} {{ countQueued.musicnn }} {{ t('recognize', 'Queued files') }}, {{ t('recognize', 'Last classification: ') }} {{ showDate(settings['musicnn.lastFile']) }}, {{ t('recognize', 'Scheduled background jobs: ') }} {{ musicnnJobs.scheduled }}, {{ musicnnJobs.lastRun ? t('recognize', 'Last background job execution: ') + showDate(musicnnJobs.lastRun) : '' }}
+ {{ t('recognize', 'Music genre recognition:') }} {{ countQueued.musicnn }} {{ t('recognize', 'Queued files') }}, {{ t('recognize', 'Last classification: ') }} {{ showDate(settings['musicnn.lastFile']) }}, {{ t('recognize', 'Scheduled background jobs: ') }} {{ musicnnJobs.scheduled }}, {{ musicnnJobs.lastRun ? t('recognize', 'Last background job execution: ') + showDate(musicnnJobs.lastRun) : '' }}, {{ t('recognize', 'TaskProcessing tasks: ') }} {{ musicnnTpStats.scheduled }} {{ t('recognize', 'scheduled') }}, {{ musicnnTpStats.running }} {{ t('recognize', 'running') }}
{{ t('recognize', 'There are queued files but no background job is scheduled to process them.') }}
@@ -197,7 +197,7 @@
{{ t('recognize', 'Waiting for status reports on video recognition. If this message persists beyond 15 minutes, please check the Nextcloud logs.') }}
- {{ t('recognize', 'Video recognition:') }} {{ countQueued.movinet }} {{ t('recognize', 'Queued files') }}, {{ t('recognize', 'Last classification: ') }} {{ showDate(settings['movinet.lastFile']) }}, {{ t('recognize', 'Scheduled background jobs: ') }} {{ movinetJobs.scheduled }}, {{ movinetJobs.lastRun ? t('recognize', 'Last background job execution: ') + showDate(movinetJobs.lastRun) : '' }}
+ {{ t('recognize', 'Video recognition:') }} {{ countQueued.movinet }} {{ t('recognize', 'Queued files') }}, {{ t('recognize', 'Last classification: ') }} {{ showDate(settings['movinet.lastFile']) }}, {{ t('recognize', 'Scheduled background jobs: ') }} {{ movinetJobs.scheduled }}, {{ movinetJobs.lastRun ? t('recognize', 'Last background job execution: ') + showDate(movinetJobs.lastRun) : '' }}, {{ t('recognize', 'TaskProcessing tasks: ') }} {{ movinetTpStats.scheduled }} {{ t('recognize', 'scheduled') }}, {{ movinetTpStats.running }} {{ t('recognize', 'running') }}
{{ t('recognize', 'There are queued files but no background job is scheduled to process them.') }}
@@ -434,10 +434,17 @@