1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00:00

feat(server, web): include pictures of shared albums on map (#7439)

* feat(server, web): include pictures of shared albums on map

* run prettier

* re-create api clients

* implement suggestions from code review

* shared from partner -> shared from partners

* rename to 'include shared partner assets'

* chore: fix tsc error in server and prettier in web

* fix: include assets shared via owner albums

---------

Co-authored-by: Zack Pollard <zackpollard@ymail.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Andreas Gerstmayr 2024-05-13 15:28:57 +02:00 committed by GitHub
parent d121903b38
commit 48927f5fb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 66 additions and 18 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1386,6 +1386,14 @@
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
},
{
"name": "withSharedAlbums",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
} }
], ],
"responses": { "responses": {

View file

@ -1442,12 +1442,13 @@ export function runAssetJobs({ assetJobsDto }: {
body: assetJobsDto body: assetJobsDto
}))); })));
} }
export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners }: { export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners, withSharedAlbums }: {
fileCreatedAfter?: string; fileCreatedAfter?: string;
fileCreatedBefore?: string; fileCreatedBefore?: string;
isArchived?: boolean; isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
withPartners?: boolean; withPartners?: boolean;
withSharedAlbums?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
@ -1457,7 +1458,8 @@ export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived,
fileCreatedBefore, fileCreatedBefore,
isArchived, isArchived,
isFavorite, isFavorite,
withPartners withPartners,
withSharedAlbums
}))}`, { }))}`, {
...opts ...opts
})); }));

View file

@ -317,6 +317,9 @@ export class MapMarkerDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
withPartners?: boolean; withPartners?: boolean;
@ValidateBoolean({ optional: true })
withSharedAlbums?: boolean;
} }
export class MemoryLaneDto { export class MemoryLaneDto {

View file

@ -183,7 +183,7 @@ export interface IAssetRepository {
softDeleteAll(ids: string[]): Promise<void>; softDeleteAll(ids: string[]): Promise<void>;
restoreAll(ids: string[]): Promise<void>; restoreAll(ids: string[]): Promise<void>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>; findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getMapMarkers(ownerIds: string[], options?: MapMarkerSearchOptions): Promise<MapMarker[]>; getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>; getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>; getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>; getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;

View file

@ -490,9 +490,24 @@ export class AssetRepository implements IAssetRepository {
}); });
} }
async getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> { async getMapMarkers(
ownerIds: string[],
albumIds: string[],
options: MapMarkerSearchOptions = {},
): Promise<MapMarker[]> {
const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
const where = {
isVisible: true,
isArchived,
exifInfo: {
latitude: Not(IsNull()),
longitude: Not(IsNull()),
},
isFavorite,
fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore),
};
const assets = await this.repository.find({ const assets = await this.repository.find({
select: { select: {
id: true, id: true,
@ -504,17 +519,10 @@ export class AssetRepository implements IAssetRepository {
longitude: true, longitude: true,
}, },
}, },
where: { where: [
ownerId: In([...ownerIds]), { ...where, ownerId: In([...ownerIds]) },
isVisible: true, { ...where, albums: { id: In([...albumIds]) } },
isArchived, ],
exifInfo: {
latitude: Not(IsNull()),
longitude: Not(IsNull()),
},
isFavorite,
fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore),
},
relations: { relations: {
exifInfo: true, exifInfo: true,
}, },

View file

@ -2,6 +2,7 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto'; import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto';
import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
@ -18,6 +19,7 @@ import { faceStub } from 'test/fixtures/face.stub';
import { partnerStub } from 'test/fixtures/partner.stub'; import { partnerStub } from 'test/fixtures/partner.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetStackRepositoryMock } from 'test/repositories/asset-stack.repository.mock'; import { newAssetStackRepositoryMock } from 'test/repositories/asset-stack.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
@ -160,6 +162,7 @@ describe(AssetService.name, () => {
let configMock: Mocked<ISystemConfigRepository>; let configMock: Mocked<ISystemConfigRepository>;
let partnerMock: Mocked<IPartnerRepository>; let partnerMock: Mocked<IPartnerRepository>;
let assetStackMock: Mocked<IAssetStackRepository>; let assetStackMock: Mocked<IAssetStackRepository>;
let albumMock: Mocked<IAlbumRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
it('should work', () => { it('should work', () => {
@ -182,6 +185,7 @@ describe(AssetService.name, () => {
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
partnerMock = newPartnerRepositoryMock(); partnerMock = newPartnerRepositoryMock();
assetStackMock = newAssetStackRepositoryMock(); assetStackMock = newAssetStackRepositoryMock();
albumMock = newAlbumRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new AssetService( sut = new AssetService(
@ -194,6 +198,7 @@ describe(AssetService.name, () => {
eventMock, eventMock,
partnerMock, partnerMock,
assetStackMock, assetStackMock,
albumMock,
loggerMock, loggerMock,
); );

View file

@ -29,6 +29,7 @@ import { UpdateStackParentDto } from 'src/dtos/stack.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryType } from 'src/entities/library.entity'; import { LibraryType } from 'src/entities/library.entity';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
@ -78,6 +79,7 @@ export class AssetService {
@Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IAssetStackRepository) private assetStackRepository: IAssetStackRepository, @Inject(IAssetStackRepository) private assetStackRepository: IAssetStackRepository,
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(AssetService.name); this.logger.setContext(AssetService.name);
@ -167,6 +169,7 @@ export class AssetService {
async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> { async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
const userIds: string[] = [auth.user.id]; const userIds: string[] = [auth.user.id];
// TODO convert to SQL join
if (options.withPartners) { if (options.withPartners) {
const partners = await this.partnerRepository.getAll(auth.user.id); const partners = await this.partnerRepository.getAll(auth.user.id);
const partnersIds = partners const partnersIds = partners
@ -174,7 +177,18 @@ export class AssetService {
.map((partner) => partner.sharedById); .map((partner) => partner.sharedById);
userIds.push(...partnersIds); userIds.push(...partnersIds);
} }
return this.assetRepository.getMapMarkers(userIds, options);
// TODO convert to SQL join
const albumIds: string[] = [];
if (options.withSharedAlbums) {
const [ownedAlbums, sharedAlbums] = await Promise.all([
this.albumRepository.getOwned(auth.user.id),
this.albumRepository.getShared(auth.user.id),
]);
albumIds.push(...ownedAlbums.map((album) => album.id), ...sharedAlbums.map((album) => album.id));
}
return this.assetRepository.getMapMarkers(userIds, albumIds, options);
} }
async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> { async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {

View file

@ -30,7 +30,12 @@
<SettingSwitch id="allow-dark-mode" title="Allow dark mode" bind:checked={settings.allowDarkMode} /> <SettingSwitch id="allow-dark-mode" title="Allow dark mode" bind:checked={settings.allowDarkMode} />
<SettingSwitch id="only-favorites" title="Only favorites" bind:checked={settings.onlyFavorites} /> <SettingSwitch id="only-favorites" title="Only favorites" bind:checked={settings.onlyFavorites} />
<SettingSwitch id="include-archived" title="Include archived" bind:checked={settings.includeArchived} /> <SettingSwitch id="include-archived" title="Include archived" bind:checked={settings.includeArchived} />
<SettingSwitch id="include-shared-with-me" title="Include shared with me" bind:checked={settings.withPartners} /> <SettingSwitch
id="include-shared-partner-assets"
title="Include shared partner assets"
bind:checked={settings.withPartners}
/>
<SettingSwitch id="include-shared-albums" title="Include shared albums" bind:checked={settings.withSharedAlbums} />
{#if customDateRange} {#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4"> <div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8"> <div class="flex items-center justify-between gap-8">

View file

@ -47,6 +47,7 @@ export interface MapSettings {
includeArchived: boolean; includeArchived: boolean;
onlyFavorites: boolean; onlyFavorites: boolean;
withPartners: boolean; withPartners: boolean;
withSharedAlbums: boolean;
relativeDate: string; relativeDate: string;
dateAfter: string; dateAfter: string;
dateBefore: string; dateBefore: string;
@ -57,6 +58,7 @@ export const mapSettings = persisted<MapSettings>('map-settings', {
includeArchived: false, includeArchived: false,
onlyFavorites: false, onlyFavorites: false,
withPartners: false, withPartners: false,
withSharedAlbums: false,
relativeDate: '', relativeDate: '',
dateAfter: '', dateAfter: '',
dateBefore: '', dateBefore: '',

View file

@ -47,7 +47,7 @@
} }
abortController = new AbortController(); abortController = new AbortController();
const { includeArchived, onlyFavorites, withPartners } = $mapSettings; const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings;
const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates(); const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates();
return await getMapMarkers( return await getMapMarkers(
@ -57,6 +57,7 @@
fileCreatedAfter: fileCreatedAfter || undefined, fileCreatedAfter: fileCreatedAfter || undefined,
fileCreatedBefore, fileCreatedBefore,
withPartners: withPartners || undefined, withPartners: withPartners || undefined,
withSharedAlbums: withSharedAlbums || undefined,
}, },
{ {
signal: abortController.signal, signal: abortController.signal,