Merge with dependent PRs

This commit is contained in:
midzelis
2026-01-15 21:46:36 +00:00
54 changed files with 552 additions and 327 deletions

View File

@@ -1691,7 +1691,7 @@ class AssetsApi {
/// View asset thumbnail
///
/// Retrieve the thumbnail image for the specified asset.
/// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.
///
/// Note: This method returns the HTTP [Response].
///
@@ -1747,7 +1747,7 @@ class AssetsApi {
/// View asset thumbnail
///
/// Retrieve the thumbnail image for the specified asset.
/// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.
///
/// Parameters:
///

View File

@@ -23,12 +23,14 @@ class AssetMediaSize {
String toJson() => value;
static const original = AssetMediaSize._(r'original');
static const fullsize = AssetMediaSize._(r'fullsize');
static const preview = AssetMediaSize._(r'preview');
static const thumbnail = AssetMediaSize._(r'thumbnail');
/// List of all possible values in this [enum][AssetMediaSize].
static const values = <AssetMediaSize>[
original,
fullsize,
preview,
thumbnail,
@@ -70,6 +72,7 @@ class AssetMediaSizeTypeTransformer {
AssetMediaSize? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'original': return AssetMediaSize.original;
case r'fullsize': return AssetMediaSize.fullsize;
case r'preview': return AssetMediaSize.preview;
case r'thumbnail': return AssetMediaSize.thumbnail;

View File

@@ -72,6 +72,7 @@ class Permission {
static const facePeriodRead = Permission._(r'face.read');
static const facePeriodUpdate = Permission._(r'face.update');
static const facePeriodDelete = Permission._(r'face.delete');
static const folderPeriodRead = Permission._(r'folder.read');
static const jobPeriodCreate = Permission._(r'job.create');
static const jobPeriodRead = Permission._(r'job.read');
static const libraryPeriodCreate = Permission._(r'library.create');
@@ -230,6 +231,7 @@ class Permission {
facePeriodRead,
facePeriodUpdate,
facePeriodDelete,
folderPeriodRead,
jobPeriodCreate,
jobPeriodRead,
libraryPeriodCreate,
@@ -423,6 +425,7 @@ class PermissionTypeTransformer {
case r'face.read': return Permission.facePeriodRead;
case r'face.update': return Permission.facePeriodUpdate;
case r'face.delete': return Permission.facePeriodDelete;
case r'folder.read': return Permission.folderPeriodRead;
case r'job.create': return Permission.jobPeriodCreate;
case r'job.read': return Permission.jobPeriodRead;
case r'library.create': return Permission.libraryPeriodCreate;

View File

@@ -27,7 +27,7 @@ function dart {
}
function typescript {
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
pnpm --filter @immich/sdk install --frozen-lockfile
pnpm --filter @immich/sdk build
}

View File

@@ -3173,6 +3173,7 @@
"state": "Stable"
}
],
"x-immich-permission": "asset.upload",
"x-immich-state": "Stable"
}
},
@@ -3225,6 +3226,7 @@
"state": "Stable"
}
],
"x-immich-permission": "job.create",
"x-immich-state": "Stable"
}
},
@@ -4277,7 +4279,7 @@
},
"/assets/{id}/thumbnail": {
"get": {
"description": "Retrieve the thumbnail image for the specified asset.",
"description": "Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.",
"operationId": "viewAsset",
"parameters": [
{
@@ -14618,6 +14620,7 @@
"state": "Stable"
}
],
"x-immich-permission": "folder.read",
"x-immich-state": "Stable"
}
},
@@ -14670,6 +14673,7 @@
"state": "Stable"
}
],
"x-immich-permission": "folder.read",
"x-immich-state": "Stable"
}
},
@@ -16301,6 +16305,7 @@
},
"AssetMediaSize": {
"enum": [
"original",
"fullsize",
"preview",
"thumbnail"
@@ -18958,6 +18963,7 @@
"face.read",
"face.update",
"face.delete",
"folder.read",
"job.create",
"job.read",
"library.create",

View File

@@ -1875,6 +1875,210 @@ export type WorkflowUpdateDto = {
name?: string;
triggerType?: PluginTriggerType;
};
export type SyncAckV1 = {};
export type SyncAlbumDeleteV1 = {
albumId: string;
};
export type SyncAlbumToAssetDeleteV1 = {
albumId: string;
assetId: string;
};
export type SyncAlbumToAssetV1 = {
albumId: string;
assetId: string;
};
export type SyncAlbumUserDeleteV1 = {
albumId: string;
userId: string;
};
export type SyncAlbumUserV1 = {
albumId: string;
role: AlbumUserRole;
userId: string;
};
export type SyncAlbumV1 = {
createdAt: string;
description: string;
id: string;
isActivityEnabled: boolean;
name: string;
order: AssetOrder;
ownerId: string;
thumbnailAssetId: string | null;
updatedAt: string;
};
export type SyncAssetDeleteV1 = {
assetId: string;
};
export type SyncAssetExifV1 = {
assetId: string;
city: string | null;
country: string | null;
dateTimeOriginal: string | null;
description: string | null;
exifImageHeight: number | null;
exifImageWidth: number | null;
exposureTime: string | null;
fNumber: number | null;
fileSizeInByte: number | null;
focalLength: number | null;
fps: number | null;
iso: number | null;
latitude: number | null;
lensModel: string | null;
longitude: number | null;
make: string | null;
model: string | null;
modifyDate: string | null;
orientation: string | null;
profileDescription: string | null;
projectionType: string | null;
rating: number | null;
state: string | null;
timeZone: string | null;
};
export type SyncAssetFaceDeleteV1 = {
assetFaceId: string;
};
export type SyncAssetFaceV1 = {
assetId: string;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
id: string;
imageHeight: number;
imageWidth: number;
personId: string | null;
sourceType: string;
};
export type SyncAssetMetadataDeleteV1 = {
assetId: string;
key: string;
};
export type SyncAssetMetadataV1 = {
assetId: string;
key: string;
value: object;
};
export type SyncAssetV1 = {
checksum: string;
deletedAt: string | null;
duration: string | null;
fileCreatedAt: string | null;
fileModifiedAt: string | null;
height: number | null;
id: string;
isEdited: boolean;
isFavorite: boolean;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: string | null;
originalFileName: string;
ownerId: string;
stackId: string | null;
thumbhash: string | null;
"type": AssetTypeEnum;
visibility: AssetVisibility;
width: number | null;
};
export type SyncAuthUserV1 = {
avatarColor: (UserAvatarColor) | null;
deletedAt: string | null;
email: string;
hasProfileImage: boolean;
id: string;
isAdmin: boolean;
name: string;
oauthId: string;
pinCode: string | null;
profileChangedAt: string;
quotaSizeInBytes: number | null;
quotaUsageInBytes: number;
storageLabel: string | null;
};
export type SyncCompleteV1 = {};
export type SyncMemoryAssetDeleteV1 = {
assetId: string;
memoryId: string;
};
export type SyncMemoryAssetV1 = {
assetId: string;
memoryId: string;
};
export type SyncMemoryDeleteV1 = {
memoryId: string;
};
export type SyncMemoryV1 = {
createdAt: string;
data: object;
deletedAt: string | null;
hideAt: string | null;
id: string;
isSaved: boolean;
memoryAt: string;
ownerId: string;
seenAt: string | null;
showAt: string | null;
"type": MemoryType;
updatedAt: string;
};
export type SyncPartnerDeleteV1 = {
sharedById: string;
sharedWithId: string;
};
export type SyncPartnerV1 = {
inTimeline: boolean;
sharedById: string;
sharedWithId: string;
};
export type SyncPersonDeleteV1 = {
personId: string;
};
export type SyncPersonV1 = {
birthDate: string | null;
color: string | null;
createdAt: string;
faceAssetId: string | null;
id: string;
isFavorite: boolean;
isHidden: boolean;
name: string;
ownerId: string;
updatedAt: string;
};
export type SyncResetV1 = {};
export type SyncStackDeleteV1 = {
stackId: string;
};
export type SyncStackV1 = {
createdAt: string;
id: string;
ownerId: string;
primaryAssetId: string;
updatedAt: string;
};
export type SyncUserDeleteV1 = {
userId: string;
};
export type SyncUserMetadataDeleteV1 = {
key: UserMetadataKey;
userId: string;
};
export type SyncUserMetadataV1 = {
key: UserMetadataKey;
userId: string;
value: object;
};
export type SyncUserV1 = {
avatarColor: (UserAvatarColor) | null;
deletedAt: string | null;
email: string;
hasProfileImage: boolean;
id: string;
name: string;
profileChangedAt: string;
};
/**
* List all activities
*/
@@ -5524,6 +5728,7 @@ export enum Permission {
FaceRead = "face.read",
FaceUpdate = "face.update",
FaceDelete = "face.delete",
FolderRead = "folder.read",
JobCreate = "job.create",
JobRead = "job.read",
LibraryCreate = "library.create",
@@ -5660,6 +5865,7 @@ export enum MirrorAxis {
Vertical = "vertical"
}
export enum AssetMediaSize {
Original = "original",
Fullsize = "fullsize",
Preview = "preview",
Thumbnail = "thumbnail"
@@ -5937,3 +6143,8 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
}
export enum UserMetadataKey {
Preferences = "preferences",
License = "license",
Onboarding = "onboarding"
}

View File

@@ -147,7 +147,8 @@ export class AssetMediaController {
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
@Endpoint({
summary: 'View asset thumbnail',
description: 'Retrieve the thumbnail image for the specified asset.',
description:
'Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async viewAsset(
@@ -202,7 +203,7 @@ export class AssetMediaController {
}
@Post('exist')
@Authenticated()
@Authenticated({ permission: Permission.AssetUpload })
@Endpoint({
summary: 'Check existing assets',
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',

View File

@@ -66,7 +66,7 @@ export class AssetController {
}
@Post('jobs')
@Authenticated()
@Authenticated({ permission: Permission.JobCreate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Run an asset job',

View File

@@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiTag } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { ViewService } from 'src/services/view.service';
@@ -13,7 +13,7 @@ export class ViewController {
constructor(private service: ViewService) {}
@Get('folder/unique-paths')
@Authenticated()
@Authenticated({ permission: Permission.FolderRead })
@Endpoint({
summary: 'Retrieve unique paths',
description: 'Retrieve a list of unique folder paths from asset original paths.',
@@ -24,7 +24,7 @@ export class ViewController {
}
@Get('folder')
@Authenticated()
@Authenticated({ permission: Permission.FolderRead })
@Endpoint({
summary: 'Retrieve assets by original path',
description: 'Retrieve assets that are children of a specific folder.',

View File

@@ -7,6 +7,7 @@ import { AssetVisibility } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
export enum AssetMediaSize {
Original = 'original',
/**
* An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF.
* or otherwise the original image itself.

View File

@@ -146,6 +146,8 @@ export enum Permission {
FaceUpdate = 'face.update',
FaceDelete = 'face.delete',
FolderRead = 'folder.read',
JobCreate = 'job.create',
JobRead = 'job.read',

View File

@@ -1,5 +1,5 @@
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { albumFactory } from '@test-data/factories/album-factory';
import { render } from '@testing-library/svelte';
@@ -7,7 +7,7 @@ vi.mock('$lib/utils');
describe('AlbumCover component', () => {
it('renders an image when the album has a thumbnail', () => {
vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf');
vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf');
const component = render(AlbumCover, {
album: albumFactory.build({
albumName: 'someName',
@@ -21,7 +21,7 @@ describe('AlbumCover component', () => {
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
expect(img.getAttribute('src')).toBe('/asdf');
expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' });
expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: '123' });
});
it('renders an image when the album has no thumbnail', () => {

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { getAssetThumbnailUrl } from '$lib/utils';
import { type AlbumResponseDto } from '@immich/sdk';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { type AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
interface Props {
@@ -15,7 +15,7 @@
let alt = $derived(album.albumName || $t('unnamed_album'));
let thumbnailUrl = $derived(
album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null,
album.albumThumbnailAssetId ? getAssetMediaUrl({ id: album.albumThumbnailAssetId }) : null,
);
</script>

View File

@@ -7,7 +7,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
@@ -142,7 +142,7 @@
<a class="aspect-square w-19 h-19" href={Route.viewAlbumAsset({ albumId, assetId: reaction.assetId })}>
<img
class="rounded-lg w-19 h-19 object-cover"
src={getAssetThumbnailUrl(reaction.assetId)}
src={getAssetMediaUrl({ id: reaction.assetId })}
alt="Profile picture of {reaction.user.name}, who commented on this asset"
/>
</a>
@@ -195,7 +195,7 @@
>
<img
class="rounded-lg w-19 h-19 object-cover"
src={getAssetThumbnailUrl(reaction.assetId)}
src={getAssetMediaUrl({ id: reaction.assetId })}
alt="Profile picture of {reaction.user.name}, who liked this asset"
/>
</a>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
import { type AlbumResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
@@ -134,7 +134,7 @@
<span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
src={getAssetMediaUrl({ id: album.albumThumbnailAssetId })}
alt={album.albumName}
class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
data-testid="album-image"

View File

@@ -12,20 +12,19 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import { getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { preloadImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
@@ -134,7 +133,7 @@
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
imageManager.preload(stack.assets[1]);
}
});
};
@@ -220,7 +219,7 @@
}
e?.stopPropagation();
preloadManager.cancel(asset);
imageManager.cancel(asset);
if (tracker.isActive()) {
return;
}
@@ -380,8 +379,8 @@
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => handlePromiseError(refresh()));
preloadManager.preload(cursor.nextAsset);
preloadManager.preload(cursor.previousAsset);
imageManager.preload(cursor.nextAsset);
imageManager.preload(cursor.previousAsset);
});
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
@@ -503,6 +502,7 @@
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
asset={previewStackedAsset!}
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
@@ -516,6 +516,7 @@
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{asset}
assetId={asset.livePhotoVideoId!}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
@@ -541,7 +542,7 @@
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
assetId={asset.id}
{asset}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}

View File

@@ -14,7 +14,7 @@
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
@@ -515,7 +515,7 @@
alt={album.albumName}
class="h-12.5 w-12.5 rounded object-cover"
src={album.albumThumbnailAssetId &&
getAssetThumbnailUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
@@ -14,7 +14,7 @@
let canvasContainer = $state<HTMLElement | null>(null);
let imageSrc = $derived(
getAssetThumbnailUrl({ id: asset.id, cacheKey: asset.thumbhash, edited: false, size: AssetMediaSize.Preview }),
getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, edited: false, size: AssetMediaSize.Preview }),
);
let imageTransform = $derived.by(() => {

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAssetOriginalUrl, getAssetThumbnailUrl } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getAssetUrl } from '$lib/utils';
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
@@ -24,13 +23,7 @@
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
bind:zoomToggle
panorama={data}
originalPanorama={isWebCompatibleImage(asset)
? getAssetOriginalUrl(asset.id)
: getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash })}
/>
<PhotoSphereViewer bind:zoomToggle panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}

View File

@@ -6,7 +6,7 @@
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
@@ -164,7 +164,7 @@
imageError = imageLoaded = true;
};
onDestroy(() => preloadManager.cancelPreloadUrl(imageLoaderUrl));
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),

View File

@@ -10,7 +10,7 @@
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
@@ -44,7 +44,9 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $derived(
playOriginalVideo ? getAssetOriginalUrl({ id: assetId, cacheKey }) : getAssetPlaybackUrl({ id: assetId, cacheKey }),
playOriginalVideo
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let isScrubbing = $state(false);
let showVideo = $state(false);
@@ -127,7 +129,7 @@
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
<VideoRemoteViewer
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
@@ -154,7 +156,7 @@
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>

View File

@@ -1,14 +1,15 @@
<script lang="ts">
import { getAssetOriginalUrl, getAssetPlaybackUrl } from '$lib/utils';
import { getAssetPlaybackUrl, getAssetUrl } from '$lib/utils';
import type { AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
assetId: string;
asset: AssetResponseDto;
}
const { assetId }: Props = $props();
const { asset }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
@@ -23,8 +24,8 @@
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer
panorama={{ source: getAssetPlaybackUrl(assetId) }}
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
plugins={[videoPlugin]}
{adapter}
navbar

View File

@@ -2,9 +2,11 @@
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import type { AssetResponseDto } from '@immich/sdk';
interface Props {
assetId: string;
asset: AssetResponseDto;
assetId?: string;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
@@ -17,6 +19,7 @@
}
let {
asset,
assetId,
projectionType,
cacheKey,
@@ -28,15 +31,17 @@
onVideoEnded,
onVideoStarted,
}: Props = $props();
const effectiveAssetId = $derived(assetId ?? asset.id);
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {assetId} />
<VideoPanoramaViewer {asset} />
{:else}
<VideoNativeViewer
{loopVideo}
{cacheKey}
{assetId}
assetId={effectiveAssetId}
{playOriginalVideo}
{onPreviousAsset}
{onNextAsset}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
@@ -60,7 +60,7 @@
onComplete?.(false);
}
return {
destroy: () => preloadManager.cancelPreloadUrl(url),
destroy: () => imageManager.cancelPreloadUrl(url),
};
}

View File

@@ -5,7 +5,7 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time';
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
@@ -333,7 +333,7 @@
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
@@ -369,7 +369,7 @@
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
url={getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash })}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { assetViewerFadeDuration } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
@@ -35,7 +35,7 @@
};
});
const imageLoaderUrl = $derived(getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview }));
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
</script>
{#if !imageLoaded}

View File

@@ -2,7 +2,7 @@
import { assetViewerFadeDuration } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { autoPlayVideo } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
@@ -32,7 +32,7 @@
playsinline
class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetPlaybackUrl({ id: asset.id })}
poster={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview })}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
draggable="false"
muted={videoViewerMuted}
volume={videoViewerVolume}

View File

@@ -30,7 +30,7 @@
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
@@ -449,7 +449,7 @@
{#if current.previousMemory && current.previousMemory.assets.length > 0}
<img
class="h-full w-full rounded-2xl object-cover"
src={getAssetThumbnailUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('previous_memory')}
draggable="false"
/>
@@ -598,7 +598,7 @@
{#if current.nextMemory && current.nextMemory.assets.length > 0}
<img
class="h-full w-full rounded-2xl object-cover"
src={getAssetThumbnailUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('next_memory')}
draggable="false"
/>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Route } from '$lib/route';
import { placesViewSettings } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { type PlacesGroup, isPlacesGroupCollapsed, togglePlacesGroupCollapsing } from '$lib/utils/places-utils';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
@@ -45,7 +45,7 @@
class="flex w-[calc((100vw-(72px+5rem))/2)] max-w-39 justify-center overflow-hidden rounded-xl brightness-75 filter"
>
<img
src={getAssetThumbnailUrl({ id: item.id, size: AssetMediaSize.Thumbnail })}
src={getAssetMediaUrl({ id: item.id, size: AssetMediaSize.Thumbnail })}
alt={city}
class="object-cover w-39 h-39"
loading="lazy"

View File

@@ -16,7 +16,7 @@
import { themeManager } from '$lib/managers/theme-manager.svelte';
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
import { mapSettings } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
import { Icon, modalManager } from '@immich/ui';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
@@ -388,7 +388,7 @@
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
{:else}
<img
src={getAssetThumbnailUrl(feature.properties?.id)}
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Route } from '$lib/route';
import { userInteraction } from '$lib/stores/user.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { onMount } from 'svelte';
@@ -34,7 +34,7 @@
<div
class="h-6 w-6 bg-cover rounded bg-gray-200 dark:bg-gray-600"
style={album.albumThumbnailAssetId
? `background-image:url('${getAssetThumbnailUrl({ id: album.albumThumbnailAssetId })}')`
? `background-image:url('${getAssetMediaUrl({ id: album.albumThumbnailAssetId })}')`
: ''}
></div>
</div>

View File

@@ -1,5 +1,5 @@
import ShareCover from '$lib/components/sharedlinks-page/covers/share-cover.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { albumFactory } from '@test-data/factories/album-factory';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
@@ -21,7 +21,7 @@ describe('ShareCover component', () => {
});
it('renders an image when the shared link is an individual share', () => {
vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf');
vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf');
const component = render(ShareCover, {
sharedLink: sharedLinkFactory.build({ assets: [assetFactory.build({ id: 'someId' })] }),
preload: false,
@@ -32,7 +32,7 @@ describe('ShareCover component', () => {
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
expect(img.getAttribute('src')).toBe('/asdf');
expect(getAssetThumbnailUrl).toHaveBeenCalledWith('someId');
expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: 'someId' });
});
it('renders an image when the shared link has no album or assets', () => {

View File

@@ -2,7 +2,7 @@
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import type { SharedLinkResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
@@ -23,7 +23,7 @@
alt={$t('individual_share')}
class={className}
{preload}
src={getAssetThumbnailUrl(sharedLink.assets[0].id)}
src={getAssetMediaUrl({ id: sharedLink.assets[0].id })}
/>
{:else}
<NoCover alt={$t('unnamed_share')} class={className} {preload} />

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
@@ -112,7 +112,7 @@
>
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id)}
src={getAssetMediaUrl({ id: asset.id })}
alt={$getAltText(toTimelineAsset(asset))}
title={assetData}
class="h-60 object-cover w-full rounded-t-md"

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Card, CardBody, IconButton, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
@@ -20,7 +20,7 @@
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
src={getAssetMediaUrl({ id: item.albumThumbnailAssetId })}
alt={item.albumName}
class="h-12 w-12 rounded-lg object-cover"
/>

View File

@@ -0,0 +1,43 @@
import { getAssetMediaUrl } from '$lib/utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
type AllAssetMediaSize = AssetMediaSize | 'all';
class ImageManager {
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
if (!asset) {
return;
}
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
if (!url) {
return;
}
const img = new Image();
img.src = url;
}
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
if (!asset) {
return;
}
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
for (const size of sizes) {
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
if (url) {
cancelImageUrl(url);
}
}
}
cancelPreloadUrl(url: string | undefined) {
if (url) {
cancelImageUrl(url);
}
}
}
export const imageManager = new ImageManager();

View File

@@ -1,38 +0,0 @@
import { getAssetUrl } from '$lib/utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
class PreloadManager {
preload(asset: AssetResponseDto | undefined) {
if (globalThis.isSecureContext) {
preloadImageUrl(getAssetUrl({ asset }));
return;
}
if (!asset || asset.type !== AssetTypeEnum.Image) {
return;
}
const img = new Image();
const url = getAssetUrl({ asset });
if (!url) {
return;
}
img.src = url;
}
cancel(asset: AssetResponseDto | undefined) {
if (!globalThis.isSecureContext || !asset) {
return;
}
const url = getAssetUrl({ asset });
cancelImageUrl(url);
}
cancelPreloadUrl(url: string | undefined) {
if (!globalThis.isSecureContext) {
return;
}
cancelImageUrl(url);
}
}
export const preloadManager = new PreloadManager();

View File

@@ -1,5 +1,5 @@
import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { getDimensions } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import {
@@ -185,7 +185,7 @@ class TransformManager implements EditToolManager {
this.imgElement = new Image();
const imageURL = getAssetThumbnailUrl({
const imageURL = getAssetMediaUrl({
id: asset.id,
cacheKey: asset.thumbhash,
edited: false,

View File

@@ -1,21 +1,21 @@
import { MediaQuery } from 'svelte/reactivity';
const pointerCoarse = new MediaQuery('pointer:coarse');
const maxMd = new MediaQuery('max-width: 767px');
const sidebar = new MediaQuery(`min-width: 850px`);
const reducedMotion = new MediaQuery('prefers-reduced-motion: reduce');
export const mediaQueryManager = {
get pointerCoarse() {
return pointerCoarse.current;
},
get maxMd() {
return maxMd.current;
},
get isFullSidebar() {
return sidebar.current;
},
get reducedMotion() {
return reducedMotion.current;
},
};
import { MediaQuery } from 'svelte/reactivity';
const pointerCoarse = new MediaQuery('pointer:coarse');
const maxMd = new MediaQuery('max-width: 767px');
const sidebar = new MediaQuery(`min-width: 850px`);
const reducedMotion = new MediaQuery('prefers-reduced-motion: reduce');
export const mediaQueryManager = {
get pointerCoarse() {
return pointerCoarse.current;
},
get maxMd() {
return maxMd.current;
},
get isFullSidebar() {
return sidebar.current;
},
get reducedMotion() {
return reducedMotion.current;
},
};

View File

@@ -1,3 +1,4 @@
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { getAssetOcr } from '@immich/sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -30,6 +31,7 @@ describe('OcrManager', () => {
beforeEach(() => {
// Reset the singleton state before each test
ocrManager.clear();
assetCacheManager.clearOcrCache();
vi.clearAllMocks();
});

View File

@@ -1,5 +1,5 @@
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { getAssetOcr } from '@immich/sdk';
export type OcrBoundingBox = {
id: string;
@@ -38,7 +38,7 @@ class OcrManager {
this.#cleared = false;
}
await this.#ocrLoader.execute(async () => {
this.#data = await getAssetOcr({ id });
this.#data = await assetCacheManager.getAssetOcr(id);
}, false);
}

View File

@@ -12,6 +12,7 @@ import {
type MaintenanceStatusResponseDto,
type NotificationDto,
type ServerVersionResponseDto,
type SyncAssetV1,
} from '@immich/sdk';
import { io, type Socket } from 'socket.io-client';
import { get, writable } from 'svelte/store';
@@ -40,7 +41,7 @@ export interface Events {
AppRestartV1: (event: AppRestartEvent) => void;
MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void;
AssetEditReadyV1: (data: { asset: { id: string } }) => void;
AssetEditReadyV1: (data: { asset: SyncAssetV1 }) => void;
}
const websocket: Socket<Events> = io({

View File

@@ -193,7 +193,7 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
return getBaseUrl() + url.pathname + url.search + url.hash;
};
type AssetUrlOptions = { id: string; cacheKey?: string | null; edited?: boolean };
type AssetUrlOptions = { id: string; cacheKey?: string | null; edited?: boolean; size?: AssetMediaSize };
export const getAssetUrl = ({
asset,
@@ -210,12 +210,10 @@ export const getAssetUrl = ({
const id = asset.id;
const cacheKey = asset.thumbhash;
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
return getAssetMediaUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
const targetSize = targetImageSize(asset, forceOriginal);
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
const size = targetImageSize(asset, forceOriginal);
return getAssetMediaUrl({ id, size, cacheKey });
};
const forceUseOriginal = (asset: AssetResponseDto) => {
@@ -224,33 +222,21 @@ const forceUseOriginal = (asset: AssetResponseDto) => {
export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => {
if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
return isWebCompatibleImage(asset) ? AssetMediaSize.Original : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
};
export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, cacheKey, edited = true } = options;
return createUrl(getAssetOriginalPath(id), { ...authManager.params, c: cacheKey, edited });
export const getAssetMediaUrl = (options: AssetUrlOptions) => {
const { id, size, cacheKey: c, edited = true } = options;
const isOriginal = size === AssetMediaSize.Original;
const path = isOriginal ? getAssetOriginalPath(id) : getAssetThumbnailPath(id);
return createUrl(path, { ...authManager.params, size: isOriginal ? undefined : size, c, edited });
};
export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size?: AssetMediaSize })) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, size, cacheKey, edited = true } = options;
return createUrl(getAssetThumbnailPath(id), { ...authManager.params, size, c: cacheKey, edited });
};
export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, cacheKey } = options;
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c: cacheKey });
export const getAssetPlaybackUrl = (options: AssetUrlOptions) => {
const { id, cacheKey: c } = options;
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c });
};
export const getProfileImageUrl = (user: UserResponseDto) =>

View File

@@ -1,5 +1,5 @@
import type { Faces } from '$lib/stores/people.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
import type { ZoomImageWheelState } from '@zoom-image/core';
@@ -82,7 +82,7 @@ export const zoomImageToBase64 = async (
if (assetType === AssetTypeEnum.Image) {
image = photoViewer;
} else if (assetType === AssetTypeEnum.Video) {
const data = getAssetThumbnailUrl(assetId);
const data = getAssetMediaUrl({ id: assetId });
const img: HTMLImageElement = new Image();
img.src = data;

View File

@@ -1,4 +1,4 @@
import { getAssetThumbnailUrl, setSharedLink } from '$lib/utils';
import { getAssetMediaUrl, setSharedLink } from '$lib/utils';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
@@ -36,7 +36,7 @@ export const loadSharedLink = async ({
setSharedLink(sharedLink);
const assetCount = sharedLink.assets.length;
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
const assetPath = assetId ? getAssetThumbnailUrl(assetId) : '/feature-panel.png';
const assetPath = assetId ? getAssetMediaUrl({ id: assetId }) : '/feature-panel.png';
return {
...common,

View File

@@ -1,14 +1,15 @@
const broadcast = new BroadcastChannel('immich');
import { ServiceWorkerMessenger } from './sw-messenger';
const messenger = new ServiceWorkerMessenger();
const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator;
const isValidSwContext = (url: string | undefined | null): url is string => {
return hasServiceWorker && !!url;
};
export function cancelImageUrl(url: string | undefined | null) {
if (!url) {
if (!isValidSwContext(url)) {
return;
}
broadcast.postMessage({ type: 'cancel', url });
}
export function preloadImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'preload', url });
void messenger.send('cancel', { url });
}

View File

@@ -0,0 +1,22 @@
export class ServiceWorkerMessenger {
constructor() {}
#sendInternal(type: string, data: Record<string, unknown>) {
if (!('serviceWorker' in navigator)) {
throw new Error('Service Worker not enabled in this environment ');
}
// eslint-disable-next-line compat/compat
navigator.serviceWorker.controller?.postMessage({
type,
...data,
});
}
/**
* Send a one-way message to the service worker.
*/
send(type: string, data: Record<string, unknown>) {
return this.#sendInternal(type, data);
}
}

View File

@@ -5,7 +5,7 @@
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
import { Route } from '$lib/route';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiHeart } from '@mdi/js';
@@ -90,7 +90,7 @@
<a class="relative" href={Route.search({ city: item.value })} draggable="false">
<div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter">
<img
src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={item.value}
class="object-cover aspect-square w-full"
/>

View File

@@ -28,7 +28,7 @@
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { memoryStore } from '$lib/stores/memory.store.svelte';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
@@ -98,7 +98,7 @@
title: $memoryLaneTitle(memory),
href: Route.memories({ id: memory.assets[0].id }),
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
src: getAssetThumbnailUrl(memory.assets[0].id),
src: getAssetMediaUrl({ id: memory.assets[0].id }),
})),
);
</script>

View File

@@ -1,25 +0,0 @@
import { handleCancel, handlePreload } from './request';
export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
// eslint-disable-next-line unicorn/prefer-add-event-listener
broadcast.onmessage = (event) => {
if (!event.data) {
return;
}
const url = new URL(event.data.url, event.origin);
switch (event.data.type) {
case 'preload': {
handlePreload(url);
break;
}
case 'cancel': {
handleCancel(url);
break;
}
}
};
};

View File

@@ -1,42 +0,0 @@
import { version } from '$service-worker';
const CACHE = `cache-${version}`;
let _cache: Cache | undefined;
const getCache = async () => {
if (_cache) {
return _cache;
}
_cache = await caches.open(CACHE);
return _cache;
};
export const get = async (key: string) => {
const cache = await getCache();
if (!cache) {
return;
}
return cache.match(key);
};
export const put = async (key: string, response: Response) => {
if (response.status !== 200) {
return;
}
const cache = await getCache();
if (!cache) {
return;
}
cache.put(key, response.clone());
};
export const prune = async () => {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
};

View File

@@ -2,9 +2,9 @@
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { installBroadcastChannelListener } from './broadcast-channel';
import { prune } from './cache';
import { handleRequest } from './request';
import { installMessageListener } from './messaging';
import { handleFetch as handleAssetFetch } from './request';
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
@@ -12,12 +12,10 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope;
const handleActivate = (event: ExtendableEvent) => {
event.waitUntil(sw.clients.claim());
event.waitUntil(prune());
};
const handleInstall = (event: ExtendableEvent) => {
event.waitUntil(sw.skipWaiting());
// do not preload app resources
};
const handleFetch = (event: FetchEvent): void => {
@@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => {
// Cache requests for thumbnails
const url = new URL(event.request.url);
if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) {
event.respondWith(handleRequest(event.request));
event.respondWith(handleAssetFetch(event.request));
return;
}
};
@@ -36,4 +34,4 @@ const handleFetch = (event: FetchEvent): void => {
sw.addEventListener('install', handleInstall, { passive: true });
sw.addEventListener('activate', handleActivate, { passive: true });
sw.addEventListener('fetch', handleFetch, { passive: true });
installBroadcastChannelListener();
installMessageListener();

View File

@@ -0,0 +1,53 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { handleCancel } from './request';
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
/**
* Send acknowledgment for a request
*/
function sendAck(client: Client, requestId: string) {
client.postMessage({
type: 'ack',
requestId,
});
}
/**
* Handle 'cancel' request: cancel a pending request
*/
const handleCancelRequest = (client: Client, url: URL, requestId: string) => {
sendAck(client, requestId);
handleCancel(url);
};
export const installMessageListener = () => {
sw.addEventListener('message', (event) => {
if (!event.data?.requestId || !event.data?.type) {
return;
}
const requestId = event.data.requestId;
switch (event.data.type) {
case 'cancel': {
const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined;
if (!url) {
return;
}
const client = event.source;
if (!client) {
return;
}
handleCancelRequest(client, url, requestId);
break;
}
}
});
};

View File

@@ -1,73 +1,68 @@
import { get, put } from './cache';
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
const pendingRequests = new Map<string, AbortController>();
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
const assertResponse = (response: Response) => {
if (!(response instanceof Response)) {
throw new TypeError('Fetch did not return a valid Response object');
}
type PendingRequest = {
controller: AbortController;
promise: Promise<Response>;
cleanupTimeout?: ReturnType<typeof setTimeout>;
};
const getCacheKey = (request: URL | Request) => {
if (isURL(request)) {
return request.toString();
const pendingRequests = new Map<string, PendingRequest>();
const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url);
const CANCELATION_MESSAGE = 'Request canceled by application';
const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export const handleFetch = (request: URL | Request): Promise<Response> => {
const requestKey = getRequestKey(request);
const existing = pendingRequests.get(requestKey);
if (existing) {
// Clone the response since response bodies can only be read once
// Each caller gets an independent clone they can consume
return existing.promise.then((response) => response.clone());
}
if (isRequest(request)) {
return request.url;
}
const pendingRequest: PendingRequest = {
controller: new AbortController(),
promise: undefined as unknown as Promise<Response>,
};
pendingRequests.set(requestKey, pendingRequest);
throw new Error(`Invalid request: ${request}`);
};
// NOTE: fetch returns after headers received, not the body
pendingRequest.promise = fetch(request, { signal: pendingRequest.controller.signal })
.catch((error: unknown) => {
const standardError = error instanceof Error ? error : new Error(String(error));
if (standardError.name === 'AbortError' || standardError.message === CANCELATION_MESSAGE) {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
}
throw standardError;
})
.finally(() => {
// Schedule cleanup after timeout to allow response body streaming to complete
const cleanupTimeout = setTimeout(() => {
pendingRequests.delete(requestKey);
}, CLEANUP_TIMEOUT_MS);
pendingRequest.cleanupTimeout = cleanupTimeout;
});
export const handlePreload = async (request: URL | Request) => {
try {
return await handleRequest(request);
} catch (error) {
console.error(`Preload failed: ${error}`);
}
};
export const handleRequest = async (request: URL | Request) => {
const cacheKey = getCacheKey(request);
const cachedResponse = await get(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
try {
const cancelToken = new AbortController();
pendingRequests.set(cacheKey, cancelToken);
const response = await fetch(request, { signal: cancelToken.signal });
assertResponse(response);
put(cacheKey, response);
return response;
} catch (error) {
if (error.name === 'AbortError') {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
}
console.log('Not an abort error', error);
throw error;
} finally {
pendingRequests.delete(cacheKey);
}
// Clone for the first caller to keep the original response unconsumed for future callers
return pendingRequest.promise.then((response) => response.clone());
};
export const handleCancel = (url: URL) => {
const cacheKey = getCacheKey(url);
const pendingRequest = pendingRequests.get(cacheKey);
if (!pendingRequest) {
return;
}
const requestKey = getRequestKey(url);
pendingRequest.abort();
pendingRequests.delete(cacheKey);
const pendingRequest = pendingRequests.get(requestKey);
if (pendingRequest) {
pendingRequest.controller.abort(CANCELATION_MESSAGE);
if (pendingRequest.cleanupTimeout) {
clearTimeout(pendingRequest.cleanupTimeout);
}
pendingRequests.delete(requestKey);
}
};