release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -16,7 +16,8 @@ vi.mock('../config', async () => {
const actual = await vi.importActual<typeof config>('../config');
return {
...actual,
getConfig: () => ({
// A spy, so the tests below can count how often the client reads it.
getConfig: vi.fn(() => ({
immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' },
authSecret: 'test-auth-secret-32-chars-long-min',
albums: ['album-1', 'album-2'],
@@ -34,7 +35,7 @@ vi.mock('../config', async () => {
cacheTtl: 60_000,
staleMaxAge: 86_400_000,
immichTimeoutMs: 15000,
}),
})),
};
});
@@ -567,6 +568,59 @@ describe('ImmichClient', () => {
});
});
/**
* getConfig() re-checks the content files and clones the whole config on
* every call, and the client used to call it once per property it read —
* four times per streamed image, twice per album on a list refresh.
*/
describe('reads the config once per operation', () => {
const configReads = () => vi.mocked(config.getConfig).mock.calls.length;
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
it('streamAsset(): once per image', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
body: new ReadableStream(),
headers: { get: () => 'image/jpeg' },
});
await immich.streamAsset('asset-1', 'preview');
expect(configReads()).toBe(1);
});
it('getAlbums(): twice on a refresh, however many albums there are', async () => {
const album = (id: string) => ({ id, albumName: id, assetCount: 1, assets: [] });
mockFetch.mockResolvedValueOnce(json([album('album-1'), album('album-2')]));
expect(await immich.getAlbums()).toHaveLength(2);
// One for the request, one for filtering, naming and caching the list.
expect(configReads()).toBe(2);
});
it('getAlbum(): twice on a cache hit', async () => {
mockFetch.mockImplementation(async (url: string) =>
json(
url.includes('/search/metadata')
? { assets: { items: [], nextPage: null } }
: { id: 'album-1', albumName: 'A', assetCount: 0, assets: [], order: 'desc' },
),
);
await immich.getAlbum('album-1');
vi.mocked(config.getConfig).mockClear();
await immich.getAlbum('album-1');
// The allowlist check, then the configured sort.
expect(configReads()).toBe(2);
});
});
describe('getAlbum()', () => {
it('returns null for unallowed album IDs', async () => {
const album = await immich.getAlbum('album-3');
@@ -821,6 +875,85 @@ describe('ImmichClient', () => {
});
});
});
/**
* Album blocks in journal entries, content pages and essay files are
* rendered from getAlbumAssetsRaw(), and so is the published-asset set. It
* used to go to Immich on every call — two requests per album per page view,
* and again on every set rebuild — with no cache and no coalescing.
*/
describe('getAlbumAssetsRaw() request count', () => {
function mockRawAlbumApi(albumId: string) {
mockFetch.mockImplementation(async (url: string) => {
const body = url.includes('/search/metadata')
? {
assets: {
items: [
{ id: 'old', isTrashed: false, fileCreatedAt: '2024-01-01T00:00:00Z' },
{ id: 'gone', isTrashed: true, fileCreatedAt: '2024-01-02T00:00:00Z' },
{ id: 'new', isTrashed: false, fileCreatedAt: '2024-01-03T00:00:00Z' },
],
nextPage: null,
},
}
: { id: albumId, albumName: 'Raw', assetCount: 3, assets: [], order: 'desc' };
return { ok: true, headers: { get: () => 'application/json' }, json: async () => body };
});
}
it('asks Immich once for many concurrent callers and serves repeats from the cache', async () => {
mockRawAlbumApi('unlisted');
const lists = await Promise.all(
Array.from({ length: 10 }, () => immich.getAlbumAssetsRaw('unlisted')),
);
// One album request plus one metadata page — not 2 × 10.
expect(mockFetch).toHaveBeenCalledTimes(2);
await immich.getAlbumAssetsRaw('unlisted');
expect(mockFetch).toHaveBeenCalledTimes(2);
// Unchanged result: trashed dropped, capture-time order of a 'desc' album.
for (const list of lists) expect(list.map((a) => a.id)).toEqual(['new', 'old']);
});
it('shares the load with getAlbum() for an allowlisted album', async () => {
mockRawAlbumApi('album-1');
await immich.getAlbum('album-1');
const raw = await immich.getAlbumAssetsRaw('album-1');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(raw.map((a) => a.id)).toEqual(['new', 'old']);
});
it('hands each caller its own array, so no one reorders the cached entry', async () => {
mockRawAlbumApi('unlisted');
const first = await immich.getAlbumAssetsRaw('unlisted');
first.reverse();
expect((await immich.getAlbumAssetsRaw('unlisted')).map((a) => a.id)).toEqual(['new', 'old']);
});
it('goes back to Immich when the admin editors ask for a fresh copy', async () => {
mockRawAlbumApi('unlisted');
await immich.getAlbumAssetsRaw('unlisted');
await immich.getAlbumAssetsRaw('unlisted', true);
expect(mockFetch).toHaveBeenCalledTimes(4);
});
it('still does not open getAlbum() to an album outside the allowlist', async () => {
mockRawAlbumApi('unlisted');
vi.spyOn(console, 'warn').mockImplementation(() => {});
await immich.getAlbumAssetsRaw('unlisted');
expect(await immich.getAlbum('unlisted')).toBeNull();
});
});
});
describe('stale fallback when Immich is unavailable', () => {