Compare commits

...

9 Commits

Author SHA1 Message Date
Jonathan Jogenfors
8888166928 fix comments 2026-02-27 14:00:27 +01:00
Jonathan Jogenfors
6982987f3f feat: add offline library statistics 2026-02-21 00:00:49 +01:00
Peter Ombodi
82c6302549 feat(mobile): timeline - add persistentBottomBar flag (#25634)
* feat(mobile): timeline - add selectable all-assets control

* feature(mobile): introduce bottomWidgetBuilder in Timeline
remove redundant code

* fix(mobile): remove redundant code

* refactor(mobile): refactor new code in Timeline

* fix(mobile): fix format

* refactor(mobile): replace unsupported Dart syntax for analyzer compatibility

* refactor(mobile): remove Timeline.bottomSheet and migrate to bottomWidgetBuilder

* refactor(mobile): restore Timeline.bottomSheet and remove bottomWidgetBuilder
add withPersistentBottomBar param to Timeline class

* refactor(mobile): refactor var name

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-02-20 23:51:26 +05:30
Min Idzelis
aae64b5e2f test: thumbnail selector (#26383)
* test: face ordering issue/flakiness

* test: thumbnail selector
2026-02-20 15:04:17 +00:00
Benjamin Nguyen
18bf96b4b2 fix(mobile): handle userPreferencesProvider error state during sync (#26332)
fix drift_search_page render bug
2026-02-20 08:57:28 -06:00
Timon
84f2956941 fix(cli): delete sidecar files after upload if requested (#26353)
* fix(cli): delete sidecar files after upload if requested

Introduced a new function, findSidecar, to locate XMP sidecar files based on specified naming conventions. Updated the deleteFiles function to delete associated sidecar files when the main asset file is deleted. Added unit tests for findSidecar to ensure correct functionality.

* lint and format

* fix test

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-20 14:54:08 +00:00
Min Idzelis
6044b41648 fix: align devcontainers with standard development containers (#26321) 2026-02-20 09:37:07 -05:00
Min Idzelis
b4e16efdf4 test: face ordering issue/flakiness (#26382) 2026-02-20 09:23:40 -05:00
Min Idzelis
19da655390 fix: exiftool-vendored.exe (#26393) 2026-02-20 09:16:42 -05:00
32 changed files with 560 additions and 277 deletions

View File

@@ -2,6 +2,7 @@
"name": "Immich - Backend, Frontend and ML",
"service": "immich-server",
"runServices": [
"immich-init",
"immich-server",
"redis",
"database",
@@ -31,29 +32,8 @@
"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,
@@ -74,7 +54,6 @@
},
{
"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,
@@ -130,8 +109,8 @@
}
},
"overrideCommand": true,
"workspaceFolder": "/workspaces/immich",
"remoteUser": "node",
"workspaceFolder": "/usr/src/app",
"remoteUser": "root",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
// The location where your uploaded files are stored

View File

@@ -1,23 +1,17 @@
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: !override # bind mount host to /workspaces/immich
- ..:/workspaces/immich
volumes:
- ${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 []

View File

@@ -2,6 +2,7 @@
"name": "Immich - Mobile",
"service": "immich-server",
"runServices": [
"immich-init",
"immich-server",
"redis",
"database",
@@ -35,7 +36,7 @@
},
"forwardPorts": [],
"overrideCommand": true,
"workspaceFolder": "/workspaces/immich",
"workspaceFolder": "/usr/src/app",
"remoteUser": "node",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {

View File

@@ -2,11 +2,6 @@
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() {
@@ -30,52 +25,8 @@ run_cmd() {
return "${PIPESTATUS[0]}"
}
# 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
export IMMICH_WORKSPACE="/usr/src/app"
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 ""
}

View File

@@ -1,26 +1,21 @@
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: !override
- ..:/workspaces/immich
volumes:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- 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
- pnpm_store_server:/buildcache/pnpm-store
- ../plugins:/build/corePlugin
immich-web:
env_file: !reset []

View File

@@ -1,17 +0,0 @@
#!/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"

View File

@@ -4,12 +4,18 @@ 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") {
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"];
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];
}
}
return pkg;

View File

@@ -7,7 +7,15 @@ 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, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset';
import {
checkForDuplicates,
deleteFiles,
findSidecar,
getAlbumName,
startWatch,
uploadFiles,
UploadOptionsDto,
} from 'src/commands/asset';
vi.mock('@immich/sdk');
@@ -309,3 +317,85 @@ 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);
});
});

View File

@@ -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 } from 'node:fs';
import { Stats, createReadStream, existsSync } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
import path, { basename } from 'node:path';
import { Queue } from 'src/queue';
@@ -403,23 +403,6 @@ 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');
@@ -429,8 +412,15 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
formData.append('isFavorite', 'false');
formData.append('assetData', new UploadFile(input, stats.size));
if (sidecarData) {
formData.append('sidecarData', sidecarData);
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
}
}
const response = await fetch(`${baseUrl}/assets`, {
@@ -446,7 +436,19 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
return response.json();
};
const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
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> => {
let fileCount = 0;
if (options.delete) {
fileCount += uploaded.length;
@@ -474,7 +476,15 @@ const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: Uplo
const chunkDelete = async (files: Asset[]) => {
for (const assetBatch of chunk(files, options.concurrency)) {
await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath)));
await Promise.all(
assetBatch.map(async (input: Asset) => {
await unlink(input.filepath);
const sidecarPath = findSidecar(input.filepath);
if (sidecarPath) {
await unlink(sidecarPath);
}
}),
);
deletionProgress.update(assetBatch.length);
}
};

View File

@@ -253,7 +253,8 @@ describe('/asset', () => {
expect(status).toBe(200);
expect(body.id).toEqual(facesAsset.id);
expect(body.people).toMatchObject(expectedFaces);
const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name));
expect(sortedPeople).toMatchObject(expectedFaces);
});
});

View File

@@ -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]:has(button[aria-checked])');
return page.locator('[data-thumbnail-focus-container][data-selected]');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
@@ -103,11 +103,8 @@ 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}"] > .group.cursor-not-allowed > .rounded-xl`,
),
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {

View File

@@ -698,7 +698,7 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_location'.t(context: context),
currentFilter: locationCurrentFilterWidget.value,
),
if (userPreferences.value?.tagsEnabled ?? false)
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
SearchFilterChip(
icon: Icons.sell_outlined,
onTap: showTagPicker,
@@ -724,14 +724,13 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
if (userPreferences.value?.ratingsEnabled ?? false) ...[
if (userPreferences.valueOrNull?.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,

View File

@@ -74,6 +74,7 @@ class Timeline extends StatefulWidget {
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
this.persistentBottomBar = false,
});
final Widget? topSliverWidget;
@@ -87,6 +88,7 @@ class Timeline extends StatefulWidget {
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
final bool persistentBottomBar;
@override
State<Timeline> createState() => _TimelineState();
@@ -143,6 +145,7 @@ class _TimelineState extends State<Timeline> {
appBar: widget.appBar,
bottomSheet: widget.bottomSheet,
withScrubber: widget.withScrubber,
persistentBottomBar: widget.persistentBottomBar,
snapToMonth: widget.snapToMonth,
initialScrollOffset: widget.initialScrollOffset,
),
@@ -173,6 +176,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
this.appBar,
this.bottomSheet,
this.withScrubber = true,
this.persistentBottomBar = false,
this.snapToMonth = true,
this.initialScrollOffset,
});
@@ -182,6 +186,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? appBar;
final Widget? bottomSheet;
final bool withScrubber;
final bool persistentBottomBar;
final bool snapToMonth;
final double? initialScrollOffset;
@@ -404,6 +409,9 @@ 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,
@@ -519,7 +527,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: Stack(
children: [
timeline,
if (!isSelectionMode && isMultiSelectEnabled) ...[
if (isMultiSelectStatusVisible)
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
@@ -528,8 +536,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: Center(child: _MultiSelectStatusButton()),
),
),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
if (isBottomWidgetVisible) widget.bottomSheet!,
],
),
),

View File

@@ -13,12 +13,16 @@ 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;
@@ -33,6 +37,7 @@ 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 &&
@@ -41,16 +46,18 @@ class LibraryStatsResponseDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(offline.hashCode) +
(photos.hashCode) +
(total.hashCode) +
(usage.hashCode) +
(videos.hashCode);
@override
String toString() => 'LibraryStatsResponseDto[photos=$photos, total=$total, usage=$usage, videos=$videos]';
String toString() => 'LibraryStatsResponseDto[offline=$offline, 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;
@@ -67,6 +74,7 @@ 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')!,
@@ -118,6 +126,7 @@ class LibraryStatsResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'offline',
'photos',
'total',
'usage',

View File

@@ -18264,6 +18264,11 @@
},
"LibraryStatsResponseDto": {
"properties": {
"offline": {
"default": 0,
"description": "Number of offline assets",
"type": "integer"
},
"photos": {
"default": 0,
"description": "Number of photos",
@@ -18287,6 +18292,7 @@
}
},
"required": [
"offline",
"photos",
"total",
"usage",

View File

@@ -1323,6 +1323,8 @@ export type UpdateLibraryDto = {
name?: string;
};
export type LibraryStatsResponseDto = {
/** Number of offline assets */
offline: number;
/** Number of photos */
photos: number;
/** Total number of assets */

2
pnpm-lock.yaml generated
View File

@@ -11,7 +11,7 @@ overrides:
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0=
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
importers:

View File

@@ -27,16 +27,14 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
FROM dev AS dev-container-server
RUN apt-get update --allow-releaseinfo-change && \
apt-get install sudo inetutils-ping openjdk-21-jre-headless \
apt-get install inetutils-ping openjdk-21-jre-headless \
vim nano curl \
-y --no-install-recommends --fix-missing
RUN usermod -aG sudo node && \
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
mkdir -p /workspaces/immich
RUN mkdir -p /workspaces && \
ln -s /usr/src/app /workspaces/immich
RUN chown node:node -R /workspaces
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
COPY --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
WORKDIR /workspaces/immich

View File

@@ -136,6 +136,9 @@ 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;
}

View File

@@ -36,27 +36,37 @@ select
(
"asset"."type" = $1
and "asset"."visibility" != $2
and "asset"."isOffline" = $3
)
) as "photos",
count(*) filter (
where
(
"asset"."type" = $3
and "asset"."visibility" != $4
"asset"."type" = $4
and "asset"."visibility" != $5
and "asset"."isOffline" = $6
)
) as "videos",
coalesce(sum("asset_exif"."fileSizeInByte"), $5) as "usage"
count(*) filter (
where
(
"asset"."isOffline" = $7
and "asset"."visibility" != $8
)
) as "offline",
coalesce(sum("asset_exif"."fileSizeInByte"), $9) 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" = $6
"library"."id" = $10
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

View File

@@ -79,7 +79,11 @@ export class LibraryRepository {
eb.fn
.countAll<number>()
.filterWhere((eb) =>
eb.and([eb('asset.type', '=', AssetType.Image), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
eb.and([
eb('asset.type', '=', AssetType.Image),
eb('asset.visibility', '!=', AssetVisibility.Hidden),
eb('asset.isOffline', '=', false),
]),
)
.as('photos'),
)
@@ -87,10 +91,22 @@ export class LibraryRepository {
eb.fn
.countAll<number>()
.filterWhere((eb) =>
eb.and([eb('asset.type', '=', AssetType.Video), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
eb.and([
eb('asset.type', '=', AssetType.Video),
eb('asset.visibility', '!=', AssetVisibility.Hidden),
eb('asset.isOffline', '=', false),
]),
)
.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)
@@ -103,6 +119,7 @@ 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)
@@ -112,6 +129,7 @@ export class LibraryRepository {
return {
photos: stats.photos,
videos: stats.videos,
offline: stats.offline,
usage: stats.usage,
total: stats.photos + stats.videos,
};

View File

@@ -681,12 +681,19 @@ 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 });
mocks.library.getStatistics.mockResolvedValue({
photos: 10,
videos: 0,
total: 10,
usage: 1337,
offline: 67,
});
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);

View File

@@ -223,6 +223,7 @@
bind:this={element}
data-asset={asset.id}
data-thumbnail-focus-container
data-selected={selected ? true : undefined}
tabindex={0}
role="link"
>

View File

@@ -2,20 +2,36 @@
import { ByteUnit } from '$lib/utils/byte-units';
import { Icon, Text } from '@immich/ui';
interface Props {
icon: string;
title: string;
interface ValueData {
value: number;
unit?: ByteUnit | undefined;
}
let { icon, title, value, unit = undefined }: Props = $props();
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;
});
});
const zeros = $derived(() => {
const maxLength = 13;
const valueLength = value.toString().length;
if (!data) {
return '0'.repeat(maxLength);
}
const valueLength = data.value.toString().length;
const zeroLength = maxLength - valueLength;
return '0'.repeat(zeroLength);
});
</script>
@@ -26,10 +42,26 @@
<Text size="giant" fontWeight="medium">{title}</Text>
</div>
<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 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>
</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>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import StatsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { ServerStatsResponseDto } from '@immich/sdk';
import type { ServerStatsResponseDto, UserAdminResponseDto } from '@immich/sdk';
import {
Code,
FormatBytes,
@@ -19,10 +19,35 @@
import { t } from 'svelte-i18n';
type Props = {
stats: ServerStatsResponseDto;
statsPromise: Promise<ServerStatsResponseDto>;
users: UserAdminResponseDto[];
};
const { stats }: Props = $props();
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 zeros = (value: number, maxLength = 13) => {
const valueLength = value.toString().length;
@@ -30,9 +55,6 @@
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">
@@ -40,48 +62,52 @@
<Text class="mb-2" fontWeight="medium">{$t('total_usage')}</Text>
<div class="hidden justify-between lg:flex gap-4">
<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} />
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} valuePromise={storagePromise} />
</div>
<div class="mt-5 flex lg:hidden">
<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>
{#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>
<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 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="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 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="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="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="absolute -end-1.5 -bottom-4">
<Code color="muted" class="text-xs font-light font-mono">{statsUsageUnit}</Code>
<div class="absolute -end-1.5 -bottom-4">
<Code color="muted" class="text-xs font-light font-mono">{storageUsageWithUnit[1]}</Code>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
@@ -95,34 +121,82 @@
<TableHeading class="w-1/4">{$t('usage')}</TableHeading>
</TableHeader>
<TableBody class="block max-h-80 overflow-y-auto">
{#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 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} />
{/if}
</span>
</TableCell>
</TableRow>
{/each}
<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}
</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>

View File

@@ -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 } from '@immich/sdk';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto, type LibraryStatsResponseDto } from '@immich/sdk';
import {
CommandPaletteDefaultProvider,
Container,
@@ -34,9 +34,21 @@
let { children, data }: Props = $props();
let libraries = $state(data.libraries);
let statistics = $state(data.statistics);
let statistics = $state<Record<string, LibraryStatsResponseDto>>({});
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));
};
@@ -94,8 +106,7 @@
</TableHeader>
<TableBody>
{#each libraries as library (library.id + library.name)}
{@const { photos, usage, videos } = statistics[library.id]}
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
{@const stats = statistics[library.id]}
{@const owner = owners[library.id]}
<TableRow>
<TableCell class={classes.column1}>
@@ -104,9 +115,29 @@
<TableCell class={classes.column2}>
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
</TableCell>
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</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.column6}>
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
</TableCell>
@@ -127,3 +158,37 @@
</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>

View File

@@ -10,7 +10,7 @@ export const load = (async ({ url }) => {
const $t = await getFormatter();
const libraries = await getAllLibraries();
const statistics = await Promise.all(
const statisticsPromise = 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,
statistics: Object.fromEntries(statistics),
statisticsPromise: statisticsPromise.then((stats) => Object.fromEntries(stats)),
owners: Object.fromEntries(owners),
meta: {
title: $t('external_libraries'),

View File

@@ -15,9 +15,17 @@
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { LibraryResponseDto } from '@immich/sdk';
import type { LibraryResponseDto, LibraryStatsResponseDto } from '@immich/sdk';
import { Code, CommandPaletteDefaultProvider, Container, Heading, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import {
mdiCameraIris,
mdiChartPie,
mdiFileDocumentRemoveOutline,
mdiFilterMinusOutline,
mdiFolderOutline,
mdiPlayCircle,
} from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { LayoutData } from './$types';
@@ -27,16 +35,28 @@
data: LayoutData;
};
const { children, data }: Props = $props();
let { children, data }: Props = $props();
const statisticsPromise = $derived.by(() => data.statisticsPromise as Promise<LibraryStatsResponseDto>);
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
const photosPromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.photos })));
let library = $state(data.library);
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));
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
if (newLibrary.id === library.id) {
library = newLibrary;
updatedLibrary = newLibrary;
}
};
@@ -61,9 +81,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')} value={statistics.photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} valuePromise={usagePromise} />
</div>
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
@@ -112,6 +132,10 @@
</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>

View File

@@ -16,12 +16,12 @@ export const load = (async ({ params: { id }, url }) => {
redirect(307, Route.libraries());
}
const statistics = await getLibraryStatistics({ id });
const statisticsPromise = getLibraryStatistics({ id });
const $t = await getFormatter();
return {
library,
statistics,
statisticsPromise,
meta: {
title: $t('admin.library_details'),
},

View File

@@ -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 } from '@immich/sdk';
import { getServerStatistics, type ServerStatsResponseDto } from '@immich/sdk';
import { Container } from '@immich/ui';
import { onMount } from 'svelte';
import type { PageData } from './$types';
@@ -12,7 +12,14 @@
const { data }: Props = $props();
let stats = $state(data.stats);
let stats = $state<ServerStatsResponseDto | undefined>(undefined);
const statsPromise = $derived.by(() => {
if (stats) {
return Promise.resolve(stats);
}
return data.statsPromise;
});
const updateStatistics = async () => {
stats = await getServerStatistics();
@@ -27,6 +34,6 @@
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
<Container size="large" center>
<ServerStatisticsPanel {stats} />
<ServerStatisticsPanel {statsPromise} users={data.users} />
</Container>
</AdminPageLayout>

View File

@@ -1,15 +1,17 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getServerStatistics } from '@immich/sdk';
import { getServerStatistics, searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const stats = await getServerStatistics();
const statsPromise = getServerStatistics();
const users = await searchUsersAdmin({ withDeleted: false });
const $t = await getFormatter();
return {
stats,
statsPromise,
users,
meta: {
title: $t('server_stats'),
},

View File

@@ -123,9 +123,21 @@
</div>
<div class="col-span-full">
<div class="flex flex-col lg:flex-row gap-4 w-full">
<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} />
<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 })}
/>
</div>
</div>