mirror of
https://github.com/immich-app/immich.git
synced 2026-06-17 04:12:16 -07:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c4cc56dd0 | |||
| f382624e68 | |||
| 24dad15636 | |||
| 7ab533b57b | |||
| d10153bbc7 | |||
| b846afeb08 | |||
| e222b19576 | |||
| 1fee99cd2a | |||
| 70bb7e4b7e | |||
| f973927c68 |
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
if: always()
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
github.rest.issues.removeLabel({
|
||||
issue_number: context.payload.pull_request.number,
|
||||
|
||||
@@ -90,6 +90,8 @@ jobs:
|
||||
mobile/**/*.g.dart
|
||||
mobile/**/*.gr.dart
|
||||
mobile/**/*.drift.dart
|
||||
mobile/**/*.g.swift
|
||||
mobile/**/*.g.kt
|
||||
|
||||
- name: Verify files have not changed
|
||||
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.4.3-ubuntu@sha256:ca3f764fdc48cebdf22dd206f33ecb0795a9a7210eacd1b5c02204aebd78b223
|
||||
image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generat
|
||||
OpenAPI is used to generate the client (Typescript, Dart) SDK. `openapi-generator-cli` can be installed [here](https://openapi-generator.tech/docs/installation/). The generated SDK is based on the `immich-openapi-specs.json` file, which is autogenerated by the server **when running in development mode**. The `immich-openapi-specs.json` file can be modified with `@nestjs/swagger` decorators used or referenced by controller endpoints. See the [NestJS OpenAPI docs](https://docs.nestjs.com/openapi/types-and-parameters) for more info. When you add a new endpoint or modify an existing one, you must run the server in development mode and run the command below to update the client SDK.
|
||||
|
||||
```bash
|
||||
make open-api
|
||||
mise open-api
|
||||
```
|
||||
|
||||
You can find the generated client SDK in the `packages/sdk/client` for Typescript SDK and `mobile/openapi` for Dart SDK.
|
||||
|
||||
@@ -218,7 +218,7 @@ When the Dev Container starts, it automatically:
|
||||
- Debug ports: 9230 (workers), 9231 (API)
|
||||
|
||||
:::info
|
||||
The Dev Container setup replaces the `make dev` command from the traditional setup. All services start automatically when you open the container.
|
||||
The Dev Container setup replaces the `mise dev` command from the traditional setup. All services start automatically when you open the container.
|
||||
:::
|
||||
|
||||
### Accessing Services
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A minimal devcontainer is supplied with this repository. All commands can be executed directly inside this container to avoid tedious installation of the environment.
|
||||
:::warning
|
||||
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`make dev`, ....). Feel free to contribute!
|
||||
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`mise dev`, ....). Feel free to contribute!
|
||||
:::
|
||||
When contributing code through a pull request, please check the following:
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ All the services are packaged to run as with single Docker Compose command.
|
||||
5. From the root directory, run:
|
||||
|
||||
```bash title="Start development server"
|
||||
make dev # required Makefile installed on the system.
|
||||
mise dev
|
||||
```
|
||||
|
||||
5. Access the dev instance in your browser at http://localhost:3000, or connect via the mobile app.
|
||||
@@ -88,7 +88,7 @@ To see local changes to `@immich/ui` in Immich, do the following:
|
||||
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yml` file (`../../ui:/usr/src/ui`)
|
||||
4. Uncomment the corresponding alias in the `web/vite.config.ts` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui/packages/ui')`)
|
||||
5. Uncomment the import statement in `web/src/app.css` file `@import '../../../ui/packages/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
|
||||
6. Start up the stack via `make dev`
|
||||
6. Start up the stack via `mise dev`
|
||||
7. After making changes in `@immich/ui`, rebuild it (`pnpm run build`)
|
||||
|
||||
### Mobile app
|
||||
|
||||
@@ -12,7 +12,7 @@ You need to run `mise //server:install` before _once_.
|
||||
The e2e tests can be run by first starting up a test production environment via:
|
||||
|
||||
```bash
|
||||
make e2e
|
||||
mise e2e
|
||||
```
|
||||
|
||||
Before you can run the tests, you need to run the following commands _once_:
|
||||
|
||||
-18
@@ -11,24 +11,6 @@ import Foundation
|
||||
#error("Unsupported platform.")
|
||||
#endif
|
||||
|
||||
/// Error class for passing custom error details to Dart side.
|
||||
final class PigeonError: Error {
|
||||
let code: String
|
||||
let message: String?
|
||||
let details: Sendable?
|
||||
|
||||
init(code: String, message: String?, details: Sendable?) {
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details
|
||||
}
|
||||
|
||||
var localizedDescription: String {
|
||||
return
|
||||
"PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
|
||||
}
|
||||
}
|
||||
|
||||
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||
return [result]
|
||||
}
|
||||
|
||||
@@ -221,6 +221,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
return;
|
||||
}
|
||||
|
||||
if (_scrubberHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
|
||||
Generated
+468
-427
File diff suppressed because it is too large
Load Diff
@@ -392,6 +392,27 @@ export const columns = {
|
||||
'asset.height',
|
||||
'asset.isEdited',
|
||||
],
|
||||
syncAlbumAsset: [
|
||||
'asset.id',
|
||||
'asset.ownerId',
|
||||
'asset.originalFileName',
|
||||
'asset.thumbhash',
|
||||
'asset.checksum',
|
||||
'asset.fileCreatedAt',
|
||||
'asset.fileModifiedAt',
|
||||
'asset.createdAt',
|
||||
'asset.localDateTime',
|
||||
'asset.type',
|
||||
'asset.deletedAt',
|
||||
'asset.visibility',
|
||||
'asset.duration',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.stackId',
|
||||
'asset.libraryId',
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
'asset.isEdited',
|
||||
],
|
||||
syncPartnerAsset: [
|
||||
'asset.id',
|
||||
'asset.ownerId',
|
||||
|
||||
@@ -69,7 +69,6 @@ select
|
||||
"asset"."localDateTime",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
"asset"."isFavorite",
|
||||
"asset"."visibility",
|
||||
"asset"."duration",
|
||||
"asset"."livePhotoVideoId",
|
||||
@@ -78,15 +77,19 @@ select
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"asset"."isEdited",
|
||||
case
|
||||
when "asset"."ownerId" = $1 then "asset"."isFavorite"
|
||||
else $2
|
||||
end as "isFavorite",
|
||||
"album_asset"."updateId"
|
||||
from
|
||||
"album_asset" as "album_asset"
|
||||
inner join "asset" on "asset"."id" = "album_asset"."assetId"
|
||||
where
|
||||
"album_asset"."updateId" < $1
|
||||
and "album_asset"."updateId" <= $2
|
||||
and "album_asset"."updateId" >= $3
|
||||
and "album_asset"."albumId" = $4
|
||||
"album_asset"."updateId" < $3
|
||||
and "album_asset"."updateId" <= $4
|
||||
and "album_asset"."updateId" >= $5
|
||||
and "album_asset"."albumId" = $6
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
|
||||
@@ -103,7 +106,6 @@ select
|
||||
"asset"."localDateTime",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
"asset"."isFavorite",
|
||||
"asset"."visibility",
|
||||
"asset"."duration",
|
||||
"asset"."livePhotoVideoId",
|
||||
@@ -112,16 +114,20 @@ select
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"asset"."isEdited",
|
||||
case
|
||||
when "asset"."ownerId" = $1 then "asset"."isFavorite"
|
||||
else $2
|
||||
end as "isFavorite",
|
||||
"asset"."updateId"
|
||||
from
|
||||
"asset" as "asset"
|
||||
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
|
||||
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
|
||||
where
|
||||
"asset"."updateId" < $1
|
||||
and "asset"."updateId" > $2
|
||||
and "album_asset"."updateId" <= $3
|
||||
and "album_user"."userId" = $4
|
||||
"asset"."updateId" < $3
|
||||
and "asset"."updateId" > $4
|
||||
and "album_asset"."updateId" <= $5
|
||||
and "album_user"."userId" = $6
|
||||
order by
|
||||
"asset"."updateId" asc
|
||||
|
||||
@@ -139,7 +145,6 @@ select
|
||||
"asset"."localDateTime",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
"asset"."isFavorite",
|
||||
"asset"."visibility",
|
||||
"asset"."duration",
|
||||
"asset"."livePhotoVideoId",
|
||||
@@ -147,15 +152,19 @@ select
|
||||
"asset"."libraryId",
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"asset"."isEdited"
|
||||
"asset"."isEdited",
|
||||
case
|
||||
when "asset"."ownerId" = $1 then "asset"."isFavorite"
|
||||
else $2
|
||||
end as "isFavorite"
|
||||
from
|
||||
"album_asset" as "album_asset"
|
||||
inner join "asset" on "asset"."id" = "album_asset"."assetId"
|
||||
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
|
||||
where
|
||||
"album_asset"."updateId" < $1
|
||||
and "album_asset"."updateId" > $2
|
||||
and "album_user"."userId" = $3
|
||||
"album_asset"."updateId" < $3
|
||||
and "album_asset"."updateId" > $4
|
||||
and "album_user"."userId" = $5
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
|
||||
|
||||
@@ -195,11 +195,20 @@ class AlbumSync extends BaseSync {
|
||||
}
|
||||
|
||||
class AlbumAssetSync extends BaseSync {
|
||||
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true })
|
||||
getBackfill(options: SyncBackfillOptions, albumId: string) {
|
||||
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID, DummyValue.UUID], stream: true })
|
||||
getBackfill(options: SyncBackfillOptions, albumId: string, userId: string) {
|
||||
return this.backfillQuery('album_asset', options)
|
||||
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
|
||||
.select(columns.syncAsset)
|
||||
.select(columns.syncAlbumAsset)
|
||||
.select((eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when('asset.ownerId', '=', userId)
|
||||
.then(eb.ref('asset.isFavorite'))
|
||||
.else(eb.val(false))
|
||||
.end()
|
||||
.as('isFavorite'),
|
||||
)
|
||||
.select('album_asset.updateId')
|
||||
.where('album_asset.albumId', '=', albumId)
|
||||
.stream();
|
||||
@@ -210,7 +219,16 @@ class AlbumAssetSync extends BaseSync {
|
||||
const userId = options.userId;
|
||||
return this.upsertQuery('asset', options)
|
||||
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
|
||||
.select(columns.syncAsset)
|
||||
.select(columns.syncAlbumAsset)
|
||||
.select((eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when('asset.ownerId', '=', userId)
|
||||
.then(eb.ref('asset.isFavorite'))
|
||||
.else(eb.val(false))
|
||||
.end()
|
||||
.as('isFavorite'),
|
||||
)
|
||||
.select('asset.updateId')
|
||||
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about
|
||||
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
@@ -224,7 +242,16 @@ class AlbumAssetSync extends BaseSync {
|
||||
return this.upsertQuery('album_asset', options)
|
||||
.select('album_asset.updateId')
|
||||
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
|
||||
.select(columns.syncAsset)
|
||||
.select(columns.syncAlbumAsset)
|
||||
.select((eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when('asset.ownerId', '=', userId)
|
||||
.then(eb.ref('asset.isFavorite'))
|
||||
.else(eb.val(false))
|
||||
.end()
|
||||
.as('isFavorite'),
|
||||
)
|
||||
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
.where('album_user.userId', '=', userId)
|
||||
.stream();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// Delete cross-owner memory assets
|
||||
await sql`
|
||||
DELETE FROM memory_asset
|
||||
USING memory, asset
|
||||
WHERE memory_asset."memoriesId" = memory.id
|
||||
AND memory_asset."assetId" = asset.id
|
||||
AND memory."ownerId" != asset."ownerId"
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Not implemented: the deleted rows were cross-owner entries
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export class AlbumService extends BaseService {
|
||||
const results = await addAssets(
|
||||
auth,
|
||||
{ access: this.accessRepository, bulk: this.albumRepository },
|
||||
{ parentId: id, assetIds: dto.ids },
|
||||
{ parentId: id, assetIds: dto.ids, permission: Permission.AssetShare },
|
||||
);
|
||||
|
||||
const { id: firstNewAssetId } = results.find(({ success }) => success) || {};
|
||||
|
||||
@@ -134,6 +134,27 @@ describe(MemoryService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not link a partner asset', async () => {
|
||||
const [assetId, userId] = newUuids();
|
||||
const memory = MemoryFactory.create({ ownerId: userId });
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.memory.create.mockResolvedValue(getForMemory(memory));
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth({ user: { id: userId } }), {
|
||||
type: memory.type,
|
||||
data: memory.data as OnThisDayData,
|
||||
memoryAt: memory.memoryAt,
|
||||
assetIds: [assetId],
|
||||
}),
|
||||
).resolves.toMatchObject({ assets: [] });
|
||||
|
||||
expect(mocks.memory.create).toHaveBeenCalledWith(expect.objectContaining({ ownerId: userId }), new Set());
|
||||
expect(mocks.access.asset.checkPartnerAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a memory without assets', async () => {
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
@@ -230,6 +251,24 @@ describe(MemoryService.name, () => {
|
||||
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not link a partner asset', async () => {
|
||||
const assetId = newUuid();
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.memory.get.mockResolvedValue(getForMemory(memory));
|
||||
mocks.memory.getAssetIds.mockResolvedValue(new Set());
|
||||
|
||||
await expect(sut.addAssets(factory.auth(), memory.id, { ids: [assetId] })).resolves.toEqual([
|
||||
{ error: 'no_permission', id: assetId, success: false },
|
||||
]);
|
||||
|
||||
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled();
|
||||
expect(mocks.access.asset.checkPartnerAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add assets', async () => {
|
||||
const assetId = newUuid();
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
@@ -93,7 +93,7 @@ export class MemoryService extends BaseService {
|
||||
const assetIds = dto.assetIds || [];
|
||||
const allowedAssetIds = await this.checkAccess({
|
||||
auth,
|
||||
permission: Permission.AssetShare,
|
||||
permission: Permission.AssetUpdate,
|
||||
ids: assetIds,
|
||||
});
|
||||
const memory = await this.memoryRepository.create(
|
||||
@@ -134,7 +134,11 @@ export class MemoryService extends BaseService {
|
||||
await this.requireAccess({ auth, permission: Permission.MemoryRead, ids: [id] });
|
||||
|
||||
const repos = { access: this.accessRepository, bulk: this.memoryRepository };
|
||||
const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids });
|
||||
const results = await addAssets(auth, repos, {
|
||||
parentId: id,
|
||||
assetIds: dto.ids,
|
||||
permission: Permission.AssetUpdate,
|
||||
});
|
||||
|
||||
const hasSuccess = results.find(({ success }) => success);
|
||||
if (hasSuccess) {
|
||||
|
||||
@@ -545,6 +545,7 @@ export class SyncService extends BaseService {
|
||||
const backfill = this.syncRepository.albumAsset.getBackfill(
|
||||
{ ...options, afterUpdateId: startId, beforeUpdateId: endId },
|
||||
album.id,
|
||||
options.userId,
|
||||
);
|
||||
|
||||
for await (const { updateId, ...data } of backfill) {
|
||||
|
||||
@@ -275,6 +275,19 @@ describe(TagService.name, () => {
|
||||
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
|
||||
expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
|
||||
});
|
||||
|
||||
it('should not tag a partner asset', async () => {
|
||||
mocks.tag.getAssetIds.mockResolvedValue(new Set());
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ id: 'asset-1', success: false, error: BulkIdErrorReason.NO_PERMISSION },
|
||||
]);
|
||||
|
||||
expect(mocks.tag.addAssetIds).not.toHaveBeenCalled();
|
||||
expect(mocks.access.asset.checkPartnerAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
|
||||
@@ -104,7 +104,7 @@ export class TagService extends BaseService {
|
||||
const results = await addAssets(
|
||||
auth,
|
||||
{ access: this.accessRepository, bulk: this.tagRepository },
|
||||
{ parentId: id, assetIds: dto.ids },
|
||||
{ parentId: id, assetIds: dto.ids, permission: Permission.AssetUpdate },
|
||||
);
|
||||
|
||||
for (const { id: assetId, success } of results) {
|
||||
|
||||
@@ -33,14 +33,14 @@ export const getAssetFiles = (files: AssetFile[]) => ({
|
||||
export const addAssets = async (
|
||||
auth: AuthDto,
|
||||
repositories: { access: AccessRepository; bulk: IBulkAsset },
|
||||
dto: { parentId: string; assetIds: string[] },
|
||||
dto: { parentId: string; assetIds: string[]; permission: Permission },
|
||||
) => {
|
||||
const { access, bulk } = repositories;
|
||||
const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds);
|
||||
const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id));
|
||||
const allowedAssetIds = await checkAccess(access, {
|
||||
auth,
|
||||
permission: Permission.AssetShare,
|
||||
permission: dto.permission,
|
||||
ids: notPresentAssetIds,
|
||||
});
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
|
||||
it('should sync asset updates for an album shared with you', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: false });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'before' });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user2.id });
|
||||
await wait(2);
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
@@ -281,9 +281,7 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
|
||||
updateSyncAck,
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: expect.objectContaining({
|
||||
id: asset.id,
|
||||
}),
|
||||
data: expect.objectContaining({ id: asset.id, originalFileName: 'before' }),
|
||||
type: SyncEntityType.AlbumAssetCreateV2,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
@@ -291,24 +289,56 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
|
||||
// update the asset
|
||||
const assetRepository = ctx.get(AssetRepository);
|
||||
await assetRepository.update({
|
||||
id: asset.id,
|
||||
isFavorite: true,
|
||||
});
|
||||
await assetRepository.update({ id: asset.id, originalFileName: 'after' });
|
||||
|
||||
const updateResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]);
|
||||
expect(updateResponse).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: expect.objectContaining({
|
||||
id: asset.id,
|
||||
isFavorite: true,
|
||||
}),
|
||||
data: expect.objectContaining({ id: asset.id, originalFileName: 'after' }),
|
||||
type: SyncEntityType.AlbumAssetUpdateV2,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should hide isFavorite for album assets owned by another user', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: true });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user2.id });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Viewer });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]);
|
||||
expect(response).toEqual([
|
||||
updateSyncAck,
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: expect.objectContaining({ id: asset.id, isFavorite: false }),
|
||||
type: SyncEntityType.AlbumAssetCreateV2,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sync isFavorite for album assets owned by the requesting user', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: auth.user.id, isFavorite: true });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user2.id });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Viewer });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]);
|
||||
expect(response).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ id: asset.id, isFavorite: true }),
|
||||
type: SyncEntityType.AlbumAssetCreateV2,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,4 +278,21 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
|
||||
});
|
||||
|
||||
it('should hide isFavorite for partner assets', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: true });
|
||||
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: expect.objectContaining({ id: asset.id, isFavorite: false }),
|
||||
type: SyncEntityType.PartnerAssetV2,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.79.2",
|
||||
"@immich/ui": "^0.80.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
|
||||
@@ -310,12 +310,12 @@
|
||||
untrack(() => map?.jumpTo({ center, zoom }));
|
||||
});
|
||||
|
||||
const onAssetsChanged = async () => {
|
||||
const onAssetsDelete = async () => {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents onAssetsDelete={onAssetsChanged} onAssetsArchive={onAssetsChanged} onAssetsUnarchive={onAssetsChanged} />
|
||||
<OnEvents {onAssetsDelete} />
|
||||
|
||||
<!-- We handle style loading ourselves so we set style blank here -->
|
||||
<MapLibre
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
@@ -220,17 +219,6 @@
|
||||
const unsubscribes = [
|
||||
websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)),
|
||||
websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)),
|
||||
eventManager.on({
|
||||
AssetsUndoArchive: async (assets) => {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const restoredAsset = assets[0];
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
|
||||
assetViewerManager.setAsset(asset);
|
||||
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
|
||||
},
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
UserAdminResponseDto,
|
||||
WorkflowResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||
|
||||
@@ -34,8 +33,6 @@ export type Events = {
|
||||
|
||||
AssetUpdate: [AssetResponseDto];
|
||||
AssetsArchive: [string[]];
|
||||
AssetsUnarchive: [TimelineAsset[]];
|
||||
AssetsUndoArchive: [TimelineAsset[]];
|
||||
AssetsDelete: [string[]];
|
||||
AssetEditsApplied: [string];
|
||||
AssetsTag: [string[]];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetOrder, AssetOrderBy, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
|
||||
import { clamp, isEqual } from 'lodash-es';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
@@ -114,15 +114,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
|
||||
this.#unsubscribes.push(
|
||||
eventManager.on({
|
||||
AssetUpdate: (asset: AssetResponseDto) => {
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
if (this.#options.albumId || this.#options.personId) {
|
||||
this.#updateAssets([timelineAsset]);
|
||||
} else {
|
||||
this.upsertAssets([timelineAsset]);
|
||||
}
|
||||
},
|
||||
AssetsUnarchive: (assets) => this.upsertAssets(assets),
|
||||
AssetUpdate: (asset: AssetResponseDto) => this.#updateAssets([toTimelineAsset(asset)]),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import { get } from 'svelte/store';
|
||||
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { downloadBlob, downloadRequest, withError } from '$lib/utils';
|
||||
@@ -32,7 +31,6 @@ import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { asQueryString } from '$lib/utils/shared-links';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { handleError } from './handle-error';
|
||||
|
||||
export const tagAssets = async ({
|
||||
@@ -402,21 +400,7 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
|
||||
});
|
||||
|
||||
asset.isArchived = data.isArchived;
|
||||
if (asset.isArchived) {
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
toastManager.primary(
|
||||
{
|
||||
description: $t('added_to_archive'),
|
||||
button: {
|
||||
label: $t('undo'),
|
||||
onclick: () => undoArchiveAssets([timelineAsset]),
|
||||
},
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
toastManager.primary($t('removed_from_archive'));
|
||||
}
|
||||
toastManager.primary(asset.isArchived ? $t(`added_to_archive`) : $t(`removed_from_archive`));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_archive', { values: { archived: asset.isArchived } }));
|
||||
}
|
||||
@@ -424,30 +408,7 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
|
||||
return asset;
|
||||
};
|
||||
|
||||
const undoArchiveAssets = async (assets: TimelineAsset[]) => {
|
||||
const $t = get(t);
|
||||
try {
|
||||
const ids = assets.map((a) => a.id);
|
||||
if (ids.length > 0) {
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
asset.visibility = AssetVisibility.Timeline;
|
||||
}
|
||||
eventManager.emit('AssetsUnarchive', assets);
|
||||
eventManager.emit('AssetsUndoArchive', assets);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_archive_unarchive', { values: { archived: false } }));
|
||||
}
|
||||
};
|
||||
|
||||
export const archiveAssets = async (assets: TimelineAsset[], visibility: AssetVisibility) => {
|
||||
export const archiveAssets = async (assets: { id: string }[], visibility: AssetVisibility) => {
|
||||
const ids = assets.map(({ id }) => id);
|
||||
const $t = get(t);
|
||||
|
||||
@@ -458,20 +419,11 @@ export const archiveAssets = async (assets: TimelineAsset[], visibility: AssetVi
|
||||
});
|
||||
}
|
||||
|
||||
if (visibility === AssetVisibility.Archive) {
|
||||
toastManager.primary(
|
||||
{
|
||||
description: $t('archived_count', { values: { count: ids.length } }),
|
||||
button: {
|
||||
label: $t('undo'),
|
||||
onclick: () => undoArchiveAssets(assets),
|
||||
},
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
toastManager.primary($t('unarchived_count', { values: { count: ids.length } }));
|
||||
}
|
||||
toastManager.primary(
|
||||
visibility === AssetVisibility.Archive
|
||||
? $t('archived_count', { values: { count: ids.length } })
|
||||
: $t('unarchived_count', { values: { count: ids.length } }),
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
onPersonAssetDelete={handlePersonAssetDelete}
|
||||
onAssetsDelete={updateAssetCount}
|
||||
onAssetsArchive={updateAssetCount}
|
||||
onAssetsUnarchive={updateAssetCount}
|
||||
/>
|
||||
|
||||
<main
|
||||
|
||||
Reference in New Issue
Block a user