Compare commits
1 Commits
csp-policy
...
postgres-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbdac440fd |
3
.github/workflows/check-openapi.yml
vendored
@@ -24,7 +24,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
|
||||
# sha is pinning to a commit instead of a tag since the action does not tag versions
|
||||
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
|
||||
# ℹ️ 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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -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.14",
|
||||
"@types/node": "^24.10.13",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
terragrunt = "0.99.4"
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
|
||||
@@ -155,7 +155,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
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:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
||||
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
user: '1000:1000'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -67,8 +67,7 @@ graph TD
|
||||
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
||||
D --> E[Smart Search]
|
||||
D --> F[Face Detection]
|
||||
D --> G[OCR]
|
||||
D --> H[Video Transcoding]
|
||||
E --> I[Duplicate Detection]
|
||||
F --> J[Facial Recognition]
|
||||
D --> G[Video Transcoding]
|
||||
E --> H[Duplicate Detection]
|
||||
F --> I[Facial Recognition]
|
||||
```
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
immich-server:
|
||||
container_name: immich-e2e-server
|
||||
image: immich-server:latest
|
||||
shm_size: 128mb
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile
|
||||
@@ -44,7 +45,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -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.14",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
66
e2e/src/specs/web/asset-viewer/stack.e2e-spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
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),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -12,7 +12,6 @@ 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;
|
||||
@@ -136,14 +135,6 @@ 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) {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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, times, and numbers based on the selected language and region",
|
||||
"custom_locale": "Custom Locale",
|
||||
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
||||
"custom_url": "Custom URL",
|
||||
"cutoff_date_description": "Keep photos from the last…",
|
||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||
@@ -895,6 +895,8 @@
|
||||
"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",
|
||||
@@ -2336,8 +2338,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/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)`);
|
||||
@@ -16,8 +16,8 @@ config_roots = [
|
||||
[tools]
|
||||
node = "24.13.1"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.30.3"
|
||||
terragrunt = "0.99.4"
|
||||
pnpm = "10.30.0"
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
java = "21.0.2"
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -79,22 +78,15 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||
}
|
||||
if (hasSpecialFormatColumn()) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
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(
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v21.json
generated
@@ -11,10 +11,6 @@ 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;
|
||||
@@ -47,14 +43,6 @@ 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) {
|
||||
|
||||
@@ -5,8 +5,6 @@ class LocalAsset extends BaseAsset {
|
||||
final String? remoteAssetId;
|
||||
final String? cloudId;
|
||||
final int orientation;
|
||||
@override
|
||||
final AssetPlaybackStyle playbackStyle;
|
||||
|
||||
final DateTime? adjustmentTime;
|
||||
final double? latitude;
|
||||
@@ -27,7 +25,6 @@ class LocalAsset extends BaseAsset {
|
||||
super.isFavorite = false,
|
||||
super.livePhotoVideoId,
|
||||
this.orientation = 0,
|
||||
required this.playbackStyle,
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
@@ -59,7 +56,6 @@ class LocalAsset extends BaseAsset {
|
||||
width: ${width ?? "<NA>"},
|
||||
height: ${height ?? "<NA>"},
|
||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||
playbackStyle: $playbackStyle,
|
||||
remoteId: ${remoteId ?? "<NA>"},
|
||||
cloudId: ${cloudId ?? "<NA>"},
|
||||
checksum: ${checksum ?? "<NA>"},
|
||||
@@ -80,7 +76,6 @@ 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;
|
||||
@@ -92,7 +87,6 @@ class LocalAsset extends BaseAsset {
|
||||
id.hashCode ^
|
||||
remoteId.hashCode ^
|
||||
orientation.hashCode ^
|
||||
playbackStyle.hashCode ^
|
||||
adjustmentTime.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode;
|
||||
@@ -111,7 +105,6 @@ class LocalAsset extends BaseAsset {
|
||||
int? durationInSeconds,
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
AssetPlaybackStyle? playbackStyle,
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
@@ -131,7 +124,6 @@ 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,
|
||||
|
||||
@@ -435,19 +435,9 @@ 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,
|
||||
};
|
||||
|
||||
@@ -25,8 +25,6 @@ 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};
|
||||
}
|
||||
@@ -45,7 +43,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
width: width,
|
||||
remoteId: remoteId,
|
||||
orientation: orientation,
|
||||
playbackStyle: playbackStyle,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
|
||||
@@ -25,7 +25,6 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<DateTime?> adjustmentTime,
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.LocalAssetEntityCompanion Function({
|
||||
@@ -44,7 +43,6 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<DateTime?> adjustmentTime,
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
|
||||
class $$LocalAssetEntityTableFilterComposer
|
||||
@@ -131,16 +129,6 @@ 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
|
||||
@@ -226,11 +214,6 @@ 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
|
||||
@@ -294,12 +277,6 @@ 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
|
||||
@@ -357,8 +334,6 @@ 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,
|
||||
@@ -375,7 +350,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -394,8 +368,6 @@ 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,
|
||||
@@ -412,7 +384,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -625,19 +596,6 @@ 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,
|
||||
@@ -654,7 +612,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -836,12 +793,6 @@ 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'],
|
||||
)!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -852,10 +803,6 @@ 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
|
||||
@@ -879,7 +826,6 @@ 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,
|
||||
@@ -896,7 +842,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -936,11 +881,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -967,9 +907,6 @@ 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
|
||||
@@ -993,9 +930,6 @@ 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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1015,7 +949,6 @@ 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,
|
||||
@@ -1036,7 +969,6 @@ 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(
|
||||
@@ -1063,9 +995,6 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1086,8 +1015,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
..write('iCloudId: $iCloudId, ')
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write('longitude: $longitude')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1109,7 +1037,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1129,8 +1056,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
other.iCloudId == this.iCloudId &&
|
||||
other.adjustmentTime == this.adjustmentTime &&
|
||||
other.latitude == this.latitude &&
|
||||
other.longitude == this.longitude &&
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
other.longitude == this.longitude);
|
||||
}
|
||||
|
||||
class LocalAssetEntityCompanion
|
||||
@@ -1150,7 +1076,6 @@ 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(),
|
||||
@@ -1167,7 +1092,6 @@ 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,
|
||||
@@ -1185,7 +1109,6 @@ 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);
|
||||
@@ -1205,7 +1128,6 @@ 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,
|
||||
@@ -1223,7 +1145,6 @@ class LocalAssetEntityCompanion
|
||||
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1243,7 +1164,6 @@ 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,
|
||||
@@ -1261,7 +1181,6 @@ class LocalAssetEntityCompanion
|
||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1315,13 +1234,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1342,8 +1254,7 @@ class LocalAssetEntityCompanion
|
||||
..write('iCloudId: $iCloudId, ')
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write('longitude: $longitude')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -26,8 +26,7 @@ SELECT
|
||||
NULL as latitude,
|
||||
NULL as longitude,
|
||||
NULL as adjustmentTime,
|
||||
rae.is_edited,
|
||||
0 as playback_style
|
||||
rae.is_edited
|
||||
FROM
|
||||
remote_asset_entity rae
|
||||
LEFT JOIN
|
||||
@@ -64,8 +63,7 @@ SELECT
|
||||
lae.latitude,
|
||||
lae.longitude,
|
||||
lae.adjustment_time,
|
||||
0 as is_edited,
|
||||
lae.playback_style
|
||||
0 as is_edited
|
||||
FROM
|
||||
local_asset_entity lae
|
||||
WHERE NOT EXISTS (
|
||||
|
||||
@@ -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, 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}',
|
||||
'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}',
|
||||
variables: [
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
...generatedlimit.introducedVariables,
|
||||
@@ -67,7 +67,6 @@ 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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -140,7 +139,6 @@ class MergedAssetResult {
|
||||
final double? longitude;
|
||||
final DateTime? adjustmentTime;
|
||||
final bool isEdited;
|
||||
final int playbackStyle;
|
||||
MergedAssetResult({
|
||||
this.remoteId,
|
||||
this.localId,
|
||||
@@ -163,7 +161,6 @@ class MergedAssetResult {
|
||||
this.longitude,
|
||||
this.adjustmentTime,
|
||||
required this.isEdited,
|
||||
required this.playbackStyle,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ 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};
|
||||
}
|
||||
@@ -47,7 +45,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
|
||||
height: height,
|
||||
width: width,
|
||||
orientation: orientation,
|
||||
playbackStyle: playbackStyle,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<bool> isFavorite,
|
||||
i0.Value<int> orientation,
|
||||
required i3.TrashOrigin source,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.TrashedLocalAssetEntityCompanion Function({
|
||||
@@ -40,7 +39,6 @@ typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<bool> isFavorite,
|
||||
i0.Value<int> orientation,
|
||||
i0.Value<i3.TrashOrigin> source,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
|
||||
class $$TrashedLocalAssetEntityTableFilterComposer
|
||||
@@ -119,16 +117,6 @@ 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
|
||||
@@ -205,11 +193,6 @@ 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
|
||||
@@ -266,12 +249,6 @@ 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
|
||||
@@ -333,8 +310,6 @@ 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,
|
||||
@@ -349,7 +324,6 @@ class $$TrashedLocalAssetEntityTableTableManager
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
source: source,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -366,8 +340,6 @@ 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,
|
||||
@@ -382,7 +354,6 @@ class $$TrashedLocalAssetEntityTableTableManager
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
source: source,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -579,19 +550,6 @@ 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,
|
||||
@@ -606,7 +564,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
||||
isFavorite,
|
||||
orientation,
|
||||
source,
|
||||
playbackStyle,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -763,13 +720,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
||||
data['${effectivePrefix}source'],
|
||||
)!,
|
||||
),
|
||||
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
|
||||
.fromSql(
|
||||
attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.int,
|
||||
data['${effectivePrefix}playback_style'],
|
||||
)!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -782,10 +732,6 @@ 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
|
||||
@@ -807,7 +753,6 @@ 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,
|
||||
@@ -822,7 +767,6 @@ 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) {
|
||||
@@ -856,13 +800,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source),
|
||||
);
|
||||
}
|
||||
{
|
||||
map['playback_style'] = i0.Variable<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
|
||||
playbackStyle,
|
||||
),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -889,8 +826,6 @@ 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
|
||||
@@ -914,11 +849,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
'source': serializer.toJson<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toJson(source),
|
||||
),
|
||||
'playbackStyle': serializer.toJson<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toJson(
|
||||
playbackStyle,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -936,7 +866,6 @@ 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,
|
||||
@@ -953,7 +882,6 @@ 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,
|
||||
@@ -978,9 +906,6 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -999,8 +924,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
..write('checksum: $checksum, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('orientation: $orientation, ')
|
||||
..write('source: $source, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write('source: $source')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1020,7 +944,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
isFavorite,
|
||||
orientation,
|
||||
source,
|
||||
playbackStyle,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1038,8 +961,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
other.checksum == this.checksum &&
|
||||
other.isFavorite == this.isFavorite &&
|
||||
other.orientation == this.orientation &&
|
||||
other.source == this.source &&
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
other.source == this.source);
|
||||
}
|
||||
|
||||
class TrashedLocalAssetEntityCompanion
|
||||
@@ -1057,7 +979,6 @@ 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(),
|
||||
@@ -1072,7 +993,6 @@ 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,
|
||||
@@ -1088,7 +1008,6 @@ 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),
|
||||
@@ -1108,7 +1027,6 @@ 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,
|
||||
@@ -1124,7 +1042,6 @@ class TrashedLocalAssetEntityCompanion
|
||||
if (isFavorite != null) 'is_favorite': isFavorite,
|
||||
if (orientation != null) 'orientation': orientation,
|
||||
if (source != null) 'source': source,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1142,7 +1059,6 @@ 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,
|
||||
@@ -1158,7 +1074,6 @@ class TrashedLocalAssetEntityCompanion
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
source: source ?? this.source,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1208,13 +1123,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1233,8 +1141,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
..write('checksum: $checksum, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('orientation: $orientation, ')
|
||||
..write('source: $source, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write('source: $source')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 21;
|
||||
int get schemaVersion => 20;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -230,10 +230,6 @@ 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -8904,591 +8904,6 @@ 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,
|
||||
@@ -9509,7 +8924,6 @@ 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) {
|
||||
@@ -9608,11 +9022,6 @@ 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');
|
||||
}
|
||||
@@ -9639,7 +9048,6 @@ 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,
|
||||
@@ -9661,6 +9069,5 @@ i1.OnUpgrade stepByStep({
|
||||
from17To18: from17To18,
|
||||
from18To19: from18To19,
|
||||
from19To20: from19To20,
|
||||
from20To21: from20To21,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -301,7 +301,6 @@ 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),
|
||||
@@ -334,7 +333,6 @@ 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,
|
||||
|
||||
@@ -101,7 +101,6 @@ 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,
|
||||
|
||||
@@ -85,7 +85,6 @@ 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,
|
||||
);
|
||||
|
||||
@@ -148,7 +147,6 @@ 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),
|
||||
@@ -197,7 +195,6 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
checksum: Value(e.checksum),
|
||||
isFavorite: Value(e.isFavorite),
|
||||
orientation: Value(e.orientation),
|
||||
playbackStyle: Value(e.playbackStyle),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -248,7 +245,6 @@ 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,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -12,8 +11,7 @@ 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:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.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';
|
||||
@@ -131,16 +129,11 @@ class _BottomPanelState extends State<_BottomPanel> {
|
||||
return;
|
||||
}
|
||||
|
||||
final db = Drift();
|
||||
try {
|
||||
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;
|
||||
await db.reset();
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
|
||||
@@ -18,10 +18,11 @@ 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';
|
||||
|
||||
@@ -52,6 +53,7 @@ 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;
|
||||
|
||||
@@ -77,6 +79,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_proxyScrollController.dispose();
|
||||
_scaleBoundarySub?.cancel();
|
||||
_eventSubscription?.cancel();
|
||||
_videoScaleStateNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -246,14 +249,17 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
|
||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||
_isZoomed = switch (scaleState) {
|
||||
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
|
||||
_ => false,
|
||||
};
|
||||
_isZoomed =
|
||||
scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.covering ||
|
||||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
|
||||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
|
||||
_viewer.setZoomed(_isZoomed);
|
||||
|
||||
if (scaleState != PhotoViewScaleState.initial) {
|
||||
if (_dragStart == null) _viewer.setControls(false);
|
||||
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -328,36 +334,31 @@ 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: showingDetails,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
disableScaleGestures: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
tightMode: true,
|
||||
onPageBuild: _onPageBuild,
|
||||
enablePanAlways: true,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
child: NativeVideoViewer(
|
||||
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
||||
asset: displayAsset,
|
||||
scaleStateNotifier: _videoScaleStateNotifier,
|
||||
disableScaleGestures: showingDetails,
|
||||
image: Image(
|
||||
image: getFullImageProvider(displayAsset, size: childSize),
|
||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
|
||||
@@ -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_bottom_app_bar.widget.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/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';
|
||||
|
||||
@@ -9,10 +9,11 @@ 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/video_viewer_controls.widget.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/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';
|
||||
@@ -25,6 +26,7 @@ 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';
|
||||
@@ -49,10 +51,21 @@ 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.playbackDelayFactor = 1});
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.image,
|
||||
this.showControls = true,
|
||||
this.playbackDelayFactor = 1,
|
||||
this.scaleStateNotifier,
|
||||
this.disableScaleGestures = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -131,6 +144,7 @@ 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;
|
||||
@@ -306,6 +320,20 @@ 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) {
|
||||
@@ -386,18 +414,29 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
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()),
|
||||
],
|
||||
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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ 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),
|
||||
),
|
||||
|
||||
@@ -25,7 +25,6 @@ class FileMediaRepository {
|
||||
type: AssetType.image,
|
||||
createdAt: entity.createDateTime,
|
||||
updatedAt: entity.modifiedDateTime,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -18,7 +17,6 @@ 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';
|
||||
@@ -35,7 +33,7 @@ import 'package:isar/isar.dart';
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 23;
|
||||
const int targetVersion = 22;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||
@@ -101,10 +99,6 @@ 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);
|
||||
}
|
||||
@@ -398,52 +392,6 @@ 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;
|
||||
|
||||
@@ -420,11 +420,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
|
||||
Widget _buildChild() {
|
||||
return widget.hasCustomChild
|
||||
? SizedBox(
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
height: scaleBoundaries.childSize.height * scale,
|
||||
child: widget.customChild!,
|
||||
)
|
||||
? widget.customChild!
|
||||
: Image(
|
||||
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
|
||||
image: widget.imageProvider!,
|
||||
@@ -432,7 +428,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||
filterQuality: widget.filterQuality,
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
fit: BoxFit.contain,
|
||||
fit: BoxFit.cover,
|
||||
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
||||
);
|
||||
}
|
||||
|
||||
4
mobile/test/drift/main/generated/schema.dart
generated
@@ -23,7 +23,6 @@ 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
|
||||
@@ -69,8 +68,6 @@ 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);
|
||||
}
|
||||
@@ -97,6 +94,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
];
|
||||
}
|
||||
|
||||
8545
mobile/test/drift/main/generated/schema_v21.dart
generated
2
mobile/test/fixtures/asset.stub.dart
vendored
@@ -64,7 +64,6 @@ abstract final class LocalAssetStub {
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025, 2),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -74,7 +73,6 @@ abstract final class LocalAssetStub {
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2000),
|
||||
updatedAt: DateTime(20021),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,7 +194,6 @@ void main() {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
adjustmentTime: DateTime(2026, 1, 2),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -244,7 +243,6 @@ void main() {
|
||||
cloudId: 'cloud-id-123',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -283,7 +281,6 @@ void main() {
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: null, // No cloudId
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -326,7 +323,6 @@ void main() {
|
||||
cloudId: 'cloud-id-livephoto',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
|
||||
@@ -155,7 +155,6 @@ abstract final class TestUtils {
|
||||
width: width,
|
||||
height: height,
|
||||
orientation: orientation,
|
||||
playbackStyle: domain.AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ LocalAsset createLocalAsset({
|
||||
createdAt: createdAt ?? DateTime.now(),
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
isFavorite: isFavorite,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/node": "^24.10.13",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "2.5.6",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
1836
pnpm-lock.yaml
generated
@@ -57,7 +57,8 @@
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.5.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"@socket.io/postgres-adapter": "^0.5.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"ajv": "^8.17.1",
|
||||
"archiver": "^7.0.0",
|
||||
"async-lock": "^1.4.0",
|
||||
@@ -109,6 +110,7 @@
|
||||
"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",
|
||||
@@ -136,7 +138,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -5,8 +5,9 @@ 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 { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { createWebSocketAdapter } 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';
|
||||
@@ -25,6 +26,7 @@ export async function configureExpress(
|
||||
{
|
||||
permitSwaggerWrite = true,
|
||||
ssr,
|
||||
socketIoAdapter,
|
||||
}: {
|
||||
/**
|
||||
* Whether to allow swagger module to write to the specs.json
|
||||
@@ -36,6 +38,10 @@ 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);
|
||||
@@ -55,7 +61,7 @@ export async function configureExpress(
|
||||
}
|
||||
|
||||
app.setGlobalPrefix('api', { exclude: excludePaths });
|
||||
app.useWebSocketAdapter(new WebSocketAdapter(app));
|
||||
app.useWebSocketAdapter(await createWebSocketAdapter(app, socketIoAdapter));
|
||||
|
||||
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -51,6 +52,7 @@ export const controllers = [
|
||||
DownloadController,
|
||||
DuplicateController,
|
||||
FaceController,
|
||||
InternalController,
|
||||
JobController,
|
||||
LibraryController,
|
||||
MaintenanceController,
|
||||
|
||||
22
server/src/controllers/internal.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
|
||||
import { ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
|
||||
import { ImmichEnvironment, LogFormat, LogLevel, SocketIoAdapter } from 'src/enum';
|
||||
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
|
||||
|
||||
// TODO import from sql-tools once the swagger plugin supports external enums
|
||||
@@ -149,6 +149,11 @@ 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;
|
||||
|
||||
@@ -518,6 +518,11 @@ export enum ImmichTelemetry {
|
||||
Job = 'job',
|
||||
}
|
||||
|
||||
export enum SocketIoAdapter {
|
||||
BroadcastChannel = 'broadcastchannel',
|
||||
Postgres = 'postgres',
|
||||
}
|
||||
|
||||
export enum ExifOrientation {
|
||||
Horizontal = 1,
|
||||
MirrorHorizontal = 2,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -18,7 +17,7 @@ class Workers {
|
||||
/**
|
||||
* Currently running workers
|
||||
*/
|
||||
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
|
||||
workers: Partial<Record<ImmichWorker, { kill: () => Promise<void> | void }>> = {};
|
||||
|
||||
/**
|
||||
* Fail-safe in case anything dies during restart
|
||||
@@ -101,25 +100,23 @@ class Workers {
|
||||
const basePath = dirname(__filename);
|
||||
const workerFile = join(basePath, 'workers', `${name}.js`);
|
||||
|
||||
let anyWorker: Worker | ChildProcess;
|
||||
let kill: (signal?: NodeJS.Signals) => Promise<void> | void;
|
||||
const inspectArg = process.execArgv.find((arg) => arg.startsWith('--inspect'));
|
||||
const workerData: { inspectorPort?: number } = {};
|
||||
|
||||
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;
|
||||
if (inspectArg) {
|
||||
const inspectorPorts: Record<ImmichWorker, number> = {
|
||||
[ImmichWorker.Api]: 9230,
|
||||
[ImmichWorker.Microservices]: 9231,
|
||||
[ImmichWorker.Maintenance]: 9232,
|
||||
};
|
||||
workerData.inspectorPort = inspectorPorts[name];
|
||||
}
|
||||
|
||||
anyWorker.on('error', (error) => this.onError(name, error));
|
||||
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
|
||||
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));
|
||||
|
||||
this.workers[name] = { kill };
|
||||
}
|
||||
@@ -152,8 +149,8 @@ class Workers {
|
||||
console.error(`${name} worker exited with code ${exitCode}`);
|
||||
|
||||
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
|
||||
console.error('Killing api process');
|
||||
void this.workers[ImmichWorker.Api].kill('SIGTERM');
|
||||
console.error('Terminating api worker');
|
||||
void this.workers[ImmichWorker.Api].kill();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
Next,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
@@ -25,12 +26,15 @@ 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';
|
||||
@@ -131,4 +135,14 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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';
|
||||
@@ -290,6 +291,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -351,4 +355,25 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
server/src/middleware/broadcast-channel.adapter.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -1,21 +1,103 @@
|
||||
import { INestApplicationContext } from '@nestjs/common';
|
||||
import { INestApplication, Logger } from '@nestjs/common';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import { Redis } from 'ioredis';
|
||||
import { ServerOptions } from '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 { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { asPostgresConnectionConfig } from 'src/utils/database';
|
||||
|
||||
export class WebSocketAdapter extends IoAdapter {
|
||||
constructor(private app: INestApplicationContext) {
|
||||
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) {
|
||||
super(app);
|
||||
this.adapterConstructor = createBroadcastChannelAdapter();
|
||||
}
|
||||
|
||||
createIOServer(port: number, options?: ServerOptions): any {
|
||||
const { redis } = this.app.get(ConfigRepository).getEnv();
|
||||
const server = super.createIOServer(port, options);
|
||||
const pubClient = new Redis(redis);
|
||||
const subClient = pubClient.duplicate();
|
||||
server.adapter(createAdapter(pubClient, subClient));
|
||||
server.adapter(this.adapterConstructor);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,30 +102,22 @@ order by
|
||||
"shared_link"."createdAt" desc
|
||||
|
||||
-- SharedLinkRepository.getAll
|
||||
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",
|
||||
select distinct
|
||||
on ("shared_link"."createdAt") "shared_link".*,
|
||||
"assets"."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".*,
|
||||
@@ -160,12 +152,12 @@ from
|
||||
and "album"."deletedAt" is null
|
||||
) as "album" on true
|
||||
where
|
||||
"shared_link"."userId" = $2
|
||||
"shared_link"."userId" = $1
|
||||
and (
|
||||
"shared_link"."type" = $3
|
||||
"shared_link"."type" = $2
|
||||
or "album"."id" is not null
|
||||
)
|
||||
and "shared_link"."albumId" = $4
|
||||
and "shared_link"."albumId" = $3
|
||||
order by
|
||||
"shared_link"."createdAt" desc
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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';
|
||||
@@ -24,24 +21,17 @@ export class AppRepository {
|
||||
}
|
||||
|
||||
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
|
||||
const server = new SocketIO();
|
||||
const { redis } = new ConfigRepository().getEnv();
|
||||
const pubClient = new Redis({ ...redis, lazyConnect: true });
|
||||
const subClient = pubClient.duplicate();
|
||||
const { port } = new ConfigRepository().getEnv();
|
||||
const url = `http://127.0.0.1:${port}/api/internal/restart`;
|
||||
|
||||
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();
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(state),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to trigger app restart: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
LogFormat,
|
||||
LogLevel,
|
||||
QueueName,
|
||||
SocketIoAdapter,
|
||||
} from 'src/enum';
|
||||
import { VectorExtension } from 'src/types';
|
||||
import { setDifference } from 'src/utils/set';
|
||||
@@ -117,6 +118,10 @@ export interface EnvData {
|
||||
};
|
||||
};
|
||||
|
||||
socketIo: {
|
||||
adapter: SocketIoAdapter;
|
||||
};
|
||||
|
||||
noColor: boolean;
|
||||
nodeVersion?: string;
|
||||
}
|
||||
@@ -347,6 +352,10 @@ const getEnv = (): EnvData => {
|
||||
},
|
||||
},
|
||||
|
||||
socketIo: {
|
||||
adapter: dto.IMMICH_SOCKETIO_ADAPTER ?? SocketIoAdapter.Postgres,
|
||||
},
|
||||
|
||||
noColor: !!dto.NO_COLOR,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, sql, Updateable } from 'kysely';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import _ from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Album, columns } from 'src/database';
|
||||
@@ -124,20 +124,19 @@ export class SharedLinkRepository {
|
||||
.selectFrom('shared_link')
|
||||
.selectAll('shared_link')
|
||||
.where('shared_link.userId', '=', userId)
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('shared_link_asset')
|
||||
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
|
||||
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
|
||||
.selectFrom('asset')
|
||||
.select((eb) => eb.fn.jsonAgg('asset').as('assets'))
|
||||
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.selectAll('asset')
|
||||
.orderBy('asset.fileCreatedAt', 'asc')
|
||||
.limit(1),
|
||||
)
|
||||
.$castTo<MapAsset[]>()
|
||||
.as('assets'),
|
||||
.as('assets'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select('assets.assets')
|
||||
.$narrowType<{ assets: NotNull }>()
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
@@ -180,6 +179,7 @@ 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -566,8 +566,6 @@ 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);
|
||||
|
||||
|
||||
@@ -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.Thumbnail, { isEdited: true }),
|
||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||
});
|
||||
|
||||
export const addAssets = async (
|
||||
|
||||
@@ -404,7 +404,6 @@ 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));
|
||||
|
||||
@@ -1,60 +1,11 @@
|
||||
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,
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
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';
|
||||
|
||||
async function bootstrap() {
|
||||
export 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 });
|
||||
@@ -19,10 +26,12 @@ async function bootstrap() {
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
if (!isStartUpError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
});
|
||||
if (!isMainThread || process.send) {
|
||||
bootstrap().catch((error) => {
|
||||
if (!isStartUpError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
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';
|
||||
|
||||
async function bootstrap() {
|
||||
export 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 });
|
||||
@@ -16,13 +25,18 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
if (!isStartUpError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
});
|
||||
if (!isMainThread) {
|
||||
bootstrap().catch((error) => {
|
||||
if (!isStartUpError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { isMainThread } from 'node:worker_threads';
|
||||
import inspector from 'node:inspector';
|
||||
import { isMainThread, workerData } from 'node:worker_threads';
|
||||
import { MicroservicesModule } from 'src/app.module';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { createWebSocketAdapter } 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';
|
||||
@@ -10,6 +11,11 @@ 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);
|
||||
@@ -24,7 +30,7 @@ export async function bootstrap() {
|
||||
|
||||
logger.setContext('Bootstrap');
|
||||
app.useLogger(logger);
|
||||
app.useWebSocketAdapter(new WebSocketAdapter(app));
|
||||
app.useWebSocketAdapter(await createWebSocketAdapter(app));
|
||||
|
||||
await (host ? app.listen(0, host) : app.listen(0));
|
||||
|
||||
|
||||
@@ -233,14 +233,6 @@ 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);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -88,24 +88,4 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,469 +95,6 @@ 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();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseExtension, ImmichEnvironment, ImmichWorker, LogFormat } from 'src/enum';
|
||||
import { DatabaseExtension, ImmichEnvironment, ImmichWorker, LogFormat, SocketIoAdapter } from 'src/enum';
|
||||
import { ConfigRepository, EnvData } from 'src/repositories/config.repository';
|
||||
import { RepositoryInterface } from 'src/types';
|
||||
import { Mocked, vitest } from 'vitest';
|
||||
@@ -99,6 +99,10 @@ const envData: EnvData = {
|
||||
},
|
||||
},
|
||||
|
||||
socketIo: {
|
||||
adapter: SocketIoAdapter.Postgres,
|
||||
},
|
||||
|
||||
noColor: false,
|
||||
};
|
||||
|
||||
|
||||
156
web/src/lib/actions/intersection-observer.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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);
|
||||
}
|
||||
43
web/src/lib/actions/resize-observer.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
BIN
web/src/lib/assets/apple/apple-splash-1125-2436.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1136-640.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1170-2532.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1179-2556.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1242-2208.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1242-2688.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1284-2778.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1290-2796.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1334-750.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1536-2048.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1620-2160.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1668-2224.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
web/src/lib/assets/apple/apple-splash-1668-2388.png
Normal file
|
After Width: | Height: | Size: 242 KiB |