Compare commits

...

12 Commits

Author SHA1 Message Date
CJPeckover
6e0005acfd - add toggle to show/hide asset owner names 2025-08-25 19:55:25 -04:00
CJPeckover
55a196bfa0 Merge branch 'immich-main' into feat/shared-album-owner-labels 2025-08-25 18:27:51 -04:00
Jason Rasmussen
d04675fb41 fix: dev-scripts (#21270)
fix/dev-scripts
2025-08-25 17:10:59 -04:00
Jason Rasmussen
acfd40b77a fix: album start/end dates on shared links (#21268) 2025-08-25 17:10:31 -04:00
Jason Rasmussen
840e43430c fix: docs typo (#21269) 2025-08-25 22:43:37 +02:00
Jason Rasmussen
a3e0c6cef5 fix: ignore abort errors (#21262) 2025-08-25 16:42:30 -04:00
CJPeckover
d0b49846dc format 2025-08-23 01:12:37 -04:00
CJPeckover
8896b2dbf5 fix lint 2025-08-23 01:11:10 -04:00
CJPeckover
251e644b2a - cleanup albumUsers creation
- use font-light for the user's name
2025-08-23 01:09:51 -04:00
CJPeckover
a02635f9a5 cleanup 2025-08-23 00:57:50 -04:00
CJPeckover
104f3dfcc3 - change owner to their name in white text instead of the avatar 2025-08-23 00:54:50 -04:00
CJPeckover
b7e3b48a44 - pass available album users along to the thumbnail through the asset-date-group
- show a small user-avatar in bottom right of thumbnail
2025-08-22 17:29:03 -04:00
13 changed files with 172 additions and 35 deletions

View File

@@ -12,8 +12,7 @@ services:
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node-modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules

View File

@@ -33,8 +33,7 @@ services:
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node-modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
@@ -97,8 +96,7 @@ services:
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- server-node-modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
@@ -194,8 +192,7 @@ services:
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
volumes:
- pnpm-store:/usr/src/app/.pnpm-store
- server-node-modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
@@ -210,8 +207,7 @@ volumes:
prometheus-data:
grafana-data:
pnpm-store:
server-node-modules:
server-dist:
server-node_modules:
web-node_modules:
github-node_modules:
cli-node_modules:

View File

@@ -88,7 +88,7 @@ The `.well-known/openid-configuration` part of the url is optional and will be a
## Auto Launch
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?authLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?autoLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
## Mobile Redirect URI

View File

@@ -38,7 +38,11 @@ from
select
"album".*,
coalesce(
json_agg("assets") filter (
json_agg(
"assets"
order by
"assets"."fileCreatedAt" asc
) filter (
where
"assets"."id" is not null
),

View File

@@ -86,7 +86,16 @@ export class SharedLinkRepository {
(join) => join.onTrue(),
)
.select((eb) =>
eb.fn.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`).as('assets'),
eb.fn
.coalesce(
eb.fn
.jsonAgg('assets')
.orderBy('assets.fileCreatedAt', 'asc')
.filterWhere('assets.id', 'is not', null),
sql`'[]'`,
)
.as('assets'),
)
.select((eb) => eb.fn.toJson('owner').as('owner'))
.groupBy(['album.id', sql`"owner".*`])

View File

@@ -33,6 +33,7 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
@@ -286,6 +287,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case PersonRepository:
case SearchRepository:
case SessionRepository:
case SharedLinkRepository:
case StackRepository:
case SyncRepository:
case SyncCheckpointRepository:
@@ -391,7 +393,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
checksum: randomBytes(32),
type: AssetType.Image,
originalPath: '/path/to/something.jpg',
ownerId: '@immich.cloud',
ownerId: 'not-a-valid-uuid',
isFavorite: false,
fileCreatedAt: now,
fileModifiedAt: now,

View File

@@ -0,0 +1,65 @@
import { Kysely } from 'kysely';
import { randomBytes } from 'node:crypto';
import { SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { DB } from 'src/schema';
import { SharedLinkService } from 'src/services/shared-link.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(SharedLinkService, {
database: db || defaultDatabase,
real: [AccessRepository, DatabaseRepository, SharedLinkRepository],
mock: [LoggingRepository, StorageRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SharedLinkService.name, () => {
describe('get', () => {
it('should return the correct dates on the shared link album', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const dates = ['2021-01-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z'];
for (const date of dates) {
const { asset } = await ctx.newAsset({ fileCreatedAt: date, localDateTime: date, ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
}
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: true,
type: SharedLinkType.Album,
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
album: expect.objectContaining({
startDate: '2020-01-01T00:00:00+00:00',
endDate: '2022-01-01T00:00:00+00:00',
}),
});
});
});
});

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, AssetVisibility } from '@immich/sdk';
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
import {
mdiArchiveArrowDownOutline,
mdiCameraBurst,
@@ -45,6 +45,7 @@
imageClass?: ClassValue;
brokenAssetClass?: ClassValue;
dimmed?: boolean;
albumUsers?: UserResponseDto[];
onClick?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
@@ -63,6 +64,7 @@
readonly = false,
showArchiveIcon = false,
showStackedIcon = true,
albumUsers = [],
onClick = undefined,
onSelect = undefined,
onMouseEvent = undefined,
@@ -84,6 +86,8 @@
let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235);
let assetOwner = $derived(albumUsers?.find((user) => user.id === asset.ownerId) ?? null);
const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
@@ -267,6 +271,12 @@
</div>
{/if}
{#if !!assetOwner}
<div class="absolute bottom-0 end-1">
<span class="text-white font-light text-sm">{assetOwner.name}</span>
</div>
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />

View File

@@ -9,6 +9,7 @@
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { navigate } from '$lib/utils/navigation';
import type { UserResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
@@ -25,7 +26,7 @@
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
albumUsers?: UserResponseDto[];
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
@@ -40,6 +41,7 @@
monthGroup = $bindable(),
assetInteraction,
timelineManager,
albumUsers = [],
onSelect,
onSelectAssets,
onSelectAssetCandidates,
@@ -189,6 +191,7 @@
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}

View File

@@ -30,7 +30,13 @@
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import {
AssetVisibility,
getAssetInfo,
type AlbumResponseDto,
type PersonResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte';
@@ -59,6 +65,7 @@
showArchiveIcon?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
albumUsers?: UserResponseDto[];
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void;
@@ -78,6 +85,7 @@
showArchiveIcon = false,
isShared = false,
album = null,
albumUsers = [],
person = null,
isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
@@ -88,6 +96,12 @@
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore;
// const albumUsers = $derived(
// album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)
// ? [album.owner, ...album.albumUsers.map(({ user }) => user)]
// : [],
// );
let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
@@ -936,6 +950,7 @@
{isSelectionMode}
{singleSelect}
{monthGroup}
{albumUsers}
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}

View File

@@ -71,6 +71,7 @@
} from '@immich/sdk';
import { Button, IconButton, modalManager } from '@immich/ui';
import {
mdiAccountEyeOutline,
mdiArrowLeft,
mdiCogOutline,
mdiDeleteOutline,
@@ -104,6 +105,7 @@
let isCreatingSharedAlbum = $state(false);
let isShowActivity = $state(false);
let albumOrder: AssetOrder | undefined = $state(data.album.order);
let showAlbumUsers = $state(false);
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
@@ -321,6 +323,11 @@
let album = $derived(data.album);
let albumId = $derived(album.id);
const albumUsers = $derived(
showAlbumUsers && album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)
? [album.owner, ...album.albumUsers.map(({ user }) => user)]
: [],
);
$effect(() => {
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
@@ -445,6 +452,7 @@
<AssetGrid
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
{album}
{albumUsers}
{timelineManager}
assetInteraction={currentAssetIntersection}
{isShared}
@@ -615,6 +623,15 @@
{#snippet trailing()}
<CastButton />
<IconButton
variant="ghost"
shape="round"
color="secondary"
aria-label="view asset owners"
icon={mdiAccountEyeOutline}
onclick={() => (showAlbumUsers = !showAlbumUsers)}
/>
{#if isEditor}
<IconButton
variant="ghost"

View File

@@ -1,4 +1,4 @@
import { cancelRequest, handleRequest } from './request';
import { handleCancel, handlePreload } from './request';
export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
@@ -7,12 +7,19 @@ export const installBroadcastChannelListener = () => {
if (!event.data) {
return;
}
const urlString = event.data.url;
const url = new URL(urlString, event.origin);
if (event.data.type === 'cancel') {
cancelRequest(url);
} else if (event.data.type === 'preload') {
handleRequest(url);
const url = new URL(event.data.url, event.origin);
switch (event.data.type) {
case 'preload': {
handlePreload(url);
break;
}
case 'cancel': {
handleCancel(url);
break;
}
}
};
};

View File

@@ -1,5 +1,7 @@
import { get, put } from './cache';
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;
@@ -21,11 +23,16 @@ const getCacheKey = (request: URL | Request) => {
throw new Error(`Invalid request: ${request}`);
};
const pendingRequests = new Map<string, AbortController>();
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;
@@ -41,23 +48,26 @@ export const handleRequest = async (request: URL | Request) => {
return response;
} catch (error) {
console.log(error);
return new Response(undefined, {
status: 499,
statusText: `Request canceled: Instructions unclear, accidentally interrupted myself (${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);
}
};
export const cancelRequest = (url: URL) => {
export const handleCancel = (url: URL) => {
const cacheKey = getCacheKey(url);
const pending = pendingRequests.get(cacheKey);
if (!pending) {
const pendingRequest = pendingRequests.get(cacheKey);
if (!pendingRequest) {
return;
}
pending.abort();
pendingRequest.abort();
pendingRequests.delete(cacheKey);
};