release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -8,6 +8,7 @@ import { env } from './env';
import { cdnBase } from './cdn';
import { thumbHashToBlurDataUrl, thumbHashToDominantHex } from './thumbhash';
import type { ImmichAsset } from './immich';
import { formatLens } from './exif';
/**
* Optional cache-buster, appended to every image and video URL.
@@ -22,15 +23,56 @@ import type { ImmichAsset } from './immich';
*/
const cacheBuster = env.IMAGE_CACHE_VERSION ? `&v=${env.IMAGE_CACHE_VERSION}` : '';
/** What an image URL needs to know about an asset beyond its ID (#831). */
export interface ImageRef {
id: string;
isEdited?: boolean | null;
updatedAt?: string | null;
}
/**
* The `e` parameter an edited photo's image URL carries, or null for a photo
* that is not edited (#831).
*
* Immich applies an edit made in its editor (crop, rotate) only when asked
* with `edited=true`. The image route always asks — the server decides that
* an edit is shown, not the URL. The marker is purely a cache key: the asset
* ID does not change when a photo is edited, so without it a browser or CDN
* that cached the unedited preview under the same URL would keep showing it
* for a year (`immutable`). Only edited photos get a new URL; every other URL
* stays as it was, which is why this is not a bump of IMAGE_CACHE_VERSION.
*
* The value is the asset's `updatedAt` in whole seconds, base 36 — precise
* enough to tell two edits apart, without publishing the millisecond. Immich regenerates an
* asset's thumbnails and ThumbHash (and, for a crop or a quarter turn, its
* dimensions) when an edit is applied or changed, which writes the asset and
* moves `updatedAt` — so a re-edit produces a new URL once the album cache
* (CACHE_TTL) has picked it up. Other updates to an edited photo move it too;
* that costs one refetch of that photo, nothing more.
*/
export function editMarker(asset: Omit<ImageRef, 'id'>): string | null {
if (asset.isEdited !== true) return null;
const time = asset.updatedAt ? Date.parse(asset.updatedAt) : NaN;
return Number.isFinite(time) && time >= 1000 ? Math.floor(time / 1000).toString(36) : '1';
}
/**
* Generate a public image proxy URL for an asset. Absolute, on the CDN, when
* CDN mode is on (lib/cdn.ts); relative otherwise.
*
* Pass the asset rather than its ID wherever it is at hand: only then can an
* edited photo get its edit marker (see editMarker()). A bare ID still shows
* the edit — the route always asks for it — but under the same URL as before
* the edit, so a copy cached earlier can outlive it.
*/
export function imageUrl(
assetId: string,
asset: string | ImageRef,
size: 'thumbnail' | 'preview' | 'original' = 'preview',
): string {
return `${cdnBase()}/api/image/${encodeAssetId(assetId)}?size=${size}${cacheBuster}`;
const ref = typeof asset === 'string' ? { id: asset } : asset;
const marker = editMarker(ref);
const edit = marker ? `&e=${marker}` : '';
return `${cdnBase()}/api/image/${encodeAssetId(ref.id)}?size=${size}${edit}${cacheBuster}`;
}
/**
@@ -92,7 +134,8 @@ export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSumm
if (!exif) return undefined;
const camera = exif.model || undefined;
const lens = exif.lensModel || undefined;
// A phone's lens names the phone again; the camera is already on the tile.
const lens = formatLens(exif.lensModel, exif.model) || undefined;
const focalLength = exif.focalLength ? `${exif.focalLength}mm` : undefined;
if (!camera && !lens && !focalLength) return undefined;
@@ -110,6 +153,19 @@ export function downloadUrl(albumId: string, assetId: string): string {
return `/api/download/${encodeAssetId(albumId)}/${encodeAssetId(assetId)}`;
}
/**
* The full-resolution image the lightbox zooms into (#467).
*
* Shaped like downloadUrl() for the same reason: the route authorises the
* zoom against the album that offered it — allowlist, every password gate,
* zoom resolved on — and checks the asset belongs to it. Relative even in CDN
* mode: the answer depends on the visitor's cookies and is `private`, so it
* must not be cached at the edge.
*/
export function zoomUrl(albumId: string, assetId: string): string {
return `/api/zoom/${encodeAssetId(albumId)}/${encodeAssetId(assetId)}`;
}
/**
* The endpoint that streams an album (or a selection of its assets) as a ZIP.
*
@@ -160,8 +216,22 @@ const ROTATED_ORIENTATIONS = new Set([5, 6, 7, 8]);
* Compute the natural aspect ratio (width / height) from EXIF dimensions,
* as the image is displayed rather than as it is stored.
* Returns undefined if dimensions are not available.
*
* A photo edited in Immich (#831) is the exception: its EXIF still describes
* the file as stored, before a crop or a quarter turn, while the edited
* rendition Folio shows has other proportions. Immich's top-level
* `width`/`height` are the edited, upright size, so those are used for it.
*/
export function assetAspectRatio(asset: Pick<ImmichAsset, 'exifInfo'>): number | undefined {
export function assetAspectRatio(
asset: Pick<ImmichAsset, 'exifInfo' | 'isEdited' | 'width' | 'height'>,
): number | undefined {
if (asset.isEdited === true) {
// Without the edited size there is nothing right to say: EXIF would give
// the unedited proportions. Undefined leaves the layout's default ratio.
return asset.width && asset.height && asset.width > 0 && asset.height > 0
? asset.width / asset.height
: undefined;
}
const w = asset.exifInfo?.exifImageWidth;
const h = asset.exifInfo?.exifImageHeight;
if (!w || !h || h <= 0) return undefined;