diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcd013b1e..ebab24e36 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.
- **Fix**: Improved and 20x faster planet database setup
- **Feature**: Huawei Moving Picture support
+## [v8.0.1] - 2026-04-16
+
+- **Feature**: Compatibility with manual tagging in face recognition.
+
## [v8.0.0] - 2026-04-04
- **Update**: Compatibility with Nextcloud 33
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 74e5f1295..83bea3d4c 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -29,7 +29,7 @@ Memories is a *batteries-included* photo management solution for Nextcloud with
1. Run `php occ memories:index` to generate metadata indices for existing photos.
1. Open the 📷 Memories app in Nextcloud and set the directory containing your photos.
]]>
- 8.1.0
+ 8.1.1agplVarun PatilMemories
diff --git a/lib/ClustersBackend/Covers.php b/lib/ClustersBackend/Covers.php
index 5621f3ec9..711fc44bb 100644
--- a/lib/ClustersBackend/Covers.php
+++ b/lib/ClustersBackend/Covers.php
@@ -19,10 +19,16 @@ final class Covers
* @param string $clusterTableId Column name for the cluster ID in clusterTable
* @param string $objectTable Table name for the object mapping
* @param string $objectTableObjectId Column name for the object ID in objectTable
- * @param string $objectTableClusterId Column name for the cluster ID in objectTable
+ * @param string $objectTableClusterId Column name for the cluster ID in objectTable.
+ * May be qualified with an alias (e.g. "foo.bar"), in which case it is
+ * used verbatim instead of being resolved against the object table.
* @param bool $validateCluster Whether to validate the cluster
* @param bool $validateFilecache Whether to validate the filecache
* @param mixed $user Query expression for user ID to use for the covers
+ * @param null|\Closure $objectTableJoin Optional hook to join further tables into the cluster validation
+ * subquery, for backends where the object reaches its cluster over
+ * more than one hop. Receives the subquery builder, whose object
+ * table is aliased "cov_objs".
*/
public static function selectCover(
IQueryBuilder &$query,
@@ -36,7 +42,14 @@ public static function selectCover(
bool $validateFilecache = true,
string $field = 'cover',
mixed $user = null,
+ ?\Closure $objectTableJoin = null,
): void {
+ // Where the cluster ID lives on the object side. A qualified name points
+ // at a table the caller joins in through $objectTableJoin.
+ $objectClusterIdRef = str_contains($objectTableClusterId, '.')
+ ? $objectTableClusterId
+ : "cov_objs.{$objectTableClusterId}";
+
// Clauses for the WHERE
$clauses = [
$query->expr()->eq('mcov.uid', $user ?? $query->expr()->literal(Util::getUser()->getUID())),
@@ -50,9 +63,15 @@ public static function selectCover(
$validSq->select($validSq->expr()->literal(1))
->from($objectTable, 'cov_objs')
->where($validSq->expr()->eq($query->expr()->castColumn("cov_objs.{$objectTableObjectId}", IQueryBuilder::PARAM_INT), 'mcov.objectid'))
- ->andWhere($validSq->expr()->eq("cov_objs.{$objectTableClusterId}", "{$clusterTable}.{$clusterTableId}"))
;
+ // Let the backend bridge any extra hops to the cluster table
+ if (null !== $objectTableJoin) {
+ $objectTableJoin($validSq);
+ }
+
+ $validSq->andWhere($validSq->expr()->eq($objectClusterIdRef, "{$clusterTable}.{$clusterTableId}"));
+
$clauses[] = SQL::exists($query, $validSq);
}
@@ -90,7 +109,10 @@ public static function selectCover(
* @param string $type Cluster type
* @param string $objectTable Table name for the object mapping
* @param string $objectTableObjectId Column name for the object ID in objectTable
- * @param string $objectTableClusterId Column name for the cluster ID in objectTable
+ * @param string $objectTableClusterId Column name for the cluster ID in objectTable.
+ * May be qualified with an alias (e.g. "foo.bar") that the caller has
+ * already joined, for backends that reach the cluster over more
+ * than one hop.
*/
public static function filterCover(
IQueryBuilder &$query,
@@ -99,10 +121,14 @@ public static function filterCover(
string $objectTableObjectId,
string $objectTableClusterId,
): void {
+ $clusterIdRef = str_contains($objectTableClusterId, '.')
+ ? $objectTableClusterId
+ : "{$objectTable}.{$objectTableClusterId}";
+
$query->innerJoin($objectTable, 'memories_covers', 'm_cov', $query->expr()->andX(
$query->expr()->eq('m_cov.uid', $query->expr()->literal(Util::getUser()->getUID())),
$query->expr()->eq('m_cov.clustertype', $query->expr()->literal($type)),
- $query->expr()->eq('m_cov.clusterid', "{$objectTable}.{$objectTableClusterId}"),
+ $query->expr()->eq('m_cov.clusterid', $clusterIdRef),
$query->expr()->eq('m_cov.objectid', $query->expr()->castColumn("{$objectTable}.{$objectTableObjectId}", IQueryBuilder::PARAM_INT)),
));
}
diff --git a/lib/ClustersBackend/FaceRecognitionBackend.php b/lib/ClustersBackend/FaceRecognitionBackend.php
index 29d26a717..9ebcc144f 100644
--- a/lib/ClustersBackend/FaceRecognitionBackend.php
+++ b/lib/ClustersBackend/FaceRecognitionBackend.php
@@ -31,6 +31,17 @@
use OCP\IAppConfig;
use OCP\IRequest;
+/**
+ * Backend for the Face Recognition app.
+ *
+ * Schema note: a face no longer points at a person directly. The chain is
+ *
+ * facerecog_faces.cluster -> facerecog_clusters.id
+ * facerecog_clusters.person -> facerecog_persons.id (NULL while unnamed)
+ *
+ * so facerecog_persons holds only named people, and the unnamed groupings
+ * live in facerecog_clusters. Everything below joins through that extra hop.
+ */
final class FaceRecognitionBackend extends Backend
{
use PeopleBackendUtils;
@@ -81,13 +92,23 @@ public function transformDayQuery(IQueryBuilder &$query, bool $aggregate): void
// Join with faces
$query->innerJoin('fri', 'facerecog_faces', 'frf', $query->expr()->eq('frf.image', 'fri.id'));
- // Join with persons
- $nameField = is_numeric($personName) ? 'frp.id' : 'frp.name';
- $query->innerJoin('frf', 'facerecog_persons', 'frp', $query->expr()->andX(
- $query->expr()->eq('frf.person', 'frp.id'),
- $query->expr()->eq('frp.user', $query->createNamedParameter($personUid)),
- $query->expr()->eq($nameField, $query->createNamedParameter($personName)),
- ));
+ // Join with clusters: every face belongs to at most one cluster
+ $query->innerJoin('frf', 'facerecog_clusters', 'frc', $query->expr()->eq('frc.id', 'frf.cluster'));
+
+ if (is_numeric($personName)) {
+ // An unnamed cluster is addressed by its numeric id
+ $query->andWhere($query->expr()->andX(
+ $query->expr()->eq('frc.id', $query->createNamedParameter($personName, \PDO::PARAM_INT)),
+ $query->expr()->eq('frc.user', $query->createNamedParameter($personUid)),
+ ));
+ } else {
+ // A named person is addressed by name, through the cluster
+ $query->innerJoin('frc', 'facerecog_persons', 'frp', $query->expr()->andX(
+ $query->expr()->eq('frp.id', 'frc.person'),
+ $query->expr()->eq('frp.user', $query->createNamedParameter($personUid)),
+ $query->expr()->eq('frp.name', $query->createNamedParameter($personName)),
+ ));
+ }
if (!$aggregate) {
// Multiple detections for the same image
@@ -153,10 +174,13 @@ public function getPhotos(string $name, ?int $limit = null, ?int $fileid = null)
{
$query = $this->tq->getBuilder();
+ // A numeric name is the id of an unnamed cluster; anything else is a
+ // person name that has to be resolved through facerecog_persons.
+ $isClusterId = is_numeric($name);
+
// SELECT face detections
$query->select(
'frf.id as faceid', // Face ID
- 'frp.id as cluster_id', // Cluster ID
'fri.file as file_id', // Get actual file
'frf.x', // Image cropping
'frf.y',
@@ -177,18 +201,34 @@ public function getPhotos(string $name, ?int $limit = null, ?int $fileid = null)
// WHERE these photos are memories indexed
$query->innerJoin('fri', 'memories', 'm', $query->expr()->eq('m.fileid', 'fri.file'));
- $query->innerJoin('frf', 'facerecog_persons', 'frp', $query->expr()->eq('frp.id', 'frf.person'));
+ // WHERE the face belongs to a cluster
+ $query->innerJoin('frf', 'facerecog_clusters', 'frc', $query->expr()->eq('frc.id', 'frf.cluster'));
- // WHERE faces are from id persons (or a cluster).
- $nameField = is_numeric($name) ? 'frp.id' : 'frp.name';
- $query->where($query->expr()->eq($nameField, $query->createNamedParameter($name)));
+ if ($isClusterId) {
+ // WHERE faces are in this unnamed cluster
+ $query->selectAlias('frc.id', 'cluster_id');
+ $query->where($query->expr()->eq('frc.id', $query->createNamedParameter($name, \PDO::PARAM_INT)));
+ } else {
+ // WHERE faces belong to a cluster of this named person
+ $query->innerJoin('frc', 'facerecog_persons', 'frp', $query->expr()->eq('frp.id', 'frc.person'));
+ $query->selectAlias('frp.id', 'cluster_id');
+ $query->where($query->expr()->eq('frp.name', $query->createNamedParameter($name)));
+ }
// WHERE these photos are in the user's requested folder recursively
$query = $this->tq->filterFilecache($query);
// LIMIT results
if (-6 === $limit) {
- Covers::filterCover($query, self::clusterType(), 'frf', 'id', 'person');
+ // The cover is keyed by whatever getClusterIdFrom() reports: the
+ // cluster id for unnamed clusters, the person id for named ones.
+ Covers::filterCover(
+ $query,
+ self::clusterType(),
+ 'frf',
+ 'id',
+ $isClusterId ? 'frf.cluster' : 'frc.person',
+ );
} elseif (null !== $limit) {
$query->setMaxResults($limit);
}
@@ -254,18 +294,21 @@ private function minFaceInClusters(): int
return (int) $this->appConfig->getValueString('facerecognition', 'min_faces_in_cluster', (string) 5);
}
+ /**
+ * Unnamed clusters: rows of facerecog_clusters with no person yet.
+ */
private function getFaceRecognitionClusters(int $fileid = 0): array
{
$query = $this->tq->getBuilder();
// SELECT all face clusters
$count = $query->func()->count(SQL::distinct($query, 'm.fileid'));
- $query->select('frp.id')->from('facerecog_persons', 'frp');
+ $query->select('frc.id')->from('facerecog_clusters', 'frc');
$query->selectAlias($count, 'count');
- $query->selectAlias('frp.user', 'user_id');
+ $query->selectAlias('frc.user', 'user_id');
// WHERE there are faces with this cluster
- $query->innerJoin('frp', 'facerecog_faces', 'frf', $query->expr()->eq('frp.id', 'frf.person'));
+ $query->innerJoin('frc', 'facerecog_faces', 'frf', $query->expr()->eq('frc.id', 'frf.cluster'));
// WHERE faces are from images.
$query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->eq('fri.id', 'frf.image'));
@@ -280,8 +323,10 @@ private function getFaceRecognitionClusters(int $fileid = 0): array
$query = $this->tq->filterFilecache($query);
// GROUP by ID of face cluster
- $query->addGroupBy('frp.id', 'frp.user');
- $query->andWhere($query->expr()->isNull('frp.name'));
+ $query->addGroupBy('frc.id', 'frc.user');
+
+ // WHERE the cluster has not been assigned to a person yet
+ $query->andWhere($query->expr()->isNull('frc.person'));
// The query change if we want the people in an fileid, or the unnamed clusters
if ($fileid > 0) {
@@ -291,36 +336,39 @@ private function getFaceRecognitionClusters(int $fileid = 0): array
// WHERE these clusters has a minimum number of faces
$query->having($query->expr()->gte($count, SQL::literal($query, $this->minFaceInClusters(), \PDO::PARAM_INT)));
// WHERE these clusters were not hidden due inconsistencies
- $query->andWhere($query->expr()->eq('frp.is_visible', $query->expr()->literal(1)));
+ $query->andWhere($query->expr()->eq('frc.is_visible', $query->expr()->literal(1)));
}
// ORDER by number of faces in cluster and id for response stability.
$query->addOrderBy('count', 'DESC');
- $query->addOrderBy('frp.id', 'DESC');
+ $query->addOrderBy('frc.id', 'DESC');
// It is not worth displaying all unnamed clusters. We show 15 to name them progressively,
$query->setMaxResults(15);
// SELECT covers
- $query = SQL::materialize($query, 'frp');
+ $query = SQL::materialize($query, 'frc');
Covers::selectCover(
query: $query,
type: self::clusterType(),
- clusterTable: 'frp',
+ clusterTable: 'frc',
clusterTableId: 'id',
objectTable: 'facerecog_faces',
objectTableObjectId: 'id',
- objectTableClusterId: 'person',
+ objectTableClusterId: 'cluster',
);
// SELECT etag for the cover
- $query = SQL::materialize($query, 'frp');
+ $query = SQL::materialize($query, 'frc');
$this->tq->selectEtag($query, 'cover', 'cover_etag');
// FETCH all faces
return $this->tq->executeQueryWithCTEs($query)->fetchAll() ?: [];
}
+ /**
+ * Named people, reached through the clusters that point at them.
+ */
private function getFaceRecognitionPersons(int $fileid = 0): array
{
$query = $this->tq->getBuilder();
@@ -333,8 +381,11 @@ private function getFaceRecognitionPersons(int $fileid = 0): array
->from('facerecog_persons', 'frp')
;
- // WHERE there are faces with this cluster
- $query->innerJoin('frp', 'facerecog_faces', 'frf', $query->expr()->eq('frp.id', 'frf.person'));
+ // WHERE there are clusters for this person
+ $query->innerJoin('frp', 'facerecog_clusters', 'frc', $query->expr()->eq('frp.id', 'frc.person'));
+
+ // WHERE there are faces in those clusters
+ $query->innerJoin('frc', 'facerecog_faces', 'frf', $query->expr()->eq('frc.id', 'frf.cluster'));
// WHERE faces are from images.
$query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->eq('fri.id', 'frf.image'));
@@ -371,7 +422,12 @@ private function getFaceRecognitionPersons(int $fileid = 0): array
clusterTableId: 'id',
objectTable: 'facerecog_faces',
objectTableObjectId: 'id',
- objectTableClusterId: 'person',
+ // A face points at a cluster and the cluster at the person, so the
+ // cover validation needs the extra hop joined in below.
+ objectTableClusterId: 'cov_frc.person',
+ objectTableJoin: static function (IQueryBuilder $sq): void {
+ $sq->innerJoin('cov_objs', 'facerecog_clusters', 'cov_frc', $sq->expr()->eq('cov_frc.id', 'cov_objs.cluster'));
+ },
);
// SELECT etag for the cover
diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php
index 7d8263cb8..9766cbb41 100644
--- a/lib/Controller/ImageController.php
+++ b/lib/Controller/ImageController.php
@@ -244,13 +244,31 @@ public function info(
// Get clusters for this file
if ($clusters) {
$clist = [];
+ $cfailed = [];
foreach (explode(',', $clusters) as $type) {
- $backend = \OC::$server->get(\OCA\Memories\ClustersBackend\Manager::class)->get($type);
- if ($backend->isEnabled()) {
- $clist[$type] = $backend->getClusters($id);
+ // One broken backend must not take down the whole
+ // metadata response. This happens in practice when a
+ // companion app (e.g. facerecognition) changes its
+ // schema underneath us: report that single feature as
+ // unavailable and keep serving everything else.
+ try {
+ $backend = \OC::$server->get(\OCA\Memories\ClustersBackend\Manager::class)->get($type);
+ if ($backend->isEnabled()) {
+ $clist[$type] = $backend->getClusters($id);
+ }
+ } catch (\Throwable $e) {
+ $cfailed[] = $type;
+ $this->logger->warning("Clusters backend \"{$type}\" failed for file {$id}: ".$e->getMessage(), [
+ 'exception' => $e,
+ 'app' => 'memories',
+ ]);
}
}
$info['clusters'] = $clist;
+
+ if ($cfailed) {
+ $info['clustersFailed'] = $cfailed;
+ }
}
} elseif ($shareNode = $this->fs->getShareNode()) {
// For public shares, get path relative to share root
diff --git a/src/components/Metadata.vue b/src/components/Metadata.vue
index 6a8b16bf9..7be752b3f 100644
--- a/src/components/Metadata.vue
+++ b/src/components/Metadata.vue
@@ -20,6 +20,31 @@
+
+
+
{{ t('memories', 'Face Recognition') }}
+
+
+ {{ t('memories', 'Add person') }}
+
+
+
+
+
+ {{ t('memories', 'Face Recognition is unavailable. The app may need an update to match its database schema.') }}
+
+
+
+
+
+
+
+ {{ t('memories', 'No faces detected — click + to tag manually') }}
+
+
+
+
+
{{ t('memories', 'Albums') }}
@@ -88,7 +113,9 @@ import { DateTime } from 'luxon';
import UserConfig from '@mixins/UserConfig';
import Cluster from '@components/frame/Cluster.vue';
import AlbumsList from '@components/modal/AlbumsList.vue';
+import FaceManualAddModal from '@components/modal/FaceManualAddModal.vue';
+import AddIcon from 'vue-material-design-icons/AccountPlus.vue';
import EditIcon from 'vue-material-design-icons/Pencil.vue';
import CalendarIcon from 'vue-material-design-icons/Calendar.vue';
import CameraIrisIcon from 'vue-material-design-icons/CameraIris.vue';
@@ -119,6 +146,8 @@ export default defineComponent({
NcAvatar,
AlbumsList,
Cluster,
+ FaceManualAddModal,
+ AddIcon,
EditIcon,
},
@@ -389,14 +418,19 @@ export default defineComponent({
},
people(): IFace[] {
- const clusters = this.baseInfo?.clusters;
+ return this.baseInfo?.clusters?.recognize ?? [];
+ },
- // force face-recognition on its own route, or if recognize is disabled
- if (this.routeIsFaceRecognition || !this.config.recognize_enabled) {
- return clusters?.facerecognition ?? [];
- }
+ facerecognitionPeople(): IFace[] {
+ return this.baseInfo?.clusters?.facerecognition ?? [];
+ },
- return clusters?.recognize ?? [];
+ /**
+ * The server could not build the face recognition clusters for this file.
+ * The rest of the metadata is still valid, so only this section degrades.
+ */
+ facerecognitionFailed(): boolean {
+ return this.baseInfo?.clustersFailed?.includes('facerecognition') ?? false;
},
isShared(): boolean {
@@ -469,6 +503,21 @@ export default defineComponent({
_m.modals.editMetadata([_m.viewer.currentPhoto!], [4]);
},
+ openManualAdd() {
+ const modal = this.$refs.manualAddModal as InstanceType | undefined;
+ if (!modal) return;
+ if (this.fileid) {
+ modal.openForFile({
+ fileid: this.fileid,
+ etag: this.baseInfo?.etag,
+ w: this.baseInfo?.w,
+ h: this.baseInfo?.h,
+ });
+ } else {
+ modal.open();
+ }
+ },
+
handleFileUpdated({ fileid }: utils.BusEvent['files:file:updated']) {
if (fileid && this.fileid === fileid) {
this.refresh();
@@ -536,6 +585,17 @@ export default defineComponent({
> .section-title {
margin-bottom: 4px;
}
+ > .section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-right: 4px;
+ margin-bottom: 4px;
+
+ > .section-title {
+ flex: 1;
+ }
+ }
> .container {
width: calc(100% / 3);
aspect-ratio: 1;
@@ -548,6 +608,17 @@ export default defineComponent({
font-size: 0.95em;
}
}
+ > .empty-hint {
+ padding: 6px 8px 10px;
+ font-size: 0.9em;
+ color: var(--color-text-lighter);
+ }
+
+ > .error-hint {
+ padding: 6px 8px 10px;
+ font-size: 0.9em;
+ color: var(--color-error-text, var(--color-error));
+ }
}
.albums {
diff --git a/src/components/modal/FaceEditModal.vue b/src/components/modal/FaceEditModal.vue
index 8071ed7e8..d85051bee 100644
--- a/src/components/modal/FaceEditModal.vue
+++ b/src/components/modal/FaceEditModal.vue
@@ -5,6 +5,11 @@
+
+
+
@@ -52,6 +58,7 @@ export default defineComponent({
data: () => ({
rawInput: String(),
+ knownNames: [] as string[],
}),
computed: {
@@ -83,12 +90,33 @@ export default defineComponent({
this.rawInput = isNaN(Number(this.name)) ? this.name : String();
this.show = true;
+ this.loadKnownNames();
},
cleanup() {
this.show = false;
},
+ /**
+ * Load already-known person names from the active backend so the name field
+ * can offer them as autocompletion (same comfort as the manual-face modal).
+ * Failure is non-fatal: it just means no suggestions are shown.
+ */
+ async loadKnownNames(): Promise {
+ try {
+ const app = this.routeIsRecognize ? 'recognize' : 'facerecognition';
+ const faces = await dav.getFaceList(app);
+ const names = faces
+ .map((f) => f.name)
+ // Keep only real names; unnamed clusters expose a numeric id as their name.
+ .filter((n): n is string => !!n && Number.isNaN(Number(n)));
+ this.knownNames = Array.from(new Set(names)).sort((a, b) => a.localeCompare(b));
+ } catch (e) {
+ console.error(e);
+ this.knownNames = [];
+ }
+ },
+
async save() {
if (!this.canSave) return;
diff --git a/src/components/modal/FaceManualAddModal.vue b/src/components/modal/FaceManualAddModal.vue
new file mode 100644
index 000000000..6b55874fa
--- /dev/null
+++ b/src/components/modal/FaceManualAddModal.vue
@@ -0,0 +1,619 @@
+
+
+
+ {{ t('memories', 'Add person') }}
+
+
+
+
+
+
{{ t('memories', 'Choose a photo containing the person you want to tag.') }}
+
+ {{ t('memories', 'Choose photo') }}
+
+
+
+
+
+
+
+
+
+ {{ t('memories', 'Drag on the photo to mark the face. Existing detections are shown in green.') }}
+
+
+ {{ t('memories', 'Enter a name below. You can redraw by dragging again.') }}
+
+
+
+
+
+
+
+ {{ f.personName || '?' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('memories', 'Reassign this face to a different person (only on this photo).') }}
+