mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 09:15:35 +03:00
feat(server): lighter buckets (#17831)
* feat(web): lighter timeline buckets * GalleryViewer * weird ssr * Remove generics from AssetInteraction * ensure keys on getAssetInfo, alt-text * empty - trigger ci * re-add alt-text * test fix * update tests * tests * missing import * feat(server): lighter buckets * fix: flappy e2e test * lint * revert settings * unneeded cast * fix after merge * Adapt web client to consume new server response format * test * missing import * lint * Use nulls, make-sql * openapi battle * date->string * tests * tests * lint/tests * lint * test * push aggregation to query * openapi * stack as tuple * openapi * update references to description * update alt text tests * update sql * update sql * update timeline tests * linting, fix expected response * string tuple * fix spec * fix * silly generator * rename patch * minimize sorting * review * lint * lint * sql * test * avoid abbreviations * review comment - type safety in test * merge conflicts * lint * lint/abbreviations * remove unncessary code * review comments * sql * re-add package-lock * use booleans, fix visibility in openapi spec, less cursed controller * update sql * no need to use sql template * array access actually doesn't seem to matter * remove redundant code * re-add sql decorator * unused type * remove null assertions * bad merge * Fix test * shave * extra clean shave * use decorator for content type * redundant types * redundant comment * update comment * unnecessary res --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetVisibility, updateAssets, Visibility } from '@immich/sdk';
|
||||
import { AssetVisibility, updateAssets } from '@immich/sdk';
|
||||
import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction, PreAction } from './action';
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
let { asset, onAction, preAction }: Props = $props();
|
||||
const isLocked = asset.visibility === Visibility.Locked;
|
||||
const isLocked = asset.visibility === AssetVisibility.Locked;
|
||||
|
||||
const toggleLockedVisibility = async () => {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { timeToSeconds } from '$lib/utils/date-time';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetMediaSize, Visibility } from '@immich/sdk';
|
||||
import { AssetMediaSize, AssetVisibility } from '@immich/sdk';
|
||||
import {
|
||||
mdiArchiveArrowDownOutline,
|
||||
mdiCameraBurst,
|
||||
@@ -291,7 +291,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !authManager.key && showArchiveIcon && asset.visibility === Visibility.Archive}
|
||||
{#if !authManager.key && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
|
||||
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import type { OnArchive } from '$lib/utils/actions';
|
||||
import { archiveAssets } from '$lib/utils/asset-utils';
|
||||
import { AssetVisibility, Visibility } from '@immich/sdk';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
@@ -24,12 +24,12 @@
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const handleArchive = async () => {
|
||||
const isArchived = unarchive ? Visibility.Timeline : Visibility.Archive;
|
||||
const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
|
||||
loading = true;
|
||||
const ids = await archiveAssets(assets, isArchived as unknown as AssetVisibility);
|
||||
const ids = await archiveAssets(assets, isArchived as AssetVisibility);
|
||||
if (ids) {
|
||||
onArchive?.(ids, isArchived);
|
||||
onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
|
||||
clearSelect();
|
||||
}
|
||||
loading = false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
||||
import { Visibility } from '@immich/sdk';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { timelineAssetFactory } from '@test-data/factories/asset-factory';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
|
||||
@@ -13,10 +13,10 @@ describe('AssetInteraction', () => {
|
||||
|
||||
it('calculates derived values from selection', () => {
|
||||
assetInteraction.selectAsset(
|
||||
timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Archive, isTrashed: true }),
|
||||
timelineAssetFactory.build({ isFavorite: true, visibility: AssetVisibility.Archive, isTrashed: true }),
|
||||
);
|
||||
assetInteraction.selectAsset(
|
||||
timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Timeline, isTrashed: false }),
|
||||
timelineAssetFactory.build({ isFavorite: true, visibility: AssetVisibility.Timeline, isTrashed: false }),
|
||||
);
|
||||
|
||||
expect(assetInteraction.selectionActive).toBe(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { Visibility, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { fromStore } from 'svelte/store';
|
||||
|
||||
@@ -21,7 +21,7 @@ export class AssetInteraction {
|
||||
private userId = $derived(this.user.current?.id);
|
||||
|
||||
isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
|
||||
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === Visibility.Archive));
|
||||
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === AssetVisibility.Archive));
|
||||
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
|
||||
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
|
||||
import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory';
|
||||
import { AssetStore } from './assets-store.svelte';
|
||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||
import { AssetStore, type TimelineAsset } from './assets-store.svelte';
|
||||
|
||||
describe('AssetStore', () => {
|
||||
beforeEach(() => {
|
||||
@@ -11,18 +11,22 @@ describe('AssetStore', () => {
|
||||
|
||||
describe('init', () => {
|
||||
let assetStore: AssetStore;
|
||||
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
||||
'2024-03-01T00:00:00.000Z': assetFactory
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(1)
|
||||
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
||||
'2024-02-01T00:00:00.000Z': assetFactory
|
||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(100)
|
||||
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
|
||||
'2024-01-01T00:00:00.000Z': assetFactory
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(3)
|
||||
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
||||
};
|
||||
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
assetStore = new AssetStore();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||
@@ -30,13 +34,14 @@ describe('AssetStore', () => {
|
||||
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
|
||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
||||
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('should load buckets in viewport', () => {
|
||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.Month });
|
||||
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -48,29 +53,31 @@ describe('AssetStore', () => {
|
||||
|
||||
expect(plainBuckets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 303 }),
|
||||
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4514.333_333_333_333 }),
|
||||
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 185.5 }),
|
||||
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 12_016 }),
|
||||
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates timeline height', () => {
|
||||
expect(assetStore.timelineHeight).toBe(5103.333_333_333_333);
|
||||
expect(assetStore.timelineHeight).toBe(12_487.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadBucket', () => {
|
||||
let assetStore: AssetStore;
|
||||
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
||||
'2024-01-03T00:00:00.000Z': assetFactory
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(1)
|
||||
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
||||
'2024-01-01T00:00:00.000Z': assetFactory
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(3)
|
||||
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
||||
};
|
||||
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
);
|
||||
beforeEach(async () => {
|
||||
assetStore = new AssetStore();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||
@@ -82,7 +89,7 @@ describe('AssetStore', () => {
|
||||
if (signal?.aborted) {
|
||||
throw new AbortError();
|
||||
}
|
||||
return bucketAssets[timeBucket];
|
||||
return bucketAssetsResponse[timeBucket];
|
||||
});
|
||||
await assetStore.updateViewport({ width: 1588, height: 0 });
|
||||
});
|
||||
@@ -296,7 +303,9 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('removes asset from bucket', () => {
|
||||
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
|
||||
localDateTime: '2024-01-20T12:00:00.000Z',
|
||||
});
|
||||
assetStore.addAssets([assetOne, assetTwo]);
|
||||
assetStore.removeAssets([assetOne.id]);
|
||||
|
||||
@@ -342,17 +351,20 @@ describe('AssetStore', () => {
|
||||
|
||||
describe('getPreviousAsset', () => {
|
||||
let assetStore: AssetStore;
|
||||
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
||||
'2024-03-01T00:00:00.000Z': assetFactory
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(1)
|
||||
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
||||
'2024-02-01T00:00:00.000Z': assetFactory
|
||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(6)
|
||||
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
|
||||
'2024-01-01T00:00:00.000Z': assetFactory
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(3)
|
||||
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
||||
};
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
assetStore = new AssetStore();
|
||||
@@ -361,8 +373,7 @@ describe('AssetStore', () => {
|
||||
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
|
||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
||||
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import {
|
||||
@@ -15,10 +16,8 @@ import {
|
||||
getAssetInfo,
|
||||
getTimeBucket,
|
||||
getTimeBuckets,
|
||||
TimeBucketSize,
|
||||
Visibility,
|
||||
type AssetResponseDto,
|
||||
type AssetStackResponseDto,
|
||||
type TimeBucketAssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -32,6 +31,7 @@ const {
|
||||
} = TUNABLES;
|
||||
|
||||
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
|
||||
|
||||
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
||||
timelineAlbumId?: string;
|
||||
deferInit?: boolean;
|
||||
@@ -75,7 +75,7 @@ export type TimelineAsset = {
|
||||
ratio: number;
|
||||
thumbhash: string | null;
|
||||
localDateTime: string;
|
||||
visibility: Visibility;
|
||||
visibility: AssetVisibility;
|
||||
isFavorite: boolean;
|
||||
isTrashed: boolean;
|
||||
isVideo: boolean;
|
||||
@@ -84,12 +84,11 @@ export type TimelineAsset = {
|
||||
duration: string | null;
|
||||
projectionType: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
text: {
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
people: string[];
|
||||
};
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
people: string[];
|
||||
};
|
||||
|
||||
class IntersectingAsset {
|
||||
// --- public ---
|
||||
readonly #group: AssetDateGroup;
|
||||
@@ -113,7 +112,7 @@ class IntersectingAsset {
|
||||
});
|
||||
|
||||
position: CommonPosition | undefined = $state();
|
||||
asset: TimelineAsset | undefined = $state();
|
||||
asset: TimelineAsset = <TimelineAsset>$state();
|
||||
id: string | undefined = $derived(this.asset?.id);
|
||||
|
||||
constructor(group: AssetDateGroup, asset: TimelineAsset) {
|
||||
@@ -121,9 +120,11 @@ class IntersectingAsset {
|
||||
this.asset = asset;
|
||||
}
|
||||
}
|
||||
|
||||
type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
||||
|
||||
type MoveAsset = { asset: TimelineAsset; year: number; month: number };
|
||||
|
||||
export class AssetDateGroup {
|
||||
// --- public
|
||||
readonly bucket: AssetBucket;
|
||||
@@ -166,6 +167,7 @@ export class AssetDateGroup {
|
||||
getFirstAsset() {
|
||||
return this.intersetingAssets[0]?.asset;
|
||||
}
|
||||
|
||||
getRandomAsset() {
|
||||
const random = Math.floor(Math.random() * this.intersetingAssets.length);
|
||||
return this.intersetingAssets[random];
|
||||
@@ -243,6 +245,7 @@ export interface Viewport {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type ViewportXY = Viewport & {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -250,11 +253,46 @@ export type ViewportXY = Viewport & {
|
||||
|
||||
class AddContext {
|
||||
lookupCache: {
|
||||
[dayOfMonth: number]: AssetDateGroup;
|
||||
[year: number]: { [month: number]: { [day: number]: AssetDateGroup } };
|
||||
} = {};
|
||||
unprocessedAssets: TimelineAsset[] = [];
|
||||
changedDateGroups = new Set<AssetDateGroup>();
|
||||
newDateGroups = new Set<AssetDateGroup>();
|
||||
|
||||
getDateGroup(year: number, month: number, day: number): AssetDateGroup | undefined {
|
||||
return this.lookupCache[year]?.[month]?.[day];
|
||||
}
|
||||
|
||||
setDateGroup(dateGroup: AssetDateGroup, year: number, month: number, day: number) {
|
||||
if (!this.lookupCache[year]) {
|
||||
this.lookupCache[year] = {};
|
||||
}
|
||||
if (!this.lookupCache[year][month]) {
|
||||
this.lookupCache[year][month] = {};
|
||||
}
|
||||
this.lookupCache[year][month][day] = dateGroup;
|
||||
}
|
||||
|
||||
get existingDateGroups() {
|
||||
return this.changedDateGroups.difference(this.newDateGroups);
|
||||
}
|
||||
|
||||
get updatedBuckets() {
|
||||
const updated = new Set<AssetBucket>();
|
||||
for (const group of this.changedDateGroups) {
|
||||
updated.add(group.bucket);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
get bucketsWithNewDateGroups() {
|
||||
const updated = new Set<AssetBucket>();
|
||||
for (const group of this.newDateGroups) {
|
||||
updated.add(group.bucket);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
for (const group of this.changedDateGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
@@ -267,6 +305,7 @@ class AddContext {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetBucket {
|
||||
// --- public ---
|
||||
#intersecting: boolean = $state(false);
|
||||
@@ -331,6 +370,7 @@ export class AssetBucket {
|
||||
this.handleLoadError,
|
||||
);
|
||||
}
|
||||
|
||||
set intersecting(newValue: boolean) {
|
||||
const old = this.#intersecting;
|
||||
if (old !== newValue) {
|
||||
@@ -422,52 +462,74 @@ export class AssetBucket {
|
||||
};
|
||||
}
|
||||
|
||||
// note - if the assets are not part of this bucket, they will not be added
|
||||
addAssets(bucketResponse: AssetResponseDto[]) {
|
||||
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
|
||||
const addContext = new AddContext();
|
||||
for (const asset of bucketResponse) {
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
const people: string[] = [];
|
||||
for (let i = 0; i < bucketAssets.id.length; i++) {
|
||||
const timelineAsset: TimelineAsset = {
|
||||
city: bucketAssets.city[i],
|
||||
country: bucketAssets.country[i],
|
||||
duration: bucketAssets.duration[i],
|
||||
id: bucketAssets.id[i],
|
||||
visibility: bucketAssets.visibility[i],
|
||||
isFavorite: bucketAssets.isFavorite[i],
|
||||
isImage: bucketAssets.isImage[i],
|
||||
isTrashed: bucketAssets.isTrashed[i],
|
||||
isVideo: !bucketAssets.isImage[i],
|
||||
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||
localDateTime: bucketAssets.localDateTime[i],
|
||||
ownerId: bucketAssets.ownerId[i],
|
||||
people,
|
||||
projectionType: bucketAssets.projectionType[i],
|
||||
ratio: bucketAssets.ratio[i],
|
||||
stack: bucketAssets.stack?.[i]
|
||||
? {
|
||||
id: bucketAssets.stack[i]![0],
|
||||
primaryAssetId: bucketAssets.id[i],
|
||||
assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
|
||||
}
|
||||
: null,
|
||||
thumbhash: bucketAssets.thumbhash[i],
|
||||
};
|
||||
this.addTimelineAsset(timelineAsset, addContext);
|
||||
}
|
||||
|
||||
for (const group of addContext.existingDateGroups) {
|
||||
group.sortAssets(this.#sortOrder);
|
||||
}
|
||||
|
||||
if (addContext.newDateGroups.size > 0) {
|
||||
this.sortDateGroups();
|
||||
}
|
||||
|
||||
addContext.sort(this, this.#sortOrder);
|
||||
|
||||
return addContext.unprocessedAssets;
|
||||
}
|
||||
|
||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
|
||||
const { id, localDateTime } = timelineAsset;
|
||||
const { localDateTime } = timelineAsset;
|
||||
const date = DateTime.fromISO(localDateTime).toUTC();
|
||||
|
||||
const month = date.get('month');
|
||||
const year = date.get('year');
|
||||
|
||||
// If the timeline asset does not belong to the current bucket, mark it as unprocessed
|
||||
if (this.month !== month || this.year !== year) {
|
||||
addContext.unprocessedAssets.push(timelineAsset);
|
||||
return;
|
||||
}
|
||||
|
||||
const day = date.get('day');
|
||||
let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day] || this.findDateGroupByDay(day);
|
||||
let dateGroup = addContext.getDateGroup(year, month, day) || this.findDateGroupByDay(day);
|
||||
|
||||
if (dateGroup) {
|
||||
// Cache the found date group for future lookups
|
||||
addContext.lookupCache[day] = dateGroup;
|
||||
addContext.setDateGroup(dateGroup, year, month, day);
|
||||
} else {
|
||||
// Create a new date group if none exists for the given day
|
||||
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
|
||||
this.dateGroups.push(dateGroup);
|
||||
addContext.lookupCache[day] = dateGroup;
|
||||
addContext.setDateGroup(dateGroup, year, month, day);
|
||||
addContext.newDateGroups.add(dateGroup);
|
||||
}
|
||||
|
||||
// Check for duplicate assets in the date group
|
||||
if (dateGroup.intersetingAssets.some((a) => a.id === id)) {
|
||||
console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the timeline asset to the date group
|
||||
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
|
||||
dateGroup.intersetingAssets.push(intersectingAsset);
|
||||
addContext.changedDateGroups.add(dateGroup);
|
||||
@@ -521,6 +583,7 @@ export class AssetBucket {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get bucketHeight() {
|
||||
return this.#bucketHeight;
|
||||
}
|
||||
@@ -909,7 +972,6 @@ export class AssetStore {
|
||||
async #initialiazeTimeBuckets() {
|
||||
const timebuckets = await getTimeBuckets({
|
||||
...this.#options,
|
||||
size: TimeBucketSize.Month,
|
||||
key: authManager.key,
|
||||
});
|
||||
|
||||
@@ -1016,6 +1078,7 @@ export class AssetStore {
|
||||
rowWidth: Math.floor(viewportWidth),
|
||||
};
|
||||
}
|
||||
|
||||
#updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
|
||||
if (invalidateHeight) {
|
||||
bucket.isBucketHeightActual = false;
|
||||
@@ -1117,7 +1180,7 @@ export class AssetStore {
|
||||
{
|
||||
...this.#options,
|
||||
timeBucket: bucketDate,
|
||||
size: TimeBucketSize.Month,
|
||||
|
||||
key: authManager.key,
|
||||
},
|
||||
{ signal },
|
||||
@@ -1128,12 +1191,11 @@ export class AssetStore {
|
||||
{
|
||||
albumId: this.#options.timelineAlbumId,
|
||||
timeBucket: bucketDate,
|
||||
size: TimeBucketSize.Month,
|
||||
key: authManager.key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
for (const { id } of albumAssets) {
|
||||
for (const id of albumAssets.id) {
|
||||
this.albumAssets.add(id);
|
||||
}
|
||||
}
|
||||
@@ -1169,9 +1231,10 @@ export class AssetStore {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const updatedBuckets = new Set<AssetBucket>();
|
||||
const updatedDateGroups = new Set<AssetDateGroup>();
|
||||
|
||||
const addContext = new AddContext();
|
||||
const updatedBuckets = new Set<AssetBucket>();
|
||||
const bucketCount = this.buckets.length;
|
||||
for (const asset of assets) {
|
||||
const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month');
|
||||
const year = utc.get('year');
|
||||
@@ -1182,20 +1245,26 @@ export class AssetStore {
|
||||
bucket = new AssetBucket(this, utc, 1, this.#options.order);
|
||||
this.buckets.push(bucket);
|
||||
}
|
||||
const addContext = new AddContext();
|
||||
|
||||
bucket.addTimelineAsset(asset, addContext);
|
||||
addContext.sort(bucket, this.#options.order);
|
||||
updatedBuckets.add(bucket);
|
||||
}
|
||||
|
||||
this.buckets.sort((a, b) => {
|
||||
return a.year === b.year ? b.month - a.month : b.year - a.year;
|
||||
});
|
||||
|
||||
for (const dateGroup of updatedDateGroups) {
|
||||
dateGroup.sortAssets(this.#options.order);
|
||||
if (this.buckets.length !== bucketCount) {
|
||||
this.buckets.sort((a, b) => {
|
||||
return a.year === b.year ? b.month - a.month : b.year - a.year;
|
||||
});
|
||||
}
|
||||
for (const bucket of updatedBuckets) {
|
||||
|
||||
for (const group of addContext.existingDateGroups) {
|
||||
group.sortAssets(this.#options.order);
|
||||
}
|
||||
|
||||
for (const bucket of addContext.bucketsWithNewDateGroups) {
|
||||
bucket.sortDateGroups();
|
||||
}
|
||||
|
||||
for (const bucket of addContext.updatedBuckets) {
|
||||
bucket.sortDateGroups();
|
||||
this.#updateGeometry(bucket, true);
|
||||
}
|
||||
@@ -1421,7 +1490,7 @@ export class AssetStore {
|
||||
|
||||
isExcluded(asset: TimelineAsset) {
|
||||
return (
|
||||
isMismatched(this.#options.visibility, asset.visibility as unknown as AssetVisibility) ||
|
||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
||||
import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import type { StackResponse } from '$lib/utils/asset-utils';
|
||||
import { deleteAssets as deleteBulk, Visibility } from '@immich/sdk';
|
||||
import { AssetVisibility, deleteAssets as deleteBulk } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { handleError } from './handle-error';
|
||||
@@ -11,7 +11,7 @@ export type OnRestore = (ids: string[]) => void;
|
||||
export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
|
||||
export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
|
||||
export type OnAddToAlbum = (ids: string[], albumId: string) => void;
|
||||
export type OnArchive = (ids: string[], visibility: Visibility) => void;
|
||||
export type OnArchive = (ids: string[], visibility: AssetVisibility) => void;
|
||||
export type OnFavorite = (ids: string[], favorite: boolean) => void;
|
||||
export type OnStack = (result: StackResponse) => void;
|
||||
export type OnUnstack = (assets: TimelineAsset[]) => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { Visibility } from '@immich/sdk';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { init, register, waitLocale } from 'svelte-i18n';
|
||||
|
||||
interface Person {
|
||||
@@ -62,7 +62,7 @@ describe('getAltText', () => {
|
||||
ratio: 1,
|
||||
thumbhash: null,
|
||||
localDateTime: '2024-01-01T12:00:00.000Z',
|
||||
visibility: Visibility.Timeline,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
isFavorite: false,
|
||||
isTrashed: false,
|
||||
isVideo,
|
||||
@@ -71,11 +71,9 @@ describe('getAltText', () => {
|
||||
duration: null,
|
||||
projectionType: null,
|
||||
livePhotoVideoId: null,
|
||||
text: {
|
||||
city: city ?? null,
|
||||
country: country ?? null,
|
||||
people: people?.map((person: Person) => person.name) ?? [],
|
||||
},
|
||||
city: city ?? null,
|
||||
country: country ?? null,
|
||||
people: people?.map((person: Person) => person.name) ?? [],
|
||||
};
|
||||
|
||||
getAltText.subscribe((fn) => {
|
||||
|
||||
@@ -41,19 +41,18 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number
|
||||
export const getAltText = derived(t, ($t) => {
|
||||
return (asset: TimelineAsset) => {
|
||||
const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) });
|
||||
const { city, country, people: names } = asset.text;
|
||||
const hasPlace = city && country;
|
||||
const hasPlace = asset.city && asset.country;
|
||||
|
||||
const peopleCount = names.length;
|
||||
const peopleCount = asset.people.length;
|
||||
const isVideo = asset.isVideo;
|
||||
|
||||
const values = {
|
||||
date,
|
||||
city,
|
||||
country,
|
||||
person1: names[0],
|
||||
person2: names[1],
|
||||
person3: names[2],
|
||||
city: asset.city,
|
||||
country: asset.country,
|
||||
person1: asset.people[0],
|
||||
person2: asset.people[1],
|
||||
person3: asset.people[2],
|
||||
isVideo,
|
||||
additionalCount: peopleCount > 3 ? peopleCount - 2 : 0,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
import { memoize } from 'lodash-es';
|
||||
import { DateTime, type LocaleOptions } from 'luxon';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -65,17 +66,12 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
if (isTimelineAsset(unknownAsset)) {
|
||||
return unknownAsset;
|
||||
}
|
||||
const assetResponse = unknownAsset as AssetResponseDto;
|
||||
const assetResponse = unknownAsset;
|
||||
const { width, height } = getAssetRatio(assetResponse);
|
||||
const ratio = width / height;
|
||||
const city = assetResponse.exifInfo?.city;
|
||||
const country = assetResponse.exifInfo?.country;
|
||||
const people = assetResponse.people?.map((person) => person.name) || [];
|
||||
const text = {
|
||||
city: city || null,
|
||||
country: country || null,
|
||||
people,
|
||||
};
|
||||
return {
|
||||
id: assetResponse.id,
|
||||
ownerId: assetResponse.ownerId,
|
||||
@@ -83,7 +79,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
thumbhash: assetResponse.thumbhash,
|
||||
localDateTime: assetResponse.localDateTime,
|
||||
isFavorite: assetResponse.isFavorite,
|
||||
visibility: assetResponse.visibility,
|
||||
visibility: assetResponse.isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline,
|
||||
isTrashed: assetResponse.isTrashed,
|
||||
isVideo: assetResponse.type == AssetTypeEnum.Video,
|
||||
isImage: assetResponse.type == AssetTypeEnum.Image,
|
||||
@@ -91,8 +87,10 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
duration: assetResponse.duration || null,
|
||||
projectionType: assetResponse.exifInfo?.projectionType || null,
|
||||
livePhotoVideoId: assetResponse.livePhotoVideoId || null,
|
||||
text,
|
||||
city: city || null,
|
||||
country: country || null,
|
||||
people,
|
||||
};
|
||||
};
|
||||
export const isTimelineAsset = (asset: AssetResponseDto | TimelineAsset): asset is TimelineAsset =>
|
||||
(asset as TimelineAsset).ratio !== undefined;
|
||||
export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
|
||||
(unknownAsset as TimelineAsset).ratio !== undefined;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
Visibility,
|
||||
type AssetResponseDto,
|
||||
type TimeBucketAssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Sync } from 'factory.ts';
|
||||
|
||||
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
||||
@@ -35,7 +41,7 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
|
||||
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
|
||||
localDateTime: Sync.each(() => faker.date.past().toISOString()),
|
||||
isFavorite: Sync.each(() => faker.datatype.boolean()),
|
||||
visibility: Visibility.Timeline,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
isTrashed: false,
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
@@ -43,9 +49,46 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
|
||||
stack: null,
|
||||
projectionType: null,
|
||||
livePhotoVideoId: Sync.each(() => faker.string.uuid()),
|
||||
text: Sync.each(() => ({
|
||||
city: faker.location.city(),
|
||||
country: faker.location.country(),
|
||||
people: [faker.person.fullName()],
|
||||
})),
|
||||
city: faker.location.city(),
|
||||
country: faker.location.country(),
|
||||
people: [faker.person.fullName()],
|
||||
});
|
||||
|
||||
export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
|
||||
const bucketAssets: TimeBucketAssetResponseDto = {
|
||||
city: [],
|
||||
country: [],
|
||||
duration: [],
|
||||
id: [],
|
||||
visibility: [],
|
||||
isFavorite: [],
|
||||
isImage: [],
|
||||
isTrashed: [],
|
||||
livePhotoVideoId: [],
|
||||
localDateTime: [],
|
||||
ownerId: [],
|
||||
projectionType: [],
|
||||
ratio: [],
|
||||
stack: [],
|
||||
thumbhash: [],
|
||||
};
|
||||
for (const asset of timelineAsset) {
|
||||
bucketAssets.city.push(asset.city);
|
||||
bucketAssets.country.push(asset.country);
|
||||
bucketAssets.duration.push(asset.duration!);
|
||||
bucketAssets.id.push(asset.id);
|
||||
bucketAssets.visibility.push(asset.visibility);
|
||||
bucketAssets.isFavorite.push(asset.isFavorite);
|
||||
bucketAssets.isImage.push(asset.isImage);
|
||||
bucketAssets.isTrashed.push(asset.isTrashed);
|
||||
bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
|
||||
bucketAssets.localDateTime.push(asset.localDateTime);
|
||||
bucketAssets.ownerId.push(asset.ownerId);
|
||||
bucketAssets.projectionType.push(asset.projectionType!);
|
||||
bucketAssets.ratio.push(asset.ratio);
|
||||
bucketAssets.stack?.push(asset.stack ? [asset.stack.id, asset.stack.assetCount.toString()] : null);
|
||||
bucketAssets.thumbhash.push(asset.thumbhash!);
|
||||
}
|
||||
|
||||
return bucketAssets;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user