chore(release): v0.10.0 (#400)

Photo Essay mode, client proofing, studio-modern preset, admin panel overhaul, watermark, cookieless analytics, subpage toggle, SEO title templates. Analytics API now admin-only, Trivy scan on publish. Album order mirrors the Immich timeline; map theme, admin status panel, lightbox counter and Docker health check fixed.

See CHANGELOG.md for the full list and the health-check upgrade note.
This commit is contained in:
Ralksta
2026-08-10 09:56:20 +02:00
committed by GitHub
parent c9036fdc94
commit 2318409e75
78 changed files with 3848 additions and 553 deletions

View File

@@ -32,6 +32,8 @@ export interface ImmichAsset {
originalMimeType: string;
thumbhash: string | null;
fileCreatedAt: string;
/** Capture time in the photographer's local zone — Immich's timeline sort key. */
localDateTime?: string;
exifInfo?: ImmichExifInfo;
isTrashed: boolean;
}
@@ -362,10 +364,14 @@ class ImmichClient {
* Get ALL configured albums (filtered by the full allowlist).
* Uses ?shared=true to only fetch albums that have been shared in Immich.
*/
async getAlbums(): Promise<ImmichAlbum[]> {
async getAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
const cacheKey = 'albums-list';
const cached = cache.get<ImmichAlbum[]>(cacheKey);
if (cached) return cached;
if (forceFresh) {
cache.delete(cacheKey);
} else {
const cached = cache.get<ImmichAlbum[]>(cacheKey);
if (cached) return cached;
}
if (this.pendingAlbumsPromise) {
return this.pendingAlbumsPromise;
@@ -439,8 +445,8 @@ class ImmichClient {
/**
* Get only standalone albums (not in any subpage) — for the homepage.
*/
async getStandaloneAlbums(): Promise<ImmichAlbum[]> {
const albums = await this.getAlbums();
async getStandaloneAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
const albums = await this.getAlbums(forceFresh);
// The API returns albums in its own order; gallery.yaml order wins.
const orderIdx = new Map(this.config.standaloneAlbums.map((id, i) => [id, i]));
return albums
@@ -451,11 +457,11 @@ class ImmichClient {
/**
* Get enriched subpage summaries for the homepage.
*/
async getSubpages(): Promise<SubpageSummary[]> {
async getSubpages(forceFresh = false): Promise<SubpageSummary[]> {
const activeSubpages = this.config.subpages.filter((sp) => sp.enabled !== false);
if (activeSubpages.length === 0) return [];
const albums = await this.getAlbums();
const albums = await this.getAlbums(forceFresh);
const albumMap = new Map(albums.map((a) => [a.id, a]));
return activeSubpages.map((sp) => {
@@ -478,13 +484,14 @@ class ImmichClient {
*/
async getSubpageAlbums(
subpageSlug: string,
forceFresh = false,
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
const subpage = this.config.subpages.find(
(sp) => sp.slug === subpageSlug && sp.enabled !== false,
);
if (!subpage) return null;
const allAlbums = await this.getAlbums();
const allAlbums = await this.getAlbums(forceFresh);
const subpageAlbumIds = new Set(subpage.albumIds);
const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id));
@@ -537,7 +544,7 @@ class ImmichClient {
return assets;
}
async getAlbum(albumId: string): Promise<ImmichAlbum | null> {
async getAlbum(albumId: string, forceFresh = false): Promise<ImmichAlbum | null> {
// Security: only serve configured albums
if (!this.config.albums.includes(albumId)) {
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
@@ -545,8 +552,12 @@ class ImmichClient {
}
const cacheKey = `album-${albumId}`;
const cached = cache.get<ImmichAlbum | Missing>(cacheKey);
if (cached) return cached === MISSING ? null : (cached as ImmichAlbum);
if (forceFresh) {
cache.delete(cacheKey);
} else {
const cached = cache.get<ImmichAlbum | Missing>(cacheKey);
if (cached) return cached === MISSING ? null : (cached as ImmichAlbum);
}
if (this.pendingAlbumPromises.has(albumId)) {
return this.pendingAlbumPromises.get(albumId)!;
@@ -581,9 +592,25 @@ class ImmichClient {
// The album endpoint used to return assets in the album's configured
// order. Metadata search happens to default to fileCreatedAt desc, but
// that is not contractual — sort explicitly so 'asc' albums are right.
//
// Mirrors Immich's own timeline query: primary key `localDateTime`
// (capture time in the photographer's local zone), tie-broken by
// `fileCreatedAt` (the same instant in UTC). The two only diverge for
// albums spanning time zones, and there local time is what the Immich
// UI shows. Sorting on `exifInfo.dateTimeOriginal` instead would be
// near-pointless: Immich already derives `fileCreatedAt` from that
// same EXIF chain, and it is null for assets without EXIF.
//
// Compare parsed instants, not strings — string order breaks on
// fractional seconds ('.' collates before '+'), which misorders bursts
// where only some frames carry sub-second precision. Unparseable dates
// fall back to 0 rather than NaN, which would make sort() incoherent.
const dir = album.order === 'asc' ? 1 : -1;
const ts = (value: string | undefined) => Date.parse(value ?? '') || 0;
album.assets.sort(
(a, b) => dir * (Date.parse(a.fileCreatedAt) - Date.parse(b.fileCreatedAt)),
(a, b) =>
dir * (ts(a.localDateTime) - ts(b.localDateTime)) ||
dir * (ts(a.fileCreatedAt) - ts(b.fileCreatedAt)),
);
const name = this.config.albumOverrides[album.id] ?? album.albumName;
@@ -609,8 +636,12 @@ class ImmichClient {
* Find an album by its URL slug.
* If subpageSlug is provided, only search within that subpage's albums.
*/
async getAlbumBySlug(slug: string, subpageSlug?: string): Promise<ImmichAlbum | null> {
const albums = await this.getAlbums();
async getAlbumBySlug(
slug: string,
subpageSlug?: string,
forceFresh = false,
): Promise<ImmichAlbum | null> {
const albums = await this.getAlbums(forceFresh);
let searchSet = albums;
if (subpageSlug) {
@@ -622,7 +653,7 @@ class ImmichClient {
const match = searchSet.find((a) => a.slug === slug);
if (!match) return null;
return this.getAlbum(match.id);
return this.getAlbum(match.id, forceFresh);
}
/**