mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge branch 'main' into openvino-cpu-fix
This commit is contained in:
commit
c49899e1b4
11 changed files with 179 additions and 25 deletions
|
|
@ -134,7 +134,7 @@ services:
|
|||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:8-bookworm@sha256:fea8b3e67b15729d4bb70589eb03367bab9ad1ee89c876f54327fc7c6e618571
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ services:
|
|||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:8-bookworm@sha256:fea8b3e67b15729d4bb70589eb03367bab9ad1ee89c876f54327fc7c6e618571
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ services:
|
|||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:8-bookworm@sha256:fea8b3e67b15729d4bb70589eb03367bab9ad1ee89c876f54327fc7c6e618571
|
||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
|||
#### OpenVINO
|
||||
|
||||
- Integrated GPUs are more likely to experience issues than discrete GPUs, especially for older processors or servers with low RAM.
|
||||
- Ensure the server's kernel version is new enough to use the device for hardware accceleration.
|
||||
- Ensure the server's kernel version is new enough to use the device for hardware acceleration.
|
||||
- Expect higher RAM usage when using OpenVINO compared to CPU processing.
|
||||
|
||||
#### RKNN
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ You can read this guide to learn more about [partner sharing](/features/partner-
|
|||
|
||||
## Public sharing
|
||||
|
||||
You can create a public link to share a group of photos or videos, or an album, with anyone. The public link can be shared via email, social media, or any other method. There are a varierity of options to customize the public link, such as setting an expiration date, password protection, and more. Public shared link is handy when you want to share a group of photos or videos with someone who doesn't have an Immich account and allow the shared user to upload their photos or videos to your account.
|
||||
You can create a public link to share a group of photos or videos, or an album, with anyone. The public link can be shared via email, social media, or any other method. There are a variety of options to customize the public link, such as setting an expiration date, password protection, and more. Public shared link is handy when you want to share a group of photos or videos with someone who doesn't have an Immich account and allow the shared user to upload their photos or videos to your account.
|
||||
|
||||
The public shared link is generated with a random URL, which acts as as a secret to avoid the link being guessed by unwanted parties, for instance.
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ In the Immich web UI:
|
|||
<img src={require('./img/create-external-library.webp').default} width="50%" title="Create Library button" />
|
||||
|
||||
- In the dialog, select which user should own the new library
|
||||
<img src={require('./img/library-owner.webp').default} width="50%" title="Library owner diaglog" />
|
||||
<img src={require('./img/library-owner.webp').default} width="50%" title="Library owner dialog" />
|
||||
|
||||
- Click the three-dots menu and select **Edit Import Paths**
|
||||
<img src={require('./img/edit-import-paths.webp').default} width="50%" title="Edit Import Paths menu option" />
|
||||
|
|
|
|||
|
|
@ -163,22 +163,22 @@ export class TagRepository {
|
|||
}
|
||||
|
||||
async deleteEmptyTags() {
|
||||
// TODO rewrite as a single statement
|
||||
await this.db.transaction().execute(async (tx) => {
|
||||
const result = await tx
|
||||
.selectFrom('asset')
|
||||
.innerJoin('tag_asset', 'tag_asset.assetsId', 'asset.id')
|
||||
.innerJoin('tag_closure', 'tag_closure.id_descendant', 'tag_asset.tagsId')
|
||||
.innerJoin('tag', 'tag.id', 'tag_closure.id_descendant')
|
||||
.select((eb) => ['tag.id', eb.fn.count<number>('asset.id').as('count')])
|
||||
.groupBy('tag.id')
|
||||
.execute();
|
||||
const result = await this.db
|
||||
.deleteFrom('tag')
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('tag_closure')
|
||||
.whereRef('tag.id', '=', 'tag_closure.id_ancestor')
|
||||
.innerJoin('tag_asset', 'tag_closure.id_descendant', 'tag_asset.tagsId'),
|
||||
),
|
||||
),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
const ids = result.filter(({ count }) => count === 0).map(({ id }) => id);
|
||||
if (ids.length > 0) {
|
||||
await this.db.deleteFrom('tag').where('id', 'in', ids).execute();
|
||||
this.logger.log(`Deleted ${ids.length} empty tags`);
|
||||
}
|
||||
});
|
||||
const deletedRows = Number(result.numDeletedRows);
|
||||
if (deletedRows > 0) {
|
||||
this.logger.log(`Deleted ${deletedRows} empty tags`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { StorageRepository } from 'src/repositories/storage.repository';
|
|||
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
|
||||
import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
|
|
@ -52,6 +53,8 @@ import { MemoryTable } from 'src/schema/tables/memory.table';
|
|||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
|
||||
import { TagTable } from 'src/schema/tables/tag.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
|
|
@ -240,6 +243,18 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
|||
user,
|
||||
};
|
||||
}
|
||||
|
||||
async newTagAsset(tagBulkAssets: { tagIds: string[]; assetIds: string[] }) {
|
||||
const tagsAssets: Insertable<TagAssetTable>[] = [];
|
||||
for (const tagsId of tagBulkAssets.tagIds) {
|
||||
for (const assetsId of tagBulkAssets.assetIds) {
|
||||
tagsAssets.push({ tagsId, assetsId });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.get(TagRepository).upsertAssetIds(tagsAssets);
|
||||
return { tagsAssets, result };
|
||||
}
|
||||
}
|
||||
|
||||
export class SyncTestContext extends MediumTestContext<SyncService> {
|
||||
|
|
@ -318,6 +333,10 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
|||
return new key(LoggingRepository.create());
|
||||
}
|
||||
|
||||
case TagRepository: {
|
||||
return new key(db, LoggingRepository.create());
|
||||
}
|
||||
|
||||
case LoggingRepository as unknown as ClassConstructor<LoggingRepository>: {
|
||||
return new key() as unknown as T;
|
||||
}
|
||||
|
|
@ -345,7 +364,8 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
|
|||
case SyncCheckpointRepository:
|
||||
case SystemMetadataRepository:
|
||||
case UserRepository:
|
||||
case VersionHistoryRepository: {
|
||||
case VersionHistoryRepository:
|
||||
case TagRepository: {
|
||||
return automock(key);
|
||||
}
|
||||
|
||||
|
|
@ -567,6 +587,23 @@ const memoryInsert = (memory: Partial<Insertable<MemoryTable>> = {}) => {
|
|||
return { ...defaults, ...memory, id };
|
||||
};
|
||||
|
||||
const tagInsert = (tag: Partial<Insertable<TagTable>>) => {
|
||||
const id = tag.id || newUuid();
|
||||
|
||||
const defaults: Insertable<TagTable> = {
|
||||
id,
|
||||
userId: '',
|
||||
value: '',
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
color: '',
|
||||
parentId: null,
|
||||
updateId: newUuid(),
|
||||
};
|
||||
|
||||
return { ...defaults, ...tag, id };
|
||||
};
|
||||
|
||||
class CustomWritable extends Writable {
|
||||
private data = '';
|
||||
|
||||
|
|
@ -619,4 +656,5 @@ export const mediumFactory = {
|
|||
memoryInsert,
|
||||
loginDetails,
|
||||
loginResponse,
|
||||
tagInsert,
|
||||
};
|
||||
|
|
|
|||
116
server/test/medium/specs/services/tag.service.spec.ts
Normal file
116
server/test/medium/specs/services/tag.service.spec.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Kysely } from 'kysely';
|
||||
import { JobStatus } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { TagService } from 'src/services/tag.service';
|
||||
import { upsertTags } from 'src/utils/tag';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(TagService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [TagRepository, AccessRepository],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(TagService.name, () => {
|
||||
describe('deleteEmptyTags', () => {
|
||||
it('single tag exists, not connected to any assets, and is deleted', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const tagRepo = ctx.get(TagRepository);
|
||||
const [tag] = await upsertTags(tagRepo, { userId: user.id, tags: ['tag-1'] });
|
||||
|
||||
await expect(tagRepo.getByValue(user.id, 'tag-1')).resolves.toEqual(expect.objectContaining({ id: tag.id }));
|
||||
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success);
|
||||
await expect(tagRepo.getByValue(user.id, 'tag-1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('single tag exists, connected to one asset, and is not deleted', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
const tagRepo = ctx.get(TagRepository);
|
||||
const [tag] = await upsertTags(tagRepo, { userId: user.id, tags: ['tag-1'] });
|
||||
|
||||
await ctx.newTagAsset({ tagIds: [tag.id], assetIds: [asset.id] });
|
||||
|
||||
await expect(tagRepo.getByValue(user.id, 'tag-1')).resolves.toEqual(expect.objectContaining({ id: tag.id }));
|
||||
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success);
|
||||
await expect(tagRepo.getByValue(user.id, 'tag-1')).resolves.toEqual(expect.objectContaining({ id: tag.id }));
|
||||
});
|
||||
|
||||
it('hierarchical tag exists, and the parent is connected to an asset, and the child is deleted', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
const tagRepo = ctx.get(TagRepository);
|
||||
const [parentTag, childTag] = await upsertTags(tagRepo, { userId: user.id, tags: ['parent', 'parent/child'] });
|
||||
|
||||
await ctx.newTagAsset({ tagIds: [parentTag.id], assetIds: [asset.id] });
|
||||
|
||||
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toEqual(
|
||||
expect.objectContaining({ id: parentTag.id }),
|
||||
);
|
||||
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toEqual(
|
||||
expect.objectContaining({ id: childTag.id }),
|
||||
);
|
||||
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success);
|
||||
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toEqual(
|
||||
expect.objectContaining({ id: parentTag.id }),
|
||||
);
|
||||
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('hierarchical tag exists, and only the child is connected to an asset, and nothing is deleted', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
const tagRepo = ctx.get(TagRepository);
|
||||
const [parentTag, childTag] = await upsertTags(tagRepo, { userId: user.id, tags: ['parent', 'parent/child'] });
|
||||
|
||||
await ctx.newTagAsset({ tagIds: [childTag.id], assetIds: [asset.id] });
|
||||
|
||||
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toEqual(
|
||||
expect.objectContaining({ id: parentTag.id }),
|
||||
);
|
||||
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toEqual(
|
||||
expect.objectContaining({ id: childTag.id }),
|
||||
);
|
||||
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success);
|
||||
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toEqual(
|
||||
expect.objectContaining({ id: parentTag.id }),
|
||||
);
|
||||
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toEqual(
|
||||
expect.objectContaining({ id: childTag.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it('hierarchical tag exists, and neither parent nor child is connected to an asset, and both are deleted', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const tagRepo = ctx.get(TagRepository);
|
||||
const [parentTag, childTag] = await upsertTags(tagRepo, { userId: user.id, tags: ['parent', 'parent/child'] });
|
||||
|
||||
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toEqual(
|
||||
expect.objectContaining({ id: parentTag.id }),
|
||||
);
|
||||
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toEqual(
|
||||
expect.objectContaining({ id: childTag.id }),
|
||||
);
|
||||
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success);
|
||||
await expect(tagRepo.getByValue(user.id, 'parent/child')).resolves.toBeUndefined();
|
||||
await expect(tagRepo.getByValue(user.id, 'parent')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue