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:
bo0tzz
2026-09-01 12:54:06 -04:00
committed by GitHub
co-authored by Jason Rasmussen
parent 6ac8f37a22
commit abdd8f458f
62 changed files with 1772 additions and 136 deletions
+6 -2
View File
@@ -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);
});
});
+16 -10
View File
@@ -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>
@@ -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>
</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}
+17
View File
@@ -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)':