mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
feat: revive compose file builder (#660)
* feat: app shell for get.immich.app * feat: revive compose file builder * fix: lints * fix: review feedback * chore: clean up types * feat: zod validation * chore: remove TODO * feat: external compose file types * feat: uid/gid input fields when in rootless mode * chore: always show db named volume description * fix: proper forms text handling * feat: also reject WSL mounts for db path * feat: container names toggle * feat: external libraries * feat: external network option * chore: rearrange server panel * chore: use NumberInput * chore: move the builder under the app shell * chore: move to immich.app/download * revert: get.immich.app changes --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
co-authored by
Jason Rasmussen
parent
6ac8f37a22
commit
abdd8f458f
@@ -14,12 +14,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@immich/svelte-markdown-preprocess": "workspace:*",
|
||||
"@json-types/compose": "^0.11.0",
|
||||
"@sveltejs/adapter-static": "^3.0.9",
|
||||
"@sveltejs/kit": "^2.55.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"@vitest/browser": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"globals": "^17.0.0",
|
||||
@@ -30,7 +32,6 @@
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0",
|
||||
@@ -39,10 +40,13 @@
|
||||
"dependencies": {
|
||||
"@immich/ui": "workspace:*",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"deepmerge": "^4.3.1",
|
||||
"feed": "^6.0.0",
|
||||
"front-matter": "^4.0.2",
|
||||
"node-emoji": "^2.2.0",
|
||||
"svelte-highlight": "^7.8.4",
|
||||
"tailwind-variants": "^3.2.2"
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"yaml": "^2.9.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parse } from 'yaml';
|
||||
import { buildCompose } from './build';
|
||||
import { DEFAULT_CONFIG, StorageType, withoutAdvanced } from './config';
|
||||
|
||||
const VERSION = 'v3';
|
||||
|
||||
describe('buildCompose', () => {
|
||||
it('produces the four core services under name "immich"', () => {
|
||||
const spec = parse(buildCompose(DEFAULT_CONFIG, VERSION));
|
||||
expect(spec.name).toBe('immich');
|
||||
expect(Object.keys(spec.services)).toEqual(['immich-server', 'immich-machine-learning', 'redis', 'database']);
|
||||
});
|
||||
|
||||
it('inlines the version into both immich image tags', () => {
|
||||
const spec = parse(buildCompose(DEFAULT_CONFIG, 'v3.0.3'));
|
||||
expect(spec.services['immich-server'].image).toBe('ghcr.io/immich-app/immich-server:v3.0.3');
|
||||
expect(spec.services['immich-machine-learning'].image).toBe('ghcr.io/immich-app/immich-machine-learning:v3.0.3');
|
||||
});
|
||||
|
||||
it('inlines storage and database locations as bind mounts', () => {
|
||||
const spec = parse(buildCompose(DEFAULT_CONFIG, VERSION));
|
||||
expect(spec.services['immich-server'].volumes).toContain('./library:/data');
|
||||
expect(spec.services.database.volumes).toContain('./postgres:/var/lib/postgresql/data');
|
||||
});
|
||||
|
||||
it('omits server environment on defaults and sets TZ only when provided', () => {
|
||||
expect(parse(buildCompose(DEFAULT_CONFIG, VERSION)).services['immich-server'].environment).toBeUndefined();
|
||||
const withTz = parse(buildCompose({ ...DEFAULT_CONFIG, timezone: 'Europe/Amsterdam' }, VERSION));
|
||||
expect(withTz.services['immich-server'].environment.TZ).toBe('Europe/Amsterdam');
|
||||
});
|
||||
|
||||
it('adds only non-empty folder overrides, between the base and localtime mounts', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.storage.customFolders = true;
|
||||
config.storage.overrides.thumbs = '/mnt/fast/thumbs';
|
||||
config.storage.overrides.backups = '/mnt/slow/backups';
|
||||
const volumes = parse(buildCompose(config, VERSION)).services['immich-server'].volumes;
|
||||
expect(volumes).toEqual([
|
||||
'./library:/data',
|
||||
'/mnt/fast/thumbs:/data/thumbs',
|
||||
'/mnt/slow/backups:/data/backups',
|
||||
'/etc/localtime:/etc/localtime:ro',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores overrides when the custom folders toggle is off', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.storage.overrides.thumbs = '/mnt/fast/thumbs';
|
||||
const volumes = parse(buildCompose(config, VERSION)).services['immich-server'].volumes;
|
||||
expect(volumes).toEqual(['./library:/data', '/etc/localtime:/etc/localtime:ro']);
|
||||
});
|
||||
|
||||
it('inlines the transcoding backend into immich-server, merging environment', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.timezone = 'Europe/Amsterdam';
|
||||
config.hwaccel.transcoding = 'vaapi-wsl';
|
||||
const server = parse(buildCompose(config, VERSION)).services['immich-server'];
|
||||
expect(server.devices).toEqual(['/dev/dri:/dev/dri', '/dev/dxg:/dev/dxg']);
|
||||
expect(server.environment).toEqual({ TZ: 'Europe/Amsterdam', LIBVA_DRIVER_NAME: 'd3d12' });
|
||||
expect(server.volumes).toEqual([
|
||||
`${DEFAULT_CONFIG.storage.uploadLocation}:/data`,
|
||||
'/etc/localtime:/etc/localtime:ro',
|
||||
'/usr/lib/wsl:/usr/lib/wsl',
|
||||
]);
|
||||
});
|
||||
|
||||
it('inlines the ML backend and suffixes the ML image tag', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.hwaccel.ml = 'cuda';
|
||||
const ml = parse(buildCompose(config, 'v3.0.3')).services['immich-machine-learning'];
|
||||
expect(ml.image).toBe('ghcr.io/immich-app/immich-machine-learning:v3.0.3-cuda');
|
||||
expect(ml.deploy.resources.reservations.devices[0].driver).toBe('nvidia');
|
||||
});
|
||||
|
||||
it('adds no accel keys and no tag suffix on cpu (default)', () => {
|
||||
const spec = parse(buildCompose(DEFAULT_CONFIG, VERSION));
|
||||
expect(spec.services['immich-server'].devices).toBeUndefined();
|
||||
expect(spec.services['immich-machine-learning'].image).toBe('ghcr.io/immich-app/immich-machine-learning:v3');
|
||||
});
|
||||
|
||||
it('mirrors a custom bundled DB password to POSTGRES_PASSWORD and DB_PASSWORD, only when non-default', () => {
|
||||
const dflt = parse(buildCompose(DEFAULT_CONFIG, VERSION));
|
||||
expect(dflt.services.database.environment.POSTGRES_PASSWORD).toBe('postgres');
|
||||
expect(dflt.services['immich-server'].environment).toBeUndefined();
|
||||
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.database.password = 'hunter2';
|
||||
const spec = parse(buildCompose(config, VERSION));
|
||||
expect(spec.services.database.environment.POSTGRES_PASSWORD).toBe('hunter2');
|
||||
expect(spec.services['immich-server'].environment.DB_PASSWORD).toBe('hunter2');
|
||||
expect(spec.services['immich-server'].environment.DB_URL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses a named postgres-data volume when the db mount is a volume', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.database.mount = { type: 'volume' };
|
||||
const spec = parse(buildCompose(config, VERSION));
|
||||
expect(spec.services.database.volumes).toEqual(['postgres-data:/var/lib/postgresql/data']);
|
||||
expect(spec.volumes['postgres-data']).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a custom host port onto container port 2283', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.port = 8080;
|
||||
expect(parse(buildCompose(config, VERSION)).services['immich-server'].ports).toEqual(['8080:2283']);
|
||||
});
|
||||
|
||||
it('states the database storage type explicitly', () => {
|
||||
expect(parse(buildCompose(DEFAULT_CONFIG, VERSION)).services.database.environment.DB_STORAGE_TYPE).toBe('SSD');
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.database.storageType = StorageType.HDD;
|
||||
expect(parse(buildCompose(config, VERSION)).services.database.environment.DB_STORAGE_TYPE).toBe('HDD');
|
||||
});
|
||||
|
||||
it('produces the default output once advanced settings are stripped', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.port = 9999;
|
||||
config.storage.customFolders = true;
|
||||
config.storage.overrides.thumbs = '/mnt/fast/thumbs';
|
||||
config.database.external = true;
|
||||
config.database.externalUrl = 'postgresql://u:p@h:5432/immich';
|
||||
config.redis.external = true;
|
||||
config.redis.host = 'redis.example.com';
|
||||
config.containerNames = false;
|
||||
config.storage.externalLibraries = [{ path: '/mnt/media/photos', readOnly: true }];
|
||||
config.network = { external: true, name: 'proxy' };
|
||||
|
||||
expect(buildCompose(withoutAdvanced(config), VERSION)).toBe(buildCompose(DEFAULT_CONFIG, VERSION));
|
||||
});
|
||||
|
||||
it('keeps null and false values while pruning empty containers', () => {
|
||||
const spec = parse(buildCompose(DEFAULT_CONFIG, VERSION));
|
||||
expect(spec.volumes['model-cache']).toBeNull();
|
||||
expect(spec.services['immich-server'].healthcheck).toEqual({ disable: false });
|
||||
});
|
||||
|
||||
it('declares a named volume only while a service still mounts it', () => {
|
||||
expect(parse(buildCompose(DEFAULT_CONFIG, VERSION)).volumes).toEqual({ 'model-cache': null });
|
||||
|
||||
const onVolume = structuredClone(DEFAULT_CONFIG);
|
||||
onVolume.database.mount = { type: 'volume' };
|
||||
expect(parse(buildCompose(onVolume, VERSION)).volumes).toEqual({ 'model-cache': null, 'postgres-data': null });
|
||||
|
||||
const external = structuredClone(DEFAULT_CONFIG);
|
||||
external.database.external = true;
|
||||
external.database.mount = { type: 'volume' };
|
||||
expect(parse(buildCompose(external, VERSION)).volumes).toEqual({ 'model-cache': null });
|
||||
});
|
||||
|
||||
it('hardens every service and rewires cache/volumes in rootless mode', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.rootless.enabled = true;
|
||||
const spec = parse(buildCompose(config, VERSION));
|
||||
for (const name of ['immich-server', 'immich-machine-learning', 'redis', 'database']) {
|
||||
expect(spec.services[name].user).toBe('1000:1000');
|
||||
expect(spec.services[name].security_opt).toEqual(['no-new-privileges:true']);
|
||||
expect(spec.services[name].cap_drop).toEqual(['NET_RAW']);
|
||||
}
|
||||
expect(spec.services['immich-machine-learning'].volumes).toEqual([
|
||||
'./ml-model-cache:/cache',
|
||||
'./ml-dotcache:/.cache',
|
||||
'./ml-config:/.config',
|
||||
]);
|
||||
expect(spec.services.redis.volumes).toEqual(['./redis:/data']);
|
||||
expect(spec.volumes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits container names when the toggle is off', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.containerNames = false;
|
||||
const spec = parse(buildCompose(config, VERSION));
|
||||
for (const name of ['immich-server', 'immich-machine-learning', 'redis', 'database']) {
|
||||
expect(spec.services[name].container_name).toBeUndefined();
|
||||
}
|
||||
expect(parse(buildCompose(DEFAULT_CONFIG, VERSION)).services['immich-server'].container_name).toBe('immich_server');
|
||||
});
|
||||
|
||||
it('mounts external libraries at the same path, honouring read-only', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.storage.externalLibraries = [
|
||||
{ path: '/mnt/media/photos', readOnly: true },
|
||||
{ path: '/mnt/media/writable', readOnly: false },
|
||||
{ path: ' '.repeat(3), readOnly: true },
|
||||
];
|
||||
const volumes = parse(buildCompose(config, VERSION)).services['immich-server'].volumes;
|
||||
expect(volumes).toEqual([
|
||||
'./library:/data',
|
||||
'/mnt/media/photos:/mnt/media/photos:ro',
|
||||
'/mnt/media/writable:/mnt/media/writable',
|
||||
'/etc/localtime:/etc/localtime:ro',
|
||||
]);
|
||||
});
|
||||
|
||||
it('attaches only the server to an external network, keeping the default one', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.network = { external: true, name: 'proxy' };
|
||||
const spec = parse(buildCompose(config, VERSION));
|
||||
expect(spec.services['immich-server'].networks).toEqual(['default', 'proxy']);
|
||||
expect(spec.services['immich-machine-learning'].networks).toBeUndefined();
|
||||
expect(spec.services.redis.networks).toBeUndefined();
|
||||
expect(spec.services.database.networks).toBeUndefined();
|
||||
expect(spec.networks).toEqual({ proxy: { external: true } });
|
||||
});
|
||||
|
||||
it('adds no network keys when the external network is off', () => {
|
||||
const spec = parse(buildCompose(DEFAULT_CONFIG, VERSION));
|
||||
expect(spec.services['immich-server'].networks).toBeUndefined();
|
||||
expect(spec.networks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies a custom uid and gid to every service', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.rootless = { enabled: true, uid: 99, gid: 100 };
|
||||
const spec = parse(buildCompose(config, VERSION));
|
||||
for (const name of ['immich-server', 'immich-machine-learning', 'redis', 'database']) {
|
||||
expect(spec.services[name].user).toBe('99:100');
|
||||
}
|
||||
});
|
||||
|
||||
it('external Postgres drops the db service, sets DB_URL, and trims depends_on', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.database.external = true;
|
||||
config.database.externalUrl = 'postgresql://immich:pw@db.example.com:5432/immich';
|
||||
const spec = parse(buildCompose(config, VERSION));
|
||||
expect(spec.services.database).toBeUndefined();
|
||||
expect(spec.services['immich-server'].environment.DB_URL).toBe('postgresql://immich:pw@db.example.com:5432/immich');
|
||||
expect(spec.services['immich-server'].depends_on).toEqual(['redis']);
|
||||
});
|
||||
|
||||
it('external Redis drops the redis service and sets non-default REDIS_ vars', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.redis.external = true;
|
||||
config.redis.host = 'redis.example.com';
|
||||
config.redis.port = 6380;
|
||||
config.redis.password = 'secret';
|
||||
const server = parse(buildCompose(config, VERSION)).services['immich-server'];
|
||||
expect(parse(buildCompose(config, VERSION)).services.redis).toBeUndefined();
|
||||
expect(server.environment).toMatchObject({
|
||||
REDIS_HOSTNAME: 'redis.example.com',
|
||||
REDIS_PORT: '6380',
|
||||
REDIS_PASSWORD: 'secret',
|
||||
});
|
||||
expect(server.depends_on).toEqual(['database']);
|
||||
});
|
||||
|
||||
it('omits depends_on entirely when both backing services are external', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.database.external = true;
|
||||
config.redis.external = true;
|
||||
config.redis.host = 'redis.example.com';
|
||||
const server = parse(buildCompose(config, VERSION)).services['immich-server'];
|
||||
expect(server.depends_on).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import deepmerge from 'deepmerge';
|
||||
import { stringify } from 'yaml';
|
||||
import { ML_BACKENDS, TRANSCODE_BACKENDS } from './hwaccel';
|
||||
import { IMAGES } from './images';
|
||||
import { FOLDER_OVERRIDES, type ImmichConfig } from './config';
|
||||
import type { ComposeFile, ComposeService } from './spec';
|
||||
|
||||
const rootlessHardening = ({ uid, gid }: ImmichConfig['rootless']): ComposeService => ({
|
||||
user: `${uid ?? ''}:${gid ?? ''}`,
|
||||
security_opt: ['no-new-privileges:true'],
|
||||
cap_drop: ['NET_RAW'],
|
||||
});
|
||||
|
||||
const ROOTLESS_VOLUMES = new Map<string, string[]>([
|
||||
['immich-machine-learning', ['./ml-model-cache:/cache', './ml-dotcache:/.cache', './ml-config:/.config']],
|
||||
['redis', ['./redis:/data']],
|
||||
]);
|
||||
|
||||
enum NamedVolume {
|
||||
ModelCache = 'model-cache',
|
||||
PostgresData = 'postgres-data',
|
||||
}
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const isEmpty = (value: unknown) =>
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(isPlainObject(value) && Object.keys(value).length === 0);
|
||||
|
||||
const pruneEmpty = <T extends object>(object: T): Partial<T> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(object)
|
||||
.map(([key, value]) => [key, isPlainObject(value) ? pruneEmpty(value) : value])
|
||||
.filter(([, value]) => !isEmpty(value)),
|
||||
) as Partial<T>;
|
||||
|
||||
type ServiceVolume = NonNullable<ComposeService['volumes']>[number];
|
||||
|
||||
const mountSource = (mount: ServiceVolume) => (typeof mount === 'string' ? mount.split(':', 1)[0] : mount.source);
|
||||
|
||||
const unlessDefault = (value: string, fallback: string) => (value === fallback ? '' : value);
|
||||
|
||||
const buildServerEnvironment = ({ timezone, database, redis }: ImmichConfig) => {
|
||||
const environment: Record<string, string> = { TZ: timezone.trim() };
|
||||
|
||||
if (database.external) {
|
||||
environment.DB_URL = database.externalUrl.trim();
|
||||
} else {
|
||||
environment.DB_PASSWORD = unlessDefault(database.password.trim(), 'postgres');
|
||||
}
|
||||
|
||||
if (redis.external) {
|
||||
environment.REDIS_HOSTNAME = redis.host.trim();
|
||||
environment.REDIS_PORT = unlessDefault(String(redis.port ?? ''), '6379');
|
||||
environment.REDIS_PASSWORD = redis.password.trim();
|
||||
}
|
||||
|
||||
return environment;
|
||||
};
|
||||
|
||||
export const buildComposeSpec = (config: ImmichConfig, version: string): ComposeFile => {
|
||||
const { rootless, network } = config;
|
||||
const serverEnvironment = buildServerEnvironment(config);
|
||||
const containerName = (name: string) => (config.containerNames ? name : '');
|
||||
|
||||
const externalNetwork = network.external ? network.name.trim() : '';
|
||||
|
||||
const libraryMounts = config.storage.externalLibraries
|
||||
.filter(({ path }) => path.trim())
|
||||
.map(({ path, readOnly }) => `${path.trim()}:${path.trim()}${readOnly ? ':ro' : ''}`);
|
||||
|
||||
const backingServices: Record<string, ComposeService> = {};
|
||||
|
||||
if (!config.redis.external) {
|
||||
backingServices.redis = {
|
||||
container_name: containerName('immich_redis'),
|
||||
image: IMAGES.redis,
|
||||
healthcheck: { test: 'redis-cli ping || exit 1' },
|
||||
restart: 'always',
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.database.external) {
|
||||
backingServices.database = {
|
||||
container_name: containerName('immich_postgres'),
|
||||
image: IMAGES.database,
|
||||
environment: {
|
||||
POSTGRES_PASSWORD: config.database.password,
|
||||
POSTGRES_USER: config.database.username,
|
||||
POSTGRES_DB: config.database.databaseName,
|
||||
POSTGRES_INITDB_ARGS: '--data-checksums',
|
||||
DB_STORAGE_TYPE: config.database.storageType,
|
||||
},
|
||||
volumes: [
|
||||
`${config.database.mount.type === 'volume' ? NamedVolume.PostgresData : config.database.mount.location}:/var/lib/postgresql/data`,
|
||||
],
|
||||
shm_size: '128mb',
|
||||
restart: 'always',
|
||||
healthcheck: { disable: false },
|
||||
};
|
||||
}
|
||||
|
||||
const overrideMounts = config.storage.customFolders
|
||||
? FOLDER_OVERRIDES.filter(({ key }) => config.storage.overrides[key].trim()).map(
|
||||
({ key, subfolder }) => `${config.storage.overrides[key].trim()}:/data/${subfolder}`,
|
||||
)
|
||||
: [];
|
||||
|
||||
const services: Record<string, ComposeService> = {
|
||||
'immich-server': deepmerge<ComposeService>(
|
||||
{
|
||||
container_name: containerName('immich_server'),
|
||||
image: IMAGES.server(version),
|
||||
volumes: [
|
||||
`${config.storage.uploadLocation}:/data`,
|
||||
...overrideMounts,
|
||||
...libraryMounts,
|
||||
'/etc/localtime:/etc/localtime:ro',
|
||||
],
|
||||
environment: serverEnvironment,
|
||||
ports: [`${config.port ?? ''}:2283`],
|
||||
depends_on: Object.keys(backingServices),
|
||||
networks: externalNetwork ? ['default', externalNetwork] : [],
|
||||
restart: 'always',
|
||||
healthcheck: { disable: false },
|
||||
},
|
||||
TRANSCODE_BACKENDS[config.hwaccel.transcoding].fragment,
|
||||
),
|
||||
'immich-machine-learning': deepmerge<ComposeService>(
|
||||
{
|
||||
container_name: containerName('immich_machine_learning'),
|
||||
image: IMAGES.machineLearning(version + ML_BACKENDS[config.hwaccel.ml].tag),
|
||||
volumes: [`${NamedVolume.ModelCache}:/cache`],
|
||||
restart: 'always',
|
||||
healthcheck: { disable: false },
|
||||
},
|
||||
ML_BACKENDS[config.hwaccel.ml].fragment,
|
||||
),
|
||||
...backingServices,
|
||||
};
|
||||
|
||||
if (rootless.enabled) {
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
const hardened = deepmerge(service, rootlessHardening(rootless));
|
||||
const rootlessVolumes = ROOTLESS_VOLUMES.get(name);
|
||||
if (rootlessVolumes) {
|
||||
hardened.volumes = rootlessVolumes;
|
||||
}
|
||||
services[name] = hardened;
|
||||
}
|
||||
}
|
||||
|
||||
const mountSources = new Set(
|
||||
Object.values(services).flatMap((service) => (service.volumes ?? []).map((mount) => mountSource(mount))),
|
||||
);
|
||||
const volumes = Object.fromEntries(
|
||||
Object.values(NamedVolume)
|
||||
.filter((name) => mountSources.has(name))
|
||||
.map((name) => [name, null]),
|
||||
);
|
||||
|
||||
const networks = externalNetwork ? { [externalNetwork]: { external: true } } : {};
|
||||
|
||||
const spec: ComposeFile = { name: 'immich', services, volumes, networks };
|
||||
|
||||
return pruneEmpty(spec);
|
||||
};
|
||||
|
||||
export const buildCompose = (config: ImmichConfig, version: string): string =>
|
||||
stringify(buildComposeSpec(config, version), { lineWidth: 0, nullStr: '' });
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_CONFIG, validate } from './config';
|
||||
|
||||
describe('validate', () => {
|
||||
it('passes on the default config', () => {
|
||||
expect(validate(DEFAULT_CONFIG)).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects a port that is empty or out of range', () => {
|
||||
for (const port of [undefined, 0, -1, 1.5, 65_536]) {
|
||||
expect(validate({ ...DEFAULT_CONFIG, port }).port).toBeTruthy();
|
||||
}
|
||||
expect(validate({ ...DEFAULT_CONFIG, port: 8080 }).port).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires the upload location', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.storage.uploadLocation = ' '.repeat(3);
|
||||
expect(validate(config)['storage.uploadLocation']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('requires the external URL when external Postgres is on, not the data location', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.database.external = true;
|
||||
config.database.mount = { type: 'bind', location: '' };
|
||||
const errors = validate(config);
|
||||
expect(errors['database.externalUrl']).toBeTruthy();
|
||||
expect(errors['database.mount.location']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires a bundled database password, but not when external', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.database.password = '';
|
||||
expect(validate(config)['database.password']).toBeTruthy();
|
||||
|
||||
config.database.external = true;
|
||||
config.database.externalUrl = 'postgresql://u:p@h:5432/immich';
|
||||
expect(validate(config)['database.password']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a Windows path for the database bind mount, but not for a volume', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.database.mount = { type: 'bind', location: String.raw`C:\immich\postgres` };
|
||||
expect(validate(config)['database.mount.location']).toContain('Windows');
|
||||
|
||||
config.database.mount = { type: 'volume' };
|
||||
expect(validate(config)['database.mount.location']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects WSL drive mounts for the database, without catching ordinary /mnt paths', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
|
||||
for (const location of ['/mnt/c/immich/postgres', '/mnt/c', '/mnt/D/data']) {
|
||||
config.database.mount = { type: 'bind', location };
|
||||
expect(validate(config)['database.mount.location']).toContain('Windows');
|
||||
}
|
||||
|
||||
for (const location of ['/mnt/data', '/mnt/storage/postgres', '/srv/mnt/data', '/mnt/cache/pg']) {
|
||||
config.database.mount = { type: 'bind', location };
|
||||
expect(validate(config)['database.mount.location']).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('requires the redis host only when external Redis is on', () => {
|
||||
expect(validate(DEFAULT_CONFIG)['redis.host']).toBeUndefined();
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.redis.external = true;
|
||||
expect(validate(config)['redis.host']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags the database sharing or nesting under a media path', () => {
|
||||
const same = structuredClone(DEFAULT_CONFIG);
|
||||
same.storage.uploadLocation = '/mnt/data';
|
||||
same.database.mount = { type: 'bind', location: '/mnt/data' };
|
||||
expect(validate(same)['storage.uploadLocation']).toBeTruthy();
|
||||
expect(validate(same)['database.mount.location']).toBeTruthy();
|
||||
|
||||
const nested = structuredClone(DEFAULT_CONFIG);
|
||||
nested.storage.uploadLocation = '/mnt/data';
|
||||
nested.database.mount = { type: 'bind', location: '/mnt/data/postgres' };
|
||||
expect(validate(nested)['database.mount.location']).toContain('overlaps');
|
||||
|
||||
const volume = structuredClone(DEFAULT_CONFIG);
|
||||
volume.storage.uploadLocation = '/mnt/data';
|
||||
volume.database.mount = { type: 'volume' };
|
||||
expect(validate(volume)['storage.uploadLocation']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores trailing slashes when comparing paths', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.storage.uploadLocation = '/mnt/data/';
|
||||
config.database.mount = { type: 'bind', location: '/mnt/data' };
|
||||
expect(validate(config)['database.mount.location']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects an out-of-range external redis port but allows blank', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.redis.external = true;
|
||||
config.redis.host = 'redis.example.com';
|
||||
expect(validate(config)['redis.port']).toBeUndefined();
|
||||
config.redis.port = 99_999;
|
||||
expect(validate(config)['redis.port']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('checks the rootless uid and gid only while rootless is on', () => {
|
||||
const off = structuredClone(DEFAULT_CONFIG);
|
||||
off.rootless = { enabled: false, uid: undefined, gid: -1 };
|
||||
expect(validate(off)).toEqual({});
|
||||
|
||||
const on = structuredClone(DEFAULT_CONFIG);
|
||||
on.rootless = { enabled: true, uid: undefined, gid: -1 };
|
||||
expect(validate(on)['rootless.uid']).toBeTruthy();
|
||||
expect(validate(on)['rootless.gid']).toBeTruthy();
|
||||
|
||||
on.rootless = { enabled: true, uid: 99, gid: 100 };
|
||||
expect(validate(on)).toEqual({});
|
||||
});
|
||||
|
||||
it('requires a network name only when the external network is on', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.network = { external: false, name: '' };
|
||||
expect(validate(config)['network.name']).toBeUndefined();
|
||||
|
||||
config.network = { external: true, name: '' };
|
||||
expect(validate(config)['network.name']).toBeTruthy();
|
||||
|
||||
config.network = { external: true, name: 'proxy' };
|
||||
expect(validate(config)['network.name']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires a path on every external library row', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.storage.externalLibraries = [
|
||||
{ path: '/mnt/media/photos', readOnly: true },
|
||||
{ path: ' '.repeat(2), readOnly: true },
|
||||
];
|
||||
expect(validate(config)['storage.externalLibraries.0.path']).toBeUndefined();
|
||||
expect(validate(config)['storage.externalLibraries.1.path']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('requires external library paths to be absolute', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
|
||||
for (const path of ['asdf', './here', '../up', String.raw`C:\photos`]) {
|
||||
config.storage.externalLibraries = [{ path, readOnly: true }];
|
||||
expect(validate(config)['storage.externalLibraries.0.path']).toContain('absolute');
|
||||
}
|
||||
|
||||
for (const path of ['/mnt/media/photos', '/srv/photos']) {
|
||||
config.storage.externalLibraries = [{ path, readOnly: true }];
|
||||
expect(validate(config)['storage.externalLibraries.0.path']).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('flags an external library that overlaps another mount', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.storage.uploadLocation = '/mnt/media';
|
||||
config.storage.externalLibraries = [{ path: '/mnt/media/photos', readOnly: true }];
|
||||
expect(validate(config)['storage.externalLibraries.0.path']).toContain('overlaps');
|
||||
expect(validate(config)['storage.uploadLocation']).toContain('overlaps');
|
||||
});
|
||||
|
||||
it('flags server mounts that point at the same host path', () => {
|
||||
const config = structuredClone(DEFAULT_CONFIG);
|
||||
config.storage.uploadLocation = '/mnt/data';
|
||||
config.storage.customFolders = true;
|
||||
config.storage.overrides.thumbs = '/mnt/data';
|
||||
const errors = validate(config);
|
||||
expect(errors['storage.uploadLocation']).toBeTruthy();
|
||||
expect(errors['storage.overrides.thumbs']).toBeTruthy();
|
||||
expect(errors['storage.overrides.profile']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { z } from 'zod';
|
||||
import { ML_BACKENDS, TRANSCODE_BACKENDS, type MlAccel, type TranscodeAccel } from './hwaccel';
|
||||
|
||||
const databaseMountSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('bind'), location: z.string() }),
|
||||
z.object({ type: z.literal('volume') }),
|
||||
]);
|
||||
|
||||
export enum StorageType {
|
||||
SSD = 'SSD',
|
||||
HDD = 'HDD',
|
||||
}
|
||||
|
||||
const storageOverridesSchema = z.object({
|
||||
thumbs: z.string(),
|
||||
encodedVideo: z.string(),
|
||||
profile: z.string(),
|
||||
backups: z.string(),
|
||||
});
|
||||
|
||||
type StorageOverrides = z.infer<typeof storageOverridesSchema>;
|
||||
|
||||
const transcodeAccelSchema = z.custom<TranscodeAccel>(
|
||||
(value) => typeof value === 'string' && Object.hasOwn(TRANSCODE_BACKENDS, value),
|
||||
);
|
||||
const mlAccelSchema = z.custom<MlAccel>((value) => typeof value === 'string' && Object.hasOwn(ML_BACKENDS, value));
|
||||
|
||||
const PORT_MESSAGE = 'Enter a port between 1 and 65535.';
|
||||
|
||||
const isPort = (value?: number) => value !== undefined && Number.isSafeInteger(value) && value >= 1 && value <= 65_535;
|
||||
|
||||
const ID_MESSAGE = 'Enter a numeric ID.';
|
||||
|
||||
const isId = (value?: number) => value !== undefined && Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
// WSL exposes Windows drives as single letter mounts, e.g. /mnt/c.
|
||||
const isWindowsPath = (path: string) => /^[a-zA-Z]:[\\/]/.test(path) || /^\/mnt\/[a-z](\/|$)/i.test(path);
|
||||
|
||||
const required = (message: string) => z.string().refine((value) => value.trim() !== '', message);
|
||||
|
||||
const externalLibrarySchema = z.object({ path: z.string(), readOnly: z.boolean() });
|
||||
|
||||
const immichConfigSchema = z.object({
|
||||
timezone: z.string(),
|
||||
rootless: z.object({
|
||||
enabled: z.boolean(),
|
||||
uid: z.number().optional(),
|
||||
gid: z.number().optional(),
|
||||
}),
|
||||
port: z.number().optional().refine(isPort, PORT_MESSAGE),
|
||||
containerNames: z.boolean(),
|
||||
hwaccel: z.object({
|
||||
transcoding: transcodeAccelSchema,
|
||||
ml: mlAccelSchema,
|
||||
}),
|
||||
storage: z.object({
|
||||
uploadLocation: required('Upload location is required.'),
|
||||
customFolders: z.boolean(),
|
||||
overrides: storageOverridesSchema,
|
||||
externalLibraries: z.array(externalLibrarySchema),
|
||||
}),
|
||||
database: z.object({
|
||||
mount: databaseMountSchema,
|
||||
storageType: z.enum(StorageType),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
databaseName: z.string(),
|
||||
external: z.boolean(),
|
||||
externalUrl: z.string(),
|
||||
}),
|
||||
redis: z.object({
|
||||
external: z.boolean(),
|
||||
host: z.string(),
|
||||
port: z.number().optional(),
|
||||
password: z.string(),
|
||||
}),
|
||||
network: z.object({
|
||||
external: z.boolean(),
|
||||
name: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ImmichConfig = z.infer<typeof immichConfigSchema>;
|
||||
|
||||
const ADVANCED_RESETS: ((config: ImmichConfig) => void)[] = [
|
||||
(config) => (config.port = DEFAULT_CONFIG.port),
|
||||
(config) => (config.storage.customFolders = DEFAULT_CONFIG.storage.customFolders),
|
||||
(config) => (config.database.external = DEFAULT_CONFIG.database.external),
|
||||
(config) => (config.redis.external = DEFAULT_CONFIG.redis.external),
|
||||
(config) => (config.containerNames = DEFAULT_CONFIG.containerNames),
|
||||
(config) => (config.storage.externalLibraries = structuredClone(DEFAULT_CONFIG.storage.externalLibraries)),
|
||||
(config) => (config.network.external = DEFAULT_CONFIG.network.external),
|
||||
];
|
||||
|
||||
export const withoutAdvanced = (config: ImmichConfig): ImmichConfig => {
|
||||
const basic = structuredClone(config);
|
||||
for (const reset of ADVANCED_RESETS) {
|
||||
reset(basic);
|
||||
}
|
||||
return basic;
|
||||
};
|
||||
|
||||
export const FOLDER_OVERRIDES: { key: keyof StorageOverrides; label: string; subfolder: string }[] = [
|
||||
{ key: 'thumbs', label: 'Thumbnails', subfolder: 'thumbs' },
|
||||
{ key: 'encodedVideo', label: 'Encoded video', subfolder: 'encoded-video' },
|
||||
{ key: 'profile', label: 'Profile', subfolder: 'profile' },
|
||||
{ key: 'backups', label: 'Backups', subfolder: 'backups' },
|
||||
];
|
||||
|
||||
export const DEFAULT_CONFIG: ImmichConfig = {
|
||||
timezone: '',
|
||||
rootless: {
|
||||
enabled: false,
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
},
|
||||
port: 2283,
|
||||
containerNames: true,
|
||||
hwaccel: {
|
||||
transcoding: 'cpu',
|
||||
ml: 'cpu',
|
||||
},
|
||||
storage: {
|
||||
uploadLocation: './library',
|
||||
customFolders: false,
|
||||
overrides: {
|
||||
thumbs: '',
|
||||
encodedVideo: '',
|
||||
profile: '',
|
||||
backups: '',
|
||||
},
|
||||
externalLibraries: [],
|
||||
},
|
||||
database: {
|
||||
mount: { type: 'bind', location: './postgres' },
|
||||
storageType: StorageType.SSD,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
databaseName: 'immich',
|
||||
external: false,
|
||||
externalUrl: '',
|
||||
},
|
||||
redis: {
|
||||
external: false,
|
||||
host: '',
|
||||
port: undefined,
|
||||
password: '',
|
||||
},
|
||||
network: {
|
||||
external: false,
|
||||
name: '',
|
||||
},
|
||||
};
|
||||
|
||||
type ValidationErrors = Record<string, string>;
|
||||
|
||||
const normalizePath = (path: string) => path.trim().replace(/\/+$/, '');
|
||||
|
||||
type Mount = { path: string[]; location: string };
|
||||
|
||||
const collectMounts = (config: ImmichConfig): Mount[] => {
|
||||
const mounts: Mount[] = [{ path: ['storage', 'uploadLocation'], location: config.storage.uploadLocation }];
|
||||
|
||||
if (config.storage.customFolders) {
|
||||
for (const { key } of FOLDER_OVERRIDES) {
|
||||
mounts.push({ path: ['storage', 'overrides', key], location: config.storage.overrides[key] });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, { path }] of config.storage.externalLibraries.entries()) {
|
||||
mounts.push({ path: ['storage', 'externalLibraries', String(index), 'path'], location: path });
|
||||
}
|
||||
|
||||
if (!config.database.external && config.database.mount.type === 'bind') {
|
||||
mounts.push({ path: ['database', 'mount', 'location'], location: config.database.mount.location });
|
||||
}
|
||||
|
||||
return mounts
|
||||
.map(({ path, location }) => ({ path, location: normalizePath(location) }))
|
||||
.filter(({ location }) => location !== '');
|
||||
};
|
||||
|
||||
const validationSchema = immichConfigSchema.superRefine((config, ctx) => {
|
||||
const fail = (path: string[], message: string) => ctx.addIssue({ code: 'custom', path, message });
|
||||
|
||||
if (config.rootless.enabled) {
|
||||
if (!isId(config.rootless.uid)) {
|
||||
fail(['rootless', 'uid'], ID_MESSAGE);
|
||||
}
|
||||
if (!isId(config.rootless.gid)) {
|
||||
fail(['rootless', 'gid'], ID_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, { path }] of config.storage.externalLibraries.entries()) {
|
||||
const location = path.trim();
|
||||
if (!location) {
|
||||
fail(['storage', 'externalLibraries', String(index), 'path'], 'A path is required.');
|
||||
} else if (!location.startsWith('/')) {
|
||||
fail(['storage', 'externalLibraries', String(index), 'path'], 'Enter an absolute path, e.g. /mnt/media/photos.');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.network.external && !config.network.name.trim()) {
|
||||
fail(['network', 'name'], 'A network name is required.');
|
||||
}
|
||||
|
||||
if (config.database.external) {
|
||||
if (!config.database.externalUrl.trim()) {
|
||||
fail(['database', 'externalUrl'], 'A connection URL is required for external Postgres.');
|
||||
}
|
||||
} else {
|
||||
if (config.database.mount.type === 'bind') {
|
||||
const location = config.database.mount.location.trim();
|
||||
if (!location) {
|
||||
fail(['database', 'mount', 'location'], 'Database location is required.');
|
||||
} else if (isWindowsPath(location)) {
|
||||
fail(
|
||||
['database', 'mount', 'location'],
|
||||
"The database can't be on a Windows drive or WSL mount, use a named volume instead.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!config.database.password.trim()) {
|
||||
fail(['database', 'password'], 'A database password is required.');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.redis.external) {
|
||||
if (!config.redis.host.trim()) {
|
||||
fail(['redis', 'host'], 'A host is required for external Redis.');
|
||||
}
|
||||
if (config.redis.port !== undefined && !isPort(config.redis.port)) {
|
||||
fail(['redis', 'port'], PORT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
const mounts = collectMounts(config);
|
||||
for (const [index, mount] of mounts.entries()) {
|
||||
for (const other of mounts.slice(index + 1)) {
|
||||
const overlaps =
|
||||
mount.location.startsWith(`${other.location}/`) || other.location.startsWith(`${mount.location}/`);
|
||||
const message =
|
||||
mount.location === other.location
|
||||
? 'This path is already used by another mount.'
|
||||
: overlaps
|
||||
? 'This path overlaps another mount.'
|
||||
: undefined;
|
||||
if (message) {
|
||||
fail(mount.path, message);
|
||||
fail(other.path, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const validate = (config: ImmichConfig): ValidationErrors => {
|
||||
const result = validationSchema.safeParse(config);
|
||||
if (result.success) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const errors: ValidationErrors = {};
|
||||
for (const issue of result.error.issues) {
|
||||
errors[issue.path.join('.')] = issue.message;
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ComposeService } from './spec';
|
||||
|
||||
export const TRANSCODE_BACKENDS = {
|
||||
cpu: { label: 'None (CPU)', fragment: {} },
|
||||
nvenc: {
|
||||
label: 'NVIDIA (NVENC)',
|
||||
fragment: {
|
||||
deploy: {
|
||||
resources: {
|
||||
reservations: {
|
||||
devices: [{ driver: 'nvidia', count: 1, capabilities: ['gpu', 'compute', 'video'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
quicksync: {
|
||||
label: 'Intel QuickSync',
|
||||
fragment: { devices: ['/dev/dri:/dev/dri'] },
|
||||
},
|
||||
rkmpp: {
|
||||
label: 'Rockchip (RKMPP)',
|
||||
fragment: {
|
||||
security_opt: ['systempaths=unconfined', 'apparmor=unconfined'],
|
||||
group_add: ['video'],
|
||||
devices: [
|
||||
'/dev/rga:/dev/rga',
|
||||
'/dev/dri:/dev/dri',
|
||||
'/dev/dma_heap:/dev/dma_heap',
|
||||
'/dev/mpp_service:/dev/mpp_service',
|
||||
],
|
||||
},
|
||||
},
|
||||
vaapi: {
|
||||
label: 'VA-API',
|
||||
fragment: { devices: ['/dev/dri:/dev/dri'] },
|
||||
},
|
||||
'vaapi-wsl': {
|
||||
label: 'VA-API (WSL2)',
|
||||
fragment: {
|
||||
devices: ['/dev/dri:/dev/dri', '/dev/dxg:/dev/dxg'],
|
||||
volumes: ['/usr/lib/wsl:/usr/lib/wsl'],
|
||||
environment: { LIBVA_DRIVER_NAME: 'd3d12' },
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, { label: string; fragment: ComposeService }>;
|
||||
|
||||
export type TranscodeAccel = keyof typeof TRANSCODE_BACKENDS;
|
||||
|
||||
export const ML_BACKENDS = {
|
||||
cpu: { label: 'None (CPU)', tag: '', fragment: {} },
|
||||
armnn: {
|
||||
label: 'ARM NN (Mali)',
|
||||
tag: '-armnn',
|
||||
fragment: {
|
||||
devices: ['/dev/mali0:/dev/mali0'],
|
||||
volumes: [
|
||||
'/lib/firmware/mali_csffw.bin:/lib/firmware/mali_csffw.bin:ro',
|
||||
'/usr/lib/libmali.so:/usr/lib/libmali.so:ro',
|
||||
],
|
||||
},
|
||||
},
|
||||
cuda: {
|
||||
label: 'NVIDIA (CUDA)',
|
||||
tag: '-cuda',
|
||||
fragment: {
|
||||
deploy: {
|
||||
resources: {
|
||||
reservations: {
|
||||
devices: [{ driver: 'nvidia', count: 1, capabilities: ['gpu'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rocm: {
|
||||
label: 'AMD (ROCm)',
|
||||
tag: '-rocm',
|
||||
fragment: { group_add: ['video'], devices: ['/dev/dri:/dev/dri', '/dev/kfd:/dev/kfd'] },
|
||||
},
|
||||
openvino: {
|
||||
label: 'Intel (OpenVINO)',
|
||||
tag: '-openvino',
|
||||
fragment: {
|
||||
device_cgroup_rules: ['c 189:* rmw'],
|
||||
devices: ['/dev/dri:/dev/dri'],
|
||||
volumes: ['/dev/bus/usb:/dev/bus/usb'],
|
||||
},
|
||||
},
|
||||
'openvino-wsl': {
|
||||
label: 'Intel OpenVINO (WSL2)',
|
||||
tag: '-openvino',
|
||||
fragment: {
|
||||
devices: ['/dev/dri:/dev/dri', '/dev/dxg:/dev/dxg'],
|
||||
volumes: ['/dev/bus/usb:/dev/bus/usb', '/usr/lib/wsl:/usr/lib/wsl'],
|
||||
},
|
||||
},
|
||||
rknn: {
|
||||
label: 'Rockchip (RKNN)',
|
||||
tag: '-rknn',
|
||||
fragment: { security_opt: ['systempaths=unconfined', 'apparmor=unconfined'], devices: ['/dev/dri:/dev/dri'] },
|
||||
},
|
||||
} satisfies Record<string, { label: string; tag: string; fragment: ComposeService }>;
|
||||
|
||||
export type MlAccel = keyof typeof ML_BACKENDS;
|
||||
|
||||
export const TRANSCODE_ACCELS = Object.entries(TRANSCODE_BACKENDS).map(([value, { label }]) => ({
|
||||
value: value as TranscodeAccel,
|
||||
label,
|
||||
}));
|
||||
|
||||
export const ML_ACCELS = Object.entries(ML_BACKENDS).map(([value, { label }]) => ({
|
||||
value: value as MlAccel,
|
||||
label,
|
||||
}));
|
||||
@@ -0,0 +1,7 @@
|
||||
export const IMAGES = {
|
||||
server: (version: string) => `ghcr.io/immich-app/immich-server:${version}`,
|
||||
machineLearning: (version: string) => `ghcr.io/immich-app/immich-machine-learning:${version}`,
|
||||
redis: 'docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9',
|
||||
database:
|
||||
'ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23',
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Compose } from '@json-types/compose';
|
||||
|
||||
export type { Service as ComposeService } from '@json-types/compose';
|
||||
|
||||
// The generated Volume type omits the null the compose schema allows for a default volume.
|
||||
export type ComposeFile = Omit<Compose, 'volumes'> & { volumes?: Record<string, null> };
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BlogType, posts } from '$lib';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
const routes = fileURLToPath(new URL('../routes', import.meta.url));
|
||||
|
||||
const markdownPosts = readdirSync(routes, { recursive: true, encoding: 'utf8' })
|
||||
.map((entry) => entry.split('/'))
|
||||
.filter((parts) => parts.includes('blog') && parts.at(-1) === '+page.md')
|
||||
.map((parts) => ({ slug: parts.at(-2)!, type: parts.at(-3)!.slice(1, -2) }));
|
||||
|
||||
describe('posts', () => {
|
||||
test('scans every markdown post in the routes folder', () => {
|
||||
expect(markdownPosts.length).toBeGreaterThan(0);
|
||||
expect(posts).toHaveLength(markdownPosts.length);
|
||||
});
|
||||
|
||||
test('derives the url from the post folder', () => {
|
||||
const urls = posts.map((post) => post.url).toSorted();
|
||||
expect(urls).toEqual(markdownPosts.map(({ slug }) => `/blog/${slug}`).toSorted());
|
||||
});
|
||||
|
||||
test('derives the type from the group folder', () => {
|
||||
const typeByUrl = new Map(markdownPosts.map(({ slug, type }) => [`/blog/${slug}`, type]));
|
||||
for (const post of posts) {
|
||||
expect(post.type, post.url).toBe(typeByUrl.get(post.url));
|
||||
expect(Object.values(BlogType)).toContain(post.type);
|
||||
}
|
||||
});
|
||||
|
||||
test('parses the front matter of every post', () => {
|
||||
for (const post of posts) {
|
||||
expect(post.id, post.url).toEqual(expect.any(String));
|
||||
expect(post.title, post.url).toEqual(expect.any(String));
|
||||
expect(post.description, post.url).toEqual(expect.any(String));
|
||||
expect(post.authors.length, post.url).toBeGreaterThan(0);
|
||||
expect(post.publishedAt.isValid, post.url).toBe(true);
|
||||
expect(post.markdown, post.url).toContain('---');
|
||||
}
|
||||
});
|
||||
|
||||
test('sorts posts from newest to oldest', () => {
|
||||
const publishedAt = posts.map((post) => post.publishedAt.valueOf());
|
||||
expect(publishedAt).toEqual(publishedAt.toSorted((a, b) => b - a));
|
||||
});
|
||||
|
||||
test('has a unique id per post', () => {
|
||||
const ids = new Set(posts.map((post) => post.id));
|
||||
expect(ids.size).toBe(posts.length);
|
||||
});
|
||||
});
|
||||
@@ -82,24 +82,27 @@ const getFrontMatterExample = (missingAttributes: string[]) => {
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const POST_PATH = /\/blog\/\((?<group>[^)]+)\)\/(?<slug>[^/]+)\/\+page\.md$/;
|
||||
|
||||
const asPost = (path: string, content: string): BlogPost => {
|
||||
const attributes = fm<PostFrontMatter>(content).attributes;
|
||||
const parts = path.split('/');
|
||||
const filename = parts.at(-2)!;
|
||||
const folder = parts.at(-3)!;
|
||||
const type = folder.slice(1, -2); // strip parens and trailing s
|
||||
const match = POST_PATH.exec(path);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`${path} is not a valid blog post path - expected blog/(types)/slug/+page.md`);
|
||||
}
|
||||
|
||||
const { slug, group } = match.groups;
|
||||
const type = group.replace(/s$/, '');
|
||||
|
||||
const requiredAttributes = ['id', 'title', 'description', 'publishedAt', 'authors'];
|
||||
const missingAttributes = requiredAttributes.filter((attribute) => !Object.hasOwn(attributes, attribute));
|
||||
if (missingAttributes.length > 0) {
|
||||
throw new Error(
|
||||
`${filename} is missing ${missingAttributes.join(', ')}.\n${getFrontMatterExample(missingAttributes)}`,
|
||||
);
|
||||
throw new Error(`${slug} is missing ${missingAttributes.join(', ')}.\n${getFrontMatterExample(missingAttributes)}`);
|
||||
}
|
||||
|
||||
if (!isBlogType(type)) {
|
||||
throw new Error(
|
||||
`${filename} has incorrect blog type - found ${type}, but expected one of ${Object.values(BlogType).join(', ')}`,
|
||||
`${slug} has incorrect blog type - found ${type}, but expected one of ${Object.values(BlogType).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,7 +116,7 @@ const asPost = (path: string, content: string): BlogPost => {
|
||||
? (DateTime.fromJSDate(attributes.modifiedAt, { zone: 'UTC' }) as DateTime<true>)
|
||||
: undefined,
|
||||
authors: attributes.authors,
|
||||
url: `/blog/${filename}`,
|
||||
url: `/blog/${slug}`,
|
||||
featured: attributes.featured,
|
||||
coverUrl: attributes.coverUrl,
|
||||
coverAlt: attributes.coverAlt,
|
||||
@@ -124,7 +127,10 @@ const asPost = (path: string, content: string): BlogPost => {
|
||||
|
||||
const getPosts = () => {
|
||||
const idMap = new Map<string, string>();
|
||||
const modules = import.meta.glob<{ default: string }>('../routes/blog/**/*.md', { query: '?raw', eager: true });
|
||||
const modules = import.meta.glob<{ default: string }>('../routes/**/blog/**/*.md', {
|
||||
query: '?raw',
|
||||
eager: true,
|
||||
});
|
||||
const posts: BlogPost[] = [];
|
||||
for (const [path, { default: content }] of Object.entries(modules)) {
|
||||
const post = asPost(path, content);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { posts } from '$lib';
|
||||
import '$lib/app.css';
|
||||
import {
|
||||
AnnouncementBanner,
|
||||
AppShell,
|
||||
AppShellHeader,
|
||||
AppShellSidebar,
|
||||
Button,
|
||||
CommandPaletteButton,
|
||||
Constants,
|
||||
IconButton,
|
||||
Link,
|
||||
Logo,
|
||||
NavbarItem,
|
||||
ScreencastOverlay,
|
||||
SiteFooter,
|
||||
Text,
|
||||
ThemeSwitcher,
|
||||
TooltipProvider,
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiChartGantt,
|
||||
mdiDownload,
|
||||
mdiMenu,
|
||||
mdiOpenInNew,
|
||||
mdiPostOutline,
|
||||
mdiScriptTextOutline,
|
||||
mdiShoppingOutline,
|
||||
} from '@mdi/js';
|
||||
import { siGithub } from 'simple-icons';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
|
||||
type Props = {
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
const { children }: Props = $props();
|
||||
|
||||
const sidebar = new MediaQuery(`max-width: 850px`);
|
||||
let isMobile = $derived(sidebar.current);
|
||||
let isOpen = $state(false);
|
||||
let open = $derived(isMobile && isOpen);
|
||||
|
||||
beforeNavigate(() => {
|
||||
isOpen = false;
|
||||
});
|
||||
|
||||
const withActiveColor = (path: string) => {
|
||||
const active = path === page.url.pathname || page.url.pathname.startsWith(path);
|
||||
return active ? 'primary' : 'secondary';
|
||||
};
|
||||
|
||||
const featuredPost = posts.find((post) => post.featured);
|
||||
</script>
|
||||
|
||||
<ScreencastOverlay />
|
||||
|
||||
<TooltipProvider>
|
||||
<AppShell>
|
||||
<AppShellHeader>
|
||||
<div class="w-full">
|
||||
{#if !page.url.pathname.startsWith('/blog') && featuredPost}
|
||||
<AnnouncementBanner until={featuredPost.publishedAt.plus({ week: 1 })}>
|
||||
{#snippet content()}
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
Read our latest post:
|
||||
<Text color="primary">
|
||||
<Link href={featuredPost.url}>{featuredPost.title}</Link>
|
||||
</Text>
|
||||
</div>
|
||||
{/snippet}
|
||||
</AnnouncementBanner>
|
||||
{/if}
|
||||
<nav class="flex grid-cols-[1fr_auto_1fr] justify-between p-2 lg:grid lg:gap-2">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
aria-label="Main menu"
|
||||
icon={mdiMenu}
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
class="md:hidden"
|
||||
/>
|
||||
<a href="/" class="flex gap-2 text-4xl">
|
||||
<Logo variant="inline" class="hidden sm:block" />
|
||||
<Logo variant="logo" class="sm:hidden" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="hidden place-items-center gap-1 lg:flex">
|
||||
<Button href="/blog" variant="ghost" color={withActiveColor('/blog')}>Blog</Button>
|
||||
<Button href="/roadmap" variant="ghost" color={withActiveColor('/roadmap')}>Roadmap</Button>
|
||||
<Button trailingIcon={mdiOpenInNew} href={Constants.Sites.Store} color="secondary" variant="ghost">
|
||||
Merch
|
||||
</Button>
|
||||
<Button trailingIcon={mdiOpenInNew} href={Constants.Sites.Docs} color="secondary" variant="ghost">
|
||||
Docs
|
||||
</Button>
|
||||
<Button
|
||||
leadingIcon={siGithub.path}
|
||||
trailingIcon={mdiOpenInNew}
|
||||
href={Constants.Socials.Github}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex place-items-center justify-end gap-2">
|
||||
<Button href={Constants.Sites.Buy} color="primary" size="small">Buy Immich</Button>
|
||||
<CommandPaletteButton />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</AppShellHeader>
|
||||
|
||||
<AppShellSidebar bind:open>
|
||||
<div class="my-4 me-4">
|
||||
<NavbarItem title="Documentation" href={Constants.Sites.Docs} icon={mdiScriptTextOutline} />
|
||||
<NavbarItem title="Blog" href="/blog" icon={mdiPostOutline} />
|
||||
<NavbarItem title="Roadmap" href="/roadmap" icon={mdiChartGantt} />
|
||||
<NavbarItem title="Download" href="/download" icon={mdiDownload} />
|
||||
<NavbarItem title="Merch" href={Constants.Sites.Store} icon={mdiShoppingOutline} />
|
||||
<NavbarItem title="Github" href={Constants.Socials.Github} icon={siGithub.path} />
|
||||
</div>
|
||||
</AppShellSidebar>
|
||||
|
||||
<div class="mx-auto flex h-full flex-col">
|
||||
{@render children?.()}
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</AppShell>
|
||||
</TooltipProvider>
|
||||
+2
@@ -33,6 +33,7 @@
|
||||
</script>
|
||||
|
||||
<SiteMetadata site={siteMetadata} page={pageMetadata} />
|
||||
|
||||
<PageContent>
|
||||
<Container size="large" center>
|
||||
<Heading size="title" class="mb-4" tag="h1">{pageMetadata.title}</Heading>
|
||||
@@ -97,6 +98,7 @@
|
||||
</CardBody>
|
||||
<CardFooter class="flex flex-col gap-2">
|
||||
<Button fullWidth href="https://immich.app/docs/install/docker-compose">View Instructions</Button>
|
||||
<Button fullWidth color="secondary" href="/docker-compose-builder">Build a Compose File</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { posts } from '$lib';
|
||||
import '$lib/app.css';
|
||||
import { getSearchProvider } from '$lib/search';
|
||||
import {
|
||||
AnnouncementBanner,
|
||||
AppShell,
|
||||
AppShellHeader,
|
||||
AppShellSidebar,
|
||||
Button,
|
||||
CommandPaletteButton,
|
||||
commandPaletteManager,
|
||||
CommandPaletteProvider,
|
||||
Constants,
|
||||
getSiteProviders,
|
||||
IconButton,
|
||||
Link,
|
||||
Logo,
|
||||
NavbarItem,
|
||||
ScreencastOverlay,
|
||||
SiteFooter,
|
||||
Text,
|
||||
ThemeSwitcher,
|
||||
TooltipProvider,
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiChartGantt,
|
||||
mdiDownload,
|
||||
mdiMenu,
|
||||
mdiOpenInNew,
|
||||
mdiPostOutline,
|
||||
mdiScriptTextOutline,
|
||||
mdiShoppingOutline,
|
||||
} from '@mdi/js';
|
||||
import { siGithub } from 'simple-icons';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
type Props = {
|
||||
@@ -64,23 +38,7 @@
|
||||
window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
});
|
||||
|
||||
const sidebar = new MediaQuery(`max-width: 850px`);
|
||||
let isMobile = $derived(sidebar.current);
|
||||
let isOpen = $state(false);
|
||||
let open = $derived(isMobile && isOpen);
|
||||
|
||||
beforeNavigate(() => {
|
||||
isOpen = false;
|
||||
});
|
||||
|
||||
const withActiveColor = (path: string) => {
|
||||
const active = path === page.url.pathname || page.url.pathname.startsWith(path);
|
||||
return active ? 'primary' : 'secondary';
|
||||
};
|
||||
|
||||
commandPaletteManager.enable();
|
||||
|
||||
const featuredPost = posts.find((post) => post.featured);
|
||||
</script>
|
||||
|
||||
<CommandPaletteProvider providers={[getSearchProvider(data?.docs ?? []), ...getSiteProviders()]} />
|
||||
@@ -88,81 +46,5 @@
|
||||
<ScreencastOverlay />
|
||||
|
||||
<TooltipProvider>
|
||||
<AppShell>
|
||||
<AppShellHeader>
|
||||
<div class="w-full">
|
||||
{#if !page.url.pathname.startsWith('/blog') && featuredPost}
|
||||
<AnnouncementBanner until={featuredPost.publishedAt.plus({ week: 1 })}>
|
||||
{#snippet content()}
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
Read our latest post:
|
||||
<Text color="primary">
|
||||
<Link href={featuredPost.url}>{featuredPost.title}</Link>
|
||||
</Text>
|
||||
</div>
|
||||
{/snippet}
|
||||
</AnnouncementBanner>
|
||||
{/if}
|
||||
<nav class="flex grid-cols-[1fr_auto_1fr] justify-between p-2 lg:grid lg:gap-2">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
aria-label="Main menu"
|
||||
icon={mdiMenu}
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
class="md:hidden"
|
||||
/>
|
||||
<a href="/" class="flex gap-2 text-4xl">
|
||||
<Logo variant="inline" class="hidden sm:block" />
|
||||
<Logo variant="logo" class="sm:hidden" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="hidden place-items-center gap-1 lg:flex">
|
||||
<Button href="/blog" variant="ghost" color={withActiveColor('/blog')}>Blog</Button>
|
||||
<Button href="/roadmap" variant="ghost" color={withActiveColor('/roadmap')}>Roadmap</Button>
|
||||
<Button trailingIcon={mdiOpenInNew} href={Constants.Sites.Store} color="secondary" variant="ghost">
|
||||
Merch
|
||||
</Button>
|
||||
<Button trailingIcon={mdiOpenInNew} href={Constants.Sites.Docs} color="secondary" variant="ghost">
|
||||
Docs
|
||||
</Button>
|
||||
<Button
|
||||
leadingIcon={siGithub.path}
|
||||
trailingIcon={mdiOpenInNew}
|
||||
href={Constants.Socials.Github}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex place-items-center justify-end gap-2">
|
||||
<Button href={Constants.Sites.Buy} color="primary" size="small">Buy Immich</Button>
|
||||
<CommandPaletteButton />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</AppShellHeader>
|
||||
|
||||
<AppShellSidebar bind:open>
|
||||
<div class="my-4 me-4">
|
||||
<NavbarItem title="Documentation" href={Constants.Sites.Docs} icon={mdiScriptTextOutline} />
|
||||
<NavbarItem title="Blog" href="/blog" icon={mdiPostOutline} />
|
||||
<NavbarItem title="Roadmap" href="/roadmap" icon={mdiChartGantt} />
|
||||
<NavbarItem title="Download" href="/download" icon={mdiDownload} />
|
||||
<NavbarItem title="Merch" href={Constants.Sites.Store} icon={mdiShoppingOutline} />
|
||||
<NavbarItem title="Github" href={Constants.Socials.Github} icon={siGithub.path} />
|
||||
</div>
|
||||
</AppShellSidebar>
|
||||
|
||||
<div class="mx-auto flex h-full flex-col">
|
||||
{@render children?.()}
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</AppShell>
|
||||
{@render children?.()}
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { siteMetadata } from '$lib';
|
||||
import PageContent from '$lib/components/PageContent.svelte';
|
||||
import { buildCompose } from '$lib/compose/build';
|
||||
import { DEFAULT_CONFIG, FOLDER_OVERRIDES, StorageType, validate, withoutAdvanced } from '$lib/compose/config';
|
||||
import { ML_ACCELS, TRANSCODE_ACCELS } from '$lib/compose/hwaccel';
|
||||
import {
|
||||
ActionBar,
|
||||
ActionButton,
|
||||
AppShell,
|
||||
AppShellBar,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
Code,
|
||||
CodeBlock,
|
||||
Container,
|
||||
ControlBarHeader,
|
||||
ControlBarTitle,
|
||||
Field,
|
||||
HelperText,
|
||||
IconButton,
|
||||
Input,
|
||||
Label,
|
||||
Link,
|
||||
LoadingSpinner,
|
||||
NumberInput,
|
||||
Select,
|
||||
SiteMetadata,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
toastManager,
|
||||
type ActionItem,
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiClose,
|
||||
mdiContentCopy,
|
||||
mdiDiceMultiple,
|
||||
mdiDownload,
|
||||
mdiEyeOffOutline,
|
||||
mdiEyeOutline,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { yaml as yamlLanguage } from 'svelte-highlight/languages';
|
||||
|
||||
const config = $state(structuredClone(DEFAULT_CONFIG));
|
||||
const defaultDatabaseLocation =
|
||||
DEFAULT_CONFIG.database.mount.type === 'bind' ? DEFAULT_CONFIG.database.mount.location : '';
|
||||
|
||||
let advanced = $state(false);
|
||||
let versionPinned = $state(false);
|
||||
let pinnedVersion = $state('');
|
||||
let latestVersion = $state('');
|
||||
let versionFailed = $state(false);
|
||||
|
||||
const majorVersion = $derived(latestVersion.split('.', 1)[0]);
|
||||
const version = $derived(versionPinned ? pinnedVersion.trim() || latestVersion : majorVersion);
|
||||
|
||||
const effectiveConfig = $derived.by(() => {
|
||||
const base = $state.snapshot(config);
|
||||
return advanced ? base : withoutAdvanced(base);
|
||||
});
|
||||
const compose = $derived(buildCompose(effectiveConfig, version));
|
||||
const errors = $derived(validate(effectiveConfig));
|
||||
const hasErrors = $derived(Object.keys(errors).length > 0);
|
||||
|
||||
const timezones = Intl.supportedValuesOf('timeZone');
|
||||
|
||||
onMount(async () => {
|
||||
if (!config.timezone) {
|
||||
config.timezone = new Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://version.immich.cloud/version');
|
||||
const { version: fetched } = response.ok ? await response.json() : {};
|
||||
if (!fetched) {
|
||||
versionFailed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pinnedVersion) {
|
||||
pinnedVersion = fetched;
|
||||
}
|
||||
latestVersion = fetched;
|
||||
} catch {
|
||||
versionFailed = true;
|
||||
}
|
||||
});
|
||||
|
||||
const generatePassword = () => {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
config.database.password = Array.from(
|
||||
crypto.getRandomValues(new Uint8Array(24)),
|
||||
(byte) => alphabet[byte % alphabet.length],
|
||||
).join('');
|
||||
};
|
||||
|
||||
const addLibrary = () => {
|
||||
config.storage.externalLibraries.push({ path: '', readOnly: true });
|
||||
};
|
||||
|
||||
const removeLibrary = (index: number) => {
|
||||
config.storage.externalLibraries.splice(index, 1);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(compose);
|
||||
toastManager.primary('Copied to clipboard');
|
||||
} catch (error) {
|
||||
toastManager.danger('Failed to copy docker-compose.yml to clipboard');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([compose], { type: 'application/yaml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'docker-compose.yml';
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
const pageMetadata = {
|
||||
title: 'Docker Compose Builder',
|
||||
description: 'Build a custom docker-compose.yml file for Immich.',
|
||||
};
|
||||
|
||||
const Copy: ActionItem = $derived({
|
||||
title: 'Copy',
|
||||
icon: mdiContentCopy,
|
||||
onAction: () => handleCopy(),
|
||||
$if: () => !!globalThis.navigator?.clipboard,
|
||||
});
|
||||
|
||||
const Download: ActionItem = {
|
||||
title: 'Download',
|
||||
icon: mdiDownload,
|
||||
onAction: () => handleDownload(),
|
||||
};
|
||||
|
||||
const Advanced: ActionItem = $derived({
|
||||
title: advanced ? 'Hide advanced options' : 'Show advanced options',
|
||||
icon: advanced ? mdiEyeOutline : mdiEyeOffOutline,
|
||||
shape: 'round',
|
||||
color: 'secondary',
|
||||
variant: 'ghost',
|
||||
onAction: () => (advanced = !advanced),
|
||||
});
|
||||
|
||||
toastManager.setOptions({ class: 'top-[66px]' });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Immich - Docker Compose</title>
|
||||
</svelte:head>
|
||||
|
||||
{#snippet fieldError(message: string | undefined)}
|
||||
{#if message}
|
||||
<HelperText color="danger">{message}</HelperText>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<SiteMetadata site={siteMetadata} page={pageMetadata} />
|
||||
|
||||
<AppShell>
|
||||
<AppShellBar>
|
||||
<ActionBar
|
||||
closeIcon={mdiArrowLeft}
|
||||
translations={{ close: 'Back' }}
|
||||
closeOnEsc={false}
|
||||
onClose={() => goto('/download')}
|
||||
static
|
||||
shape="round"
|
||||
variant="filled"
|
||||
>
|
||||
<ControlBarHeader>
|
||||
<ControlBarTitle>{pageMetadata.title}</ControlBarTitle>
|
||||
</ControlBarHeader>
|
||||
</ActionBar>
|
||||
</AppShellBar>
|
||||
<PageContent>
|
||||
<Container size="giant" center>
|
||||
<div class="my-4 flex justify-end gap-2">
|
||||
<ActionButton action={Advanced} />
|
||||
<ActionButton action={Copy} />
|
||||
<ActionButton action={Download} type="button" variant="filled" size="medium" shape="round" color="primary" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[clamp(20rem,34%,31.25rem)_1fr]">
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<Stack gap={6}>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<CardTitle>General</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Stack gap={4}>
|
||||
<Field>
|
||||
<Stack gap={2}>
|
||||
<Label label="Immich Version Tag" size="small" />
|
||||
<div class="grid grid-cols-2 gap-1 rounded-2xl border p-1">
|
||||
<Button
|
||||
size="small"
|
||||
fullWidth
|
||||
variant={versionPinned ? 'ghost' : 'filled'}
|
||||
color={versionPinned ? 'secondary' : 'primary'}
|
||||
onclick={() => (versionPinned = false)}
|
||||
>
|
||||
Rolling
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
fullWidth
|
||||
variant={versionPinned ? 'filled' : 'ghost'}
|
||||
color={versionPinned ? 'primary' : 'secondary'}
|
||||
onclick={() => (versionPinned = true)}
|
||||
>
|
||||
Pinned
|
||||
</Button>
|
||||
</div>
|
||||
{#if versionPinned}
|
||||
<Input
|
||||
bind:value={pinnedVersion}
|
||||
placeholder={latestVersion}
|
||||
aria-label="Pinned Immich version tag"
|
||||
/>
|
||||
{/if}
|
||||
{#if !version}
|
||||
<Text size="small" color="muted">
|
||||
{versionFailed ? 'Version unavailable.' : 'Checking for the latest release...'}
|
||||
</Text>
|
||||
{:else if versionPinned}
|
||||
<Text size="small" color="muted">
|
||||
Locked to <Code>{version}</Code>, updated manually.
|
||||
</Text>
|
||||
{:else}
|
||||
<Text size="small" color="muted">
|
||||
The newest <Code>{majorVersion}</Code> release each time you pull.
|
||||
</Text>
|
||||
{/if}
|
||||
</Stack>
|
||||
</Field>
|
||||
|
||||
<Field label="Rootless mode">
|
||||
<Switch bind:checked={config.rootless.enabled} class="flex justify-between gap-4" />
|
||||
</Field>
|
||||
{#if config.rootless.enabled}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Field label="UID" invalid={!!errors['rootless.uid']}>
|
||||
<NumberInput
|
||||
bind:value={config.rootless.uid}
|
||||
min={0}
|
||||
placeholder={String(DEFAULT_CONFIG.rootless.uid)}
|
||||
/>
|
||||
{@render fieldError(errors['rootless.uid'])}
|
||||
</Field>
|
||||
<Field label="GID" invalid={!!errors['rootless.gid']}>
|
||||
<NumberInput
|
||||
bind:value={config.rootless.gid}
|
||||
min={0}
|
||||
placeholder={String(DEFAULT_CONFIG.rootless.gid)}
|
||||
/>
|
||||
{@render fieldError(errors['rootless.gid'])}
|
||||
</Field>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if advanced}
|
||||
<Field
|
||||
label="Container names"
|
||||
description="Turn off to run more than one Immich stack on the same host."
|
||||
>
|
||||
<Switch bind:checked={config.containerNames} class="flex justify-between gap-4" />
|
||||
</Field>
|
||||
{/if}
|
||||
</Stack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<CardTitle>Server</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Stack gap={4}>
|
||||
<Field label="Server Timezone">
|
||||
<Input bind:value={config.timezone} list="timezones" placeholder="Etc/UTC" />
|
||||
<!-- TODO: Combobox in @immich/ui -->
|
||||
<datalist id="timezones">
|
||||
{#each timezones as timezone (timezone)}
|
||||
<option value={timezone}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</Field>
|
||||
|
||||
<Field label="Upload Location" invalid={!!errors['storage.uploadLocation']}>
|
||||
<Input
|
||||
bind:value={config.storage.uploadLocation}
|
||||
placeholder={DEFAULT_CONFIG.storage.uploadLocation}
|
||||
/>
|
||||
{@render fieldError(errors['storage.uploadLocation'])}
|
||||
</Field>
|
||||
|
||||
{#if advanced}
|
||||
<Field
|
||||
label="Custom folder locations"
|
||||
description="Mount individual subfolders on separate storage."
|
||||
>
|
||||
<Switch bind:checked={config.storage.customFolders} class="flex justify-between gap-4" />
|
||||
</Field>
|
||||
{#if config.storage.customFolders}
|
||||
<Text size="small" color="muted">Leave a field blank to keep it under the upload location.</Text>
|
||||
{#each FOLDER_OVERRIDES as folder (folder.key)}
|
||||
{@const error = errors[`storage.overrides.${folder.key}`]}
|
||||
<Field label={folder.label} invalid={!!error}>
|
||||
<Input bind:value={config.storage.overrides[folder.key]} placeholder="Optional host path" />
|
||||
{@render fieldError(error)}
|
||||
</Field>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<Stack gap={2}>
|
||||
<Label label="External libraries" size="small" />
|
||||
<Text size="small" color="muted"
|
||||
>Existing media, mounted at the same path inside the container.</Text
|
||||
>
|
||||
{#each config.storage.externalLibraries as library, index (index)}
|
||||
{@const error = errors[`storage.externalLibraries.${index}.path`]}
|
||||
{@const name = library.path.trim() || `row ${index + 1}`}
|
||||
<Field invalid={!!error}>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="grow">
|
||||
<Input
|
||||
bind:value={library.path}
|
||||
placeholder="/mnt/media/photos"
|
||||
aria-label={`External library path ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
<Field class="w-auto">
|
||||
<Checkbox bind:checked={library.readOnly} aria-label={`Read-only for ${name}`} />
|
||||
</Field>
|
||||
<Text size="small" color="muted">Read-only</Text>
|
||||
<IconButton
|
||||
icon={mdiClose}
|
||||
onclick={() => removeLibrary(index)}
|
||||
aria-label={`Remove ${name}`}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
/>
|
||||
</div>
|
||||
{@render fieldError(error)}
|
||||
</Field>
|
||||
{/each}
|
||||
{#if config.storage.externalLibraries.some(({ readOnly }) => !readOnly)}
|
||||
<Text size="small" color="muted">
|
||||
Where read-only is off, Immich can delete files and write XMP sidecars.
|
||||
</Text>
|
||||
{/if}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
leadingIcon={mdiPlus}
|
||||
class="self-start"
|
||||
onclick={addLibrary}
|
||||
>
|
||||
Add library
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Field label="Host Port" invalid={!!errors.port}>
|
||||
<NumberInput
|
||||
bind:value={config.port}
|
||||
min={1}
|
||||
max={65_535}
|
||||
placeholder={String(DEFAULT_CONFIG.port)}
|
||||
/>
|
||||
{@render fieldError(errors.port)}
|
||||
</Field>
|
||||
|
||||
<Field label="External network" description="Attach the server to a network you already created.">
|
||||
<Switch bind:checked={config.network.external} class="flex justify-between gap-4" />
|
||||
</Field>
|
||||
{#if config.network.external}
|
||||
<Field label="Network name" invalid={!!errors['network.name']}>
|
||||
<Input bind:value={config.network.name} placeholder="proxy" />
|
||||
{@render fieldError(errors['network.name'])}
|
||||
</Field>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Field label="Transcoding Hardware Acceleration">
|
||||
<Select bind:value={config.hwaccel.transcoding} options={TRANSCODE_ACCELS} />
|
||||
</Field>
|
||||
</Stack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<CardTitle>Machine Learning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Field label="Hardware Acceleration">
|
||||
<Select bind:value={config.hwaccel.ml} options={ML_ACCELS} />
|
||||
</Field>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<CardTitle>Database</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Stack gap={4}>
|
||||
{#if advanced}
|
||||
<Field label="External Postgres">
|
||||
<Switch bind:checked={config.database.external} class="flex justify-between gap-4" />
|
||||
</Field>
|
||||
{/if}
|
||||
{#if advanced && config.database.external}
|
||||
<Text size="small" color="muted">
|
||||
The VectorChord extension must be installed. See the
|
||||
<Link href="https://docs.immich.app/administration/postgres-standalone"
|
||||
>standalone Postgres guide</Link
|
||||
>.
|
||||
</Text>
|
||||
<Field label="Connection URL" invalid={!!errors['database.externalUrl']}>
|
||||
<Input
|
||||
bind:value={config.database.externalUrl}
|
||||
placeholder="postgresql://user:password@host:5432/immich"
|
||||
/>
|
||||
{@render fieldError(errors['database.externalUrl'])}
|
||||
</Field>
|
||||
{:else}
|
||||
<Field
|
||||
label="Use a named volume"
|
||||
description="Docker manages the storage location. Recommended on Windows and macOS."
|
||||
>
|
||||
<Switch
|
||||
checked={config.database.mount.type === 'volume'}
|
||||
onCheckedChange={(value) =>
|
||||
(config.database.mount = value
|
||||
? { type: 'volume' }
|
||||
: structuredClone(DEFAULT_CONFIG.database.mount))}
|
||||
class="flex justify-between gap-4"
|
||||
/>
|
||||
</Field>
|
||||
{#if config.database.mount.type === 'bind'}
|
||||
<Field label="Database Location" invalid={!!errors['database.mount.location']}>
|
||||
<Input bind:value={config.database.mount.location} placeholder={defaultDatabaseLocation} />
|
||||
{@render fieldError(errors['database.mount.location'])}
|
||||
</Field>
|
||||
{/if}
|
||||
<Field label="Database storage type">
|
||||
<Select bind:value={config.database.storageType} options={Object.values(StorageType)} />
|
||||
</Field>
|
||||
<Field label="Database Password" invalid={!!errors['database.password']}>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="grow">
|
||||
<Input bind:value={config.database.password} />
|
||||
</div>
|
||||
<IconButton
|
||||
icon={mdiDiceMultiple}
|
||||
onclick={generatePassword}
|
||||
aria-label="Generate a random password"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
/>
|
||||
</div>
|
||||
{@render fieldError(errors['database.password'])}
|
||||
</Field>
|
||||
{/if}
|
||||
</Stack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{#if advanced}
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<CardTitle>Redis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Stack gap={4}>
|
||||
<Field label="External Redis">
|
||||
<Switch bind:checked={config.redis.external} class="flex justify-between gap-4" />
|
||||
</Field>
|
||||
{#if config.redis.external}
|
||||
<Field label="Host" invalid={!!errors['redis.host']}>
|
||||
<Input bind:value={config.redis.host} placeholder="redis.example.com" />
|
||||
{@render fieldError(errors['redis.host'])}
|
||||
</Field>
|
||||
<Field label="Port" invalid={!!errors['redis.port']}>
|
||||
<NumberInput bind:value={config.redis.port} min={1} max={65_535} placeholder="6379" />
|
||||
{@render fieldError(errors['redis.port'])}
|
||||
</Field>
|
||||
<Field label="Password">
|
||||
<Input bind:value={config.redis.password} placeholder="Optional" />
|
||||
</Field>
|
||||
{/if}
|
||||
</Stack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<div class="min-w-0 self-start lg:sticky lg:top-6">
|
||||
{#if versionFailed}
|
||||
<Text color="danger">Could not reach the Immich version service. Reload the page to try again.</Text>
|
||||
{:else if version}
|
||||
<CodeBlock code={compose} language={yamlLanguage} lineNumbers copy={!hasErrors} />
|
||||
{:else}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</PageContent>
|
||||
</AppShell>
|
||||
@@ -86,7 +86,7 @@ export class ImportService {
|
||||
const slug = data.slug || slugify(title);
|
||||
const folder = `(${postType}s)/${slug}`;
|
||||
const bucketFolder = `blog/${uuid}`;
|
||||
const outputRelative = `src/routes/blog/${folder}/+page.md`;
|
||||
const outputRelative = `src/routes/(shell)/blog/${folder}/+page.md`;
|
||||
|
||||
console.log(`Importing:
|
||||
ID: ${uuid}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/Button/Button.svelte';
|
||||
import IconButton from '$lib/components/IconButton/IconButton.svelte';
|
||||
import type { ActionItem, Color, Size, Variants } from '$lib/types.js';
|
||||
import type { ActionItem, Color, Shape, Size, Variants } from '$lib/types.js';
|
||||
import { isEnabled } from '$lib/utilities/common.js';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
|
||||
type Props = {
|
||||
action: ActionItem & { data?: { title?: string } };
|
||||
color?: Color;
|
||||
shape?: Shape;
|
||||
size?: Size;
|
||||
variant?: Variants;
|
||||
type?: 'icon' | 'button';
|
||||
};
|
||||
|
||||
const { action, type = 'icon', size, color: colorOverride, variant: variantOverride }: Props = $props();
|
||||
const { action, type = 'icon', shape, size, color: colorOverride, variant: variantOverride }: Props = $props();
|
||||
const { title, icon, onAction } = $derived(action);
|
||||
const common = $derived({
|
||||
variant: variantOverride ?? 'ghost',
|
||||
@@ -24,9 +25,15 @@
|
||||
|
||||
{#if isEnabled(action)}
|
||||
{#if type === 'icon'}
|
||||
<IconButton {...common} {size} shape="round" icon={icon ?? mdiPlus} aria-label={title} />
|
||||
<IconButton {...common} {size} shape={shape ?? 'round'} icon={icon ?? mdiPlus} aria-label={title} />
|
||||
{:else if type === 'button'}
|
||||
<Button {...common} size={size ?? 'small'} leadingIcon={icon} title={action.data?.title}>
|
||||
<Button
|
||||
{...common}
|
||||
shape={shape ?? 'semi-round'}
|
||||
size={size ?? 'small'}
|
||||
leadingIcon={icon}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
Generated
+17
@@ -543,6 +543,9 @@ importers:
|
||||
'@mdi/js':
|
||||
specifier: ^7.4.47
|
||||
version: 7.4.47
|
||||
deepmerge:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1
|
||||
feed:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -558,10 +561,19 @@ importers:
|
||||
tailwind-variants:
|
||||
specifier: ^3.2.2
|
||||
version: 3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.3)
|
||||
yaml:
|
||||
specifier: ^2.9.0
|
||||
version: 2.9.0
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@immich/svelte-markdown-preprocess':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/svelte-markdown-preprocess
|
||||
'@json-types/compose':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@sveltejs/adapter-static':
|
||||
specifier: ^3.0.9
|
||||
version: 3.0.10(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))
|
||||
@@ -2019,6 +2031,9 @@ packages:
|
||||
'@js-sdsl/ordered-map@4.4.2':
|
||||
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
|
||||
|
||||
'@json-types/compose@0.11.0':
|
||||
resolution: {integrity: sha512-O5X3sIGVWz2T5W4rEp9C16iBlEeSWEhQ+tpn/uPnSvf6pRC0eGc+YqJVVKtnuO3EgDAi+tIPVoxp8tUFkE1dmg==}
|
||||
|
||||
'@kurkle/color@0.3.4':
|
||||
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||
|
||||
@@ -6249,6 +6264,8 @@ snapshots:
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2': {}
|
||||
|
||||
'@json-types/compose@0.11.0': {}
|
||||
|
||||
'@kurkle/color@0.3.4': {}
|
||||
|
||||
'@kwsites/file-exists@1.1.1(supports-color@10.2.2)':
|
||||
|
||||
Reference in New Issue
Block a user