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:
Min Idzelis
2025-05-19 17:40:48 -04:00
committed by GitHub
parent 59f666b115
commit e7edbcdf04
41 changed files with 1109 additions and 510 deletions

View File

@@ -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';

View File

@@ -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({

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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));

View File

@@ -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 });
});

View File

@@ -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)
);

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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;
};