10 Commits
Author SHA1 Message Date
Codex 2c1f321627 Align local web integrations with updated lint rules and Cast types 2026-10-28 21:00:29 +03:00
Codex e8c1fd297d Prepare local13 release and align dependency lockfile 2026-10-28 20:46:38 +03:00
bo0tzzandCodex 8e3e3a48ae perf(server): only serialize exif for returned search rows (#32620)
(cherry picked from commit e51de38c40a29fd5eb1347cd991bbbafccfe62c6)
2026-10-28 20:41:41 +03:00
austinmdillowandCodex dc5553b239 feat: workflow filter by missing EXIF property (#32713)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit a1a80cb1c416d3a480963a78d7b9df329d867bf6)
2026-10-28 20:41:41 +03:00
renovate[bot]andCodex 32e02a05d2 chore(deps): update terraform cloudflare to v5 (#32794)
(cherry picked from commit d8aaa950099f6687a8f6861f63466242a748ac1b)
2026-10-28 20:41:41 +03:00
shenlongandCodex c581bce715 fix: include partner shared assets for people even without showInTimeline (#32675)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
(cherry picked from commit 21ed7454836073a646273c7475b347e043439a90)
2026-10-28 20:41:41 +03:00
Daniel DietzlerandCodex dd23264385 chore: deploy docs when terraform changes (#32795)
(cherry picked from commit 4ea9c1f7c520f1e1c6b6f22968abdb7f9b4aa29f)
2026-10-28 20:41:41 +03:00
Jason RasmussenandCodex 6dbe72b6a1 fix: gc time for long-lived queries (#32793)
(cherry picked from commit 079568691580605e92c5d3c3c9037cfbd657f374)
2026-10-28 20:41:41 +03:00
Daniel DietzlerandCodex 72f96701f0 feat: cluster group admin permission for regenerate people (#32787)
(cherry picked from commit 9f273a604d5bdbd9583758c3a79d2fc0141c7a77)
2026-10-28 20:41:41 +03:00
Jason RasmussenandCodex b4ae7f0dcf feat: passkey feature level (#32791)
(cherry picked from commit f5d33ff6618510917fb9f7bce705ec9583a207fb)
2026-10-28 20:41:41 +03:00
36 changed files with 919 additions and 572 deletions
+1
View File
@@ -35,6 +35,7 @@ jobs:
filters: | filters: |
docs: docs:
- 'docs/**' - 'docs/**'
- 'deployment/**'
open-api: open-api:
- 'open-api/immich-openapi-specs.json' - 'open-api/immich-openapi-specs.json'
force-filters: | force-filters: |
+18 -28
View File
@@ -2,34 +2,24 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.10" version = "5.28.1"
constraints = "4.52.10" constraints = "5.28.1"
hashes = [ hashes = [
"h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=", "h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
"h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=", "h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
"h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=", "h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
"h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=", "h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
"h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=", "h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
"h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=", "h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
"h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=", "h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
"h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=", "h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
"h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=", "zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
"h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=", "zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
"h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=", "zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
"h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=", "zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
"h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=", "zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
"zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55", "zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
"zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2", "zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
"zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562", "zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
"zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
"zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
"zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
"zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
"zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
"zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
"zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
"zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
"zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
"zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
] ]
} }
@@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.52.10" version = "5.28.1"
} }
} }
} }
+18 -28
View File
@@ -2,34 +2,24 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.10" version = "5.28.1"
constraints = "4.52.10" constraints = "5.28.1"
hashes = [ hashes = [
"h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=", "h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
"h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=", "h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
"h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=", "h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
"h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=", "h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
"h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=", "h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
"h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=", "h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
"h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=", "h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
"h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=", "h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
"h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=", "zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
"h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=", "zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
"h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=", "zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
"h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=", "zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
"h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=", "zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
"zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55", "zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
"zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2", "zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
"zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562", "zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
"zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
"zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
"zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
"zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
"zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
"zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
"zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
"zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
"zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
"zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
] ]
} }
+1 -1
View File
@@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.52.10" version = "5.28.1"
} }
} }
} }
@@ -74,8 +74,8 @@ class TimelineFactory {
TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy)); TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy));
TimelineService person(List<String> userIds, String personId) => TimelineService person(String userId, String personId) =>
TimelineService(_timelineRepository.person(userIds, personId, groupBy)); TimelineService(_timelineRepository.person(userId, personId, groupBy));
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) => TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type)); TimelineService(_timelineRepository.fromAssets(assets, type));
@@ -366,10 +366,10 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
origin: TimelineOrigin.place, origin: TimelineOrigin.place,
); );
TimelineQuery person(List<String> userIds, String personId, GroupAssetsBy groupBy) => ( TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userIds, personId, groupBy: groupBy), bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy),
assetSource: (offset, count) => assetSource: (offset, count) =>
_getPersonBucketAssets(userIds, personId, groupBy: groupBy, offset: offset, count: count), _getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count),
origin: TimelineOrigin.person, origin: TimelineOrigin.person,
); );
@@ -430,7 +430,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
} }
Expression<bool> _personAssetFilter(List<String> userIds, String personId) { Expression<bool> _personAssetFilter(String userId, String personId) {
final faceAssetIds = _db.assetFaceEntity.selectOnly() final faceAssetIds = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId]) ..addColumns([_db.assetFaceEntity.assetId])
..where( ..where(
@@ -438,22 +438,23 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
_db.assetFaceEntity.isVisible.equals(true) & _db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(), _db.assetFaceEntity.deletedAt.isNull(),
); );
final partnerIds = _db.partnerEntity.selectOnly()
..addColumns([_db.partnerEntity.sharedById])
..where(_db.partnerEntity.sharedWithId.equals(userId));
final albumAssetIds = _db.remoteAlbumAssetEntity.selectOnly()..addColumns([_db.remoteAlbumAssetEntity.assetId]); final albumAssetIds = _db.remoteAlbumAssetEntity.selectOnly()..addColumns([_db.remoteAlbumAssetEntity.assetId]);
return _db.remoteAssetEntity.id.isInQuery(faceAssetIds) & return _db.remoteAssetEntity.id.isInQuery(faceAssetIds) &
(_db.remoteAssetEntity.ownerId.isIn(userIds) | _db.remoteAssetEntity.id.isInQuery(albumAssetIds)) & (_db.remoteAssetEntity.ownerId.equals(userId) |
_db.remoteAssetEntity.ownerId.isInQuery(partnerIds) |
_db.remoteAssetEntity.id.isInQuery(albumAssetIds)) &
_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline); _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline);
} }
Stream<List<Bucket>> _watchPersonBucket( Stream<List<Bucket>> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
List<String> userIds,
String personId, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
if (groupBy == GroupAssetsBy.none) { if (groupBy == GroupAssetsBy.none) {
final query = _db.remoteAssetEntity.selectOnly() final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id.count()]) ..addColumns([_db.remoteAssetEntity.id.count()])
..where(_personAssetFilter(userIds, personId)); ..where(_personAssetFilter(userId, personId));
return query.map((row) { return query.map((row) {
final count = row.read(_db.remoteAssetEntity.id.count())!; final count = row.read(_db.remoteAssetEntity.id.count())!;
@@ -466,7 +467,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
final query = _db.remoteAssetEntity.selectOnly() final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp]) ..addColumns([assetCountExp, dateExp])
..where(_personAssetFilter(userIds, personId)) ..where(_personAssetFilter(userId, personId))
..groupBy([dateExp]) ..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]); ..orderBy([OrderingTerm.desc(dateExp)]);
@@ -478,14 +479,14 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
} }
Future<List<BaseAsset>> _getPersonBucketAssets( Future<List<BaseAsset>> _getPersonBucketAssets(
List<String> userIds, String userId,
String personId, { String personId, {
required int offset, required int offset,
required int count, required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day, GroupAssetsBy groupBy = GroupAssetsBy.day,
}) { }) {
final query = _db.remoteAssetEntity.select() final query = _db.remoteAssetEntity.select()
..where((_) => _personAssetFilter(userIds, personId)) ..where((_) => _personAssetFilter(userId, personId))
..orderBy(_assetDateOrder(groupBy)) ..orderBy(_assetDateOrder(groupBy))
..limit(count, offset: offset); ..limit(count, offset: offset);
@@ -82,8 +82,7 @@ class _PersonPageState extends ConsumerState<PersonPage> {
throw Exception('User must be logged in to view person timeline'); throw Exception('User must be logged in to view person timeline');
} }
final users = ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]; final timelineService = ref.watch(timelineFactoryProvider).person(user.id, _person.id);
final timelineService = ref.watch(timelineFactoryProvider).person(users, _person.id);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}), }),
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 3.3.5-local.12+3030504 version: 3.3.5-local.13+3030505
environment: environment:
sdk: '>=3.12.0 <4.0.0' sdk: '>=3.12.0 <4.0.0'
@@ -103,6 +103,22 @@ void main() {
}); });
group('person assets', () { group('person assets', () {
test('includes partner assets even when the partner is not shown in the timeline', () async {
final user = await ctx.newUser();
final partner = await ctx.newUser();
final stranger = await ctx.newUser();
await ctx.newPartner(sharedById: partner.id, sharedWithId: user.id, inTimeline: false);
final person = await ctx.newPerson(ownerId: user.id);
final partnerAsset = await ctx.newRemoteAsset(ownerId: partner.id);
final strangerAsset = await ctx.newRemoteAsset(ownerId: stranger.id);
await ctx.newFace(assetId: partnerAsset.id, personId: person.id);
await ctx.newFace(assetId: strangerAsset.id, personId: person.id);
final assets = await sut.person(user.id, person.id, .day).assetSource(0, 10);
expect(assets.map((a) => (a as RemoteAsset).id), [partnerAsset.id]);
});
test('does not duplicate an asset that has multiple face records for the same person', () async { test('does not duplicate an asset that has multiple face records for the same person', () async {
// Regression check for #26723: an INNER JOIN between remote_asset_entity and asset_face_entity // Regression check for #26723: an INNER JOIN between remote_asset_entity and asset_face_entity
// fanned out one asset into N rows when N face records pointed at the same (asset, person) pair // fanned out one asset into N rows when N face records pointed at the same (asset, person) pair
@@ -113,7 +129,7 @@ void main() {
await ctx.newFace(assetId: asset.id, personId: person.id); await ctx.newFace(assetId: asset.id, personId: person.id);
await ctx.newFace(assetId: asset.id, personId: person.id); await ctx.newFace(assetId: asset.id, personId: person.id);
final query = sut.person([user.id], person.id, .day); final query = sut.person(user.id, person.id, .day);
final buckets = await query.bucketSource().first; final buckets = await query.bucketSource().first;
expect(buckets, hasLength(1)); expect(buckets, hasLength(1));
@@ -140,7 +156,7 @@ void main() {
await ctx.newFace(assetId: shiftedLater.id, personId: person.id); await ctx.newFace(assetId: shiftedLater.id, personId: person.id);
await ctx.newFace(assetId: shiftedEarlier.id, personId: person.id); await ctx.newFace(assetId: shiftedEarlier.id, personId: person.id);
final query = sut.person([user.id], person.id, .day); final query = sut.person(user.id, person.id, .day);
final buckets = await query.bucketSource().first; final buckets = await query.bucketSource().first;
expect(buckets, hasLength(2)); expect(buckets, hasLength(2));
+6 -3
View File
@@ -6343,7 +6343,7 @@
"state": "Added" "state": "Added"
} }
], ],
"x-immich-permission": "clusterGroup.read" "x-immich-permission": "clusterGroup.admin"
} }
}, },
"/cluster-groups/{id}/requests": { "/cluster-groups/{id}/requests": {
@@ -25811,6 +25811,7 @@
"backup.delete", "backup.delete",
"clusterGroup.read", "clusterGroup.read",
"clusterGroup.leave", "clusterGroup.leave",
"clusterGroup.admin",
"clusterGroupRequest.create", "clusterGroupRequest.create",
"clusterGroupRequest.read", "clusterGroupRequest.read",
"clusterGroupRequest.delete", "clusterGroupRequest.delete",
@@ -28466,11 +28467,13 @@
"ServerFeatureLevel": { "ServerFeatureLevel": {
"description": "Server feature level", "description": "Server feature level",
"enum": [ "enum": [
1 1,
2
], ],
"type": "integer", "type": "integer",
"x-enum-varnames": [ "x-enum-varnames": [
"FeatureLevel" "FeatureLevel",
"Passkeys"
] ]
}, },
"ServerFeaturesDto": { "ServerFeaturesDto": {
+2 -2
View File
@@ -407,7 +407,7 @@
"matchType": { "matchType": {
"type": "string", "type": "string",
"title": "Match type", "title": "Match type",
"enum": ["contains", "startsWith", "exact", "regex"], "enum": ["contains", "startsWith", "exact", "regex", "present", "missing"],
"default": "contains", "default": "contains",
"description": "Type of pattern matching to perform", "description": "Type of pattern matching to perform",
"uiHint": { "uiHint": {
@@ -424,7 +424,7 @@
} }
} }
}, },
"required": ["property", "pattern"] "required": ["property"]
}, },
"uiHints": ["Filter"] "uiHints": ["Filter"]
}, },
+21 -8
View File
@@ -3,13 +3,18 @@ import { AssetVisibility } from '@immich/sdk';
import type { Manifest } from '../dist/index.d.ts'; import type { Manifest } from '../dist/index.d.ts';
type MatchValueConfig = { type MatchValueConfig = {
pattern: string; pattern?: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith'; matchType?: 'contains' | 'exact' | 'regex' | 'startsWith' | 'missing' | 'present';
caseSensitive?: boolean; caseSensitive?: boolean;
}; };
const matchValueResult = (value: string, config: MatchValueConfig) => { const matchValueResult = (value: string | null, config: MatchValueConfig) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config; const { pattern = '', matchType = 'contains', caseSensitive = false } = config;
if (value === null) {
return { workflow: { continue: matchType === 'missing' } };
}
const searchName = caseSensitive ? value : value.toLowerCase(); const searchName = caseSensitive ? value : value.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
@@ -32,6 +37,15 @@ const matchValueResult = (value: string, config: MatchValueConfig) => {
return { workflow: { continue: regex.test(value) } }; return { workflow: { continue: regex.test(value) } };
} }
case 'present': {
return { workflow: { continue: true } };
}
case 'missing': {
// value is not null at this point
return { workflow: { continue: false } };
}
default: { default: {
return {}; return {};
} }
@@ -135,11 +149,10 @@ const methods = wrapper<Manifest>({
}, },
assetExifFilter: ({ config, data }) => { assetExifFilter: ({ config, data }) => {
if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) { let value = data.asset.exifInfo?.[config.property];
return { workflow: { continue: false } };
}
return matchValueResult(String(data.asset.exifInfo[config.property]), config); // Empty EXIF value is treated as missing to mirror behavior of the web UI
return matchValueResult(value === null || value === undefined || value === '' ? null : String(value), config);
}, },
assetDateFilter: ({ config, data }) => { assetDateFilter: ({ config, data }) => {
+3 -1
View File
@@ -8351,6 +8351,7 @@ export enum Permission {
BackupDelete = "backup.delete", BackupDelete = "backup.delete",
ClusterGroupRead = "clusterGroup.read", ClusterGroupRead = "clusterGroup.read",
ClusterGroupLeave = "clusterGroup.leave", ClusterGroupLeave = "clusterGroup.leave",
ClusterGroupAdmin = "clusterGroup.admin",
ClusterGroupRequestCreate = "clusterGroupRequest.create", ClusterGroupRequestCreate = "clusterGroupRequest.create",
ClusterGroupRequestRead = "clusterGroupRequest.read", ClusterGroupRequestRead = "clusterGroupRequest.read",
ClusterGroupRequestDelete = "clusterGroupRequest.delete", ClusterGroupRequestDelete = "clusterGroupRequest.delete",
@@ -8823,7 +8824,8 @@ export enum ReleaseType {
Prerelease = "prerelease" Prerelease = "prerelease"
} }
export enum ServerFeatureLevel { export enum ServerFeatureLevel {
FeatureLevel = 1 FeatureLevel = 1,
Passkeys = 2
} }
export enum UserMetadataKey { export enum UserMetadataKey {
Preferences = "preferences", Preferences = "preferences",
+460 -445
View File
File diff suppressed because it is too large Load Diff
@@ -94,7 +94,7 @@ export class ClusterGroupController {
} }
@Post(':id/regenerate-people') @Post(':id/regenerate-people')
@Authenticated({ permission: Permission.ClusterGroupRead }) @Authenticated({ permission: Permission.ClusterGroupAdmin })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({ @Endpoint({
summary: 'Regenerate people of users in cluster group', summary: 'Regenerate people of users in cluster group',
+2
View File
@@ -193,6 +193,7 @@ export enum Permission {
ClusterGroupRead = 'clusterGroup.read', ClusterGroupRead = 'clusterGroup.read',
ClusterGroupLeave = 'clusterGroup.leave', ClusterGroupLeave = 'clusterGroup.leave',
ClusterGroupAdmin = 'clusterGroup.admin',
ClusterGroupRequestCreate = 'clusterGroupRequest.create', ClusterGroupRequestCreate = 'clusterGroupRequest.create',
ClusterGroupRequestRead = 'clusterGroupRequest.read', ClusterGroupRequestRead = 'clusterGroupRequest.read',
ClusterGroupRequestDelete = 'clusterGroupRequest.delete', ClusterGroupRequestDelete = 'clusterGroupRequest.delete',
@@ -1340,6 +1341,7 @@ export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'Searc
export enum ServerFeatureLevel { export enum ServerFeatureLevel {
FeatureLevel = 1, FeatureLevel = 1,
Passkeys = 2,
} }
export const ServerFeatureLevelSchema = z export const ServerFeatureLevelSchema = z
+126 -3
View File
@@ -38,7 +38,48 @@ select
and "asset_file"."type" = $1 and "asset_file"."type" = $1
) as agg ) as agg
) as "files", ) as "files",
to_json("asset_exif") as "exifInfo" (
select
to_json(obj)
from
(
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as obj
) as "exifInfo"
from from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
@@ -239,7 +280,48 @@ select
"asset_edit"."assetId" = "asset"."id" "asset_edit"."assetId" = "asset"."id"
) as agg ) as agg
) as "edits", ) as "edits",
to_json("asset_exif") as "exifInfo", (
select
to_json(obj)
from
(
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as obj
) as "exifInfo",
( (
select select
to_json(obj) to_json(obj)
@@ -449,7 +531,48 @@ where
select select
"asset"."id", "asset"."id",
"asset"."visibility", "asset"."visibility",
to_json("asset_exif") as "exifInfo", (
select
to_json(obj)
from
(
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as obj
) as "exifInfo",
( (
select select
coalesce(json_agg(agg), '[]') coalesce(json_agg(agg), '[]')
+84 -2
View File
@@ -134,7 +134,48 @@ select
"asset"."type", "asset"."type",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
to_json("asset_exif") as "exifInfo" (
select
to_json(obj)
from
(
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as obj
) as "exifInfo"
from from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
@@ -1420,7 +1461,48 @@ offset
-- SearchRepository.searchMetadataV3 (order-rating-withExif) -- SearchRepository.searchMetadataV3 (order-rating-withExif)
select select
to_json("asset_exif") as "exifInfo", (
select
to_json(obj)
from
(
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as obj
) as "exifInfo",
"asset"."id", "asset"."id",
"asset"."updateId", "asset"."updateId",
"asset"."createdAt", "asset"."createdAt",
+1 -1
View File
@@ -73,7 +73,7 @@ export class ClusterGroupService extends BaseService {
} }
async regeneratePeople(auth: AuthDto, id: string) { async regeneratePeople(auth: AuthDto, id: string) {
await this.requireAccess({ auth, permission: Permission.ClusterGroupRead, ids: [id] }); await this.requireAccess({ auth, permission: Permission.ClusterGroupAdmin, ids: [id] });
await this.jobRepository.queue({ await this.jobRepository.queue({
name: JobName.FacialRecognitionQueueAll, name: JobName.FacialRecognitionQueueAll,
+4
View File
@@ -356,6 +356,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return access.person.checkFaceOwnerAccess(auth.user.id, ids); return access.person.checkFaceOwnerAccess(auth.user.id, ids);
} }
case Permission.ClusterGroupAdmin: {
return access.clusterGroup.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ClusterGroupRead: { case Permission.ClusterGroupRead: {
const isMember = await access.clusterGroup.checkOwnerAccess(auth.user.id, ids); const isMember = await access.clusterGroup.checkOwnerAccess(auth.user.id, ids);
const isInvited = await access.clusterGroup.checkInviteAccess(auth.user.id, setDifference(ids, isMember)); const isInvited = await access.clusterGroup.checkInviteAccess(auth.user.id, setDifference(ids, isMember));
+7 -2
View File
@@ -123,10 +123,15 @@ export function withExif<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
return qb.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId').select(selectExifInfo); return qb.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId').select(selectExifInfo);
} }
const selectReturnedExifInfo = (eb: ExpressionBuilder<DB, 'asset'>) =>
jsonObjectFrom(eb.selectFrom('asset_exif').select(columns.exif).whereRef('asset_exif.assetId', '=', 'asset.id')).as(
'exifInfo',
);
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'asset', O>) { export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
return qb return qb
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')) .select(selectReturnedExifInfo)
.$narrowType<{ exifInfo: NotNull }>(); .$narrowType<{ exifInfo: NotNull }>();
} }
@@ -816,7 +821,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.selectFrom('asset') .selectFrom('asset')
// postgres eliminates the left join when no exif column is referenced, so unused joins are free // postgres eliminates the left join when no exif column is referenced, so unused joins are free
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.$if(!!options.withExif, (qb) => qb.select(selectExifInfo)) .$if(!!options.withExif, (qb) => qb.select(selectReturnedExifInfo))
.$if(scopeGlobally, (qb) => qb.where(ownershipPredicate)) .$if(scopeGlobally, (qb) => qb.where(ownershipPredicate))
.where((eb) => .where((eb) =>
eb.or([eb('asset.visibility', '!=', AssetVisibility.Locked), eb('asset.ownerId', '=', scope.lockedOwnerId)]), eb.or([eb('asset.visibility', '!=', AssetVisibility.Locked), eb('asset.ownerId', '=', scope.lockedOwnerId)]),
@@ -530,6 +530,112 @@ describe('core plugin', () => {
}); });
}); });
describe('assetExifFilter', () => {
describe('missing', () => {
it('should favorite asset if it has no orientation', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video });
await ctx.newExif({ assetId: asset.id, orientation: null });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
steps: [
{
method: 'immich-plugin-core#assetExifFilter',
config: { property: 'orientation', matchType: 'missing' },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
it('should not favorite asset if it has orientation', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video });
await ctx.newExif({ assetId: asset.id, orientation: '2' });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
steps: [
{
method: 'immich-plugin-core#assetExifFilter',
config: { property: 'orientation', matchType: 'missing' },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
});
describe('present', () => {
it('should favorite asset if it has orientation', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video });
await ctx.newExif({ assetId: asset.id, orientation: '2' });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
steps: [
{
method: 'immich-plugin-core#assetExifFilter',
config: { property: 'orientation', matchType: 'present' },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
it('should not favorite asset if it has no orientation', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video });
await ctx.newExif({ assetId: asset.id, orientation: null });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
steps: [
{
method: 'immich-plugin-core#assetExifFilter',
config: { property: 'orientation', matchType: 'present' },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
});
});
describe('assetDateFilter', () => { describe('assetDateFilter', () => {
it('should favorite assets created during the first 7 days of a specific year and month', async () => { it('should favorite assets created during the first 7 days of a specific year and month', async () => {
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
@@ -61,16 +61,14 @@
currentMemories.flatMap((memory) => { currentMemories.flatMap((memory) => {
const asset = memory.assets[frame % memory.assets.length]; const asset = memory.assets[frame % memory.assets.length];
return asset return asset
? [ ? {
{ id: memory.id,
id: memory.id, title: $memoryLaneTitle(memory),
title: $memoryLaneTitle(memory), href: Route.viewMemory({ id: memory.id, assetId: asset.id }),
href: Route.viewMemory({ id: memory.id, assetId: asset.id }), alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(asset)) } }),
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(asset)) } }), src: getAssetMediaUrl({ id: asset.id }),
src: getAssetMediaUrl({ id: asset.id }), type: memory.type,
type: memory.type, }
},
]
: []; : [];
}), }),
); );
@@ -24,5 +24,5 @@ export function updateObject(target: any, source: any): boolean {
return updated; return updated;
} }
export function isMismatched<T>(option: T | undefined, value: T): boolean { export function isMismatched<T>(option: T | undefined, value: T): boolean {
return option === undefined ? false : option !== value; return option !== undefined && option !== value;
} }
@@ -12,6 +12,7 @@ export const getAdminConfigQuery = () =>
queryOptions({ queryOptions({
queryKey: ['config', 'admin'], queryKey: ['config', 'admin'],
queryFn: ({ signal }) => getAdminConfig({ signal }), queryFn: ({ signal }) => getAdminConfig({ signal }),
gcTime: Infinity,
}); });
export const getAdminConfigDefaultsQuery = () => export const getAdminConfigDefaultsQuery = () =>
@@ -5,4 +5,5 @@ export const getPublicConfigQuery = () =>
queryOptions({ queryOptions({
queryKey: ['config', 'public'], queryKey: ['config', 'public'],
queryFn: ({ signal }) => getPublicConfig({ signal }), queryFn: ({ signal }) => getPublicConfig({ signal }),
gcTime: Infinity,
}); });
+1
View File
@@ -23,5 +23,6 @@ export const getUserConfigQuery = () =>
queryOptions({ queryOptions({
queryKey: ['config', 'user'], queryKey: ['config', 'user'],
queryFn: ({ signal }) => getUserConfig({ signal }), queryFn: ({ signal }) => getUserConfig({ signal }),
gcTime: Infinity,
select: withEffectiveFlags, select: withEffectiveFlags,
}); });
+1
View File
@@ -23,6 +23,7 @@ export const getServerConfigQuery = () =>
queryOptions({ queryOptions({
queryKey: ['server', 'config'], queryKey: ['server', 'config'],
queryFn: ({ signal }) => getServerConfig({ signal }), queryFn: ({ signal }) => getServerConfig({ signal }),
gcTime: Infinity,
}); });
export const getServerStatisticsQuery = () => export const getServerStatisticsQuery = () =>
+1 -1
View File
@@ -10,7 +10,7 @@ class SidebarStore {
/** Toggles the sidebar visibility, if available at the current screen width. */ /** Toggles the sidebar visibility, if available at the current screen width. */
toggle() { toggle() {
this.isOpen = mediaQueryManager.isFullSidebar ? true : !this.isOpen; this.isOpen = mediaQueryManager.isFullSidebar || !this.isOpen;
} }
} }
@@ -1,6 +1,5 @@
import { authManager } from '#lib/managers/auth-manager.svelte.ts'; import { authManager } from '#lib/managers/auth-manager.svelte.ts';
import { CastDestinationType, CastState, type ICastDestination } from '#lib/managers/cast-manager.svelte.ts'; import { CastDestinationType, CastState, type ICastDestination } from '#lib/managers/cast-manager.svelte.ts';
import 'chromecast-caf-sender';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
const FRAMEWORK_LINK = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'; const FRAMEWORK_LINK = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
-2
View File
@@ -121,7 +121,6 @@ export const calculateFittedFontSize = (
return clamp(fontSize, MIN_FONT_SIZE, MAX_FONT_SIZE); return clamp(fontSize, MIN_FONT_SIZE, MAX_FONT_SIZE);
} }
// eslint-disable-next-line tscompat/tscompat -- This assigns the Canvas 2D font property, not a font() method.
context.font = getReferenceFont(); context.font = getReferenceFont();
let maxCharWidth = 0; let maxCharWidth = 0;
@@ -146,7 +145,6 @@ export const calculateFittedFontSize = (
return clamp((1.4 * fitWidth) / text.length, MIN_FONT_SIZE, MAX_FONT_SIZE); return clamp((1.4 * fitWidth) / text.length, MIN_FONT_SIZE, MAX_FONT_SIZE);
} }
// eslint-disable-next-line tscompat/tscompat -- This assigns the Canvas 2D font property, not a font() method.
context.font = getReferenceFont(); context.font = getReferenceFont();
const metrics = context.measureText(text); const metrics = context.measureText(text);
-3
View File
@@ -11,7 +11,6 @@ export async function acquireWakeLock() {
return; return;
} }
// eslint-disable-next-line tscompat/tscompat -- The feature check above guards Wake Lock access.
if (sentinel && !sentinel.released) { if (sentinel && !sentinel.released) {
return; return;
} }
@@ -20,7 +19,6 @@ export async function acquireWakeLock() {
} }
acquiring = true; acquiring = true;
try { try {
// eslint-disable-next-line tscompat/tscompat -- Browsers without Wake Lock return before this call.
sentinel = await navigator.wakeLock.request('screen'); sentinel = await navigator.wakeLock.request('screen');
} catch (error) { } catch (error) {
console.warn('Failed to acquire wake lock:', error); console.warn('Failed to acquire wake lock:', error);
@@ -38,7 +36,6 @@ export async function releaseWakeLock() {
// Unset first to avoid race condition after await // Unset first to avoid race condition after await
sentinel = undefined; sentinel = undefined;
// eslint-disable-next-line tscompat/tscompat -- A sentinel is only acquired on browsers supporting Wake Lock.
await toReleaseSentinel.release(); await toReleaseSentinel.release();
} }
@@ -226,7 +226,7 @@
return { albumId, order: album.order }; return { albumId, order: album.order };
}); });
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 1); const isShared = $derived(viewMode !== AlbumPageViewMode.SELECT_ASSETS && album.albumUsers.length > 1);
$effect(() => { $effect(() => {
if (assetViewerManager.isViewing || !isShared) { if (assetViewerManager.isViewing || !isShared) {
@@ -248,10 +248,10 @@
const isEditor = $derived(isAlbumEditor(album)); const isEditor = $derived(isAlbumEditor(album));
const isSelectionMode = $derived( const isSelectionMode = $derived(
viewMode === AlbumPageViewMode.SELECT_ASSETS ? true : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL, viewMode === AlbumPageViewMode.SELECT_ASSETS || viewMode === AlbumPageViewMode.SELECT_THUMBNAIL,
); );
const singleSelect = $derived( const singleSelect = $derived(
viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL, viewMode !== AlbumPageViewMode.SELECT_ASSETS && viewMode === AlbumPageViewMode.SELECT_THUMBNAIL,
); );
const showArchiveIcon = $derived(viewMode !== AlbumPageViewMode.SELECT_ASSETS); const showArchiveIcon = $derived(viewMode !== AlbumPageViewMode.SELECT_ASSETS);
const onSelect = ({ id }: { id: string }) => { const onSelect = ({ id }: { id: string }) => {
@@ -6,7 +6,7 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
let gCastEnabled = $state(authManager.authenticated ? authManager.preferences.cast.gCastEnabled : false); let gCastEnabled = $state(authManager.authenticated && authManager.preferences.cast.gCastEnabled);
onDestroy(async () => { onDestroy(async () => {
try { try {
+2 -3
View File
@@ -5,14 +5,13 @@
"paths": { "paths": {
"#i18n/*": ["../i18n/*"], "#i18n/*": ["../i18n/*"],
"#test-data/*": ["./src/test-data/*"], "#test-data/*": ["./src/test-data/*"],
"#tests/*": ["./src/../tests/*"], "#tests/*": ["./src/../tests/*"]
"chromecast-caf-sender": ["./node_modules/@types/chromecast-caf-sender/index.d.ts"]
}, },
"rootDir": ".", "rootDir": ".",
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"target": "es2022", "target": "es2022",
"types": ["$app/types", "vitest/globals"] "types": ["$app/types", "vitest/globals", "chromecast-caf-sender"]
}, },
"exclude": ["vite.config.ts", "src/service-worker/*"], "exclude": ["vite.config.ts", "src/service-worker/*"],
"extends": "$app/tsconfig", "extends": "$app/tsconfig",