mirror of
https://github.com/immich-app/immich.git
synced 2026-02-03 10:38:01 -08:00
Compare commits
4 Commits
sqlite_thu
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51e7dcf0e8 | ||
|
|
95e8e474b8 | ||
|
|
9f52d864cf | ||
|
|
0273dcb0cf |
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -591,9 +591,9 @@ jobs:
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
uv run ruff check --output-format=github immich_ml
|
||||
- name: Check black formatting
|
||||
- name: Format with ruff
|
||||
run: |
|
||||
uv run black --check immich_ml
|
||||
uv run ruff format --check immich_ml
|
||||
- name: Run mypy type checking
|
||||
run: |
|
||||
uv run mypy --strict immich_ml/
|
||||
|
||||
2
e2e/src/generators/memory.ts
Normal file
2
e2e/src/generators/memory.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects';
|
||||
export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects';
|
||||
84
e2e/src/generators/memory/model-objects.ts
Normal file
84
e2e/src/generators/memory/model-objects.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { toAssetResponseDto } from 'src/generators/timeline/rest-response';
|
||||
import type { MockTimelineAsset } from 'src/generators/timeline/timeline-config';
|
||||
import { SeededRandom, selectRandomMultiple } from 'src/generators/timeline/utils';
|
||||
|
||||
export type MemoryConfig = {
|
||||
id?: string;
|
||||
ownerId: string;
|
||||
year: number;
|
||||
memoryAt: string;
|
||||
isSaved?: boolean;
|
||||
};
|
||||
|
||||
export type MemoryYearConfig = {
|
||||
year: number;
|
||||
assetCount: number;
|
||||
};
|
||||
|
||||
export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
const memoryId = config.id ?? faker.string.uuid();
|
||||
|
||||
return {
|
||||
id: memoryId,
|
||||
assets: assets.map((asset) => toAssetResponseDto(asset)),
|
||||
data: { year: config.year } as OnThisDayDto,
|
||||
memoryAt: config.memoryAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isSaved: config.isSaved ?? false,
|
||||
ownerId: config.ownerId,
|
||||
type: MemoryType.OnThisDay,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateMemoriesFromTimeline(
|
||||
timelineAssets: MockTimelineAsset[],
|
||||
ownerId: string,
|
||||
memoryConfigs: MemoryYearConfig[],
|
||||
seed: number = 42,
|
||||
): MemoryResponseDto[] {
|
||||
const rng = new SeededRandom(seed);
|
||||
const memories: MemoryResponseDto[] = [];
|
||||
const usedAssetIds = new Set<string>();
|
||||
|
||||
for (const config of memoryConfigs) {
|
||||
const yearAssets = timelineAssets.filter((asset) => {
|
||||
const assetYear = DateTime.fromISO(asset.fileCreatedAt).year;
|
||||
return assetYear === config.year && !usedAssetIds.has(asset.id);
|
||||
});
|
||||
|
||||
if (yearAssets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const countToSelect = Math.min(config.assetCount, yearAssets.length);
|
||||
const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng);
|
||||
|
||||
for (const asset of selectedAssets) {
|
||||
usedAssetIds.add(asset.id);
|
||||
}
|
||||
|
||||
selectedAssets.sort(
|
||||
(a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds,
|
||||
);
|
||||
|
||||
const memoryAt = DateTime.now().set({ year: config.year }).toISO()!;
|
||||
|
||||
memories.push(
|
||||
generateMemory(
|
||||
{
|
||||
ownerId,
|
||||
year: config.year,
|
||||
memoryAt,
|
||||
},
|
||||
selectedAssets,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return memories;
|
||||
}
|
||||
65
e2e/src/mock-network/memory-network.ts
Normal file
65
e2e/src/mock-network/memory-network.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
|
||||
export type MemoryChanges = {
|
||||
memoryDeletions: string[];
|
||||
assetRemovals: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export const setupMemoryMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
memories: MemoryResponseDto[],
|
||||
changes: MemoryChanges,
|
||||
) => {
|
||||
await context.route('**/api/memories*', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === '/api/memories' && request.method() === 'GET') {
|
||||
const activeMemories = memories
|
||||
.filter((memory) => !changes.memoryDeletions.includes(memory.id))
|
||||
.map((memory) => {
|
||||
const removedAssets = changes.assetRemovals.get(memory.id) ?? [];
|
||||
return {
|
||||
...memory,
|
||||
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
|
||||
};
|
||||
})
|
||||
.filter((memory) => memory.assets.length > 0);
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: activeMemories,
|
||||
});
|
||||
}
|
||||
|
||||
const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/);
|
||||
if (memoryMatch && request.method() === 'GET') {
|
||||
const memoryId = memoryMatch[1];
|
||||
const memory = memories.find((m) => m.id === memoryId);
|
||||
|
||||
if (!memory || changes.memoryDeletions.includes(memoryId)) {
|
||||
return route.fulfill({ status: 404 });
|
||||
}
|
||||
|
||||
const removedAssets = changes.assetRemovals.get(memoryId) ?? [];
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
...memory,
|
||||
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') {
|
||||
const memoryId = pathname.split('/').pop()!;
|
||||
changes.memoryDeletions.push(memoryId);
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
});
|
||||
};
|
||||
289
e2e/src/web/specs/memory/memory-viewer.ui-spec.ts
Normal file
289
e2e/src/web/specs/memory/memory-viewer.ui-spec.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { generateMemoriesFromTimeline } from 'src/generators/memory';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||
import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/mock-network/memory-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||
import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from 'src/web/specs/memory/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
let memories: MemoryResponseDto[];
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
const memoryChanges: MemoryChanges = {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
|
||||
timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: adminUserId,
|
||||
});
|
||||
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
assets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 3 },
|
||||
{ year: 2023, assetCount: 2 },
|
||||
{ year: 2022, assetCount: 4 },
|
||||
],
|
||||
42,
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
memoryChanges.memoryDeletions = [];
|
||||
memoryChanges.assetRemovals.clear();
|
||||
});
|
||||
|
||||
test.describe('Asset viewer navigation from gallery', () => {
|
||||
test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const middleAsset = firstMemory.assets[1];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory);
|
||||
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
});
|
||||
|
||||
test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => {
|
||||
const lastMemory = memories.at(-1)!;
|
||||
const firstAssetOfLastMemory = lastMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
|
||||
await memoryAssetViewerUtils.clickNextButton(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id);
|
||||
});
|
||||
|
||||
test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await memoryAssetViewerUtils.clickPreviousButton(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
});
|
||||
|
||||
test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const veryFirstAsset = firstMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => {
|
||||
const lastMemory = memories.at(-1)!;
|
||||
const veryLastAsset = lastMemory.assets.at(-1)!;
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectNextButtonNotVisible(page);
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
});
|
||||
|
||||
test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
let memories: MemoryResponseDto[];
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
const memoryChanges: MemoryChanges = {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
|
||||
timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: adminUserId,
|
||||
});
|
||||
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
assets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 2 },
|
||||
{ year: 2023, assetCount: 1 },
|
||||
{ year: 2022, assetCount: 2 },
|
||||
],
|
||||
123,
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
memoryChanges.memoryDeletions = [];
|
||||
memoryChanges.assetRemovals.clear();
|
||||
});
|
||||
|
||||
test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => {
|
||||
const singleAssetMemory = memories[1];
|
||||
const singleAsset = singleAssetMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
});
|
||||
123
e2e/src/web/specs/memory/utils.ts
Normal file
123
e2e/src/web/specs/memory/utils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
|
||||
function getAssetIdFromUrl(url: URL): string | null {
|
||||
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
|
||||
if (pathMatch) {
|
||||
return pathMatch[1];
|
||||
}
|
||||
return url.searchParams.get('id');
|
||||
}
|
||||
|
||||
export const memoryViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#memory-viewer');
|
||||
},
|
||||
|
||||
async waitForMemoryLoad(page: Page) {
|
||||
await expect(this.locator(page)).toBeVisible();
|
||||
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
|
||||
},
|
||||
|
||||
async openMemoryPage(page: Page) {
|
||||
await page.goto('/memory');
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
|
||||
async openMemoryPageWithAsset(page: Page, assetId: string) {
|
||||
await page.goto(`/memory?id=${assetId}`);
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
};
|
||||
|
||||
export const memoryGalleryUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#gallery-memory');
|
||||
},
|
||||
|
||||
thumbnailWithAssetId(page: Page, assetId: string) {
|
||||
return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
||||
},
|
||||
|
||||
async scrollToGallery(page: Page) {
|
||||
const showGalleryButton = page.getByLabel('Show gallery');
|
||||
if (await showGalleryButton.isVisible()) {
|
||||
await showGalleryButton.click();
|
||||
}
|
||||
await expect(this.locator(page)).toBeInViewport();
|
||||
},
|
||||
|
||||
async clickThumbnail(page: Page, assetId: string) {
|
||||
await this.scrollToGallery(page);
|
||||
await this.thumbnailWithAssetId(page, assetId).click();
|
||||
},
|
||||
|
||||
async getAllThumbnails(page: Page) {
|
||||
await this.scrollToGallery(page);
|
||||
return page.locator('#gallery-memory [data-thumbnail-focus-container]');
|
||||
},
|
||||
};
|
||||
|
||||
export const memoryAssetViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
|
||||
async waitForViewerOpen(page: Page) {
|
||||
await expect(this.locator(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async waitForAssetLoad(page: Page, asset: AssetResponseDto) {
|
||||
const viewer = this.locator(page);
|
||||
const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
|
||||
const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
|
||||
|
||||
await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 });
|
||||
},
|
||||
|
||||
nextButton(page: Page) {
|
||||
return page.getByLabel('View next asset');
|
||||
},
|
||||
|
||||
previousButton(page: Page) {
|
||||
return page.getByLabel('View previous asset');
|
||||
},
|
||||
|
||||
async expectNextButtonVisible(page: Page) {
|
||||
await expect(this.nextButton(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async expectNextButtonNotVisible(page: Page) {
|
||||
await expect(this.nextButton(page)).toHaveCount(0);
|
||||
},
|
||||
|
||||
async expectPreviousButtonVisible(page: Page) {
|
||||
await expect(this.previousButton(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async expectPreviousButtonNotVisible(page: Page) {
|
||||
await expect(this.previousButton(page)).toHaveCount(0);
|
||||
},
|
||||
|
||||
async clickNextButton(page: Page) {
|
||||
await this.nextButton(page).click();
|
||||
},
|
||||
|
||||
async clickPreviousButton(page: Page) {
|
||||
await this.previousButton(page).click();
|
||||
},
|
||||
|
||||
async closeViewer(page: Page) {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(this.locator(page)).not.toBeVisible();
|
||||
},
|
||||
|
||||
getCurrentAssetId(page: Page): string | null {
|
||||
const url = new URL(page.url());
|
||||
return getAssetIdFromUrl(url);
|
||||
},
|
||||
|
||||
async expectCurrentAssetId(page: Page, expectedAssetId: string) {
|
||||
await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId);
|
||||
},
|
||||
};
|
||||
@@ -41,7 +41,6 @@ types = [
|
||||
"types-ujson>=5.10.0.20240515",
|
||||
]
|
||||
lint = [
|
||||
"black>=23.3.0",
|
||||
"mypy>=1.3.0",
|
||||
"ruff>=0.0.272",
|
||||
{ include-group = "types" },
|
||||
@@ -93,9 +92,5 @@ target-version = "py311"
|
||||
select = ["E", "F", "I"]
|
||||
per-file-ignores = { "test_main.py" = ["F403"] }
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
target-version = ['py311']
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = ["providers", "ov_device_ids"]
|
||||
|
||||
50
machine-learning/uv.lock
generated
50
machine-learning/uv.lock
generated
@@ -85,43 +85,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "25.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pytokens" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.7.0"
|
||||
@@ -961,7 +924,6 @@ rknn = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "black" },
|
||||
{ name = "httpx" },
|
||||
{ name = "locust" },
|
||||
{ name = "mypy" },
|
||||
@@ -977,7 +939,6 @@ dev = [
|
||||
{ name = "types-ujson" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "black" },
|
||||
{ name = "mypy" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-pyyaml" },
|
||||
@@ -1031,7 +992,6 @@ provides-extras = ["cpu", "cuda", "openvino", "armnn", "rknn", "rocm"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "black", specifier = ">=23.3.0" },
|
||||
{ name = "httpx", specifier = ">=0.24.1" },
|
||||
{ name = "locust", specifier = ">=2.15.1" },
|
||||
{ name = "mypy", specifier = ">=1.3.0" },
|
||||
@@ -1047,7 +1007,6 @@ dev = [
|
||||
{ name = "types-ujson", specifier = ">=5.10.0.20240515" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "black", specifier = ">=23.3.0" },
|
||||
{ name = "mypy", specifier = ">=1.3.0" },
|
||||
{ name = "ruff", specifier = ">=0.0.272" },
|
||||
{ name = "types-pyyaml", specifier = ">=6.0.12.20241230" },
|
||||
@@ -2232,15 +2191,6 @@ client = [
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytokens"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
|
||||
@@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
|
||||
|
||||
@RoutePage()
|
||||
class FailedBackupStatusPage extends HookConsumerWidget {
|
||||
@@ -58,7 +59,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Image(
|
||||
fit: BoxFit.cover,
|
||||
image: ImmichLocalThumbnailProvider(asset: errorAsset.asset, height: 512, width: 512),
|
||||
image: LocalThumbProvider(id: errorAsset.asset.localId!, assetType: base_asset.AssetType.video),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||
|
||||
class GalleryStackedChildren extends HookConsumerWidget {
|
||||
final ValueNotifier<int> stackIndex;
|
||||
@@ -70,7 +70,7 @@ class GalleryStackedChildren extends HookConsumerWidget {
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: Image(
|
||||
fit: BoxFit.cover,
|
||||
image: ImmichRemoteImageProvider(assetId: assetId),
|
||||
image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: asset.thumbhash ?? ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/partner.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
|
||||
@@ -221,12 +221,7 @@ class PeopleCollectionCard extends ConsumerWidget {
|
||||
mainAxisSpacing: 8,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: people.take(4).map((person) {
|
||||
return CircleAvatar(
|
||||
backgroundImage: NetworkImage(
|
||||
getFaceThumbnailUrl(person.id),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
),
|
||||
);
|
||||
return CircleAvatar(backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)));
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/search/people.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
|
||||
@@ -17,7 +17,6 @@ class PeopleCollectionPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final people = ref.watch(getAllPeopleProvider);
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final formFocus = useFocusNode();
|
||||
final ValueNotifier<String?> search = useState(null);
|
||||
|
||||
@@ -88,7 +87,7 @@ class PeopleCollectionPage extends HookConsumerWidget {
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
maxRadius: isTablet ? 120 / 2 : 96 / 2,
|
||||
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
|
||||
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
@@ -10,9 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
@@ -125,13 +125,10 @@ class PlaceTile extends StatelessWidget {
|
||||
title: Text(name, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
child: CachedNetworkImage(
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailUrl,
|
||||
httpHeaders: ApiService.getRequestHeaders(),
|
||||
errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined),
|
||||
child: Thumbnail(imageProvider: RemoteImageProvider(url: thumbnailUrl)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/search/people.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
@@ -88,10 +88,7 @@ class PersonResultPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(left: 8.0, top: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 36,
|
||||
backgroundImage: NetworkImage(getFaceThumbnailUrl(personId), headers: ApiService.getRequestHeaders()),
|
||||
),
|
||||
CircleAvatar(radius: 36, backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(personId))),
|
||||
Expanded(
|
||||
child: Padding(padding: const EdgeInsets.only(left: 16.0, right: 16.0), child: buildTitleBlock()),
|
||||
),
|
||||
|
||||
@@ -12,8 +12,8 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
@@ -179,12 +179,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
|
||||
mainAxisSpacing: 8,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: people.take(4).map((person) {
|
||||
return CircleAvatar(
|
||||
backgroundImage: NetworkImage(
|
||||
getFaceThumbnailUrl(person.id),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
),
|
||||
);
|
||||
return CircleAvatar(backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)));
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/utils/people.utils.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
@@ -31,7 +31,6 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final people = ref.watch(driftGetAllPeopleProvider);
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -90,7 +89,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
maxRadius: isTablet ? 100 / 2 : 96 / 2,
|
||||
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
|
||||
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,8 +10,8 @@ import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/utils/people.utils.dart';
|
||||
|
||||
@@ -108,8 +108,6 @@ class _PeopleAvatar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 96),
|
||||
child: Padding(
|
||||
@@ -127,7 +125,7 @@ class _PeopleAvatar extends StatelessWidget {
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
maxRadius: imageSize / 2,
|
||||
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
|
||||
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -134,7 +134,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
|
||||
|
||||
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
||||
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
|
||||
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
|
||||
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash) : null;
|
||||
}
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
|
||||
@@ -10,50 +10,48 @@ import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||
with CancellableImageProviderMixin<RemoteThumbProvider> {
|
||||
final String assetId;
|
||||
final String thumbhash;
|
||||
class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
|
||||
with CancellableImageProviderMixin<RemoteImageProvider> {
|
||||
final String url;
|
||||
|
||||
RemoteThumbProvider({required this.assetId, required this.thumbhash});
|
||||
RemoteImageProvider({required this.url});
|
||||
|
||||
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash})
|
||||
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash);
|
||||
|
||||
@override
|
||||
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
Future<RemoteImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
||||
ImageStreamCompleter loadImage(RemoteImageProvider key, ImageDecoderCallback decode) {
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
DiagnosticsProperty<String>('URL', key.url),
|
||||
],
|
||||
onDispose: cancel,
|
||||
);
|
||||
}
|
||||
|
||||
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
);
|
||||
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
|
||||
final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders());
|
||||
return loadRequest(request, decode);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is RemoteThumbProvider) {
|
||||
return assetId == other.assetId && thumbhash == other.thumbhash;
|
||||
if (other is RemoteImageProvider) {
|
||||
return url == other.url;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
|
||||
int get hashCode => url.hashCode;
|
||||
}
|
||||
|
||||
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
|
||||
@@ -73,7 +71,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
|
||||
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
|
||||
@@ -27,7 +27,7 @@ class Thumbnail extends StatefulWidget {
|
||||
this.fit = BoxFit.cover,
|
||||
Size size = kThumbnailResolution,
|
||||
super.key,
|
||||
}) : imageProvider = RemoteThumbProvider(assetId: remoteId, thumbhash: thumbhash),
|
||||
}) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash),
|
||||
thumbhashProvider = null;
|
||||
|
||||
Thumbnail.fromAsset({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
|
||||
class PartnerUserAvatar extends StatelessWidget {
|
||||
const PartnerUserAvatar({super.key, required this.partner});
|
||||
@@ -18,11 +17,7 @@ class PartnerUserAvatar extends StatelessWidget {
|
||||
return CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: context.primaryColor.withAlpha(50),
|
||||
foregroundImage: CachedNetworkImageProvider(
|
||||
url,
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
cacheKey: "user-${partner.id}-profile",
|
||||
),
|
||||
foregroundImage: RemoteImageProvider(url: url),
|
||||
// silence errors if user has no profile image, use initials as fallback
|
||||
onForegroundImageError: (exception, stackTrace) {},
|
||||
child: Text(nameFirstLetter.toUpperCase()),
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
|
||||
|
||||
/// The local image provider for an asset
|
||||
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
||||
final Asset asset;
|
||||
// only used for videos
|
||||
final double width;
|
||||
final double height;
|
||||
final Logger log = Logger('ImmichLocalImageProvider');
|
||||
|
||||
ImmichLocalImageProvider({required this.asset, required this.width, required this.height})
|
||||
: assert(asset.local != null, 'Only usable when asset.local is set');
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImmichLocalImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(ImmichLocalImageProvider key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key.asset, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription(asset.fileName);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
Asset asset,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
try {
|
||||
final local = asset.local;
|
||||
if (local == null) {
|
||||
throw StateError('Asset ${asset.fileName} has no local data');
|
||||
}
|
||||
|
||||
switch (asset.type) {
|
||||
case AssetType.image:
|
||||
final File? file = await local.originFile;
|
||||
if (file == null) {
|
||||
throw StateError("Opening file for asset ${asset.fileName} failed");
|
||||
}
|
||||
final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
|
||||
yield await decode(buffer);
|
||||
break;
|
||||
case AssetType.video:
|
||||
final size = ThumbnailSize(width.ceil(), height.ceil());
|
||||
final thumbBytes = await local.thumbnailDataWithSize(size);
|
||||
if (thumbBytes == null) {
|
||||
throw StateError("Failed to load preview for ${asset.fileName}");
|
||||
}
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
yield await decode(buffer);
|
||||
break;
|
||||
default:
|
||||
throw StateError('Unsupported asset type ${asset.type}');
|
||||
}
|
||||
} catch (error, stack) {
|
||||
log.severe('Error loading local image ${asset.fileName}', error, stack);
|
||||
} finally {
|
||||
unawaited(chunkEvents.close());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ImmichLocalImageProvider) {
|
||||
return asset.id == other.asset.id && asset.localId == other.asset.localId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(asset.id, asset.localId);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// The local image provider for an asset
|
||||
/// Only viable
|
||||
class ImmichLocalThumbnailProvider extends ImageProvider<ImmichLocalThumbnailProvider> {
|
||||
final Asset asset;
|
||||
final int height;
|
||||
final int width;
|
||||
final CacheManager? cacheManager;
|
||||
final Logger log = Logger("ImmichLocalThumbnailProvider");
|
||||
final String? userId;
|
||||
|
||||
ImmichLocalThumbnailProvider({
|
||||
required this.asset,
|
||||
this.height = 256,
|
||||
this.width = 256,
|
||||
this.cacheManager,
|
||||
this.userId,
|
||||
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImmichLocalThumbnailProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(ImmichLocalThumbnailProvider key, ImageDecoderCallback decode) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key.asset, cache, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription(key.asset.fileName);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(Asset assetData, CacheManager cache, ImageDecoderCallback decode) async* {
|
||||
final cacheKey = '$userId${assetData.localId}${assetData.checksum}$width$height';
|
||||
final fileFromCache = await cache.getFileFromCache(cacheKey);
|
||||
if (fileFromCache != null) {
|
||||
try {
|
||||
final buffer = await ui.ImmutableBuffer.fromFilePath(fileFromCache.file.path);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
return;
|
||||
} catch (error) {
|
||||
log.severe('Found thumbnail in cache, but loading it failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
final thumbnailBytes = await assetData.local?.thumbnailDataWithSize(ThumbnailSize(width, height), quality: 80);
|
||||
if (thumbnailBytes == null) {
|
||||
throw StateError("Loading thumb for local photo ${assetData.fileName} failed");
|
||||
}
|
||||
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbnailBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
await cache.putFile(cacheKey, thumbnailBytes);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ImmichLocalThumbnailProvider) {
|
||||
return asset.id == other.asset.id && asset.localId == other.asset.localId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(asset.id, asset.localId);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:openapi/api.dart' as api;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
/// The remote image provider for full size remote images
|
||||
class ImmichRemoteImageProvider extends ImageProvider<ImmichRemoteImageProvider> {
|
||||
/// The [Asset.remoteId] of the asset to fetch
|
||||
final String assetId;
|
||||
|
||||
/// The image cache manager
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
const ImmichRemoteImageProvider({required this.assetId, this.cacheManager});
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImmichRemoteImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(ImmichRemoteImageProvider key, ImageDecoderCallback decode) {
|
||||
final cache = cacheManager ?? RemoteImageCacheManager();
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether to show the original file or load a compressed version
|
||||
bool get _useOriginal => Store.get(AppSettingsEnum.loadOriginal.storeKey, AppSettingsEnum.loadOriginal.defaultValue);
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
ImmichRemoteImageProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load the higher resolution version of the image
|
||||
final url = getThumbnailUrlForRemoteId(key.assetId, type: api.AssetMediaSize.preview);
|
||||
final codec = await ImageLoader.loadImageFromCache(url, cache: cache, decode: decode, chunkEvents: chunkEvents);
|
||||
yield codec;
|
||||
|
||||
// Load the final remote image
|
||||
if (_useOriginal) {
|
||||
// Load the original image
|
||||
final url = getOriginalUrlForRemoteId(key.assetId);
|
||||
final codec = await ImageLoader.loadImageFromCache(url, cache: cache, decode: decode, chunkEvents: chunkEvents);
|
||||
yield codec;
|
||||
}
|
||||
await chunkEvents.close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ImmichRemoteImageProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
import 'package:openapi/api.dart' as api;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
/// The remote image provider
|
||||
class ImmichRemoteThumbnailProvider extends ImageProvider<ImmichRemoteThumbnailProvider> {
|
||||
/// The [Asset.remoteId] of the asset to fetch
|
||||
final String assetId;
|
||||
|
||||
final int? height;
|
||||
final int? width;
|
||||
|
||||
/// The image cache manager
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
const ImmichRemoteThumbnailProvider({required this.assetId, this.height, this.width, this.cacheManager});
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImmichRemoteThumbnailProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(ImmichRemoteThumbnailProvider key, ImageDecoderCallback decode) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
return MultiImageStreamCompleter(codec: _codec(key, cache, decode), scale: 1.0);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(ImmichRemoteThumbnailProvider key, CacheManager cache, ImageDecoderCallback decode) async* {
|
||||
// Load a preview to the chunk events
|
||||
final preview = getThumbnailUrlForRemoteId(key.assetId, type: api.AssetMediaSize.thumbnail);
|
||||
|
||||
yield await ImageLoader.loadImageFromCache(preview, cache: cache, decode: decode);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ImmichRemoteThumbnailProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
||||
11
mobile/lib/utils/cache/custom_image_cache.dart
vendored
11
mobile/lib/utils/cache/custom_image_cache.dart
vendored
@@ -2,10 +2,6 @@ import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
|
||||
|
||||
/// [ImageCache] that uses two caches for small and large images
|
||||
/// so that a single large image does not evict all small images
|
||||
@@ -39,14 +35,9 @@ final class CustomImageCache implements ImageCache {
|
||||
}
|
||||
|
||||
/// Gets the cache for the given key
|
||||
/// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider]
|
||||
/// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider]
|
||||
ImageCache _cacheForKey(Object key) {
|
||||
return switch (key) {
|
||||
ImmichLocalImageProvider() ||
|
||||
ImmichRemoteImageProvider() ||
|
||||
LocalFullImageProvider() ||
|
||||
RemoteFullImageProvider() => _large,
|
||||
LocalFullImageProvider() || RemoteFullImageProvider() => _large,
|
||||
ThumbHashProvider() => _thumbhash,
|
||||
_ => _small,
|
||||
};
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/activity_service.provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||
|
||||
@@ -102,7 +102,7 @@ class _ActivityAssetThumbnail extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
image: DecorationImage(
|
||||
image: ImmichRemoteThumbnailProvider(assetId: assetId),
|
||||
image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: ""),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,9 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/activity_service.provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
|
||||
@@ -56,7 +56,7 @@ class CommentBubble extends ConsumerWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: Image(
|
||||
image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!),
|
||||
image: RemoteImageProvider.thumbnail(assetId: activity.assetId!, thumbhash: ""),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -32,15 +32,12 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
buildAlbumThumbnail() {
|
||||
return CachedNetworkImage(
|
||||
return SizedBox(
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageUrl: getAlbumThumbnailUrl(album, type: AssetMediaSize.thumbnail),
|
||||
httpHeaders: ApiService.getRequestHeaders(),
|
||||
cacheKey: getAlbumThumbNailCacheKey(album, type: AssetMediaSize.thumbnail),
|
||||
errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined),
|
||||
child: Thumbnail(
|
||||
imageProvider: RemoteImageProvider(url: getAlbumThumbnailUrl(album, type: AssetMediaSize.thumbnail)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
@@ -34,13 +35,21 @@ class ImmichImage extends StatelessWidget {
|
||||
}
|
||||
|
||||
if (asset == null) {
|
||||
return ImmichRemoteImageProvider(assetId: assetId!);
|
||||
return RemoteFullImageProvider(assetId: assetId!, thumbhash: '', assetType: base_asset.AssetType.video);
|
||||
}
|
||||
|
||||
if (useLocal(asset)) {
|
||||
return ImmichLocalImageProvider(asset: asset, width: width, height: height);
|
||||
return LocalFullImageProvider(
|
||||
id: asset.localId!,
|
||||
assetType: base_asset.AssetType.video,
|
||||
size: Size(width, height),
|
||||
);
|
||||
} else {
|
||||
return ImmichRemoteImageProvider(assetId: asset.remoteId!);
|
||||
return RemoteFullImageProvider(
|
||||
assetId: asset.remoteId!,
|
||||
thumbhash: asset.thumbhash ?? '',
|
||||
assetType: base_asset.AssetType.video,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
||||
import 'package:immich_mobile/utils/thumbnail_utils.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||
import 'package:immich_mobile/widgets/common/thumbhash_placeholder.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
|
||||
|
||||
class ImmichThumbnail extends HookConsumerWidget {
|
||||
const ImmichThumbnail({this.asset, this.width = 250, this.height = 250, this.fit = BoxFit.cover, super.key});
|
||||
@@ -24,26 +24,29 @@ class ImmichThumbnail extends HookConsumerWidget {
|
||||
/// either by using the asset ID or the asset itself
|
||||
/// [asset] is the Asset to request, or else use [assetId] to get a remote
|
||||
/// image provider
|
||||
static ImageProvider imageProvider({Asset? asset, String? assetId, String? userId, int thumbnailSize = 256}) {
|
||||
static ImageProvider imageProvider({Asset? asset, String? assetId, int thumbnailSize = 256}) {
|
||||
if (asset == null && assetId == null) {
|
||||
throw Exception('Must supply either asset or assetId');
|
||||
}
|
||||
|
||||
if (asset == null) {
|
||||
return ImmichRemoteThumbnailProvider(assetId: assetId!);
|
||||
return RemoteImageProvider.thumbnail(assetId: assetId!, thumbhash: "");
|
||||
}
|
||||
|
||||
if (ImmichImage.useLocal(asset)) {
|
||||
return ImmichLocalThumbnailProvider(asset: asset, height: thumbnailSize, width: thumbnailSize, userId: userId);
|
||||
return LocalThumbProvider(
|
||||
id: asset.localId!,
|
||||
assetType: base_asset.AssetType.video,
|
||||
size: Size(thumbnailSize.toDouble(), thumbnailSize.toDouble()),
|
||||
);
|
||||
} else {
|
||||
return ImmichRemoteThumbnailProvider(assetId: asset.remoteId!, height: thumbnailSize, width: thumbnailSize);
|
||||
return RemoteImageProvider.thumbnail(assetId: asset.remoteId!, thumbhash: asset.thumbhash ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Uint8List? blurhash = useBlurHashRef(asset).value;
|
||||
final userId = ref.watch(currentUserProvider)?.id;
|
||||
|
||||
if (asset == null) {
|
||||
return Container(
|
||||
@@ -56,7 +59,7 @@ class ImmichThumbnail extends HookConsumerWidget {
|
||||
|
||||
final assetAltText = getAltText(asset!.exifInfo, asset!.fileCreatedAt, asset!.type, []);
|
||||
|
||||
final thumbnailProviderInstance = ImmichThumbnail.imageProvider(asset: asset, userId: userId);
|
||||
final thumbnailProviderInstance = ImmichThumbnail.imageProvider(asset: asset);
|
||||
|
||||
customErrorBuilder(BuildContext ctx, Object error, StackTrace? stackTrace) {
|
||||
thumbnailProviderInstance.evict();
|
||||
|
||||
@@ -14,8 +14,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/people.utils.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
@@ -230,10 +230,7 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
maxRadius: 84 / 2,
|
||||
backgroundImage: NetworkImage(
|
||||
getFaceThumbnailUrl(widget.person.id),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
),
|
||||
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(widget.person.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
|
||||
Widget userAvatar(BuildContext context, UserDto u, {double? radius}) {
|
||||
final url = "${Store.get(StoreKey.serverEndpoint)}/users/${u.id}/profile-image";
|
||||
@@ -12,11 +11,7 @@ Widget userAvatar(BuildContext context, UserDto u, {double? radius}) {
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: context.primaryColor.withAlpha(50),
|
||||
foregroundImage: CachedNetworkImageProvider(
|
||||
url,
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
cacheKey: "user-${u.id}-profile",
|
||||
),
|
||||
foregroundImage: RemoteImageProvider(url: url),
|
||||
// silence errors if user has no profile image, use initials as fallback
|
||||
onForegroundImageError: (exception, stackTrace) {},
|
||||
child: Text(nameFirstLetter.toUpperCase()),
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/transparent_image.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class UserCircleAvatar extends ConsumerWidget {
|
||||
@@ -46,16 +44,12 @@ class UserCircleAvatar extends ConsumerWidget {
|
||||
child: user.hasProfileImage
|
||||
? ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(50)),
|
||||
child: CachedNetworkImage(
|
||||
child: Image(
|
||||
fit: BoxFit.cover,
|
||||
cacheKey: '${user.id}-${user.profileChangedAt.toIso8601String()}',
|
||||
width: size,
|
||||
height: size,
|
||||
placeholder: (_, __) => Image.memory(kTransparentImage),
|
||||
imageUrl: profileImageUrl,
|
||||
httpHeaders: ApiService.getRequestHeaders(),
|
||||
fadeInDuration: const Duration(milliseconds: 300),
|
||||
errorWidget: (context, error, stackTrace) => textIcon,
|
||||
image: RemoteImageProvider(url: profileImageUrl),
|
||||
errorBuilder: (context, error, stackTrace) => textIcon,
|
||||
),
|
||||
)
|
||||
: textIcon,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class PositionedAssetMarkerIcon extends StatelessWidget {
|
||||
@@ -53,7 +52,6 @@ class _AssetMarkerIcon extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = getThumbnailUrlForRemoteId(id);
|
||||
final cacheKey = getThumbnailCacheKeyForRemoteId(id, thumbhash);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
@@ -79,12 +77,7 @@ class _AssetMarkerIcon extends StatelessWidget {
|
||||
backgroundColor: context.colorScheme.onSurface,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.37,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
cacheKey: cacheKey,
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
errorListener: (_) => const Icon(Icons.image_not_supported_outlined),
|
||||
),
|
||||
backgroundImage: RemoteImageProvider(url: imageUrl),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class CuratedPeopleRow extends StatelessWidget {
|
||||
@@ -29,7 +29,6 @@ class CuratedPeopleRow extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: List.generate(content.length, (index) {
|
||||
final person = content[index];
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: Column(
|
||||
@@ -44,7 +43,7 @@ class CuratedPeopleRow extends StatelessWidget {
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
maxRadius: imageSize / 2,
|
||||
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
|
||||
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,8 +6,8 @@ import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/search/people.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
|
||||
@@ -23,7 +23,6 @@ class PeoplePicker extends HookConsumerWidget {
|
||||
final imageSize = 60.0;
|
||||
final searchQuery = useState('');
|
||||
final people = ref.watch(getAllPeopleProvider);
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final selectedPeople = useState<Set<PersonDto>>(filter ?? {});
|
||||
|
||||
return Column(
|
||||
@@ -75,7 +74,7 @@ class PeoplePicker extends HookConsumerWidget {
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
maxRadius: imageSize / 2,
|
||||
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
|
||||
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/widgets/search/thumbnail_with_info_container.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
|
||||
class ThumbnailWithInfo extends StatelessWidget {
|
||||
const ThumbnailWithInfo({
|
||||
@@ -30,14 +30,7 @@ class ThumbnailWithInfo extends StatelessWidget {
|
||||
child: imageUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: CachedNetworkImage(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl!,
|
||||
httpHeaders: ApiService.getRequestHeaders(),
|
||||
errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined),
|
||||
),
|
||||
child: Thumbnail(imageProvider: RemoteImageProvider(url: imageUrl!)),
|
||||
)
|
||||
: Center(child: Icon(noImageIcon ?? Icons.not_listed_location, color: textAndIconColor)),
|
||||
);
|
||||
|
||||
@@ -201,30 +201,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.9.5"
|
||||
cached_network_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cached_network_image
|
||||
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
cached_network_image_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_platform_interface
|
||||
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
cached_network_image_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_web
|
||||
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
cancellation_token:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1249,10 +1225,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1942,10 +1918,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
version: "0.7.7"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -12,7 +12,6 @@ dependencies:
|
||||
async: ^2.13.0
|
||||
auto_route: ^9.2.0
|
||||
background_downloader: ^9.3.0
|
||||
cached_network_image: ^3.4.1
|
||||
cancellation_token_http: ^2.1.0
|
||||
cast: ^2.1.0
|
||||
collection: ^1.19.1
|
||||
|
||||
103
pnpm-lock.yaml
generated
103
pnpm-lock.yaml
generated
@@ -9,7 +9,7 @@ overrides:
|
||||
canvas: 2.11.2
|
||||
sharp: ^0.34.5
|
||||
|
||||
packageExtensionsChecksum: sha256-vheqqqBU5SU8N8ma3OjzLM07nd511Xmy+mOvgxie+Ts=
|
||||
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
|
||||
|
||||
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0=
|
||||
|
||||
@@ -421,9 +421,6 @@ importers:
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
better-sqlite3:
|
||||
specifier: ^12.6.2
|
||||
version: 12.6.2
|
||||
body-parser:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.2
|
||||
@@ -608,9 +605,6 @@ importers:
|
||||
'@types/bcrypt':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
'@types/better-sqlite3':
|
||||
specifier: ^7.6.13
|
||||
version: 7.6.13
|
||||
'@types/body-parser':
|
||||
specifier: ^1.19.6
|
||||
version: 1.19.6
|
||||
@@ -5086,9 +5080,6 @@ packages:
|
||||
'@types/bcrypt@6.0.0':
|
||||
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
|
||||
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
@@ -6061,10 +6052,6 @@ packages:
|
||||
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
better-sqlite3@12.6.2:
|
||||
resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==}
|
||||
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
|
||||
|
||||
big.js@5.2.2:
|
||||
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
|
||||
|
||||
@@ -6072,9 +6059,6 @@ packages:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
bindings@1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
|
||||
bits-ui@2.14.4:
|
||||
resolution: {integrity: sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -7601,10 +7585,6 @@ packages:
|
||||
resolution: {integrity: sha512-CpNH1FAhIQG5AlKndlTf05mNbuFxINyzG9629ZI/CKwr+39zWo8swxpnXc3GUfUvUfxkCCxumDPy2QVmi3XJkQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -7720,9 +7700,6 @@ packages:
|
||||
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
file-uri-to-path@1.0.0:
|
||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7923,9 +7900,6 @@ packages:
|
||||
get-tsconfig@4.13.0:
|
||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
github-slugger@1.5.0:
|
||||
resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==}
|
||||
|
||||
@@ -9557,9 +9531,6 @@ packages:
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
|
||||
natural-compare-lite@1.4.0:
|
||||
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
|
||||
|
||||
@@ -9622,10 +9593,6 @@ packages:
|
||||
no-case@3.0.4:
|
||||
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
|
||||
|
||||
node-abi@3.87.0:
|
||||
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
node-abort-controller@3.1.1:
|
||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||
|
||||
@@ -10572,11 +10539,6 @@ packages:
|
||||
potpack@2.1.0:
|
||||
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -11283,9 +11245,6 @@ packages:
|
||||
simple-get@3.1.1:
|
||||
resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==}
|
||||
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
simple-icons@15.22.0:
|
||||
resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==}
|
||||
engines: {node: '>=0.12.18'}
|
||||
@@ -11982,9 +11941,6 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
tweetnacl@0.14.5:
|
||||
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||
|
||||
@@ -17899,10 +17855,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
@@ -19087,23 +19039,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
better-sqlite3@12.6.2:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
node-addon-api: 8.5.0
|
||||
node-gyp: 12.1.0
|
||||
prebuild-install: 7.1.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
big.js@5.2.2: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bindings@1.5.0:
|
||||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
|
||||
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.3
|
||||
@@ -20869,8 +20808,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
exiftool-vendored.exe: 13.45.0
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
exponential-backoff@3.1.3: {}
|
||||
@@ -21048,8 +20985,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
file-uri-to-path@1.0.0: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
@@ -21266,8 +21201,6 @@ snapshots:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
github-from-package@0.0.0: {}
|
||||
|
||||
github-slugger@1.5.0: {}
|
||||
|
||||
gl-matrix@3.4.4: {}
|
||||
@@ -23383,8 +23316,6 @@ snapshots:
|
||||
|
||||
nanoid@5.1.6: {}
|
||||
|
||||
napi-build-utils@2.0.0: {}
|
||||
|
||||
natural-compare-lite@1.4.0: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -23449,10 +23380,6 @@ snapshots:
|
||||
lower-case: 2.0.2
|
||||
tslib: 2.8.1
|
||||
|
||||
node-abi@3.87.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
node-abort-controller@3.1.1: {}
|
||||
|
||||
node-addon-api@4.3.0: {}
|
||||
@@ -24441,21 +24368,6 @@ snapshots:
|
||||
|
||||
potpack@2.1.0: {}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
expand-template: 2.0.3
|
||||
github-from-package: 0.0.0
|
||||
minimist: 1.2.8
|
||||
mkdirp-classic: 0.5.3
|
||||
napi-build-utils: 2.0.0
|
||||
node-abi: 3.87.0
|
||||
pump: 3.0.3
|
||||
rc: 1.2.8
|
||||
simple-get: 4.0.1
|
||||
tar-fs: 2.1.4
|
||||
tunnel-agent: 0.6.0
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-linter-helpers@1.0.1:
|
||||
@@ -25392,7 +25304,8 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
simple-concat@1.0.1:
|
||||
optional: true
|
||||
|
||||
simple-get@3.1.1:
|
||||
dependencies:
|
||||
@@ -25401,12 +25314,6 @@ snapshots:
|
||||
simple-concat: 1.0.1
|
||||
optional: true
|
||||
|
||||
simple-get@4.0.1:
|
||||
dependencies:
|
||||
decompress-response: 6.0.0
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
simple-icons@15.22.0: {}
|
||||
|
||||
simple-icons@16.4.0: {}
|
||||
@@ -26243,10 +26150,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
tweetnacl@0.14.5: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
|
||||
@@ -29,7 +29,6 @@ onlyBuiltDependencies:
|
||||
- sharp
|
||||
- '@tailwindcss/oxide'
|
||||
- bcrypt
|
||||
- better-sqlite3
|
||||
overrides:
|
||||
canvas: 2.11.2
|
||||
sharp: ^0.34.5
|
||||
@@ -60,10 +59,6 @@ packageExtensions:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
better-sqlite3:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
dedupePeerDependents: false
|
||||
preferWorkspacePackages: true
|
||||
injectWorkspacePackages: true
|
||||
|
||||
@@ -6,4 +6,4 @@ if [[ "$IMMICH_ENV" == "production" ]]; then
|
||||
fi
|
||||
|
||||
cd /usr/src/app || exit
|
||||
exec pnpm --filter immich exec nest start --no-shell --debug "0.0.0.0:9230" --watch -- "$@"
|
||||
pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch -- "$@"
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
"archiver": "^7.0.0",
|
||||
"async-lock": "^1.4.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"body-parser": "^2.2.0",
|
||||
"bullmq": "^5.51.0",
|
||||
"chokidar": "^4.0.3",
|
||||
@@ -125,7 +124,6 @@
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/async-lock": "^1.4.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/body-parser": "^1.19.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
|
||||
@@ -89,10 +89,8 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
console.log(`[${this.worker}] onModuleDestroy called - emitting AppShutdown`);
|
||||
await this.eventRepository.emit('AppShutdown');
|
||||
await teardownTelemetry();
|
||||
console.log(`[${this.worker}] onModuleDestroy complete`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +148,6 @@ export class ImmichAdminModule implements OnModuleDestroy {
|
||||
constructor(private service: CliService) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
console.log('[ImmichAdmin] onModuleDestroy called');
|
||||
await this.service.cleanup();
|
||||
console.log('[ImmichAdmin] onModuleDestroy complete');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
PromptConfirmMoveQuestions,
|
||||
PromptMediaLocationQuestions,
|
||||
} from 'src/commands/media-location.command';
|
||||
import {
|
||||
MigrateThumbnailsCommand,
|
||||
PromptConfirmMigrationQuestion,
|
||||
} from 'src/commands/migrate-thumbnails.command';
|
||||
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
||||
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
|
||||
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
|
||||
@@ -32,6 +28,4 @@ export const commandsAndQuestions = [
|
||||
ChangeMediaLocationCommand,
|
||||
PromptMediaLocationQuestions,
|
||||
PromptConfirmMoveQuestions,
|
||||
MigrateThumbnailsCommand,
|
||||
PromptConfirmMigrationQuestion,
|
||||
];
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Command, CommandRunner, InquirerService, Option, Question, QuestionSet } from 'nest-commander';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
@Command({
|
||||
name: 'migrate-thumbnails-to-sqlite',
|
||||
description: 'Migrate thumbnails from filesystem to SQLite storage',
|
||||
})
|
||||
export class MigrateThumbnailsCommand extends CommandRunner {
|
||||
constructor(
|
||||
private service: CliService,
|
||||
private inquirer: InquirerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-p, --path <path>',
|
||||
description: 'Absolute path to the SQLite database file',
|
||||
})
|
||||
parsePath(value: string) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-y, --yes',
|
||||
description: 'Skip confirmation prompt',
|
||||
})
|
||||
parseYes() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: { path?: string; yes?: boolean }): Promise<void> {
|
||||
try {
|
||||
const sqlitePath = options.path ?? this.service.getDefaultThumbnailStoragePath();
|
||||
|
||||
console.log(`\nMigration settings:`);
|
||||
console.log(` SQLite path: ${sqlitePath}`);
|
||||
console.log(`\nThis will read all thumbnail files from the filesystem and store them in SQLite.`);
|
||||
console.log(`Existing entries in SQLite will be skipped.\n`);
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirmed } = await this.inquirer.ask<{ confirmed: boolean }>('prompt-confirm-migration', {});
|
||||
if (!confirmed) {
|
||||
console.log('Migration cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nStarting migration...\n');
|
||||
|
||||
let lastProgressUpdate = 0;
|
||||
|
||||
const result = await this.service.migrateThumbnailsToSqlite({
|
||||
sqlitePath,
|
||||
onProgress: ({ current, migrated, skipped, errors }) => {
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate > 500 || current === 1) {
|
||||
lastProgressUpdate = now;
|
||||
process.stdout.write(
|
||||
`\rProcessed: ${current} | Migrated: ${migrated} | Skipped: ${skipped} | Errors: ${errors}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`\n\nMigration complete!`);
|
||||
console.log(` Total processed: ${result.total}`);
|
||||
console.log(` Migrated: ${result.migrated}`);
|
||||
console.log(` Skipped: ${result.skipped}`);
|
||||
console.log(` Errors: ${result.errors}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Migration failed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@QuestionSet({ name: 'prompt-confirm-migration' })
|
||||
export class PromptConfirmMigrationQuestion {
|
||||
@Question({
|
||||
message: 'Do you want to proceed with the migration? [Y/n]',
|
||||
name: 'confirmed',
|
||||
})
|
||||
parseConfirmed(value: string): boolean {
|
||||
return ['yes', 'y', ''].includes((value || 'y').toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadFiles } from 'src/types';
|
||||
import { ImmichBufferResponse, ImmichFileResponse, sendBuffer, sendFile } from 'src/utils/file';
|
||||
import { ImmichFileResponse, sendFile } from 'src/utils/file';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Assets)
|
||||
@@ -163,29 +163,26 @@ export class AssetMediaController {
|
||||
|
||||
if (viewThumbnailRes instanceof ImmichFileResponse) {
|
||||
await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewThumbnailRes instanceof ImmichBufferResponse) {
|
||||
await sendBuffer(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
|
||||
return;
|
||||
}
|
||||
|
||||
const { targetSize } = viewThumbnailRes;
|
||||
const [reqPath, reqSearch] = req.url.split('?');
|
||||
let redirPath: string;
|
||||
const redirSearchParams = new URLSearchParams(reqSearch);
|
||||
if (targetSize === 'original') {
|
||||
redirPath = 'original';
|
||||
redirSearchParams.delete('size');
|
||||
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
|
||||
redirPath = reqPath;
|
||||
redirSearchParams.set('size', targetSize);
|
||||
} else {
|
||||
throw new Error('Invalid targetSize: ' + targetSize);
|
||||
// viewThumbnailRes is a AssetMediaRedirectResponse
|
||||
// which redirects to the original asset or a specific size to make better use of caching
|
||||
const { targetSize } = viewThumbnailRes;
|
||||
const [reqPath, reqSearch] = req.url.split('?');
|
||||
let redirPath: string;
|
||||
const redirSearchParams = new URLSearchParams(reqSearch);
|
||||
if (targetSize === 'original') {
|
||||
// relative path to this.downloadAsset
|
||||
redirPath = 'original';
|
||||
redirSearchParams.delete('size');
|
||||
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
|
||||
redirPath = reqPath;
|
||||
redirSearchParams.set('size', targetSize);
|
||||
} else {
|
||||
throw new Error('Invalid targetSize: ' + targetSize);
|
||||
}
|
||||
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
|
||||
return res.redirect(finalRedirPath);
|
||||
}
|
||||
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
|
||||
return res.redirect(finalRedirPath);
|
||||
}
|
||||
|
||||
@Get(':id/video/playback')
|
||||
|
||||
@@ -120,10 +120,6 @@ export class StorageCore {
|
||||
);
|
||||
}
|
||||
|
||||
static getThumbnailStoragePath(): string {
|
||||
return join(StorageCore.getMediaLocation(), 'thumbnails.sqlite3');
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
||||
}
|
||||
|
||||
@@ -140,10 +140,6 @@ export class EnvDto {
|
||||
@Optional()
|
||||
IMMICH_WORKERS_EXCLUDE?: string;
|
||||
|
||||
@Optional()
|
||||
@Matches(/^\//, { message: 'IMMICH_THUMBNAIL_SQLITE_PATH must be an absolute path' })
|
||||
IMMICH_THUMBNAIL_SQLITE_PATH?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
DB_DATABASE_NAME?: string;
|
||||
|
||||
@@ -25,16 +25,10 @@ class Workers {
|
||||
*/
|
||||
restarting = false;
|
||||
|
||||
/**
|
||||
* Whether we're in graceful shutdown
|
||||
*/
|
||||
shuttingDown = false;
|
||||
|
||||
/**
|
||||
* Boot all enabled workers
|
||||
*/
|
||||
async bootstrap() {
|
||||
this.setupSignalHandlers();
|
||||
const isMaintenanceMode = await this.isMaintenanceMode();
|
||||
const { workers } = new ConfigRepository().getEnv();
|
||||
|
||||
@@ -120,12 +114,7 @@ class Workers {
|
||||
} else {
|
||||
const worker = new Worker(workerFile);
|
||||
|
||||
kill = () => {
|
||||
// Post a shutdown message to allow graceful cleanup
|
||||
worker.postMessage({ type: 'shutdown' });
|
||||
// Force terminate after timeout if worker doesn't exit
|
||||
setTimeout(() => void worker.terminate(), 5000);
|
||||
};
|
||||
kill = async () => void (await worker.terminate());
|
||||
anyWorker = worker;
|
||||
}
|
||||
|
||||
@@ -135,53 +124,17 @@ class Workers {
|
||||
this.workers[name] = { kill };
|
||||
}
|
||||
|
||||
private setupSignalHandlers() {
|
||||
const shutdown = async (signal: NodeJS.Signals) => {
|
||||
if (this.shuttingDown) {
|
||||
return;
|
||||
}
|
||||
this.shuttingDown = true;
|
||||
console.log(`Received ${signal}, initiating graceful shutdown...`);
|
||||
|
||||
const workerNames = Object.keys(this.workers) as ImmichWorker[];
|
||||
for (const name of workerNames) {
|
||||
console.log(`Sending ${signal} to ${name} worker`);
|
||||
await this.workers[name]?.kill(signal);
|
||||
}
|
||||
|
||||
// Give workers time to shutdown gracefully
|
||||
setTimeout(() => {
|
||||
console.log('Shutdown timeout reached, forcing exit');
|
||||
process.exit(0);
|
||||
}, 10_000);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
}
|
||||
|
||||
onError(name: ImmichWorker, error: Error) {
|
||||
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
|
||||
}
|
||||
|
||||
onExit(name: ImmichWorker, exitCode: number | null) {
|
||||
console.log(`${name} worker exited with code ${exitCode}`);
|
||||
delete this.workers[name];
|
||||
|
||||
// graceful shutdown in progress
|
||||
if (this.shuttingDown) {
|
||||
if (Object.keys(this.workers).length === 0) {
|
||||
console.log('All workers have exited, shutting down main process');
|
||||
process.exit(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// restart immich server
|
||||
if (exitCode === ExitCode.AppRestart || this.restarting) {
|
||||
this.restarting = true;
|
||||
|
||||
console.info(`${name} worker shutdown for restart`);
|
||||
delete this.workers[name];
|
||||
|
||||
// once all workers shut down, bootstrap again
|
||||
if (Object.keys(this.workers).length === 0) {
|
||||
@@ -192,9 +145,11 @@ class Workers {
|
||||
return;
|
||||
}
|
||||
|
||||
// unexpected exit - shutdown the entire process
|
||||
// shutdown the entire process
|
||||
delete this.workers[name];
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error(`${name} worker exited unexpectedly`);
|
||||
console.error(`${name} worker exited with code ${exitCode}`);
|
||||
|
||||
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
|
||||
console.error('Killing api process');
|
||||
|
||||
@@ -423,13 +423,4 @@ export class AssetJobRepository {
|
||||
streamForMigrationJob() {
|
||||
return this.db.selectFrom('asset').select(['id']).where('asset.deletedAt', 'is', null).stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [], stream: true })
|
||||
streamAllThumbnailFiles() {
|
||||
return this.db
|
||||
.selectFrom('asset_file')
|
||||
.select(['asset_file.assetId', 'asset_file.type', 'asset_file.path', 'asset_file.isEdited'])
|
||||
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview])
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +107,6 @@ export interface EnvData {
|
||||
mediaLocation?: string;
|
||||
};
|
||||
|
||||
thumbnailStorage: {
|
||||
sqlitePath?: string;
|
||||
};
|
||||
|
||||
workers: ImmichWorker[];
|
||||
|
||||
plugins: {
|
||||
@@ -335,10 +331,6 @@ const getEnv = (): EnvData => {
|
||||
mediaLocation: dto.IMMICH_MEDIA_LOCATION,
|
||||
},
|
||||
|
||||
thumbnailStorage: {
|
||||
sqlitePath: dto.IMMICH_THUMBNAIL_SQLITE_PATH,
|
||||
},
|
||||
|
||||
telemetry: {
|
||||
apiPort: dto.IMMICH_API_METRICS_PORT || 8081,
|
||||
microservicesPort: dto.IMMICH_MICROSERVICES_METRICS_PORT || 8082,
|
||||
|
||||
@@ -44,7 +44,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
@@ -99,7 +98,6 @@ export const repositories = [
|
||||
SystemMetadataRepository,
|
||||
TagRepository,
|
||||
TelemetryRepository,
|
||||
ThumbnailStorageRepository,
|
||||
TrashRepository,
|
||||
UserRepository,
|
||||
ViewRepository,
|
||||
|
||||
@@ -2,7 +2,7 @@ import sharp from 'sharp';
|
||||
import { AssetFace } from 'src/database';
|
||||
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import { ImageFormat, SourceType } from 'src/enum';
|
||||
import { SourceType } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||
import { MediaRepository } from 'src/repositories/media.repository';
|
||||
@@ -664,51 +664,4 @@ describe(MediaRepository.name, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateThumbnailToBuffer', () => {
|
||||
it('should return a buffer instead of writing to file', async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const result = await sut.generateThumbnailToBuffer(inputBuffer, {
|
||||
format: ImageFormat.Webp,
|
||||
quality: 80,
|
||||
size: 50,
|
||||
colorspace: 'srgb',
|
||||
processInvalidImages: false,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const metadata = await sharp(result).metadata();
|
||||
expect(metadata.format).toBe('webp');
|
||||
expect(metadata.width).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('should apply same options as generateThumbnail', async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const result = await sut.generateThumbnailToBuffer(inputBuffer, {
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 90,
|
||||
size: 75,
|
||||
colorspace: 'srgb',
|
||||
processInvalidImages: false,
|
||||
progressive: true,
|
||||
});
|
||||
|
||||
const metadata = await sharp(result).metadata();
|
||||
expect(metadata.format).toBe('jpeg');
|
||||
expect(metadata.width).toBe(75);
|
||||
expect(metadata.height).toBe(75);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,17 +182,6 @@ export class MediaRepository {
|
||||
await decoded.toFile(output);
|
||||
}
|
||||
|
||||
async generateThumbnailToBuffer(input: string | Buffer, options: GenerateThumbnailOptions): Promise<Buffer> {
|
||||
const pipeline = await this.getImageDecodingPipeline(input, options);
|
||||
return pipeline
|
||||
.toFormat(options.format, {
|
||||
quality: options.quality,
|
||||
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
|
||||
progressive: options.progressive,
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
let pipeline = sharp(input, {
|
||||
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
|
||||
import { automock } from 'test/utils';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(ThumbnailStorageRepository.name, () => {
|
||||
let sut: ThumbnailStorageRepository;
|
||||
let testDbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDbPath = join(tmpdir(), `immich-test-thumbnails-${randomUUID()}.db`);
|
||||
const logger = automock(LoggingRepository, { args: [undefined, { getEnv: () => ({}) }], strict: false });
|
||||
sut = new ThumbnailStorageRepository(logger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sut.close();
|
||||
if (existsSync(testDbPath)) {
|
||||
rmSync(testDbPath);
|
||||
}
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should create database and schema', () => {
|
||||
sut.initialize(testDbPath);
|
||||
|
||||
expect(sut.isEnabled()).toBe(true);
|
||||
|
||||
const db = new Database(testDbPath, { readonly: true });
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='thumbnails'").all();
|
||||
db.close();
|
||||
|
||||
expect(tables).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should enable WAL mode', () => {
|
||||
sut.initialize(testDbPath);
|
||||
|
||||
const db = new Database(testDbPath, { readonly: true });
|
||||
const result = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string };
|
||||
db.close();
|
||||
|
||||
expect(result.journal_mode).toBe('wal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('store', () => {
|
||||
const assetId = randomUUID();
|
||||
const testData = Buffer.from('test thumbnail data');
|
||||
const mimeType = 'image/webp';
|
||||
|
||||
beforeEach(() => {
|
||||
sut.initialize(testDbPath);
|
||||
});
|
||||
|
||||
it('should store thumbnail data', async () => {
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: testData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.data).toEqual(testData);
|
||||
expect(result!.mimeType).toBe(mimeType);
|
||||
expect(result!.size).toBe(testData.length);
|
||||
});
|
||||
|
||||
it('should replace existing thumbnail on conflict', async () => {
|
||||
const newData = Buffer.from('updated thumbnail data');
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: testData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: newData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
expect(result!.data).toEqual(newData);
|
||||
expect(result!.size).toBe(newData.length);
|
||||
});
|
||||
|
||||
it('should store with correct mime type and size', async () => {
|
||||
const jpegData = Buffer.from('jpeg thumbnail data with different size');
|
||||
const jpegMimeType = 'image/jpeg';
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
data: jpegData,
|
||||
mimeType: jpegMimeType,
|
||||
});
|
||||
|
||||
const result = await sut.get(assetId, AssetFileType.Preview, false);
|
||||
expect(result!.mimeType).toBe(jpegMimeType);
|
||||
expect(result!.size).toBe(jpegData.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
const assetId = randomUUID();
|
||||
const testData = Buffer.from('test thumbnail data');
|
||||
const mimeType = 'image/webp';
|
||||
|
||||
beforeEach(async () => {
|
||||
sut.initialize(testDbPath);
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: testData,
|
||||
mimeType,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return stored thumbnail data', async () => {
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.data).toEqual(testData);
|
||||
expect(result!.mimeType).toBe(mimeType);
|
||||
});
|
||||
|
||||
it('should return null for non-existent thumbnail', async () => {
|
||||
const result = await sut.get(randomUUID(), AssetFileType.Thumbnail, false);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should distinguish between edited and non-edited', async () => {
|
||||
const editedData = Buffer.from('edited thumbnail data');
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: true,
|
||||
data: editedData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const nonEditedResult = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
const editedResult = await sut.get(assetId, AssetFileType.Thumbnail, true);
|
||||
|
||||
expect(nonEditedResult!.data).toEqual(testData);
|
||||
expect(editedResult!.data).toEqual(editedData);
|
||||
});
|
||||
|
||||
it('should distinguish between different thumbnail types', async () => {
|
||||
const previewData = Buffer.from('preview data');
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
data: previewData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const thumbnailResult = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
const previewResult = await sut.get(assetId, AssetFileType.Preview, false);
|
||||
|
||||
expect(thumbnailResult!.data).toEqual(testData);
|
||||
expect(previewResult!.data).toEqual(previewData);
|
||||
});
|
||||
|
||||
it('should fall back to non-edited when edited is requested but not found', async () => {
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, true);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.data).toEqual(testData);
|
||||
});
|
||||
|
||||
it('should return edited when both exist and edited is requested', async () => {
|
||||
const editedData = Buffer.from('edited thumbnail data');
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: true,
|
||||
data: editedData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, true);
|
||||
|
||||
expect(result!.data).toEqual(editedData);
|
||||
});
|
||||
|
||||
it('should not fall back to edited when non-edited is requested but not found', async () => {
|
||||
const newAssetId = randomUUID();
|
||||
const editedData = Buffer.from('edited only data');
|
||||
|
||||
await sut.store({
|
||||
assetId: newAssetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: true,
|
||||
data: editedData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const result = await sut.get(newAssetId, AssetFileType.Thumbnail, false);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
const assetId = randomUUID();
|
||||
const testData = Buffer.from('test thumbnail data');
|
||||
const mimeType = 'image/webp';
|
||||
|
||||
beforeEach(async () => {
|
||||
sut.initialize(testDbPath);
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: testData,
|
||||
mimeType,
|
||||
});
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
data: testData,
|
||||
mimeType,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete specific thumbnail', async () => {
|
||||
await sut.delete(assetId, AssetFileType.Thumbnail, false);
|
||||
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should not affect other thumbnails for same asset', async () => {
|
||||
await sut.delete(assetId, AssetFileType.Thumbnail, false);
|
||||
|
||||
const previewResult = await sut.get(assetId, AssetFileType.Preview, false);
|
||||
expect(previewResult).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteByAsset', () => {
|
||||
const assetId = randomUUID();
|
||||
const otherAssetId = randomUUID();
|
||||
const testData = Buffer.from('test thumbnail data');
|
||||
const mimeType = 'image/webp';
|
||||
|
||||
beforeEach(async () => {
|
||||
sut.initialize(testDbPath);
|
||||
await sut.store({ assetId, type: AssetFileType.Thumbnail, isEdited: false, data: testData, mimeType });
|
||||
await sut.store({ assetId, type: AssetFileType.Preview, isEdited: false, data: testData, mimeType });
|
||||
await sut.store({ assetId, type: AssetFileType.Thumbnail, isEdited: true, data: testData, mimeType });
|
||||
await sut.store({ assetId: otherAssetId, type: AssetFileType.Thumbnail, isEdited: false, data: testData, mimeType });
|
||||
});
|
||||
|
||||
it('should delete all thumbnails for an asset', async () => {
|
||||
await sut.deleteByAsset(assetId);
|
||||
|
||||
expect(await sut.get(assetId, AssetFileType.Thumbnail, false)).toBeNull();
|
||||
expect(await sut.get(assetId, AssetFileType.Preview, false)).toBeNull();
|
||||
expect(await sut.get(assetId, AssetFileType.Thumbnail, true)).toBeNull();
|
||||
});
|
||||
|
||||
it('should not affect other assets', async () => {
|
||||
await sut.deleteByAsset(assetId);
|
||||
|
||||
const otherAssetResult = await sut.get(otherAssetId, AssetFileType.Thumbnail, false);
|
||||
expect(otherAssetResult).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabled', () => {
|
||||
it('should return false when database is not initialized', () => {
|
||||
expect(sut.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when database is initialized', () => {
|
||||
sut.initialize(testDbPath);
|
||||
expect(sut.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import Database, { Database as DatabaseType, Statement } from 'better-sqlite3';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
|
||||
export interface ThumbnailData {
|
||||
assetId: string;
|
||||
type: AssetFileType;
|
||||
isEdited: boolean;
|
||||
data: Buffer;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface ThumbnailResult {
|
||||
data: Buffer;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ThumbnailRow {
|
||||
data: Buffer;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ThumbnailStorageRepository implements OnModuleDestroy {
|
||||
private database: DatabaseType | null = null;
|
||||
private insertStatement: Statement | null = null;
|
||||
private selectStatement: Statement | null = null;
|
||||
private deleteStatement: Statement | null = null;
|
||||
private deleteByAssetStatement: Statement | null = null;
|
||||
|
||||
constructor(private logger: LoggingRepository) {
|
||||
this.logger.setContext(ThumbnailStorageRepository.name);
|
||||
}
|
||||
|
||||
initialize(databasePath: string): void {
|
||||
this.database = new Database(databasePath);
|
||||
|
||||
this.database.pragma('page_size = 32768');
|
||||
this.database.pragma('journal_mode = WAL');
|
||||
this.database.pragma('synchronous = NORMAL');
|
||||
this.database.pragma('cache_size = -131072');
|
||||
this.database.pragma('mmap_size = 2147483648');
|
||||
this.database.pragma('temp_store = MEMORY');
|
||||
this.database.pragma('wal_autocheckpoint = 10000');
|
||||
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS thumbnails (
|
||||
asset_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
is_edited INTEGER NOT NULL DEFAULT 0,
|
||||
data BLOB NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
PRIMARY KEY (asset_id, type, is_edited)
|
||||
)
|
||||
`);
|
||||
|
||||
this.insertStatement = this.database.prepare(`
|
||||
INSERT OR REPLACE INTO thumbnails (asset_id, type, is_edited, data, mime_type, size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.selectStatement = this.database.prepare(`
|
||||
SELECT data, mime_type, size FROM thumbnails
|
||||
WHERE asset_id = ? AND type = ? AND is_edited = ?
|
||||
`);
|
||||
|
||||
this.deleteStatement = this.database.prepare(`
|
||||
DELETE FROM thumbnails WHERE asset_id = ? AND type = ? AND is_edited = ?
|
||||
`);
|
||||
|
||||
this.deleteByAssetStatement = this.database.prepare(`
|
||||
DELETE FROM thumbnails WHERE asset_id = ?
|
||||
`);
|
||||
|
||||
this.logger.log(`SQLite thumbnail storage initialized at ${databasePath}`);
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.database !== null;
|
||||
}
|
||||
|
||||
store(thumbnail: ThumbnailData): void {
|
||||
if (!this.insertStatement) {
|
||||
throw new Error('SQLite thumbnail storage not initialized');
|
||||
}
|
||||
|
||||
const isEditedInt = thumbnail.isEdited ? 1 : 0;
|
||||
this.insertStatement.run(
|
||||
thumbnail.assetId,
|
||||
thumbnail.type,
|
||||
isEditedInt,
|
||||
thumbnail.data,
|
||||
thumbnail.mimeType,
|
||||
thumbnail.data.length,
|
||||
);
|
||||
}
|
||||
|
||||
get(assetId: string, type: AssetFileType, isEdited: boolean): ThumbnailResult | null {
|
||||
if (!this.selectStatement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEditedInt = isEdited ? 1 : 0;
|
||||
let result = this.selectStatement.get(assetId, type, isEditedInt) as ThumbnailRow | undefined;
|
||||
|
||||
if (!result && isEdited) {
|
||||
result = this.selectStatement.get(assetId, type, 0) as ThumbnailRow | undefined;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
mimeType: result.mime_type,
|
||||
size: result.size,
|
||||
};
|
||||
}
|
||||
|
||||
delete(assetId: string, type: AssetFileType, isEdited: boolean): void {
|
||||
if (!this.deleteStatement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEditedInt = isEdited ? 1 : 0;
|
||||
this.deleteStatement.run(assetId, type, isEditedInt);
|
||||
}
|
||||
|
||||
deleteByAsset(assetId: string): void {
|
||||
if (!this.deleteByAssetStatement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deleteByAssetStatement.run(assetId);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.database) {
|
||||
this.logger.log('Closing SQLite thumbnail storage database');
|
||||
this.database.pragma('wal_checkpoint(TRUNCATE)');
|
||||
this.database.close();
|
||||
this.database = null;
|
||||
this.insertStatement = null;
|
||||
this.selectStatement = null;
|
||||
this.deleteStatement = null;
|
||||
this.deleteByAssetStatement = null;
|
||||
this.logger.log('SQLite thumbnail storage database closed');
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.logger.log('onModuleDestroy called - closing SQLite thumbnail storage');
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadBody } from 'src/types';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
|
||||
import { ImmichBufferResponse, ImmichFileResponse } from 'src/utils/file';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
@@ -762,128 +762,6 @@ describe(AssetMediaService.name, () => {
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
|
||||
});
|
||||
|
||||
describe('with SQLite storage', () => {
|
||||
it('should return ImmichBufferResponse when thumbnail exists in SQLite', async () => {
|
||||
const thumbnailData = Buffer.from('thumbnail-data');
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
|
||||
mocks.thumbnailStorage.get.mockReturnValue({
|
||||
data: thumbnailData,
|
||||
mimeType: 'image/webp',
|
||||
size: thumbnailData.length,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
new ImmichBufferResponse({
|
||||
data: thumbnailData,
|
||||
contentType: 'image/webp',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
fileName: 'asset-id_thumbnail.webp',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
expect(mocks.asset.getForThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to filesystem when thumbnail not in SQLite', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
|
||||
mocks.thumbnailStorage.get.mockReturnValue(null);
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle edited thumbnails from SQLite', async () => {
|
||||
const thumbnailData = Buffer.from('edited-thumbnail-data');
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
|
||||
mocks.thumbnailStorage.get.mockReturnValue({
|
||||
data: thumbnailData,
|
||||
mimeType: 'image/webp',
|
||||
size: thumbnailData.length,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: true }),
|
||||
).resolves.toEqual(
|
||||
new ImmichBufferResponse({
|
||||
data: thumbnailData,
|
||||
contentType: 'image/webp',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
fileName: 'asset-id_thumbnail.webp',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
|
||||
});
|
||||
|
||||
it('should handle preview size from SQLite', async () => {
|
||||
const previewData = Buffer.from('preview-data');
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
|
||||
mocks.thumbnailStorage.get.mockReturnValue({
|
||||
data: previewData,
|
||||
mimeType: 'image/jpeg',
|
||||
size: previewData.length,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
).resolves.toEqual(
|
||||
new ImmichBufferResponse({
|
||||
data: previewData,
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
fileName: 'asset-id_preview.jpg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Preview, false);
|
||||
});
|
||||
|
||||
it('should skip SQLite lookup when storage is disabled', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(false);
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.thumbnailStorage.get).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('playbackVideo', () => {
|
||||
|
||||
@@ -37,12 +37,7 @@ import { UploadFile, UploadRequest } from 'src/types';
|
||||
import { requireUploadAccess } from 'src/utils/access';
|
||||
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import {
|
||||
getFilenameExtension,
|
||||
getFileNameWithoutExtension,
|
||||
ImmichBufferResponse,
|
||||
ImmichFileResponse,
|
||||
} from 'src/utils/file';
|
||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
@@ -224,7 +219,7 @@ export class AssetMediaService extends BaseService {
|
||||
auth: AuthDto,
|
||||
id: string,
|
||||
dto: AssetMediaOptionsDto,
|
||||
): Promise<ImmichFileResponse | ImmichBufferResponse | AssetMediaRedirectResponse> {
|
||||
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||
|
||||
if (dto.size === AssetMediaSize.Original) {
|
||||
@@ -236,30 +231,20 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
|
||||
const isEdited = dto.edited ?? false;
|
||||
|
||||
if (this.thumbnailStorageRepository.isEnabled()) {
|
||||
const thumbnail = this.thumbnailStorageRepository.get(id, size, isEdited);
|
||||
if (thumbnail) {
|
||||
// this.logger.log(`Thumbnail served from SQLite: assetId=${id}, type=${size}`);
|
||||
const extension = mimeTypes.toExtension(thumbnail.mimeType) || '';
|
||||
const fileName = `${id}_${size}${extension}`;
|
||||
return new ImmichBufferResponse({
|
||||
data: thumbnail.data,
|
||||
contentType: thumbnail.mimeType,
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(id, size, isEdited);
|
||||
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
|
||||
id,
|
||||
size,
|
||||
dto.edited ?? false,
|
||||
);
|
||||
|
||||
if (size === AssetFileType.FullSize && mimeTypes.isWebSupportedImage(originalPath) && !dto.edited) {
|
||||
// use original file for web supported images
|
||||
return { targetSize: 'original' };
|
||||
}
|
||||
|
||||
if (dto.size === AssetMediaSize.FULLSIZE && !path) {
|
||||
// downgrade to preview if fullsize is not available.
|
||||
// e.g. disabled or not yet (re)generated
|
||||
return { targetSize: AssetMediaSize.PREVIEW };
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
@@ -109,7 +108,6 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
SystemMetadataRepository,
|
||||
TagRepository,
|
||||
TelemetryRepository,
|
||||
ThumbnailStorageRepository,
|
||||
TrashRepository,
|
||||
UserRepository,
|
||||
VersionHistoryRepository,
|
||||
@@ -169,7 +167,6 @@ export class BaseService {
|
||||
protected systemMetadataRepository: SystemMetadataRepository,
|
||||
protected tagRepository: TagRepository,
|
||||
protected telemetryRepository: TelemetryRepository,
|
||||
protected thumbnailStorageRepository: ThumbnailStorageRepository,
|
||||
protected trashRepository: TrashRepository,
|
||||
protected userRepository: UserRepository,
|
||||
protected versionRepository: VersionHistoryRepository,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { AssetFileType, MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
@Injectable()
|
||||
export class CliService extends BaseService {
|
||||
@@ -188,88 +186,4 @@ export class CliService extends BaseService {
|
||||
cleanup() {
|
||||
return this.databaseRepository.shutdown();
|
||||
}
|
||||
|
||||
getDefaultThumbnailStoragePath(): string {
|
||||
const envData = this.configRepository.getEnv();
|
||||
if (envData.thumbnailStorage.sqlitePath) {
|
||||
return envData.thumbnailStorage.sqlitePath;
|
||||
}
|
||||
|
||||
const mediaLocation = envData.storage.mediaLocation ?? this.detectMediaLocation();
|
||||
StorageCore.setMediaLocation(mediaLocation);
|
||||
return StorageCore.getThumbnailStoragePath();
|
||||
}
|
||||
|
||||
private detectMediaLocation(): string {
|
||||
const candidates = ['/data', '/usr/src/app/upload'];
|
||||
for (const candidate of candidates) {
|
||||
if (this.storageRepository.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return '/usr/src/app/upload';
|
||||
}
|
||||
|
||||
async migrateThumbnailsToSqlite(options: {
|
||||
sqlitePath: string;
|
||||
onProgress: (progress: { current: number; migrated: number; skipped: number; errors: number }) => void;
|
||||
}): Promise<{ total: number; migrated: number; skipped: number; errors: number }> {
|
||||
const { sqlitePath, onProgress } = options;
|
||||
|
||||
if (!isAbsolute(sqlitePath)) {
|
||||
throw new Error('SQLite path must be an absolute path');
|
||||
}
|
||||
|
||||
this.thumbnailStorageRepository.initialize(sqlitePath);
|
||||
|
||||
let current = 0;
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for await (const file of this.assetJobRepository.streamAllThumbnailFiles()) {
|
||||
current++;
|
||||
|
||||
try {
|
||||
const existingData = this.thumbnailStorageRepository.get(
|
||||
file.assetId,
|
||||
file.type as AssetFileType,
|
||||
file.isEdited,
|
||||
);
|
||||
|
||||
if (existingData) {
|
||||
skipped++;
|
||||
onProgress({ current, migrated, skipped, errors });
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileExists = await this.storageRepository.checkFileExists(file.path);
|
||||
if (!fileExists) {
|
||||
skipped++;
|
||||
onProgress({ current, migrated, skipped, errors });
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await this.storageRepository.readFile(file.path);
|
||||
const mimeType = mimeTypes.lookup(file.path) || 'image/jpeg';
|
||||
|
||||
this.thumbnailStorageRepository.store({
|
||||
assetId: file.assetId,
|
||||
type: file.type as AssetFileType,
|
||||
isEdited: file.isEdited,
|
||||
data,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
migrated++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
this.logger.error(`Failed to migrate thumbnail for asset ${file.assetId}: ${error}`);
|
||||
}
|
||||
|
||||
onProgress({ current, migrated, skipped, errors });
|
||||
}
|
||||
|
||||
return { total: current, migrated, skipped, errors };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1128,82 +1128,6 @@ describe(MediaService.name, () => {
|
||||
expect.stringContaining('fullsize.jpeg'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('with SQLite storage enabled', () => {
|
||||
beforeEach(() => {
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should store thumbnail in SQLite when enabled', async () => {
|
||||
const thumbnailBuffer = Buffer.from('thumbnail-data');
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnailToBuffer).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith({
|
||||
assetId: assetStub.image.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: thumbnailBuffer,
|
||||
mimeType: 'image/webp',
|
||||
});
|
||||
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith({
|
||||
assetId: assetStub.image.id,
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
data: thumbnailBuffer,
|
||||
mimeType: 'image/jpeg',
|
||||
});
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not write to filesystem when SQLite is enabled', async () => {
|
||||
const thumbnailBuffer = Buffer.from('thumbnail-data');
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should store with correct mime type for JPEG preview', async () => {
|
||||
const thumbnailBuffer = Buffer.from('preview-data');
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { format: ImageFormat.Jpeg } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
mimeType: 'image/jpeg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with SQLite storage disabled', () => {
|
||||
beforeEach(() => {
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should continue using filesystem when SQLite is disabled', async () => {
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
|
||||
expect(mocks.media.generateThumbnailToBuffer).not.toHaveBeenCalled();
|
||||
expect(mocks.thumbnailStorage.store).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAssetEditThumbnailGeneration', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { AssetFile, Exif } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetEditAction, AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
ImageDimensions,
|
||||
JobItem,
|
||||
JobOf,
|
||||
RawImageInfo,
|
||||
VideoFormat,
|
||||
VideoInterfaces,
|
||||
VideoStreamInfo,
|
||||
@@ -57,9 +56,6 @@ interface UpsertFileOptions {
|
||||
|
||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||
|
||||
// Feature flag: Enable fullsize thumbnail storage in SQLite
|
||||
const SQLITE_STORE_FULLSIZE = true;
|
||||
|
||||
@Injectable()
|
||||
export class MediaService extends BaseService {
|
||||
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
||||
@@ -324,27 +320,8 @@ export class MediaService extends BaseService {
|
||||
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
|
||||
const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage;
|
||||
|
||||
// generate final images
|
||||
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
|
||||
const decodedDimensions = { width: info.width, height: info.height };
|
||||
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
||||
|
||||
if (this.thumbnailStorageRepository.isEnabled()) {
|
||||
return this.generateImageThumbnailsToSqlite(
|
||||
asset,
|
||||
data,
|
||||
image,
|
||||
thumbnailOptions,
|
||||
useEdits,
|
||||
fullsizeDimensions,
|
||||
extracted,
|
||||
generateFullsize,
|
||||
convertFullsize,
|
||||
);
|
||||
} else {
|
||||
console.log('not enabled');
|
||||
}
|
||||
|
||||
// generate final images to filesystem
|
||||
const promises = [
|
||||
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
||||
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
|
||||
@@ -399,6 +376,9 @@ export class MediaService extends BaseService {
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
const decodedDimensions = { width: info.width, height: info.height };
|
||||
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
||||
|
||||
return {
|
||||
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
|
||||
thumbhash: outputs[0] as Buffer,
|
||||
@@ -406,82 +386,6 @@ export class MediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async generateImageThumbnailsToSqlite(
|
||||
asset: ThumbnailAsset,
|
||||
data: Buffer,
|
||||
image: SystemConfig['image'],
|
||||
thumbnailOptions: {
|
||||
colorspace: Colorspace;
|
||||
processInvalidImages: boolean;
|
||||
raw: RawImageInfo;
|
||||
edits: AssetEditActionItem[];
|
||||
},
|
||||
useEdits: boolean,
|
||||
fullsizeDimensions: ImageDimensions,
|
||||
extracted: { buffer: Buffer; format: RawExtractedFormat } | null,
|
||||
generateFullsize: boolean,
|
||||
convertFullsize: boolean,
|
||||
) {
|
||||
const [thumbnailBuffer, previewBuffer, thumbhash] = await Promise.all([
|
||||
this.mediaRepository.generateThumbnailToBuffer(data, { ...image.thumbnail, ...thumbnailOptions }),
|
||||
this.mediaRepository.generateThumbnailToBuffer(data, { ...image.preview, ...thumbnailOptions }),
|
||||
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
||||
]);
|
||||
|
||||
// Check if fullsize should be stored in SQLite
|
||||
let fullsizeBuffer: Buffer | null = null;
|
||||
let fullsizeMimeType: string | null = null;
|
||||
|
||||
if (SQLITE_STORE_FULLSIZE && generateFullsize) {
|
||||
if (convertFullsize) {
|
||||
// Convert scenario: generate fullsize from data buffer
|
||||
fullsizeBuffer = await this.mediaRepository.generateThumbnailToBuffer(data, {
|
||||
format: image.fullsize.format,
|
||||
quality: image.fullsize.quality,
|
||||
progressive: image.fullsize.progressive,
|
||||
...thumbnailOptions,
|
||||
});
|
||||
fullsizeMimeType = `image/${image.fullsize.format}`;
|
||||
} else if (extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
||||
// Extract scenario: use extracted buffer directly
|
||||
fullsizeBuffer = extracted.buffer;
|
||||
fullsizeMimeType = 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
this.thumbnailStorageRepository.store({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: useEdits,
|
||||
data: thumbnailBuffer,
|
||||
mimeType: `image/${image.thumbnail.format}`,
|
||||
});
|
||||
this.thumbnailStorageRepository.store({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: useEdits,
|
||||
data: previewBuffer,
|
||||
mimeType: `image/${image.preview.format}`,
|
||||
});
|
||||
|
||||
// Store fullsize if generated
|
||||
if (fullsizeBuffer && fullsizeMimeType) {
|
||||
this.thumbnailStorageRepository.store({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.FullSize,
|
||||
isEdited: useEdits,
|
||||
data: fullsizeBuffer,
|
||||
mimeType: fullsizeMimeType,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
files: [] as UpsertFileOptions[],
|
||||
thumbhash,
|
||||
fullsizeDimensions,
|
||||
};
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
||||
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
|
||||
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
|
||||
|
||||
@@ -45,7 +45,6 @@ export class StorageService extends BaseService {
|
||||
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.StorageService })
|
||||
async onBootstrap() {
|
||||
StorageCore.setMediaLocation(this.detectMediaLocation());
|
||||
this.initializeThumbnailStorage();
|
||||
|
||||
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
|
||||
const flags =
|
||||
@@ -134,12 +133,6 @@ export class StorageService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
private initializeThumbnailStorage(): void {
|
||||
const { thumbnailStorage } = this.configRepository.getEnv();
|
||||
const path = thumbnailStorage.sqlitePath ?? StorageCore.getThumbnailStoragePath();
|
||||
this.thumbnailStorageRepository.initialize(path);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.FileDelete, queue: QueueName.BackgroundTask })
|
||||
async handleDeleteFiles(job: JobOf<JobName.FileDelete>): Promise<JobStatus> {
|
||||
const { files } = job;
|
||||
|
||||
@@ -30,17 +30,6 @@ export class ImmichFileResponse {
|
||||
Object.assign(this, response);
|
||||
}
|
||||
}
|
||||
|
||||
export class ImmichBufferResponse {
|
||||
public readonly data!: Buffer;
|
||||
public readonly contentType!: string;
|
||||
public readonly cacheControl!: CacheControl;
|
||||
public readonly fileName?: string;
|
||||
|
||||
constructor(response: ImmichBufferResponse) {
|
||||
Object.assign(this, response);
|
||||
}
|
||||
}
|
||||
type SendFile = Parameters<Response['sendFile']>;
|
||||
type SendFileOptions = SendFile[1];
|
||||
|
||||
@@ -93,40 +82,6 @@ export const sendFile = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const sendBuffer = async (
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
handler: () => Promise<ImmichBufferResponse> | ImmichBufferResponse,
|
||||
logger: LoggingRepository,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const file = await handler();
|
||||
const cacheControlHeader = cacheControlHeaders[file.cacheControl];
|
||||
if (cacheControlHeader) {
|
||||
res.set('Cache-Control', cacheControlHeader);
|
||||
}
|
||||
|
||||
res.header('Content-Type', file.contentType);
|
||||
res.header('Content-Length', file.data.length.toString());
|
||||
if (file.fileName) {
|
||||
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
|
||||
}
|
||||
|
||||
res.send(file.data);
|
||||
} catch (error: Error | any) {
|
||||
if (isConnectionAborted(error) || res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof HttpException === false) {
|
||||
logger.error(`Unable to send buffer: ${error}`, error.stack);
|
||||
}
|
||||
|
||||
res.header('Cache-Control', 'none');
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
|
||||
return new StreamableFile(stream, { type, length });
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ async function bootstrap() {
|
||||
configureTelemetry();
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
|
||||
app.enableShutdownHooks();
|
||||
app.get(AppRepository).setCloseFn(() => app.close());
|
||||
|
||||
void configureExpress(app, {
|
||||
|
||||
@@ -11,7 +11,6 @@ async function bootstrap() {
|
||||
configureTelemetry();
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
|
||||
app.enableShutdownHooks();
|
||||
app.get(AppRepository).setCloseFn(() => app.close());
|
||||
|
||||
void configureExpress(app, {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { isMainThread, parentPort } from 'node:worker_threads';
|
||||
import { isMainThread } from 'node:worker_threads';
|
||||
import { MicroservicesModule } from 'src/app.module';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
@@ -16,7 +16,6 @@ export async function bootstrap() {
|
||||
}
|
||||
|
||||
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
|
||||
app.enableShutdownHooks();
|
||||
const logger = await app.resolve(LoggingRepository);
|
||||
const configRepository = app.get(ConfigRepository);
|
||||
app.get(AppRepository).setCloseFn(() => app.close());
|
||||
@@ -30,24 +29,13 @@ export async function bootstrap() {
|
||||
await (host ? app.listen(0, host) : app.listen(0));
|
||||
|
||||
logger.log(`Immich Microservices is running [v${serverVersion}] [${environment}] `);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
if (!isMainThread) {
|
||||
bootstrap()
|
||||
.then((app) => {
|
||||
parentPort?.on('message', (message) => {
|
||||
if (message?.type === 'shutdown') {
|
||||
console.log('Microservices worker received shutdown message');
|
||||
void app.close();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isStartUpError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
bootstrap().catch((error) => {
|
||||
if (!isStartUpError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,10 +84,6 @@ const envData: EnvData = {
|
||||
ignoreMountCheckErrors: false,
|
||||
},
|
||||
|
||||
thumbnailStorage: {
|
||||
sqlitePath: undefined,
|
||||
},
|
||||
|
||||
telemetry: {
|
||||
apiPort: 8081,
|
||||
microservicesPort: 8082,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Mocked, vitest } from 'vitest';
|
||||
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
|
||||
return {
|
||||
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
generateThumbnailToBuffer: vitest.fn().mockResolvedValue(Buffer.from('')),
|
||||
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
|
||||
import { RepositoryInterface } from 'src/types';
|
||||
import { Mocked, vitest } from 'vitest';
|
||||
|
||||
export const newThumbnailStorageRepositoryMock = (): Mocked<RepositoryInterface<ThumbnailStorageRepository>> => {
|
||||
return {
|
||||
initialize: vitest.fn(),
|
||||
isEnabled: vitest.fn().mockReturnValue(false),
|
||||
store: vitest.fn(),
|
||||
get: vitest.fn(),
|
||||
delete: vitest.fn(),
|
||||
deleteByAsset: vitest.fn(),
|
||||
close: vitest.fn(),
|
||||
};
|
||||
};
|
||||
@@ -60,7 +60,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
@@ -82,7 +81,6 @@ import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'
|
||||
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||
import { newThumbnailStorageRepositoryMock } from 'test/repositories/thumbnail-storage.repository.mock';
|
||||
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
||||
import { assert, Mock, Mocked, vitest } from 'vitest';
|
||||
|
||||
@@ -257,7 +255,6 @@ export type ServiceOverrides = {
|
||||
systemMetadata: SystemMetadataRepository;
|
||||
tag: TagRepository;
|
||||
telemetry: TelemetryRepository;
|
||||
thumbnailStorage: ThumbnailStorageRepository;
|
||||
trash: TrashRepository;
|
||||
user: UserRepository;
|
||||
versionHistory: VersionHistoryRepository;
|
||||
@@ -335,7 +332,6 @@ export const getMocks = () => {
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
tag: automock(TagRepository, { args: [, loggerMock], strict: false }),
|
||||
telemetry: newTelemetryRepositoryMock(),
|
||||
thumbnailStorage: newThumbnailStorageRepositoryMock(),
|
||||
trash: automock(TrashRepository),
|
||||
user: automock(UserRepository, { strict: false }),
|
||||
versionHistory: automock(VersionHistoryRepository),
|
||||
@@ -401,7 +397,6 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.systemMetadata || (mocks.systemMetadata as As<SystemMetadataRepository>),
|
||||
overrides.tag || (mocks.tag as As<TagRepository>),
|
||||
overrides.telemetry || (mocks.telemetry as unknown as TelemetryRepository),
|
||||
overrides.thumbnailStorage || (mocks.thumbnailStorage as As<ThumbnailStorageRepository>),
|
||||
overrides.trash || (mocks.trash as As<TrashRepository>),
|
||||
overrides.user || (mocks.user as As<UserRepository>),
|
||||
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),
|
||||
|
||||
@@ -68,7 +68,11 @@
|
||||
let currentMemoryAssetFull = $derived.by(async () =>
|
||||
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
|
||||
);
|
||||
let currentTimelineAssets = $derived(current?.memory.assets || []);
|
||||
let currentTimelineAssets = $derived([
|
||||
...(current?.previousMemory?.assets ?? []),
|
||||
...(current?.memory.assets ?? []),
|
||||
...(current?.nextMemory?.assets ?? []),
|
||||
]);
|
||||
|
||||
let isSaved = $derived(current?.memory.isSaved);
|
||||
let viewerHeight = $state(0);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { changePinCode } from '@immich/sdk';
|
||||
import { Button, Heading, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let currentPinCode = $state('');
|
||||
let newPinCode = $state('');
|
||||
@@ -38,27 +37,23 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
|
||||
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
||||
<Heading>{$t('change_pin_code')}</Heading>
|
||||
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
|
||||
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
|
||||
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
|
||||
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<form autocomplete="off" onsubmit={handleSubmit}>
|
||||
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
||||
<Heading>{$t('change_pin_code')}</Heading>
|
||||
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
|
||||
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
|
||||
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
|
||||
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<OnEvents {onUserPinCodeReset} />
|
||||
|
||||
<section>
|
||||
<section class="my-4 sm:ms-8">
|
||||
{#if hasPinCode}
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<PinCodeChangeForm />
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="ms-8 mt-4 flex flex-col gap-6">
|
||||
<div class="sm:ms-8 flex flex-col gap-6">
|
||||
<Field label={$t('theme_selection')} description={$t('theme_selection_description')}>
|
||||
<Switch checked={themeManager.theme.system} onCheckedChange={(checked) => themeManager.setSystem(checked)} />
|
||||
</Field>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
<Field label={$t('password')} required>
|
||||
<PasswordInput bind:value={password} autocomplete="current-password" />
|
||||
</Field>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
|
||||
import { Button, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import DeviceCard from './device-card.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -50,33 +51,39 @@
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
{#if currentSession}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
{$t('current_device')}
|
||||
</Text>
|
||||
<DeviceCard session={currentSession} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if otherSessions.length > 0}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
{$t('other_devices')}
|
||||
</Text>
|
||||
{#each otherSessions as session, index (session.id)}
|
||||
<DeviceCard {session} onDelete={() => handleDelete(session)} />
|
||||
{#if index !== otherSessions.length - 1}
|
||||
<hr class="my-3" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
{#if currentSession}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
{$t('current_device')}
|
||||
</Text>
|
||||
<DeviceCard session={currentSession} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if otherSessions.length > 0}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
{$t('other_devices')}
|
||||
</Text>
|
||||
{#each otherSessions as session, index (session.id)}
|
||||
<DeviceCard {session} onDelete={() => handleDelete(session)} />
|
||||
{#if index !== otherSessions.length - 1}
|
||||
<hr class="my-3" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<hr />
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}
|
||||
>{$t('log_out_all_devices')}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('archive_size')}
|
||||
|
||||
@@ -67,9 +67,9 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="ms-4 mt-4 flex flex-col">
|
||||
<div class="sm:ms-4 md:ms-8 flex flex-col">
|
||||
<SettingAccordion key="albums" title={$t('albums')} subtitle={$t('albums_feature_description')}>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('albums_default_sort_order')} description={$t('albums_default_sort_order_description')}>
|
||||
<Select
|
||||
options={[
|
||||
@@ -83,7 +83,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={foldersEnabled} />
|
||||
</Field>
|
||||
@@ -97,7 +97,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="memories" title={$t('time_based_memories')} subtitle={$t('photos_from_previous_years')}>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={memoriesEnabled} />
|
||||
</Field>
|
||||
@@ -109,7 +109,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="people" title={$t('people')} subtitle={$t('people_feature_description')}>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={peopleEnabled} />
|
||||
</Field>
|
||||
@@ -123,7 +123,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="rating" title={$t('rating')} subtitle={$t('rating_description')}>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={ratingsEnabled} />
|
||||
</Field>
|
||||
@@ -131,7 +131,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="shared-links" title={$t('shared_links')} subtitle={$t('shared_links_description')}>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={sharedLinksEnabled} />
|
||||
</Field>
|
||||
@@ -145,7 +145,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="tags" title={$t('tags')} subtitle={$t('tag_feature_description')}>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={tagsEnabled} />
|
||||
</Field>
|
||||
@@ -159,7 +159,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="cast" title={$t('cast')} subtitle={$t('cast_description')}>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('gcast_enabled')} description={$t('gcast_enabled_description')}>
|
||||
<Switch bind:checked={gCastEnabled} />
|
||||
</Field>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-6">
|
||||
<div class="sm:ms-8 flex flex-col gap-6">
|
||||
<Field label={$t('enable')} description={$t('notification_toggle_setting_description')}>
|
||||
<Switch bind:checked={emailNotificationsEnabled} />
|
||||
</Field>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="flex justify-end">
|
||||
<div class="sm:ms-8 flex justify-end">
|
||||
{#if loading}
|
||||
<div class="flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
|
||||
|
||||
<section class="my-4">
|
||||
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex flex-col gap-2" in:fade={{ duration: 500 }}>
|
||||
<div class="mb-2 flex justify-end">
|
||||
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}>
|
||||
{Create.title}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={preventDefault(bubble('submit'))}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
<Field label={$t('user_id')} disabled>
|
||||
<Input bind:value={editedUser.id} />
|
||||
</Field>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8" in:fade={{ duration: 500 }}>
|
||||
{#if $isPurchased}
|
||||
<!-- BADGE TOGGLE -->
|
||||
<div class="mb-4">
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
</TableRow>
|
||||
{/snippet}
|
||||
|
||||
<section class="my-6 w-full">
|
||||
<section class="my-4 w-full">
|
||||
<Heading size="tiny">{$t('photos_and_videos')}</Heading>
|
||||
<Table striped spacing="small" class="mt-4" size="small">
|
||||
<TableHeader>
|
||||
|
||||
Reference in New Issue
Block a user