Compare commits

..

1 Commits

Author SHA1 Message Date
Santo Shakil 2a0a608e14 fix(mobile): order album/place/person timelines by local date 2026-06-26 16:47:13 +06:00
32 changed files with 1064 additions and 1019 deletions
+2 -2
View File
@@ -716,7 +716,7 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
- name: Install server dependencies
run: pnpm --filter immich install --frozen-lockfile
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Run API generation
run: mise //:open-api
working-directory: open-api
@@ -774,7 +774,7 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
- name: Install server dependencies
run: pnpm install --frozen-lockfile
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build plugins
run: mise //:plugins
@@ -14,8 +14,6 @@ Under Email, enter the required details to connect with an SMTP server.
You can use [this guide](/guides/smtp-gmail) to use Gmail's SMTP server.
You can use [this guide](/guides/smtp-microsoft365) to use Microsoft's SMTP server.
## User's notifications settings
Users can manage their email notification settings from their account settings page on the web. They can choose to turn email notifications on or off for the following events:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

-19
View File
@@ -1,19 +0,0 @@
# SMTP settings using Microsoft 365
This guide walks you through how to get the information you need to set up your Immich instance to send emails using Microsoft's SMTP server.
## Create an app password
You will need to generate an app password to use your Microsoft email in Immich. Depending on if you have a personal or business account, you can use https://go.microsoft.com/fwlink/?linkid=2274139 or https://myaccount.microsoft.com/securtiy-info respectively.
## Entering the SMTP credential in Immich
Entering your credential in Immich's email notification settings at `Administration -> Settings -> Notification Settings`
Host: smtp-mail.outlook.com
Port: 587
username: your mail address
Password: app password you created earlier
SMTPS: set it to disabled
<img src={require('./img/email-ms-settings.webp').default} width="80%" title="SMTP settings" />
+1 -1
View File
@@ -48,7 +48,7 @@
"pngjs": "^7.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.35.2",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^6.0.0",
+1
View File
@@ -57,6 +57,7 @@ dir = "open-api"
run = "bash ./bin/generate-dart-sdk.sh"
[tasks.open-api]
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
run = [
{ task = "//:plugins" },
{ task = "//server:install" },
@@ -181,7 +181,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchRemoteAlbumBucket(albumId, groupBy: groupBy),
assetSource: (offset, count) => _getRemoteAlbumBucketAssets(albumId, offset: offset, count: count),
assetSource: (offset, count) =>
_getRemoteAlbumBucketAssets(albumId, groupBy: groupBy, offset: offset, count: count),
origin: TimelineOrigin.remoteAlbum,
);
@@ -235,7 +236,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.handleError((error) => const <Bucket>[]);
}
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(String albumId, {required int offset, required int count}) async {
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(
String albumId, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) async {
final albumData = await (_db.remoteAlbumEntity.select()..where((row) => row.id.equals(albumId))).getSingleOrNull();
// If album doesn't exist (was deleted), return empty list
@@ -262,11 +268,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
),
])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId));
if (isAscending) {
query.orderBy([OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
} else {
query.orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]);
}
// Order assets by the same effective date the buckets group by, otherwise offset
// paging puts an asset whose localDateTime differs from createdAt under the wrong
// date header (#28852). createdAt is the within-day tiebreak.
OrderingTerm ord(Expression<Object> exp) => isAscending ? OrderingTerm.asc(exp) : OrderingTerm.desc(exp);
query.orderBy([
if (groupBy != GroupAssetsBy.none) ord(_db.remoteAssetEntity.effectiveCreatedAt(groupBy)),
ord(_db.remoteAssetEntity.createdAt),
]);
query.limit(count, offset: offset);
@@ -373,13 +382,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
TimelineQuery place(String place, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPlaceBucket(place, groupBy: groupBy),
assetSource: (offset, count) => _getPlaceBucketAssets(place, offset: offset, count: count),
assetSource: (offset, count) => _getPlaceBucketAssets(place, groupBy: groupBy, offset: offset, count: count),
origin: TimelineOrigin.place,
);
TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy),
assetSource: (offset, count) => _getPersonBucketAssets(userId, personId, offset: offset, count: count),
assetSource: (offset, count) =>
_getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count),
origin: TimelineOrigin.person,
);
@@ -416,7 +426,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}).watch();
}
Future<List<BaseAsset>> _getPlaceBucketAssets(String place, {required int offset, required int count}) {
Future<List<BaseAsset>> _getPlaceBucketAssets(
String place, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
final query =
_db.remoteAssetEntity.select().join([
innerJoin(
@@ -430,7 +445,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteExifEntity.city.equals(place),
)
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
// Match the bucket grouping (#28852); place buckets are always date-grouped.
..orderBy([
OrderingTerm.desc(_db.remoteAssetEntity.effectiveCreatedAt(groupBy)),
OrderingTerm.desc(_db.remoteAssetEntity.createdAt),
])
..limit(count, offset: offset);
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
@@ -486,6 +505,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
String personId, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
final idQuery = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId])
@@ -503,7 +523,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
// Match the bucket grouping (#28852); createdAt is the within-day tiebreak.
..orderBy([
if (groupBy != GroupAssetsBy.none) (row) => OrderingTerm.desc(row.effectiveCreatedAt(groupBy)),
(row) => OrderingTerm.desc(row.createdAt),
])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
@@ -106,57 +106,65 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
borderRadius: const BorderRadius.all(Radius.circular(18.5)),
color: context.colorScheme.surfaceContainerLow,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
context.primaryColor.withValues(alpha: 0.2),
context.primaryColor.withValues(alpha: 0.1),
],
child: Material(
color: context.colorScheme.surfaceContainerLow,
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
onTap: () => _onToggle(!_isEnabled),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
context.primaryColor.withValues(alpha: 0.2),
context.primaryColor.withValues(alpha: 0.1),
],
),
),
child: isProcessing
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: Icon(Icons.cloud_upload_outlined, color: context.primaryColor, size: 24),
),
),
child: isProcessing
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: Icon(Icons.cloud_upload_outlined, color: context.primaryColor, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Text(
"enable_backup".t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
"enable_backup".t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
),
),
],
),
if (errorCount > 0)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
"upload_error_with_count".t(context: context, args: {'count': '$errorCount'}),
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error),
),
),
),
],
),
if (errorCount > 0)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
"upload_error_with_count".t(context: context, args: {'count': '$errorCount'}),
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error),
),
),
],
),
),
Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)),
],
),
Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)),
],
),
),
),
),
@@ -46,6 +46,40 @@ void main() {
expect((assets.first as RemoteAsset).id, remoteAsset.id);
expect([localAsset1.id, localAsset2.id], contains((assets.first as RemoteAsset).localId));
});
test('orders assets by effective date so they land under the correct date bucket (#28852)', () async {
// Buckets group by the effective date = coalesce(localDateTime, createdAt). The asset
// list must use the same ordering, otherwise offset paging puts an asset whose
// localDateTime differs from createdAt under the wrong date header.
final user = await ctx.newUser();
final album = await ctx.newRemoteAlbum(ownerId: user.id, order: .desc);
// A: shown on Sep 3 (localDateTime) but only has the earlier Sep 2 createdAt.
final assetA = await ctx.newRemoteAsset(
ownerId: user.id,
createdAt: DateTime.utc(2024, 9, 2, 12),
localDateTime: DateTime.utc(2024, 9, 3, 12),
);
// B: the inverse — shown on Sep 2 but has the later Sep 3 createdAt.
final assetB = await ctx.newRemoteAsset(
ownerId: user.id,
createdAt: DateTime.utc(2024, 9, 3, 12),
localDateTime: DateTime.utc(2024, 9, 2, 12),
);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: assetA.id);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: assetB.id);
final query = sut.remoteAlbum(album.id, .day);
final buckets = await query.bucketSource().first;
expect(buckets, hasLength(2));
expect(buckets.every((b) => b.assetCount == 1), isTrue);
// Buckets are ordered by effective date desc (Sep 3 then Sep 2), so the asset list
// must be A then B. The pre-fix raw-createdAt ordering returned B first (Sep 3 createdAt),
// which slotted B under the Sep 3 header and A under Sep 2.
final assets = await query.assetSource(0, 10);
expect(assets.map((a) => (a as RemoteAsset).id).toList(), [assetA.id, assetB.id]);
});
});
group('person assets', () {
@@ -69,5 +103,33 @@ void main() {
expect(assets, hasLength(1));
expect((assets.first as RemoteAsset).id, asset.id);
});
test('orders assets by effective date so they land under the correct date bucket (#28852)', () async {
final user = await ctx.newUser();
final person = await ctx.newPerson(ownerId: user.id);
// A shown on Sep 3 (localDateTime) with the earlier Sep 2 createdAt; B is the inverse.
final assetA = await ctx.newRemoteAsset(
ownerId: user.id,
createdAt: DateTime.utc(2024, 9, 2, 12),
localDateTime: DateTime.utc(2024, 9, 3, 12),
);
final assetB = await ctx.newRemoteAsset(
ownerId: user.id,
createdAt: DateTime.utc(2024, 9, 3, 12),
localDateTime: DateTime.utc(2024, 9, 2, 12),
);
await ctx.newFace(assetId: assetA.id, personId: person.id);
await ctx.newFace(assetId: assetB.id, personId: person.id);
final query = sut.person(user.id, person.id, .day);
final buckets = await query.bucketSource().first;
expect(buckets, hasLength(2));
// Buckets are date-desc (Sep 3 then Sep 2); the asset list must match (A then B).
// The pre-fix raw-createdAt order returned B first.
final assets = await query.assetSource(0, 10);
expect(assets.map((a) => (a as RemoteAsset).id).toList(), [assetA.id, assetB.id]);
});
});
}
+2 -1
View File
@@ -102,6 +102,7 @@ class MediumRepositoryContext {
String? stackId,
String? thumbHash,
String? libraryId,
DateTime? localDateTime,
}) async {
id ??= TestUtils.uuid();
createdAt ??= TestUtils.date();
@@ -125,7 +126,7 @@ class MediumRepositoryContext {
isEdited: .new(isEdited ?? false),
livePhotoVideoId: .new(livePhotoVideoId),
stackId: .new(stackId),
localDateTime: .new(createdAt.toLocal()),
localDateTime: .new(localDateTime ?? createdAt.toLocal()),
thumbHash: .new(TestUtils.uuid(thumbHash)),
libraryId: .new(TestUtils.uuid(libraryId)),
),
+1 -1
View File
@@ -16252,7 +16252,7 @@
},
{
"name": "Faces",
"description": "A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created manually."
"description": "A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually."
},
{
"name": "Integrity (admin)",
+90 -22
View File
@@ -233,6 +233,12 @@
}
}
},
{
"name": "assetTimeline",
"title": "Move to timeline",
"description": "Change visibility to timeline",
"types": ["AssetV1"]
},
{
"name": "assetVisibility",
"title": "Update visibility",
@@ -295,38 +301,100 @@
}
},
{
"name": "webhook",
"title": "Trigger Webhook",
"description": "POST/PUT event data to any URL",
"name": "noop1",
"title": "DEV: Nested properties",
"description": "Example configuration with nested properties",
"types": ["AssetV1"],
"hostFunctions": true,
"allowedHosts": ["*"],
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "URL",
"description": "Event data will be PUT/POSTed to this URL as a JSON object"
"number1": {
"type": "number",
"title": "Number 1",
"description": "Basic number"
},
"headerName": {
"type": "string",
"title": "Header name",
"description": "The name of an additional header to include with the request (e.g. authentication)"
"number2": {
"type": "number",
"title": "Number 2",
"array": true,
"description": "List of numbers"
},
"headerValue": {
"string1": {
"type": "string",
"title": "Header value",
"description": "The value of the additional header"
"title": "String 1",
"description": "Basic string"
},
"method": {
"string2": {
"type": "string",
"title": "Method",
"description": "The HTTP method to use in the request",
"enum": ["POST", "PUT"]
"title": "String 2",
"array": true,
"description": "List of strings"
},
"string3": {
"type": "string",
"title": "String 3",
"enum": ["choice-1", "choice-2"],
"description": "Select from a list"
},
"nested": {
"type": "object",
"title": "Nested",
"description": "Nested properties for nesting",
"properties": {
"nested1": {
"type": "string",
"title": "Nested 1",
"description": "Nested string"
},
"nested2": {
"type": "number",
"title": "Nested 2",
"description": "Nested number"
},
"nested3": {
"type": "object",
"title": "Nested 3",
"description": "Nested again",
"properties": {
"nested4": {
"type": "boolean",
"title": "Nested 4",
"description": "Nested, nested boolean"
}
}
}
}
}
},
"required": ["url"]
}
}
},
{
"name": "noop2",
"title": "DEV: Album pickers",
"description": "Example configuration with album pickers",
"types": ["AssetV1"],
"schema": {
"properties": {
"albumId": {
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"uiHint": {
"type": "AlbumId",
"order": 1
}
},
"albumIds": {
"type": "string",
"title": "Album IDs",
"description": "Target album IDs",
"array": true,
"uiHint": {
"type": "AlbumId",
"order": 2
}
}
}
}
}
]
+143 -187
View File
@@ -1,201 +1,157 @@
import { wrapper } from '@immich/plugin-sdk';
import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type { Manifest } from '../dist/index.d.ts';
const methods = wrapper<Manifest>({
assetAddToAlbums: ({ config, data, functions }) => {
const assetId = data.asset.id;
const wrapper = getWrapper<Manifest>();
if (config.albumIds.length === 0) {
if (!config.albumName) {
return {};
}
export const assetFileFilter = wrapper<'assetFileFilter'>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
const { asset } = data;
config.albumIds.push(existing.id);
const fileName = asset.originalFileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
if (config.albumIds.length === 1) {
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
}
default: {
return {};
}
}
});
export const assetMissingTimeZoneFilter = wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
});
export const assetLocationFilter = wrapper<'assetLocationFilter'>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
) {
return { workflow: { continue: false } };
}
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
return { workflow: { continue: true } };
}
const assetLat = data.asset.exifInfo?.latitude;
const assetLon = data.asset.exifInfo?.longitude;
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
return { workflow: { continue: false } };
}
const earthDiameter = 12742;
const deg = Math.PI / 180;
const delta = Math.asin(
Math.sqrt(
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
Math.cos(assetLat * deg) *
Math.cos(configLat * deg) *
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
),
);
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
});
export const assetTypeFilter = wrapper<'assetTypeFilter'>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
export const assetFavorite = wrapper<'assetFavorite'>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
changes: {
asset: { isFavorite: target },
},
};
}
});
export const assetVisibility = wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
}));
export const assetArchive = wrapper<'assetArchive'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
});
export const assetLock = wrapper<'assetLock'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
});
// export const assetTrash = () => {
// // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// };
export const assetAddToAlbums = wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
if (!config.albumName) {
return {};
}
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
config.albumIds.push(existing.id);
}
if (config.albumIds.length === 1) {
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
return {};
},
}
assetArchive: ({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
},
assetFavorite: ({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
changes: {
asset: { isFavorite: target },
},
};
}
},
assetFileFilter: ({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data;
const fileName = asset.originalFileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
}
default: {
return {};
}
}
},
assetLocationFilter: ({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
) {
return { workflow: { continue: false } };
}
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
return { workflow: { continue: true } };
}
const assetLat = data.asset.exifInfo?.latitude;
const assetLon = data.asset.exifInfo?.longitude;
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
return { workflow: { continue: false } };
}
const earthDiameter = 12742;
const deg = Math.PI / 180;
const delta = Math.asin(
Math.sqrt(
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
Math.cos(assetLat * deg) *
Math.cos(configLat * deg) *
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
),
);
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
},
assetLock: ({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
},
assetMissingTimeZoneFilter: ({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
},
assetTypeFilter: ({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
},
assetVisibility: ({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
}),
webhook: ({ config, data, functions }) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (config.headerName && config.headerValue) {
headers[config.headerName] = config.headerValue;
}
functions.httpRequest(config.url, {
method: config.method ?? 'POST',
body: JSON.stringify(data.asset),
headers,
});
return {};
},
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
return {};
});
const {
assetAddToAlbums,
assetArchive,
assetFavorite,
assetFileFilter,
assetLocationFilter,
assetLock,
assetMissingTimeZoneFilter,
assetTypeFilter,
assetVisibility,
webhook,
// should be empty. ensures that every field is destructured
...rest
} = methods;
export {
assetAddToAlbums,
assetArchive,
assetFavorite,
assetFileFilter,
assetLocationFilter,
assetLock,
assetMissingTimeZoneFilter,
assetTypeFilter,
assetVisibility,
webhook,
};
'All methods must be destructured and exported' satisfies string & typeof rest;
+1 -1
View File
@@ -4,7 +4,7 @@
"declaration": true,
"emitDeclarationOnly": true,
"esModuleInterop": true, // Enables compatibility with Babel-style module imports
"lib": ["es2020", "DOM"], // Specify a list of library files to be included in the compilation
"lib": ["es2020"], // Specify a list of library files to be included in the compilation
"module": "nodenext", // Specify module code generation
"moduleResolution": "nodenext",
"noEmit": true, // Do not emit outputs (no .js or .d.ts files)
-17
View File
@@ -30,23 +30,12 @@ type HostFunctionResult<T> =
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
type HttpRequestOptions = {
method?: string;
headers?: Record<string, string>;
body?: string;
};
type HttpResponse = {
ok: string;
status: number;
body: string;
};
export const availableFunctions = [
'searchAlbums',
'createAlbum',
'addAssetsToAlbum',
'addAssetsToAlbums',
'httpRequest',
] as const;
export const hostFunctions = (authToken: string) => {
@@ -90,11 +79,5 @@ export const hostFunctions = (authToken: string) => {
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
httpRequest: (url: string, options?: HttpRequestOptions) =>
call<[string, HttpRequestOptions | undefined], HttpResponse>(
'httpRequest',
authToken,
[url, options],
),
} satisfies Record<(typeof availableFunctions)[number], unknown>;
};
+45 -47
View File
@@ -1,3 +1,4 @@
import type { WorkflowType } from '@immich/sdk';
import { hostFunctions } from 'src/host-functions.js';
import type {
WorkflowEventPayload,
@@ -52,56 +53,53 @@ type ConfigValue<
'required' extends keyof T ? T['required'] : undefined
>['properties'];
export const wrapper = <T extends Record<string, any>>(methods: {
[K in T['methods'][number] as K['name']]: (
payload: WorkflowEventPayload<
K['types'][number],
ConfigValue<K['schema']>
> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<K['types'][number]> | undefined;
}) => {
const result: { [K in keyof typeof methods]: () => void } = {} as never;
for (const name of Object.keys(methods) as (keyof typeof methods)[]) {
result[name] = () => {
const input = Host.inputString();
export const getWrapper =
<T extends Record<string, any>>() =>
<
K extends T['methods'][number]['name'],
L extends WorkflowType = (T['methods'][number] & {
name: K;
})['types'][number],
TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>,
>(
fn: (
payload: WorkflowEventPayload<L, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<L> | undefined,
) =>
() => {
const input = Host.inputString();
try {
const payload = JSON.parse(input) as WorkflowEventPayload<
typeof name,
(T['methods'][number]['name'] & { name: typeof name })['schema']
>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
try {
const payload = JSON.parse(input) as WorkflowEventPayload<K, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${String(event.type)}, config=${eventConfigBefore}`,
);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = methods[name](event) ?? {};
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
};
}
return result;
};
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
};
+609 -550
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -29,7 +29,7 @@ allowBuilds:
postman-code-generators: false
overrides:
canvas: 3.2.3
sharp: ^0.35.2
sharp: ^0.34.5
packageExtensions:
nestjs-kysely:
dependencies:
+5 -6
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202606180900@sha256:3871e19b02c37d0e3d2ee200e4977e0f2afc77730fd8dcc5e4532b6e2b26bdce AS builder
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -20,9 +20,8 @@ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \
pnpm --filter immich --prod deploy /output/server-pruned && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --dir /output/server-pruned/node_modules/sharp exec npm run build
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --prod --no-optional deploy /output/server-pruned
FROM builder AS web
@@ -38,7 +37,7 @@ RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \
pnpm --filter @immich/sdk --filter immich-web build
FROM builder AS cli
@@ -81,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202606180900@sha256:442159fae88a04b01a4caafbd9813f08aeefb2e1ae3b8a47cc82409208dfbd80
FROM ghcr.io/immich-app/base-server-prod:202606161235@sha256:c6d59e3923f548d29a212b4dc51b6281a722cfa1da7972a009c0f3830f5762d6
WORKDIR /usr/src/app
ENV NODE_ENV=production \
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202606180900@sha256:3871e19b02c37d0e3d2ee200e4977e0f2afc77730fd8dcc5e4532b6e2b26bdce AS dev
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS dev
COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise
+2 -2
View File
@@ -107,7 +107,7 @@
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"semver": "^7.8.1",
"sharp": "^0.35.2",
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0",
@@ -168,6 +168,6 @@
"vitest": "^3.0.0"
},
"overrides": {
"sharp": "^0.35.2"
"sharp": "^0.34.5"
}
}
+1 -1
View File
@@ -155,7 +155,7 @@ export const endpointTags: Record<ApiTag, string> = {
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
[ApiTag.Faces]:
'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created manually.',
'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually.',
[ApiTag.Integrity]: 'Endpoints for viewing and managing integrity reports.',
[ApiTag.Jobs]:
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
-1
View File
@@ -368,7 +368,6 @@ export const columns = {
'plugin_method.types',
'plugin_method.schema',
'plugin_method.hostFunctions',
'plugin_method.allowedHosts',
'plugin_method.uiHints',
],
syncAsset: [
-5
View File
@@ -18,11 +18,6 @@ const PluginManifestMethodSchema = z
description: z.string().min(1).describe('Method description'),
types: z.array(WorkflowTypeSchema).min(1).describe('Workflow type'),
hostFunctions: z.boolean().optional().default(false).describe('Method uses host functions'),
allowedHosts: z
.array(z.string())
.optional()
.default([])
.describe('Hostnames the method can access (use * for wildcards)'),
schema: PluginManifestMethodSchemaSchema.describe('Schema'),
uiHints: z.array(z.string()).optional().describe('Ui hints, for example "filter"'),
})
-5
View File
@@ -48,7 +48,6 @@ select
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
@@ -85,7 +84,6 @@ select
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
@@ -122,7 +120,6 @@ select
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
@@ -159,7 +156,6 @@ select
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
@@ -194,7 +190,6 @@ select
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts",
"plugin_method"."uiHints"
from
"plugin_method"
+1 -2
View File
@@ -80,8 +80,7 @@ select
"plugin_method"."pluginId" as "pluginId",
"plugin_method"."name" as "methodName",
"plugin_method"."types" as "types",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts"
"plugin_method"."hostFunctions"
from
"workflow_step"
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
+2 -3
View File
@@ -190,7 +190,6 @@ export class PluginRepository {
description: ref('excluded.description'),
types: ref('excluded.types'),
hostFunctions: ref('excluded.hostFunctions'),
allowedHosts: ref('excluded.allowedHosts'),
uiHints: ref('excluded.uiHints'),
schema: ref('excluded.schema'),
})),
@@ -241,7 +240,7 @@ export class PluginRepository {
}
}
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown, context?: unknown) {
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown) {
const item = this.pluginMap.get(pluginKey);
if (!item) {
throw new Error(`No loaded plugin found for ${pluginKey}`);
@@ -252,7 +251,7 @@ export class PluginRepository {
try {
const plugin = await pool.acquire();
try {
const result = await plugin.call(methodName, JSON.stringify(input), context);
const result = await plugin.call(methodName, JSON.stringify(input));
return (result ? result.json() : result) as T;
} finally {
await pool.release(plugin);
@@ -79,7 +79,6 @@ export class WorkflowRepository {
'plugin_method.name as methodName',
'plugin_method.types as types',
'plugin_method.hostFunctions',
'plugin_method.allowedHosts',
]),
).as('steps'),
])
@@ -1,9 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin_method" ADD "allowedHosts" character varying[] NOT NULL DEFAULT '{}';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin_method" DROP COLUMN "allowedHosts";`.execute(db);
}
@@ -27,9 +27,6 @@ export class PluginMethodTable {
@Column({ type: 'boolean', default: false })
hostFunctions!: Generated<boolean>;
@Column({ type: 'character varying', default: [], array: true })
allowedHosts!: Generated<string[]>;
@Column({ type: 'jsonb', nullable: true })
schema!: JsonSchemaDto | null;
@@ -42,10 +42,6 @@ type ExecuteOptions<T extends WorkflowType> = {
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
type HostContext = {
allowedHosts: string[];
};
export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string;
@@ -70,48 +66,20 @@ export class WorkflowExecutionService extends BaseService {
const albumService = BaseService.create(AlbumService, this);
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args));
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args));
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, ctx, args) =>
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, args) => albumService.getAll(authDto, ...args));
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, args) => albumService.create(authDto, ...args));
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args),
);
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, ctx, args) =>
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args),
);
const httpRequest = this.wrap<
[
url: string,
options?: {
method?: string;
headers?: Record<string, string>;
body?: string;
},
]
>(async (authDto, context, args) => {
const hostname = new URL(args[0]).hostname;
for (const pattern of context.allowedHosts) {
const regex = new RegExp(pattern.replaceAll('.', String.raw`\.`).replaceAll('*', '.*'));
if (regex.test(hostname)) {
const res = await fetch(...args);
return {
ok: res.ok,
status: res.status,
body: await res.text(),
};
}
}
throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest');
});
const functions = {
searchAlbums,
createAlbum,
addAssetsToAlbum,
addAssetsToAlbums,
httpRequest,
};
const stubs: typeof functions = {
@@ -119,7 +87,6 @@ export class WorkflowExecutionService extends BaseService {
createAlbum: dummy,
addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy,
httpRequest: dummy,
};
const plugins = await this.pluginRepository.getForLoad();
@@ -154,7 +121,7 @@ export class WorkflowExecutionService extends BaseService {
return id + (hostFunctions ? '/worker' : '');
}
private wrap<T>(fn: (authDto: AuthDto, context: HostContext, args: T) => Promise<unknown>) {
private wrap<T>(fn: (authDto: AuthDto, args: T) => Promise<unknown>) {
return async (plugin: CurrentPlugin, offset: bigint) => {
try {
const handle = plugin.read(offset);
@@ -169,9 +136,8 @@ export class WorkflowExecutionService extends BaseService {
throw new Error('authToken is required');
}
const context = plugin.hostContext<HostContext>();
const authDto = this.validate(authToken);
const response = await fn(authDto, context, args);
const response = await fn(authDto, args);
return plugin.store(JSON.stringify({ success: true, response }));
} catch (error: Error | any) {
@@ -415,10 +381,6 @@ export class WorkflowExecutionService extends BaseService {
data,
};
const context: HostContext = {
allowedHosts: step.allowedHosts,
};
if (step.methodName.startsWith('noop')) {
continue;
}
@@ -429,7 +391,6 @@ export class WorkflowExecutionService extends BaseService {
methodName: step.methodName,
},
payload,
context,
);
if (result?.changes) {
await write(
@@ -427,32 +427,4 @@ describe('core plugin', () => {
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
});
describe('webhook', () => {
it('should trigger a webhook on asset upload', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') }));
vi.stubGlobal('fetch', fetchMock);
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{
method: 'immich-plugin-core#webhook',
config: { url: 'http://localhost', method: 'POST' },
},
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalled();
});
afterEach(() => {
vi.unstubAllGlobals();
});
});
});