mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 13:15:22 -04:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17b3676038 | |||
| 6876eb2f05 | |||
| b09ebb11e9 | |||
| 181b028b09 | |||
| eb20b715e4 | |||
| a277c6311f | |||
| 5889c42eb6 | |||
| 14cce0cba3 | |||
| 9b80ffd9c6 | |||
| 306a3b8c7f | |||
| be0fc403d8 | |||
| c13fd9e4b5 | |||
| 8724848fce | |||
| 2d950db940 | |||
| 4b9ebc2cff | |||
| e2d26ebdea | |||
| 8c6adf7157 | |||
| 48fdd39d30 | |||
| 22bf7c2005 | |||
| 47b45453c8 | |||
| 448c069fb6 |
@@ -35,7 +35,12 @@ jobs:
|
||||
close_template:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse_template
|
||||
if: ${{ needs.parse_template.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
||||
if: >-
|
||||
${{
|
||||
needs.parse_template.outputs.uses_template == 'false'
|
||||
&& github.event.pull_request.state != 'closed'
|
||||
&& !contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
|
||||
}}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Duplicates Utility
|
||||
|
||||
Immich comes with a duplicates utility to help you detect assets that look visually similar. The duplicate detection feature relies on machine learning and is enabled by default. For more information about when the duplicate detection job runs, see [Jobs and Workers](/administration/jobs-workers). Once an asset has been processed and added to a duplicate group, it becomes available to review in the "Review duplicates" utility, which can be found [here](https://my.immich.app/utilities/duplicates).
|
||||
|
||||
## Reviewing duplicates
|
||||
|
||||
The review duplicates page allows the user to individually select which assets should be kept and which ones should be trashed. When more than one asset is kept, there is an option to automatically put the kept assets into a stack.
|
||||
|
||||
### Automatic preselection
|
||||
|
||||
When using "Deduplicate All" or viewing suggestions, Immich automatically preselects which assets to keep based on:
|
||||
|
||||
1. **Image size in bytes** — larger files are preferred as they typically have higher quality.
|
||||
2. **Count of EXIF data** — assets with more metadata are preferred.
|
||||
|
||||
### Synchronizing metadata
|
||||
|
||||
When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept assets. The following metadata is synchronized:
|
||||
|
||||
| Name | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Album | The kept assets will be added to _every_ album that the other assets in the group belong to. |
|
||||
| Favorite | If any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. |
|
||||
| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept assets. |
|
||||
| Description | Descriptions from each asset are combined together and synchronized to all the kept assets. |
|
||||
| Visibility | The most restrictive visibility is applied to the kept assets. |
|
||||
| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. |
|
||||
| Tag | Tags from all assets in the group are merged and applied to every kept asset. |
|
||||
@@ -0,0 +1,651 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/duplicates', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
|
||||
admin = await utils.adminSetup();
|
||||
|
||||
[user1, user2] = await Promise.all([
|
||||
utils.userSetup(admin.accessToken, createUserDto.user1),
|
||||
utils.userSetup(admin.accessToken, createUserDto.user2),
|
||||
]);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
|
||||
// Note: We don't reset users since they're set up once in beforeAll
|
||||
// Stack must be reset before asset due to foreign key constraint
|
||||
await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
|
||||
});
|
||||
|
||||
describe('GET /duplicates', () => {
|
||||
it('should return empty array when no duplicates', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/duplicates')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return duplicate groups with suggestedKeepAssetIds', async () => {
|
||||
// Create assets with different file sizes for duplicate detection
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Manually set duplicateId on both assets to create a duplicate group
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000001';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/duplicates')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
{
|
||||
duplicateId,
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({ id: asset1.id }),
|
||||
expect.objectContaining({ id: asset2.id }),
|
||||
]),
|
||||
suggestedKeepAssetIds: expect.any(Array),
|
||||
},
|
||||
]);
|
||||
expect(body[0].suggestedKeepAssetIds.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /duplicates/resolve', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.send({
|
||||
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return failure for non-existent duplicate group', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId: uuidDto.dummy,
|
||||
status: 'FAILED',
|
||||
reason: expect.stringContaining('not found or access denied'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve duplicate group with keepers', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000002';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify side effects: duplicateId cleared on kept asset
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
|
||||
// Verify side effects: trashed asset is trashed and duplicateId cleared
|
||||
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(trashedAsset.isTrashed).toBe(true);
|
||||
expect(trashedAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000003';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('disjoint');
|
||||
});
|
||||
|
||||
it('should require keepAssetIds when partially trashing', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000004';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('must cover all assets');
|
||||
});
|
||||
|
||||
it('should reject partial resolution (not all assets covered)', async () => {
|
||||
const [asset1, asset2, asset3] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000010';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('must cover all assets');
|
||||
});
|
||||
|
||||
it('should reject asset not in duplicate group', async () => {
|
||||
const [asset1, asset2, outsideAsset] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000011';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('not a member of duplicate group');
|
||||
});
|
||||
|
||||
it('should allow trash-all without keepers', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000012';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
status: 'COMPLETED',
|
||||
results: [
|
||||
{
|
||||
duplicateId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify both assets are trashed
|
||||
const [asset1Info, asset2Info] = await Promise.all([
|
||||
utils.getAssetInfo(user1.accessToken, asset1.id),
|
||||
utils.getAssetInfo(user1.accessToken, asset2.id),
|
||||
]);
|
||||
|
||||
expect(asset1Info.isTrashed).toBe(true);
|
||||
expect(asset1Info.duplicateId).toBeNull();
|
||||
expect(asset2Info.isTrashed).toBe(true);
|
||||
expect(asset2Info.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject cross-user duplicate group access', async () => {
|
||||
const asset1 = await utils.createAsset(user1.accessToken);
|
||||
const asset2 = await utils.createAsset(user2.accessToken);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000013';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// User1 tries to resolve a group containing user2's asset
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('FAILED');
|
||||
expect(body.results[0].reason).toContain('not a member of duplicate group');
|
||||
});
|
||||
|
||||
it('should synchronize favorites when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Mark one asset as favorite
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], isFavorite: true });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000020';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify favorite was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.isFavorite).toBe(true);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize visibility when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Archive one asset
|
||||
await utils.archiveAssets(user1.accessToken, [asset2.id]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000021';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify visibility was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.visibility).toBe('archive');
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize rating when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set rating on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], rating: 5 });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000022';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify rating was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.rating).toBe(5);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize description when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set description on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], description: 'Test description for duplicate' });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000023';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify description was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize location when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Set location on one asset
|
||||
await request(app)
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000024';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify location was synchronized to keeper
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
|
||||
expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
});
|
||||
|
||||
it('should synchronize albums when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Create albums and add assets to different albums
|
||||
const album1 = await utils.createAlbum(user1.accessToken, {
|
||||
albumName: 'Album 1',
|
||||
assetIds: [asset1.id],
|
||||
});
|
||||
const album2 = await utils.createAlbum(user1.accessToken, {
|
||||
albumName: 'Album 2',
|
||||
assetIds: [asset2.id],
|
||||
});
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000025';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify keeper is now in both albums
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
|
||||
// Check albums directly
|
||||
const { status: album1Status, body: album1Body } = await request(app)
|
||||
.get(`/albums/${album1.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
const { status: album2Status, body: album2Body } = await request(app)
|
||||
.get(`/albums/${album2.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(album1Status).toBe(200);
|
||||
expect(album2Status).toBe(200);
|
||||
expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
|
||||
expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
|
||||
});
|
||||
|
||||
it('should synchronize tags when enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
// Wait for metadata extraction to complete before adding tags
|
||||
// Otherwise, metadata jobs will race and overwrite our tags
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
// Create tags and tag assets differently
|
||||
const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
|
||||
await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
|
||||
await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000026';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify keeper has both tags
|
||||
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(keptAsset.duplicateId).toBeNull();
|
||||
expect(keptAsset.tags).toBeDefined();
|
||||
const tagIds = keptAsset.tags?.map((t) => t.id) || [];
|
||||
expect(tagIds).toContain(tags[0].id);
|
||||
expect(tagIds).toContain(tags[1].id);
|
||||
});
|
||||
|
||||
it('should handle batch resolve with mixed success and failure', async () => {
|
||||
// Create first group that will succeed
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
const duplicateId1 = '00000000-0000-4000-8000-000000000027';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
|
||||
|
||||
// Create second group with non-existent duplicate ID (will fail)
|
||||
const fakeId = '00000000-0000-4000-8000-000000000099';
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [
|
||||
{ duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
|
||||
{ duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.status).toBe('COMPLETED');
|
||||
expect(body.results).toHaveLength(2);
|
||||
|
||||
// First group should succeed
|
||||
expect(body.results[0].duplicateId).toBe(duplicateId1);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Second group should fail
|
||||
expect(body.results[1].duplicateId).toBe(fakeId);
|
||||
expect(body.results[1].status).toBe('FAILED');
|
||||
expect(body.results[1].reason).toContain('not found or access denied');
|
||||
|
||||
// Verify first group was actually resolved despite second failure
|
||||
const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
|
||||
expect(asset1Info.duplicateId).toBeNull();
|
||||
const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(asset2Info.isTrashed).toBe(true);
|
||||
});
|
||||
|
||||
it('should trash assets when trash is enabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000028';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// Ensure trash is enabled (default)
|
||||
const config = await utils.getSystemConfig(admin.accessToken);
|
||||
expect(config.trash.enabled).toBe(true);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Verify asset is trashed (not deleted)
|
||||
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
|
||||
expect(trashedAsset.isTrashed).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete assets when trash is disabled', async () => {
|
||||
const [asset1, asset2] = await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
utils.createAsset(user1.accessToken),
|
||||
]);
|
||||
|
||||
const duplicateId = '00000000-0000-4000-8000-000000000029';
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
|
||||
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
|
||||
|
||||
// Disable trash
|
||||
await request(app)
|
||||
.put('/system-config')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
trash: { enabled: false, days: 30 },
|
||||
});
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/duplicates/resolve')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({
|
||||
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.results[0].status).toBe('SUCCESS');
|
||||
|
||||
// Asset should be marked as deleted (force delete)
|
||||
const { status: getStatus } = await request(app)
|
||||
.get(`/assets/${asset2.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
// Asset should still be accessible (soft deleted) but marked as deleted
|
||||
expect(getStatus).toBe(200);
|
||||
|
||||
// Re-enable trash for other tests
|
||||
await utils.resetAdminConfig(admin.accessToken);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ export const uuidDto = {
|
||||
invalid: 'invalid-uuid',
|
||||
// valid uuid v4
|
||||
notFound: '00000000-0000-4000-a000-000000000000',
|
||||
dummy: '00000000-0000-4000-a000-000000000001',
|
||||
dummy2: '00000000-0000-4000-a000-000000000002',
|
||||
};
|
||||
|
||||
const adminLoginDto = {
|
||||
|
||||
@@ -424,6 +424,7 @@ describe('/albums', () => {
|
||||
description: '',
|
||||
albumThumbnailAssetId: null,
|
||||
shared: false,
|
||||
isFavorite: false,
|
||||
albumUsers: [],
|
||||
hasSharedLink: false,
|
||||
assets: [],
|
||||
@@ -540,6 +541,44 @@ describe('/albums', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /albums/:id/user-metadata', () => {
|
||||
it('should toggle favorite status per user on a shared album', async () => {
|
||||
const before = await getAlbumInfo({ id: user1Albums[3].id }, { headers: asBearerAuth(user2.accessToken) });
|
||||
expect(before.isFavorite).toBe(false);
|
||||
|
||||
const favoriteResponse = await request(app)
|
||||
.patch(`/albums/${user1Albums[3].id}/user-metadata`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ isFavorite: true });
|
||||
|
||||
expect(favoriteResponse.status).toBe(200);
|
||||
expect(favoriteResponse.body).toMatchObject({ id: user1Albums[3].id, isFavorite: true });
|
||||
|
||||
const favoritedForViewer = await getAlbumInfo(
|
||||
{ id: user1Albums[3].id },
|
||||
{ headers: asBearerAuth(user2.accessToken) },
|
||||
);
|
||||
const unchangedForOwner = await getAlbumInfo(
|
||||
{ id: user1Albums[3].id },
|
||||
{ headers: asBearerAuth(user1.accessToken) },
|
||||
);
|
||||
|
||||
expect(favoritedForViewer.isFavorite).toBe(true);
|
||||
expect(unchangedForOwner.isFavorite).toBe(false);
|
||||
|
||||
const unfavoriteResponse = await request(app)
|
||||
.patch(`/albums/${user1Albums[3].id}/user-metadata`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ isFavorite: false });
|
||||
|
||||
expect(unfavoriteResponse.status).toBe(200);
|
||||
expect(unfavoriteResponse.body).toMatchObject({ id: user1Albums[3].id, isFavorite: false });
|
||||
|
||||
const after = await getAlbumInfo({ id: user1Albums[3].id }, { headers: asBearerAuth(user2.accessToken) });
|
||||
expect(after.isFavorite).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /albums/:id/assets', () => {
|
||||
it('should require authorization', async () => {
|
||||
const { status, body } = await request(app)
|
||||
|
||||
@@ -427,6 +427,7 @@ export function getAlbum(
|
||||
albumUsers: [], // Empty array for non-shared album
|
||||
shared: false,
|
||||
hasSharedLink: false,
|
||||
isFavorite: false,
|
||||
isActivityEnabled: true,
|
||||
assetCount: albumAssets.length,
|
||||
assets: albumAssets,
|
||||
|
||||
@@ -510,6 +510,9 @@ export const utils = {
|
||||
createStack: (accessToken: string, assetIds: string[]) =>
|
||||
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
setAssetDuplicateId: (accessToken: string, assetId: string, duplicateId: string | null) =>
|
||||
updateAssets({ assetBulkUpdateDto: { ids: [assetId], duplicateId } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
upsertTags: (accessToken: string, tags: string[]) =>
|
||||
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
|
||||
+5
-5
@@ -892,10 +892,8 @@
|
||||
"day": "Day",
|
||||
"days": "Days",
|
||||
"deduplicate_all": "Deduplicate All",
|
||||
"deduplication_criteria_1": "Image size in bytes",
|
||||
"deduplication_criteria_2": "Count of EXIF data",
|
||||
"deduplication_info": "Deduplication Info",
|
||||
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
||||
"default_locale": "Default Locale",
|
||||
"default_locale_description": "Format dates and numbers based on your browser locale",
|
||||
"delete": "Delete",
|
||||
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
||||
"delete_action_prompt": "{count} deleted",
|
||||
@@ -971,7 +969,7 @@
|
||||
"downloading_media": "Downloading media",
|
||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||
"duplicates": "Duplicates",
|
||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates",
|
||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
|
||||
"duration": "Duration",
|
||||
"edit": "Edit",
|
||||
"edit_album": "Edit album",
|
||||
@@ -1392,6 +1390,7 @@
|
||||
"like": "Like",
|
||||
"like_deleted": "Like deleted",
|
||||
"link_motion_video": "Link motion video",
|
||||
"link_to_docs": "For more information, refer to the <link>documentation</link>.",
|
||||
"link_to_oauth": "Link to OAuth",
|
||||
"linked_oauth_account": "Linked OAuth account",
|
||||
"list": "List",
|
||||
@@ -2396,6 +2395,7 @@
|
||||
"viewer_remove_from_stack": "Remove from Stack",
|
||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||
"viewer_unstack": "Un-Stack",
|
||||
"visibility": "Visibility",
|
||||
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
|
||||
"visual": "Visual",
|
||||
"visual_builder": "Visual builder",
|
||||
|
||||
@@ -207,6 +207,11 @@ class DriftMemoryPage extends HookConsumerWidget {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
DriftMemoryPage.setMemory(ref, memories[pageNumber]);
|
||||
});
|
||||
|
||||
// Update currentAsset to the first asset of the new memory
|
||||
if (memories[pageNumber].assets.isNotEmpty) {
|
||||
currentAsset.value = memories[pageNumber].assets.first;
|
||||
}
|
||||
}
|
||||
|
||||
currentAssetPage.value = 0;
|
||||
|
||||
@@ -71,16 +71,13 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,24 +3,21 @@ import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart' show InformationCollector;
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';
|
||||
|
||||
/// A [MultiFrameImageStreamCompleter] with support for listener tracking
|
||||
/// which makes resource cleanup possible when no longer needed.
|
||||
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
|
||||
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
|
||||
void Function()? _onLastListenerRemoved;
|
||||
int _listenerCount = 0;
|
||||
// True once any image or the codec has been provided.
|
||||
// Until then the image cache holds one listener, so "last real listener gone"
|
||||
// is _listenerCount == 1, not 0.
|
||||
bool didProvideImage = false;
|
||||
|
||||
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter with CacheAwareListenerTrackerMixin {
|
||||
AnimatedImageStreamCompleter._({
|
||||
required super.codec,
|
||||
required super.scale,
|
||||
required bool hadInitialImage,
|
||||
super.informationCollector,
|
||||
void Function()? onLastListenerRemoved,
|
||||
}) : _onLastListenerRemoved = onLastListenerRemoved;
|
||||
}) {
|
||||
setupListenerTracking(hadInitialImage: hadInitialImage, onLastListenerRemoved: onLastListenerRemoved);
|
||||
}
|
||||
|
||||
factory AnimatedImageStreamCompleter({
|
||||
required Stream<Object> stream,
|
||||
@@ -33,23 +30,21 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
|
||||
final self = AnimatedImageStreamCompleter._(
|
||||
codec: codecCompleter.future,
|
||||
scale: scale,
|
||||
hadInitialImage: initialImage != null,
|
||||
informationCollector: informationCollector,
|
||||
onLastListenerRemoved: onLastListenerRemoved,
|
||||
);
|
||||
|
||||
if (initialImage != null) {
|
||||
self.didProvideImage = true;
|
||||
self.setImage(initialImage);
|
||||
}
|
||||
|
||||
stream.listen(
|
||||
(item) {
|
||||
if (item is ImageInfo) {
|
||||
self.didProvideImage = true;
|
||||
self.setImage(item);
|
||||
} else if (item is ui.Codec) {
|
||||
if (!codecCompleter.isCompleted) {
|
||||
self.didProvideImage = true;
|
||||
codecCompleter.complete(item);
|
||||
}
|
||||
}
|
||||
@@ -70,27 +65,4 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
@override
|
||||
void addListener(ImageStreamListener listener) {
|
||||
super.addListener(listener);
|
||||
_listenerCount++;
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(ImageStreamListener listener) {
|
||||
super.removeListener(listener);
|
||||
_listenerCount--;
|
||||
|
||||
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
|
||||
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;
|
||||
|
||||
if (onlyCacheListenerLeft || noListenersAfterCodec) {
|
||||
final onLastListenerRemoved = _onLastListenerRemoved;
|
||||
if (onLastListenerRemoved != null) {
|
||||
_onLastListenerRemoved = null;
|
||||
onLastListenerRemoved();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
/// Tracks listeners on an [ImageStreamCompleter] to safely cancel in-flight
|
||||
/// network requests without interfering with [ImageCache] internals.
|
||||
///
|
||||
/// ### Problem
|
||||
/// Cancelling fetches when the listener count drops to 1 (cache only) or 0
|
||||
/// is unsafe due to three framework behaviours:
|
||||
///
|
||||
/// 1. **Memory-pressure eviction** — `ImageCache.clear()` removes the cache
|
||||
/// listener while UI widgets still need the image. A count-based check
|
||||
/// would cancel the active fetch, leaving the UI with no image.
|
||||
/// 2. **Synchronous detach during `putIfAbsent`** — When an `initialImage`
|
||||
/// is provided, the cache attaches, receives the frame, and detaches
|
||||
/// synchronously *before* the UI widget can attach. Count reaches 0 and
|
||||
/// would trigger a false cancel.
|
||||
/// 3. **Listener misidentification** — After the cache detaches (via 1 or 2),
|
||||
/// the next UI listener could be mistaken for the cache listener, causing
|
||||
/// incorrect cancellations when that widget is disposed.
|
||||
///
|
||||
/// ### Solution: First-Listener Heuristic
|
||||
/// The cache is always the first listener attached (via `putIfAbsent`). This
|
||||
/// mixin records that identity once and uses it for all subsequent decisions:
|
||||
///
|
||||
/// * **Identity locking** — The first listener is assumed to be the cache.
|
||||
/// Once identified, `_hasIdentifiedCacheListener` prevents reassignment.
|
||||
/// * **Targeted cancellation** — Cancel only when the identified cache
|
||||
/// listener is the sole remaining listener and no image has been delivered.
|
||||
/// * **Sync-removal bypass** — When `hadInitialImage` is set, the first
|
||||
/// synchronous removal of the cache listener is ignored so the fetch
|
||||
/// survives until the UI attaches.
|
||||
mixin CacheAwareListenerTrackerMixin on ImageStreamCompleter {
|
||||
void Function()? _onLastListenerRemoved;
|
||||
int _listenerCount = 0;
|
||||
bool _hadInitialImage = false;
|
||||
bool _hasIgnoredFirstSyncRemoval = false;
|
||||
ImageStreamListener? _cacheListener;
|
||||
bool _hasIdentifiedCacheListener = false;
|
||||
|
||||
/// Initializes the tracking state. Must be called in the subclass constructor.
|
||||
void setupListenerTracking({required bool hadInitialImage, void Function()? onLastListenerRemoved}) {
|
||||
_hadInitialImage = hadInitialImage;
|
||||
_onLastListenerRemoved = onLastListenerRemoved;
|
||||
}
|
||||
|
||||
@override
|
||||
void addListener(ImageStreamListener listener) {
|
||||
if (!_hasIdentifiedCacheListener) {
|
||||
_hasIdentifiedCacheListener = true;
|
||||
_cacheListener = listener;
|
||||
}
|
||||
|
||||
_listenerCount++;
|
||||
super.addListener(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(ImageStreamListener listener) {
|
||||
super.removeListener(listener);
|
||||
_listenerCount--;
|
||||
|
||||
final bool isCacheListener = listener == _cacheListener;
|
||||
if (isCacheListener) {
|
||||
_cacheListener = null;
|
||||
}
|
||||
|
||||
if (_hadInitialImage && !_hasIgnoredFirstSyncRemoval && isCacheListener) {
|
||||
_hasIgnoredFirstSyncRemoval = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final bool onlyCacheListenerLeft = _listenerCount == 1 && _cacheListener != null;
|
||||
|
||||
final bool completelyAbandoned = _listenerCount == 0;
|
||||
|
||||
if (onlyCacheListenerLeft || completelyAbandoned) {
|
||||
final onLastListenerRemoved = _onLastListenerRemoved;
|
||||
if (onLastListenerRemoved != null) {
|
||||
_onLastListenerRemoved = null;
|
||||
onLastListenerRemoved();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,10 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';
|
||||
|
||||
/// An ImageStreamCompleter with support for loading multiple images.
|
||||
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
||||
void Function()? _onLastListenerRemoved;
|
||||
int _listenerCount = 0;
|
||||
// True once setImage() has been called at least once.
|
||||
bool didProvideImage = false;
|
||||
|
||||
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter with CacheAwareListenerTrackerMixin {
|
||||
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
|
||||
/// should be the primary images to display (typically asynchronously as they load).
|
||||
/// The [initialImage] is an optional image that will be emitted synchronously
|
||||
@@ -24,14 +20,14 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
||||
InformationCollector? informationCollector,
|
||||
void Function()? onLastListenerRemoved,
|
||||
}) {
|
||||
setupListenerTracking(hadInitialImage: initialImage != null, onLastListenerRemoved: onLastListenerRemoved);
|
||||
|
||||
if (initialImage != null) {
|
||||
didProvideImage = true;
|
||||
setImage(initialImage);
|
||||
}
|
||||
_onLastListenerRemoved = onLastListenerRemoved;
|
||||
|
||||
images.listen(
|
||||
(image) {
|
||||
didProvideImage = true;
|
||||
setImage(image);
|
||||
},
|
||||
onError: (Object error, StackTrace stack) {
|
||||
@@ -45,26 +41,4 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void addListener(ImageStreamListener listener) {
|
||||
super.addListener(listener);
|
||||
_listenerCount = _listenerCount + 1;
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(ImageStreamListener listener) {
|
||||
super.removeListener(listener);
|
||||
_listenerCount = _listenerCount - 1;
|
||||
|
||||
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
|
||||
final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage;
|
||||
|
||||
final onLastListenerRemoved = _onLastListenerRemoved;
|
||||
|
||||
if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) {
|
||||
_onLastListenerRemoved = null;
|
||||
onLastListenerRemoved();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ class DownloadService {
|
||||
return result != null;
|
||||
} on PlatformException catch (error, stack) {
|
||||
// Handle saving MotionPhotos on iOS
|
||||
if (error.code == 'PHPhotosErrorDomain (-1)') {
|
||||
if (error.code.startsWith('PHPhotosErrorDomain')) {
|
||||
final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: task.filename);
|
||||
return result != null;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,16 @@ abstract final class DynamicTheme {
|
||||
// Some palettes do not generate surface container colors accurately,
|
||||
// so we regenerate all colors using the primary color
|
||||
_theme = ImmichTheme(
|
||||
light: ColorScheme.fromSeed(seedColor: primaryColor, brightness: Brightness.light),
|
||||
dark: ColorScheme.fromSeed(seedColor: primaryColor, brightness: Brightness.dark),
|
||||
light: ColorScheme.fromSeed(
|
||||
seedColor: primaryColor,
|
||||
brightness: Brightness.light,
|
||||
dynamicSchemeVariant: DynamicSchemeVariant.fidelity,
|
||||
),
|
||||
dark: ColorScheme.fromSeed(
|
||||
seedColor: primaryColor,
|
||||
brightness: Brightness.dark,
|
||||
dynamicSchemeVariant: DynamicSchemeVariant.fidelity,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -62,6 +62,7 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale
|
||||
),
|
||||
chipTheme: const ChipThemeData(side: BorderSide.none),
|
||||
sliderTheme: const SliderThemeData(
|
||||
trackHeight: 12,
|
||||
// ignore: deprecated_member_use
|
||||
year2023: false,
|
||||
),
|
||||
|
||||
@@ -66,9 +66,9 @@ class VideoControls extends HookConsumerWidget {
|
||||
final isLoaded = duration != Duration.zero;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12),
|
||||
child: Column(
|
||||
spacing: 16,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
@@ -77,8 +77,8 @@ class VideoControls extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: const BoxConstraints(),
|
||||
icon: isFinished
|
||||
? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows)
|
||||
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows),
|
||||
? const Icon(Icons.replay, color: Colors.white, shadows: _controlShadows)
|
||||
: AnimatedPlayPause(color: Colors.white, playing: isPlaying, shadows: _controlShadows),
|
||||
onPressed: () => _toggle(ref, isCasting),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -91,7 +91,7 @@ class VideoControls extends HookConsumerWidget {
|
||||
shadows: _controlShadows,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
|
||||
Generated
+7
@@ -95,6 +95,7 @@ Class | Method | HTTP request | Description
|
||||
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | Remove user from album
|
||||
*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | Update an album
|
||||
*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | Update user role
|
||||
*AlbumsApi* | [**updateAlbumUserMetadata**](doc//AlbumsApi.md#updatealbumusermetadata) | **PATCH** /albums/{id}/user-metadata | Update album user metadata
|
||||
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Check bulk upload
|
||||
*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | Check existing assets
|
||||
*AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset
|
||||
@@ -156,6 +157,7 @@ Class | Method | HTTP request | Description
|
||||
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
|
||||
*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates
|
||||
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
|
||||
*DuplicatesApi* | [**resolveDuplicates**](doc//DuplicatesApi.md#resolveduplicates) | **POST** /duplicates/resolve | Resolve duplicate groups
|
||||
*FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces | Create a face
|
||||
*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | Delete a face
|
||||
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | Retrieve faces for asset
|
||||
@@ -422,6 +424,8 @@ Class | Method | HTTP request | Description
|
||||
- [DownloadResponseDto](doc//DownloadResponseDto.md)
|
||||
- [DownloadUpdate](doc//DownloadUpdate.md)
|
||||
- [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md)
|
||||
- [DuplicateResolveDto](doc//DuplicateResolveDto.md)
|
||||
- [DuplicateResolveGroupDto](doc//DuplicateResolveGroupDto.md)
|
||||
- [DuplicateResponseDto](doc//DuplicateResponseDto.md)
|
||||
- [EmailNotificationsResponse](doc//EmailNotificationsResponse.md)
|
||||
- [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md)
|
||||
@@ -573,6 +577,8 @@ Class | Method | HTTP request | Description
|
||||
- [SyncAlbumToAssetDeleteV1](doc//SyncAlbumToAssetDeleteV1.md)
|
||||
- [SyncAlbumToAssetV1](doc//SyncAlbumToAssetV1.md)
|
||||
- [SyncAlbumUserDeleteV1](doc//SyncAlbumUserDeleteV1.md)
|
||||
- [SyncAlbumUserMetadataDeleteV1](doc//SyncAlbumUserMetadataDeleteV1.md)
|
||||
- [SyncAlbumUserMetadataV1](doc//SyncAlbumUserMetadataV1.md)
|
||||
- [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
|
||||
- [SyncAlbumV1](doc//SyncAlbumV1.md)
|
||||
- [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
|
||||
@@ -653,6 +659,7 @@ Class | Method | HTTP request | Description
|
||||
- [TrashResponseDto](doc//TrashResponseDto.md)
|
||||
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
|
||||
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
|
||||
- [UpdateAlbumUserMetadataDto](doc//UpdateAlbumUserMetadataDto.md)
|
||||
- [UpdateAssetDto](doc//UpdateAssetDto.md)
|
||||
- [UpdateLibraryDto](doc//UpdateLibraryDto.md)
|
||||
- [UsageByUserDto](doc//UsageByUserDto.md)
|
||||
|
||||
Generated
+5
@@ -161,6 +161,8 @@ part 'model/download_response.dart';
|
||||
part 'model/download_response_dto.dart';
|
||||
part 'model/download_update.dart';
|
||||
part 'model/duplicate_detection_config.dart';
|
||||
part 'model/duplicate_resolve_dto.dart';
|
||||
part 'model/duplicate_resolve_group_dto.dart';
|
||||
part 'model/duplicate_response_dto.dart';
|
||||
part 'model/email_notifications_response.dart';
|
||||
part 'model/email_notifications_update.dart';
|
||||
@@ -312,6 +314,8 @@ part 'model/sync_album_delete_v1.dart';
|
||||
part 'model/sync_album_to_asset_delete_v1.dart';
|
||||
part 'model/sync_album_to_asset_v1.dart';
|
||||
part 'model/sync_album_user_delete_v1.dart';
|
||||
part 'model/sync_album_user_metadata_delete_v1.dart';
|
||||
part 'model/sync_album_user_metadata_v1.dart';
|
||||
part 'model/sync_album_user_v1.dart';
|
||||
part 'model/sync_album_v1.dart';
|
||||
part 'model/sync_asset_delete_v1.dart';
|
||||
@@ -392,6 +396,7 @@ part 'model/transcode_policy.dart';
|
||||
part 'model/trash_response_dto.dart';
|
||||
part 'model/update_album_dto.dart';
|
||||
part 'model/update_album_user_dto.dart';
|
||||
part 'model/update_album_user_metadata_dto.dart';
|
||||
part 'model/update_asset_dto.dart';
|
||||
part 'model/update_library_dto.dart';
|
||||
part 'model/usage_by_user_dto.dart';
|
||||
|
||||
Generated
+61
@@ -771,4 +771,65 @@ class AlbumsApi {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Update album user metadata
|
||||
///
|
||||
/// Update metadata for the authenticated user on a specific album.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [UpdateAlbumUserMetadataDto] updateAlbumUserMetadataDto (required):
|
||||
Future<Response> updateAlbumUserMetadataWithHttpInfo(String id, UpdateAlbumUserMetadataDto updateAlbumUserMetadataDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/albums/{id}/user-metadata'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = updateAlbumUserMetadataDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PATCH',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update album user metadata
|
||||
///
|
||||
/// Update metadata for the authenticated user on a specific album.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [UpdateAlbumUserMetadataDto] updateAlbumUserMetadataDto (required):
|
||||
Future<AlbumResponseDto?> updateAlbumUserMetadata(String id, UpdateAlbumUserMetadataDto updateAlbumUserMetadataDto,) async {
|
||||
final response = await updateAlbumUserMetadataWithHttpInfo(id, updateAlbumUserMetadataDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumResponseDto',) as AlbumResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -163,4 +163,63 @@ class DuplicatesApi {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Resolve duplicate groups
|
||||
///
|
||||
/// Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [DuplicateResolveDto] duplicateResolveDto (required):
|
||||
Future<Response> resolveDuplicatesWithHttpInfo(DuplicateResolveDto duplicateResolveDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/duplicates/resolve';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = duplicateResolveDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve duplicate groups
|
||||
///
|
||||
/// Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [DuplicateResolveDto] duplicateResolveDto (required):
|
||||
Future<List<BulkIdResponseDto>?> resolveDuplicates(DuplicateResolveDto duplicateResolveDto,) async {
|
||||
final response = await resolveDuplicatesWithHttpInfo(duplicateResolveDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<BulkIdResponseDto>') as List)
|
||||
.cast<BulkIdResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+10
@@ -368,6 +368,10 @@ class ApiClient {
|
||||
return DownloadUpdate.fromJson(value);
|
||||
case 'DuplicateDetectionConfig':
|
||||
return DuplicateDetectionConfig.fromJson(value);
|
||||
case 'DuplicateResolveDto':
|
||||
return DuplicateResolveDto.fromJson(value);
|
||||
case 'DuplicateResolveGroupDto':
|
||||
return DuplicateResolveGroupDto.fromJson(value);
|
||||
case 'DuplicateResponseDto':
|
||||
return DuplicateResponseDto.fromJson(value);
|
||||
case 'EmailNotificationsResponse':
|
||||
@@ -670,6 +674,10 @@ class ApiClient {
|
||||
return SyncAlbumToAssetV1.fromJson(value);
|
||||
case 'SyncAlbumUserDeleteV1':
|
||||
return SyncAlbumUserDeleteV1.fromJson(value);
|
||||
case 'SyncAlbumUserMetadataDeleteV1':
|
||||
return SyncAlbumUserMetadataDeleteV1.fromJson(value);
|
||||
case 'SyncAlbumUserMetadataV1':
|
||||
return SyncAlbumUserMetadataV1.fromJson(value);
|
||||
case 'SyncAlbumUserV1':
|
||||
return SyncAlbumUserV1.fromJson(value);
|
||||
case 'SyncAlbumV1':
|
||||
@@ -830,6 +838,8 @@ class ApiClient {
|
||||
return UpdateAlbumDto.fromJson(value);
|
||||
case 'UpdateAlbumUserDto':
|
||||
return UpdateAlbumUserDto.fromJson(value);
|
||||
case 'UpdateAlbumUserMetadataDto':
|
||||
return UpdateAlbumUserMetadataDto.fromJson(value);
|
||||
case 'UpdateAssetDto':
|
||||
return UpdateAssetDto.fromJson(value);
|
||||
case 'UpdateLibraryDto':
|
||||
|
||||
+10
-1
@@ -25,6 +25,7 @@ class AlbumResponseDto {
|
||||
required this.hasSharedLink,
|
||||
required this.id,
|
||||
required this.isActivityEnabled,
|
||||
required this.isFavorite,
|
||||
this.lastModifiedAssetTimestamp,
|
||||
this.order,
|
||||
required this.owner,
|
||||
@@ -73,6 +74,9 @@ class AlbumResponseDto {
|
||||
/// Activity feed enabled
|
||||
bool isActivityEnabled;
|
||||
|
||||
/// Is favorite
|
||||
bool isFavorite;
|
||||
|
||||
/// Last modified asset timestamp
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
@@ -125,6 +129,7 @@ class AlbumResponseDto {
|
||||
other.hasSharedLink == hasSharedLink &&
|
||||
other.id == id &&
|
||||
other.isActivityEnabled == isActivityEnabled &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
|
||||
other.order == order &&
|
||||
other.owner == owner &&
|
||||
@@ -148,6 +153,7 @@ class AlbumResponseDto {
|
||||
(hasSharedLink.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isActivityEnabled.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
|
||||
(order == null ? 0 : order!.hashCode) +
|
||||
(owner.hashCode) +
|
||||
@@ -157,7 +163,7 @@ class AlbumResponseDto {
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, assets=$assets, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]';
|
||||
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, assets=$assets, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, isFavorite=$isFavorite, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -181,6 +187,7 @@ class AlbumResponseDto {
|
||||
json[r'hasSharedLink'] = this.hasSharedLink;
|
||||
json[r'id'] = this.id;
|
||||
json[r'isActivityEnabled'] = this.isActivityEnabled;
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
if (this.lastModifiedAssetTimestamp != null) {
|
||||
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
|
||||
} else {
|
||||
@@ -224,6 +231,7 @@ class AlbumResponseDto {
|
||||
hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
|
||||
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
|
||||
order: AssetOrder.fromJson(json[r'order']),
|
||||
owner: UserResponseDto.fromJson(json[r'owner'])!,
|
||||
@@ -288,6 +296,7 @@ class AlbumResponseDto {
|
||||
'hasSharedLink',
|
||||
'id',
|
||||
'isActivityEnabled',
|
||||
'isFavorite',
|
||||
'owner',
|
||||
'ownerId',
|
||||
'shared',
|
||||
|
||||
@@ -27,6 +27,7 @@ class BulkIdErrorReason {
|
||||
static const noPermission = BulkIdErrorReason._(r'no_permission');
|
||||
static const notFound = BulkIdErrorReason._(r'not_found');
|
||||
static const unknown = BulkIdErrorReason._(r'unknown');
|
||||
static const validation = BulkIdErrorReason._(r'validation');
|
||||
|
||||
/// List of all possible values in this [enum][BulkIdErrorReason].
|
||||
static const values = <BulkIdErrorReason>[
|
||||
@@ -34,6 +35,7 @@ class BulkIdErrorReason {
|
||||
noPermission,
|
||||
notFound,
|
||||
unknown,
|
||||
validation,
|
||||
];
|
||||
|
||||
static BulkIdErrorReason? fromJson(dynamic value) => BulkIdErrorReasonTypeTransformer().decode(value);
|
||||
@@ -76,6 +78,7 @@ class BulkIdErrorReasonTypeTransformer {
|
||||
case r'no_permission': return BulkIdErrorReason.noPermission;
|
||||
case r'not_found': return BulkIdErrorReason.notFound;
|
||||
case r'unknown': return BulkIdErrorReason.unknown;
|
||||
case r'validation': return BulkIdErrorReason.validation;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
||||
+21
-1
@@ -14,6 +14,7 @@ class BulkIdResponseDto {
|
||||
/// Returns a new [BulkIdResponseDto] instance.
|
||||
BulkIdResponseDto({
|
||||
this.error,
|
||||
this.errorMessage,
|
||||
required this.id,
|
||||
required this.success,
|
||||
});
|
||||
@@ -21,6 +22,14 @@ class BulkIdResponseDto {
|
||||
/// Error reason if failed
|
||||
BulkIdResponseDtoErrorEnum? error;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? errorMessage;
|
||||
|
||||
/// ID
|
||||
String id;
|
||||
|
||||
@@ -30,6 +39,7 @@ class BulkIdResponseDto {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is BulkIdResponseDto &&
|
||||
other.error == error &&
|
||||
other.errorMessage == errorMessage &&
|
||||
other.id == id &&
|
||||
other.success == success;
|
||||
|
||||
@@ -37,11 +47,12 @@ class BulkIdResponseDto {
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(error == null ? 0 : error!.hashCode) +
|
||||
(errorMessage == null ? 0 : errorMessage!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(success.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'BulkIdResponseDto[error=$error, id=$id, success=$success]';
|
||||
String toString() => 'BulkIdResponseDto[error=$error, errorMessage=$errorMessage, id=$id, success=$success]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -49,6 +60,11 @@ class BulkIdResponseDto {
|
||||
json[r'error'] = this.error;
|
||||
} else {
|
||||
// json[r'error'] = null;
|
||||
}
|
||||
if (this.errorMessage != null) {
|
||||
json[r'errorMessage'] = this.errorMessage;
|
||||
} else {
|
||||
// json[r'errorMessage'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
json[r'success'] = this.success;
|
||||
@@ -65,6 +81,7 @@ class BulkIdResponseDto {
|
||||
|
||||
return BulkIdResponseDto(
|
||||
error: BulkIdResponseDtoErrorEnum.fromJson(json[r'error']),
|
||||
errorMessage: mapValueOfType<String>(json, r'errorMessage'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
success: mapValueOfType<bool>(json, r'success')!,
|
||||
);
|
||||
@@ -136,6 +153,7 @@ class BulkIdResponseDtoErrorEnum {
|
||||
static const noPermission = BulkIdResponseDtoErrorEnum._(r'no_permission');
|
||||
static const notFound = BulkIdResponseDtoErrorEnum._(r'not_found');
|
||||
static const unknown = BulkIdResponseDtoErrorEnum._(r'unknown');
|
||||
static const validation = BulkIdResponseDtoErrorEnum._(r'validation');
|
||||
|
||||
/// List of all possible values in this [enum][BulkIdResponseDtoErrorEnum].
|
||||
static const values = <BulkIdResponseDtoErrorEnum>[
|
||||
@@ -143,6 +161,7 @@ class BulkIdResponseDtoErrorEnum {
|
||||
noPermission,
|
||||
notFound,
|
||||
unknown,
|
||||
validation,
|
||||
];
|
||||
|
||||
static BulkIdResponseDtoErrorEnum? fromJson(dynamic value) => BulkIdResponseDtoErrorEnumTypeTransformer().decode(value);
|
||||
@@ -185,6 +204,7 @@ class BulkIdResponseDtoErrorEnumTypeTransformer {
|
||||
case r'no_permission': return BulkIdResponseDtoErrorEnum.noPermission;
|
||||
case r'not_found': return BulkIdResponseDtoErrorEnum.notFound;
|
||||
case r'unknown': return BulkIdResponseDtoErrorEnum.unknown;
|
||||
case r'validation': return BulkIdResponseDtoErrorEnum.validation;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class DuplicateResolveDto {
|
||||
/// Returns a new [DuplicateResolveDto] instance.
|
||||
DuplicateResolveDto({
|
||||
this.groups = const [],
|
||||
});
|
||||
|
||||
/// List of duplicate groups to resolve
|
||||
List<DuplicateResolveGroupDto> groups;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DuplicateResolveDto &&
|
||||
_deepEquality.equals(other.groups, groups);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(groups.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'DuplicateResolveDto[groups=$groups]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'groups'] = this.groups;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [DuplicateResolveDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static DuplicateResolveDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "DuplicateResolveDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return DuplicateResolveDto(
|
||||
groups: DuplicateResolveGroupDto.listFromJson(json[r'groups']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<DuplicateResolveDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <DuplicateResolveDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = DuplicateResolveDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, DuplicateResolveDto> mapFromJson(dynamic json) {
|
||||
final map = <String, DuplicateResolveDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = DuplicateResolveDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of DuplicateResolveDto-objects as value to a dart map
|
||||
static Map<String, List<DuplicateResolveDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<DuplicateResolveDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = DuplicateResolveDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'groups',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class DuplicateResolveGroupDto {
|
||||
/// Returns a new [DuplicateResolveGroupDto] instance.
|
||||
DuplicateResolveGroupDto({
|
||||
required this.duplicateId,
|
||||
this.keepAssetIds = const [],
|
||||
this.trashAssetIds = const [],
|
||||
});
|
||||
|
||||
String duplicateId;
|
||||
|
||||
/// Asset IDs to keep
|
||||
List<String> keepAssetIds;
|
||||
|
||||
/// Asset IDs to trash or delete
|
||||
List<String> trashAssetIds;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DuplicateResolveGroupDto &&
|
||||
other.duplicateId == duplicateId &&
|
||||
_deepEquality.equals(other.keepAssetIds, keepAssetIds) &&
|
||||
_deepEquality.equals(other.trashAssetIds, trashAssetIds);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(duplicateId.hashCode) +
|
||||
(keepAssetIds.hashCode) +
|
||||
(trashAssetIds.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'DuplicateResolveGroupDto[duplicateId=$duplicateId, keepAssetIds=$keepAssetIds, trashAssetIds=$trashAssetIds]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'duplicateId'] = this.duplicateId;
|
||||
json[r'keepAssetIds'] = this.keepAssetIds;
|
||||
json[r'trashAssetIds'] = this.trashAssetIds;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [DuplicateResolveGroupDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static DuplicateResolveGroupDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "DuplicateResolveGroupDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return DuplicateResolveGroupDto(
|
||||
duplicateId: mapValueOfType<String>(json, r'duplicateId')!,
|
||||
keepAssetIds: json[r'keepAssetIds'] is Iterable
|
||||
? (json[r'keepAssetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
trashAssetIds: json[r'trashAssetIds'] is Iterable
|
||||
? (json[r'trashAssetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<DuplicateResolveGroupDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <DuplicateResolveGroupDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = DuplicateResolveGroupDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, DuplicateResolveGroupDto> mapFromJson(dynamic json) {
|
||||
final map = <String, DuplicateResolveGroupDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = DuplicateResolveGroupDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of DuplicateResolveGroupDto-objects as value to a dart map
|
||||
static Map<String, List<DuplicateResolveGroupDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<DuplicateResolveGroupDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = DuplicateResolveGroupDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'duplicateId',
|
||||
'keepAssetIds',
|
||||
'trashAssetIds',
|
||||
};
|
||||
}
|
||||
|
||||
+14
-3
@@ -15,6 +15,7 @@ class DuplicateResponseDto {
|
||||
DuplicateResponseDto({
|
||||
this.assets = const [],
|
||||
required this.duplicateId,
|
||||
this.suggestedKeepAssetIds = const [],
|
||||
});
|
||||
|
||||
/// Duplicate assets
|
||||
@@ -23,24 +24,30 @@ class DuplicateResponseDto {
|
||||
/// Duplicate group ID
|
||||
String duplicateId;
|
||||
|
||||
/// Suggested asset IDs to keep based on file size and EXIF data
|
||||
List<String> suggestedKeepAssetIds;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DuplicateResponseDto &&
|
||||
_deepEquality.equals(other.assets, assets) &&
|
||||
other.duplicateId == duplicateId;
|
||||
other.duplicateId == duplicateId &&
|
||||
_deepEquality.equals(other.suggestedKeepAssetIds, suggestedKeepAssetIds);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assets.hashCode) +
|
||||
(duplicateId.hashCode);
|
||||
(duplicateId.hashCode) +
|
||||
(suggestedKeepAssetIds.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'DuplicateResponseDto[assets=$assets, duplicateId=$duplicateId]';
|
||||
String toString() => 'DuplicateResponseDto[assets=$assets, duplicateId=$duplicateId, suggestedKeepAssetIds=$suggestedKeepAssetIds]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assets'] = this.assets;
|
||||
json[r'duplicateId'] = this.duplicateId;
|
||||
json[r'suggestedKeepAssetIds'] = this.suggestedKeepAssetIds;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -55,6 +62,9 @@ class DuplicateResponseDto {
|
||||
return DuplicateResponseDto(
|
||||
assets: AssetResponseDto.listFromJson(json[r'assets']),
|
||||
duplicateId: mapValueOfType<String>(json, r'duplicateId')!,
|
||||
suggestedKeepAssetIds: json[r'suggestedKeepAssetIds'] is Iterable
|
||||
? (json[r'suggestedKeepAssetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -104,6 +114,7 @@ class DuplicateResponseDto {
|
||||
static const requiredKeys = <String>{
|
||||
'assets',
|
||||
'duplicateId',
|
||||
'suggestedKeepAssetIds',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SyncAlbumUserMetadataDeleteV1 {
|
||||
/// Returns a new [SyncAlbumUserMetadataDeleteV1] instance.
|
||||
SyncAlbumUserMetadataDeleteV1({
|
||||
required this.albumId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
/// Album ID
|
||||
String albumId;
|
||||
|
||||
/// User ID
|
||||
String userId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserMetadataDeleteV1 &&
|
||||
other.albumId == albumId &&
|
||||
other.userId == userId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(albumId.hashCode) +
|
||||
(userId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SyncAlbumUserMetadataDeleteV1[albumId=$albumId, userId=$userId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'albumId'] = this.albumId;
|
||||
json[r'userId'] = this.userId;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SyncAlbumUserMetadataDeleteV1] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SyncAlbumUserMetadataDeleteV1? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SyncAlbumUserMetadataDeleteV1");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SyncAlbumUserMetadataDeleteV1(
|
||||
albumId: mapValueOfType<String>(json, r'albumId')!,
|
||||
userId: mapValueOfType<String>(json, r'userId')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SyncAlbumUserMetadataDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SyncAlbumUserMetadataDeleteV1>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SyncAlbumUserMetadataDeleteV1.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SyncAlbumUserMetadataDeleteV1> mapFromJson(dynamic json) {
|
||||
final map = <String, SyncAlbumUserMetadataDeleteV1>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SyncAlbumUserMetadataDeleteV1.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SyncAlbumUserMetadataDeleteV1-objects as value to a dart map
|
||||
static Map<String, List<SyncAlbumUserMetadataDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SyncAlbumUserMetadataDeleteV1>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SyncAlbumUserMetadataDeleteV1.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'albumId',
|
||||
'userId',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SyncAlbumUserMetadataV1 {
|
||||
/// Returns a new [SyncAlbumUserMetadataV1] instance.
|
||||
SyncAlbumUserMetadataV1({
|
||||
required this.albumId,
|
||||
required this.isFavorite,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
/// Album ID
|
||||
String albumId;
|
||||
|
||||
/// Is favorite
|
||||
bool isFavorite;
|
||||
|
||||
/// User ID
|
||||
String userId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserMetadataV1 &&
|
||||
other.albumId == albumId &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.userId == userId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(albumId.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(userId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SyncAlbumUserMetadataV1[albumId=$albumId, isFavorite=$isFavorite, userId=$userId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'albumId'] = this.albumId;
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
json[r'userId'] = this.userId;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SyncAlbumUserMetadataV1] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SyncAlbumUserMetadataV1? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SyncAlbumUserMetadataV1");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SyncAlbumUserMetadataV1(
|
||||
albumId: mapValueOfType<String>(json, r'albumId')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
|
||||
userId: mapValueOfType<String>(json, r'userId')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SyncAlbumUserMetadataV1> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SyncAlbumUserMetadataV1>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SyncAlbumUserMetadataV1.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SyncAlbumUserMetadataV1> mapFromJson(dynamic json) {
|
||||
final map = <String, SyncAlbumUserMetadataV1>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SyncAlbumUserMetadataV1.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SyncAlbumUserMetadataV1-objects as value to a dart map
|
||||
static Map<String, List<SyncAlbumUserMetadataV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SyncAlbumUserMetadataV1>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SyncAlbumUserMetadataV1.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'albumId',
|
||||
'isFavorite',
|
||||
'userId',
|
||||
};
|
||||
}
|
||||
|
||||
+6
@@ -48,6 +48,8 @@ class SyncEntityType {
|
||||
static const albumUserV1 = SyncEntityType._(r'AlbumUserV1');
|
||||
static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1');
|
||||
static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1');
|
||||
static const albumUserMetadataV1 = SyncEntityType._(r'AlbumUserMetadataV1');
|
||||
static const albumUserMetadataDeleteV1 = SyncEntityType._(r'AlbumUserMetadataDeleteV1');
|
||||
static const albumAssetCreateV1 = SyncEntityType._(r'AlbumAssetCreateV1');
|
||||
static const albumAssetUpdateV1 = SyncEntityType._(r'AlbumAssetUpdateV1');
|
||||
static const albumAssetBackfillV1 = SyncEntityType._(r'AlbumAssetBackfillV1');
|
||||
@@ -101,6 +103,8 @@ class SyncEntityType {
|
||||
albumUserV1,
|
||||
albumUserBackfillV1,
|
||||
albumUserDeleteV1,
|
||||
albumUserMetadataV1,
|
||||
albumUserMetadataDeleteV1,
|
||||
albumAssetCreateV1,
|
||||
albumAssetUpdateV1,
|
||||
albumAssetBackfillV1,
|
||||
@@ -189,6 +193,8 @@ class SyncEntityTypeTypeTransformer {
|
||||
case r'AlbumUserV1': return SyncEntityType.albumUserV1;
|
||||
case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1;
|
||||
case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1;
|
||||
case r'AlbumUserMetadataV1': return SyncEntityType.albumUserMetadataV1;
|
||||
case r'AlbumUserMetadataDeleteV1': return SyncEntityType.albumUserMetadataDeleteV1;
|
||||
case r'AlbumAssetCreateV1': return SyncEntityType.albumAssetCreateV1;
|
||||
case r'AlbumAssetUpdateV1': return SyncEntityType.albumAssetUpdateV1;
|
||||
case r'AlbumAssetBackfillV1': return SyncEntityType.albumAssetBackfillV1;
|
||||
|
||||
+3
@@ -25,6 +25,7 @@ class SyncRequestType {
|
||||
|
||||
static const albumsV1 = SyncRequestType._(r'AlbumsV1');
|
||||
static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1');
|
||||
static const albumUserMetadataV1 = SyncRequestType._(r'AlbumUserMetadataV1');
|
||||
static const albumToAssetsV1 = SyncRequestType._(r'AlbumToAssetsV1');
|
||||
static const albumAssetsV1 = SyncRequestType._(r'AlbumAssetsV1');
|
||||
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
|
||||
@@ -50,6 +51,7 @@ class SyncRequestType {
|
||||
static const values = <SyncRequestType>[
|
||||
albumsV1,
|
||||
albumUsersV1,
|
||||
albumUserMetadataV1,
|
||||
albumToAssetsV1,
|
||||
albumAssetsV1,
|
||||
albumAssetExifsV1,
|
||||
@@ -110,6 +112,7 @@ class SyncRequestTypeTypeTransformer {
|
||||
switch (data) {
|
||||
case r'AlbumsV1': return SyncRequestType.albumsV1;
|
||||
case r'AlbumUsersV1': return SyncRequestType.albumUsersV1;
|
||||
case r'AlbumUserMetadataV1': return SyncRequestType.albumUserMetadataV1;
|
||||
case r'AlbumToAssetsV1': return SyncRequestType.albumToAssetsV1;
|
||||
case r'AlbumAssetsV1': return SyncRequestType.albumAssetsV1;
|
||||
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class UpdateAlbumUserMetadataDto {
|
||||
/// Returns a new [UpdateAlbumUserMetadataDto] instance.
|
||||
UpdateAlbumUserMetadataDto({
|
||||
required this.isFavorite,
|
||||
});
|
||||
|
||||
/// Favorite status
|
||||
bool isFavorite;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumUserMetadataDto &&
|
||||
other.isFavorite == isFavorite;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(isFavorite.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UpdateAlbumUserMetadataDto[isFavorite=$isFavorite]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [UpdateAlbumUserMetadataDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static UpdateAlbumUserMetadataDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "UpdateAlbumUserMetadataDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return UpdateAlbumUserMetadataDto(
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<UpdateAlbumUserMetadataDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <UpdateAlbumUserMetadataDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = UpdateAlbumUserMetadataDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, UpdateAlbumUserMetadataDto> mapFromJson(dynamic json) {
|
||||
final map = <String, UpdateAlbumUserMetadataDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = UpdateAlbumUserMetadataDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of UpdateAlbumUserMetadataDto-objects as value to a dart map
|
||||
static Map<String, List<UpdateAlbumUserMetadataDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<UpdateAlbumUserMetadataDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = UpdateAlbumUserMetadataDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'isFavorite',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';
|
||||
|
||||
class TestImageCompleter extends ImageStreamCompleter with CacheAwareListenerTrackerMixin {
|
||||
bool wasCancelled = false;
|
||||
|
||||
TestImageCompleter({required bool hadInitialImage}) {
|
||||
setupListenerTracking(
|
||||
hadInitialImage: hadInitialImage,
|
||||
onLastListenerRemoved: () {
|
||||
wasCancelled = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void setImage(ImageInfo image) {
|
||||
super.setImage(image);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late ImageCache cache;
|
||||
late ImageStreamListener uiListener;
|
||||
|
||||
setUp(() {
|
||||
// Create a fresh, real Flutter ImageCache for every test
|
||||
cache = ImageCache();
|
||||
uiListener = ImageStreamListener((_, __) {});
|
||||
});
|
||||
|
||||
group('CacheAwareListenerTrackerMixin with Real ImageCache', () {
|
||||
|
||||
testWidgets('cancels fetch when UI detaches before completion', (WidgetTester tester) async {
|
||||
final completer = TestImageCompleter(hadInitialImage: false);
|
||||
final key = Object();
|
||||
|
||||
// 1. Request image from the real cache (simulating the provider)
|
||||
final stream = cache.putIfAbsent(key, () => completer)!;
|
||||
|
||||
// 2. UI attaches
|
||||
stream.addListener(uiListener);
|
||||
expect(completer.wasCancelled, isFalse);
|
||||
|
||||
// 3. Simulate asynchronous network delay...
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
// 4. User scrolls away before network finishes. UI detaches.
|
||||
stream.removeListener(uiListener);
|
||||
|
||||
expect(completer.wasCancelled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('survives cache eviction while UI listener is still attached', (WidgetTester tester) async {
|
||||
final completer = TestImageCompleter(hadInitialImage: false);
|
||||
final key = Object();
|
||||
|
||||
// 1. Request image and attach UI
|
||||
final stream = cache.putIfAbsent(key, () => completer)!;
|
||||
stream.addListener(uiListener);
|
||||
|
||||
// 2. Simulate app going to background -> OS Memory Warning -> Cache clears
|
||||
cache.clear();
|
||||
|
||||
// Even though the real cache just aggressively detached its listener,
|
||||
// the stream MUST survive because the UI widget is still on screen!
|
||||
expect(completer.wasCancelled, isFalse);
|
||||
|
||||
// 3. UI widget finally detaches
|
||||
stream.removeListener(uiListener);
|
||||
expect(completer.wasCancelled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('survives synchronous cache detach during putIfAbsent with initialImage', (WidgetTester tester) async {
|
||||
final completer = TestImageCompleter(hadInitialImage: true);
|
||||
final key = Object();
|
||||
|
||||
// Run image creation outside FakeAsync zone to avoid hang
|
||||
late ui.Image dummyImage;
|
||||
await tester.runAsync(() async {
|
||||
dummyImage = await createTestImage(width: 1, height: 1);
|
||||
});
|
||||
|
||||
final initialImageInfo = ImageInfo(image: dummyImage);
|
||||
|
||||
final stream = cache.putIfAbsent(key, () {
|
||||
completer.setImage(initialImageInfo);
|
||||
return completer;
|
||||
})!;
|
||||
|
||||
expect(completer.wasCancelled, isFalse);
|
||||
|
||||
stream.addListener(uiListener);
|
||||
expect(completer.wasCancelled, isFalse);
|
||||
|
||||
stream.removeListener(uiListener);
|
||||
expect(completer.wasCancelled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('fires cleanup on full abandonment even after successful fetch', (WidgetTester tester) async {
|
||||
final completer = TestImageCompleter(hadInitialImage: false);
|
||||
final key = Object();
|
||||
|
||||
final stream = cache.putIfAbsent(key, () => completer)!;
|
||||
stream.addListener(uiListener);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
// Run image creation outside FakeAsync zone to avoid hang
|
||||
late ui.Image dummyImage;
|
||||
await tester.runAsync(() async {
|
||||
dummyImage = await createTestImage(width: 1, height: 1);
|
||||
});
|
||||
|
||||
completer.setImage(ImageInfo(image: dummyImage));
|
||||
|
||||
stream.removeListener(uiListener);
|
||||
|
||||
// The stream is completely abandoned (0 listeners), so it fires the cleanup hook.
|
||||
// Since the image is already downloaded, canceling the network token is a safe no-op.
|
||||
expect(completer.wasCancelled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('Multiple UI listeners — only all detached, should cancel', (WidgetTester tester) async {
|
||||
final completer = TestImageCompleter(hadInitialImage: false);
|
||||
final key = Object();
|
||||
|
||||
final stream = cache.putIfAbsent(key, () => completer)!;
|
||||
|
||||
final uiListener2 = ImageStreamListener((_, __) {});
|
||||
stream.addListener(uiListener);
|
||||
stream.addListener(uiListener2);
|
||||
|
||||
// First UI detach leaves cache + one UI → no cancel
|
||||
stream.removeListener(uiListener);
|
||||
expect(completer.wasCancelled, isFalse);
|
||||
|
||||
// Second UI detach leaves only cache → cancel
|
||||
stream.removeListener(uiListener2);
|
||||
expect(completer.wasCancelled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('Listener misidentification: new listener after cache eviction is not treated as cache', (WidgetTester tester) async {
|
||||
final completer = TestImageCompleter(hadInitialImage: false);
|
||||
final key = Object();
|
||||
|
||||
final stream = cache.putIfAbsent(key, () => completer)!;
|
||||
|
||||
// UI attaches
|
||||
stream.addListener(uiListener);
|
||||
|
||||
// Cache eviction removes the cache listener
|
||||
cache.clear();
|
||||
expect(completer.wasCancelled, isFalse);
|
||||
|
||||
// A second UI listener attaches — must NOT be treated as cache
|
||||
final uiListener2 = ImageStreamListener((_, __) {});
|
||||
stream.addListener(uiListener2);
|
||||
|
||||
// Remove first UI listener; second UI still active → no cancel
|
||||
stream.removeListener(uiListener);
|
||||
expect(completer.wasCancelled, isFalse);
|
||||
|
||||
// Remove second UI listener; completely abandoned → cancel
|
||||
stream.removeListener(uiListener2);
|
||||
expect(completer.wasCancelled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('No UI listener ever attaches (cache-only) — cache detaches should cancel', (WidgetTester tester) async {
|
||||
final completer = TestImageCompleter(hadInitialImage: false);
|
||||
final key = Object();
|
||||
|
||||
cache.putIfAbsent(key, () => completer);
|
||||
|
||||
// Cache eviction removes the only listener
|
||||
cache.clear();
|
||||
expect(completer.wasCancelled, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2221,6 +2221,72 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/albums/{id}/user-metadata": {
|
||||
"patch": {
|
||||
"description": "Update metadata for the authenticated user on a specific album.",
|
||||
"operationId": "updateAlbumUserMetadata",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateAlbumUserMetadataDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AlbumResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Update album user metadata",
|
||||
"tags": [
|
||||
"Albums"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.7.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.7.0",
|
||||
"state": "Beta"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "album.read",
|
||||
"x-immich-state": "Beta"
|
||||
}
|
||||
},
|
||||
"/albums/{id}/user/{userId}": {
|
||||
"delete": {
|
||||
"description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.",
|
||||
@@ -5285,6 +5351,65 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/duplicates/resolve": {
|
||||
"post": {
|
||||
"description": "Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.",
|
||||
"operationId": "resolveDuplicates",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DuplicateResolveDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BulkIdResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Resolve duplicate groups",
|
||||
"tags": [
|
||||
"Duplicates"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.0.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v3.0.0",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "duplicate.delete",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/duplicates/{id}": {
|
||||
"delete": {
|
||||
"description": "Delete a single duplicate asset specified by its ID.",
|
||||
@@ -15611,6 +15736,10 @@
|
||||
"description": "Activity feed enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"isFavorite": {
|
||||
"description": "Is favorite",
|
||||
"type": "boolean"
|
||||
},
|
||||
"lastModifiedAssetTimestamp": {
|
||||
"description": "Last modified asset timestamp",
|
||||
"format": "date-time",
|
||||
@@ -15657,6 +15786,7 @@
|
||||
"hasSharedLink",
|
||||
"id",
|
||||
"isActivityEnabled",
|
||||
"isFavorite",
|
||||
"owner",
|
||||
"ownerId",
|
||||
"shared",
|
||||
@@ -17299,7 +17429,8 @@
|
||||
"duplicate",
|
||||
"no_permission",
|
||||
"not_found",
|
||||
"unknown"
|
||||
"unknown",
|
||||
"validation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17311,10 +17442,14 @@
|
||||
"duplicate",
|
||||
"no_permission",
|
||||
"not_found",
|
||||
"unknown"
|
||||
"unknown",
|
||||
"validation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"errorMessage": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID",
|
||||
"type": "string"
|
||||
@@ -17828,6 +17963,52 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DuplicateResolveDto": {
|
||||
"properties": {
|
||||
"groups": {
|
||||
"description": "List of duplicate groups to resolve",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DuplicateResolveGroupDto"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"groups"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DuplicateResolveGroupDto": {
|
||||
"properties": {
|
||||
"duplicateId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"keepAssetIds": {
|
||||
"description": "Asset IDs to keep",
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"trashAssetIds": {
|
||||
"description": "Asset IDs to trash or delete",
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"duplicateId",
|
||||
"keepAssetIds",
|
||||
"trashAssetIds"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DuplicateResponseDto": {
|
||||
"properties": {
|
||||
"assets": {
|
||||
@@ -17840,11 +18021,20 @@
|
||||
"duplicateId": {
|
||||
"description": "Duplicate group ID",
|
||||
"type": "string"
|
||||
},
|
||||
"suggestedKeepAssetIds": {
|
||||
"description": "Suggested asset IDs to keep based on file size and EXIF data",
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assets",
|
||||
"duplicateId"
|
||||
"duplicateId",
|
||||
"suggestedKeepAssetIds"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -22626,6 +22816,45 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAlbumUserMetadataDeleteV1": {
|
||||
"properties": {
|
||||
"albumId": {
|
||||
"description": "Album ID",
|
||||
"type": "string"
|
||||
},
|
||||
"userId": {
|
||||
"description": "User ID",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"albumId",
|
||||
"userId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAlbumUserMetadataV1": {
|
||||
"properties": {
|
||||
"albumId": {
|
||||
"description": "Album ID",
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"description": "Is favorite",
|
||||
"type": "boolean"
|
||||
},
|
||||
"userId": {
|
||||
"description": "User ID",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"albumId",
|
||||
"isFavorite",
|
||||
"userId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAlbumUserV1": {
|
||||
"properties": {
|
||||
"albumId": {
|
||||
@@ -23332,6 +23561,8 @@
|
||||
"AlbumUserV1",
|
||||
"AlbumUserBackfillV1",
|
||||
"AlbumUserDeleteV1",
|
||||
"AlbumUserMetadataV1",
|
||||
"AlbumUserMetadataDeleteV1",
|
||||
"AlbumAssetCreateV1",
|
||||
"AlbumAssetUpdateV1",
|
||||
"AlbumAssetBackfillV1",
|
||||
@@ -23607,6 +23838,7 @@
|
||||
"enum": [
|
||||
"AlbumsV1",
|
||||
"AlbumUsersV1",
|
||||
"AlbumUserMetadataV1",
|
||||
"AlbumToAssetsV1",
|
||||
"AlbumAssetsV1",
|
||||
"AlbumAssetExifsV1",
|
||||
@@ -25288,6 +25520,18 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UpdateAlbumUserMetadataDto": {
|
||||
"properties": {
|
||||
"isFavorite": {
|
||||
"description": "Favorite status",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"isFavorite"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UpdateAssetDto": {
|
||||
"properties": {
|
||||
"dateTimeOriginal": {
|
||||
|
||||
@@ -656,6 +656,8 @@ export type AlbumResponseDto = {
|
||||
id: string;
|
||||
/** Activity feed enabled */
|
||||
isActivityEnabled: boolean;
|
||||
/** Is favorite */
|
||||
isFavorite: boolean;
|
||||
/** Last modified asset timestamp */
|
||||
lastModifiedAssetTimestamp?: string;
|
||||
/** Asset sort order */
|
||||
@@ -725,11 +727,16 @@ export type BulkIdsDto = {
|
||||
export type BulkIdResponseDto = {
|
||||
/** Error reason if failed */
|
||||
error?: Error;
|
||||
errorMessage?: string;
|
||||
/** ID */
|
||||
id: string;
|
||||
/** Whether operation succeeded */
|
||||
success: boolean;
|
||||
};
|
||||
export type UpdateAlbumUserMetadataDto = {
|
||||
/** Favorite status */
|
||||
isFavorite: boolean;
|
||||
};
|
||||
export type UpdateAlbumUserDto = {
|
||||
/** Album user role */
|
||||
role: AlbumUserRole;
|
||||
@@ -1163,6 +1170,19 @@ export type DuplicateResponseDto = {
|
||||
assets: AssetResponseDto[];
|
||||
/** Duplicate group ID */
|
||||
duplicateId: string;
|
||||
/** Suggested asset IDs to keep based on file size and EXIF data */
|
||||
suggestedKeepAssetIds: string[];
|
||||
};
|
||||
export type DuplicateResolveGroupDto = {
|
||||
duplicateId: string;
|
||||
/** Asset IDs to keep */
|
||||
keepAssetIds: string[];
|
||||
/** Asset IDs to trash or delete */
|
||||
trashAssetIds: string[];
|
||||
};
|
||||
export type DuplicateResolveDto = {
|
||||
/** List of duplicate groups to resolve */
|
||||
groups: DuplicateResolveGroupDto[];
|
||||
};
|
||||
export type PersonResponseDto = {
|
||||
/** Person date of birth */
|
||||
@@ -2936,6 +2956,20 @@ export type SyncAlbumUserDeleteV1 = {
|
||||
/** User ID */
|
||||
userId: string;
|
||||
};
|
||||
export type SyncAlbumUserMetadataDeleteV1 = {
|
||||
/** Album ID */
|
||||
albumId: string;
|
||||
/** User ID */
|
||||
userId: string;
|
||||
};
|
||||
export type SyncAlbumUserMetadataV1 = {
|
||||
/** Album ID */
|
||||
albumId: string;
|
||||
/** Is favorite */
|
||||
isFavorite: boolean;
|
||||
/** User ID */
|
||||
userId: string;
|
||||
};
|
||||
export type SyncAlbumUserV1 = {
|
||||
/** Album ID */
|
||||
albumId: string;
|
||||
@@ -3810,6 +3844,22 @@ export function addAssetsToAlbum({ id, key, slug, bulkIdsDto }: {
|
||||
body: bulkIdsDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Update album user metadata
|
||||
*/
|
||||
export function updateAlbumUserMetadata({ id, updateAlbumUserMetadataDto }: {
|
||||
id: string;
|
||||
updateAlbumUserMetadataDto: UpdateAlbumUserMetadataDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AlbumResponseDto;
|
||||
}>(`/albums/${encodeURIComponent(id)}/user-metadata`, oazapfts.json({
|
||||
...opts,
|
||||
method: "PATCH",
|
||||
body: updateAlbumUserMetadataDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Remove user from album
|
||||
*/
|
||||
@@ -4531,6 +4581,21 @@ export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Resolve duplicate groups
|
||||
*/
|
||||
export function resolveDuplicates({ duplicateResolveDto }: {
|
||||
duplicateResolveDto: DuplicateResolveDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: BulkIdResponseDto[];
|
||||
}>("/duplicates/resolve", oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: duplicateResolveDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Delete a duplicate
|
||||
*/
|
||||
@@ -6893,13 +6958,15 @@ export enum BulkIdErrorReason {
|
||||
Duplicate = "duplicate",
|
||||
NoPermission = "no_permission",
|
||||
NotFound = "not_found",
|
||||
Unknown = "unknown"
|
||||
Unknown = "unknown",
|
||||
Validation = "validation"
|
||||
}
|
||||
export enum Error {
|
||||
Duplicate = "duplicate",
|
||||
NoPermission = "no_permission",
|
||||
NotFound = "not_found",
|
||||
Unknown = "unknown"
|
||||
Unknown = "unknown",
|
||||
Validation = "validation"
|
||||
}
|
||||
export enum Permission {
|
||||
All = "all",
|
||||
@@ -7258,6 +7325,8 @@ export enum SyncEntityType {
|
||||
AlbumUserV1 = "AlbumUserV1",
|
||||
AlbumUserBackfillV1 = "AlbumUserBackfillV1",
|
||||
AlbumUserDeleteV1 = "AlbumUserDeleteV1",
|
||||
AlbumUserMetadataV1 = "AlbumUserMetadataV1",
|
||||
AlbumUserMetadataDeleteV1 = "AlbumUserMetadataDeleteV1",
|
||||
AlbumAssetCreateV1 = "AlbumAssetCreateV1",
|
||||
AlbumAssetUpdateV1 = "AlbumAssetUpdateV1",
|
||||
AlbumAssetBackfillV1 = "AlbumAssetBackfillV1",
|
||||
@@ -7287,6 +7356,7 @@ export enum SyncEntityType {
|
||||
export enum SyncRequestType {
|
||||
AlbumsV1 = "AlbumsV1",
|
||||
AlbumUsersV1 = "AlbumUsersV1",
|
||||
AlbumUserMetadataV1 = "AlbumUserMetadataV1",
|
||||
AlbumToAssetsV1 = "AlbumToAssetsV1",
|
||||
AlbumAssetsV1 = "AlbumAssetsV1",
|
||||
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
|
||||
|
||||
Generated
+17
-17
@@ -3192,8 +3192,8 @@ packages:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@internationalized/date@3.10.0':
|
||||
resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==}
|
||||
'@internationalized/date@3.12.0':
|
||||
resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==}
|
||||
|
||||
'@ioredis/commands@1.5.0':
|
||||
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
|
||||
@@ -5821,8 +5821,8 @@ packages:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
bits-ui@2.16.0:
|
||||
resolution: {integrity: sha512-utsUZE7W7MxOQF1jmSYfzUrt2nZxgkq0yPqQcBQ0WQDMq8ETd1yEiHlPpqhMrpKU7IivjSf4XVysDDy+UVkMUw==}
|
||||
bits-ui@2.16.3:
|
||||
resolution: {integrity: sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@internationalized/date': ^3.8.1
|
||||
@@ -8825,8 +8825,8 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
marked@17.0.3:
|
||||
resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==}
|
||||
marked@17.0.5:
|
||||
resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
@@ -10987,8 +10987,8 @@ packages:
|
||||
resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==}
|
||||
engines: {node: '>=0.12.18'}
|
||||
|
||||
simple-icons@16.9.0:
|
||||
resolution: {integrity: sha512-aKst2C7cLkFyaiQ/Crlwxt9xYOpGPk05XuJZ0ZTJNNCzHCKYrGWz2ebJSi5dG8CmTCxUF/BGs6A8uyJn/EQxqw==}
|
||||
simple-icons@16.13.0:
|
||||
resolution: {integrity: sha512-N4AMZvFERU5YLEtUudtUesiM2H4O5xQ9qfS3K0oOV5II5KVtxOUAlmZ7KqBgiTSGBgCVkuLD/Z9dJKBtnI3kKQ==}
|
||||
engines: {node: '>=0.12.18'}
|
||||
|
||||
sirv@2.0.4:
|
||||
@@ -15130,18 +15130,18 @@ snapshots:
|
||||
'@immich/svelte-markdown-preprocess@0.2.1(svelte@5.54.1)':
|
||||
dependencies:
|
||||
front-matter: 4.0.2
|
||||
marked: 17.0.3
|
||||
marked: 17.0.5
|
||||
node-emoji: 2.2.0
|
||||
svelte: 5.54.1
|
||||
|
||||
'@immich/ui@0.69.0(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)':
|
||||
dependencies:
|
||||
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.54.1)
|
||||
'@internationalized/date': 3.10.0
|
||||
'@internationalized/date': 3.12.0
|
||||
'@mdi/js': 7.4.47
|
||||
bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)
|
||||
bits-ui: 2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)
|
||||
luxon: 3.7.2
|
||||
simple-icons: 16.9.0
|
||||
simple-icons: 16.13.0
|
||||
svelte: 5.54.1
|
||||
svelte-highlight: 7.9.0
|
||||
tailwind-merge: 3.5.0
|
||||
@@ -15290,7 +15290,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@internationalized/date@3.10.0':
|
||||
'@internationalized/date@3.12.0':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
|
||||
@@ -18126,11 +18126,11 @@ snapshots:
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1):
|
||||
bits-ui@2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.3
|
||||
'@floating-ui/dom': 1.7.4
|
||||
'@internationalized/date': 3.10.0
|
||||
'@internationalized/date': 3.12.0
|
||||
esm-env: 1.2.2
|
||||
runed: 0.35.1(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)
|
||||
svelte: 5.54.1
|
||||
@@ -21579,7 +21579,7 @@ snapshots:
|
||||
|
||||
marked@16.4.2: {}
|
||||
|
||||
marked@17.0.3: {}
|
||||
marked@17.0.5: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
@@ -24323,7 +24323,7 @@ snapshots:
|
||||
|
||||
simple-icons@15.22.0: {}
|
||||
|
||||
simple-icons@16.9.0: {}
|
||||
simple-icons@16.13.0: {}
|
||||
|
||||
sirv@2.0.4:
|
||||
dependencies:
|
||||
|
||||
@@ -79,6 +79,21 @@ describe(AlbumController.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /albums/:id/user-metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}/user-metadata`).send({ isFavorite: true });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an invalid favorite payload', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/albums/${factory.uuid()}/user-metadata`)
|
||||
.send({ isFavorite: 'invalid' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /albums/:id/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/albums/${factory.uuid()}/assets`);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
GetAlbumsDto,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
UpdateAlbumUserMetadataDto,
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -89,6 +90,21 @@ export class AlbumController {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/user-metadata')
|
||||
@Authenticated({ permission: Permission.AlbumRead })
|
||||
@Endpoint({
|
||||
summary: 'Update album user metadata',
|
||||
description: 'Update metadata for the authenticated user on a specific album.',
|
||||
history: new HistoryBuilder().added('v2.7.0').beta('v2.7.0'),
|
||||
})
|
||||
updateAlbumUserMetadata(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: UpdateAlbumUserMetadataDto,
|
||||
): Promise<AlbumResponseDto> {
|
||||
return this.service.updateAlbumUserMetadata(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Authenticated({ permission: Permission.AlbumDelete })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||
import { DuplicateService } from 'src/services/duplicate.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(DuplicateController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(DuplicateService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(DuplicateController, [{ provide: DuplicateService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /duplicates', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/duplicates');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /duplicates', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/duplicates');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /duplicates/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/duplicates/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
|
||||
import { DuplicateResolveDto, DuplicateResponseDto } from 'src/dtos/duplicate.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { DuplicateService } from 'src/services/duplicate.service';
|
||||
@@ -48,4 +48,16 @@ export class DuplicateController {
|
||||
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
|
||||
@Post('resolve')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Authenticated({ permission: Permission.DuplicateDelete })
|
||||
@Endpoint({
|
||||
summary: 'Resolve duplicate groups',
|
||||
description: 'Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.',
|
||||
history: new HistoryBuilder().added('v3.0.0').alpha('v3.0.0'),
|
||||
})
|
||||
resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: DuplicateResolveDto): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.resolve(auth, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AssetFileType,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
ChecksumAlgorithm,
|
||||
MemoryType,
|
||||
Permission,
|
||||
PluginContext,
|
||||
@@ -112,6 +113,7 @@ export type Memory = {
|
||||
export type Asset = {
|
||||
id: string;
|
||||
checksum: Buffer<ArrayBufferLike>;
|
||||
checksumAlgorithm: ChecksumAlgorithm;
|
||||
deviceAssetId: string;
|
||||
deviceId: string;
|
||||
fileCreatedAt: Date;
|
||||
@@ -330,6 +332,7 @@ export const columns = {
|
||||
asset: [
|
||||
'asset.id',
|
||||
'asset.checksum',
|
||||
'asset.checksumAlgorithm',
|
||||
'asset.deviceAssetId',
|
||||
'asset.deviceId',
|
||||
'asset.fileCreatedAt',
|
||||
@@ -400,6 +403,11 @@ export const columns = {
|
||||
'asset.isEdited',
|
||||
],
|
||||
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
|
||||
syncAlbumUserMetadata: [
|
||||
'album_user_metadata.albumId as albumId',
|
||||
'album_user_metadata.userId as userId',
|
||||
'album_user_metadata.isFavorite',
|
||||
],
|
||||
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
|
||||
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
|
||||
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
|
||||
|
||||
@@ -20,4 +20,14 @@ describe('mapAlbum', () => {
|
||||
expect(dto.startDate).toBeUndefined();
|
||||
expect(dto.endDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should default isFavorite to false', () => {
|
||||
const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false);
|
||||
expect(dto.isFavorite).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve a provided favorite state', () => {
|
||||
const dto = mapAlbum({ ...getForAlbum(AlbumFactory.create()), isFavorite: true }, false);
|
||||
expect(dto.isFavorite).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,11 @@ export class UpdateAlbumDto {
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
||||
export class UpdateAlbumUserMetadataDto {
|
||||
@ValidateBoolean({ description: 'Favorite status' })
|
||||
isFavorite!: boolean;
|
||||
}
|
||||
|
||||
export class GetAlbumsDto {
|
||||
@ValidateBoolean({
|
||||
optional: true,
|
||||
@@ -183,6 +188,8 @@ export class AlbumResponseDto {
|
||||
endDate?: string;
|
||||
@ApiProperty({ description: 'Activity feed enabled' })
|
||||
isActivityEnabled!: boolean;
|
||||
@ApiProperty({ description: 'Is favorite' })
|
||||
isFavorite!: boolean;
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
|
||||
order?: AssetOrder;
|
||||
|
||||
@@ -205,6 +212,7 @@ export type MapAlbumDto = {
|
||||
ownerId: string;
|
||||
owner: ShallowDehydrateObject<User>;
|
||||
isActivityEnabled: boolean;
|
||||
isFavorite?: boolean;
|
||||
order: AssetOrder;
|
||||
};
|
||||
|
||||
@@ -256,6 +264,7 @@ export const mapAlbum = (
|
||||
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
|
||||
assetCount: entity.assets?.length || 0,
|
||||
isActivityEnabled: entity.isActivityEnabled,
|
||||
isFavorite: auth?.sharedLink ? false : (entity.isFavorite ?? false),
|
||||
order: entity.order,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export enum BulkIdErrorReason {
|
||||
NO_PERMISSION = 'no_permission',
|
||||
NOT_FOUND = 'not_found',
|
||||
UNKNOWN = 'unknown',
|
||||
VALIDATION = 'validation',
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@@ -37,4 +38,5 @@ export class BulkIdResponseDto {
|
||||
success!: boolean;
|
||||
@ApiPropertyOptional({ description: 'Error reason if failed', enum: BulkIdErrorReason })
|
||||
error?: BulkIdErrorReason;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from 'src/dtos/person.dto';
|
||||
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
|
||||
import { ImageDimensions, MaybeDehydrated } from 'src/types';
|
||||
import { getDimensions } from 'src/utils/asset.util';
|
||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||
@@ -148,6 +148,7 @@ export type MapAsset = {
|
||||
updateId: string;
|
||||
status: AssetStatus;
|
||||
checksum: Buffer<ArrayBufferLike>;
|
||||
checksumAlgorithm: ChecksumAlgorithm;
|
||||
deviceAssetId: string;
|
||||
deviceId: string;
|
||||
duplicateId: string | null;
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { ValidateUUID } from 'src/validation';
|
||||
|
||||
export class DuplicateResponseDto {
|
||||
@ApiProperty({ description: 'Duplicate group ID' })
|
||||
duplicateId!: string;
|
||||
@ApiProperty({ description: 'Duplicate assets' })
|
||||
assets!: AssetResponseDto[];
|
||||
|
||||
@ValidateUUID({ each: true, description: 'Suggested asset IDs to keep based on file size and EXIF data' })
|
||||
suggestedKeepAssetIds!: string[];
|
||||
}
|
||||
|
||||
export class DuplicateResolveGroupDto {
|
||||
@ValidateUUID()
|
||||
duplicateId!: string;
|
||||
|
||||
@ValidateUUID({ each: true, description: 'Asset IDs to keep' })
|
||||
keepAssetIds!: string[];
|
||||
|
||||
@ValidateUUID({ each: true, description: 'Asset IDs to trash or delete' })
|
||||
trashAssetIds!: string[];
|
||||
}
|
||||
|
||||
export class DuplicateResolveDto {
|
||||
@ApiProperty({ description: 'List of duplicate groups to resolve' })
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
@Type(() => DuplicateResolveGroupDto)
|
||||
@ArrayMinSize(1)
|
||||
groups!: DuplicateResolveGroupDto[];
|
||||
}
|
||||
|
||||
@@ -279,6 +279,24 @@ export class SyncAlbumUserV1 {
|
||||
role!: AlbumUserRole;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumUserMetadataDeleteV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumUserMetadataV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
@ApiProperty({ description: 'Is favorite' })
|
||||
isFavorite!: boolean;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
@@ -511,6 +529,8 @@ export type SyncItem = {
|
||||
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
|
||||
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
|
||||
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
|
||||
[SyncEntityType.AlbumUserMetadataV1]: SyncAlbumUserMetadataV1;
|
||||
[SyncEntityType.AlbumUserMetadataDeleteV1]: SyncAlbumUserMetadataDeleteV1;
|
||||
[SyncEntityType.AlbumAssetCreateV1]: SyncAssetV1;
|
||||
[SyncEntityType.AlbumAssetUpdateV1]: SyncAssetV1;
|
||||
[SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1;
|
||||
|
||||
@@ -37,6 +37,11 @@ export enum AssetType {
|
||||
Other = 'OTHER',
|
||||
}
|
||||
|
||||
export enum ChecksumAlgorithm {
|
||||
sha1File = 'sha1', // sha1 checksum of the whole file contents
|
||||
sha1Path = 'sha1-path', // sha1 checksum of "path:" plus the file path, currently used in external libraries, deprecated
|
||||
}
|
||||
|
||||
export enum AssetFileType {
|
||||
/**
|
||||
* An full/large-size image extracted/converted from RAW photos
|
||||
@@ -718,6 +723,7 @@ export enum ExitCode {
|
||||
export enum SyncRequestType {
|
||||
AlbumsV1 = 'AlbumsV1',
|
||||
AlbumUsersV1 = 'AlbumUsersV1',
|
||||
AlbumUserMetadataV1 = 'AlbumUserMetadataV1',
|
||||
AlbumToAssetsV1 = 'AlbumToAssetsV1',
|
||||
AlbumAssetsV1 = 'AlbumAssetsV1',
|
||||
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
||||
@@ -772,6 +778,8 @@ export enum SyncEntityType {
|
||||
AlbumUserV1 = 'AlbumUserV1',
|
||||
AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
|
||||
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
|
||||
AlbumUserMetadataV1 = 'AlbumUserMetadataV1',
|
||||
AlbumUserMetadataDeleteV1 = 'AlbumUserMetadataDeleteV1',
|
||||
|
||||
AlbumAssetCreateV1 = 'AlbumAssetCreateV1',
|
||||
AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1',
|
||||
|
||||
@@ -160,6 +160,16 @@ where
|
||||
"session"."userId" = $1
|
||||
and "session"."id" in ($2)
|
||||
|
||||
-- AccessRepository.duplicate.checkOwnerAccess
|
||||
select
|
||||
"asset"."duplicateId"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."duplicateId" in ($1)
|
||||
and "asset"."ownerId" = $2
|
||||
and "asset"."deletedAt" is null
|
||||
|
||||
-- AccessRepository.memory.checkOwnerAccess
|
||||
select
|
||||
"memory"."id"
|
||||
|
||||
@@ -21,6 +21,18 @@ select
|
||||
"user"."id" = "album"."ownerId"
|
||||
) as obj
|
||||
) as "owner",
|
||||
coalesce(
|
||||
(
|
||||
select
|
||||
"album_user_metadata"."isFavorite"
|
||||
from
|
||||
"album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."albumId" = "album"."id"
|
||||
and "album_user_metadata"."userId" = $1
|
||||
),
|
||||
false
|
||||
) as "isFavorite",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -88,12 +100,24 @@ select
|
||||
from
|
||||
"album"
|
||||
where
|
||||
"album"."id" = $1
|
||||
"album"."id" = $2
|
||||
and "album"."deletedAt" is null
|
||||
|
||||
-- AlbumRepository.getByAssetId
|
||||
select
|
||||
"album".*,
|
||||
coalesce(
|
||||
(
|
||||
select
|
||||
"album_user_metadata"."isFavorite"
|
||||
from
|
||||
"album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."albumId" = "album"."id"
|
||||
and "album_user_metadata"."userId" = $1
|
||||
),
|
||||
false
|
||||
) as "isFavorite",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
@@ -146,6 +170,31 @@ select
|
||||
from
|
||||
"album"
|
||||
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
|
||||
where
|
||||
(
|
||||
"album"."ownerId" = $2
|
||||
or exists (
|
||||
select
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."albumId" = "album"."id"
|
||||
and "album_user"."userId" = $3
|
||||
)
|
||||
)
|
||||
and "album_asset"."assetId" = $4
|
||||
and "album"."deletedAt" is null
|
||||
order by
|
||||
"album"."createdAt" desc,
|
||||
"album"."createdAt" desc
|
||||
|
||||
-- AlbumRepository.getByAssetIds
|
||||
select
|
||||
"album"."id",
|
||||
"album_asset"."assetId"
|
||||
from
|
||||
"album"
|
||||
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
|
||||
where
|
||||
(
|
||||
"album"."ownerId" = $1
|
||||
@@ -158,11 +207,8 @@ where
|
||||
and "album_user"."userId" = $2
|
||||
)
|
||||
)
|
||||
and "album_asset"."assetId" = $3
|
||||
and "album_asset"."assetId" in ($3)
|
||||
and "album"."deletedAt" is null
|
||||
order by
|
||||
"album"."createdAt" desc,
|
||||
"album"."createdAt" desc
|
||||
|
||||
-- AlbumRepository.getMetadataForIds
|
||||
select
|
||||
@@ -188,6 +234,18 @@ group by
|
||||
-- AlbumRepository.getOwned
|
||||
select
|
||||
"album".*,
|
||||
coalesce(
|
||||
(
|
||||
select
|
||||
"album_user_metadata"."isFavorite"
|
||||
from
|
||||
"album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."albumId" = "album"."id"
|
||||
and "album_user_metadata"."userId" = $1
|
||||
),
|
||||
false
|
||||
) as "isFavorite",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
@@ -253,7 +311,7 @@ select
|
||||
from
|
||||
"album"
|
||||
where
|
||||
"album"."ownerId" = $1
|
||||
"album"."ownerId" = $2
|
||||
and "album"."deletedAt" is null
|
||||
order by
|
||||
"album"."createdAt" desc
|
||||
@@ -261,6 +319,18 @@ order by
|
||||
-- AlbumRepository.getShared
|
||||
select
|
||||
"album".*,
|
||||
coalesce(
|
||||
(
|
||||
select
|
||||
"album_user_metadata"."isFavorite"
|
||||
from
|
||||
"album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."albumId" = "album"."id"
|
||||
and "album_user_metadata"."userId" = $1
|
||||
),
|
||||
false
|
||||
) as "isFavorite",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -334,8 +404,8 @@ where
|
||||
where
|
||||
"album_user"."albumId" = "album"."id"
|
||||
and (
|
||||
"album"."ownerId" = $1
|
||||
or "album_user"."userId" = $2
|
||||
"album"."ownerId" = $2
|
||||
or "album_user"."userId" = $3
|
||||
)
|
||||
)
|
||||
or exists (
|
||||
@@ -344,7 +414,7 @@ where
|
||||
"shared_link"
|
||||
where
|
||||
"shared_link"."albumId" = "album"."id"
|
||||
and "shared_link"."userId" = $3
|
||||
and "shared_link"."userId" = $4
|
||||
)
|
||||
)
|
||||
and "album"."deletedAt" is null
|
||||
@@ -354,6 +424,18 @@ order by
|
||||
-- AlbumRepository.getNotShared
|
||||
select
|
||||
"album".*,
|
||||
coalesce(
|
||||
(
|
||||
select
|
||||
"album_user_metadata"."isFavorite"
|
||||
from
|
||||
"album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."albumId" = "album"."id"
|
||||
and "album_user_metadata"."userId" = $1
|
||||
),
|
||||
false
|
||||
) as "isFavorite",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
@@ -375,7 +457,7 @@ select
|
||||
from
|
||||
"album"
|
||||
where
|
||||
"album"."ownerId" = $1
|
||||
"album"."ownerId" = $2
|
||||
and "album"."deletedAt" is null
|
||||
and not exists (
|
||||
select
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- AlbumUserMetadataRepository.upsert
|
||||
insert into
|
||||
"album_user_metadata" ("albumId", "userId", "isFavorite")
|
||||
values
|
||||
($1, $2, $3)
|
||||
on conflict ("albumId", "userId") do update
|
||||
set
|
||||
"isFavorite" = "excluded"."isFavorite"
|
||||
@@ -1,6 +1,7 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- AlbumUserRepository.create
|
||||
begin
|
||||
insert into
|
||||
"album_user" ("userId", "albumId")
|
||||
values
|
||||
@@ -9,6 +10,7 @@ returning
|
||||
"userId",
|
||||
"albumId",
|
||||
"role"
|
||||
rollback
|
||||
|
||||
-- AlbumUserRepository.update
|
||||
update "album_user"
|
||||
@@ -19,7 +21,13 @@ where
|
||||
and "albumId" = $3
|
||||
|
||||
-- AlbumUserRepository.delete
|
||||
begin
|
||||
delete from "album_user_metadata"
|
||||
where
|
||||
"userId" = $1
|
||||
and "albumId" = $2
|
||||
delete from "album_user"
|
||||
where
|
||||
"userId" = $1
|
||||
and "albumId" = $2
|
||||
commit
|
||||
|
||||
@@ -249,6 +249,7 @@ where
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."checksum",
|
||||
"asset"."checksumAlgorithm",
|
||||
"asset"."deviceAssetId",
|
||||
"asset"."deviceId",
|
||||
"asset"."fileCreatedAt",
|
||||
|
||||
@@ -15,7 +15,26 @@ with
|
||||
inner join lateral (
|
||||
select
|
||||
"asset".*,
|
||||
"asset_exif" as "exifInfo"
|
||||
to_json("asset_exif") as "exifInfo",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"tag"."id",
|
||||
"tag"."value",
|
||||
"tag"."createdAt",
|
||||
"tag"."updatedAt",
|
||||
"tag"."color",
|
||||
"tag"."parentId"
|
||||
from
|
||||
"tag"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
|
||||
where
|
||||
"tag_asset"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "tags"
|
||||
from
|
||||
"asset_exif"
|
||||
where
|
||||
@@ -29,36 +48,84 @@ with
|
||||
and "asset"."stackId" is null
|
||||
group by
|
||||
"asset"."duplicateId"
|
||||
),
|
||||
"unique" as (
|
||||
select
|
||||
"duplicateId"
|
||||
from
|
||||
"duplicates"
|
||||
where
|
||||
json_array_length("assets") = $2
|
||||
),
|
||||
"removed_unique" as (
|
||||
update "asset"
|
||||
set
|
||||
"duplicateId" = $3
|
||||
from
|
||||
"unique"
|
||||
where
|
||||
"asset"."duplicateId" = "unique"."duplicateId"
|
||||
)
|
||||
select
|
||||
*
|
||||
from
|
||||
"duplicates"
|
||||
where
|
||||
not exists (
|
||||
json_array_length("assets") > $2
|
||||
|
||||
-- DuplicateRepository.cleanupSingletonGroups
|
||||
with
|
||||
"singletons" as (
|
||||
select
|
||||
"duplicateId"
|
||||
from
|
||||
"unique"
|
||||
"asset"
|
||||
where
|
||||
"unique"."duplicateId" = "duplicates"."duplicateId"
|
||||
"ownerId" = $1::uuid
|
||||
and "duplicateId" is not null
|
||||
and "deletedAt" is null
|
||||
and "stackId" is null
|
||||
group by
|
||||
"duplicateId"
|
||||
having
|
||||
count("id") = $2
|
||||
)
|
||||
update "asset"
|
||||
set
|
||||
"duplicateId" = $3
|
||||
from
|
||||
"singletons"
|
||||
where
|
||||
"asset"."duplicateId" = "singletons"."duplicateId"
|
||||
|
||||
-- DuplicateRepository.get
|
||||
select
|
||||
"asset"."duplicateId",
|
||||
json_agg(
|
||||
"asset2"
|
||||
order by
|
||||
"asset"."localDateTime" asc
|
||||
) as "assets"
|
||||
from
|
||||
"asset"
|
||||
inner join lateral (
|
||||
select
|
||||
"asset".*,
|
||||
to_json("asset_exif") as "exifInfo",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"tag"."id",
|
||||
"tag"."value",
|
||||
"tag"."createdAt",
|
||||
"tag"."updatedAt",
|
||||
"tag"."color",
|
||||
"tag"."parentId"
|
||||
from
|
||||
"tag"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
|
||||
where
|
||||
"tag_asset"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "tags"
|
||||
from
|
||||
"asset_exif"
|
||||
where
|
||||
"asset_exif"."assetId" = "asset"."id"
|
||||
) as "asset2" on true
|
||||
where
|
||||
"asset"."visibility" in ('archive', 'timeline')
|
||||
and "asset"."duplicateId" = $1::uuid
|
||||
and "asset"."deletedAt" is null
|
||||
and "asset"."stackId" is null
|
||||
group by
|
||||
"asset"."duplicateId"
|
||||
|
||||
-- DuplicateRepository.delete
|
||||
update "asset"
|
||||
|
||||
@@ -228,12 +228,12 @@ select
|
||||
from
|
||||
"asset_face"
|
||||
left join "asset" on "asset"."id" = "asset_face"."assetId"
|
||||
and "asset_face"."personId" = $1
|
||||
and "asset"."visibility" = 'timeline'
|
||||
and "asset"."deletedAt" is null
|
||||
where
|
||||
"asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
and "asset_face"."personId" = $1
|
||||
|
||||
-- PersonRepository.getNumberOfPeople
|
||||
select
|
||||
|
||||
@@ -285,6 +285,35 @@ where
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
|
||||
-- SyncRepository.albumUserMetadata.getDeletes
|
||||
select
|
||||
"id",
|
||||
"albumId",
|
||||
"userId"
|
||||
from
|
||||
"album_user_metadata_audit" as "album_user_metadata_audit"
|
||||
where
|
||||
"album_user_metadata_audit"."id" < $1
|
||||
and "album_user_metadata_audit"."id" > $2
|
||||
and "userId" = $3
|
||||
order by
|
||||
"album_user_metadata_audit"."id" asc
|
||||
|
||||
-- SyncRepository.albumUserMetadata.getUpserts
|
||||
select
|
||||
"album_user_metadata"."albumId" as "albumId",
|
||||
"album_user_metadata"."userId" as "userId",
|
||||
"album_user_metadata"."isFavorite",
|
||||
"album_user_metadata"."updateId"
|
||||
from
|
||||
"album_user_metadata" as "album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."updateId" < $1
|
||||
and "album_user_metadata"."updateId" > $2
|
||||
and "userId" = $3
|
||||
order by
|
||||
"album_user_metadata"."updateId" asc
|
||||
|
||||
-- SyncRepository.albumToAsset.getBackfill
|
||||
select
|
||||
"album_asset"."assetId" as "assetId",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { Kysely, NotNull, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AlbumUserRole, AssetVisibility } from 'src/enum';
|
||||
@@ -285,6 +285,28 @@ class AuthDeviceAccess {
|
||||
}
|
||||
}
|
||||
|
||||
class DuplicateAccess {
|
||||
constructor(private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async checkOwnerAccess(userId: string, duplicateIds: Set<string>) {
|
||||
if (duplicateIds.size === 0) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select('asset.duplicateId')
|
||||
.where('asset.duplicateId', 'in', [...duplicateIds])
|
||||
.where('asset.ownerId', '=', userId)
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.$narrowType<{ duplicateId: NotNull }>()
|
||||
.execute()
|
||||
.then((assets) => new Set(assets.map((asset) => asset.duplicateId)));
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationAccess {
|
||||
constructor(private db: Kysely<DB>) {}
|
||||
|
||||
@@ -488,6 +510,7 @@ export class AccessRepository {
|
||||
album: AlbumAccess;
|
||||
asset: AssetAccess;
|
||||
authDevice: AuthDeviceAccess;
|
||||
duplicate: DuplicateAccess;
|
||||
memory: MemoryAccess;
|
||||
notification: NotificationAccess;
|
||||
person: PersonAccess;
|
||||
@@ -503,6 +526,7 @@ export class AccessRepository {
|
||||
this.album = new AlbumAccess(db);
|
||||
this.asset = new AssetAccess(db);
|
||||
this.authDevice = new AuthDeviceAccess(db);
|
||||
this.duplicate = new DuplicateAccess(db);
|
||||
this.memory = new MemoryAccess(db);
|
||||
this.notification = new NotificationAccess(db);
|
||||
this.person = new PersonAccess(db);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { DB } from 'src/schema';
|
||||
import { AlbumUserMetadataTable } from 'src/schema/tables/album-user-metadata.table';
|
||||
|
||||
export type AlbumUserMetadataId = {
|
||||
albumId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AlbumUserMetadataRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [{ albumId: DummyValue.UUID, userId: DummyValue.UUID, isFavorite: true }] })
|
||||
async upsert(dto: Insertable<AlbumUserMetadataTable>) {
|
||||
await this.db
|
||||
.insertInto('album_user_metadata')
|
||||
.values(dto)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['albumId', 'userId']).doUpdateSet((eb) => ({ isFavorite: eb.ref('excluded.isFavorite') })),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,21 @@ export class AlbumUserRepository {
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
|
||||
create(albumUser: Insertable<AlbumUserTable>) {
|
||||
return this.db
|
||||
.insertInto('album_user')
|
||||
.values(albumUser)
|
||||
.returning(['userId', 'albumId', 'role'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
const result = await tx
|
||||
.insertInto('album_user')
|
||||
.values(albumUser)
|
||||
.returning(['userId', 'albumId', 'role'])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await tx
|
||||
.insertInto('album_user_metadata')
|
||||
.values({ albumId: albumUser.albumId, userId: albumUser.userId, isFavorite: false })
|
||||
.onConflict((oc) => oc.columns(['albumId', 'userId']).doNothing())
|
||||
.execute();
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] })
|
||||
@@ -36,6 +46,9 @@ export class AlbumUserRepository {
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
|
||||
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
|
||||
await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute();
|
||||
await this.db.transaction().execute(async (tx) => {
|
||||
await tx.deleteFrom('album_user_metadata').where('userId', '=', userId).where('albumId', '=', albumId).execute();
|
||||
await tx.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,18 @@ export interface AlbumInfoOptions {
|
||||
withAssets: boolean;
|
||||
}
|
||||
|
||||
const withFavorite = (eb: ExpressionBuilder<DB, 'album'>, userId?: string) => {
|
||||
if (!userId) {
|
||||
return sql<boolean>`false`.as('isFavorite');
|
||||
}
|
||||
|
||||
return sql<boolean>`coalesce(${eb
|
||||
.selectFrom('album_user_metadata')
|
||||
.select('album_user_metadata.isFavorite')
|
||||
.whereRef('album_user_metadata.albumId', '=', 'album.id')
|
||||
.where('album_user_metadata.userId', '=', userId)}, false)`.as('isFavorite');
|
||||
};
|
||||
|
||||
const withOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album.ownerId'))
|
||||
.$notNull()
|
||||
@@ -84,14 +96,15 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
export class AlbumRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
|
||||
async getById(id: string, options: AlbumInfoOptions) {
|
||||
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }, DummyValue.UUID] })
|
||||
async getById(id: string, options: AlbumInfoOptions, userId?: string) {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.where('album.id', '=', id)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select(withOwner)
|
||||
.select((eb) => withFavorite(eb, userId))
|
||||
.select(withAlbumUsers)
|
||||
.select(withSharedLink)
|
||||
.$if(options.withAssets, (eb) => eb.select(withAssets))
|
||||
@@ -119,12 +132,51 @@ export class AlbumRepository {
|
||||
.where('album_asset.assetId', '=', assetId)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.select((eb) => withFavorite(eb, ownerId))
|
||||
.select(withOwner)
|
||||
.select(withAlbumUsers)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async getByAssetIds(ownerId: string, assetIds: string[]): Promise<Map<string, string[]>> {
|
||||
if (assetIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const results = await this.db
|
||||
.selectFrom('album')
|
||||
.select('album.id')
|
||||
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('album.ownerId', '=', ownerId),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where('album_user.userId', '=', ownerId),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.where('album_asset.assetId', 'in', assetIds)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select('album_asset.assetId')
|
||||
.execute();
|
||||
|
||||
// Group by assetId
|
||||
const map = new Map<string, string[]>();
|
||||
for (const row of results) {
|
||||
const existing = map.get(row.assetId) ?? [];
|
||||
existing.push(row.id);
|
||||
map.set(row.assetId, existing);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@ChunkedArray()
|
||||
async getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]> {
|
||||
@@ -156,6 +208,7 @@ export class AlbumRepository {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.select((eb) => withFavorite(eb, ownerId))
|
||||
.select(withOwner)
|
||||
.select(withAlbumUsers)
|
||||
.select(withSharedLink)
|
||||
@@ -173,6 +226,7 @@ export class AlbumRepository {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.select((eb) => withFavorite(eb, ownerId))
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.exists(
|
||||
@@ -205,6 +259,7 @@ export class AlbumRepository {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.select((eb) => withFavorite(eb, ownerId))
|
||||
.where('album.ownerId', '=', ownerId)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id'))))
|
||||
@@ -280,6 +335,15 @@ export class AlbumRepository {
|
||||
throw new Error('Failed to create album');
|
||||
}
|
||||
|
||||
await tx
|
||||
.insertInto('album_user_metadata')
|
||||
.values([
|
||||
{ albumId: newAlbum.id, userId: album.ownerId, isFavorite: false },
|
||||
...albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, isFavorite: false })),
|
||||
])
|
||||
.onConflict((oc) => oc.columns(['albumId', 'userId']).doNothing())
|
||||
.execute();
|
||||
|
||||
if (assetIds.length > 0) {
|
||||
await this.addAssets(tx, newAlbum.id, assetIds);
|
||||
}
|
||||
@@ -297,6 +361,7 @@ export class AlbumRepository {
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.where('id', '=', newAlbum.id)
|
||||
.select((eb) => withFavorite(eb, album.ownerId))
|
||||
.select(withOwner)
|
||||
.select(withAssets)
|
||||
.select(withAlbumUsers)
|
||||
@@ -339,7 +404,12 @@ export class AlbumRepository {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.db.insertInto('album_asset').values(values).execute();
|
||||
await this.db
|
||||
.insertInto('album_asset')
|
||||
.values(values)
|
||||
// Allow idempotent album sync without failing on existing album memberships.
|
||||
.onConflict((oc) => oc.columns(['albumId', 'assetId']).doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, NotNull, Selectable, ShallowDehydrateObject, sql } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetType, VectorIndex } from 'src/enum';
|
||||
import { probes } from 'src/repositories/database.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { anyUuid, asUuid, withDefaultVisibility } from 'src/utils/database';
|
||||
|
||||
// Maximum number of candidate duplicates to return from vector search
|
||||
const DUPLICATE_SEARCH_LIMIT = 64;
|
||||
|
||||
interface DuplicateSearch {
|
||||
assetId: string;
|
||||
embedding: string;
|
||||
@@ -34,20 +40,39 @@ export class DuplicateRepository {
|
||||
qb
|
||||
.selectFrom('asset')
|
||||
.$call(withDefaultVisibility)
|
||||
// Use innerJoinLateral to build a composite object per asset that includes
|
||||
// exifInfo and tags. This "asset2" object is then aggregated via jsonAgg.
|
||||
// Tags must be included here (not via separate joins) so they appear in the
|
||||
// final MapAsset[] output - needed for tag synchronization during resolution.
|
||||
.innerJoinLateral(
|
||||
(qb) =>
|
||||
qb
|
||||
.selectFrom('asset_exif')
|
||||
.selectAll('asset')
|
||||
.select((eb) =>
|
||||
eb.table('asset_exif').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
|
||||
eb.fn
|
||||
.toJson('asset_exif')
|
||||
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>()
|
||||
.as('exifInfo'),
|
||||
)
|
||||
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('tag')
|
||||
.select(columns.tag)
|
||||
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
|
||||
.whereRef('tag_asset.assetId', '=', 'asset.id'),
|
||||
).as('tags'),
|
||||
)
|
||||
.whereRef('asset_exif.assetId', '=', 'asset.id')
|
||||
.as('asset2'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select('asset.duplicateId')
|
||||
.select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').as('assets'))
|
||||
.select((eb) =>
|
||||
eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo<MapAsset[]>().as('assets'),
|
||||
)
|
||||
.where('asset.ownerId', '=', asUuid(userId))
|
||||
.where('asset.duplicateId', 'is not', null)
|
||||
.$narrowType<{ duplicateId: NotNull }>()
|
||||
@@ -55,29 +80,80 @@ export class DuplicateRepository {
|
||||
.where('asset.stackId', 'is', null)
|
||||
.groupBy('asset.duplicateId'),
|
||||
)
|
||||
.with('unique', (qb) =>
|
||||
qb
|
||||
.selectFrom('duplicates')
|
||||
.select('duplicateId')
|
||||
.where((eb) => eb(eb.fn('json_array_length', ['assets']), '=', 1)),
|
||||
)
|
||||
.with('removed_unique', (qb) =>
|
||||
qb
|
||||
.updateTable('asset')
|
||||
.set({ duplicateId: null })
|
||||
.from('unique')
|
||||
.whereRef('asset.duplicateId', '=', 'unique.duplicateId'),
|
||||
)
|
||||
.selectFrom('duplicates')
|
||||
.selectAll()
|
||||
// TODO: compare with filtering by json_array_length > 1
|
||||
.where(({ not, exists }) =>
|
||||
not(exists((eb) => eb.selectFrom('unique').whereRef('unique.duplicateId', '=', 'duplicates.duplicateId'))),
|
||||
)
|
||||
// Filter out singleton groups (only 1 asset) directly in the query
|
||||
.where((eb) => eb(eb.fn('json_array_length', ['assets']), '>', 1))
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async cleanupSingletonGroups(userId: string): Promise<void> {
|
||||
// Remove duplicateId from assets that are the only member of their duplicate group
|
||||
await this.db
|
||||
.with('singletons', (qb) =>
|
||||
qb
|
||||
.selectFrom('asset')
|
||||
.select('duplicateId')
|
||||
.where('ownerId', '=', asUuid(userId))
|
||||
.where('duplicateId', 'is not', null)
|
||||
.$narrowType<{ duplicateId: NotNull }>()
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('stackId', 'is', null)
|
||||
.groupBy('duplicateId')
|
||||
.having((eb) => eb.fn.count('id'), '=', 1),
|
||||
)
|
||||
.updateTable('asset')
|
||||
.set({ duplicateId: null })
|
||||
.from('singletons')
|
||||
.whereRef('asset.duplicateId', '=', 'singletons.duplicateId')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||
async get(duplicateId: string): Promise<{ duplicateId: string; assets: MapAsset[] } | undefined> {
|
||||
const result = await this.db
|
||||
.selectFrom('asset')
|
||||
.$call(withDefaultVisibility)
|
||||
// Use innerJoinLateral to build a composite object per asset that includes
|
||||
// exifInfo and tags. This "asset2" object is then aggregated via jsonAgg.
|
||||
// Tags must be included here (not via separate joins) so they appear in the
|
||||
// final MapAsset[] output - needed for tag synchronization during resolution.
|
||||
.innerJoinLateral(
|
||||
(qb) =>
|
||||
qb
|
||||
.selectFrom('asset_exif')
|
||||
.selectAll('asset')
|
||||
.select((eb) => eb.fn.toJson('asset_exif').as('exifInfo'))
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('tag')
|
||||
.select(columns.tag)
|
||||
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
|
||||
.whereRef('tag_asset.assetId', '=', 'asset.id'),
|
||||
).as('tags'),
|
||||
)
|
||||
.whereRef('asset_exif.assetId', '=', 'asset.id')
|
||||
.as('asset2'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select('asset.duplicateId')
|
||||
.select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo<MapAsset[]>().as('assets'))
|
||||
.where('asset.duplicateId', '=', asUuid(duplicateId))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.where('asset.stackId', 'is', null)
|
||||
.groupBy('asset.duplicateId')
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result || !result.duplicateId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { duplicateId: result.duplicateId, assets: result.assets };
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.db
|
||||
@@ -134,7 +210,7 @@ export class DuplicateRepository {
|
||||
.where('asset.id', '!=', asUuid(assetId))
|
||||
.where('asset.stackId', 'is', null)
|
||||
.orderBy('distance')
|
||||
.limit(64),
|
||||
.limit(DUPLICATE_SEARCH_LIMIT),
|
||||
)
|
||||
.selectFrom('cte')
|
||||
.selectAll()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||
@@ -55,6 +56,7 @@ export const repositories = [
|
||||
AccessRepository,
|
||||
ActivityRepository,
|
||||
AlbumRepository,
|
||||
AlbumUserMetadataRepository,
|
||||
AlbumUserRepository,
|
||||
AuditRepository,
|
||||
ApiKeyRepository,
|
||||
|
||||
@@ -352,13 +352,13 @@ export class PersonRepository {
|
||||
.leftJoin('asset', (join) =>
|
||||
join
|
||||
.onRef('asset.id', '=', 'asset_face.assetId')
|
||||
.on('asset_face.personId', '=', personId)
|
||||
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
|
||||
.on('asset.deletedAt', 'is', null),
|
||||
)
|
||||
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.where('asset_face.isVisible', 'is', true)
|
||||
.where('asset_face.personId', '=', personId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,6 +49,7 @@ export class SyncRepository {
|
||||
album: AlbumSync;
|
||||
albumAsset: AlbumAssetSync;
|
||||
albumAssetExif: AlbumAssetExifSync;
|
||||
albumUserMetadata: AlbumUserMetadataSync;
|
||||
albumToAsset: AlbumToAssetSync;
|
||||
albumUser: AlbumUserSync;
|
||||
asset: AssetSync;
|
||||
@@ -72,6 +73,7 @@ export class SyncRepository {
|
||||
this.album = new AlbumSync(this.db);
|
||||
this.albumAsset = new AlbumAssetSync(this.db);
|
||||
this.albumAssetExif = new AlbumAssetExifSync(this.db);
|
||||
this.albumUserMetadata = new AlbumUserMetadataSync(this.db);
|
||||
this.albumToAsset = new AlbumToAssetSync(this.db);
|
||||
this.albumUser = new AlbumUserSync(this.db);
|
||||
this.asset = new AssetSync(this.db);
|
||||
@@ -385,6 +387,29 @@ class AlbumUserSync extends BaseSync {
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumUserMetadataSync extends BaseSync {
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getDeletes(options: SyncQueryOptions) {
|
||||
return this.auditQuery('album_user_metadata_audit', options)
|
||||
.select(['id', 'albumId', 'userId'])
|
||||
.where('userId', '=', options.userId)
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('album_user_metadata_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('album_user_metadata', options)
|
||||
.select(columns.syncAlbumUserMetadata)
|
||||
.select('album_user_metadata.updateId')
|
||||
.where('userId', '=', options.userId)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
class AssetSync extends BaseSync {
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getDeletes(options: SyncQueryOptions) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { registerEnum } from '@immich/sql-tools';
|
||||
import { AssetStatus, AssetVisibility, SourceType } from 'src/enum';
|
||||
import { AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
|
||||
|
||||
export const assets_status_enum = registerEnum({
|
||||
name: 'assets_status_enum',
|
||||
@@ -15,3 +15,8 @@ export const asset_visibility_enum = registerEnum({
|
||||
name: 'asset_visibility_enum',
|
||||
values: Object.values(AssetVisibility),
|
||||
});
|
||||
|
||||
export const asset_checksum_algorithm_enum = registerEnum({
|
||||
name: 'asset_checksum_algorithm_enum',
|
||||
values: Object.values(ChecksumAlgorithm),
|
||||
});
|
||||
|
||||
@@ -165,6 +165,19 @@ export const album_user_delete_audit = registerFunction({
|
||||
END`,
|
||||
});
|
||||
|
||||
export const album_user_metadata_audit = registerFunction({
|
||||
name: 'album_user_metadata_audit',
|
||||
returnType: 'TRIGGER',
|
||||
language: 'PLPGSQL',
|
||||
body: `
|
||||
BEGIN
|
||||
INSERT INTO album_user_metadata_audit ("albumId", "userId")
|
||||
SELECT "albumId", "userId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END`,
|
||||
});
|
||||
|
||||
export const memory_delete_audit = registerFunction({
|
||||
name: 'memory_delete_audit',
|
||||
returnType: 'TRIGGER',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
album_delete_audit,
|
||||
album_user_after_insert,
|
||||
album_user_delete_audit,
|
||||
album_user_metadata_audit,
|
||||
asset_delete_audit,
|
||||
asset_face_audit,
|
||||
asset_metadata_audit,
|
||||
@@ -25,6 +26,8 @@ import { AlbumAssetAuditTable } from 'src/schema/tables/album-asset-audit.table'
|
||||
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
|
||||
import { AlbumAuditTable } from 'src/schema/tables/album-audit.table';
|
||||
import { AlbumUserAuditTable } from 'src/schema/tables/album-user-audit.table';
|
||||
import { AlbumUserMetadataAuditTable } from 'src/schema/tables/album-user-metadata-audit.table';
|
||||
import { AlbumUserMetadataTable } from 'src/schema/tables/album-user-metadata.table';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
|
||||
@@ -83,6 +86,8 @@ export class ImmichDatabase {
|
||||
AlbumAssetTable,
|
||||
AlbumAssetAuditTable,
|
||||
AlbumAuditTable,
|
||||
AlbumUserMetadataAuditTable,
|
||||
AlbumUserMetadataTable,
|
||||
AlbumUserAuditTable,
|
||||
AlbumUserTable,
|
||||
AlbumTable,
|
||||
@@ -150,6 +155,7 @@ export class ImmichDatabase {
|
||||
asset_delete_audit,
|
||||
album_delete_audit,
|
||||
album_user_after_insert,
|
||||
album_user_metadata_audit,
|
||||
album_user_delete_audit,
|
||||
memory_delete_audit,
|
||||
memory_asset_delete_audit,
|
||||
@@ -178,6 +184,8 @@ export interface DB {
|
||||
album_audit: AlbumAuditTable;
|
||||
album_asset: AlbumAssetTable;
|
||||
album_asset_audit: AlbumAssetAuditTable;
|
||||
album_user_metadata: AlbumUserMetadataTable;
|
||||
album_user_metadata_audit: AlbumUserMetadataAuditTable;
|
||||
album_user: AlbumUserTable;
|
||||
album_user_audit: AlbumUserAuditTable;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE TYPE "asset_checksum_algorithm_enum" AS ENUM ('sha1','sha1-path');`.execute(db);
|
||||
await sql`ALTER TABLE "asset" ADD "checksumAlgorithm" asset_checksum_algorithm_enum;`.execute(db);
|
||||
|
||||
await sql`
|
||||
UPDATE "asset"
|
||||
SET "checksumAlgorithm" = CASE
|
||||
WHEN "isExternal" = true THEN 'sha1-path'::asset_checksum_algorithm_enum
|
||||
ELSE 'sha1'::asset_checksum_algorithm_enum
|
||||
END
|
||||
`.execute(db);
|
||||
|
||||
await sql`ALTER TABLE "asset" ALTER COLUMN "checksumAlgorithm" SET NOT NULL;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset" DROP COLUMN "checksumAlgorithm";`.execute(db);
|
||||
await sql`DROP TYPE "asset_checksum_algorithm_enum";`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE OR REPLACE FUNCTION album_user_metadata_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO album_user_metadata_audit ("albumId", "userId")
|
||||
SELECT "albumId", "userId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE TABLE "album_user_metadata_audit" (
|
||||
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"albumId" uuid NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT "album_user_metadata_audit_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_audit_albumId_idx" ON "album_user_metadata_audit" ("albumId");`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_audit_userId_idx" ON "album_user_metadata_audit" ("userId");`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_audit_deletedAt_idx" ON "album_user_metadata_audit" ("deletedAt");`.execute(db);
|
||||
await sql`CREATE TABLE "album_user_metadata" (
|
||||
"albumId" uuid NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"isFavorite" boolean NOT NULL DEFAULT false,
|
||||
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "album_user_metadata_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "album" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "album_user_metadata_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "album_user_metadata_pkey" PRIMARY KEY ("albumId", "userId")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_userId_idx" ON "album_user_metadata" ("userId");`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_updateId_idx" ON "album_user_metadata" ("updateId");`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_updatedAt_idx" ON "album_user_metadata" ("updatedAt");`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "album_user_metadata_audit"
|
||||
AFTER DELETE ON "album_user_metadata"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH STATEMENT
|
||||
WHEN (pg_trigger_depth() = 0)
|
||||
EXECUTE FUNCTION album_user_metadata_audit();`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "album_user_metadata_updated_at"
|
||||
BEFORE UPDATE ON "album_user_metadata"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION updated_at();`.execute(db);
|
||||
await sql`INSERT INTO "album_user_metadata" ("albumId", "userId", "isFavorite")
|
||||
SELECT "id", "ownerId", false FROM "album"
|
||||
ON CONFLICT ("albumId", "userId") DO NOTHING;`.execute(db);
|
||||
await sql`INSERT INTO "album_user_metadata" ("albumId", "userId", "isFavorite")
|
||||
SELECT "albumId", "userId", false FROM "album_user"
|
||||
ON CONFLICT ("albumId", "userId") DO NOTHING;`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_user_metadata_audit', '{"type":"function","name":"album_user_metadata_audit","sql":"CREATE OR REPLACE FUNCTION album_user_metadata_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_user_metadata_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"albumId\\", \\"userId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_metadata_audit', '{"type":"trigger","name":"album_user_metadata_audit","sql":"CREATE OR REPLACE TRIGGER \\"album_user_metadata_audit\\"\\n AFTER DELETE ON \\"album_user_metadata\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION album_user_metadata_audit();"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_metadata_updated_at', '{"type":"trigger","name":"album_user_metadata_updated_at","sql":"CREATE OR REPLACE TRIGGER \\"album_user_metadata_updated_at\\"\\n BEFORE UPDATE ON \\"album_user_metadata\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TABLE "album_user_metadata_audit";`.execute(db);
|
||||
await sql`DROP TABLE "album_user_metadata";`.execute(db);
|
||||
await sql`DROP FUNCTION album_user_metadata_audit;`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_user_metadata_audit';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_metadata_audit';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_metadata_updated_at';`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
|
||||
@Table('album_user_metadata_audit')
|
||||
export class AlbumUserMetadataAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ type: 'uuid', index: true })
|
||||
albumId!: string;
|
||||
|
||||
@Column({ type: 'uuid', index: true })
|
||||
userId!: string;
|
||||
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
||||
deletedAt!: Generated<Timestamp>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { album_user_metadata_audit } from 'src/schema/functions';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@UpdatedAtTrigger('album_user_metadata_updated_at')
|
||||
@Table('album_user_metadata')
|
||||
@AfterDeleteTrigger({
|
||||
scope: 'statement',
|
||||
function: album_user_metadata_audit,
|
||||
referencingOldTableAs: 'old',
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
export class AlbumUserMetadataTable {
|
||||
@ForeignKeyColumn(() => AlbumTable, {
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
primary: true,
|
||||
index: false,
|
||||
})
|
||||
albumId!: string;
|
||||
|
||||
@ForeignKeyColumn(() => UserTable, {
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
primary: true,
|
||||
index: true,
|
||||
})
|
||||
userId!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isFavorite!: Generated<boolean>;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
@UpdateDateColumn({ index: true })
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
|
||||
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
|
||||
import { asset_checksum_algorithm_enum, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
|
||||
import { asset_delete_audit } from 'src/schema/functions';
|
||||
import { LibraryTable } from 'src/schema/tables/library.table';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
@@ -95,6 +95,9 @@ export class AssetTable {
|
||||
@Column({ type: 'bytea', index: true })
|
||||
checksum!: Buffer; // sha1 checksum
|
||||
|
||||
@Column({ enum: asset_checksum_algorithm_enum })
|
||||
checksumAlgorithm!: ChecksumAlgorithm;
|
||||
|
||||
@ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
|
||||
livePhotoVideoId!: string | null;
|
||||
|
||||
|
||||
@@ -112,6 +112,23 @@ describe(AlbumService.name, () => {
|
||||
expect(mocks.album.getShared).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('includes favorite status in album lists', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getOwned.mockResolvedValue([{ ...getForAlbum(album), isFavorite: true }]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.getAll(AuthFactory.create(album.owner), {});
|
||||
expect(result[0].isFavorite).toBe(true);
|
||||
});
|
||||
|
||||
it('gets list of albums that are NOT shared', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]);
|
||||
@@ -337,12 +354,42 @@ describe(AlbumService.name, () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.update.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.update).toHaveBeenCalledWith(album.id, { id: album.id, albumName: 'new album name' });
|
||||
});
|
||||
|
||||
it('should preserve favorite status in the response', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.update.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true });
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' });
|
||||
expect(result.isFavorite).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
@@ -430,7 +477,16 @@ describe(AlbumService.name, () => {
|
||||
const user = UserFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.update.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build());
|
||||
|
||||
@@ -468,7 +524,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, undefined);
|
||||
});
|
||||
|
||||
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
|
||||
@@ -565,10 +621,28 @@ describe(AlbumService.name, () => {
|
||||
|
||||
await sut.get(AuthFactory.create(album.owner), album.id, {});
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }, album.owner.id);
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([album.id]));
|
||||
});
|
||||
|
||||
it('should include favorite status for the authenticated user', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true });
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
lastModifiedAssetTimestamp: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.get(AuthFactory.create(album.owner), album.id, {});
|
||||
expect(result.isFavorite).toBe(true);
|
||||
});
|
||||
|
||||
it('should get a shared album via a shared link', async () => {
|
||||
const album = AlbumFactory.from().albumUser().build();
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
@@ -586,7 +660,7 @@ describe(AlbumService.name, () => {
|
||||
const auth = AuthFactory.from().sharedLink().build();
|
||||
await sut.get(auth, album.id, {});
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }, auth.user.id);
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(auth.sharedLink!.id, new Set([album.id]));
|
||||
});
|
||||
|
||||
@@ -607,7 +681,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
await sut.get(AuthFactory.create(user), album.id, {});
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }, user.id);
|
||||
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
new Set([album.id]),
|
||||
@@ -615,6 +689,26 @@ describe(AlbumService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not expose favorite status over shared links', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
lastModifiedAssetTimestamp: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
|
||||
const auth = AuthFactory.from().sharedLink().build();
|
||||
const result = await sut.get(auth, album.id, {});
|
||||
|
||||
expect(result.isFavorite).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw an error for no access', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
await expect(sut.get(auth, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
@@ -628,6 +722,53 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAlbumUserMetadata', () => {
|
||||
it('should update favorite status for an owned album', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.albumUserMetadata.upsert.mockResolvedValue();
|
||||
mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true });
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.updateAlbumUserMetadata(AuthFactory.create(album.owner), album.id, { isFavorite: true });
|
||||
|
||||
expect(mocks.albumUserMetadata.upsert).toHaveBeenCalledWith({
|
||||
albumId: album.id,
|
||||
userId: album.owner.id,
|
||||
isFavorite: true,
|
||||
});
|
||||
expect(result.isFavorite).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow shared viewers to update favorite status', async () => {
|
||||
const viewer = UserFactory.create();
|
||||
const album = AlbumFactory.from().albumUser({ userId: viewer.id, role: AlbumUserRole.Viewer }).build();
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.albumUserMetadata.upsert.mockResolvedValue();
|
||||
mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true });
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.updateAlbumUserMetadata(AuthFactory.create(viewer), album.id, { isFavorite: true });
|
||||
expect(result.isFavorite).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should allow the owner to add assets', async () => {
|
||||
const owner = UserFactory.create({ isAdmin: true });
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
mapAlbumWithoutAssets,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
UpdateAlbumUserMetadataDto,
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -77,7 +78,7 @@ export class AlbumService extends BaseService {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
|
||||
await this.albumRepository.updateThumbnails();
|
||||
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
|
||||
const album = await this.findOrFail(id, { withAssets });
|
||||
const album = await this.findOrFail(id, { withAssets }, auth.user.id);
|
||||
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
|
||||
|
||||
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0;
|
||||
@@ -147,7 +148,7 @@ export class AlbumService extends BaseService {
|
||||
throw new BadRequestException('Invalid album thumbnail');
|
||||
}
|
||||
}
|
||||
const updatedAlbum = await this.albumRepository.update(album.id, {
|
||||
await this.albumRepository.update(album.id, {
|
||||
id: album.id,
|
||||
albumName: dto.albumName,
|
||||
description: dto.description,
|
||||
@@ -156,7 +157,15 @@ export class AlbumService extends BaseService {
|
||||
order: dto.order,
|
||||
});
|
||||
|
||||
return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets });
|
||||
return this.get(auth, id, { withoutAssets: true });
|
||||
}
|
||||
|
||||
async updateAlbumUserMetadata(auth: AuthDto, id: string, dto: UpdateAlbumUserMetadataDto): Promise<AlbumResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
|
||||
|
||||
await this.albumUserMetadataRepository.upsert({ albumId: id, userId: auth.user.id, isFavorite: dto.isFavorite });
|
||||
|
||||
return this.get(auth, id, { withoutAssets: true });
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
@@ -306,7 +315,7 @@ export class AlbumService extends BaseService {
|
||||
await this.eventRepository.emit('AlbumInvite', { id, userId });
|
||||
}
|
||||
|
||||
return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets);
|
||||
return this.get(auth, id, { withoutAssets: true });
|
||||
}
|
||||
|
||||
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
|
||||
@@ -338,8 +347,8 @@ export class AlbumService extends BaseService {
|
||||
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
|
||||
}
|
||||
|
||||
private async findOrFail(id: string, options: AlbumInfoOptions) {
|
||||
const album = await this.albumRepository.getById(id, options);
|
||||
private async findOrFail(id: string, options: AlbumInfoOptions, userId?: string) {
|
||||
const album = await this.albumRepository.getById(id, options, userId);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
AssetStatus,
|
||||
AssetVisibility,
|
||||
CacheControl,
|
||||
ChecksumAlgorithm,
|
||||
JobName,
|
||||
Permission,
|
||||
StorageFolder,
|
||||
@@ -425,6 +426,7 @@ export class AssetMediaService extends BaseService {
|
||||
deviceId: asset.deviceId,
|
||||
type: asset.type,
|
||||
checksum: asset.checksum,
|
||||
checksumAlgorithm: asset.checksumAlgorithm,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
@@ -446,6 +448,7 @@ export class AssetMediaService extends BaseService {
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StorageCore } from 'src/cores/storage.core';
|
||||
import { UserAdmin } from 'src/database';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||
@@ -66,6 +67,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
AccessRepository,
|
||||
ActivityRepository,
|
||||
AlbumRepository,
|
||||
AlbumUserMetadataRepository,
|
||||
AlbumUserRepository,
|
||||
ApiKeyRepository,
|
||||
AppRepository,
|
||||
@@ -125,6 +127,7 @@ export class BaseService {
|
||||
protected accessRepository: AccessRepository,
|
||||
protected activityRepository: ActivityRepository,
|
||||
protected albumRepository: AlbumRepository,
|
||||
protected albumUserMetadataRepository: AlbumUserMetadataRepository,
|
||||
protected albumUserRepository: AlbumUserRepository,
|
||||
protected apiKeyRepository: ApiKeyRepository,
|
||||
protected appRepository: AppRepository,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
||||
import { DuplicateService } from 'src/services/duplicate.service';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForDuplicate } from 'test/mappers';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
import { beforeEach, vitest } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vitest } from 'vitest';
|
||||
|
||||
vitest.useFakeTimers();
|
||||
|
||||
@@ -26,7 +27,7 @@ const hasDupe = {
|
||||
duplicateId: 'duplicate-id',
|
||||
};
|
||||
|
||||
describe(SearchService.name, () => {
|
||||
describe(DuplicateService.name, () => {
|
||||
let sut: DuplicateService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
@@ -41,6 +42,8 @@ describe(SearchService.name, () => {
|
||||
describe('getDuplicates', () => {
|
||||
it('should get duplicates', async () => {
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['duplicate-id']));
|
||||
mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue();
|
||||
mocks.duplicateRepository.getAll.mockResolvedValue([
|
||||
{
|
||||
duplicateId: 'duplicate-id',
|
||||
@@ -51,9 +54,24 @@ describe(SearchService.name, () => {
|
||||
{
|
||||
duplicateId: 'duplicate-id',
|
||||
assets: [expect.objectContaining({ id: asset.id }), expect.objectContaining({ id: asset.id })],
|
||||
suggestedKeepAssetIds: [asset.id],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return suggestedKeepAssetIds based on file size', async () => {
|
||||
const smallAsset = AssetFactory.from().exif({ fileSizeInByte: 1000 }).build();
|
||||
const largeAsset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build();
|
||||
mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue();
|
||||
mocks.duplicateRepository.getAll.mockResolvedValue([
|
||||
{
|
||||
duplicateId: 'duplicate-id',
|
||||
assets: [getForDuplicate(smallAsset), getForDuplicate(largeAsset)],
|
||||
},
|
||||
]);
|
||||
const result = await sut.getDuplicates(authStub.admin);
|
||||
expect(result[0].suggestedKeepAssetIds).toEqual([largeAsset.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueSearchDuplicates', () => {
|
||||
@@ -131,6 +149,200 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('should handle mixed success and failure', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2']));
|
||||
mocks.duplicateRepository.get.mockResolvedValueOnce(void 0);
|
||||
mocks.duplicateRepository.get.mockResolvedValueOnce({
|
||||
duplicateId: 'group-2',
|
||||
assets: [asset as unknown as MapAsset],
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.resolve(authStub.admin, {
|
||||
groups: [
|
||||
{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [] },
|
||||
{ duplicateId: 'group-2', keepAssetIds: [asset.id], trashAssetIds: [] },
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ id: 'group-1', success: false, error: BulkIdErrorReason.NOT_FOUND },
|
||||
{ id: 'group-2', success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should catch and report errors', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.duplicateRepository.get.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
await expect(
|
||||
sut.resolve(authStub.admin, {
|
||||
groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [] }],
|
||||
}),
|
||||
).resolves.toEqual([{ id: 'group-1', success: false, error: BulkIdErrorReason.UNKNOWN }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGroup (via resolve)', () => {
|
||||
it('should fail if duplicate group not found', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['missing-id']));
|
||||
mocks.duplicateRepository.get.mockResolvedValue(void 0);
|
||||
|
||||
await expect(
|
||||
sut.resolve(authStub.admin, {
|
||||
groups: [{ duplicateId: 'missing-id', keepAssetIds: [], trashAssetIds: [] }],
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
id: 'missing-id',
|
||||
success: false,
|
||||
error: BulkIdErrorReason.NOT_FOUND,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip when keepAssetIds contains non-member', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.duplicateRepository.get.mockResolvedValue({
|
||||
duplicateId: 'group-1',
|
||||
assets: [asset as unknown as MapAsset],
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.resolve(authStub.admin, {
|
||||
groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-999', asset.id], trashAssetIds: [] }],
|
||||
}),
|
||||
).resolves.toEqual([{ id: 'group-1', success: true }]);
|
||||
});
|
||||
|
||||
it('should skip when trashAssetIds contains non-member', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.duplicateRepository.get.mockResolvedValue({
|
||||
duplicateId: 'group-1',
|
||||
assets: [asset as unknown as MapAsset],
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.resolve(authStub.admin, {
|
||||
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset.id], trashAssetIds: ['asset-999'] }],
|
||||
}),
|
||||
).resolves.toEqual([{ id: 'group-1', success: true }]);
|
||||
});
|
||||
|
||||
it('should fail if keepAssetIds and trashAssetIds overlap', async () => {
|
||||
const asset1 = AssetFactory.create();
|
||||
const asset2 = AssetFactory.create();
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.duplicateRepository.get.mockResolvedValue({
|
||||
duplicateId: 'group-1',
|
||||
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset],
|
||||
});
|
||||
|
||||
const result = await sut.resolve(authStub.admin, {
|
||||
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
|
||||
});
|
||||
|
||||
expect(result[0].success).toBe(false);
|
||||
expect(result[0].errorMessage).toContain('An asset cannot be in both keepAssetIds and trashAssetIds');
|
||||
});
|
||||
|
||||
it('should fail if keepAssetIds and trashAssetIds do not cover all assets', async () => {
|
||||
const asset1 = AssetFactory.create();
|
||||
const asset2 = AssetFactory.create();
|
||||
const asset3 = AssetFactory.create();
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.duplicateRepository.get.mockResolvedValue({
|
||||
duplicateId: 'group-1',
|
||||
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset, asset3 as unknown as MapAsset],
|
||||
});
|
||||
|
||||
const result = await sut.resolve(authStub.admin, {
|
||||
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(result[0].success).toBe(false);
|
||||
expect(result[0].errorMessage).toContain('Every asset must be in either keepAssetIds or trashAssetIds');
|
||||
});
|
||||
|
||||
it('should fail if partial trash without keepers', async () => {
|
||||
const asset1 = AssetFactory.create();
|
||||
const asset2 = AssetFactory.create();
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.duplicateRepository.get.mockResolvedValue({
|
||||
duplicateId: 'group-1',
|
||||
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset],
|
||||
});
|
||||
|
||||
const result = await sut.resolve(authStub.admin, {
|
||||
groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [asset1.id] }],
|
||||
});
|
||||
|
||||
expect(result[0].success).toBe(false);
|
||||
expect(result[0].errorMessage).toContain('Every asset must be in either keepAssetIds or trashAssetIds');
|
||||
});
|
||||
|
||||
it('should sync merged tags to asset_exif.tags', async () => {
|
||||
const asset1 = AssetFactory.create();
|
||||
const asset2 = AssetFactory.create();
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
|
||||
mocks.duplicateRepository.get.mockResolvedValue({
|
||||
duplicateId: 'group-1',
|
||||
assets: [
|
||||
{
|
||||
...asset1,
|
||||
tags: [
|
||||
{
|
||||
id: 'tag-1',
|
||||
value: 'Work',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
parentId: null,
|
||||
color: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...asset2,
|
||||
tags: [
|
||||
{
|
||||
id: 'tag-2',
|
||||
value: 'Travel',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
parentId: null,
|
||||
color: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any,
|
||||
});
|
||||
|
||||
const result = await sut.resolve(authStub.admin, {
|
||||
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
|
||||
});
|
||||
|
||||
expect(result[0].success).toBe(true);
|
||||
|
||||
// Verify tags were applied to tag_asset table
|
||||
expect(mocks.tag.replaceAssetTags).toHaveBeenCalledWith(asset1.id, ['tag-1', 'tag-2']);
|
||||
|
||||
// Verify merged tag values were written to asset_exif.tags so SidecarWrite preserves them
|
||||
expect(mocks.asset.updateAllExif).toHaveBeenCalledWith([asset1.id], { tags: ['Work', 'Travel'] });
|
||||
|
||||
// Verify SidecarWrite was queued (to write tags to sidecar)
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: asset1.id } }]);
|
||||
});
|
||||
|
||||
// NOTE: The following integration-style tests are covered by E2E tests instead
|
||||
// to avoid complex mock setup. The validation and error-handling logic above
|
||||
// is thoroughly unit tested.
|
||||
});
|
||||
|
||||
describe('handleSearchDuplicates', () => {
|
||||
beforeEach(() => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
|
||||
@@ -1,24 +1,84 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
|
||||
import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { DuplicateResolveDto, DuplicateResolveGroupDto, DuplicateResponseDto } from 'src/dtos/duplicate.dto';
|
||||
import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||
import { AssetDuplicateResult } from 'src/repositories/search.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { suggestDuplicateKeepAssetIds } from 'src/utils/duplicate';
|
||||
import { isDuplicateDetectionEnabled } from 'src/utils/misc';
|
||||
|
||||
type ResolveRequest = {
|
||||
assetUpdate: {
|
||||
isFavorite?: boolean;
|
||||
visibility?: AssetVisibility;
|
||||
};
|
||||
|
||||
exifUpdate: {
|
||||
rating?: number;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
mergedAlbumIds: string[];
|
||||
|
||||
mergedTagIds: string[];
|
||||
|
||||
mergedTagValues: string[];
|
||||
};
|
||||
|
||||
const uniqueNonEmptyLines = (values: Array<string | null | undefined>): string[] => {
|
||||
const unique = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
for (const value of values) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || unique.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
unique.add(trimmed);
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const getUniqueCoordinate = (assets: MapAsset[], key: 'latitude' | 'longitude'): number | null => {
|
||||
const values = assets
|
||||
.map((asset) => asset.exifInfo?.[key])
|
||||
.filter((value): value is number => Number.isFinite(value));
|
||||
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unique = new Set(values);
|
||||
return unique.size === 1 ? [...unique][0] : null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DuplicateService extends BaseService {
|
||||
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
|
||||
// Clean up singleton groups (assets that are the only member of their duplicate group)
|
||||
await this.duplicateRepository.cleanupSingletonGroups(auth.user.id);
|
||||
|
||||
const duplicates = await this.duplicateRepository.getAll(auth.user.id);
|
||||
return duplicates.map(({ duplicateId, assets }) => ({
|
||||
duplicateId,
|
||||
assets: assets.map((asset) => mapAsset(asset, { auth })),
|
||||
}));
|
||||
return duplicates.map(({ duplicateId, assets }) => {
|
||||
const mappedAssets = assets.map((asset) => mapAsset(asset, { auth }));
|
||||
return {
|
||||
duplicateId,
|
||||
assets: mappedAssets,
|
||||
suggestedKeepAssetIds: suggestDuplicateKeepAssetIds(mappedAssets),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
@@ -29,6 +89,213 @@ export class DuplicateService extends BaseService {
|
||||
await this.duplicateRepository.deleteAll(auth.user.id, dto.ids);
|
||||
}
|
||||
|
||||
async resolve(auth: AuthDto, dto: DuplicateResolveDto) {
|
||||
const duplicateIds = dto.groups.map(({ duplicateId }) => duplicateId);
|
||||
|
||||
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: duplicateIds });
|
||||
|
||||
const results: BulkIdResponseDto[] = [];
|
||||
|
||||
for (const group of dto.groups) {
|
||||
try {
|
||||
results.push(await this.resolveGroup(auth, group));
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Error resolving duplicate group ${group.duplicateId}: ${error}`, error?.stack);
|
||||
results.push({ id: group.duplicateId, success: false, error: BulkIdErrorReason.UNKNOWN });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async resolveGroup(auth: AuthDto, group: DuplicateResolveGroupDto): Promise<BulkIdResponseDto> {
|
||||
const { duplicateId, keepAssetIds, trashAssetIds } = group;
|
||||
|
||||
const duplicateGroup = await this.duplicateRepository.get(duplicateId);
|
||||
if (!duplicateGroup) {
|
||||
return { id: duplicateId, success: false, error: BulkIdErrorReason.NOT_FOUND };
|
||||
}
|
||||
|
||||
const groupAssetIds = new Set(duplicateGroup.assets.map((a) => a.id));
|
||||
|
||||
// ignore/skip asset IDs not in the group
|
||||
const idsToKeep = keepAssetIds.filter((id) => groupAssetIds.has(id));
|
||||
const idsToTrash = trashAssetIds.filter((id) => groupAssetIds.has(id));
|
||||
|
||||
for (const assetId of groupAssetIds) {
|
||||
if (idsToKeep.includes(assetId) && idsToTrash.includes(assetId)) {
|
||||
return {
|
||||
id: duplicateId,
|
||||
success: false,
|
||||
error: BulkIdErrorReason.VALIDATION,
|
||||
errorMessage: 'An asset cannot be in both keepAssetIds and trashAssetIds',
|
||||
};
|
||||
}
|
||||
|
||||
if (!idsToKeep.includes(assetId) && !idsToTrash.includes(assetId)) {
|
||||
return {
|
||||
id: duplicateId,
|
||||
success: false,
|
||||
error: BulkIdErrorReason.VALIDATION,
|
||||
errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToTrash.length > 0) {
|
||||
const ids = await this.checkAccess({ auth, permission: Permission.AssetDelete, ids: idsToTrash });
|
||||
if (ids.size !== idsToTrash.length) {
|
||||
return {
|
||||
id: duplicateId,
|
||||
success: false,
|
||||
error: BulkIdErrorReason.NO_PERMISSION,
|
||||
errorMessage: 'No permission to delete assets',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const assetAlbumMap = await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]);
|
||||
|
||||
const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds, mergedTagValues } = this.getSyncMergeResult(
|
||||
duplicateGroup.assets,
|
||||
assetAlbumMap,
|
||||
);
|
||||
|
||||
if (mergedAlbumIds.length > 0) {
|
||||
const allowedAlbumIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.AlbumAssetCreate,
|
||||
ids: mergedAlbumIds,
|
||||
});
|
||||
|
||||
const allowedShareIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.AssetShare,
|
||||
ids: idsToKeep,
|
||||
});
|
||||
|
||||
if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) {
|
||||
await this.albumRepository.addAssetIdsToAlbums(
|
||||
[...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mergedTagIds.length > 0) {
|
||||
const allowedTagIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.TagAsset,
|
||||
ids: mergedTagIds,
|
||||
});
|
||||
|
||||
if (allowedTagIds.size > 0) {
|
||||
// Replace tags for each keeper asset to ensure all merged tags are applied
|
||||
await Promise.all(idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds])));
|
||||
|
||||
// Update asset_exif.tags so the subsequent SidecarWrite + MetadataExtraction
|
||||
// cycle preserves the merged tags (updateAllExif locks the property automatically)
|
||||
await this.assetRepository.updateAllExif(idsToKeep, { tags: mergedTagValues });
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToKeep.length > 0) {
|
||||
const hasExifUpdate = Object.keys(exifUpdate).length > 0;
|
||||
const hasTagUpdate = mergedTagIds.length > 0;
|
||||
|
||||
if (hasExifUpdate) {
|
||||
await this.assetRepository.updateAllExif(idsToKeep, exifUpdate);
|
||||
}
|
||||
|
||||
if (hasExifUpdate || hasTagUpdate) {
|
||||
await this.jobRepository.queueAll(idsToKeep.map((id) => ({ name: JobName.SidecarWrite, data: { id } })));
|
||||
}
|
||||
|
||||
await this.assetRepository.updateAll(idsToKeep, { duplicateId: null, ...assetUpdate });
|
||||
}
|
||||
|
||||
if (idsToTrash.length > 0) {
|
||||
// TODO: this is duplicated with AssetService.deleteAssets
|
||||
const { trash } = await this.getConfig({ withCache: true });
|
||||
const force = !trash.enabled;
|
||||
|
||||
await this.assetRepository.updateAll(idsToTrash, {
|
||||
deletedAt: new Date(),
|
||||
status: force ? AssetStatus.Deleted : AssetStatus.Trashed,
|
||||
duplicateId: null,
|
||||
});
|
||||
|
||||
await this.eventRepository.emit(force ? 'AssetDeleteAll' : 'AssetTrashAll', {
|
||||
assetIds: idsToTrash,
|
||||
userId: auth.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { id: duplicateId, success: true };
|
||||
}
|
||||
|
||||
private getSyncMergeResult(assets: MapAsset[], assetAlbumMap: Map<string, string[]> = new Map()): ResolveRequest {
|
||||
const response: ResolveRequest = {
|
||||
mergedAlbumIds: [],
|
||||
mergedTagIds: [],
|
||||
mergedTagValues: [],
|
||||
assetUpdate: {},
|
||||
exifUpdate: {},
|
||||
};
|
||||
|
||||
response.assetUpdate.isFavorite = assets.some((asset) => asset.isFavorite);
|
||||
|
||||
const visibilityOrder = [AssetVisibility.Locked, AssetVisibility.Archive, AssetVisibility.Timeline];
|
||||
let visibility = visibilityOrder.find((level) => assets.some((asset) => asset.visibility === level));
|
||||
if (!visibility && assets.some((asset) => asset.visibility === AssetVisibility.Hidden)) {
|
||||
visibility = AssetVisibility.Hidden;
|
||||
}
|
||||
if (visibility) {
|
||||
response.assetUpdate.visibility = visibility;
|
||||
}
|
||||
|
||||
let rating = 0;
|
||||
for (const asset of assets) {
|
||||
const assetRating = asset.exifInfo?.rating ?? 0;
|
||||
if (assetRating > rating) {
|
||||
rating = assetRating;
|
||||
}
|
||||
}
|
||||
if (rating > 0) {
|
||||
response.exifUpdate.rating = rating;
|
||||
}
|
||||
|
||||
const descriptionLines = uniqueNonEmptyLines(assets.map((asset) => asset.exifInfo?.description));
|
||||
const description = descriptionLines.length > 0 ? descriptionLines.join('\n') : null;
|
||||
if (description !== null) {
|
||||
response.exifUpdate.description = description;
|
||||
}
|
||||
|
||||
const latitude = getUniqueCoordinate(assets, 'latitude');
|
||||
const longitude = getUniqueCoordinate(assets, 'longitude');
|
||||
if (latitude !== null && longitude !== null) {
|
||||
response.exifUpdate.latitude = latitude;
|
||||
response.exifUpdate.longitude = longitude;
|
||||
}
|
||||
|
||||
const albumIdSet = new Set<string>();
|
||||
for (const [, albumIds] of assetAlbumMap) {
|
||||
for (const albumId of albumIds) {
|
||||
albumIdSet.add(albumId);
|
||||
}
|
||||
}
|
||||
response.mergedAlbumIds = [...albumIdSet];
|
||||
|
||||
const allTags = assets.flatMap((asset) => asset.tags ?? []);
|
||||
const tagIds = [...new Set(allTags.map((tag) => tag.id).filter((id): id is string => !!id))];
|
||||
const tagValues = [...new Set(allTags.map((tag) => tag.value).filter((v): v is string => !!v))];
|
||||
if (tagIds.length > 0) {
|
||||
response.mergedTagIds = tagIds;
|
||||
response.mergedTagValues = tagValues;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetDetectDuplicatesQueueAll, queue: QueueName.DuplicateDetection })
|
||||
async handleQueueSearchDuplicates({ force }: JobOf<JobName.AssetDetectDuplicatesQueueAll>): Promise<JobStatus> {
|
||||
const { machineLearning } = await this.getConfig({ withCache: false });
|
||||
|
||||
@@ -17,7 +17,17 @@ import {
|
||||
ValidateLibraryImportPathResponseDto,
|
||||
ValidateLibraryResponseDto,
|
||||
} from 'src/dtos/library.dto';
|
||||
import { AssetStatus, AssetType, CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import {
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ChecksumAlgorithm,
|
||||
CronJob,
|
||||
DatabaseLock,
|
||||
ImmichWorker,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
} from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { AssetSyncResult } from 'src/repositories/library.repository';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
@@ -400,6 +410,7 @@ export class LibraryService extends BaseService {
|
||||
ownerId,
|
||||
libraryId,
|
||||
checksum: this.cryptoRepository.hashSha1(`path:${assetPath}`),
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1Path,
|
||||
originalPath: assetPath,
|
||||
|
||||
fileCreatedAt: stat.mtime,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AssetFileType,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
ChecksumAlgorithm,
|
||||
ExifOrientation,
|
||||
ImmichWorker,
|
||||
JobName,
|
||||
@@ -652,6 +653,7 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith({
|
||||
checksum: expect.any(Buffer),
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
@@ -705,6 +707,7 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith({
|
||||
checksum: expect.any(Buffer),
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
@@ -758,6 +761,7 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.storage.readFile).toHaveBeenCalledWith(asset.originalPath, expect.any(Object));
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith({
|
||||
checksum: expect.any(Buffer),
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
AssetFileType,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
ChecksumAlgorithm,
|
||||
DatabaseLock,
|
||||
ExifOrientation,
|
||||
ImmichWorker,
|
||||
@@ -675,6 +676,7 @@ export class MetadataService extends BaseService {
|
||||
fileModifiedAt: stats.mtime,
|
||||
localDateTime: dates.localDateTime,
|
||||
checksum,
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
ownerId: asset.ownerId,
|
||||
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
|
||||
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
|
||||
|
||||
@@ -78,6 +78,7 @@ export const SYNC_TYPES_ORDER = [
|
||||
SyncRequestType.AlbumAssetsV1,
|
||||
SyncRequestType.AlbumsV1,
|
||||
SyncRequestType.AlbumUsersV1,
|
||||
SyncRequestType.AlbumUserMetadataV1,
|
||||
SyncRequestType.AlbumToAssetsV1,
|
||||
SyncRequestType.AssetExifsV1,
|
||||
SyncRequestType.AlbumAssetExifsV1,
|
||||
@@ -183,6 +184,7 @@ export class SyncService extends BaseService {
|
||||
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumUserMetadataV1]: () => this.syncAlbumUserMetadataV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumAssetExifsV1]: () =>
|
||||
@@ -213,6 +215,7 @@ export class SyncService extends BaseService {
|
||||
|
||||
await this.syncRepository.album.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.albumUser.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.albumUserMetadata.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.albumToAsset.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.asset.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold);
|
||||
@@ -489,6 +492,20 @@ export class SyncService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncAlbumUserMetadataV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
||||
const deleteType = SyncEntityType.AlbumUserMetadataDeleteV1;
|
||||
const deletes = this.syncRepository.albumUserMetadata.getDeletes({ ...options, ack: checkpointMap[deleteType] });
|
||||
for await (const { id, ...data } of deletes) {
|
||||
send(response, { type: deleteType, ids: [id], data });
|
||||
}
|
||||
|
||||
const upsertType = SyncEntityType.AlbumUserMetadataV1;
|
||||
const upserts = this.syncRepository.albumUserMetadata.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
send(response, { type: upsertType, ids: [updateId], data });
|
||||
}
|
||||
}
|
||||
|
||||
private async syncAlbumAssetsV1(
|
||||
options: SyncQueryOptions,
|
||||
response: Writable,
|
||||
|
||||
@@ -241,6 +241,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
|
||||
}
|
||||
|
||||
case Permission.DuplicateRead:
|
||||
case Permission.DuplicateDelete: {
|
||||
return access.duplicate.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.AuthDeviceDelete: {
|
||||
return await access.authDevice.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AssetType, AssetVisibility } from 'src/enum';
|
||||
import { getExifCount, suggestDuplicate, suggestDuplicateKeepAssetIds } from 'src/utils/duplicate';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const createAsset = (
|
||||
id: string,
|
||||
fileSizeInByte: number | null = null,
|
||||
exifFields: Record<string, unknown> = {},
|
||||
): AssetResponseDto => ({
|
||||
id,
|
||||
type: AssetType.Image,
|
||||
thumbhash: null,
|
||||
localDateTime: new Date().toISOString(),
|
||||
duration: '0:00:00.00000',
|
||||
hasMetadata: true,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceAssetId: 'device-asset-1',
|
||||
deviceId: 'device-1',
|
||||
ownerId: 'owner-1',
|
||||
originalPath: '/path/to/asset',
|
||||
originalFileName: 'asset.jpg',
|
||||
fileCreatedAt: new Date().toISOString(),
|
||||
fileModifiedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isTrashed: false,
|
||||
isOffline: false,
|
||||
isEdited: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
checksum: 'checksum',
|
||||
exifInfo:
|
||||
fileSizeInByte !== null || Object.keys(exifFields).length > 0 ? { fileSizeInByte, ...exifFields } : undefined,
|
||||
});
|
||||
|
||||
describe('duplicate utils', () => {
|
||||
describe('getExifCount', () => {
|
||||
it('should return 0 for asset without exifInfo', () => {
|
||||
const asset = createAsset('asset-1');
|
||||
asset.exifInfo = undefined;
|
||||
expect(getExifCount(asset)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 for empty exifInfo', () => {
|
||||
const asset = createAsset('asset-1');
|
||||
asset.exifInfo = {};
|
||||
expect(getExifCount(asset)).toBe(0);
|
||||
});
|
||||
|
||||
it('should count all truthy values in exifInfo', () => {
|
||||
const asset = createAsset('asset-1', 1000, {
|
||||
make: 'Canon',
|
||||
model: 'EOS 5D',
|
||||
dateTimeOriginal: new Date(),
|
||||
timeZone: 'UTC',
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
city: 'New York',
|
||||
state: 'NY',
|
||||
country: 'USA',
|
||||
description: 'A photo',
|
||||
rating: 5,
|
||||
});
|
||||
// fileSizeInByte (1000) + 11 other truthy fields = 12
|
||||
expect(getExifCount(asset)).toBe(12);
|
||||
});
|
||||
|
||||
it('should not count null or undefined values', () => {
|
||||
const asset = createAsset('asset-1', 1000, {
|
||||
make: 'Canon',
|
||||
model: null,
|
||||
latitude: undefined,
|
||||
city: '',
|
||||
rating: 0,
|
||||
});
|
||||
// fileSizeInByte (1000) + make ('Canon') = 2 truthy values
|
||||
// model (null), latitude (undefined), city (''), rating (0) are all falsy
|
||||
expect(getExifCount(asset)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestDuplicate', () => {
|
||||
it('should return undefined for empty list', () => {
|
||||
expect(suggestDuplicate([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the single asset for list with one asset', () => {
|
||||
const asset = createAsset('asset-1', 1000);
|
||||
expect(suggestDuplicate([asset])).toEqual(asset);
|
||||
});
|
||||
|
||||
it('should return asset with largest file size', () => {
|
||||
const small = createAsset('small', 1000);
|
||||
const large = createAsset('large', 5000);
|
||||
const medium = createAsset('medium', 3000);
|
||||
|
||||
expect(suggestDuplicate([small, large, medium])?.id).toBe('large');
|
||||
expect(suggestDuplicate([large, small, medium])?.id).toBe('large');
|
||||
expect(suggestDuplicate([medium, small, large])?.id).toBe('large');
|
||||
});
|
||||
|
||||
it('should use EXIF count as tie-breaker when file sizes are equal', () => {
|
||||
const lessExif = createAsset('less-exif', 1000, { make: 'Canon' });
|
||||
const moreExif = createAsset('more-exif', 1000, {
|
||||
make: 'Canon',
|
||||
model: 'EOS 5D',
|
||||
dateTimeOriginal: new Date(),
|
||||
city: 'New York',
|
||||
});
|
||||
|
||||
expect(suggestDuplicate([lessExif, moreExif])?.id).toBe('more-exif');
|
||||
expect(suggestDuplicate([moreExif, lessExif])?.id).toBe('more-exif');
|
||||
});
|
||||
|
||||
it('should handle assets with no exifInfo (treat as 0 file size)', () => {
|
||||
const noExif = createAsset('no-exif');
|
||||
noExif.exifInfo = undefined;
|
||||
const withExif = createAsset('with-exif', 1000);
|
||||
|
||||
expect(suggestDuplicate([noExif, withExif])?.id).toBe('with-exif');
|
||||
});
|
||||
|
||||
it('should handle assets with exifInfo but no fileSizeInByte', () => {
|
||||
const noFileSize = createAsset('no-file-size');
|
||||
noFileSize.exifInfo = { make: 'Canon', model: 'EOS 5D' };
|
||||
const withFileSize = createAsset('with-file-size', 1000);
|
||||
|
||||
expect(suggestDuplicate([noFileSize, withFileSize])?.id).toBe('with-file-size');
|
||||
});
|
||||
|
||||
it('should return last asset when all have same file size and EXIF count', () => {
|
||||
const asset1 = createAsset('asset-1', 1000, { make: 'Canon' });
|
||||
const asset2 = createAsset('asset-2', 1000, { make: 'Nikon' });
|
||||
|
||||
// Both have same file size (1000) and same EXIF count (2: fileSizeInByte + make)
|
||||
// Should return the last one in the sorted array
|
||||
const result = suggestDuplicate([asset1, asset2]);
|
||||
// Since they're equal, the last one after sorting should be returned
|
||||
expect(result).toBeDefined();
|
||||
expect(['asset-1', 'asset-2']).toContain(result?.id);
|
||||
});
|
||||
|
||||
it('should prioritize file size over EXIF count', () => {
|
||||
const largeWithLessExif = createAsset('large-less-exif', 5000, { make: 'Canon' });
|
||||
const smallWithMoreExif = createAsset('small-more-exif', 1000, {
|
||||
make: 'Canon',
|
||||
model: 'EOS 5D',
|
||||
dateTimeOriginal: new Date(),
|
||||
city: 'New York',
|
||||
state: 'NY',
|
||||
country: 'USA',
|
||||
});
|
||||
|
||||
expect(suggestDuplicate([largeWithLessExif, smallWithMoreExif])?.id).toBe('large-less-exif');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestDuplicateKeepAssetIds', () => {
|
||||
it('should return empty array for empty list', () => {
|
||||
expect(suggestDuplicateKeepAssetIds([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return array with single asset ID', () => {
|
||||
const asset = createAsset('asset-1', 1000);
|
||||
expect(suggestDuplicateKeepAssetIds([asset])).toEqual(['asset-1']);
|
||||
});
|
||||
|
||||
it('should return array with best asset ID', () => {
|
||||
const small = createAsset('small', 1000);
|
||||
const large = createAsset('large', 5000);
|
||||
|
||||
expect(suggestDuplicateKeepAssetIds([small, large])).toEqual(['large']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
|
||||
/**
|
||||
* Counts all truthy values in the exifInfo object.
|
||||
* This matches the client implementation in web/src/lib/utils/exif-utils.ts
|
||||
*
|
||||
* @param asset Asset with optional exifInfo
|
||||
* @returns Count of truthy EXIF values
|
||||
*/
|
||||
export const getExifCount = (asset: AssetResponseDto): number => {
|
||||
return Object.values(asset.exifInfo ?? {}).filter(Boolean).length;
|
||||
};
|
||||
|
||||
/**
|
||||
* Suggests the best duplicate asset to keep from a list of duplicates.
|
||||
* This is a direct port of the client logic from web/src/lib/utils/duplicate-utils.ts
|
||||
*
|
||||
* The best asset is determined by the following criteria:
|
||||
* 1. Largest image file size in bytes
|
||||
* 2. Largest count of EXIF data (as tie-breaker)
|
||||
*
|
||||
* @param assets List of duplicate assets
|
||||
* @returns The best asset to keep, or undefined if empty list
|
||||
*/
|
||||
export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto | undefined => {
|
||||
if (assets.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Sort by file size ascending (smallest first)
|
||||
let duplicateAssets = [...assets].toSorted(
|
||||
(a, b) => (a.exifInfo?.fileSizeInByte ?? 0) - (b.exifInfo?.fileSizeInByte ?? 0),
|
||||
);
|
||||
|
||||
// Get the largest file size (last element after sorting)
|
||||
const largestFileSize = duplicateAssets.at(-1)?.exifInfo?.fileSizeInByte ?? 0;
|
||||
|
||||
// Filter to keep only assets with the largest file size
|
||||
duplicateAssets = duplicateAssets.filter((asset) => (asset.exifInfo?.fileSizeInByte ?? 0) === largestFileSize);
|
||||
|
||||
// If there are multiple assets with the same file size, sort by EXIF count
|
||||
if (duplicateAssets.length >= 2) {
|
||||
duplicateAssets = duplicateAssets.toSorted((a, b) => getExifCount(a) - getExifCount(b));
|
||||
}
|
||||
|
||||
// Return the last asset (highest EXIF count among highest file size)
|
||||
return duplicateAssets.at(-1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Suggests the best duplicate asset IDs to keep from a list of duplicates.
|
||||
* Returns an array with a single asset ID (the best candidate), or empty if no assets.
|
||||
*
|
||||
* @param assets List of duplicate assets
|
||||
* @returns Array of suggested asset IDs to keep (0 or 1 element)
|
||||
*/
|
||||
export const suggestDuplicateKeepAssetIds = (assets: AssetResponseDto[]): string[] => {
|
||||
const suggested = suggestDuplicate(assets);
|
||||
return suggested ? [suggested.id] : [];
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
|
||||
@@ -53,6 +53,7 @@ export class AssetFactory {
|
||||
updateId: newUuidV7(),
|
||||
status: AssetStatus.Active,
|
||||
checksum: newSha1(),
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
deviceAssetId: '',
|
||||
deviceId: '',
|
||||
duplicateId: null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
@@ -75,6 +76,7 @@ export const getDehydrated = <T extends Record<string, unknown>>(entity: T) => {
|
||||
|
||||
export const getForAlbum = (album: ReturnType<AlbumFactory['build']>) => ({
|
||||
...album,
|
||||
isFavorite: false,
|
||||
assets: album.assets.map((asset) =>
|
||||
getDehydrated({ ...getForAsset(asset), exifInfo: getDehydrated(asset.exifInfo) }),
|
||||
),
|
||||
@@ -125,6 +127,7 @@ export const getForMemory = (memory: ReturnType<MemoryFactory['build']>) => ({
|
||||
export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']>) => ({
|
||||
id: asset.id,
|
||||
checksum: asset.checksum,
|
||||
checksumAlgorithm: asset.checksumAlgorithm,
|
||||
deviceAssetId: asset.deviceAssetId,
|
||||
deviceId: asset.deviceId,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
@@ -204,10 +207,11 @@ export const getForStack = (stack: ReturnType<StackFactory['build']>) => ({
|
||||
})),
|
||||
});
|
||||
|
||||
export const getForDuplicate = (asset: ReturnType<AssetFactory['build']>) => ({
|
||||
...getDehydrated(asset),
|
||||
exifInfo: getDehydrated(asset.exifInfo),
|
||||
});
|
||||
export const getForDuplicate = (asset: ReturnType<AssetFactory['build']>) =>
|
||||
({
|
||||
...getDehydrated(asset),
|
||||
exifInfo: getDehydrated(asset.exifInfo),
|
||||
}) as unknown as MapAsset;
|
||||
|
||||
export const getForSharedLink = (sharedLink: ReturnType<SharedLinkFactory['build']>) => ({
|
||||
...sharedLink,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
AlbumUserRole,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
ChecksumAlgorithm,
|
||||
MemoryType,
|
||||
SourceType,
|
||||
SyncEntityType,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
@@ -400,6 +402,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||
switch (key) {
|
||||
case AccessRepository:
|
||||
case AlbumRepository:
|
||||
case AlbumUserMetadataRepository:
|
||||
case AlbumUserRepository:
|
||||
case ActivityRepository:
|
||||
case AssetRepository:
|
||||
@@ -464,6 +467,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
|
||||
switch (key) {
|
||||
case ActivityRepository:
|
||||
case AlbumRepository:
|
||||
case AlbumUserMetadataRepository:
|
||||
case AssetRepository:
|
||||
case AssetJobRepository:
|
||||
case ConfigRepository:
|
||||
@@ -547,6 +551,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
|
||||
deviceId: '',
|
||||
originalFileName: '',
|
||||
checksum: randomBytes(32),
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
type: AssetType.Image,
|
||||
originalPath: '/path/to/something.jpg',
|
||||
ownerId: 'not-a-valid-uuid',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
sut: ctx.get(AlbumUserMetadataRepository),
|
||||
albumUserRepo: ctx.get(AlbumUserRepository),
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(AlbumUserMetadataRepository.name, () => {
|
||||
it('should create an owner metadata row when an album is created', async () => {
|
||||
const { ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('album_user_metadata')
|
||||
.select(['albumId', 'userId', 'isFavorite'])
|
||||
.where('albumId', '=', album.id)
|
||||
.where('userId', '=', user.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({
|
||||
albumId: album.id,
|
||||
userId: user.id,
|
||||
isFavorite: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a shared-user metadata row when an album user is added', async () => {
|
||||
const { ctx, albumUserRepo } = setup();
|
||||
const { user: owner } = await ctx.newUser();
|
||||
const { user: sharedUser } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: owner.id });
|
||||
|
||||
await albumUserRepo.create({ albumId: album.id, userId: sharedUser.id });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('album_user_metadata')
|
||||
.select(['albumId', 'userId', 'isFavorite'])
|
||||
.where('albumId', '=', album.id)
|
||||
.where('userId', '=', sharedUser.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({
|
||||
albumId: album.id,
|
||||
userId: sharedUser.id,
|
||||
isFavorite: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete metadata and write an audit row when album access is removed', async () => {
|
||||
const { ctx, albumUserRepo, sut } = setup();
|
||||
const { user: owner } = await ctx.newUser();
|
||||
const { user: sharedUser } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: owner.id });
|
||||
|
||||
await albumUserRepo.create({ albumId: album.id, userId: sharedUser.id });
|
||||
await sut.upsert({ albumId: album.id, userId: sharedUser.id, isFavorite: true });
|
||||
|
||||
await albumUserRepo.delete({ albumId: album.id, userId: sharedUser.id });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('album_user_metadata')
|
||||
.select('albumId')
|
||||
.where('albumId', '=', album.id)
|
||||
.where('userId', '=', sharedUser.id)
|
||||
.executeTakeFirst(),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('album_user_metadata_audit')
|
||||
.select(['albumId', 'userId'])
|
||||
.where('albumId', '=', album.id)
|
||||
.where('userId', '=', sharedUser.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({
|
||||
albumId: album.id,
|
||||
userId: sharedUser.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { SyncEntityType, SyncRequestType } from 'src/enum';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { SyncTestContext } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = async (db?: Kysely<DB>) => {
|
||||
const ctx = new SyncTestContext(db || defaultDatabase);
|
||||
const { auth, user, session } = await ctx.newSyncAuthUser();
|
||||
return { auth, user, session, ctx };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(SyncEntityType.AlbumUserMetadataV1, () => {
|
||||
it('should sync owner album metadata rows', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
albumId: album.id,
|
||||
userId: auth.user.id,
|
||||
isFavorite: false,
|
||||
},
|
||||
type: SyncEntityType.AlbumUserMetadataV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sync favorite updates', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const repo = ctx.get(AlbumUserMetadataRepository);
|
||||
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
|
||||
|
||||
const initial = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
await ctx.syncAckAll(auth, initial);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
|
||||
await repo.upsert({ albumId: album.id, userId: auth.user.id, isFavorite: true });
|
||||
|
||||
const updated = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
expect(updated).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
albumId: album.id,
|
||||
userId: auth.user.id,
|
||||
isFavorite: true,
|
||||
},
|
||||
type: SyncEntityType.AlbumUserMetadataV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SyncEntityType.AlbumUserMetadataDeleteV1, () => {
|
||||
it('should sync metadata deletes when shared album access is removed', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const albumUserRepo = ctx.get(AlbumUserRepository);
|
||||
const { user: owner } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: owner.id });
|
||||
await albumUserRepo.create({ albumId: album.id, userId: auth.user.id });
|
||||
|
||||
const initial = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
await ctx.syncAckAll(auth, initial);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
|
||||
await albumUserRepo.delete({ albumId: album.id, userId: auth.user.id });
|
||||
|
||||
const deleted = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
expect(deleted).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
albumId: album.id,
|
||||
userId: auth.user.id,
|
||||
},
|
||||
type: SyncEntityType.AlbumUserMetadataDeleteV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
|
||||
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
duplicate: {
|
||||
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
memory: {
|
||||
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AuthGuard } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||
@@ -211,6 +212,7 @@ export type ServiceOverrides = {
|
||||
access: AccessRepository;
|
||||
activity: ActivityRepository;
|
||||
album: AlbumRepository;
|
||||
albumUserMetadata: AlbumUserMetadataRepository;
|
||||
albumUser: AlbumUserRepository;
|
||||
apiKey: ApiKeyRepository;
|
||||
app: AppRepository;
|
||||
@@ -296,6 +298,7 @@ export const getMocks = () => {
|
||||
activity: automock(ActivityRepository),
|
||||
audit: automock(AuditRepository),
|
||||
album: automock(AlbumRepository, { strict: false }),
|
||||
albumUserMetadata: automock(AlbumUserMetadataRepository),
|
||||
albumUser: automock(AlbumUserRepository),
|
||||
asset: newAssetRepositoryMock(),
|
||||
assetEdit: automock(AssetEditRepository),
|
||||
@@ -362,6 +365,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.access || (mocks.access as IAccessRepository as AccessRepository),
|
||||
overrides.activity || (mocks.activity as As<ActivityRepository>),
|
||||
overrides.album || (mocks.album as As<AlbumRepository>),
|
||||
overrides.albumUserMetadata || (mocks.albumUserMetadata as As<AlbumUserMetadataRepository>),
|
||||
overrides.albumUser || (mocks.albumUser as As<AlbumUserRepository>),
|
||||
overrides.apiKey || (mocks.apiKey as As<ApiKeyRepository>),
|
||||
overrides.app || (mocks.app as As<AppRepository>),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { Link } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
};
|
||||
|
||||
const { href }: Props = $props();
|
||||
</script>
|
||||
|
||||
<FormatMessage key="link_to_docs">
|
||||
{#snippet children({ message })}
|
||||
<Link {href}>{message}</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
@@ -5,19 +5,18 @@
|
||||
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { handleDownloadAlbum } from '$lib/services/album.service';
|
||||
import { getGlobalActions } from '$lib/services/app.service';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { ActionButton, IconButton, Logo } from '@immich/ui';
|
||||
import { mdiDownload, mdiFileImagePlusOutline, mdiPresentationPlay } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -27,10 +26,9 @@
|
||||
|
||||
interface Props {
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
user?: UserResponseDto | undefined;
|
||||
}
|
||||
|
||||
let { sharedLink, user = undefined }: Props = $props();
|
||||
let { sharedLink }: Props = $props();
|
||||
|
||||
const album = sharedLink.album as AlbumResponseDto;
|
||||
|
||||
@@ -39,8 +37,6 @@
|
||||
const options = $derived({ albumId: album.id, order: album.order });
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
|
||||
const assetInteraction = new AssetInteraction();
|
||||
|
||||
dragAndDropFilesStore.subscribe((value) => {
|
||||
if (value.isDragging && value.files.length > 0) {
|
||||
handlePromiseError(fileUploadHandler({ files: value.files, albumId: album.id }));
|
||||
@@ -67,15 +63,15 @@
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Escape' },
|
||||
onShortcut: () => {
|
||||
if (!assetViewerManager.isViewing && assetInteraction.selectionActive) {
|
||||
cancelMultiselect(assetInteraction);
|
||||
if (!assetViewerManager.isViewing && assetMultiSelectManager.selectionActive) {
|
||||
assetMultiSelectManager.clear();
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
|
||||
<Timeline enableRouting={true} {album} bind:timelineManager {options} {assetInteraction}>
|
||||
<Timeline enableRouting={true} {album} bind:timelineManager {options} assetInteraction={assetMultiSelectManager}>
|
||||
<section class="pt-8 md:pt-24 px-2 md:px-0">
|
||||
<!-- ALBUM TITLE -->
|
||||
<h1 class="text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all">
|
||||
@@ -99,13 +95,9 @@
|
||||
</main>
|
||||
|
||||
<header>
|
||||
{#if assetInteraction.selectionActive}
|
||||
<AssetSelectControlBar
|
||||
ownerId={user?.id}
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||
>
|
||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||
{#if assetMultiSelectManager.selectionActive}
|
||||
<AssetSelectControlBar>
|
||||
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
|
||||
{#if sharedLink.allowDownload}
|
||||
<DownloadAction filename="{album.albumName}.zip" />
|
||||
{/if}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { ActivityResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiCommentOutline, mdiThumbUp, mdiThumbUpOutline } from '@mdi/js';
|
||||
|
||||
interface Props {
|
||||
@@ -16,21 +16,32 @@
|
||||
let { isLiked, numberOfComments, numberOfLikes, disabled, onFavorite }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="w-full flex p-4 items-center justify-center rounded-full gap-5 bg-subtle border bg-opacity-60">
|
||||
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<Icon icon={isLiked ? mdiThumbUp : mdiThumbUpOutline} size="24" class={isLiked ? 'text-primary' : 'text-fg'} />
|
||||
{#if numberOfLikes}
|
||||
<div class="text-l">{numberOfLikes.toLocaleString($locale)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" onclick={() => assetViewerManager.toggleActivityPanel()}>
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<Icon icon={mdiCommentOutline} class="scale-x-[-1]" size="24" />
|
||||
{#if numberOfComments}
|
||||
<div class="text-l">{numberOfComments.toLocaleString($locale)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<div class="flex p-1 items-center justify-center rounded-full gap-1 bg-subtle/70 border">
|
||||
<Button
|
||||
{disabled}
|
||||
onclick={onFavorite}
|
||||
leadingIcon={isLiked ? mdiThumbUp : mdiThumbUpOutline}
|
||||
shape="round"
|
||||
size="large"
|
||||
variant="ghost"
|
||||
color={isLiked ? 'primary' : 'secondary'}
|
||||
class="p-3 text-base"
|
||||
>
|
||||
{#if numberOfLikes}
|
||||
{numberOfLikes.toLocaleString($locale)}
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => assetViewerManager.toggleActivityPanel()}
|
||||
leadingIcon={mdiCommentOutline}
|
||||
shape="round"
|
||||
size="large"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
class="p-3 text-base"
|
||||
>
|
||||
{#if numberOfComments}
|
||||
{numberOfComments.toLocaleString($locale)}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { getGlobalActions } from '$lib/services/app.service';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
@@ -93,7 +92,7 @@
|
||||
title: $t('go_back'),
|
||||
type: $t('assets'),
|
||||
icon: languageManager.rtl ? mdiArrowRight : mdiArrowLeft,
|
||||
$if: () => !!onClose && !isFaceEditMode.value,
|
||||
$if: () => !!onClose && !assetViewerManager.isFaceEditMode,
|
||||
onAction: () => onClose?.(),
|
||||
shortcuts: [{ key: 'Escape' }],
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
@@ -498,7 +497,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && previousAsset}
|
||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
@@ -571,7 +570,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && nextAsset}
|
||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
@@ -208,7 +207,7 @@
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => (isFaceEditMode.value = !isFaceEditMode.value)}
|
||||
onclick={() => assetViewerManager.toggleFaceEditMode()}
|
||||
/>
|
||||
|
||||
{#if people.length > 0 || unassignedFaces.length > 0}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { getAssetEdits, type AssetResponseDto } from '@immich/sdk';
|
||||
@@ -47,7 +47,12 @@
|
||||
let { asset = $bindable(), onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: onClose },
|
||||
{ shortcut: { key: 'Enter' }, onShortcut: applyEdits },
|
||||
]}
|
||||
/>
|
||||
|
||||
<section class="relative flex flex-col h-full p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg dark pt-3">
|
||||
<HStack class="justify-between me-4">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user