mirror of
https://github.com/immich-app/immich.git
synced 2026-05-27 10:02:31 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a213f3d81 | |||
| 5ead92bb12 | |||
| ee2c3e14c3 | |||
| f812c5846a | |||
| 3f93169301 | |||
| 8937fe0133 | |||
| 0a055d0fc7 | |||
| 334ebbfe7d | |||
| 57dd127162 |
@@ -2,7 +2,6 @@
|
||||
"name": "Immich - Backend, Frontend and ML",
|
||||
"service": "immich-server",
|
||||
"runServices": [
|
||||
"immich-init",
|
||||
"immich-server",
|
||||
"redis",
|
||||
"database",
|
||||
@@ -32,8 +31,29 @@
|
||||
"tasks": {
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Fix Permissions, Install Dependencies",
|
||||
"type": "shell",
|
||||
"command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0",
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "dedicated",
|
||||
"showReuseMessage": true,
|
||||
"clear": false,
|
||||
"group": "Devcontainer tasks",
|
||||
"close": true
|
||||
},
|
||||
"runOptions": {
|
||||
"runOn": "default"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Immich API Server (Nest)",
|
||||
"dependsOn": ["Fix Permissions, Install Dependencies"],
|
||||
"type": "shell",
|
||||
"command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0",
|
||||
"isBackground": true,
|
||||
@@ -54,6 +74,7 @@
|
||||
},
|
||||
{
|
||||
"label": "Immich Web Server (Vite)",
|
||||
"dependsOn": ["Fix Permissions, Install Dependencies"],
|
||||
"type": "shell",
|
||||
"command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0",
|
||||
"isBackground": true,
|
||||
@@ -109,8 +130,8 @@
|
||||
}
|
||||
},
|
||||
"overrideCommand": true,
|
||||
"workspaceFolder": "/usr/src/app",
|
||||
"remoteUser": "root",
|
||||
"workspaceFolder": "/workspaces/immich",
|
||||
"remoteUser": "node",
|
||||
"userEnvProbe": "loginInteractiveShell",
|
||||
"remoteEnv": {
|
||||
// The location where your uploaded files are stored
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
services:
|
||||
immich-app-base:
|
||||
image: busybox
|
||||
immich-server:
|
||||
extends:
|
||||
service: immich-app-base
|
||||
profiles: !reset []
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
target: dev-container-mobile
|
||||
environment:
|
||||
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
|
||||
volumes:
|
||||
volumes: !override # bind mount host to /workspaces/immich
|
||||
- ..:/workspaces/immich
|
||||
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"name": "Immich - Mobile",
|
||||
"service": "immich-server",
|
||||
"runServices": [
|
||||
"immich-init",
|
||||
"immich-server",
|
||||
"redis",
|
||||
"database",
|
||||
@@ -36,7 +35,7 @@
|
||||
},
|
||||
"forwardPorts": [],
|
||||
"overrideCommand": true,
|
||||
"workspaceFolder": "/usr/src/app",
|
||||
"workspaceFolder": "/workspaces/immich",
|
||||
"remoteUser": "node",
|
||||
"userEnvProbe": "loginInteractiveShell",
|
||||
"remoteEnv": {
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
export IMMICH_PORT="${DEV_SERVER_PORT:-2283}"
|
||||
export DEV_PORT="${DEV_PORT:-3000}"
|
||||
|
||||
# search for immich directory inside workspace.
|
||||
# /workspaces/immich is the bind mount, but other directories can be mounted if runing
|
||||
# Devcontainer: Clone [repository|pull request] in container volumne
|
||||
WORKSPACES_DIR="/workspaces"
|
||||
IMMICH_DIR="$WORKSPACES_DIR/immich"
|
||||
IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log"
|
||||
|
||||
log() {
|
||||
@@ -25,8 +30,52 @@ run_cmd() {
|
||||
return "${PIPESTATUS[0]}"
|
||||
}
|
||||
|
||||
export IMMICH_WORKSPACE="/usr/src/app"
|
||||
# Find directories excluding /workspaces/immich
|
||||
mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*")
|
||||
|
||||
if [ ${#other_dirs[@]} -gt 1 ]; then
|
||||
log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR."
|
||||
exit 1
|
||||
elif [ ${#other_dirs[@]} -eq 1 ]; then
|
||||
export IMMICH_WORKSPACE="${other_dirs[0]}"
|
||||
else
|
||||
export IMMICH_WORKSPACE="$IMMICH_DIR"
|
||||
fi
|
||||
|
||||
log "Found immich workspace in $IMMICH_WORKSPACE"
|
||||
log ""
|
||||
|
||||
fix_permissions() {
|
||||
|
||||
log "Fixing permissions for ${IMMICH_WORKSPACE}"
|
||||
|
||||
# Change ownership for directories that exist
|
||||
for dir in "${IMMICH_WORKSPACE}/.vscode" \
|
||||
"${IMMICH_WORKSPACE}/server/upload" \
|
||||
"${IMMICH_WORKSPACE}/.pnpm-store" \
|
||||
"${IMMICH_WORKSPACE}/.github/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/cli/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/e2e/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/server/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/server/dist" \
|
||||
"${IMMICH_WORKSPACE}/web/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/web/dist"; do
|
||||
if [ -d "$dir" ]; then
|
||||
run_cmd sudo chown node -R "$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
log ""
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
|
||||
log "Installing dependencies"
|
||||
(
|
||||
cd "${IMMICH_WORKSPACE}" || exit 1
|
||||
export CI=1 FROZEN=1 OFFLINE=1
|
||||
run_cmd make setup-web-dev setup-server-dev
|
||||
)
|
||||
log ""
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
services:
|
||||
immich-app-base:
|
||||
image: busybox
|
||||
immich-server:
|
||||
extends:
|
||||
service: immich-app-base
|
||||
profiles: !reset []
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
target: dev-container-server
|
||||
env_file: !reset []
|
||||
hostname: immich-dev
|
||||
environment:
|
||||
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
|
||||
volumes:
|
||||
volumes: !override
|
||||
- ..:/workspaces/immich
|
||||
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- ../plugins:/build/corePlugin
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# shellcheck source=common.sh
|
||||
# shellcheck disable=SC1091
|
||||
source /immich-devcontainer/container-common.sh
|
||||
|
||||
log "Setting up Immich dev container..."
|
||||
fix_permissions
|
||||
|
||||
log "Setup complete, please wait while backend and frontend services automatically start"
|
||||
log
|
||||
log "If necessary, the services may be manually started using"
|
||||
log
|
||||
log "$ /immich-devcontainer/container-start-backend.sh"
|
||||
log "$ /immich-devcontainer/container-start-frontend.sh"
|
||||
log
|
||||
log "From different terminal windows, as these scripts automatically restart the server"
|
||||
log "on error, and will continuously run in a loop"
|
||||
+5
-11
@@ -4,18 +4,12 @@ module.exports = {
|
||||
if (!pkg.name) {
|
||||
return pkg;
|
||||
}
|
||||
// make exiftool-vendored.pl a regular dependency since Docker prod
|
||||
// images build with --no-optional to reduce image size
|
||||
if (pkg.name === "exiftool-vendored") {
|
||||
const binaryPackage =
|
||||
process.platform === "win32"
|
||||
? "exiftool-vendored.exe"
|
||||
: "exiftool-vendored.pl";
|
||||
|
||||
if (pkg.optionalDependencies[binaryPackage]) {
|
||||
pkg.dependencies[binaryPackage] =
|
||||
pkg.optionalDependencies[binaryPackage];
|
||||
delete pkg.optionalDependencies[binaryPackage];
|
||||
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
|
||||
// make exiftool-vendored.pl a regular dependency
|
||||
pkg.dependencies["exiftool-vendored.pl"] =
|
||||
pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||
delete pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||
}
|
||||
}
|
||||
return pkg;
|
||||
|
||||
@@ -7,15 +7,7 @@ import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
||||
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
|
||||
import createFetchMock from 'vitest-fetch-mock';
|
||||
|
||||
import {
|
||||
checkForDuplicates,
|
||||
deleteFiles,
|
||||
findSidecar,
|
||||
getAlbumName,
|
||||
startWatch,
|
||||
uploadFiles,
|
||||
UploadOptionsDto,
|
||||
} from 'src/commands/asset';
|
||||
import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset';
|
||||
|
||||
vi.mock('@immich/sdk');
|
||||
|
||||
@@ -317,85 +309,3 @@ describe('startWatch', () => {
|
||||
await fs.promises.rm(testFolder, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSidecar', () => {
|
||||
let testDir: string;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-sidecar-'));
|
||||
testFilePath = path.join(testDir, 'test.jpg');
|
||||
fs.writeFileSync(testFilePath, 'test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should find sidecar file with photo.xmp naming convention', () => {
|
||||
const sidecarPath = path.join(testDir, 'test.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
const result = findSidecar(testFilePath);
|
||||
expect(result).toBe(sidecarPath);
|
||||
});
|
||||
|
||||
it('should find sidecar file with photo.ext.xmp naming convention', () => {
|
||||
const sidecarPath = path.join(testDir, 'test.jpg.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
const result = findSidecar(testFilePath);
|
||||
expect(result).toBe(sidecarPath);
|
||||
});
|
||||
|
||||
it('should prefer photo.ext.xmp over photo.xmp when both exist', () => {
|
||||
const sidecarPath1 = path.join(testDir, 'test.xmp');
|
||||
const sidecarPath2 = path.join(testDir, 'test.jpg.xmp');
|
||||
fs.writeFileSync(sidecarPath1, 'xmp data 1');
|
||||
fs.writeFileSync(sidecarPath2, 'xmp data 2');
|
||||
|
||||
const result = findSidecar(testFilePath);
|
||||
// Should return the first one found (photo.xmp) based on the order in the code
|
||||
expect(result).toBe(sidecarPath1);
|
||||
});
|
||||
|
||||
it('should return undefined when no sidecar file exists', () => {
|
||||
const result = findSidecar(testFilePath);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFiles', () => {
|
||||
let testDir: string;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-delete-'));
|
||||
testFilePath = path.join(testDir, 'test.jpg');
|
||||
fs.writeFileSync(testFilePath, 'test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should delete asset and sidecar file when main file is deleted', async () => {
|
||||
const sidecarPath = path.join(testDir, 'test.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: true, concurrency: 1 });
|
||||
|
||||
expect(fs.existsSync(testFilePath)).toBe(false);
|
||||
expect(fs.existsSync(sidecarPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not delete sidecar file when delete option is false', async () => {
|
||||
const sidecarPath = path.join(testDir, 'test.xmp');
|
||||
fs.writeFileSync(sidecarPath, 'xmp data');
|
||||
|
||||
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: false, concurrency: 1 });
|
||||
|
||||
expect(fs.existsSync(testFilePath)).toBe(true);
|
||||
expect(fs.existsSync(sidecarPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-32
@@ -17,7 +17,7 @@ import { Matcher, watch as watchFs } from 'chokidar';
|
||||
import { MultiBar, Presets, SingleBar } from 'cli-progress';
|
||||
import { chunk } from 'lodash-es';
|
||||
import micromatch from 'micromatch';
|
||||
import { Stats, createReadStream, existsSync } from 'node:fs';
|
||||
import { Stats, createReadStream } from 'node:fs';
|
||||
import { stat, unlink } from 'node:fs/promises';
|
||||
import path, { basename } from 'node:path';
|
||||
import { Queue } from 'src/queue';
|
||||
@@ -403,6 +403,23 @@ export const uploadFiles = async (
|
||||
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
|
||||
const { baseUrl, headers } = defaults;
|
||||
|
||||
const assetPath = path.parse(input);
|
||||
const noExtension = path.join(assetPath.dir, assetPath.name);
|
||||
|
||||
const sidecarsFiles = await Promise.all(
|
||||
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
||||
[`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => {
|
||||
try {
|
||||
const stats = await stat(sidecarPath);
|
||||
return new UploadFile(sidecarPath, stats.size);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
|
||||
formData.append('deviceId', 'CLI');
|
||||
@@ -412,15 +429,8 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
|
||||
formData.append('isFavorite', 'false');
|
||||
formData.append('assetData', new UploadFile(input, stats.size));
|
||||
|
||||
const sidecarPath = findSidecar(input);
|
||||
if (sidecarPath) {
|
||||
try {
|
||||
const stats = await stat(sidecarPath);
|
||||
const sidecarData = new UploadFile(sidecarPath, stats.size);
|
||||
formData.append('sidecarData', sidecarData);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
if (sidecarData) {
|
||||
formData.append('sidecarData', sidecarData);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/assets`, {
|
||||
@@ -436,19 +446,7 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const findSidecar = (filepath: string): string | undefined => {
|
||||
const assetPath = path.parse(filepath);
|
||||
const noExtension = path.join(assetPath.dir, assetPath.name);
|
||||
|
||||
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
||||
for (const sidecarPath of [`${noExtension}.xmp`, `${filepath}.xmp`]) {
|
||||
if (existsSync(sidecarPath)) {
|
||||
return sidecarPath;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
|
||||
const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
|
||||
let fileCount = 0;
|
||||
if (options.delete) {
|
||||
fileCount += uploaded.length;
|
||||
@@ -476,15 +474,7 @@ export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], option
|
||||
|
||||
const chunkDelete = async (files: Asset[]) => {
|
||||
for (const assetBatch of chunk(files, options.concurrency)) {
|
||||
await Promise.all(
|
||||
assetBatch.map(async (input: Asset) => {
|
||||
await unlink(input.filepath);
|
||||
const sidecarPath = findSidecar(input.filepath);
|
||||
if (sidecarPath) {
|
||||
await unlink(sidecarPath);
|
||||
}
|
||||
}),
|
||||
);
|
||||
await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath)));
|
||||
deletionProgress.update(assetBatch.length);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -253,8 +253,7 @@ describe('/asset', () => {
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.id).toEqual(facesAsset.id);
|
||||
const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
expect(sortedPeople).toMatchObject(expectedFaces);
|
||||
expect(body.people).toMatchObject(expectedFaces);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export const thumbnailUtils = {
|
||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
|
||||
},
|
||||
selectedAsset(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
||||
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
|
||||
},
|
||||
async clickAssetId(page: Page, assetId: string) {
|
||||
await thumbnailUtils.withAssetId(page, assetId).click();
|
||||
@@ -103,8 +103,11 @@ export const thumbnailUtils = {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
},
|
||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||
// todo - need a data attribute for selected
|
||||
await expect(
|
||||
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
|
||||
page.locator(
|
||||
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||
|
||||
@@ -698,7 +698,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_location'.t(context: context),
|
||||
currentFilter: locationCurrentFilterWidget.value,
|
||||
),
|
||||
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
|
||||
if (userPreferences.value?.tagsEnabled ?? false)
|
||||
SearchFilterChip(
|
||||
icon: Icons.sell_outlined,
|
||||
onTap: showTagPicker,
|
||||
@@ -724,13 +724,14 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_media_type'.t(context: context),
|
||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||
),
|
||||
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
|
||||
if (userPreferences.value?.ratingsEnabled ?? false) ...[
|
||||
SearchFilterChip(
|
||||
icon: Icons.star_outline_rounded,
|
||||
onTap: showStarRatingPicker,
|
||||
label: 'search_filter_star_rating'.t(context: context),
|
||||
currentFilter: ratingCurrentFilterWidget.value,
|
||||
),
|
||||
],
|
||||
SearchFilterChip(
|
||||
icon: Icons.display_settings_outlined,
|
||||
onTap: showDisplayOptionPicker,
|
||||
|
||||
@@ -74,7 +74,6 @@ class Timeline extends StatefulWidget {
|
||||
this.snapToMonth = true,
|
||||
this.initialScrollOffset,
|
||||
this.readOnly = false,
|
||||
this.persistentBottomBar = false,
|
||||
});
|
||||
|
||||
final Widget? topSliverWidget;
|
||||
@@ -88,7 +87,6 @@ class Timeline extends StatefulWidget {
|
||||
final bool snapToMonth;
|
||||
final double? initialScrollOffset;
|
||||
final bool readOnly;
|
||||
final bool persistentBottomBar;
|
||||
|
||||
@override
|
||||
State<Timeline> createState() => _TimelineState();
|
||||
@@ -145,7 +143,6 @@ class _TimelineState extends State<Timeline> {
|
||||
appBar: widget.appBar,
|
||||
bottomSheet: widget.bottomSheet,
|
||||
withScrubber: widget.withScrubber,
|
||||
persistentBottomBar: widget.persistentBottomBar,
|
||||
snapToMonth: widget.snapToMonth,
|
||||
initialScrollOffset: widget.initialScrollOffset,
|
||||
),
|
||||
@@ -176,7 +173,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
this.appBar,
|
||||
this.bottomSheet,
|
||||
this.withScrubber = true,
|
||||
this.persistentBottomBar = false,
|
||||
this.snapToMonth = true,
|
||||
this.initialScrollOffset,
|
||||
});
|
||||
@@ -186,7 +182,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
final Widget? appBar;
|
||||
final Widget? bottomSheet;
|
||||
final bool withScrubber;
|
||||
final bool persistentBottomBar;
|
||||
final bool snapToMonth;
|
||||
final double? initialScrollOffset;
|
||||
|
||||
@@ -409,9 +404,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
|
||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
|
||||
final isBottomWidgetVisible =
|
||||
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
|
||||
|
||||
return PopScope(
|
||||
canPop: !isMultiSelectEnabled,
|
||||
@@ -527,7 +519,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
child: Stack(
|
||||
children: [
|
||||
timeline,
|
||||
if (isMultiSelectStatusVisible)
|
||||
if (!isSelectionMode && isMultiSelectEnabled) ...[
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
@@ -536,7 +528,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
),
|
||||
),
|
||||
if (isBottomWidgetVisible) widget.bottomSheet!,
|
||||
if (widget.bottomSheet != null) widget.bottomSheet!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+1
-10
@@ -13,16 +13,12 @@ part of openapi.api;
|
||||
class LibraryStatsResponseDto {
|
||||
/// Returns a new [LibraryStatsResponseDto] instance.
|
||||
LibraryStatsResponseDto({
|
||||
this.offline = 0,
|
||||
this.photos = 0,
|
||||
this.total = 0,
|
||||
this.usage = 0,
|
||||
this.videos = 0,
|
||||
});
|
||||
|
||||
/// Number of offline assets
|
||||
int offline;
|
||||
|
||||
/// Number of photos
|
||||
int photos;
|
||||
|
||||
@@ -37,7 +33,6 @@ class LibraryStatsResponseDto {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is LibraryStatsResponseDto &&
|
||||
other.offline == offline &&
|
||||
other.photos == photos &&
|
||||
other.total == total &&
|
||||
other.usage == usage &&
|
||||
@@ -46,18 +41,16 @@ class LibraryStatsResponseDto {
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(offline.hashCode) +
|
||||
(photos.hashCode) +
|
||||
(total.hashCode) +
|
||||
(usage.hashCode) +
|
||||
(videos.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'LibraryStatsResponseDto[offline=$offline, photos=$photos, total=$total, usage=$usage, videos=$videos]';
|
||||
String toString() => 'LibraryStatsResponseDto[photos=$photos, total=$total, usage=$usage, videos=$videos]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'offline'] = this.offline;
|
||||
json[r'photos'] = this.photos;
|
||||
json[r'total'] = this.total;
|
||||
json[r'usage'] = this.usage;
|
||||
@@ -74,7 +67,6 @@ class LibraryStatsResponseDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return LibraryStatsResponseDto(
|
||||
offline: mapValueOfType<int>(json, r'offline')!,
|
||||
photos: mapValueOfType<int>(json, r'photos')!,
|
||||
total: mapValueOfType<int>(json, r'total')!,
|
||||
usage: mapValueOfType<int>(json, r'usage')!,
|
||||
@@ -126,7 +118,6 @@ class LibraryStatsResponseDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'offline',
|
||||
'photos',
|
||||
'total',
|
||||
'usage',
|
||||
|
||||
@@ -18264,11 +18264,6 @@
|
||||
},
|
||||
"LibraryStatsResponseDto": {
|
||||
"properties": {
|
||||
"offline": {
|
||||
"default": 0,
|
||||
"description": "Number of offline assets",
|
||||
"type": "integer"
|
||||
},
|
||||
"photos": {
|
||||
"default": 0,
|
||||
"description": "Number of photos",
|
||||
@@ -18292,7 +18287,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"offline",
|
||||
"photos",
|
||||
"total",
|
||||
"usage",
|
||||
|
||||
@@ -1323,8 +1323,6 @@ export type UpdateLibraryDto = {
|
||||
name?: string;
|
||||
};
|
||||
export type LibraryStatsResponseDto = {
|
||||
/** Number of offline assets */
|
||||
offline: number;
|
||||
/** Number of photos */
|
||||
photos: number;
|
||||
/** Total number of assets */
|
||||
|
||||
Generated
+10
-4
@@ -11,7 +11,7 @@ overrides:
|
||||
|
||||
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
|
||||
|
||||
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
|
||||
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0=
|
||||
|
||||
importers:
|
||||
|
||||
@@ -343,6 +343,9 @@ importers:
|
||||
'@extism/extism':
|
||||
specifier: 2.0.0-rc13
|
||||
version: 2.0.0-rc13
|
||||
'@immich/walkrs':
|
||||
specifier: ^0.0.13
|
||||
version: 0.0.13
|
||||
'@nestjs/bullmq':
|
||||
specifier: ^11.0.1
|
||||
version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0)
|
||||
@@ -454,9 +457,6 @@ importers:
|
||||
express:
|
||||
specifier: ^5.1.0
|
||||
version: 5.2.1
|
||||
fast-glob:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.3
|
||||
fluent-ffmpeg:
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.3
|
||||
@@ -3023,6 +3023,10 @@ packages:
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
'@immich/walkrs@0.0.13':
|
||||
resolution: {integrity: sha512-qKDoXFgy3d2Z7SIJBn25BcNyQnPLAp2zZEBcewpWxG5+qAXDPi3M3sweJ9qJ11Eha+YlmpUO3c8yd5CCBeq96A==}
|
||||
engines: {pnpm: '>=10.0.0'}
|
||||
|
||||
'@inquirer/ansi@1.0.2':
|
||||
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -14835,6 +14839,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
'@immich/walkrs@0.0.13': {}
|
||||
|
||||
'@inquirer/ansi@1.0.2': {}
|
||||
|
||||
'@inquirer/checkbox@4.3.2(@types/node@24.10.13)':
|
||||
|
||||
+26
-17
@@ -9,6 +9,9 @@ packages:
|
||||
- plugins
|
||||
- web
|
||||
- .github
|
||||
|
||||
dedupePeerDependents: false
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- '@nestjs/core'
|
||||
- '@parcel/watcher'
|
||||
@@ -25,42 +28,48 @@ ignoredBuiltDependencies:
|
||||
- protobufjs
|
||||
- ssh2
|
||||
- utimes
|
||||
|
||||
injectWorkspacePackages: true
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
||||
- '@tailwindcss/oxide'
|
||||
- bcrypt
|
||||
|
||||
overrides:
|
||||
canvas: 2.11.2
|
||||
sharp: ^0.34.5
|
||||
|
||||
packageExtensions:
|
||||
nestjs-kysely:
|
||||
'@immich/ui':
|
||||
dependencies:
|
||||
tslib: '*'
|
||||
nestjs-otel:
|
||||
dependencies:
|
||||
tslib: '*'
|
||||
tailwindcss: '>=4.1'
|
||||
'@photo-sphere-viewer/equirectangular-video-adapter':
|
||||
dependencies:
|
||||
three: '*'
|
||||
'@photo-sphere-viewer/video-plugin':
|
||||
dependencies:
|
||||
three: '*'
|
||||
sharp:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
'@immich/ui':
|
||||
dependencies:
|
||||
tailwindcss: '>=4.1'
|
||||
tailwind-variants:
|
||||
dependencies:
|
||||
tailwindcss: '>=4.1'
|
||||
bcrypt:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
dedupePeerDependents: false
|
||||
nestjs-kysely:
|
||||
dependencies:
|
||||
tslib: '*'
|
||||
nestjs-otel:
|
||||
dependencies:
|
||||
tslib: '*'
|
||||
sharp:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
tailwind-variants:
|
||||
dependencies:
|
||||
tailwindcss: '>=4.1'
|
||||
|
||||
preferWorkspacePackages: true
|
||||
injectWorkspacePackages: true
|
||||
|
||||
shamefullyHoist: false
|
||||
|
||||
verifyDepsBeforeRun: install
|
||||
|
||||
@@ -27,14 +27,16 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
|
||||
FROM dev AS dev-container-server
|
||||
|
||||
RUN apt-get update --allow-releaseinfo-change && \
|
||||
apt-get install inetutils-ping openjdk-21-jre-headless \
|
||||
apt-get install sudo inetutils-ping openjdk-21-jre-headless \
|
||||
vim nano curl \
|
||||
-y --no-install-recommends --fix-missing
|
||||
|
||||
RUN mkdir -p /workspaces && \
|
||||
ln -s /usr/src/app /workspaces/immich
|
||||
RUN usermod -aG sudo node && \
|
||||
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
|
||||
mkdir -p /workspaces/immich
|
||||
|
||||
COPY --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
|
||||
RUN chown node:node -R /workspaces
|
||||
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
|
||||
|
||||
WORKDIR /workspaces/immich
|
||||
|
||||
|
||||
+1
-1
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@extism/extism": "2.0.0-rc13",
|
||||
"@immich/walkrs": "^0.0.13",
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
@@ -72,7 +73,6 @@
|
||||
"cron": "4.4.0",
|
||||
"exiftool-vendored": "^34.3.0",
|
||||
"express": "^5.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"geo-tz": "^8.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
|
||||
@@ -54,16 +54,12 @@ export class UpdateLibraryDto {
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export interface CrawlOptionsDto {
|
||||
pathsToCrawl: string[];
|
||||
export interface WalkOptionsDto {
|
||||
pathsToWalk: string[];
|
||||
includeHidden?: boolean;
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export interface WalkOptionsDto extends CrawlOptionsDto {
|
||||
take: number;
|
||||
}
|
||||
|
||||
export class ValidateLibraryDto {
|
||||
@ApiPropertyOptional({ description: 'Import paths to validate (max 128)' })
|
||||
@Optional()
|
||||
@@ -136,9 +132,6 @@ export class LibraryStatsResponseDto {
|
||||
@ApiProperty({ type: 'integer', description: 'Total number of assets' })
|
||||
total = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Number of offline assets' })
|
||||
offline = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' })
|
||||
usage = 0;
|
||||
}
|
||||
|
||||
@@ -36,37 +36,27 @@ select
|
||||
(
|
||||
"asset"."type" = $1
|
||||
and "asset"."visibility" != $2
|
||||
and "asset"."isOffline" = $3
|
||||
)
|
||||
) as "photos",
|
||||
count(*) filter (
|
||||
where
|
||||
(
|
||||
"asset"."type" = $4
|
||||
and "asset"."visibility" != $5
|
||||
and "asset"."isOffline" = $6
|
||||
"asset"."type" = $3
|
||||
and "asset"."visibility" != $4
|
||||
)
|
||||
) as "videos",
|
||||
count(*) filter (
|
||||
where
|
||||
(
|
||||
"asset"."isOffline" = $7
|
||||
and "asset"."visibility" != $8
|
||||
)
|
||||
) as "offline",
|
||||
coalesce(sum("asset_exif"."fileSizeInByte"), $9) as "usage"
|
||||
coalesce(sum("asset_exif"."fileSizeInByte"), $5) as "usage"
|
||||
from
|
||||
"library"
|
||||
inner join "asset" on "asset"."libraryId" = "library"."id"
|
||||
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"library"."id" = $10
|
||||
"library"."id" = $6
|
||||
group by
|
||||
"library"."id"
|
||||
select
|
||||
0::int as "photos",
|
||||
0::int as "videos",
|
||||
0::int as "offline",
|
||||
0::int as "usage",
|
||||
0::int as "total"
|
||||
from
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { isEmpty, isUndefined, omitBy } from 'lodash';
|
||||
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { LockableProperty, Stack } from 'src/database';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
|
||||
@@ -79,11 +79,7 @@ export class LibraryRepository {
|
||||
eb.fn
|
||||
.countAll<number>()
|
||||
.filterWhere((eb) =>
|
||||
eb.and([
|
||||
eb('asset.type', '=', AssetType.Image),
|
||||
eb('asset.visibility', '!=', AssetVisibility.Hidden),
|
||||
eb('asset.isOffline', '=', false),
|
||||
]),
|
||||
eb.and([eb('asset.type', '=', AssetType.Image), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
|
||||
)
|
||||
.as('photos'),
|
||||
)
|
||||
@@ -91,22 +87,10 @@ export class LibraryRepository {
|
||||
eb.fn
|
||||
.countAll<number>()
|
||||
.filterWhere((eb) =>
|
||||
eb.and([
|
||||
eb('asset.type', '=', AssetType.Video),
|
||||
eb('asset.visibility', '!=', AssetVisibility.Hidden),
|
||||
eb('asset.isOffline', '=', false),
|
||||
]),
|
||||
eb.and([eb('asset.type', '=', AssetType.Video), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
|
||||
)
|
||||
.as('videos'),
|
||||
)
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.countAll<number>()
|
||||
.filterWhere((eb) =>
|
||||
eb.and([eb('asset.isOffline', '=', true), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
|
||||
)
|
||||
.as('offline'),
|
||||
)
|
||||
.select((eb) => eb.fn.coalesce((eb) => eb.fn.sum('asset_exif.fileSizeInByte'), eb.val(0)).as('usage'))
|
||||
.groupBy('library.id')
|
||||
.where('library.id', '=', id)
|
||||
@@ -119,7 +103,6 @@ export class LibraryRepository {
|
||||
.selectFrom('library')
|
||||
.select(zero.as('photos'))
|
||||
.select(zero.as('videos'))
|
||||
.select(zero.as('offline'))
|
||||
.select(zero.as('usage'))
|
||||
.select(zero.as('total'))
|
||||
.where('library.id', '=', id)
|
||||
@@ -129,7 +112,6 @@ export class LibraryRepository {
|
||||
return {
|
||||
photos: stats.photos,
|
||||
videos: stats.videos,
|
||||
offline: stats.offline,
|
||||
usage: stats.usage,
|
||||
total: stats.photos + stats.videos,
|
||||
};
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import mockfs from 'mock-fs';
|
||||
import { CrawlOptionsDto } from 'src/dtos/library.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { automock } from 'test/utils';
|
||||
|
||||
interface Test {
|
||||
test: string;
|
||||
options: CrawlOptionsDto;
|
||||
files: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
|
||||
const tests: Test[] = [
|
||||
{
|
||||
test: 'should return empty when crawling an empty path list',
|
||||
options: {
|
||||
pathsToCrawl: [],
|
||||
},
|
||||
files: {},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by file extension',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
exclusionPatterns: ['**/*.tif'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.tif': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by file extension without case sensitivity',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
exclusionPatterns: ['**/*.TIF'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.tif': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by folder',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
exclusionPatterns: ['**/raw/**'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/raw/image.jpg': false,
|
||||
'/photos/raw2/image.jpg': true,
|
||||
'/photos/folder/raw/image.jpg': false,
|
||||
'/photos/crawl/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl multiple paths',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/', '/images/', '/albums/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image1.jpg': true,
|
||||
'/images/image2.jpg': true,
|
||||
'/albums/image3.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path without trailing slash',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/subfolder/image1.jpg': true,
|
||||
'/photos/subfolder/image2.jpg': true,
|
||||
'/image1.jpg': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should filter file extensions',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.txt': false,
|
||||
'/photos/1': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should include photo and video extensions',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/', '/videos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.jpeg': true,
|
||||
'/photos/image.heic': true,
|
||||
'/photos/image.heif': true,
|
||||
'/photos/image.png': true,
|
||||
'/photos/image.gif': true,
|
||||
'/photos/image.tif': true,
|
||||
'/photos/image.tiff': true,
|
||||
'/photos/image.webp': true,
|
||||
'/photos/image.dng': true,
|
||||
'/photos/image.nef': true,
|
||||
'/videos/video.mp4': true,
|
||||
'/videos/video.mov': true,
|
||||
'/videos/video.webm': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should check file extensions without case sensitivity',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.Jpg': true,
|
||||
'/photos/image.jpG': true,
|
||||
'/photos/image.JPG': true,
|
||||
'/photos/image.jpEg': true,
|
||||
'/photos/image.TIFF': true,
|
||||
'/photos/image.tif': true,
|
||||
'/photos/image.dng': true,
|
||||
'/photos/image.NEF': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should normalize the path',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/1/../2'],
|
||||
},
|
||||
files: {
|
||||
'/photos/1/image.jpg': false,
|
||||
'/photos/2/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should return absolute paths',
|
||||
options: {
|
||||
pathsToCrawl: ['photos'],
|
||||
},
|
||||
files: {
|
||||
[`${cwd}/photos/1.jpg`]: true,
|
||||
[`${cwd}/photos/2.jpg`]: true,
|
||||
[`/photos/3.jpg`]: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should support special characters in paths',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos (new)'],
|
||||
},
|
||||
files: {
|
||||
['/photos (new)/1.jpg']: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe(StorageRepository.name, () => {
|
||||
let sut: StorageRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
sut = new StorageRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockfs.restore();
|
||||
});
|
||||
|
||||
describe('crawl', () => {
|
||||
for (const { test, options, files } of tests) {
|
||||
it(test, async () => {
|
||||
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
|
||||
|
||||
const actual = await sut.crawl(options);
|
||||
const expected = Object.entries(files)
|
||||
.filter((entry) => entry[1])
|
||||
.map(([file]) => file);
|
||||
|
||||
expect(actual.toSorted()).toEqual(expected.toSorted());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { WalkItem } from '@immich/walkrs' with { 'resolution-mode': 'import' };
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import archiver from 'archiver';
|
||||
import chokidar, { ChokidarOptions } from 'chokidar';
|
||||
import { escapePath, glob, globStream } from 'fast-glob';
|
||||
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable, Writable } from 'node:stream';
|
||||
import { createGunzip, createGzip } from 'node:zlib';
|
||||
import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto';
|
||||
import { WalkOptionsDto } from 'src/dtos/library.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
@@ -198,54 +198,22 @@ export class StorageRepository {
|
||||
};
|
||||
}
|
||||
|
||||
crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
|
||||
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
|
||||
if (pathsToCrawl.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
async *walk(walkOptions: WalkOptionsDto): AsyncGenerator<WalkItem[], void, unknown> {
|
||||
const { pathsToWalk, exclusionPatterns, includeHidden } = walkOptions;
|
||||
if (pathsToWalk.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path));
|
||||
const { walk } = await import('@immich/walkrs');
|
||||
|
||||
return glob(globbedPaths, {
|
||||
absolute: true,
|
||||
caseSensitiveMatch: false,
|
||||
onlyFiles: true,
|
||||
dot: includeHidden,
|
||||
ignore: exclusionPatterns,
|
||||
yield* walk({
|
||||
paths: pathsToWalk.map((p) => path.resolve(p)),
|
||||
includeHidden: includeHidden ?? false,
|
||||
exclusionPatterns,
|
||||
extensions: mimeTypes.getSupportedFileExtensions(),
|
||||
});
|
||||
}
|
||||
|
||||
async *walk(walkOptions: WalkOptionsDto): AsyncGenerator<string[]> {
|
||||
const { pathsToCrawl, exclusionPatterns, includeHidden } = walkOptions;
|
||||
if (pathsToCrawl.length === 0) {
|
||||
async function* emptyGenerator() {}
|
||||
return emptyGenerator();
|
||||
}
|
||||
|
||||
const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path));
|
||||
|
||||
const stream = globStream(globbedPaths, {
|
||||
absolute: true,
|
||||
caseSensitiveMatch: false,
|
||||
onlyFiles: true,
|
||||
dot: includeHidden,
|
||||
ignore: exclusionPatterns,
|
||||
});
|
||||
|
||||
let batch: string[] = [];
|
||||
for await (const value of stream) {
|
||||
batch.push(value.toString());
|
||||
if (batch.length === walkOptions.take) {
|
||||
yield batch;
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
yield batch;
|
||||
}
|
||||
}
|
||||
|
||||
watch(paths: string[], options: ChokidarOptions, events: Partial<WatchEvents>) {
|
||||
const watcher = chokidar.watch(paths, options);
|
||||
|
||||
@@ -257,10 +225,4 @@ export class StorageRepository {
|
||||
|
||||
return () => watcher.close();
|
||||
}
|
||||
|
||||
private asGlob(pathToCrawl: string): string {
|
||||
const escapedPath = escapePath(pathToCrawl).replaceAll('"', '["]').replaceAll("'", "[']").replaceAll('`', '[`]');
|
||||
const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`;
|
||||
return `${escapedPath}/**/${extensions}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Stats } from 'node:fs';
|
||||
import { defaults, SystemConfig } from 'src/config';
|
||||
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
|
||||
import { mapLibrary } from 'src/dtos/library.dto';
|
||||
import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
|
||||
import { LibraryService } from 'src/services/library.service';
|
||||
@@ -14,10 +13,6 @@ import { factory, newDate, newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
import { vitest } from 'vitest';
|
||||
|
||||
async function* mockWalk() {
|
||||
yield await Promise.resolve(['/data/user1/photo.jpg']);
|
||||
}
|
||||
|
||||
describe(LibraryService.name, () => {
|
||||
let sut: LibraryService;
|
||||
|
||||
@@ -165,7 +160,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library({ importPaths: ['/foo', '/bar'] });
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(mockWalk);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
|
||||
})(),
|
||||
);
|
||||
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
@@ -201,16 +200,20 @@ describe(LibraryService.name, () => {
|
||||
});
|
||||
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
|
||||
})(),
|
||||
);
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
|
||||
await sut.handleQueueSyncFiles({ id: library.id });
|
||||
|
||||
expect(mocks.storage.walk).toHaveBeenCalledWith({
|
||||
pathsToCrawl: [library.importPaths[1]],
|
||||
pathsToWalk: [library.importPaths[1]],
|
||||
exclusionPatterns: [],
|
||||
includeHidden: false,
|
||||
take: JOBS_LIBRARY_PAGINATION_SIZE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -220,7 +223,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library({ importPaths: ['/foo', '/bar'] });
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(mockWalk);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
|
||||
})(),
|
||||
);
|
||||
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
@@ -242,33 +249,6 @@ describe(LibraryService.name, () => {
|
||||
|
||||
await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.Skipped);
|
||||
});
|
||||
|
||||
it('should ignore import paths that do not exist', async () => {
|
||||
const library = factory.library({ importPaths: ['/foo', '/bar'] });
|
||||
|
||||
mocks.storage.stat.mockImplementation((path): Promise<Stats> => {
|
||||
if (path === library.importPaths[0]) {
|
||||
const error = { code: 'ENOENT' } as any;
|
||||
throw error;
|
||||
}
|
||||
return Promise.resolve({
|
||||
isDirectory: () => true,
|
||||
} as Stats);
|
||||
});
|
||||
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
|
||||
await sut.handleQueueSyncFiles({ id: library.id });
|
||||
|
||||
expect(mocks.storage.walk).toHaveBeenCalledWith({
|
||||
pathsToCrawl: [library.importPaths[1]],
|
||||
exclusionPatterns: [],
|
||||
includeHidden: false,
|
||||
take: JOBS_LIBRARY_PAGINATION_SIZE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueSyncAssets', () => {
|
||||
@@ -276,7 +256,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library();
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([]);
|
||||
})(),
|
||||
);
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
|
||||
|
||||
@@ -294,7 +278,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library();
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([]);
|
||||
})(),
|
||||
);
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
|
||||
|
||||
@@ -309,7 +297,11 @@ describe(LibraryService.name, () => {
|
||||
const asset = AssetFactory.create({ libraryId: library.id, isExternal: true });
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([]);
|
||||
})(),
|
||||
);
|
||||
mocks.library.streamAssetIds.mockReturnValue(makeStream([asset]));
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 0n });
|
||||
@@ -681,19 +673,12 @@ describe(LibraryService.name, () => {
|
||||
it('should return library statistics', async () => {
|
||||
const library = factory.library();
|
||||
|
||||
mocks.library.getStatistics.mockResolvedValue({
|
||||
photos: 10,
|
||||
videos: 0,
|
||||
total: 10,
|
||||
usage: 1337,
|
||||
offline: 67,
|
||||
});
|
||||
mocks.library.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
|
||||
await expect(sut.getStatistics(library.id)).resolves.toEqual({
|
||||
photos: 10,
|
||||
videos: 0,
|
||||
total: 10,
|
||||
usage: 1337,
|
||||
offline: 67,
|
||||
});
|
||||
|
||||
expect(mocks.library.getStatistics).toHaveBeenCalledWith(library.id);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { R_OK } from 'node:constants';
|
||||
import { Stats } from 'node:fs';
|
||||
import path, { basename, isAbsolute, parse } from 'node:path';
|
||||
import picomatch from 'picomatch';
|
||||
|
||||
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
@@ -247,9 +248,11 @@ export class LibraryService extends BaseService {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const newPaths = await this.assetRepository.filterNewExternalAssetPaths(library.id, job.paths);
|
||||
|
||||
const assetImports: Insertable<AssetTable>[] = [];
|
||||
await Promise.all(
|
||||
job.paths.map((path) =>
|
||||
newPaths.map((path) =>
|
||||
this.processEntity(path, library.ownerId, job.libraryId)
|
||||
.then((asset) => assetImports.push(asset))
|
||||
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)),
|
||||
@@ -394,6 +397,7 @@ export class LibraryService extends BaseService {
|
||||
|
||||
private async processEntity(filePath: string, ownerId: string, libraryId: string) {
|
||||
const assetPath = path.normalize(filePath);
|
||||
|
||||
const stat = await this.storageRepository.stat(assetPath);
|
||||
|
||||
return {
|
||||
@@ -636,42 +640,56 @@ export class LibraryService extends BaseService {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const pathsOnDisk = this.storageRepository.walk({
|
||||
pathsToCrawl: validImportPaths,
|
||||
includeHidden: false,
|
||||
exclusionPatterns: library.exclusionPatterns,
|
||||
take: JOBS_LIBRARY_PAGINATION_SIZE,
|
||||
});
|
||||
|
||||
let importCount = 0;
|
||||
let crawlCount = 0;
|
||||
|
||||
this.logger.log(`Starting disk crawl of ${validImportPaths.length} import path(s) for library ${library.id}...`);
|
||||
|
||||
for await (const pathBatch of pathsOnDisk) {
|
||||
crawlCount += pathBatch.length;
|
||||
const paths = await this.assetRepository.filterNewExternalAssetPaths(library.id, pathBatch);
|
||||
const fileWalker = this.storageRepository.walk({
|
||||
pathsToWalk: validImportPaths,
|
||||
includeHidden: false, // TODO: make this configurable?
|
||||
exclusionPatterns: library.exclusionPatterns,
|
||||
});
|
||||
|
||||
if (paths.length > 0) {
|
||||
importCount += paths.length;
|
||||
const walkStart = Date.now();
|
||||
let progressCounter = 0;
|
||||
let lastLoggedMilestone = 0;
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.LibrarySyncFiles,
|
||||
data: {
|
||||
libraryId: library.id,
|
||||
paths,
|
||||
progressCounter: crawlCount,
|
||||
},
|
||||
});
|
||||
for await (const walkItems of fileWalker) {
|
||||
const paths: string[] = [];
|
||||
for (const item of walkItems) {
|
||||
if (item.type === 'error') {
|
||||
this.logger.warn(`Error walking ${item.path ?? 'unknown path'}: ${item.message} for library ${library.id}`);
|
||||
} else {
|
||||
paths.push(item.path);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Crawled ${crawlCount} file(s) so far: ${paths.length} of current batch of ${pathBatch.length} will be imported to library ${library.id}...`,
|
||||
);
|
||||
if (paths.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
progressCounter += paths.length;
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.LibrarySyncFiles,
|
||||
data: {
|
||||
libraryId: library.id,
|
||||
paths,
|
||||
progressCounter,
|
||||
},
|
||||
});
|
||||
|
||||
const currentMilestone = Math.floor(progressCounter / 100_000);
|
||||
// Log every 100k files found to give some feedback on progress for large libraries
|
||||
if (currentMilestone > lastLoggedMilestone) {
|
||||
const roundedCount = currentMilestone * 100_000;
|
||||
this.logger.log(
|
||||
`Disk walk found ${roundedCount} file(s) so far (${((Date.now() - walkStart) / 1000).toFixed(2)}s elapsed) for library ${library.id}...`,
|
||||
);
|
||||
lastLoggedMilestone = currentMilestone;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Finished disk crawl, ${crawlCount} file(s) found on disk and queued ${importCount} file(s) for import into ${library.id}`,
|
||||
`Finished disk walk, ${progressCounter} file(s) found on disk in ${((Date.now() - walkStart) / 1000).toFixed(2)}s for library ${library.id}`,
|
||||
);
|
||||
|
||||
await this.libraryRepository.update(job.id, { refreshedAt: new Date() });
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import type { WalkError, WalkItem } from '@immich/walkrs' with { 'resolution-mode': 'import' };
|
||||
import { Kysely } from 'kysely';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import { WalkOptionsDto } from 'src/dtos/library.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
interface Test {
|
||||
test: string;
|
||||
options: WalkOptionsDto;
|
||||
files: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const createTestFiles = async (basePath: string, files: string[]) => {
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fullPath = path.join(basePath, file.replace(/^\//, ''));
|
||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||
await fs.writeFile(fullPath, '');
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const tests: Test[] = [
|
||||
{
|
||||
test: 'should return empty when walking an empty path list',
|
||||
options: {
|
||||
pathsToWalk: [],
|
||||
},
|
||||
files: {},
|
||||
},
|
||||
{
|
||||
test: 'should walk a single path',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by file extension',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
exclusionPatterns: ['**/*.tif'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.tif': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by file extension without case sensitivity',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
exclusionPatterns: ['**/*.TIF'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.tif': false,
|
||||
'/photos/image.tIf': false,
|
||||
'/photos/image.TIF': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by folder',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
exclusionPatterns: ['**/raw/**'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/raw/image.jpg': false,
|
||||
'/photos/raw2/image.jpg': true,
|
||||
'/photos/folder/raw/image.jpg': false,
|
||||
'/photos/walk/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should walk multiple paths',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/', '/images/', '/albums/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image1.jpg': true,
|
||||
'/images/image2.jpg': true,
|
||||
'/albums/image3.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should walk a single path without trailing slash',
|
||||
options: {
|
||||
pathsToWalk: ['/photos'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should walk a single path',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/subfolder/image1.jpg': true,
|
||||
'/photos/subfolder/image2.jpg': true,
|
||||
'/image1.jpg': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should filter file extensions',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.txt': false,
|
||||
'/photos/1': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should include photo and video extensions',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/', '/videos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.jpeg': true,
|
||||
'/photos/image.heic': true,
|
||||
'/photos/image.heif': true,
|
||||
'/photos/image.png': true,
|
||||
'/photos/image.gif': true,
|
||||
'/photos/image.tif': true,
|
||||
'/photos/image.tiff': true,
|
||||
'/photos/image.webp': true,
|
||||
'/photos/image.dng': true,
|
||||
'/photos/image.nef': true,
|
||||
'/videos/video.mp4': true,
|
||||
'/videos/video.mov': true,
|
||||
'/videos/video.webm': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should check file extensions without case sensitivity',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/photos/image.Jpg': true,
|
||||
'/photos/image.jpG': true,
|
||||
'/photos/image.JPG': true,
|
||||
'/photos/image.jpEg': true,
|
||||
'/photos/image.TIFF': true,
|
||||
'/photos/image.tif': true,
|
||||
'/photos/image.dng': true,
|
||||
'/photos/image.NEF': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should normalize the path',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/1/../2'],
|
||||
},
|
||||
files: {
|
||||
'/photos/1/image.jpg': false,
|
||||
'/photos/2/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should support special characters in paths',
|
||||
options: {
|
||||
pathsToWalk: ['/photos (new)'],
|
||||
},
|
||||
files: {
|
||||
['/photos (new)/1.jpg']: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
return { sut: ctx.get(StorageRepository) };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(StorageRepository.name, () => {
|
||||
let sut: StorageRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut } = setup());
|
||||
});
|
||||
|
||||
describe('walk', () => {
|
||||
for (const { test, options, files } of tests) {
|
||||
describe(test, () => {
|
||||
const fileList = Object.keys(files);
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'immich-storage-test-'));
|
||||
await createTestFiles(tempDir, fileList);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns expected files', async () => {
|
||||
const adjustedOptions = {
|
||||
...options,
|
||||
pathsToWalk: options.pathsToWalk.map((p) => path.join(tempDir, p.replace(/^\//, ''))),
|
||||
};
|
||||
|
||||
const actual: string[] = [];
|
||||
for await (const batch of sut.walk(adjustedOptions)) {
|
||||
for (const item of batch) {
|
||||
if (item.type === 'entry') {
|
||||
actual.push(item.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
const expected = Object.entries(files)
|
||||
.filter((entry) => entry[1])
|
||||
.map(([file]) => path.join(tempDir, file.replace(/^\//, '')));
|
||||
|
||||
expect(actual.toSorted()).toEqual(expected.toSorted());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('should handle access denied errors gracefully', async () => {
|
||||
const testDir = await fs.mkdtemp(join(os.tmpdir(), 'immich-test-access-denied-'));
|
||||
const restrictedDir = join(testDir, 'restricted');
|
||||
const restrictedFile = join(restrictedDir, 'file.jpg');
|
||||
const accessibleFile = join(testDir, 'accessible.jpg');
|
||||
|
||||
try {
|
||||
// Create test directory structure
|
||||
await fs.mkdir(restrictedDir, { recursive: true });
|
||||
await fs.writeFile(accessibleFile, 'accessible content');
|
||||
await fs.writeFile(restrictedFile, 'restricted content');
|
||||
|
||||
// Remove all permissions from restricted directory to simulate access denied
|
||||
await fs.chmod(restrictedDir, 0o000);
|
||||
|
||||
const actual: string[] = [];
|
||||
const errors: WalkItem[] = [];
|
||||
for await (const batch of sut.walk({ pathsToWalk: [testDir] })) {
|
||||
for (const item of batch) {
|
||||
if (item.type === 'entry') {
|
||||
actual.push(item.path);
|
||||
} else {
|
||||
errors.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should successfully walk accessible file but skip restricted directory
|
||||
expect(actual).toContain(accessibleFile);
|
||||
expect(actual).not.toContain(restrictedFile);
|
||||
// Should have encountered an error for the restricted directory
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors.some((e) => e.type === 'error' && e.message?.includes('restricted'))).toBe(true);
|
||||
} finally {
|
||||
// Cleanup: restore permissions before deletion
|
||||
try {
|
||||
await fs.chmod(restrictedDir, 0o755);
|
||||
} catch {
|
||||
// Ignore errors if directory was already deleted or permissions cannot be restored
|
||||
}
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error details for access denied paths', async () => {
|
||||
const testDir = await fs.mkdtemp(join(os.tmpdir(), 'immich-test-access-denied-'));
|
||||
const restrictedDir = join(testDir, 'restricted');
|
||||
const restrictedFile = join(restrictedDir, 'file.jpg');
|
||||
const accessibleFile = join(testDir, 'accessible.jpg');
|
||||
|
||||
try {
|
||||
// Create test directory structure
|
||||
await fs.mkdir(restrictedDir, { recursive: true });
|
||||
await fs.writeFile(accessibleFile, 'accessible content');
|
||||
await fs.writeFile(restrictedFile, 'restricted content');
|
||||
|
||||
// Remove all permissions from restricted directory to simulate access denied
|
||||
await fs.chmod(restrictedDir, 0o000);
|
||||
|
||||
const errors: WalkError[] = [];
|
||||
for await (const batch of sut.walk({ pathsToWalk: [testDir] })) {
|
||||
for (const item of batch) {
|
||||
if (item.type === 'error') {
|
||||
errors.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should have error details including path and message
|
||||
expect(errors.length).toBe(1);
|
||||
const restrictedError = errors.find((e) => e.type === 'error' && e.message?.includes('restricted'));
|
||||
expect(restrictedError).toBeDefined();
|
||||
expect(restrictedError?.type).toBe('error');
|
||||
expect(restrictedError?.message).toBeDefined();
|
||||
} finally {
|
||||
// Cleanup: restore permissions before deletion
|
||||
try {
|
||||
await fs.chmod(restrictedDir, 0o755);
|
||||
} catch {
|
||||
// Ignore errors if directory was already deleted or permissions cannot be restored
|
||||
}
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,8 +68,7 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
||||
readdir: vitest.fn(),
|
||||
realpath: vitest.fn().mockImplementation((filepath: string) => Promise.resolve(filepath)),
|
||||
stat: vitest.fn(),
|
||||
crawl: vitest.fn(),
|
||||
walk: vitest.fn().mockImplementation(async function* () {}),
|
||||
walk: vitest.fn(),
|
||||
rename: vitest.fn(),
|
||||
copyFile: vitest.fn(),
|
||||
utimes: vitest.fn(),
|
||||
|
||||
@@ -223,7 +223,6 @@
|
||||
bind:this={element}
|
||||
data-asset={asset.id}
|
||||
data-thumbnail-focus-container
|
||||
data-selected={selected ? true : undefined}
|
||||
tabindex={0}
|
||||
role="link"
|
||||
>
|
||||
|
||||
@@ -2,36 +2,20 @@
|
||||
import { ByteUnit } from '$lib/utils/byte-units';
|
||||
import { Icon, Text } from '@immich/ui';
|
||||
|
||||
interface ValueData {
|
||||
interface Props {
|
||||
icon: string;
|
||||
title: string;
|
||||
value: number;
|
||||
unit?: ByteUnit | undefined;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
title: string;
|
||||
valuePromise: Promise<ValueData>;
|
||||
}
|
||||
|
||||
let { icon, title, valuePromise }: Props = $props();
|
||||
let isLoading = $state(true);
|
||||
let data = $state<ValueData | null>(null);
|
||||
|
||||
$effect.pre(() => {
|
||||
isLoading = true;
|
||||
void valuePromise.then((result) => {
|
||||
data = result;
|
||||
isLoading = false;
|
||||
});
|
||||
});
|
||||
let { icon, title, value, unit = undefined }: Props = $props();
|
||||
|
||||
const zeros = $derived(() => {
|
||||
const maxLength = 13;
|
||||
if (!data) {
|
||||
return '0'.repeat(maxLength);
|
||||
}
|
||||
const valueLength = data.value.toString().length;
|
||||
const valueLength = value.toString().length;
|
||||
const zeroLength = maxLength - valueLength;
|
||||
|
||||
return '0'.repeat(zeroLength);
|
||||
});
|
||||
</script>
|
||||
@@ -42,26 +26,10 @@
|
||||
<Text size="giant" fontWeight="medium">{title}</Text>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto font-mono text-2xl font-medium relative">
|
||||
<span class="text-gray-300 dark:text-gray-600" class:shimmer-text={isLoading}>{zeros()}</span
|
||||
>{#if !isLoading && data}<span>{data.value}</span>
|
||||
{#if data.unit}<code class="font-mono text-base font-normal">{data.unit}</code>{/if}{/if}
|
||||
<div class="mx-auto font-mono text-2xl font-medium">
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span><span>{value}</span>
|
||||
{#if unit}
|
||||
<code class="font-mono text-base font-normal">{unit}</code>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shimmer-text {
|
||||
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 1) 100%);
|
||||
mask-size: 200% 100%;
|
||||
animation: shimmer 2.25s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
mask-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
mask-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
import StatsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import type { ServerStatsResponseDto, UserAdminResponseDto } from '@immich/sdk';
|
||||
import type { ServerStatsResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Code,
|
||||
FormatBytes,
|
||||
@@ -19,35 +19,10 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
statsPromise: Promise<ServerStatsResponseDto>;
|
||||
users: UserAdminResponseDto[];
|
||||
stats: ServerStatsResponseDto;
|
||||
};
|
||||
|
||||
const { statsPromise, users }: Props = $props();
|
||||
let stats = $state<ServerStatsResponseDto | null>(null);
|
||||
|
||||
$effect.pre(() => {
|
||||
void statsPromise.then((result) => {
|
||||
stats = result;
|
||||
});
|
||||
});
|
||||
|
||||
const photosPromise = $derived.by(() => statsPromise.then((data) => ({ value: data.photos })));
|
||||
|
||||
const videosPromise = $derived.by(() => statsPromise.then((data) => ({ value: data.videos })));
|
||||
|
||||
const storagePromise = $derived.by(() =>
|
||||
statsPromise.then((data) => {
|
||||
const TiB = 1024 ** 4;
|
||||
const [value, unit] = getBytesWithUnit(data.usage, data.usage > TiB ? 2 : 0);
|
||||
return { value, unit };
|
||||
}),
|
||||
);
|
||||
|
||||
const storageUsageWithUnit = $derived.by(() => {
|
||||
const TiB = 1024 ** 4;
|
||||
return stats ? getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0) : ([0, ''] as const);
|
||||
});
|
||||
const { stats }: Props = $props();
|
||||
|
||||
const zeros = (value: number, maxLength = 13) => {
|
||||
const valueLength = value.toString().length;
|
||||
@@ -55,6 +30,9 @@
|
||||
|
||||
return '0'.repeat(zeroLength);
|
||||
};
|
||||
|
||||
const TiB = 1024 ** 4;
|
||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-5 my-4">
|
||||
@@ -62,52 +40,48 @@
|
||||
<Text class="mb-2" fontWeight="medium">{$t('total_usage')}</Text>
|
||||
|
||||
<div class="hidden justify-between lg:flex gap-4">
|
||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
|
||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
|
||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} valuePromise={storagePromise} />
|
||||
<StatsCard icon={mdiCameraIris} title={$t('photos')} value={stats.photos} />
|
||||
<StatsCard icon={mdiPlayCircle} title={$t('videos')} value={stats.videos} />
|
||||
<StatsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex lg:hidden">
|
||||
{#if stats}
|
||||
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
<div class="flex flex-1 place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiCameraIris} size="25" />
|
||||
<Text size="medium" fontWeight="medium">{$t('photos')}</Text>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center font-mono text-2xl font-medium">
|
||||
<span class="text-light-300">{zeros(stats.photos)}</span><span class="text-primary">{stats.photos}</span>
|
||||
</div>
|
||||
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
<div class="flex flex-1 place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiCameraIris} size="25" />
|
||||
<Text size="medium" fontWeight="medium">{$t('photos')}</Text>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
<div class="flex flex-1 place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiPlayCircle} size="25" />
|
||||
<Text size="medium" fontWeight="medium">{$t('videos')}</Text>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center font-mono text-2xl font-medium">
|
||||
<span class="text-light-300">{zeros(stats.videos)}</span><span class="text-primary">{stats.videos}</span>
|
||||
</div>
|
||||
<div class="relative text-center font-mono text-2xl font-medium">
|
||||
<span class="text-light-300">{zeros(stats.photos)}</span><span class="text-primary">{stats.photos}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
<div class="flex flex-1 place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiPlayCircle} size="25" />
|
||||
<Text size="medium" fontWeight="medium">{$t('videos')}</Text>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-5">
|
||||
<div class="flex flex-1 flex-nowrap place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiChartPie} size="25" />
|
||||
<Text size="medium" fontWeight="medium">{$t('storage')}</Text>
|
||||
</div>
|
||||
|
||||
<div class="relative flex text-center font-mono text-2xl font-medium">
|
||||
<span class="text-light-300">{zeros(storageUsageWithUnit[0])}</span><span class="text-primary"
|
||||
>{storageUsageWithUnit[0]}</span
|
||||
>
|
||||
<div class="relative text-center font-mono text-2xl font-medium">
|
||||
<span class="text-light-300">{zeros(stats.videos)}</span><span class="text-primary">{stats.videos}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-5">
|
||||
<div class="flex flex-1 flex-nowrap place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiChartPie} size="25" />
|
||||
<Text size="medium" fontWeight="medium">{$t('storage')}</Text>
|
||||
</div>
|
||||
|
||||
<div class="absolute -end-1.5 -bottom-4">
|
||||
<Code color="muted" class="text-xs font-light font-mono">{storageUsageWithUnit[1]}</Code>
|
||||
</div>
|
||||
<div class="relative flex text-center font-mono text-2xl font-medium">
|
||||
<span class="text-light-300">{zeros(statsUsage)}</span><span class="text-primary">{statsUsage}</span>
|
||||
|
||||
<div class="absolute -end-1.5 -bottom-4">
|
||||
<Code color="muted" class="text-xs font-light font-mono">{statsUsageUnit}</Code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,82 +95,34 @@
|
||||
<TableHeading class="w-1/4">{$t('usage')}</TableHeading>
|
||||
</TableHeader>
|
||||
<TableBody class="block max-h-80 overflow-y-auto">
|
||||
{#if stats}
|
||||
{#each stats.usageByUser as user (user.userId)}
|
||||
<TableRow>
|
||||
<TableCell class="w-1/4">{user.userName}</TableCell>
|
||||
<TableCell class="w-1/4">
|
||||
{user.photos.toLocaleString($locale)} (<FormatBytes bytes={user.usagePhotos} />)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
{user.videos.toLocaleString($locale)} (<FormatBytes
|
||||
bytes={user.usageVideos}
|
||||
precision={0}
|
||||
/>)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
<FormatBytes bytes={user.usage} precision={0} />
|
||||
{#if user.quotaSizeInBytes !== null}
|
||||
/ <FormatBytes bytes={user.quotaSizeInBytes} precision={0} />
|
||||
{#each stats.usageByUser as user (user.userId)}
|
||||
<TableRow>
|
||||
<TableCell class="w-1/4">{user.userName}</TableCell>
|
||||
<TableCell class="w-1/4">
|
||||
{user.photos.toLocaleString($locale)} (<FormatBytes bytes={user.usagePhotos} />)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
{user.videos.toLocaleString($locale)} (<FormatBytes bytes={user.usageVideos} precision={0} />)</TableCell
|
||||
>
|
||||
<TableCell class="w-1/4">
|
||||
<FormatBytes bytes={user.usage} precision={0} />
|
||||
{#if user.quotaSizeInBytes !== null}
|
||||
/ <FormatBytes bytes={user.quotaSizeInBytes} precision={0} />
|
||||
{/if}
|
||||
<span class="text-primary">
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 0,
|
||||
})})
|
||||
{:else}
|
||||
({$t('unlimited')})
|
||||
{/if}
|
||||
<span class="text-primary">
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 0,
|
||||
})})
|
||||
{:else}
|
||||
({$t('unlimited')})
|
||||
{/if}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{:else if users.length}
|
||||
{#each users as user (user.id)}
|
||||
<TableRow>
|
||||
<TableCell class="w-1/4">{user.name}</TableCell>
|
||||
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-16"></span></TableCell>
|
||||
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-16"></span></TableCell>
|
||||
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-24"></span></TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.skeleton-loader {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: rgba(156, 163, 175, 0.35);
|
||||
}
|
||||
|
||||
.skeleton-loader::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0.8) 50%,
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-position: 200% 0;
|
||||
animation: skeleton-animation 2000ms infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-animation {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { getLibrariesActions, getLibraryActions } from '$lib/services/library.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto, type LibraryStatsResponseDto } from '@immich/sdk';
|
||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
CommandPaletteDefaultProvider,
|
||||
Container,
|
||||
@@ -34,21 +34,9 @@
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
let libraries = $state(data.libraries);
|
||||
let statistics = $state<Record<string, LibraryStatsResponseDto>>({});
|
||||
let statistics = $state(data.statistics);
|
||||
let owners = $state(data.owners);
|
||||
|
||||
const loadStatistics = async () => {
|
||||
try {
|
||||
statistics = await data.statisticsPromise;
|
||||
} catch (error) {
|
||||
console.error('Failed to load library statistics:', error);
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
void loadStatistics();
|
||||
});
|
||||
|
||||
const onLibraryCreate = async (library: LibraryResponseDto) => {
|
||||
await goto(Route.viewLibrary(library));
|
||||
};
|
||||
@@ -106,7 +94,8 @@
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each libraries as library (library.id + library.name)}
|
||||
{@const stats = statistics[library.id]}
|
||||
{@const { photos, usage, videos } = statistics[library.id]}
|
||||
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
|
||||
{@const owner = owners[library.id]}
|
||||
<TableRow>
|
||||
<TableCell class={classes.column1}>
|
||||
@@ -115,29 +104,9 @@
|
||||
<TableCell class={classes.column2}>
|
||||
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
|
||||
</TableCell>
|
||||
{#if stats}
|
||||
<TableCell class={classes.column3}>
|
||||
{stats.photos.toLocaleString($locale)}
|
||||
</TableCell>
|
||||
<TableCell class={classes.column4}>
|
||||
{stats.videos.toLocaleString($locale)}
|
||||
</TableCell>
|
||||
<TableCell class={classes.column5}>
|
||||
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(stats.usage, 0)}
|
||||
{diskUsage}
|
||||
{diskUsageUnit}
|
||||
</TableCell>
|
||||
{:else}
|
||||
<TableCell class={classes.column3}>
|
||||
<span class="skeleton-loader inline-block h-4 w-14"></span>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column4}>
|
||||
<span class="skeleton-loader inline-block h-4 w-14"></span>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column5}>
|
||||
<span class="skeleton-loader inline-block h-4 w-20"></span>
|
||||
</TableCell>
|
||||
{/if}
|
||||
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</TableCell>
|
||||
<TableCell class={classes.column6}>
|
||||
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
|
||||
</TableCell>
|
||||
@@ -158,37 +127,3 @@
|
||||
</div>
|
||||
</Container>
|
||||
</AdminPageLayout>
|
||||
|
||||
<style>
|
||||
.skeleton-loader {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: rgba(156, 163, 175, 0.35);
|
||||
}
|
||||
|
||||
.skeleton-loader::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0.8) 50%,
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-position: 200% 0;
|
||||
animation: skeleton-animation 2000ms infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-animation {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const load = (async ({ url }) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const libraries = await getAllLibraries();
|
||||
const statisticsPromise = Promise.all(
|
||||
const statistics = await Promise.all(
|
||||
libraries.map(async ({ id }) => [id, await getLibraryStatistics({ id })] as const),
|
||||
);
|
||||
const owners = await Promise.all(
|
||||
@@ -20,7 +20,7 @@ export const load = (async ({ url }) => {
|
||||
return {
|
||||
allUsers,
|
||||
libraries,
|
||||
statisticsPromise: statisticsPromise.then((stats) => Object.fromEntries(stats)),
|
||||
statistics: Object.fromEntries(statistics),
|
||||
owners: Object.fromEntries(owners),
|
||||
meta: {
|
||||
title: $t('external_libraries'),
|
||||
|
||||
@@ -15,17 +15,9 @@
|
||||
getLibraryFolderActions,
|
||||
} from '$lib/services/library.service';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
|
||||
import type { LibraryResponseDto, LibraryStatsResponseDto } from '@immich/sdk';
|
||||
import type { LibraryResponseDto } from '@immich/sdk';
|
||||
import { Code, CommandPaletteDefaultProvider, Container, Heading, modalManager } from '@immich/ui';
|
||||
import {
|
||||
mdiCameraIris,
|
||||
mdiChartPie,
|
||||
mdiFileDocumentRemoveOutline,
|
||||
mdiFilterMinusOutline,
|
||||
mdiFolderOutline,
|
||||
mdiPlayCircle,
|
||||
} from '@mdi/js';
|
||||
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { LayoutData } from './$types';
|
||||
@@ -35,28 +27,16 @@
|
||||
data: LayoutData;
|
||||
};
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
const statisticsPromise = $derived.by(() => data.statisticsPromise as Promise<LibraryStatsResponseDto>);
|
||||
const { children, data }: Props = $props();
|
||||
|
||||
const photosPromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.photos })));
|
||||
const statistics = data.statistics;
|
||||
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
|
||||
|
||||
const videosPromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.videos })));
|
||||
|
||||
const usagePromise = $derived.by(() =>
|
||||
statisticsPromise.then((stats) => {
|
||||
const [value, unit] = getBytesWithUnit(stats.usage);
|
||||
return { value, unit };
|
||||
}),
|
||||
);
|
||||
|
||||
const offlinePromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.offline })));
|
||||
|
||||
let updatedLibrary = $state<LibraryResponseDto | undefined>(undefined);
|
||||
const library = $derived.by(() => (updatedLibrary?.id === data.library.id ? updatedLibrary : data.library));
|
||||
let library = $state(data.library);
|
||||
|
||||
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
|
||||
if (newLibrary.id === library.id) {
|
||||
updatedLibrary = newLibrary;
|
||||
library = newLibrary;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,9 +61,9 @@
|
||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
||||
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
|
||||
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
|
||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
|
||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
|
||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} valuePromise={usagePromise} />
|
||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
|
||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
|
||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
|
||||
</div>
|
||||
|
||||
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
|
||||
@@ -132,10 +112,6 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</AdminCard>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<ServerStatisticsCard icon={mdiFileDocumentRemoveOutline} title={$t('offline')} valuePromise={offlinePromise} />
|
||||
</div>
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</Container>
|
||||
|
||||
@@ -16,12 +16,12 @@ export const load = (async ({ params: { id }, url }) => {
|
||||
redirect(307, Route.libraries());
|
||||
}
|
||||
|
||||
const statisticsPromise = getLibraryStatistics({ id });
|
||||
const statistics = await getLibraryStatistics({ id });
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
library,
|
||||
statisticsPromise,
|
||||
statistics,
|
||||
meta: {
|
||||
title: $t('admin.library_details'),
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import ServerStatisticsPanel from '$lib/components/server-statistics/ServerStatisticsPanel.svelte';
|
||||
import { getServerStatistics, type ServerStatsResponseDto } from '@immich/sdk';
|
||||
import { getServerStatistics } from '@immich/sdk';
|
||||
import { Container } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
@@ -12,14 +12,7 @@
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
let stats = $state<ServerStatsResponseDto | undefined>(undefined);
|
||||
|
||||
const statsPromise = $derived.by(() => {
|
||||
if (stats) {
|
||||
return Promise.resolve(stats);
|
||||
}
|
||||
return data.statsPromise;
|
||||
});
|
||||
let stats = $state(data.stats);
|
||||
|
||||
const updateStatistics = async () => {
|
||||
stats = await getServerStatistics();
|
||||
@@ -34,6 +27,6 @@
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
|
||||
<Container size="large" center>
|
||||
<ServerStatisticsPanel {statsPromise} users={data.users} />
|
||||
<ServerStatisticsPanel {stats} />
|
||||
</Container>
|
||||
</AdminPageLayout>
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getServerStatistics, searchUsersAdmin } from '@immich/sdk';
|
||||
import { getServerStatistics } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
const statsPromise = getServerStatistics();
|
||||
const users = await searchUsersAdmin({ withDeleted: false });
|
||||
const stats = await getServerStatistics();
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
statsPromise,
|
||||
users,
|
||||
stats,
|
||||
meta: {
|
||||
title: $t('server_stats'),
|
||||
},
|
||||
|
||||
@@ -123,21 +123,9 @@
|
||||
</div>
|
||||
<div class="col-span-full">
|
||||
<div class="flex flex-col lg:flex-row gap-4 w-full">
|
||||
<ServerStatisticsCard
|
||||
icon={mdiCameraIris}
|
||||
title={$t('photos')}
|
||||
valuePromise={Promise.resolve({ value: userStatistics.images })}
|
||||
/>
|
||||
<ServerStatisticsCard
|
||||
icon={mdiPlayCircle}
|
||||
title={$t('videos')}
|
||||
valuePromise={Promise.resolve({ value: userStatistics.videos })}
|
||||
/>
|
||||
<ServerStatisticsCard
|
||||
icon={mdiChartPie}
|
||||
title={$t('storage')}
|
||||
valuePromise={Promise.resolve({ value: statsUsage, unit: statsUsageUnit })}
|
||||
/>
|
||||
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={userStatistics.images} />
|
||||
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={userStatistics.videos} />
|
||||
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user