mirror of
https://github.com/immich-app/immich.git
synced 2026-02-14 04:47:57 +03:00
Merge branch 'fix/sidecar-check-job' of https://github.com/immich-app/immich into feat/sidecar-asset-files
This commit is contained in:
@@ -778,9 +778,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('addAssetsToAlbums', () => {
|
||||
it('should allow the owner to add assets', async () => {
|
||||
mocks.access.album.checkOwnerAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||
@@ -792,7 +790,7 @@ describe(AlbumService.name, () => {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||
).resolves.toEqual({ success: true, error: undefined });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||
@@ -805,14 +803,18 @@ describe(AlbumService.name, () => {
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||
{ albumsId: 'album-123', assetsId: 'asset-1' },
|
||||
{ albumsId: 'album-123', assetsId: 'asset-2' },
|
||||
{ albumsId: 'album-123', assetsId: 'asset-3' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-1' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-2' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not set the thumbnail if the album has one already', async () => {
|
||||
mocks.access.album.checkOwnerAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }))
|
||||
@@ -824,7 +826,7 @@ describe(AlbumService.name, () => {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||
).resolves.toEqual({ success: true, error: undefined });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||
@@ -837,14 +839,18 @@ describe(AlbumService.name, () => {
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-id',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||
{ albumsId: 'album-123', assetsId: 'asset-1' },
|
||||
{ albumsId: 'album-123', assetsId: 'asset-2' },
|
||||
{ albumsId: 'album-123', assetsId: 'asset-3' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-1' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-2' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should allow a shared user to add assets', async () => {
|
||||
mocks.access.album.checkSharedAlbumAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||
@@ -856,7 +862,7 @@ describe(AlbumService.name, () => {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||
).resolves.toEqual({ success: true, error: undefined });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||
@@ -869,8 +875,14 @@ describe(AlbumService.name, () => {
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||
{ albumsId: 'album-123', assetsId: 'asset-1' },
|
||||
{ albumsId: 'album-123', assetsId: 'asset-2' },
|
||||
{ albumsId: 'album-123', assetsId: 'asset-3' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-1' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-2' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-3' },
|
||||
]);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||
id: 'album-123',
|
||||
recipientId: 'admin_id',
|
||||
@@ -896,18 +908,14 @@ describe(AlbumService.name, () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.UNKNOWN,
|
||||
error: BulkIdErrorReason.NO_PERMISSION,
|
||||
});
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow a shared link user to add assets to multiple albums', async () => {
|
||||
mocks.access.album.checkSharedLinkAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set());
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set(['album-123']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||
@@ -919,7 +927,7 @@ describe(AlbumService.name, () => {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 });
|
||||
).resolves.toEqual({ success: true, error: undefined });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||
@@ -927,22 +935,23 @@ describe(AlbumService.name, () => {
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||
{ albumsId: 'album-123', assetsId: 'asset-1' },
|
||||
{ albumsId: 'album-123', assetsId: 'asset-2' },
|
||||
{ albumsId: 'album-123', assetsId: 'asset-3' },
|
||||
]);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||
id: 'album-123',
|
||||
recipientId: 'user-id',
|
||||
});
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLink?.id,
|
||||
new Set(['album-123']),
|
||||
new Set(['album-123', 'album-321']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow adding assets shared via partner sharing', async () => {
|
||||
mocks.access.album.checkOwnerAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
|
||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||
@@ -954,7 +963,7 @@ describe(AlbumService.name, () => {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||
).resolves.toEqual({ success: true, error: undefined });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||
@@ -967,8 +976,14 @@ describe(AlbumService.name, () => {
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||
{ albumsId: 'album-123', assetsId: 'asset-1' },
|
||||
{ albumsId: 'album-123', assetsId: 'asset-2' },
|
||||
{ albumsId: 'album-123', assetsId: 'asset-3' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-1' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-2' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-3' },
|
||||
]);
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['asset-1', 'asset-2', 'asset-3']),
|
||||
@@ -976,23 +991,21 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip some duplicate assets', async () => {
|
||||
mocks.access.album.checkOwnerAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds
|
||||
.mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3']))
|
||||
.mockResolvedValueOnce(new Set());
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.admin, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 });
|
||||
).resolves.toEqual({ success: true, error: undefined });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', {
|
||||
@@ -1000,8 +1013,11 @@ describe(AlbumService.name, () => {
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||
{ albumsId: 'album-321', assetsId: 'asset-1' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-2' },
|
||||
{ albumsId: 'album-321', assetsId: 'asset-3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip all duplicate assets', async () => {
|
||||
@@ -1021,8 +1037,6 @@ describe(AlbumService.name, () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.DUPLICATE,
|
||||
});
|
||||
|
||||
@@ -1046,9 +1060,7 @@ describe(AlbumService.name, () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.UNKNOWN,
|
||||
error: BulkIdErrorReason.NO_PERMISSION,
|
||||
});
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
@@ -1076,9 +1088,7 @@ describe(AlbumService.name, () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.UNKNOWN,
|
||||
error: BulkIdErrorReason.NO_PERMISSION,
|
||||
});
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
@@ -1099,9 +1109,7 @@ describe(AlbumService.name, () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.UNKNOWN,
|
||||
error: BulkIdErrorReason.NO_PERMISSION,
|
||||
});
|
||||
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled();
|
||||
|
||||
@@ -191,36 +191,57 @@ export class AlbumService extends BaseService {
|
||||
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
|
||||
const results: AlbumsAddAssetsResponseDto = {
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.DUPLICATE,
|
||||
};
|
||||
const successfulAssetIds: Set<string> = new Set();
|
||||
for (const albumId of dto.albumIds) {
|
||||
try {
|
||||
const albumResults = await this.addAssets(auth, albumId, { ids: dto.assetIds });
|
||||
|
||||
let success = false;
|
||||
for (const res of albumResults) {
|
||||
if (res.success) {
|
||||
success = true;
|
||||
results.success = true;
|
||||
results.error = undefined;
|
||||
successfulAssetIds.add(res.id);
|
||||
} else if (results.error && res.error !== BulkIdErrorReason.DUPLICATE) {
|
||||
results.error = BulkIdErrorReason.UNKNOWN;
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
results.albumSuccessCount++;
|
||||
}
|
||||
} catch {
|
||||
if (results.error) {
|
||||
results.error = BulkIdErrorReason.UNKNOWN;
|
||||
}
|
||||
const allowedAlbumIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.AlbumAssetCreate,
|
||||
ids: dto.albumIds,
|
||||
});
|
||||
if (allowedAlbumIds.size === 0) {
|
||||
results.error = BulkIdErrorReason.NO_PERMISSION;
|
||||
return results;
|
||||
}
|
||||
|
||||
const allowedAssetIds = await this.checkAccess({ auth, permission: Permission.AssetShare, ids: dto.assetIds });
|
||||
if (allowedAssetIds.size === 0) {
|
||||
results.error = BulkIdErrorReason.NO_PERMISSION;
|
||||
return results;
|
||||
}
|
||||
|
||||
const albumAssetValues: { albumsId: string; assetsId: string }[] = [];
|
||||
const events: { id: string; recipients: string[] }[] = [];
|
||||
for (const albumId of allowedAlbumIds) {
|
||||
const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]);
|
||||
const notPresentAssetIds = [...allowedAssetIds].filter((id) => !existingAssetIds.has(id));
|
||||
if (notPresentAssetIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const album = await this.findOrFail(albumId, { withAssets: false });
|
||||
results.error = undefined;
|
||||
results.success = true;
|
||||
|
||||
for (const assetId of notPresentAssetIds) {
|
||||
albumAssetValues.push({ albumsId: albumId, assetsId: assetId });
|
||||
}
|
||||
await this.albumRepository.update(albumId, {
|
||||
id: albumId,
|
||||
updatedAt: new Date(),
|
||||
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
|
||||
});
|
||||
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
|
||||
(userId) => userId !== auth.user.id,
|
||||
);
|
||||
events.push({ id: albumId, recipients: allUsersExceptUs });
|
||||
}
|
||||
|
||||
await this.albumRepository.addAssetIdsToAlbums(albumAssetValues);
|
||||
for (const event of events) {
|
||||
for (const recipientId of event.recipients) {
|
||||
await this.eventRepository.emit('AlbumUpdate', { id: event.id, recipientId });
|
||||
}
|
||||
}
|
||||
results.assetSuccessCount = successfulAssetIds.size;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -238,11 +238,11 @@ describe(JobService.name, () => {
|
||||
|
||||
const tests: Array<{ item: JobItem; jobs: JobName[]; stub?: any }> = [
|
||||
{
|
||||
item: { name: JobName.SidecarSync, data: { id: 'asset-1' } },
|
||||
item: { name: JobName.SidecarCheck, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.AssetExtractMetadata],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.SidecarDiscovery, data: { id: 'asset-1' } },
|
||||
item: { name: JobName.SidecarCheck, data: { id: 'asset-1' } },
|
||||
jobs: [JobName.AssetExtractMetadata],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -309,8 +309,7 @@ export class JobService extends BaseService {
|
||||
*/
|
||||
private async onDone(item: JobItem) {
|
||||
switch (item.name) {
|
||||
case JobName.SidecarSync:
|
||||
case JobName.SidecarDiscovery: {
|
||||
case JobName.SidecarCheck: {
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: item.data });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -527,7 +527,7 @@ describe(LibraryService.name, () => {
|
||||
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.SidecarDiscovery,
|
||||
name: JobName.SidecarCheck,
|
||||
data: {
|
||||
id: assetStub.external.id,
|
||||
source: 'upload',
|
||||
@@ -573,7 +573,7 @@ describe(LibraryService.name, () => {
|
||||
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.SidecarDiscovery,
|
||||
name: JobName.SidecarCheck,
|
||||
data: {
|
||||
id: assetStub.image.id,
|
||||
source: 'upload',
|
||||
|
||||
@@ -123,6 +123,10 @@ export class LibraryService extends BaseService {
|
||||
{
|
||||
usePolling: false,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 5000,
|
||||
pollInterval: 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
onReady: () => _resolve(),
|
||||
@@ -410,7 +414,7 @@ export class LibraryService extends BaseService {
|
||||
// We queue a sidecar discovery which, in turn, queues metadata extraction
|
||||
await this.jobRepository.queueAll(
|
||||
assetIds.map((assetId) => ({
|
||||
name: JobName.SidecarDiscovery,
|
||||
name: JobName.SidecarCheck,
|
||||
data: { id: assetId, source: 'upload' },
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -42,7 +42,6 @@ describe(MediaService.name, () => {
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image]));
|
||||
|
||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
|
||||
mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BinaryField, ExifDateTime } from 'exiftool-vendored';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Stats } from 'node:fs';
|
||||
import { constants } from 'node:fs/promises';
|
||||
import { defaults } from 'src/config';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
@@ -24,6 +23,21 @@ import { tagStub } from 'test/fixtures/tag.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const forSidecarJob = (
|
||||
asset: {
|
||||
id?: string;
|
||||
originalPath?: string;
|
||||
sidecarPath?: string | null;
|
||||
} = {},
|
||||
) => {
|
||||
return {
|
||||
id: factory.uuid(),
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
sidecarPath: null,
|
||||
...asset,
|
||||
};
|
||||
};
|
||||
|
||||
const makeFaceTags = (face: Partial<{ Name: string }> = {}, orientation?: ImmichTags['Orientation']) => ({
|
||||
Orientation: orientation,
|
||||
RegionInfo: {
|
||||
@@ -1473,7 +1487,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.SidecarSync,
|
||||
name: JobName.SidecarCheck,
|
||||
data: { id: assetStub.sidecar.id },
|
||||
},
|
||||
]);
|
||||
@@ -1487,159 +1501,66 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.SidecarDiscovery,
|
||||
name: JobName.SidecarCheck,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSidecarSync', () => {
|
||||
describe('handleSidecarCheck', () => {
|
||||
it("should do nothing if asset isn't found", async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0);
|
||||
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed);
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: assetStub.image.id })).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing if asset has no sidecar file', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed);
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
it('should detect a new sidecar at .jpg.xmp', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
|
||||
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: `/path/to/IMG_123.jpg.xmp` });
|
||||
});
|
||||
|
||||
it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
it('should detect a new sidecar at .xmp', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
|
||||
|
||||
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith(
|
||||
`${assetStub.sidecar.originalPath}.xmp`,
|
||||
constants.R_OK,
|
||||
);
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: assetStub.sidecar.id,
|
||||
path: assetStub.sidecar.files[1].path,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set sidecar path if exists (sidecar named photo.xmp)', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(
|
||||
removeNonSidecarFiles(assetStub.sidecarWithoutExt as any),
|
||||
);
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
assetStub.sidecarWithoutExt.files[1].path,
|
||||
constants.R_OK,
|
||||
);
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: assetStub.sidecarWithoutExt.id,
|
||||
path: assetStub.sidecarWithoutExt.files[1].path,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
});
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.files[1].path, constants.R_OK);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
assetStub.sidecarWithoutExt.files[1].path,
|
||||
constants.R_OK,
|
||||
);
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: assetStub.sidecar.id,
|
||||
path: assetStub.sidecar.files[1].path,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: '/path/to/IMG_123.xmp' });
|
||||
});
|
||||
|
||||
it('should unset sidecar path if file does not exist anymore', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg.xmp' });
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(false);
|
||||
|
||||
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith(
|
||||
`${assetStub.sidecar.originalPath}.xmp`,
|
||||
constants.R_OK,
|
||||
);
|
||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith({
|
||||
assetId: assetStub.sidecar.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
});
|
||||
});
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
describe('handleSidecarDiscovery', () => {
|
||||
it('should skip hidden assets', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset as any);
|
||||
await expect(sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(
|
||||
JobStatus.Skipped,
|
||||
);
|
||||
|
||||
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: null });
|
||||
});
|
||||
|
||||
it('should skip assets that already have a known sidecar', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
|
||||
await expect(sut.handleSidecarDiscovery({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Skipped);
|
||||
it('should do nothing if the sidecar file still exists', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg' });
|
||||
|
||||
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
});
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||
|
||||
it('should do nothing when no sidecar is found on disk', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.storage.checkFileExists.mockResolvedValue(false);
|
||||
await expect(sut.handleSidecarDiscovery({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.xmp', constants.R_OK);
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a image asset when a sidecar is found on disk', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
await expect(sut.handleSidecarDiscovery({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.xmp', constants.R_OK);
|
||||
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: assetStub.image.id,
|
||||
path: '/original/path.jpg.xmp',
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update a video asset when a sidecar is found', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
await expect(sut.handleSidecarDiscovery({ id: assetStub.video.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.xmp', constants.R_OK);
|
||||
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: assetStub.image.id,
|
||||
path: '/original/path.ext.xmp',
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSidecarWrite', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import _ from 'lodash';
|
||||
import { Duration } from 'luxon';
|
||||
import { Stats } from 'node:fs';
|
||||
import { constants } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { join, parse } from 'node:path';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset, AssetFace, AssetFile } from 'src/database';
|
||||
@@ -331,7 +331,7 @@ export class MetadataService extends BaseService {
|
||||
|
||||
const assets = this.assetJobRepository.streamForSidecar(force);
|
||||
for await (const asset of assets) {
|
||||
jobs.push({ name: force ? JobName.SidecarSync : JobName.SidecarDiscovery, data: { id: asset.id } });
|
||||
jobs.push({ name: JobName.SidecarCheck, data: { id: asset.id } });
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await queueAll();
|
||||
}
|
||||
@@ -342,14 +342,43 @@ export class MetadataService extends BaseService {
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.SidecarSync, queue: QueueName.Sidecar })
|
||||
handleSidecarSync({ id }: JobOf<JobName.SidecarSync>): Promise<JobStatus> {
|
||||
return this.processSidecar(id, true);
|
||||
}
|
||||
@OnJob({ name: JobName.SidecarCheck, queue: QueueName.Sidecar })
|
||||
async handleSidecarCheck({ id }: JobOf<JobName.SidecarCheck>): Promise<JobStatus | undefined> {
|
||||
const asset = await this.assetJobRepository.getForSidecarCheckJob(id);
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.SidecarDiscovery, queue: QueueName.Sidecar })
|
||||
handleSidecarDiscovery({ id }: JobOf<JobName.SidecarDiscovery>): Promise<JobStatus> {
|
||||
return this.processSidecar(id, false);
|
||||
let sidecarPath = null;
|
||||
for (const candidate of this.getSidecarCandidates(asset)) {
|
||||
const exists = await this.storageRepository.checkFileExists(candidate, constants.R_OK);
|
||||
if (!exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sidecarPath = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
const existingSidecar = asset.files ? asset.files.find((file) => file.type === AssetFileType.Sidecar) : null;
|
||||
|
||||
const isChanged = sidecarPath !== existingSidecar?.path;
|
||||
|
||||
this.logger.debug(
|
||||
`Sidecar check found old=${existingSidecar?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
|
||||
if (!isChanged) {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
if (sidecarPath === null) {
|
||||
await this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar });
|
||||
} else {
|
||||
await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath });
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AssetTag' })
|
||||
@@ -399,6 +428,21 @@ export class MetadataService extends BaseService {
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
private getSidecarCandidates({ files, originalPath }: { files: AssetFile[] | null; originalPath: string }) {
|
||||
const candidates: string[] = [];
|
||||
|
||||
const assetPath = parse(originalPath);
|
||||
|
||||
candidates.push(
|
||||
// IMG_123.jpg.xmp
|
||||
`${originalPath}.xmp`,
|
||||
// IMG_123.xmp
|
||||
`${join(assetPath.dir, assetPath.name)}.xmp`,
|
||||
);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private getImageDimensions(exifTags: ImmichTags): { width?: number; height?: number } {
|
||||
/*
|
||||
* The "true" values for width and height are a bit hidden, depending on the camera model and file format.
|
||||
@@ -586,7 +630,7 @@ export class MetadataService extends BaseService {
|
||||
checksum,
|
||||
ownerId: asset.ownerId,
|
||||
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
|
||||
originalFileName: `${path.parse(asset.originalFileName).name}.mp4`,
|
||||
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
@@ -898,80 +942,4 @@ export class MetadataService extends BaseService {
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForMetadataExtraction(id);
|
||||
|
||||
if (!asset) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
if (isSync) {
|
||||
// We are performing a sidecar sync
|
||||
if (asset.files.length === 0) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
} else if (!asset.isExternal) {
|
||||
// We are performing a sidecar discovery and not on an external library
|
||||
if (asset.visibility === AssetVisibility.Hidden) {
|
||||
// Skip hidden assets
|
||||
return JobStatus.Skipped;
|
||||
} else if (asset.files.length > 1) {
|
||||
// Skip assets that already have a sidecar
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
}
|
||||
|
||||
let currentSidecar: AssetFile | null;
|
||||
|
||||
if (asset.files.length === 0) {
|
||||
currentSidecar = null;
|
||||
} else if (asset.files.length === 1) {
|
||||
currentSidecar = asset.files[0];
|
||||
} else {
|
||||
throw new Error(
|
||||
`Multiple sidecar files found for asset ${asset.id}: ${asset.files.map((file) => file.path).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
||||
const assetPath = path.parse(asset.originalPath);
|
||||
const assetPathWithoutExt = path.join(assetPath.dir, assetPath.name);
|
||||
const sidecarPathWithoutExt = `${assetPathWithoutExt}.xmp`;
|
||||
const sidecarPathWithExt = `${asset.originalPath}.xmp`;
|
||||
|
||||
const [sidecarPathWithExtExists, sidecarPathWithoutExtExists] = await Promise.all([
|
||||
this.storageRepository.checkFileExists(sidecarPathWithExt, constants.R_OK),
|
||||
this.storageRepository.checkFileExists(sidecarPathWithoutExt, constants.R_OK),
|
||||
]);
|
||||
|
||||
let sidecarPath = null;
|
||||
if (sidecarPathWithExtExists) {
|
||||
// Sidecars with the extension have precedence over those without
|
||||
sidecarPath = sidecarPathWithExt;
|
||||
} else if (sidecarPathWithoutExtExists) {
|
||||
sidecarPath = sidecarPathWithoutExt;
|
||||
}
|
||||
|
||||
if (asset.isExternal) {
|
||||
if (sidecarPath !== currentSidecar?.path) {
|
||||
await (sidecarPath
|
||||
? this.assetRepository.upsertFile({ assetId: asset.id, path: sidecarPath, type: AssetFileType.Sidecar })
|
||||
: this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar }));
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
if (sidecarPath) {
|
||||
this.logger.debug(`Detected sidecar at '${sidecarPath}' for asset ${asset.id}: ${asset.originalPath}`);
|
||||
await this.assetRepository.upsertFile({ assetId: asset.id, path: sidecarPath, type: AssetFileType.Sidecar });
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
this.logger.debug(`No sidecar found for asset ${asset.id}: ${asset.originalPath}`);
|
||||
await this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar });
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,10 @@ export class PersonService extends BaseService {
|
||||
throw new BadRequestException('Invalid assetId for feature face');
|
||||
}
|
||||
|
||||
if (face.asset.isOffline) {
|
||||
throw new BadRequestException('An offline asset cannot be used for feature face');
|
||||
}
|
||||
|
||||
faceId = face.id;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user