mirror of
https://github.com/immich-app/immich.git
synced 2026-01-25 10:54:37 -08:00
Merge with dependent PRs
This commit is contained in:
4
mobile/openapi/lib/api/assets_api.dart
generated
4
mobile/openapi/lib/api/assets_api.dart
generated
@@ -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:
|
||||
///
|
||||
|
||||
3
mobile/openapi/lib/model/asset_media_size.dart
generated
3
mobile/openapi/lib/model/asset_media_size.dart
generated
@@ -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;
|
||||
|
||||
3
mobile/openapi/lib/model/permission.dart
generated
3
mobile/openapi/lib/model/permission.dart
generated
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -66,7 +66,7 @@ export class AssetController {
|
||||
}
|
||||
|
||||
@Post('jobs')
|
||||
@Authenticated()
|
||||
@Authenticated({ permission: Permission.JobCreate })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Run an asset job',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -146,6 +146,8 @@ export enum Permission {
|
||||
FaceUpdate = 'face.update',
|
||||
FaceDelete = 'face.delete',
|
||||
|
||||
FolderRead = 'folder.read',
|
||||
|
||||
JobCreate = 'job.create',
|
||||
JobRead = 'job.read',
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
43
web/src/lib/managers/ImageManager.svelte.ts
Normal file
43
web/src/lib/managers/ImageManager.svelte.ts
Normal 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();
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
22
web/src/lib/utils/sw-messenger.ts
Normal file
22
web/src/lib/utils/sw-messenger.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
53
web/src/service-worker/messaging.ts
Normal file
53
web/src/service-worker/messaging.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user