Compare commits

..

34 Commits

Author SHA1 Message Date
midzelis
5194643571 feat: enable SvelteKit CSP policy with hash-based inline script/style protection 2026-03-03 14:37:56 +00:00
Mees Frensel
0560f98c2d chore(web): clarify locale settings description (#25562) 2026-03-03 12:52:17 +01:00
Brandon Annin
49ad411d50 fix(docs): add ocr to job flow diagram (#26505) 2026-03-03 12:43:59 +01:00
renovate[bot]
2478cc40f4 chore(deps): update dependency terragrunt to v0.99.4 (#26658)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 11:42:11 +00:00
Joe Babbitt
44eeb1e088 fix: implement existing withStacked on searchAssetBuilder (#26607)
Co-authored-by: Joe <code@joebabbitt.com>
2026-03-03 11:41:29 +00:00
Min Idzelis
a868ae3ad0 perf: move album fetching into detail panel (#26632) 2026-03-03 12:25:03 +01:00
renovate[bot]
acac0d4f37 chore(deps): update github-actions (#26656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: bo0tzz <git@bo0tzz.me>
2026-03-03 11:14:12 +00:00
Michel Heusschen
8c40a28fef fix(server): clean up edited thumbnail when deleting asset (#26664) 2026-03-03 12:08:07 +01:00
renovate[bot]
b2081eda1e fix(deps): update typescript-projects (#26657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-03 12:06:22 +01:00
renovate[bot]
9670c853c6 chore(deps): update docker.io/valkey/valkey:9 docker digest to 2bce660 (#26652)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 12:02:04 +01:00
renovate[bot]
cc2dacb308 chore(deps): update prom/prometheus docker digest to 4a61322 (#26653)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 11:23:13 +01:00
renovate[bot]
15fc6b18f3 chore(deps): update dependency @types/node to ^24.10.14 (#26654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 09:22:58 +00:00
Mees Frensel
a284e38890 fix(web): timeline and asset viewer RTL support (#26513) 2026-03-03 09:01:54 +01:00
Thomas
05010c3a84 fix(mobile): asset viewer hero animation (#26545)
The image in the photo view has no height, and is therefore entirely
unconstrained. This causes the image to take up the full height of the
viewport during the hero animation, which can make look out of sync. In
some other cases, it can stretch or resize the image to fill the entire
viewport.
2026-03-02 22:26:53 -06:00
Min Idzelis
4da3d68a67 refactor: use keyed each for face bounding boxes (#26648) 2026-03-02 22:16:13 -06:00
Min Idzelis
20c639e52a refactor: extract shared ContentMetrics for overlay position calculations (#26310) 2026-03-02 21:49:56 -06:00
Luis Nachtigall
6deb97d5bc fix(mobile): android detect supported version for special format column (#26633)
* fix(android): detect supported version for special format column

* fix(android): remove unnecessary suppression for new API in special format check

* fix(android): change visibility of hasSpecialFormatColumn method to private
2026-03-02 17:06:35 -05:00
Snowknight26
b282d83e95 fix(web): show shared link download button when logged in (#26629) 2026-03-02 22:00:23 +01:00
Jason Rasmussen
5bc08f8654 refactor: queue names (#26650) 2026-03-02 15:46:26 -05:00
shenlong
f54924d46a refactor: simplify video zooming (#26527)
fix: simplify video zooming

# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
#	mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-03-02 17:53:49 +00:00
Michel Heusschen
dffe4d1d5c refactor(web): remove resize observer action (#26647) 2026-03-02 14:45:34 +00:00
Min Idzelis
7f47cdd645 feat: enhance face-editor positioning (#26303)
feat: enhance face-editor positioning - less overlap

test: timeline with actual video
2026-03-02 09:44:59 -05:00
Min Idzelis
625b30c50a test: stack editor e2e tests (#26526)
* feat: add responsive layout to broken asset

* test: stack editor e2e tests
2026-03-02 09:43:56 -05:00
Min Idzelis
8619d14eca feat: add responsive layout to broken asset (#26384) 2026-03-02 09:27:40 -05:00
Min Idzelis
062546c168 refactor: rename image cancel method (#26381) 2026-03-02 09:23:20 -05:00
Michel Heusschen
ea668d6b22 refactor(web): convert memory observer to an attachment (#26646) 2026-03-02 09:20:13 -05:00
Michel Heusschen
f06af2c600 refactor(web): dedupe isAllUserOwned logic (#26645) 2026-03-02 09:18:32 -05:00
Snowknight26
9dd2633e0c chore(web): deduplicate storage template examples (#26462) 2026-03-02 12:52:02 +01:00
Mees Frensel
13a514c189 fix(web): small thumbnail issues (#26643) 2026-03-02 12:50:33 +01:00
Mees Frensel
b0c9120bb6 chore: update PWA support (#26491) 2026-03-02 11:35:53 +00:00
Yaros
bc4265416d fix(web): top bar z index on search page (#26582) 2026-03-02 11:33:00 +01:00
shenlong
d4434f2276 fix: reset db from splash screen (#26617)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-03-01 15:23:26 -06:00
Luis Nachtigall
f4e156494f feat(mobile): add playbackStyle to local asset entity and related database schema (#26596)
* feat: add playbackStyle to local asset entity and related database schema

* implement conversion function for playbackStyle in local sync service

* implement conversion function for playbackStyle in local sync service

* refactor: remove deducedPlaybackStyle from TrashedLocalAssetEntityData

* add playbackStyle column to trashed local asset entity

* make playbackStyle non-nullable across the mobile codebase

* Streamline playbackStyle backfill:
- only backfill local assets playbackStyle in flutter/dart code
- only update trashed local assets in db migration

* bump target database version to 23 and update migration logic for playbackStyle

* set playback_style to 0 in merged_asset.drift as its a getter in base asset

* run make pigeon

* Populate playbackStyle for trashed assets during native migration
2026-03-01 14:50:21 -05:00
Min Idzelis
84abad564e fix(server): deduplicate shared links in getAll query (#26395) 2026-03-01 14:41:15 -05:00
157 changed files with 12893 additions and 2855 deletions

View File

@@ -24,8 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
# sha is pinning to a commit instead of a tag since the action does not tag versions
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
category: '/language:${{matrix.language}}'

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.13",
"@types/node": "^24.10.14",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -1,5 +1,5 @@
[tools]
terragrunt = "0.98.0"
terragrunt = "0.99.4"
opentofu = "1.11.4"
[tasks."tg:fmt"]

View File

@@ -155,7 +155,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus

View File

@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
user: '1000:1000'
security_opt:
- no-new-privileges:true

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -67,7 +67,8 @@ graph TD
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
D --> E[Smart Search]
D --> F[Face Detection]
D --> G[Video Transcoding]
E --> H[Duplicate Detection]
F --> I[Facial Recognition]
D --> G[OCR]
D --> H[Video Transcoding]
E --> I[Duplicate Detection]
F --> J[Facial Recognition]
```

View File

@@ -11,7 +11,6 @@ services:
immich-server:
container_name: immich-e2e-server
image: immich-server:latest
shm_size: 128mb
build:
context: ../
dockerfile: server/Dockerfile
@@ -45,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.10.13",
"@types/node": "^24.10.14",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",

View File

@@ -1,66 +0,0 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, Page, test } from '@playwright/test';
import { utils } from 'src/utils';
async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
test.describe('Asset Viewer stack', () => {
let admin: LoginResponseDto;
let assetOne: AssetMediaResponseDto;
let assetTwo: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
assetOne = await utils.createAsset(admin.accessToken);
assetTwo = await utils.createAsset(admin.accessToken);
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
});
test('stack slideshow is visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await expect(stackAssets.first()).toBeVisible();
await expect(stackAssets.nth(1)).toBeVisible();
});
test('tags of primary asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});

View File

@@ -0,0 +1,167 @@
import { faker } from '@faker-js/faker';
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline';
export type MockStack = {
id: string;
primaryAssetId: string;
assets: AssetResponseDto[];
brokenAssetIds: Set<string>;
assetMap: Map<string, AssetResponseDto>;
};
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
const assetId = faker.string.uuid();
const now = new Date().toISOString();
return {
id: assetId,
deviceAssetId: `device-${assetId}`,
ownerId,
owner: {
id: ownerId,
email: 'admin@immich.cloud',
name: 'Admin',
profileImagePath: '',
profileChangedAt: now,
avatarColor: 'blue' as never,
},
libraryId: `library-${ownerId}`,
deviceId: `device-${ownerId}`,
type: AssetTypeEnum.Image,
originalPath: `/original/${assetId}.jpg`,
originalFileName: `${assetId}.jpg`,
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt: now,
fileModifiedAt: now,
localDateTime: now,
updatedAt: now,
createdAt: now,
isFavorite: false,
isArchived: false,
isTrashed: false,
visibility: AssetVisibility.Timeline,
duration: '0:00:00.00000',
exifInfo: {
make: null,
model: null,
exifImageWidth: 3000,
exifImageHeight: 4000,
fileSizeInByte: null,
orientation: null,
dateTimeOriginal: now,
modifyDate: null,
timeZone: null,
lensModel: null,
fNumber: null,
focalLength: null,
iso: null,
exposureTime: null,
latitude: null,
longitude: null,
city: null,
country: null,
state: null,
description: null,
},
livePhotoVideoId: null,
tags: [],
people: [],
unassignedFaces: [],
stack: null,
isOffline: false,
hasMetadata: true,
duplicateId: null,
resized: true,
checksum: faker.string.alphanumeric({ length: 28 }),
width: 3000,
height: 4000,
isEdited: false,
};
};
export const createMockStack = (
primaryAssetDto: AssetResponseDto,
additionalAssets: AssetResponseDto[],
brokenAssetIds?: Set<string>,
): MockStack => {
const stackId = faker.string.uuid();
const allAssets = [primaryAssetDto, ...additionalAssets];
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
primaryAssetDto.stack = {
id: stackId,
assetCount: allAssets.length,
primaryAssetId: primaryAssetDto.id,
};
return {
id: stackId,
primaryAssetId: primaryAssetDto.id,
assets: allAssets,
brokenAssetIds: resolvedBrokenIds,
assetMap,
};
};
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
await context.route('**/api/stacks/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const stackResponse: StackResponseDto = {
id: mockStack.id,
primaryAssetId: mockStack.primaryAssetId,
assets: mockStack.assets,
};
return route.fulfill({
status: 200,
contentType: 'application/json',
json: stackResponse,
});
});
await context.route('**/api/assets/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetId = segments.at(-1);
if (assetId && mockStack.assetMap.has(assetId)) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: mockStack.assetMap.get(assetId),
});
}
return route.fallback();
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
return route.fallback();
}
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
return route.fulfill({ status: 404 });
}
const asset = mockStack.assetMap.get(match.groups.assetId)!;
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
const body =
match.groups.size === 'preview'
? await randomPreview(match.groups.assetId, ratio)
: await randomThumbnail(match.groups.assetId, ratio);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body,
});
});
};

View File

@@ -0,0 +1,127 @@
import { BrowserContext } from '@playwright/test';
import { randomThumbnail } from 'src/ui/generators/timeline';
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
const MINIMAL_MP4_BASE64 =
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
export type MockPerson = {
id: string;
name: string;
birthDate: string | null;
isHidden: boolean;
thumbnailPath: string;
updatedAt: string;
};
export const createMockPeople = (count: number): MockPerson[] => {
const names = [
'Alice Johnson',
'Bob Smith',
'Charlie Brown',
'Diana Prince',
'Eve Adams',
'Frank Castle',
'Grace Lee',
'Hank Pym',
'Iris West',
'Jack Ryan',
];
return Array.from({ length: count }, (_, index) => ({
id: `person-${index}`,
name: names[index % names.length],
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
updatedAt: '2025-01-01T00:00:00.000Z',
}));
};
export type FaceCreateCapture = {
requests: Array<{
assetId: string;
personId: string;
x: number;
y: number;
width: number;
height: number;
imageWidth: number;
imageHeight: number;
}>;
};
export const setupFaceEditorMockApiRoutes = async (
context: BrowserContext,
mockPeople: MockPerson[],
faceCreateCapture: FaceCreateCapture,
) => {
await context.route('**/api/people?*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
hasNextPage: false,
hidden: 0,
people: mockPeople,
total: mockPeople.length,
},
});
});
await context.route('**/api/faces', async (route, request) => {
if (request.method() !== 'POST') {
return route.fallback();
}
const body = request.postDataJSON();
faceCreateCapture.requests.push(body);
return route.fulfill({
status: 201,
contentType: 'text/plain',
body: 'OK',
});
});
await context.route('**/api/people/*/thumbnail', async (route) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail('person-thumb', 1),
});
});
};

View File

@@ -12,6 +12,7 @@ import {
TimelineData,
} from 'src/ui/generators/timeline';
import { sleep } from 'src/ui/specs/timeline/utils';
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
export class TimelineTestContext {
slowBucket = false;
@@ -135,6 +136,14 @@ export const setupTimelineMockApiRoutes = async (
return route.continue();
});
await context.route('**/api/assets/*/video/playback*', async (route) => {
return route.fulfill({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: MINIMAL_MP4_BUFFER,
});
});
await context.route('**/api/albums/**', async (route, request) => {
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
if (albumsMatch) {

View File

@@ -0,0 +1,84 @@
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('broken-asset responsiveness', () => {
const fixture = setupAssetViewerFixture(889);
let mockStack: MockStack;
test.beforeAll(async () => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const brokenAssets = [
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
];
mockStack = createMockStack(primaryAssetDto, brokenAssets);
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
for (const brokenAsset of await brokenAssets.all()) {
await expect(brokenAsset.locator('svg')).not.toBeVisible();
}
});
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
for (const brokenAsset of await brokenAssets.all()) {
const messageSpan = brokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-xs/);
}
});
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route(
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
async (route) => {
return route.fulfill({ status: 404 });
},
);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
const messageSpan = viewerBrokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-base/);
});
});

View File

@@ -0,0 +1,285 @@
import { expect, Page, test } from '@playwright/test';
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline';
import {
createMockPeople,
FaceCreateCapture,
MockPerson,
setupFaceEditorMockApiRoutes,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
const waitForSelectorTransition = async (page: Page) => {
await page.waitForFunction(
() => {
const selector = document.querySelector('#face-selector') as HTMLElement | null;
if (!selector) {
return false;
}
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
},
undefined,
{ timeout: 1000, polling: 50 },
);
};
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.keyboard.press('i');
await page.locator('#detail-panel').waitFor({ state: 'visible' });
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await waitForSelectorTransition(page);
};
test.describe.configure({ mode: 'parallel' });
test.describe('face-editor', () => {
const fixture = setupAssetViewerFixture(777);
const rng = new SeededRandom(777);
let mockPeople: MockPerson[];
let faceCreateCapture: FaceCreateCapture;
test.beforeAll(async () => {
mockPeople = createMockPeople(8);
});
test.beforeEach(async ({ context }) => {
faceCreateCapture = { requests: [] };
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
});
type ScreenRect = { top: number; left: number; width: number; height: number };
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
const dataEl = page.locator('#face-editor-data');
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
const canvasBox = await page.locator('#face-editor').boundingBox();
if (!canvasBox) {
throw new Error('Canvas element not found');
}
const left = Number(await dataEl.getAttribute('data-face-left'));
const top = Number(await dataEl.getAttribute('data-face-top'));
const width = Number(await dataEl.getAttribute('data-face-width'));
const height = Number(await dataEl.getAttribute('data-face-height'));
return {
top: canvasBox.y + top,
left: canvasBox.x + left,
width,
height,
};
};
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
const box = await page.locator('#face-selector').boundingBox();
if (!box) {
throw new Error('Face selector element not found');
}
return { top: box.y, left: box.x, width: box.width, height: box.height };
};
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
return overlapX * overlapY;
};
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
const faceBox = await getFaceBoxRect(page);
const centerX = faceBox.left + faceBox.width / 2;
const centerY = faceBox.top + faceBox.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
};
test('Face editor opens with person list', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
}
});
test('Search filters people by name', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const searchInput = page.locator('#face-selector input');
await searchInput.fill('Alice');
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
await searchInput.clear();
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
}
});
test('Search with no results shows empty message', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const searchInput = page.locator('#face-selector input');
await searchInput.fill('Nonexistent Person XYZ');
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
}
});
test('Selecting a person shows confirmation dialog', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const personToTag = mockPeople[0];
await page.locator('#face-selector').getByText(personToTag.name).click();
await expect(page.getByRole('dialog')).toBeVisible();
});
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const personToTag = mockPeople[0];
await page.locator('#face-selector').getByText(personToTag.name).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: /confirm/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(page.locator('#face-editor')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
});
test('Cancel button closes face editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(page.locator('#face-editor')).toBeHidden();
});
test('Selector does not overlap face box on initial open', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 0, 150);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 200, 0);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, -300, -300);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 300, 300);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector stays within viewport bounds', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const viewportSize = page.viewportSize()!;
const selectorBox = await getSelectorRect(page);
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
});
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, -400, -400);
const viewportSize = page.viewportSize()!;
const selectorBox = await getSelectorRect(page);
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
});
test('Face box is draggable on the canvas', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const beforeDrag = await getFaceBoxRect(page);
await dragFaceBox(page, 100, 50);
const afterDrag = await getFaceBoxRect(page);
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
});
});

View File

@@ -0,0 +1,84 @@
import { faker } from '@faker-js/faker';
import type { AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { assetViewerUtils } from '../timeline/utils';
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer stack', () => {
const fixture = setupAssetViewerFixture(888);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
primaryAssetDto.tags = [
{
id: faker.string.uuid(),
name: '1',
value: 'test/1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.tags = [
{
id: faker.string.uuid(),
name: '2',
value: 'test/2',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('stack slideshow is visible', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const stackAssets = stackSlideshow.locator('[data-asset]');
await expect(stackAssets).toHaveCount(mockStack.assets.length);
});
test('tags of primary asset are visible', async ({ context, page }) => {
await enableTagsPreference(context);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ context, page }) => {
await enableTagsPreference(context);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});

View File

@@ -0,0 +1,116 @@
import { faker } from '@faker-js/faker';
import type { AssetResponseDto } from '@immich/sdk';
import { BrowserContext, Page, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { utils } from 'src/utils';
export type AssetViewerTestFixture = {
adminUserId: string;
timelineRestData: TimelineData;
assets: TimelineAssetConfig[];
testContext: TimelineTestContext;
changes: Changes;
primaryAsset: TimelineAssetConfig;
primaryAssetDto: AssetResponseDto;
};
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
const rng = new SeededRandom(seed);
const testContext = new TimelineTestContext();
const fixture: AssetViewerTestFixture = {
adminUserId: undefined!,
timelineRestData: undefined!,
assets: [],
testContext,
changes: {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
},
primaryAsset: undefined!,
primaryAssetDto: undefined!,
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
utils.initSdk();
fixture.adminUserId = faker.string.uuid();
testContext.adminId = fixture.adminUserId;
fixture.timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: fixture.adminUserId,
});
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
fixture.assets.push(...timeBucket);
}
fixture.primaryAsset = selectRandom(
fixture.assets.filter((a) => a.isImage),
rng,
);
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, fixture.adminUserId);
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
});
test.afterEach(() => {
fixture.testContext.slowBucket = false;
fixture.changes.albumAdditions = [];
fixture.changes.assetDeletions = [];
fixture.changes.assetArchivals = [];
fixture.changes.assetFavorites = [];
});
return fixture;
}
export async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
export async function enableTagsPreference(context: BrowserContext) {
await context.route('**/users/me/preferences', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: { defaultAssetOrder: 'desc' },
folders: { enabled: false, sidebarWeb: false },
memories: { enabled: true, duration: 5 },
people: { enabled: true, sidebarWeb: false },
sharedLinks: { enabled: true, sidebarWeb: false },
ratings: { enabled: false },
tags: { enabled: true, sidebarWeb: false },
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
cast: { gCastEnabled: false },
},
});
});
}

View File

@@ -871,8 +871,8 @@
"current_pin_code": "Current PIN code",
"current_server_address": "Current server address",
"custom_date": "Custom date",
"custom_locale": "Custom Locale",
"custom_locale_description": "Format dates and numbers based on the language and the region",
"custom_locale": "Custom locale",
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
"custom_url": "Custom URL",
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
@@ -895,8 +895,6 @@
"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",
@@ -2338,6 +2336,8 @@
"url": "URL",
"usage": "Usage",
"use_biometric": "Use biometric",
"use_browser_locale": "Use browser locale",
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
"use_current_connection": "Use current connection",
"use_custom_date_range": "Use custom date range instead",
"user": "User",

72
misc/update-csp-hashes.mjs Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* Computes SHA-256 hashes for inline <script> elements in app.html
* and updates the script-src CSP directive in svelte.config.js.
*
* SvelteKit's CSP hash mode only hashes inline content it generates itself,
* not the template content from app.html. This script fills that gap.
*
* Run this script whenever the inline scripts in app.html change.
*
* Usage: node misc/update-csp-hashes.mjs
*/
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDirectory, '..');
const appHtmlPath = join(repoRoot, 'web', 'src', 'app.html');
const configPath = join(repoRoot, 'web', 'svelte.config.js');
const appHtml = readFileSync(appHtmlPath, 'utf-8');
const scriptRegex = /<script[^>]*>([\s\S]*?)<\/script>/gi;
const hashes = [];
let match;
while ((match = scriptRegex.exec(appHtml)) !== null) {
const content = match[1];
const hash = createHash('sha256').update(content).digest('base64');
hashes.push(`sha256-${hash}`);
const preview = content.trim().slice(0, 60).replaceAll('\n', ' ');
console.log(`Found: ${preview}...`);
console.log(` Hash: sha256-${hash}`);
console.log();
}
if (hashes.length === 0) {
console.log('No inline <script> elements found in app.html');
process.exit(0);
}
let config = readFileSync(configPath, 'utf-8');
const scriptSrcRegex = /'script-src':\s*\[[\s\S]*?\]/;
const scriptSrcMatch = config.match(scriptSrcRegex);
if (!scriptSrcMatch) {
console.error("Could not find 'script-src' directive in svelte.config.js");
process.exit(1);
}
const existingEntries = [];
const entryRegex = /'([^']+)'/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(scriptSrcMatch[0])) !== null) {
const value = entryMatch[1];
if (value === 'script-src' || value.startsWith('sha256-')) {
continue;
}
existingEntries.push(value);
}
const allEntries = [...existingEntries, ...hashes];
const formatted = allEntries.map((entry) => ` '${entry}'`).join(',\n');
const newScriptSrc = `'script-src': [\n${formatted},\n ]`;
config = config.replace(scriptSrcRegex, newScriptSrc);
writeFileSync(configPath, config);
console.log(`Updated svelte.config.js with ${hashes.length} script hash(es)`);

View File

@@ -16,8 +16,8 @@ config_roots = [
[tools]
node = "24.13.1"
flutter = "3.35.7"
pnpm = "10.30.0"
terragrunt = "0.98.0"
pnpm = "10.30.3"
terragrunt = "0.99.4"
opentofu = "1.11.4"
java = "21.0.2"

View File

@@ -7,6 +7,7 @@ import android.database.Cursor
import androidx.exifinterface.media.ExifInterface
import android.os.Build
import android.os.Bundle
import android.os.ext.SdkExtensions
import android.provider.MediaStore
import android.util.Base64
import android.util.Log
@@ -78,15 +79,22 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.IS_FAVORITE)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (hasSpecialFormatColumn()) {
add(SPECIAL_FORMAT_COLUMN)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Fallback: read XMP from MediaStore to detect Motion Photos
// only needed if SPECIAL_FORMAT column isn't available
add(MediaStore.MediaColumns.XMP)
}
}.toTypedArray()
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
// _special_format requires S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
private fun hasSpecialFormatColumn(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21
}
protected fun getCursor(

File diff suppressed because one or more lines are too long

View File

@@ -11,6 +11,10 @@ enum AssetType {
enum AssetState { local, remote, merged }
// do not change!
// keep in sync with PlatformAssetPlaybackStyle
enum AssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
sealed class BaseAsset {
final String name;
final String? checksum;
@@ -43,6 +47,14 @@ sealed class BaseAsset {
bool get isMotionPhoto => livePhotoVideoId != null;
AssetPlaybackStyle get playbackStyle {
if (isVideo) return AssetPlaybackStyle.video;
if (isMotionPhoto) return AssetPlaybackStyle.livePhoto;
if (isImage && durationInSeconds != null && durationInSeconds! > 0) return AssetPlaybackStyle.imageAnimated;
if (isImage) return AssetPlaybackStyle.image;
return AssetPlaybackStyle.unknown;
}
Duration get duration {
final durationInSeconds = this.durationInSeconds;
if (durationInSeconds != null) {

View File

@@ -5,6 +5,8 @@ class LocalAsset extends BaseAsset {
final String? remoteAssetId;
final String? cloudId;
final int orientation;
@override
final AssetPlaybackStyle playbackStyle;
final DateTime? adjustmentTime;
final double? latitude;
@@ -25,6 +27,7 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
required this.playbackStyle,
this.adjustmentTime,
this.latitude,
this.longitude,
@@ -56,6 +59,7 @@ class LocalAsset extends BaseAsset {
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
playbackStyle: $playbackStyle,
remoteId: ${remoteId ?? "<NA>"},
cloudId: ${cloudId ?? "<NA>"},
checksum: ${checksum ?? "<NA>"},
@@ -76,6 +80,7 @@ class LocalAsset extends BaseAsset {
id == other.id &&
cloudId == other.cloudId &&
orientation == other.orientation &&
playbackStyle == other.playbackStyle &&
adjustmentTime == other.adjustmentTime &&
latitude == other.latitude &&
longitude == other.longitude;
@@ -87,6 +92,7 @@ class LocalAsset extends BaseAsset {
id.hashCode ^
remoteId.hashCode ^
orientation.hashCode ^
playbackStyle.hashCode ^
adjustmentTime.hashCode ^
latitude.hashCode ^
longitude.hashCode;
@@ -105,6 +111,7 @@ class LocalAsset extends BaseAsset {
int? durationInSeconds,
bool? isFavorite,
int? orientation,
AssetPlaybackStyle? playbackStyle,
DateTime? adjustmentTime,
double? latitude,
double? longitude,
@@ -124,6 +131,7 @@ class LocalAsset extends BaseAsset {
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
playbackStyle: playbackStyle ?? this.playbackStyle,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,

View File

@@ -435,9 +435,19 @@ extension PlatformToLocalAsset on PlatformAsset {
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
orientation: orientation,
playbackStyle: _toPlaybackStyle(playbackStyle),
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude,
longitude: longitude,
isEdited: false,
);
}
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
};

View File

@@ -25,6 +25,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
RealColumn get longitude => real().nullable()();
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
}
@@ -43,6 +45,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
width: width,
remoteId: remoteId,
orientation: orientation,
playbackStyle: playbackStyle,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,

View File

@@ -25,6 +25,7 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
});
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({
@@ -43,6 +44,7 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
});
class $$LocalAssetEntityTableFilterComposer
@@ -129,6 +131,16 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.longitude,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnWithTypeConverterFilters<
i2.AssetPlaybackStyle,
i2.AssetPlaybackStyle,
int
>
get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
}
class $$LocalAssetEntityTableOrderingComposer
@@ -214,6 +226,11 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.longitude,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$LocalAssetEntityTableAnnotationComposer
@@ -277,6 +294,12 @@ class $$LocalAssetEntityTableAnnotationComposer
i0.GeneratedColumn<double> get longitude =>
$composableBuilder(column: $table.longitude, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => column,
);
}
class $$LocalAssetEntityTableTableManager
@@ -334,6 +357,8 @@ class $$LocalAssetEntityTableTableManager
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion(
name: name,
type: type,
@@ -350,6 +375,7 @@ class $$LocalAssetEntityTableTableManager
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
),
createCompanionCallback:
({
@@ -368,6 +394,8 @@ class $$LocalAssetEntityTableTableManager
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion.insert(
name: name,
type: type,
@@ -384,6 +412,7 @@ class $$LocalAssetEntityTableTableManager
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -596,6 +625,19 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
requiredDuringInsert: false,
);
@override
late final i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
playbackStyle =
i0.GeneratedColumn<int>(
'playback_style',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const i4.Constant(0),
).withConverter<i2.AssetPlaybackStyle>(
i1.$LocalAssetEntityTable.$converterplaybackStyle,
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
type,
@@ -612,6 +654,7 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
adjustmentTime,
latitude,
longitude,
playbackStyle,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -793,6 +836,12 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
i0.DriftSqlType.double,
data['${effectivePrefix}longitude'],
),
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}playback_style'],
)!,
),
);
}
@@ -803,6 +852,10 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
static i0.JsonTypeConverter2<i2.AssetType, int, int> $convertertype =
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
static i0.JsonTypeConverter2<i2.AssetPlaybackStyle, int, int>
$converterplaybackStyle = const i0.EnumIndexConverter<i2.AssetPlaybackStyle>(
i2.AssetPlaybackStyle.values,
);
@override
bool get withoutRowId => true;
@override
@@ -826,6 +879,7 @@ class LocalAssetEntityData extends i0.DataClass
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
final i2.AssetPlaybackStyle playbackStyle;
const LocalAssetEntityData({
required this.name,
required this.type,
@@ -842,6 +896,7 @@ class LocalAssetEntityData extends i0.DataClass
this.adjustmentTime,
this.latitude,
this.longitude,
required this.playbackStyle,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -881,6 +936,11 @@ class LocalAssetEntityData extends i0.DataClass
if (!nullToAbsent || longitude != null) {
map['longitude'] = i0.Variable<double>(longitude);
}
{
map['playback_style'] = i0.Variable<int>(
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
);
}
return map;
}
@@ -907,6 +967,9 @@ class LocalAssetEntityData extends i0.DataClass
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
serializer.fromJson<int>(json['playbackStyle']),
),
);
}
@override
@@ -930,6 +993,9 @@ class LocalAssetEntityData extends i0.DataClass
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
'playbackStyle': serializer.toJson<int>(
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
),
};
}
@@ -949,6 +1015,7 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
i2.AssetPlaybackStyle? playbackStyle,
}) => i1.LocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -969,6 +1036,7 @@ class LocalAssetEntityData extends i0.DataClass
: this.adjustmentTime,
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
);
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
return LocalAssetEntityData(
@@ -995,6 +1063,9 @@ class LocalAssetEntityData extends i0.DataClass
: this.adjustmentTime,
latitude: data.latitude.present ? data.latitude.value : this.latitude,
longitude: data.longitude.present ? data.longitude.value : this.longitude,
playbackStyle: data.playbackStyle.present
? data.playbackStyle.value
: this.playbackStyle,
);
}
@@ -1015,7 +1086,8 @@ class LocalAssetEntityData extends i0.DataClass
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle')
..write(')'))
.toString();
}
@@ -1037,6 +1109,7 @@ class LocalAssetEntityData extends i0.DataClass
adjustmentTime,
latitude,
longitude,
playbackStyle,
);
@override
bool operator ==(Object other) =>
@@ -1056,7 +1129,8 @@ class LocalAssetEntityData extends i0.DataClass
other.iCloudId == this.iCloudId &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
other.longitude == this.longitude &&
other.playbackStyle == this.playbackStyle);
}
class LocalAssetEntityCompanion
@@ -1076,6 +1150,7 @@ class LocalAssetEntityCompanion
final i0.Value<DateTime?> adjustmentTime;
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1092,6 +1167,7 @@ class LocalAssetEntityCompanion
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
});
LocalAssetEntityCompanion.insert({
required String name,
@@ -1109,6 +1185,7 @@ class LocalAssetEntityCompanion
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id);
@@ -1128,6 +1205,7 @@ class LocalAssetEntityCompanion
i0.Expression<DateTime>? adjustmentTime,
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
i0.Expression<int>? playbackStyle,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1145,6 +1223,7 @@ class LocalAssetEntityCompanion
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
if (playbackStyle != null) 'playback_style': playbackStyle,
});
}
@@ -1164,6 +1243,7 @@ class LocalAssetEntityCompanion
i0.Value<DateTime?>? adjustmentTime,
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
}) {
return i1.LocalAssetEntityCompanion(
name: name ?? this.name,
@@ -1181,6 +1261,7 @@ class LocalAssetEntityCompanion
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
);
}
@@ -1234,6 +1315,13 @@ class LocalAssetEntityCompanion
if (longitude.present) {
map['longitude'] = i0.Variable<double>(longitude.value);
}
if (playbackStyle.present) {
map['playback_style'] = i0.Variable<int>(
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(
playbackStyle.value,
),
);
}
return map;
}
@@ -1254,7 +1342,8 @@ class LocalAssetEntityCompanion
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle')
..write(')'))
.toString();
}

View File

@@ -26,7 +26,8 @@ SELECT
NULL as latitude,
NULL as longitude,
NULL as adjustmentTime,
rae.is_edited
rae.is_edited,
0 as playback_style
FROM
remote_asset_entity rae
LEFT JOIN
@@ -63,7 +64,8 @@ SELECT
lae.latitude,
lae.longitude,
lae.adjustment_time,
0 as is_edited
0 as is_edited,
lae.playback_style
FROM
local_asset_entity lae
WHERE NOT EXISTS (

View File

@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@@ -67,6 +67,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
longitude: row.readNullable<double>('longitude'),
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
isEdited: row.read<bool>('is_edited'),
playbackStyle: row.read<int>('playback_style'),
),
);
}
@@ -139,6 +140,7 @@ class MergedAssetResult {
final double? longitude;
final DateTime? adjustmentTime;
final bool isEdited;
final int playbackStyle;
MergedAssetResult({
this.remoteId,
this.localId,
@@ -161,6 +163,7 @@ class MergedAssetResult {
this.longitude,
this.adjustmentTime,
required this.isEdited,
required this.playbackStyle,
});
}

View File

@@ -28,6 +28,8 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity
IntColumn get source => intEnum<TrashOrigin>()();
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id, albumId};
}
@@ -45,6 +47,7 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
height: height,
width: width,
orientation: orientation,
playbackStyle: playbackStyle,
isEdited: false,
);
}

View File

@@ -23,6 +23,7 @@ typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder =
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
required i3.TrashOrigin source,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
});
typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
i1.TrashedLocalAssetEntityCompanion Function({
@@ -39,6 +40,7 @@ typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
i0.Value<i3.TrashOrigin> source,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
});
class $$TrashedLocalAssetEntityTableFilterComposer
@@ -117,6 +119,16 @@ class $$TrashedLocalAssetEntityTableFilterComposer
column: $table.source,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnWithTypeConverterFilters<
i2.AssetPlaybackStyle,
i2.AssetPlaybackStyle,
int
>
get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
}
class $$TrashedLocalAssetEntityTableOrderingComposer
@@ -193,6 +205,11 @@ class $$TrashedLocalAssetEntityTableOrderingComposer
column: $table.source,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$TrashedLocalAssetEntityTableAnnotationComposer
@@ -249,6 +266,12 @@ class $$TrashedLocalAssetEntityTableAnnotationComposer
i0.GeneratedColumnWithTypeConverter<i3.TrashOrigin, int> get source =>
$composableBuilder(column: $table.source, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
get playbackStyle => $composableBuilder(
column: $table.playbackStyle,
builder: (column) => column,
);
}
class $$TrashedLocalAssetEntityTableTableManager
@@ -310,6 +333,8 @@ class $$TrashedLocalAssetEntityTableTableManager
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<i3.TrashOrigin> source = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
}) => i1.TrashedLocalAssetEntityCompanion(
name: name,
type: type,
@@ -324,6 +349,7 @@ class $$TrashedLocalAssetEntityTableTableManager
isFavorite: isFavorite,
orientation: orientation,
source: source,
playbackStyle: playbackStyle,
),
createCompanionCallback:
({
@@ -340,6 +366,8 @@ class $$TrashedLocalAssetEntityTableTableManager
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
required i3.TrashOrigin source,
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
}) => i1.TrashedLocalAssetEntityCompanion.insert(
name: name,
type: type,
@@ -354,6 +382,7 @@ class $$TrashedLocalAssetEntityTableTableManager
isFavorite: isFavorite,
orientation: orientation,
source: source,
playbackStyle: playbackStyle,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -550,6 +579,19 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
i1.$TrashedLocalAssetEntityTable.$convertersource,
);
@override
late final i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
playbackStyle =
i0.GeneratedColumn<int>(
'playback_style',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const i4.Constant(0),
).withConverter<i2.AssetPlaybackStyle>(
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle,
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
type,
@@ -564,6 +606,7 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
isFavorite,
orientation,
source,
playbackStyle,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -720,6 +763,13 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
data['${effectivePrefix}source'],
)!,
),
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}playback_style'],
)!,
),
);
}
@@ -732,6 +782,10 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
static i0.JsonTypeConverter2<i3.TrashOrigin, int, int> $convertersource =
const i0.EnumIndexConverter<i3.TrashOrigin>(i3.TrashOrigin.values);
static i0.JsonTypeConverter2<i2.AssetPlaybackStyle, int, int>
$converterplaybackStyle = const i0.EnumIndexConverter<i2.AssetPlaybackStyle>(
i2.AssetPlaybackStyle.values,
);
@override
bool get withoutRowId => true;
@override
@@ -753,6 +807,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
final bool isFavorite;
final int orientation;
final i3.TrashOrigin source;
final i2.AssetPlaybackStyle playbackStyle;
const TrashedLocalAssetEntityData({
required this.name,
required this.type,
@@ -767,6 +822,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
required this.isFavorite,
required this.orientation,
required this.source,
required this.playbackStyle,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -800,6 +856,13 @@ class TrashedLocalAssetEntityData extends i0.DataClass
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source),
);
}
{
map['playback_style'] = i0.Variable<int>(
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
playbackStyle,
),
);
}
return map;
}
@@ -826,6 +889,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
source: i1.$TrashedLocalAssetEntityTable.$convertersource.fromJson(
serializer.fromJson<int>(json['source']),
),
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
.fromJson(serializer.fromJson<int>(json['playbackStyle'])),
);
}
@override
@@ -849,6 +914,11 @@ class TrashedLocalAssetEntityData extends i0.DataClass
'source': serializer.toJson<int>(
i1.$TrashedLocalAssetEntityTable.$convertersource.toJson(source),
),
'playbackStyle': serializer.toJson<int>(
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toJson(
playbackStyle,
),
),
};
}
@@ -866,6 +936,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
bool? isFavorite,
int? orientation,
i3.TrashOrigin? source,
i2.AssetPlaybackStyle? playbackStyle,
}) => i1.TrashedLocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -882,6 +953,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
source: source ?? this.source,
playbackStyle: playbackStyle ?? this.playbackStyle,
);
TrashedLocalAssetEntityData copyWithCompanion(
i1.TrashedLocalAssetEntityCompanion data,
@@ -906,6 +978,9 @@ class TrashedLocalAssetEntityData extends i0.DataClass
? data.orientation.value
: this.orientation,
source: data.source.present ? data.source.value : this.source,
playbackStyle: data.playbackStyle.present
? data.playbackStyle.value
: this.playbackStyle,
);
}
@@ -924,7 +999,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('source: $source')
..write('source: $source, ')
..write('playbackStyle: $playbackStyle')
..write(')'))
.toString();
}
@@ -944,6 +1020,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
isFavorite,
orientation,
source,
playbackStyle,
);
@override
bool operator ==(Object other) =>
@@ -961,7 +1038,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
other.checksum == this.checksum &&
other.isFavorite == this.isFavorite &&
other.orientation == this.orientation &&
other.source == this.source);
other.source == this.source &&
other.playbackStyle == this.playbackStyle);
}
class TrashedLocalAssetEntityCompanion
@@ -979,6 +1057,7 @@ class TrashedLocalAssetEntityCompanion
final i0.Value<bool> isFavorite;
final i0.Value<int> orientation;
final i0.Value<i3.TrashOrigin> source;
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
const TrashedLocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -993,6 +1072,7 @@ class TrashedLocalAssetEntityCompanion
this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.source = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
});
TrashedLocalAssetEntityCompanion.insert({
required String name,
@@ -1008,6 +1088,7 @@ class TrashedLocalAssetEntityCompanion
this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
required i3.TrashOrigin source,
this.playbackStyle = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id),
@@ -1027,6 +1108,7 @@ class TrashedLocalAssetEntityCompanion
i0.Expression<bool>? isFavorite,
i0.Expression<int>? orientation,
i0.Expression<int>? source,
i0.Expression<int>? playbackStyle,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1042,6 +1124,7 @@ class TrashedLocalAssetEntityCompanion
if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation,
if (source != null) 'source': source,
if (playbackStyle != null) 'playback_style': playbackStyle,
});
}
@@ -1059,6 +1142,7 @@ class TrashedLocalAssetEntityCompanion
i0.Value<bool>? isFavorite,
i0.Value<int>? orientation,
i0.Value<i3.TrashOrigin>? source,
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
}) {
return i1.TrashedLocalAssetEntityCompanion(
name: name ?? this.name,
@@ -1074,6 +1158,7 @@ class TrashedLocalAssetEntityCompanion
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
source: source ?? this.source,
playbackStyle: playbackStyle ?? this.playbackStyle,
);
}
@@ -1123,6 +1208,13 @@ class TrashedLocalAssetEntityCompanion
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source.value),
);
}
if (playbackStyle.present) {
map['playback_style'] = i0.Variable<int>(
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
playbackStyle.value,
),
);
}
return map;
}
@@ -1141,7 +1233,8 @@ class TrashedLocalAssetEntityCompanion
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('source: $source')
..write('source: $source, ')
..write('playbackStyle: $playbackStyle')
..write(')'))
.toString();
}

View File

@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 20;
int get schemaVersion => 21;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -230,6 +230,10 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
},
from20To21: (m, v21) async {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
},
),
);

View File

@@ -8904,6 +8904,591 @@ i1.GeneratedColumn<bool> _column_102(String aliasedName) =>
),
defaultValue: const CustomExpression('1'),
);
final class Schema21 extends i0.VersionedSchema {
Schema21({required super.database}) : super(version: 21);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxRemoteAlbumOwnerId,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetLocalDateTimeDay,
idxRemoteAssetLocalDateTimeMonth,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape28 remoteAssetEntity = Shape28(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
_column_101,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape30 localAssetEntity = Shape30(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_98,
_column_96,
_column_46,
_column_47,
_column_103,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
'idx_remote_album_owner_id',
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
'idx_remote_asset_local_date_time_day',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
);
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
'idx_remote_asset_local_date_time_month',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape29 assetFaceEntity = Shape29(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
_column_102,
_column_18,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape31 trashedLocalAssetEntity = Shape31(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
_column_97,
_column_103,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape30 extends i0.VersionedTable {
Shape30({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_103(String aliasedName) =>
i1.GeneratedColumn<int>(
'playback_style',
aliasedName,
false,
type: i1.DriftSqlType.int,
defaultValue: const CustomExpression('0'),
);
class Shape31 extends i0.VersionedTable {
Shape31({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get source =>
columnsByName['source']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -8924,6 +9509,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -9022,6 +9608,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from19To20(migrator, schema);
return 20;
case 20:
final schema = Schema21(database: database);
final migrator = i1.Migrator(database, schema);
await from20To21(migrator, schema);
return 21;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -9048,6 +9639,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -9069,5 +9661,6 @@ i1.OnUpgrade stepByStep({
from17To18: from17To18,
from18To19: from18To19,
from19To20: from19To20,
from20To21: from20To21,
),
);

View File

@@ -301,6 +301,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
id: asset.id,
orientation: Value(asset.orientation),
isFavorite: Value(asset.isFavorite),
playbackStyle: Value(asset.playbackStyle),
latitude: Value(asset.latitude),
longitude: Value(asset.longitude),
adjustmentTime: Value(asset.adjustmentTime),
@@ -333,6 +334,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
checksum: const Value(null),
orientation: Value(asset.orientation),
isFavorite: Value(asset.isFavorite),
playbackStyle: Value(asset.playbackStyle),
);
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
_db.localAssetEntity,

View File

@@ -101,6 +101,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds,
orientation: row.orientation,
playbackStyle: AssetPlaybackStyle.values[row.playbackStyle],
cloudId: row.iCloudId,
latitude: row.latitude,
longitude: row.longitude,

View File

@@ -85,6 +85,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
durationInSeconds: Value(item.asset.durationInSeconds),
isFavorite: Value(item.asset.isFavorite),
orientation: Value(item.asset.orientation),
playbackStyle: Value(item.asset.playbackStyle),
source: TrashOrigin.localSync,
);
@@ -147,6 +148,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
durationInSeconds: Value(asset.durationInSeconds),
isFavorite: Value(asset.isFavorite),
orientation: Value(asset.orientation),
playbackStyle: Value(asset.playbackStyle),
createdAt: Value(asset.createdAt),
updatedAt: Value(asset.updatedAt),
source: const Value(TrashOrigin.remoteSync),
@@ -195,6 +197,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
checksum: Value(e.checksum),
isFavorite: Value(e.isFavorite),
orientation: Value(e.orientation),
playbackStyle: Value(e.playbackStyle),
);
});
@@ -245,6 +248,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
checksum: Value(e.asset.checksum),
isFavorite: Value(e.asset.isFavorite),
orientation: Value(e.asset.orientation),
playbackStyle: Value(e.asset.playbackStyle),
source: TrashOrigin.localUser,
albumId: e.albumId,
);

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -11,7 +12,8 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
@@ -129,11 +131,16 @@ class _BottomPanelState extends State<_BottomPanel> {
return;
}
final db = Drift();
try {
await db.reset();
} finally {
await db.close();
final dir = await getApplicationDocumentsDirectory();
for (final suffix in ['', '-wal', '-shm']) {
final file = File(path.join(dir.path, 'immich.sqlite$suffix'));
if (await file.exists()) {
await file.delete();
}
}
} catch (_) {
return;
}
if (mounted) {

View File

@@ -18,11 +18,10 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -53,7 +52,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
double _snapOffset = 0.0;
@@ -79,7 +77,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_proxyScrollController.dispose();
_scaleBoundarySub?.cancel();
_eventSubscription?.cancel();
_videoScaleStateNotifier.dispose();
super.dispose();
}
@@ -249,17 +246,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
_isZoomed =
scaleState == PhotoViewScaleState.zoomedIn ||
scaleState == PhotoViewScaleState.covering ||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
_isZoomed = switch (scaleState) {
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
_ => false,
};
_viewer.setZoomed(_isZoomed);
if (scaleState != PhotoViewScaleState.initial) {
if (_dragStart == null) _viewer.setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
@@ -334,31 +328,36 @@ class _AssetPageState extends ConsumerState<AssetPage> {
);
}
final Size childSize;
if (displayAsset.width != null && displayAsset.height != null) {
final r = displayAsset.width! / displayAsset.height!;
final w = math.min(context.width, context.height * r);
childSize = Size(w, w / r);
} else {
childSize = Size(context.height, context.height);
}
return PhotoView.customChild(
key: Key(displayAsset.heroTag),
childSize: childSize,
filterQuality: FilterQuality.low,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
heroAttributes: heroAttributes,
filterQuality: FilterQuality.high,
basePosition: Alignment.center,
disableScaleGestures: true,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
tightMode: true,
disableScaleGestures: showingDetails,
scaleStateChangedCallback: _onScaleStateChanged,
onPageBuild: _onPageBuild,
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer(
key: _NativeVideoViewerKey(displayAsset.heroTag),
asset: displayAsset,
scaleStateNotifier: _videoScaleStateNotifier,
disableScaleGestures: showingDetails,
image: Image(
image: getFullImageProvider(displayAsset, size: context.sizeData),
height: context.height,
width: context.width,
image: getFullImageProvider(displayAsset, size: childSize),
fit: BoxFit.contain,
alignment: Alignment.center,
),

View File

@@ -18,8 +18,8 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widge
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';

View File

@@ -9,11 +9,10 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
@@ -26,7 +25,6 @@ import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -51,21 +49,10 @@ bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
class NativeVideoViewer extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
final BaseAsset asset;
final bool showControls;
final int playbackDelayFactor;
final Widget image;
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
final bool disableScaleGestures;
const NativeVideoViewer({
super.key,
required this.asset,
required this.image,
this.showControls = true,
this.playbackDelayFactor = 1,
this.scaleStateNotifier,
this.disableScaleGestures = false,
});
const NativeVideoViewer({super.key, required this.asset, required this.image, this.playbackDelayFactor = 1});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -144,7 +131,6 @@ class NativeVideoViewer extends HookConsumerWidget {
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(null);
useMemoized(() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
@@ -320,20 +306,6 @@ class NativeVideoViewer extends HookConsumerWidget {
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) {
Size? videoContextSize;
if (videoAspectRatio == null || context == null) {
return null;
}
final contextAspectRatio = context.width / context.height;
if (videoAspectRatio > contextAspectRatio) {
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
} else {
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
}
return videoContextSize;
}
ref.listen(currentAssetNotifier, (_, value) {
final playerController = controller.value;
if (playerController != null && value != asset) {
@@ -414,29 +386,18 @@ class NativeVideoViewer extends HookConsumerWidget {
}
});
return SizedBox(
width: context.width,
height: context.height,
child: Stack(
children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
if (!isVisible.value || controller.value == null) Center(child: image),
if (aspectRatio.value != null && !isCasting && isCurrent)
Visibility.maintain(
visible: isVisible.value,
child: PhotoView.customChild(
enableRotation: false,
disableScaleGestures: disableScaleGestures,
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
childSize: videoContextSize(aspectRatio.value, context),
child: NativeVideoPlayerView(onViewReady: initController),
),
),
if (showControls) const Center(child: VideoViewerControls()),
],
),
return Stack(
children: [
// This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset
Center(child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain(
visible: isVisible.value,
child: NativeVideoPlayerView(onViewReady: initController),
),
const Center(child: VideoViewerControls()),
],
);
}

View File

@@ -58,7 +58,6 @@ class DriftMemoryCard extends StatelessWidget {
child: NativeVideoViewer(
key: ValueKey(asset.id),
asset: asset,
showControls: false,
playbackDelayFactor: 2,
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
),

View File

@@ -25,6 +25,7 @@ class FileMediaRepository {
type: AssetType.image,
createdAt: entity.createDateTime,
updatedAt: entity.modifiedDateTime,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}

View File

@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
@@ -17,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
@@ -33,7 +35,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 22;
const int targetVersion = 23;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -99,6 +101,10 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
}
}
if (version < 23 && Store.isBetaTimelineEnabled) {
await _populateLocalAssetPlaybackStyle(drift);
}
if (version < 22 && !Store.isBetaTimelineEnabled) {
await Store.put(StoreKey.needBetaMigration, true);
}
@@ -392,6 +398,52 @@ Future<void> migrateStoreToIsar(Isar db, Drift drift) async {
}
}
Future<void> _populateLocalAssetPlaybackStyle(Drift db) async {
try {
final nativeApi = NativeSyncApi();
final albums = await nativeApi.getAlbums();
for (final album in albums) {
final assets = await nativeApi.getAssetsForAlbum(album.id);
await db.batch((batch) {
for (final asset in assets) {
batch.update(
db.localAssetEntity,
LocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
where: (t) => t.id.equals(asset.id),
);
}
});
}
final trashedAssetMap = await nativeApi.getTrashedAssets();
for (final assets in trashedAssetMap.values) {
await db.batch((batch) {
for (final asset in assets) {
batch.update(
db.trashedLocalAssetEntity,
TrashedLocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
where: (t) => t.id.equals(asset.id),
);
}
});
}
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets");
} catch (error) {
dPrint(() => "[MIGRATION] Error while populating playbackStyle: $error");
}
}
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
};
class _DeviceAsset {
final String assetId;
final List<int>? hash;

View File

@@ -420,7 +420,11 @@ class PhotoViewCoreState extends State<PhotoViewCore>
Widget _buildChild() {
return widget.hasCustomChild
? widget.customChild!
? SizedBox(
width: scaleBoundaries.childSize.width * scale,
height: scaleBoundaries.childSize.height * scale,
child: widget.customChild!,
)
: Image(
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
image: widget.imageProvider!,
@@ -428,7 +432,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
gaplessPlayback: widget.gaplessPlayback ?? false,
filterQuality: widget.filterQuality,
width: scaleBoundaries.childSize.width * scale,
fit: BoxFit.cover,
fit: BoxFit.contain,
isAntiAlias: widget.filterQuality == FilterQuality.high,
);
}

View File

@@ -23,6 +23,7 @@ import 'schema_v17.dart' as v17;
import 'schema_v18.dart' as v18;
import 'schema_v19.dart' as v19;
import 'schema_v20.dart' as v20;
import 'schema_v21.dart' as v21;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -68,6 +69,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v19.DatabaseAtV19(db);
case 20:
return v20.DatabaseAtV20(db);
case 21:
return v21.DatabaseAtV21(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -94,5 +97,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
18,
19,
20,
21,
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,7 @@ abstract final class LocalAssetStub {
type: AssetType.image,
createdAt: DateTime(2025),
updatedAt: DateTime(2025, 2),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
@@ -73,6 +74,7 @@ abstract final class LocalAssetStub {
type: AssetType.image,
createdAt: DateTime(2000),
updatedAt: DateTime(20021),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}

View File

@@ -194,6 +194,7 @@ void main() {
latitude: 37.7749,
longitude: -122.4194,
adjustmentTime: DateTime(2026, 1, 2),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
@@ -243,6 +244,7 @@ void main() {
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
@@ -281,6 +283,7 @@ void main() {
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: null, // No cloudId
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
@@ -323,6 +326,7 @@ void main() {
cloudId: 'cloud-id-livephoto',
latitude: 37.7749,
longitude: -122.4194,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);

View File

@@ -155,6 +155,7 @@ abstract final class TestUtils {
width: width,
height: height,
orientation: orientation,
playbackStyle: domain.AssetPlaybackStyle.image,
isEdited: false,
);
}

View File

@@ -27,6 +27,7 @@ class MediumFactory {
type: type ?? AssetType.image,
createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}

View File

@@ -23,6 +23,7 @@ LocalAsset createLocalAsset({
createdAt: createdAt ?? DateTime.now(),
updatedAt: updatedAt ?? DateTime.now(),
isFavorite: isFavorite,
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}

View File

@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.10.13",
"@types/node": "^24.10.14",
"typescript": "^5.3.3"
},
"repository": {

View File

@@ -3,7 +3,7 @@
"version": "2.5.6",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
"engines": {
"pnpm": ">=10.0.0"
}

1836
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,8 +57,7 @@
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2",
"@socket.io/postgres-adapter": "^0.5.0",
"@types/pg": "^8.16.0",
"@socket.io/redis-adapter": "^8.3.0",
"ajv": "^8.17.1",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
@@ -110,7 +109,6 @@
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.6",
"tailwindcss-preset-email": "^1.4.0",
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
@@ -138,7 +136,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.13",
"@types/node": "^24.10.14",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",

View File

@@ -5,9 +5,8 @@ import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { excludePaths, serverVersion } from 'src/constants';
import { SocketIoAdapter } from 'src/enum';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { createWebSocketAdapter } from 'src/middleware/websocket.adapter';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
@@ -26,7 +25,6 @@ export async function configureExpress(
{
permitSwaggerWrite = true,
ssr,
socketIoAdapter,
}: {
/**
* Whether to allow swagger module to write to the specs.json
@@ -38,10 +36,6 @@ export async function configureExpress(
* Service to use for server-side rendering
*/
ssr: typeof ApiService | typeof MaintenanceWorkerService;
/**
* Override the Socket.IO adapter. If not specified, uses the adapter from config.
*/
socketIoAdapter?: SocketIoAdapter;
},
) {
const configRepository = app.get(ConfigRepository);
@@ -61,7 +55,7 @@ export async function configureExpress(
}
app.setGlobalPrefix('api', { exclude: excludePaths });
app.useWebSocketAdapter(await createWebSocketAdapter(app, socketIoAdapter));
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });

View File

@@ -10,7 +10,6 @@ import { DatabaseBackupController } from 'src/controllers/database-backup.contro
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { InternalController } from 'src/controllers/internal.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MaintenanceController } from 'src/controllers/maintenance.controller';
@@ -52,7 +51,6 @@ export const controllers = [
DownloadController,
DuplicateController,
FaceController,
InternalController,
JobController,
LibraryController,
MaintenanceController,

View File

@@ -1,22 +0,0 @@
import { Body, Controller, NotFoundException, Post, Req } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import { Request } from 'express';
import { AppRestartEvent, EventRepository } from 'src/repositories/event.repository';
const LOCALHOST_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
@ApiExcludeController()
@Controller('internal')
export class InternalController {
constructor(private eventRepository: EventRepository) {}
@Post('restart')
async restart(@Req() req: Request, @Body() dto: AppRestartEvent): Promise<void> {
const remoteAddress = req.socket.remoteAddress;
if (!remoteAddress || !LOCALHOST_ADDRESSES.has(remoteAddress)) {
throw new NotFoundException();
}
await this.eventRepository.emit('AppRestart', dto);
}
}

View File

@@ -1,6 +1,6 @@
import { Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
import { ImmichEnvironment, LogFormat, LogLevel, SocketIoAdapter } from 'src/enum';
import { ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
// TODO import from sql-tools once the swagger plugin supports external enums
@@ -149,11 +149,6 @@ export class EnvDto {
@Optional()
IMMICH_WORKERS_EXCLUDE?: string;
@IsEnum(SocketIoAdapter)
@Optional()
@Transform(({ value }) => (value ? String(value).toLowerCase().trim() : value))
IMMICH_SOCKETIO_ADAPTER?: SocketIoAdapter;
@IsString()
@Optional()
DB_DATABASE_NAME?: string;

View File

@@ -518,11 +518,6 @@ export enum ImmichTelemetry {
Job = 'job',
}
export enum SocketIoAdapter {
BroadcastChannel = 'broadcastchannel',
Postgres = 'postgres',
}
export enum ExifOrientation {
Horizontal = 1,
MirrorHorizontal = 2,

View File

@@ -1,5 +1,6 @@
import { Kysely, sql } from 'kysely';
import { CommandFactory } from 'nest-commander';
import { ChildProcess, fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { PostgresError } from 'postgres';
@@ -17,7 +18,7 @@ class Workers {
/**
* Currently running workers
*/
workers: Partial<Record<ImmichWorker, { kill: () => Promise<void> | void }>> = {};
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
/**
* Fail-safe in case anything dies during restart
@@ -100,23 +101,25 @@ class Workers {
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
const inspectArg = process.execArgv.find((arg) => arg.startsWith('--inspect'));
const workerData: { inspectorPort?: number } = {};
let anyWorker: Worker | ChildProcess;
let kill: (signal?: NodeJS.Signals) => Promise<void> | void;
if (inspectArg) {
const inspectorPorts: Record<ImmichWorker, number> = {
[ImmichWorker.Api]: 9230,
[ImmichWorker.Microservices]: 9231,
[ImmichWorker.Maintenance]: 9232,
};
workerData.inspectorPort = inspectorPorts[name];
if (name === ImmichWorker.Api) {
const worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
kill = (signal) => void worker.kill(signal);
anyWorker = worker;
} else {
const worker = new Worker(workerFile);
kill = async () => void (await worker.terminate());
anyWorker = worker;
}
const worker = new Worker(workerFile, { workerData });
const kill = async () => void (await worker.terminate());
worker.on('error', (error) => this.onError(name, error));
worker.on('exit', (exitCode) => this.onExit(name, exitCode));
anyWorker.on('error', (error) => this.onError(name, error));
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
this.workers[name] = { kill };
}
@@ -149,8 +152,8 @@ class Workers {
console.error(`${name} worker exited with code ${exitCode}`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
console.error('Terminating api worker');
void this.workers[ImmichWorker.Api].kill();
console.error('Killing api process');
void this.workers[ImmichWorker.Api].kill('SIGTERM');
}
}

View File

@@ -4,7 +4,6 @@ import {
Delete,
Get,
Next,
NotFoundException,
Param,
Post,
Req,
@@ -26,15 +25,12 @@ import { ImmichCookie } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { GetLoginDetails } from 'src/middleware/auth.guard';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { LoginDetails } from 'src/services/auth.service';
import { sendFile } from 'src/utils/file';
import { respondWithCookie } from 'src/utils/response';
import { FilenameParamDto } from 'src/validation';
const LOCALHOST_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
import type { DatabaseBackupController as _DatabaseBackupController } from 'src/controllers/database-backup.controller';
import type { ServerController as _ServerController } from 'src/controllers/server.controller';
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
@@ -135,14 +131,4 @@ export class MaintenanceWorkerController {
setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): void {
void this.service.setAction(dto);
}
@Post('internal/restart')
internalRestart(@Req() req: Request, @Body() dto: AppRestartEvent): void {
const remoteAddress = req.socket.remoteAddress;
if (!remoteAddress || !LOCALHOST_ADDRESSES.has(remoteAddress)) {
throw new NotFoundException();
}
this.service.handleInternalRestart(dto);
}
}

View File

@@ -19,7 +19,6 @@ import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-webs
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
@@ -291,9 +290,6 @@ export class MaintenanceWorkerService {
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
if (!lock) {
// Another maintenance worker has the lock - poll until maintenance mode ends
this.logger.log('Another worker has the maintenance lock, polling for maintenance mode changes...');
await this.pollForMaintenanceEnd();
return;
}
@@ -355,25 +351,4 @@ export class MaintenanceWorkerService {
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
handleInternalRestart(state: AppRestartEvent): void {
this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
private async pollForMaintenanceEnd(): Promise<void> {
const pollIntervalMs = 5000;
while (true) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const state = await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
if (!state?.isMaintenanceMode) {
this.logger.log('Maintenance mode ended, restarting...');
this.appRepository.exitApp();
return;
}
}
}
}

View File

@@ -1,80 +0,0 @@
import {
ClusterAdapterWithHeartbeat,
type ClusterAdapterOptions,
type ClusterMessage,
type ClusterResponse,
type ServerId,
} from 'socket.io-adapter';
const BC_CHANNEL_NAME = 'immich:socketio';
interface BroadcastChannelPayload {
type: 'message' | 'response';
sourceUid: string;
targetUid?: string;
data: unknown;
}
/**
* Socket.IO adapter using Node.js BroadcastChannel
*
* Relays messages between worker_threads within a single OS process.
* Zero external dependencies. Does NOT work across containers — use
* the Postgres adapter for multi-replica deployments.
*/
class BroadcastChannelAdapter extends ClusterAdapterWithHeartbeat {
private readonly channel: BroadcastChannel;
constructor(nsp: any, opts?: Partial<ClusterAdapterOptions>) {
super(nsp, opts ?? {});
this.channel = new BroadcastChannel(BC_CHANNEL_NAME);
this.channel.addEventListener('message', (event: MessageEvent<BroadcastChannelPayload>) => {
const msg = event.data;
if (msg.sourceUid === this.uid) {
return;
}
if (msg.type === 'message') {
this.onMessage(msg.data as ClusterMessage);
} else if (msg.type === 'response' && msg.targetUid === this.uid) {
this.onResponse(msg.data as ClusterResponse);
}
});
this.init();
}
override doPublish(message: ClusterMessage): Promise<string> {
this.channel.postMessage({
type: 'message',
sourceUid: this.uid,
data: message,
});
return Promise.resolve('');
}
override doPublishResponse(requesterUid: ServerId, response: ClusterResponse): Promise<void> {
this.channel.postMessage({
type: 'response',
sourceUid: this.uid,
targetUid: requesterUid,
data: response,
});
return Promise.resolve();
}
override close(): void {
super.close();
this.channel.close();
}
}
export function createBroadcastChannelAdapter(opts?: Partial<ClusterAdapterOptions>) {
const options: Partial<ClusterAdapterOptions> = {
...opts,
};
return function (nsp: any) {
return new BroadcastChannelAdapter(nsp, options);
};
}

View File

@@ -1,103 +1,21 @@
import { INestApplication, Logger } from '@nestjs/common';
import { INestApplicationContext } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { Pool, PoolConfig } from 'pg';
import type { ServerOptions } from 'socket.io';
import { SocketIoAdapter } from 'src/enum';
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis';
import { ServerOptions } from 'socket.io';
import { ConfigRepository } from 'src/repositories/config.repository';
import { asPostgresConnectionConfig } from 'src/utils/database';
export type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
export function asPgPoolSsl(ssl?: Ssl): PoolConfig['ssl'] {
if (ssl === undefined || ssl === false || ssl === 'allow') {
return false;
}
if (ssl === true || ssl === 'prefer' || ssl === 'require') {
return { rejectUnauthorized: false };
}
if (ssl === 'verify-full') {
return { rejectUnauthorized: true };
}
return ssl;
}
class BroadcastChannelSocketAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createBroadcastChannelAdapter>;
constructor(app: INestApplication) {
export class WebSocketAdapter extends IoAdapter {
constructor(private app: INestApplicationContext) {
super(app);
this.adapterConstructor = createBroadcastChannelAdapter();
}
createIOServer(port: number, options?: ServerOptions): any {
const { redis } = this.app.get(ConfigRepository).getEnv();
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
return server;
}
}
class PostgresSocketAdapter extends IoAdapter {
private adapterConstructor: any;
constructor(app: INestApplication, adapterConstructor: any) {
super(app);
this.adapterConstructor = adapterConstructor;
}
createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
return server;
}
}
export async function createWebSocketAdapter(
app: INestApplication,
adapterOverride?: SocketIoAdapter,
): Promise<IoAdapter> {
const logger = new Logger('WebSocketAdapter');
const config = new ConfigRepository();
const { database, socketIo } = config.getEnv();
const adapter = adapterOverride ?? socketIo.adapter;
switch (adapter) {
case SocketIoAdapter.Postgres: {
logger.log('Using Postgres Socket.IO adapter');
const { createAdapter } = await import('@socket.io/postgres-adapter');
const config = asPostgresConnectionConfig(database.config);
const pool = new Pool({
host: config.host,
port: config.port,
user: config.username,
password: config.password,
database: config.database,
ssl: asPgPoolSsl(config.ssl),
max: 2,
});
await pool.query(`
CREATE TABLE IF NOT EXISTS socket_io_attachments (
id bigserial UNIQUE,
created_at timestamptz DEFAULT NOW(),
payload bytea
);
`);
pool.on('error', (error) => {
logger.error(' Postgres pool error', error);
});
const adapterConstructor = createAdapter(pool);
return new PostgresSocketAdapter(app, adapterConstructor);
}
case SocketIoAdapter.BroadcastChannel: {
logger.log('Using BroadcastChannel Socket.IO adapter');
return new BroadcastChannelSocketAdapter(app);
}
}
}

View File

@@ -102,22 +102,30 @@ order by
"shared_link"."createdAt" desc
-- SharedLinkRepository.getAll
select distinct
on ("shared_link"."createdAt") "shared_link".*,
"assets"."assets",
select
"shared_link".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset".*
from
"shared_link_asset"
inner join "asset" on "asset"."id" = "shared_link_asset"."assetId"
where
"shared_link"."id" = "shared_link_asset"."sharedLinkId"
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" asc
limit
$1
) as agg
) as "assets",
to_json("album") as "album"
from
"shared_link"
left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id"
left join lateral (
select
json_agg("asset") as "assets"
from
"asset"
where
"asset"."id" = "shared_link_asset"."assetId"
and "asset"."deletedAt" is null
) as "assets" on true
left join lateral (
select
"album".*,
@@ -152,12 +160,12 @@ from
and "album"."deletedAt" is null
) as "album" on true
where
"shared_link"."userId" = $1
"shared_link"."userId" = $2
and (
"shared_link"."type" = $2
"shared_link"."type" = $3
or "album"."id" is not null
)
and "shared_link"."albumId" = $3
and "shared_link"."albumId" = $4
order by
"shared_link"."createdAt" desc

View File

@@ -1,4 +1,7 @@
import { Injectable } from '@nestjs/common';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { Server as SocketIO } from 'socket.io';
import { ExitCode } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
@@ -21,17 +24,24 @@ export class AppRepository {
}
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
const { port } = new ConfigRepository().getEnv();
const url = `http://127.0.0.1:${port}/api/internal/restart`;
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis({ ...redis, lazyConnect: true });
const subClient = pubClient.duplicate();
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state),
await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, async () => {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.some((response) => response !== 'ok')) {
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
}
pubClient.disconnect();
subClient.disconnect();
});
if (!response.ok) {
throw new Error(`Failed to trigger app restart: ${response.status} ${response.statusText}`);
}
}
}

View File

@@ -21,7 +21,6 @@ import {
LogFormat,
LogLevel,
QueueName,
SocketIoAdapter,
} from 'src/enum';
import { VectorExtension } from 'src/types';
import { setDifference } from 'src/utils/set';
@@ -118,10 +117,6 @@ export interface EnvData {
};
};
socketIo: {
adapter: SocketIoAdapter;
};
noColor: boolean;
nodeVersion?: string;
}
@@ -352,10 +347,6 @@ const getEnv = (): EnvData => {
},
},
socketIo: {
adapter: dto.IMMICH_SOCKETIO_ADAPTER ?? SocketIoAdapter.Postgres,
},
noColor: !!dto.NO_COLOR,
};
};

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database';
@@ -124,19 +124,20 @@ export class SharedLinkRepository {
.selectFrom('shared_link')
.selectAll('shared_link')
.where('shared_link.userId', '=', userId)
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id')
.leftJoinLateral(
(eb) =>
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('asset')
.select((eb) => eb.fn.jsonAgg('asset').as('assets'))
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
.selectFrom('shared_link_asset')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.as('assets'),
(join) => join.onTrue(),
.selectAll('asset')
.orderBy('asset.fileCreatedAt', 'asc')
.limit(1),
)
.$castTo<MapAsset[]>()
.as('assets'),
)
.select('assets.assets')
.$narrowType<{ assets: NotNull }>()
.leftJoinLateral(
(eb) =>
eb
@@ -179,7 +180,6 @@ export class SharedLinkRepository {
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
.orderBy('shared_link.createdAt', 'desc')
.distinctOn(['shared_link.createdAt'])
.execute();
}

View File

@@ -566,6 +566,8 @@ describe(AssetService.name, () => {
.file({ type: AssetFileType.Thumbnail })
.file({ type: AssetFileType.Preview })
.file({ type: AssetFileType.FullSize })
.file({ type: AssetFileType.Preview, isEdited: true })
.file({ type: AssetFileType.Thumbnail, isEdited: true })
.build();
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);

View File

@@ -25,7 +25,7 @@ export const getAssetFiles = (files: AssetFile[]) => ({
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
});
export const addAssets = async (

View File

@@ -404,6 +404,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
)
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
.$if(!!options.withExif, withExifInner)
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));

View File

@@ -1,11 +1,60 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { Server as SocketIO } from 'socket.io';
import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
import { StorageFolder } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
export function sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
/**
* Keep trying until we manage to stop Immich
*
* Sometimes there appear to be communication
* issues between to the other servers.
*
* This issue only occurs with this method.
*/
async function tryTerminate() {
while (true) {
try {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.length > 0) {
return;
}
} catch (error) {
console.error(error);
console.error('Encountered an error while telling Immich to stop.');
}
console.info(
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
);
await new Promise((r) => setTimeout(r, 1e3));
}
}
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
void tryTerminate().finally(() => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
export async function createMaintenanceLoginUrl(
baseUrl: string,
auth: MaintenanceAuthDto,

View File

@@ -1,21 +1,14 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import inspector from 'node:inspector';
import { isMainThread, workerData } from 'node:worker_threads';
import { configureExpress, configureTelemetry } from 'src/app.common';
import { ApiModule } from 'src/app.module';
import { AppRepository } from 'src/repositories/app.repository';
import { ApiService } from 'src/services/api.service';
import { isStartUpError } from 'src/utils/misc';
export async function bootstrap() {
async function bootstrap() {
process.title = 'immich-api';
const { inspectorPort } = workerData ?? {};
if (inspectorPort) {
inspector.open(inspectorPort, '0.0.0.0', false);
}
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
@@ -26,12 +19,10 @@ export async function bootstrap() {
});
}
if (!isMainThread || process.send) {
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
process.exit(1);
});
}
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});

View File

@@ -1,22 +1,13 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import inspector from 'node:inspector';
import { isMainThread, workerData } from 'node:worker_threads';
import { configureExpress, configureTelemetry } from 'src/app.common';
import { MaintenanceModule } from 'src/app.module';
import { SocketIoAdapter } from 'src/enum';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AppRepository } from 'src/repositories/app.repository';
import { isStartUpError } from 'src/utils/misc';
export async function bootstrap() {
async function bootstrap() {
process.title = 'immich-maintenance';
const { inspectorPort } = workerData ?? {};
if (inspectorPort) {
inspector.open(inspectorPort, '0.0.0.0', false);
}
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
@@ -25,18 +16,13 @@ export async function bootstrap() {
void configureExpress(app, {
permitSwaggerWrite: false,
ssr: MaintenanceWorkerService,
// Use BroadcastChannel instead of Postgres adapter to avoid crash when
// pg_terminate_backend() kills all database connections during restore
socketIoAdapter: SocketIoAdapter.BroadcastChannel,
});
}
if (!isMainThread) {
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
process.exit(1);
});
}
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});

View File

@@ -1,9 +1,8 @@
import { NestFactory } from '@nestjs/core';
import inspector from 'node:inspector';
import { isMainThread, workerData } from 'node:worker_threads';
import { isMainThread } from 'node:worker_threads';
import { MicroservicesModule } from 'src/app.module';
import { serverVersion } from 'src/constants';
import { createWebSocketAdapter } from 'src/middleware/websocket.adapter';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -11,11 +10,6 @@ import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { isStartUpError } from 'src/utils/misc';
export async function bootstrap() {
const { inspectorPort } = workerData ?? {};
if (inspectorPort) {
inspector.open(inspectorPort, '0.0.0.0', false);
}
const { telemetry } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
bootstrapTelemetry(telemetry.microservicesPort);
@@ -30,7 +24,7 @@ export async function bootstrap() {
logger.setContext('Bootstrap');
app.useLogger(logger);
app.useWebSocketAdapter(await createWebSocketAdapter(app));
app.useWebSocketAdapter(new WebSocketAdapter(app));
await (host ? app.listen(0, host) : app.listen(0));

View File

@@ -233,6 +233,14 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { albumUser: { albumId, userId, role }, result };
}
async softDeleteAsset(assetId: string) {
await this.database.updateTable('asset').set({ deletedAt: new Date() }).where('id', '=', assetId).execute();
}
async softDeleteAlbum(albumId: string) {
await this.database.updateTable('album').set({ deletedAt: new Date() }).where('id', '=', albumId).execute();
}
async newJobStatus(dto: Partial<Insertable<AssetJobStatusTable>> & { assetId: string }) {
const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: dto.assetId });
const result = await this.get(AssetRepository).upsertJobStatus(jobStatus);

View File

@@ -1,276 +0,0 @@
import { ClusterMessage, ClusterResponse } from 'socket.io-adapter';
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
import { vi } from 'vitest';
const createMockNamespace = () => ({
name: '/',
sockets: new Map(),
adapter: null,
server: {
encoder: {
encode: vi.fn().mockReturnValue([]),
},
_opts: {},
sockets: {
sockets: new Map(),
},
},
});
describe('BroadcastChannelAdapter', () => {
describe('createBroadcastChannelAdapter', () => {
it('should return a factory function', () => {
const factory = createBroadcastChannelAdapter();
expect(typeof factory).toBe('function');
});
it('should create adapter instance when factory is called', () => {
const mockNamespace = createMockNamespace();
const factory = createBroadcastChannelAdapter();
const adapter = factory(mockNamespace);
expect(adapter).toBeDefined();
expect(adapter.doPublish).toBeDefined();
expect(adapter.doPublishResponse).toBeDefined();
adapter.close();
});
});
describe('BroadcastChannelAdapter message passing', () => {
it('should actually send and receive messages between two adapters', async () => {
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedMessages: ClusterMessage[] = [];
const messageReceived = new Promise<void>((resolve) => {
const originalOnMessage = adapter2.onMessage.bind(adapter2);
adapter2.onMessage = (message: ClusterMessage) => {
receivedMessages.push(message);
resolve();
return originalOnMessage(message);
};
});
const testMessage = {
type: 2,
data: {
opts: { rooms: new Set(['room1']) },
rooms: ['room1'],
},
nsp: '/',
};
void adapter1.doPublish(testMessage as any);
await Promise.race([messageReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
expect(receivedMessages.length).toBeGreaterThan(0);
adapter1.close();
adapter2.close();
});
it('should send ConfigUpdate-style event and receive it on another adapter', async () => {
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedMessages: ClusterMessage[] = [];
const messageReceived = new Promise<void>((resolve) => {
const originalOnMessage = adapter2.onMessage.bind(adapter2);
adapter2.onMessage = (message: ClusterMessage) => {
receivedMessages.push(message);
if ((message as any)?.data?.event === 'ConfigUpdate') {
resolve();
}
return originalOnMessage(message);
};
});
const configUpdateMessage = {
type: 2,
data: {
event: 'ConfigUpdate',
payload: { newConfig: { ffmpeg: { crf: 23 } }, oldConfig: { ffmpeg: { crf: 20 } } },
opts: { rooms: new Set() },
rooms: [],
},
nsp: '/',
};
void adapter1.doPublish(configUpdateMessage as any);
await Promise.race([messageReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
const configMessages = receivedMessages.filter((m) => (m as any)?.data?.event === 'ConfigUpdate');
expect(configMessages.length).toBeGreaterThan(0);
expect((configMessages[0] as any).data.payload.newConfig.ffmpeg.crf).toBe(23);
adapter1.close();
adapter2.close();
});
it('should send AppRestart-style event and receive it on another adapter', async () => {
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedMessages: ClusterMessage[] = [];
const messageReceived = new Promise<void>((resolve) => {
const originalOnMessage = adapter2.onMessage.bind(adapter2);
adapter2.onMessage = (message: ClusterMessage) => {
receivedMessages.push(message);
if ((message as any)?.data?.event === 'AppRestart') {
resolve();
}
return originalOnMessage(message);
};
});
const appRestartMessage = {
type: 2,
data: {
event: 'AppRestart',
payload: { isMaintenanceMode: true },
opts: { rooms: new Set() },
rooms: [],
},
nsp: '/',
};
void adapter1.doPublish(appRestartMessage as any);
await Promise.race([messageReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
const restartMessages = receivedMessages.filter((m) => (m as any)?.data?.event === 'AppRestart');
expect(restartMessages.length).toBeGreaterThan(0);
expect((restartMessages[0] as any).data.payload.isMaintenanceMode).toBe(true);
adapter1.close();
adapter2.close();
});
it('should not receive its own messages (echo prevention)', async () => {
const factory = createBroadcastChannelAdapter();
const namespace = createMockNamespace();
const adapter = factory(namespace);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedOwnMessages: ClusterMessage[] = [];
const uniqueMarker = `test-${Date.now()}-${Math.random()}`;
const originalOnMessage = adapter.onMessage.bind(adapter);
adapter.onMessage = (message: ClusterMessage) => {
if ((message as any)?.data?.marker === uniqueMarker) {
receivedOwnMessages.push(message);
}
return originalOnMessage(message);
};
const testMessage = {
type: 2,
data: {
marker: uniqueMarker,
opts: { rooms: new Set() },
rooms: [],
},
nsp: '/',
};
void adapter.doPublish(testMessage as any);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(receivedOwnMessages.length).toBe(0);
adapter.close();
});
it('should send and receive response messages between adapters', async () => {
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedResponses: ClusterResponse[] = [];
const responseReceived = new Promise<void>((resolve) => {
const originalOnResponse = adapter1.onResponse.bind(adapter1);
adapter1.onResponse = (response: ClusterResponse) => {
receivedResponses.push(response);
resolve();
return originalOnResponse(response);
};
});
const responseMessage = {
type: 3,
data: { result: 'success', count: 42 },
};
void adapter2.doPublishResponse((adapter1 as any).uid, responseMessage as any);
await Promise.race([responseReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
expect(receivedResponses.length).toBeGreaterThan(0);
adapter1.close();
adapter2.close();
});
});
describe('BroadcastChannelAdapter lifecycle', () => {
it('should close cleanly without errors', () => {
const factory = createBroadcastChannelAdapter();
const namespace = createMockNamespace();
const adapter = factory(namespace);
expect(() => adapter.close()).not.toThrow();
});
it('should handle multiple adapters closing in sequence', () => {
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const factory3 = createBroadcastChannelAdapter();
const adapter1 = factory1(createMockNamespace());
const adapter2 = factory2(createMockNamespace());
const adapter3 = factory3(createMockNamespace());
expect(() => {
adapter1.close();
adapter2.close();
adapter3.close();
}).not.toThrow();
});
});
});

View File

@@ -1,159 +0,0 @@
import { Server } from 'socket.io';
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { automock } from 'test/utils';
import { vi } from 'vitest';
describe('WebSocket Integration - serverSend with adapters', () => {
describe('BroadcastChannel adapter', () => {
it('should broadcast ConfigUpdate event through BroadcastChannel adapter', async () => {
const createMockNamespace = () => ({
name: '/',
sockets: new Map(),
adapter: null,
server: {
encoder: { encode: vi.fn().mockReturnValue([]) },
_opts: {},
sockets: { sockets: new Map() },
},
});
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedMessages: any[] = [];
vi.spyOn(adapter2, 'onMessage').mockImplementation((message: any) => {
receivedMessages.push(message);
});
const configUpdatePayload = {
type: 5,
data: {
event: 'ConfigUpdate',
args: [{ newConfig: { ffmpeg: { crf: 23 } }, oldConfig: { ffmpeg: { crf: 20 } } }],
},
nsp: '/',
};
void adapter1.doPublish(configUpdatePayload as any);
await new Promise((resolve) => setTimeout(resolve, 100));
const configMessages = receivedMessages.filter((m) => m?.data?.event === 'ConfigUpdate');
expect(configMessages.length).toBeGreaterThan(0);
adapter1.close();
adapter2.close();
});
it('should broadcast AppRestart event through BroadcastChannel adapter', async () => {
const createMockNamespace = () => ({
name: '/',
sockets: new Map(),
adapter: null,
server: {
encoder: { encode: vi.fn().mockReturnValue([]) },
_opts: {},
sockets: { sockets: new Map() },
},
});
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedMessages: any[] = [];
vi.spyOn(adapter2, 'onMessage').mockImplementation((message: any) => {
receivedMessages.push(message);
});
const appRestartPayload = {
type: 5,
data: {
event: 'AppRestart',
args: [{ isMaintenanceMode: true }],
},
nsp: '/',
};
void adapter1.doPublish(appRestartPayload as any);
await new Promise((resolve) => setTimeout(resolve, 100));
const restartMessages = receivedMessages.filter((m) => m?.data?.event === 'AppRestart');
expect(restartMessages.length).toBeGreaterThan(0);
adapter1.close();
adapter2.close();
});
});
describe('WebsocketRepository with adapter', () => {
it('should call serverSideEmit when serverSend is called', () => {
const mockServer = {
serverSideEmit: vi.fn(),
on: vi.fn(),
} as unknown as Server;
const eventRepository = automock(EventRepository, {
args: [undefined, undefined, { setContext: () => {} }],
});
const loggingRepository = automock(LoggingRepository, {
args: [undefined, { getEnv: () => ({ noColor: false }) }],
strict: false,
});
const websocketRepository = new WebsocketRepository(eventRepository, loggingRepository);
(websocketRepository as any).server = mockServer;
websocketRepository.serverSend('ConfigUpdate', {
newConfig: { ffmpeg: { crf: 23 } } as any,
oldConfig: { ffmpeg: { crf: 20 } } as any,
});
expect(mockServer.serverSideEmit).toHaveBeenCalledWith('ConfigUpdate', {
newConfig: { ffmpeg: { crf: 23 } },
oldConfig: { ffmpeg: { crf: 20 } },
});
});
it('should call serverSideEmit for AppRestart event', () => {
const mockServer = {
serverSideEmit: vi.fn(),
on: vi.fn(),
} as unknown as Server;
const eventRepository = automock(EventRepository, {
args: [undefined, undefined, { setContext: () => {} }],
});
const loggingRepository = automock(LoggingRepository, {
args: [undefined, { getEnv: () => ({ noColor: false }) }],
strict: false,
});
const websocketRepository = new WebsocketRepository(eventRepository, loggingRepository);
(websocketRepository as any).server = mockServer;
websocketRepository.serverSend('AppRestart', { isMaintenanceMode: true });
expect(mockServer.serverSideEmit).toHaveBeenCalledWith('AppRestart', { isMaintenanceMode: true });
});
});
});

View File

@@ -1,70 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { SocketIoAdapter } from 'src/enum';
import { asPgPoolSsl, createWebSocketAdapter } from 'src/middleware/websocket.adapter';
import { Mocked, vi } from 'vitest';
describe('asPgPoolSsl', () => {
it('should return false for undefined ssl', () => {
expect(asPgPoolSsl()).toBe(false);
});
it('should return false for ssl = false', () => {
expect(asPgPoolSsl(false)).toBe(false);
});
it('should return false for ssl = "allow"', () => {
expect(asPgPoolSsl('allow')).toBe(false);
});
it('should return { rejectUnauthorized: false } for ssl = true', () => {
expect(asPgPoolSsl(true)).toEqual({ rejectUnauthorized: false });
});
it('should return { rejectUnauthorized: false } for ssl = "prefer"', () => {
expect(asPgPoolSsl('prefer')).toEqual({ rejectUnauthorized: false });
});
it('should return { rejectUnauthorized: false } for ssl = "require"', () => {
expect(asPgPoolSsl('require')).toEqual({ rejectUnauthorized: false });
});
it('should return { rejectUnauthorized: true } for ssl = "verify-full"', () => {
expect(asPgPoolSsl('verify-full')).toEqual({ rejectUnauthorized: true });
});
it('should pass through object ssl config unchanged', () => {
const sslConfig = { ca: 'certificate', rejectUnauthorized: true };
expect(asPgPoolSsl(sslConfig)).toBe(sslConfig);
});
});
describe('createWebSocketAdapter', () => {
let mockApp: Mocked<INestApplication>;
beforeEach(() => {
vi.clearAllMocks();
mockApp = {
getHttpServer: vi.fn().mockReturnValue({}),
} as unknown as Mocked<INestApplication>;
});
describe('BroadcastChannel adapter', () => {
it('should create BroadcastChannel adapter when configured', async () => {
const adapter = await createWebSocketAdapter(mockApp, SocketIoAdapter.BroadcastChannel);
expect(adapter).toBeDefined();
expect(adapter).toBeInstanceOf(IoAdapter);
});
});
describe('Postgres adapter', () => {
it('should create Postgres adapter when configured', async () => {
const adapter = await createWebSocketAdapter(mockApp, SocketIoAdapter.Postgres);
expect(adapter).toBeDefined();
expect(adapter).toBeInstanceOf(IoAdapter);
});
});
});

View File

@@ -88,4 +88,24 @@ describe(SearchService.name, () => {
expect(result).toEqual({ total: 0 });
});
});
describe('withStacked option', () => {
it('should exclude stacked assets when withStacked is false', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset: primaryAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: stackedAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: unstackedAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newStack({ ownerId: user.id }, [primaryAsset.id, stackedAsset.id]);
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { withStacked: false });
expect(response.assets.items.length).toBe(1);
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
});
});
});

View File

@@ -95,6 +95,469 @@ describe(SharedLinkService.name, () => {
});
});
describe('getAll', () => {
it('should return all shared links even when they share the same createdAt', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sameTimestamp = '2024-01-01T00:00:00.000Z';
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: sameTimestamp,
});
const link2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: sameTimestamp,
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(2);
const ids = result.map((r) => r.id);
expect(ids).toContain(link1.id);
expect(ids).toContain(link2.id);
});
it('should return shared links sorted by createdAt in descending order', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: '2021-01-01T00:00:00.000Z',
});
const link2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: '2023-01-01T00:00:00.000Z',
});
const link3 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
createdAt: '2022-01-01T00:00:00.000Z',
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(3);
expect(result.map((r) => r.id)).toEqual([link2.id, link3.id, link1.id]);
});
it('should not return shared links belonging to other users', async () => {
const { sut, ctx } = setup();
const { user: userA } = await ctx.newUser();
const { user: userB } = await ctx.newUser();
const authA = factory.auth({ user: userA });
const authB = factory.auth({ user: userB });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const linkA = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: userA.id,
allowUpload: false,
type: SharedLinkType.Individual,
});
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: userB.id,
allowUpload: false,
type: SharedLinkType.Individual,
});
const resultA = await sut.getAll(authA, {});
expect(resultA).toHaveLength(1);
expect(resultA[0].id).toBe(linkA.id);
const resultB = await sut.getAll(authB, {});
expect(resultB).toHaveLength(1);
expect(resultB[0].id).not.toBe(linkA.id);
});
it('should filter by albumId', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album: album1 } = await ctx.newAlbum({ ownerId: user.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album1.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album2.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const result = await sut.getAll(auth, { albumId: album1.id });
expect(result).toHaveLength(1);
expect(result[0].id).toBe(link1.id);
});
it('should return album shared links with album data', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(1);
expect(result[0].album).toBeDefined();
expect(result[0].album!.id).toBe(album.id);
});
it('should return multiple album shared links without sql error from json group by', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album: album1 } = await ctx.newAlbum({ ownerId: user.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const link1 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album1.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const link2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album2.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(2);
const ids = result.map((r) => r.id);
expect(ids).toContain(link1.id);
expect(ids).toContain(link2.id);
expect(result[0].album).toBeDefined();
expect(result[1].album).toBeDefined();
});
it('should return mixed album and individual shared links together', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const albumLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const albumLink2 = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
const individualLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(3);
const ids = result.map((r) => r.id);
expect(ids).toContain(albumLink.id);
expect(ids).toContain(albumLink2.id);
expect(ids).toContain(individualLink.id);
});
it('should return only the first asset as cover for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const assets = await Promise.all([
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2021-01-01T00:00:00.000Z' }),
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2023-01-01T00:00:00.000Z' }),
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2022-01-01T00:00:00.000Z' }),
]);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: assets.map(({ asset }) => asset.id),
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(1);
expect(result[0].assets).toHaveLength(1);
expect(result[0].assets[0].id).toBe(assets[0].asset.id);
});
});
describe('get', () => {
it('should not return trashed assets for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset: visibleAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: visibleAsset.id, make: 'Canon' });
const { asset: trashedAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: trashedAsset.id, make: 'Canon' });
await ctx.softDeleteAsset(trashedAsset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [visibleAsset.id, trashedAsset.id],
});
const result = await sut.get(auth, sharedLink.id);
expect(result).toBeDefined();
expect(result!.assets).toHaveLength(1);
expect(result!.assets[0].id).toBe(visibleAsset.id);
});
it('should return empty assets when all individually shared assets are trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
await ctx.softDeleteAsset(asset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
assets: [],
});
});
it('should not return trashed assets in a shared album', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const { asset: visibleAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: visibleAsset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: visibleAsset.id });
const { asset: trashedAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: trashedAsset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: trashedAsset.id });
await ctx.softDeleteAsset(trashedAsset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: true,
type: SharedLinkType.Album,
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
album: expect.objectContaining({ assetCount: 1 }),
});
});
it('should return an empty asset count when all album assets are trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.softDeleteAsset(asset.id);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
album: expect.objectContaining({ assetCount: 0 }),
});
});
it('should not return an album shared link when the album is trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await ctx.softDeleteAlbum(album.id);
await expect(sut.get(auth, sharedLink.id)).rejects.toThrow('Shared link not found');
});
});
describe('getAll', () => {
it('should not return trashed assets as cover for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset: trashedAsset } = await ctx.newAsset({
ownerId: user.id,
fileCreatedAt: '2020-01-01T00:00:00.000Z',
});
await ctx.softDeleteAsset(trashedAsset.id);
const { asset: visibleAsset } = await ctx.newAsset({
ownerId: user.id,
fileCreatedAt: '2021-01-01T00:00:00.000Z',
});
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
allowUpload: false,
type: SharedLinkType.Individual,
assetIds: [trashedAsset.id, visibleAsset.id],
});
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(1);
expect(result[0].assets).toHaveLength(1);
expect(result[0].assets[0].id).toBe(visibleAsset.id);
});
it('should not return an album shared link when the album is trashed', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLinkRepo = ctx.get(SharedLinkRepository);
await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: false,
type: SharedLinkType.Album,
});
await ctx.softDeleteAlbum(album.id);
const result = await sut.getAll(auth, {});
expect(result).toHaveLength(0);
});
});
it('should remove individually shared asset', async () => {
const { sut, ctx } = setup();

View File

@@ -1,4 +1,4 @@
import { DatabaseExtension, ImmichEnvironment, ImmichWorker, LogFormat, SocketIoAdapter } from 'src/enum';
import { DatabaseExtension, ImmichEnvironment, ImmichWorker, LogFormat } from 'src/enum';
import { ConfigRepository, EnvData } from 'src/repositories/config.repository';
import { RepositoryInterface } from 'src/types';
import { Mocked, vitest } from 'vitest';
@@ -99,10 +99,6 @@ const envData: EnvData = {
},
},
socketIo: {
adapter: SocketIoAdapter.Postgres,
},
noColor: false,
};

View File

@@ -1,156 +0,0 @@
type Config = IntersectionObserverActionProperties & {
observer?: IntersectionObserver;
};
type TrackedProperties = {
root?: Element | Document | null;
threshold?: number | number[];
top?: string;
right?: string;
bottom?: string;
left?: string;
};
type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown;
type OnSeparateCallback = (element: HTMLElement) => unknown;
type IntersectionObserverActionProperties = {
key?: string;
disabled?: boolean;
/** Function to execute when the element leaves the viewport */
onSeparate?: OnSeparateCallback;
/** Function to execute when the element enters the viewport */
onIntersect?: OnIntersectCallback;
root?: Element | Document | null;
threshold?: number | number[];
top?: string;
right?: string;
bottom?: string;
left?: string;
};
type TaskKey = HTMLElement | string;
function isEquivalent(a: TrackedProperties, b: TrackedProperties) {
return (
a?.bottom === b?.bottom &&
a?.top === b?.top &&
a?.left === b?.left &&
a?.right == b?.right &&
a?.threshold === b?.threshold &&
a?.root === b?.root
);
}
const elementToConfig = new Map<TaskKey, Config>();
const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => {
if (!target.isConnected) {
elementToConfig.get(key)?.observer?.unobserve(target);
return;
}
const {
root,
threshold,
top = '0px',
right = '0px',
bottom = '0px',
left = '0px',
onSeparate,
onIntersect,
} = properties;
const rootMargin = `${top} ${right} ${bottom} ${left}`;
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
// This IntersectionObserver is limited to observing a single element, the one the
// action is attached to. If there are multiple entries, it means that this
// observer is being notified of multiple events that have occurred quickly together,
// and the latest element is the one we are interested in.
entries.sort((a, b) => a.time - b.time);
const latestEntry = entries.pop();
if (latestEntry?.isIntersecting) {
onIntersect?.(latestEntry);
} else {
onSeparate?.(target);
}
},
{
rootMargin,
threshold,
root,
},
);
observer.observe(target);
elementToConfig.set(key, { ...properties, observer });
};
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
if (properties.disabled) {
const config = elementToConfig.get(key);
const { observer } = config || {};
observer?.unobserve(element);
elementToConfig.delete(key);
} else {
elementToConfig.set(key, properties);
observe(key, element, properties);
}
}
function _intersectionObserver(
key: HTMLElement | string,
element: HTMLElement,
properties: IntersectionObserverActionProperties,
) {
configure(key, element, properties);
return {
update(properties: IntersectionObserverActionProperties) {
const config = elementToConfig.get(key);
if (!config) {
return;
}
if (isEquivalent(config, properties)) {
return;
}
configure(key, element, properties);
},
destroy: () => {
const config = elementToConfig.get(key);
const { observer } = config || {};
observer?.unobserve(element);
elementToConfig.delete(key);
},
};
}
/**
* Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold).
* @param element
* @param properties One or multiple configurations for the IntersectionObserver(s)
* @returns
*/
export function intersectionObserver(
element: HTMLElement,
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
) {
// svelte doesn't allow multiple use:action directives of the same kind on the same element,
// so accept an array when multiple configurations are needed.
if (Array.isArray(properties)) {
if (!properties.every((p) => p.key)) {
throw new Error('Multiple configurations must specify key');
}
const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p));
return {
update: (properties: IntersectionObserverActionProperties[]) => {
for (const [i, props] of properties.entries()) {
observers[i].update(props);
}
},
destroy: () => {
for (const observer of observers) {
observer.destroy();
}
},
};
}
return _intersectionObserver(properties.key || element, element, properties);
}

View File

@@ -1,43 +0,0 @@
export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
let observer: ResizeObserver;
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
/**
* Installs a resizeObserver on the given element - when the element changes
* size, invokes a callback function with the width/height. Intended as a
* replacement for bind:clientWidth and bind:clientHeight in svelte4 which use
* an iframe to measure the size of the element, which can be bad for
* performance and memory usage. In svelte5, they adapted bind:clientHeight and
* bind:clientWidth to use an internal resize observer.
*
* TODO: When svelte5 is ready, go back to bind:clientWidth and
* bind:clientHeight.
*/
export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) {
if (!observer) {
callbacks = new WeakMap();
observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const onResize = callbacks.get(entry.target as HTMLElement);
if (onResize) {
onResize({
target: entry.target as HTMLElement,
width: entry.borderBoxSize[0].inlineSize,
height: entry.borderBoxSize[0].blockSize,
});
}
}
});
}
callbacks.set(element, onResize);
observer.observe(element);
return {
destroy: () => {
callbacks.delete(element);
observer.unobserve(element);
},
};
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Some files were not shown because too many files have changed in this diff Show More