diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f8922166d3..ab5c2e6d98 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -124,7 +124,11 @@ jobs: push: ${{ !github.event.pull_request.head.repo.fork }} cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}} cache-to: ${{ steps.cache-target.outputs.cache-to }} - build-args: | - DEVICE=${{ matrix.device }} tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} + build-args: | + DEVICE=${{ matrix.device }} + BUILD_ID=${{ github.run_id }} + BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }} + BUILD_SOURCE_REF=${{ github.ref_name }} + BUILD_SOURCE_COMMIT=${{ github.sha }} diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 67fb96bc61..afe85ca4e0 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -26,6 +26,16 @@ services: - /etc/localtime:/etc/localtime:ro env_file: - .env + environment: + IMMICH_REPOSITORY: immich-app/immich + IMMICH_REPOSITORY_URL: https://github.com/immich-app/immich + IMMICH_SOURCE_REF: local + IMMICH_SOURCE_COMMIT: af2efbdbbddc27cd06142f22253ccbbbbeec1f55 + IMMICH_SOURCE_URL: https://github.com/immich-app/immich/commit/af2efbdbbddc27cd06142f22253ccbbbbeec1f55 + IMMICH_BUILD: '9654404849' + IMMICH_BUILD_URL: https://github.com/immich-app/immich/actions/runs/9654404849 + IMMICH_BUILD_IMAGE: development + IMMICH_BUILD_IMAGE_URL: https://github.com/immich-app/immich/pkgs/container/immich-server ulimits: nofile: soft: 1048576 @@ -107,7 +117,22 @@ services: interval: 5m start_interval: 30s start_period: 5m - command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] + command: + [ + 'postgres', + '-c', + 'shared_preload_libraries=vectors.so', + '-c', + 'search_path="$$user", public, vectors', + '-c', + 'logging_collector=on', + '-c', + 'max_wal_size=2GB', + '-c', + 'shared_buffers=512MB', + '-c', + 'wal_compression=on', + ] # set IMMICH_METRICS=true in .env to enable metrics # immich-prometheus: diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 1b2e11fc68..04bcd0032f 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -10,6 +10,11 @@ services: build: context: ../ dockerfile: server/Dockerfile + args: + - BUILD_ID=1234567890 + - BUILD_IMAGE=e2e + - BUILD_SOURCE_REF=e2e + - BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee environment: - DB_HOSTNAME=database - DB_USERNAME=postgres diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 431971ac85..2711b86241 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -15,6 +15,39 @@ describe('/server-info', () => { nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); }); + describe('GET /server-info/about', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/server-info/about'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should return about information', async () => { + const { status, body } = await request(app) + .get('/server-info/about') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + version: expect.any(String), + versionUrl: expect.any(String), + repository: 'immich-app/immich', + repositoryUrl: 'https://github.com/immich-app/immich', + build: '1234567890', + buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890', + buildImage: 'e2e', + buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server', + sourceRef: 'e2e', + sourceCommit: 'e2eeeeeeeeeeeeeeeeee', + sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee', + nodejs: expect.any(String), + ffmpeg: expect.any(String), + imagemagick: expect.any(String), + libvips: expect.any(String), + exiftool: expect.any(String), + }); + }); + }); + describe('GET /server-info/storage', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/server-info/storage'); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e0ffdd5377..691393eb63 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 84f465f542..fafd17659b 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart index 76da103b70..2ddcaa630b 100644 Binary files a/mobile/openapi/lib/api/server_info_api.dart and b/mobile/openapi/lib/api/server_info_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 1b3c6aed87..18a243ca4c 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/server_about_response_dto.dart b/mobile/openapi/lib/model/server_about_response_dto.dart new file mode 100644 index 0000000000..3b38c4ebcc Binary files /dev/null and b/mobile/openapi/lib/model/server_about_response_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 17f74d33b0..d403cf7530 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4718,6 +4718,38 @@ ] } }, + "/server-info/about": { + "get": { + "operationId": "getAboutInfo", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerAboutResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server Info" + ] + } + }, "/server-info/config": { "get": { "operationId": "getServerConfig", @@ -9630,6 +9662,63 @@ ], "type": "string" }, + "ServerAboutResponseDto": { + "properties": { + "build": { + "type": "string" + }, + "buildImage": { + "type": "string" + }, + "buildImageUrl": { + "type": "string" + }, + "buildUrl": { + "type": "string" + }, + "exiftool": { + "type": "string" + }, + "ffmpeg": { + "type": "string" + }, + "imagemagick": { + "type": "string" + }, + "libvips": { + "type": "string" + }, + "nodejs": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "repositoryUrl": { + "type": "string" + }, + "sourceCommit": { + "type": "string" + }, + "sourceRef": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "version": { + "type": "string" + }, + "versionUrl": { + "type": "string" + } + }, + "required": [ + "version", + "versionUrl" + ], + "type": "object" + }, "ServerConfigDto": { "properties": { "externalDomain": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 143ec74e65..7a1da9d13d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -787,6 +787,24 @@ export type SmartSearchDto = { withDeleted?: boolean; withExif?: boolean; }; +export type ServerAboutResponseDto = { + build?: string; + buildImage?: string; + buildImageUrl?: string; + buildUrl?: string; + exiftool?: string; + ffmpeg?: string; + imagemagick?: string; + libvips?: string; + nodejs?: string; + repository?: string; + repositoryUrl?: string; + sourceCommit?: string; + sourceRef?: string; + sourceUrl?: string; + version: string; + versionUrl: string; +}; export type ServerConfigDto = { externalDomain: string; isInitialized: boolean; @@ -2363,6 +2381,14 @@ export function getSearchSuggestions({ country, make, model, state, $type }: { ...opts })); } +export function getAboutInfo(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerAboutResponseDto; + }>("/server-info/about", { + ...opts + })); +} export function getServerConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/Dockerfile b/server/Dockerfile index 877b4bec09..36d3297080 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -59,6 +59,22 @@ RUN npm link && npm install -g @immich/cli && npm cache clean --force COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE ENV PATH="${PATH}:/usr/src/app/bin" + +ARG BUILD_ID +ARG BUILD_IMAGE +ARG BUILD_SOURCE_REF +ARG BUILD_SOURCE_COMMIT + +ENV IMMICH_BUILD=${BUILD_ID} +ENV IMMICH_BUILD_URL=https://github.com/immich-app/immich/actions/runs/${BUILD_ID} +ENV IMMICH_BUILD_IMAGE=${BUILD_IMAGE} +ENV IMMICH_BUILD_IMAGE_URL=https://github.com/immich-app/immich/pkgs/container/immich-server +ENV IMMICH_REPOSITORY=immich-app/immich +ENV IMMICH_REPOSITORY_URL=https://github.com/immich-app/immich +ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF} +ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT} +ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT} + VOLUME /usr/src/app/upload EXPOSE 3001 ENTRYPOINT ["tini", "--", "/bin/bash"] diff --git a/server/src/config.ts b/server/src/config.ts index 624dd385ad..b420fe43ea 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -429,3 +429,15 @@ export const clsConfig: ClsModuleOptions = { }, }, }; + +export const getBuildMetadata = () => ({ + build: process.env.IMMICH_BUILD, + buildUrl: process.env.IMMICH_BUILD_URL, + buildImage: process.env.IMMICH_BUILD_IMAGE, + buildImageUrl: process.env.IMMICH_BUILD_IMAGE_URL, + repository: process.env.IMMICH_REPOSITORY, + repositoryUrl: process.env.IMMICH_REPOSITORY_URL, + sourceRef: process.env.IMMICH_SOURCE_REF, + sourceCommit: process.env.IMMICH_SOURCE_COMMIT, + sourceUrl: process.env.IMMICH_SOURCE_URL, +}); diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index 03968c1f5e..2aaac4a0ff 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { + ServerAboutResponseDto, ServerConfigDto, ServerFeaturesDto, ServerMediaTypesResponseDto, @@ -22,6 +23,12 @@ export class ServerInfoController { private versionService: VersionService, ) {} + @Get('about') + @Authenticated() + getAboutInfo(): Promise { + return this.service.getAboutInfo(); + } + @Get('storage') @Authenticated() getStorage(): Promise { diff --git a/server/src/dtos/server-info.dto.ts b/server/src/dtos/server-info.dto.ts index 94a5b4df6e..940c89c793 100644 --- a/server/src/dtos/server-info.dto.ts +++ b/server/src/dtos/server-info.dto.ts @@ -7,6 +7,29 @@ export class ServerPingResponse { res!: string; } +export class ServerAboutResponseDto { + version!: string; + versionUrl!: string; + + repository?: string; + repositoryUrl?: string; + + sourceRef?: string; + sourceCommit?: string; + sourceUrl?: string; + + build?: string; + buildUrl?: string; + buildImage?: string; + buildImageUrl?: string; + + nodejs?: string; + ffmpeg?: string; + imagemagick?: string; + libvips?: string; + exiftool?: string; +} + export class ServerStorageResponseDto { diskSize!: string; diskUse!: string; diff --git a/server/src/interfaces/server-info.interface.ts b/server/src/interfaces/server-info.interface.ts index a4168d4c3e..6dc857ddea 100644 --- a/server/src/interfaces/server-info.interface.ts +++ b/server/src/interfaces/server-info.interface.ts @@ -8,8 +8,17 @@ export interface GitHubRelease { body: string; } +export interface ServerBuildVersions { + nodejs: string; + ffmpeg: string; + libvips: string; + exiftool: string; + imagemagick: string; +} + export const IServerInfoRepository = 'IServerInfoRepository'; export interface IServerInfoRepository { getGitHubRelease(): Promise; + getBuildVersions(): Promise; } diff --git a/server/src/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts index 5f14a881c1..c4b1e664af 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -1,10 +1,45 @@ -import { Injectable } from '@nestjs/common'; -import { GitHubRelease, IServerInfoRepository } from 'src/interfaces/server-info.interface'; +import { Inject, Injectable } from '@nestjs/common'; +import { exiftool } from 'exiftool-vendored'; +import { exec as execCallback } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import sharp from 'sharp'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface'; import { Instrumentation } from 'src/utils/instrumentation'; +const exec = promisify(execCallback); +const maybeFirstLine = async (command: string): Promise => { + try { + const { stdout } = await exec(command); + return stdout.trim().split('\n')[0] || ''; + } catch { + return ''; + } +}; + +type BuildLockfile = { + sources: Array<{ name: string; version: string }>; + packages: Array<{ name: string; version: string }>; +}; + +const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => { + if (!lockfile) { + return; + } + + const items = [...(lockfile.sources || []), ...(lockfile?.packages || [])]; + const item = items.find((item) => item.name === name); + return item?.version; +}; + @Instrumentation() @Injectable() export class ServerInfoRepository implements IServerInfoRepository { + constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { + this.logger.setContext(ServerInfoRepository.name); + } + async getGitHubRelease(): Promise { try { const response = await fetch('https://api.github.com/repos/immich-app/immich/releases/latest'); @@ -18,4 +53,25 @@ export class ServerInfoRepository implements IServerInfoRepository { throw new Error(`Failed to fetch GitHub release: ${error}`); } } + + async getBuildVersions(): Promise { + const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([ + maybeFirstLine('node --version'), + maybeFirstLine('ffmpeg -version'), + maybeFirstLine('convert --version'), + ]); + + const lockfile = await readFile('build-lock.json') + .then((buffer) => JSON.parse(buffer.toString())) + .catch(() => this.logger.warn('Failed to read build-lock.json')); + + return { + nodejs: nodejsOutput || process.env.NODE_VERSION || '', + exiftool: await exiftool.version(), + ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '', + libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips, + imagemagick: + getLockfileVersion('imagemagick', lockfile) || magickOutput.replaceAll('Version: ImageMagick ', '') || '', + }; + } } diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index 90d70b21ff..b1200cadc5 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -1,9 +1,11 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { ServerInfoService } from 'src/services/server-info.service'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; @@ -13,16 +15,18 @@ describe(ServerInfoService.name, () => { let sut: ServerInfoService; let storageMock: Mocked; let userMock: Mocked; + let serverInfoMock: Mocked; let systemMock: Mocked; let loggerMock: Mocked; beforeEach(() => { storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); + serverInfoMock = newServerInfoRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new ServerInfoService(userMock, storageMock, systemMock, loggerMock); + sut = new ServerInfoService(userMock, storageMock, systemMock, serverInfoMock, loggerMock); }); it('should work', () => { diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index 5b0831c93c..f077c47019 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -1,7 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; +import { getBuildMetadata } from 'src/config'; +import { serverVersion } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { + ServerAboutResponseDto, ServerConfigDto, ServerFeaturesDto, ServerMediaTypesResponseDto, @@ -12,6 +15,7 @@ import { } from 'src/dtos/server-info.dto'; import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; @@ -27,6 +31,7 @@ export class ServerInfoService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, + @Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(ServerInfoService.name); @@ -42,6 +47,19 @@ export class ServerInfoService { } } + async getAboutInfo(): Promise { + const version = serverVersion.toString(); + const buildMetadata = getBuildMetadata(); + const buildVersions = await this.serverInfoRepository.getBuildVersions(); + + return { + version, + versionUrl: `https://github.com/immich-app/immich/releases/tag/${version}`, + ...buildMetadata, + ...buildVersions, + }; + } + async getStorage(): Promise { const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase); diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 3bf6a24e11..74489e04ea 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -10,7 +10,7 @@ import { VersionService } from 'src/services/version.service'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock'; +import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked } from 'vitest'; diff --git a/server/test/repositories/system-info.repository.mock.ts b/server/test/repositories/server-info.repository.mock.ts similarity index 87% rename from server/test/repositories/system-info.repository.mock.ts rename to server/test/repositories/server-info.repository.mock.ts index 977d5dca2d..f55933d3c6 100644 --- a/server/test/repositories/system-info.repository.mock.ts +++ b/server/test/repositories/server-info.repository.mock.ts @@ -4,5 +4,6 @@ import { Mocked, vitest } from 'vitest'; export const newServerInfoRepositoryMock = (): Mocked => { return { getGitHubRelease: vitest.fn(), + getBuildVersions: vitest.fn(), }; }; diff --git a/web/src/lib/components/shared-components/server-about-modal.svelte b/web/src/lib/components/shared-components/server-about-modal.svelte new file mode 100644 index 0000000000..d347170033 --- /dev/null +++ b/web/src/lib/components/shared-components/server-about-modal.svelte @@ -0,0 +1,156 @@ + + + + +
+
+ + +
+ +
+ +

+ {info.exiftool} +

+
+ +
+ +

+ {info.nodejs} +

+
+ +
+ +

+ {info.libvips} +

+
+ +
10 ? 'col-span-2' : ''}> + +

+ {info.imagemagick} +

+
+ +
10 ? 'col-span-2' : ''}> + +

+ {info.ffmpeg} +

+
+ + {#if info.repository && info.repositoryUrl} +
+ + +
+ {/if} + + {#if info.sourceRef && info.sourceCommit && info.sourceUrl} + + {/if} + + {#if info.build && info.buildUrl} +
+ + +
+ {/if} + + {#if info.buildImage && info.buildImage} +
+ + +
+ {/if} +
+
+
diff --git a/web/src/lib/components/shared-components/status-box.svelte b/web/src/lib/components/shared-components/status-box.svelte index f4d5a268cc..cd13b3d251 100644 --- a/web/src/lib/components/shared-components/status-box.svelte +++ b/web/src/lib/components/shared-components/status-box.svelte @@ -1,19 +1,22 @@ +{#if isOpen} + (isOpen = false)} info={aboutInfo} /> +{/if} +

{$t('version')}

{#if $connected && version} - (isOpen = true)} + class="font-medium text-immich-primary dark:text-immich-dark-primary">{version} - {version} - {:else}

{$t('unknown')}

{/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 02c1f3e2bb..31e6ffdbc9 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1,4 +1,5 @@ { + "about": "About", "account": "Account", "account_settings": "Account Settings", "acknowledge": "Acknowledge", @@ -380,6 +381,8 @@ "birthdate_saved": "Date of birth saved successfully", "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.", "blurred_background": "Blurred background", + "build": "Build", + "build_image": "Build Image", "bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!", "bulk_keep_duplicates_confirmation": "Are you sure you want to keep {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will resolve all duplicate groups without deleting anything.", "bulk_trash_duplicates_confirmation": "Are you sure you want to bulk trash {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and trash all other duplicates.", @@ -904,6 +907,7 @@ "repair": "Repair", "repair_no_results_message": "Untracked and missing files will show up here", "replace_with_upload": "Replace with upload", + "repository": "Repository", "require_password": "Require password", "require_user_to_change_password_on_first_login": "Require user to change password on first login", "reset": "Reset", @@ -1016,6 +1020,7 @@ "sort_oldest": "Oldest photo", "sort_recent": "Most recent photo", "sort_title": "Title", + "source": "Source", "stack": "Stack", "stack_selected_photos": "Stack selected photos", "stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",