Pārlūkot izejas kodu

Show all albums an asset appears in on the asset viewer page (#575)

* Add route to query albums for a specific asset

* Update API and add to detail-panel

* Fix tests

* Refactor API endpoint

* Added alt attribute to img tag

Co-authored-by: Alex <alex.tran1502@gmail.com>
Matthias Rupp 2 gadi atpakaļ
vecāks
revīzija
caa7b07398

+ 4 - 2
mobile/openapi/doc/AlbumApi.md

@@ -259,7 +259,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 # **getAllAlbums**
-> List<AlbumResponseDto> getAllAlbums(shared)
+> List<AlbumResponseDto> getAllAlbums(shared, assetId)
 
 
 
@@ -275,9 +275,10 @@ import 'package:openapi/api.dart';
 
 final api_instance = AlbumApi();
 final shared = true; // bool | 
+final assetId = assetId_example; // String | Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
 
 try {
-    final result = api_instance.getAllAlbums(shared);
+    final result = api_instance.getAllAlbums(shared, assetId);
     print(result);
 } catch (e) {
     print('Exception when calling AlbumApi->getAllAlbums: $e\n');
@@ -289,6 +290,7 @@ try {
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
  **shared** | **bool**|  | [optional] 
+ **assetId** | **String**| Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums | [optional] 
 
 ### Return type
 

+ 12 - 3
mobile/openapi/lib/api/album_api.dart

@@ -259,7 +259,10 @@ class AlbumApi {
   /// Parameters:
   ///
   /// * [bool] shared:
-  Future<Response> getAllAlbumsWithHttpInfo({ bool? shared, }) async {
+  ///
+  /// * [String] assetId:
+  ///   Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
+  Future<Response> getAllAlbumsWithHttpInfo({ bool? shared, String? assetId, }) async {
     // ignore: prefer_const_declarations
     final path = r'/album';
 
@@ -273,6 +276,9 @@ class AlbumApi {
     if (shared != null) {
       queryParams.addAll(_queryParams('', 'shared', shared));
     }
+    if (assetId != null) {
+      queryParams.addAll(_queryParams('', 'assetId', assetId));
+    }
 
     const contentTypes = <String>[];
 
@@ -291,8 +297,11 @@ class AlbumApi {
   /// Parameters:
   ///
   /// * [bool] shared:
-  Future<List<AlbumResponseDto>?> getAllAlbums({ bool? shared, }) async {
-    final response = await getAllAlbumsWithHttpInfo( shared: shared, );
+  ///
+  /// * [String] assetId:
+  ///   Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
+  Future<List<AlbumResponseDto>?> getAllAlbums({ bool? shared, String? assetId, }) async {
+    final response = await getAllAlbumsWithHttpInfo( shared: shared, assetId: assetId, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }

+ 26 - 0
server/apps/immich/src/api-v1/album/album-repository.ts

@@ -22,6 +22,7 @@ export interface IAlbumRepository {
   removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<AlbumEntity>;
   addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
   updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
+  getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
 }
 
 export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
@@ -149,6 +150,31 @@ export class AlbumRepository implements IAlbumRepository {
     return albums;
   }
 
+  async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> {
+    let query = this.albumRepository.createQueryBuilder('album');
+
+    const albums = await query
+        .where('album.ownerId = :ownerId', { ownerId: userId })
+        .andWhere((qb) => {
+          // shared with userId
+          const subQuery = qb
+              .subQuery()
+              .select('assetAlbum.albumId')
+              .from(AssetAlbumEntity, 'assetAlbum')
+              .where('assetAlbum.assetId = :assetId', {assetId: assetId})
+              .getQuery();
+          return `album.id IN ${subQuery}`;
+        })
+        .leftJoinAndSelect('album.assets', 'assets')
+        .leftJoinAndSelect('assets.assetInfo', 'assetInfo')
+        .leftJoinAndSelect('album.sharedUsers', 'sharedUser')
+        .leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
+        .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
+        .getMany();
+
+    return albums;
+  }
+
   async get(albumId: string): Promise<AlbumEntity | undefined> {
     let query = this.albumRepository.createQueryBuilder('album');
 

+ 1 - 0
server/apps/immich/src/api-v1/album/album.service.spec.ts

@@ -116,6 +116,7 @@ describe('Album service', () => {
       removeAssets: jest.fn(),
       removeUser: jest.fn(),
       updateAlbum: jest.fn(),
+      getListByAssetId: jest.fn()
     };
     sut = new AlbumService(albumRepositoryMock);
   });

+ 4 - 1
server/apps/immich/src/api-v1/album/album.service.ts

@@ -48,8 +48,11 @@ export class AlbumService {
    * @returns All Shared Album And Its Members
    */
   async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> {
+    if (typeof getAlbumsDto.assetId === 'string') {
+      const albums = await this._albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId);
+      return albums.map(mapAlbumExcludeAssetInfo);
+    }
     const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
-
     return albums.map((album) => mapAlbumExcludeAssetInfo(album));
   }
 

+ 7 - 0
server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts

@@ -18,4 +18,11 @@ export class GetAlbumsDto {
    * undefined: shared and owned albums
    */
   shared?: boolean;
+
+  /**
+   * Only returns albums that contain the asset
+   * Ignores the shared parameter
+   * undefined: get all albums
+   */
+  assetId?: string;
 }

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
server/immich-openapi-specs.json


+ 15 - 7
web/src/api/open-api/api.ts

@@ -1457,10 +1457,11 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
         /**
          * 
          * @param {boolean} [shared] 
+         * @param {string} [assetId] Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getAllAlbums: async (shared?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getAllAlbums: async (shared?: boolean, assetId?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             const localVarPath = `/album`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -1481,6 +1482,10 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['shared'] = shared;
             }
 
+            if (assetId !== undefined) {
+                localVarQueryParameter['assetId'] = assetId;
+            }
+
 
     
             setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -1684,11 +1689,12 @@ export const AlbumApiFp = function(configuration?: Configuration) {
         /**
          * 
          * @param {boolean} [shared] 
+         * @param {string} [assetId] Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getAllAlbums(shared?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AlbumResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAlbums(shared, options);
+        async getAllAlbums(shared?: boolean, assetId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AlbumResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAlbums(shared, assetId, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -1784,11 +1790,12 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
         /**
          * 
          * @param {boolean} [shared] 
+         * @param {string} [assetId] Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getAllAlbums(shared?: boolean, options?: any): AxiosPromise<Array<AlbumResponseDto>> {
-            return localVarFp.getAllAlbums(shared, options).then((request) => request(axios, basePath));
+        getAllAlbums(shared?: boolean, assetId?: string, options?: any): AxiosPromise<Array<AlbumResponseDto>> {
+            return localVarFp.getAllAlbums(shared, assetId, options).then((request) => request(axios, basePath));
         },
         /**
          * 
@@ -1890,12 +1897,13 @@ export class AlbumApi extends BaseAPI {
     /**
      * 
      * @param {boolean} [shared] 
+     * @param {string} [assetId] Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
      * @param {*} [options] Override http request option.
      * @throws {RequiredError}
      * @memberof AlbumApi
      */
-    public getAllAlbums(shared?: boolean, options?: AxiosRequestConfig) {
-        return AlbumApiFp(this.configuration).getAllAlbums(shared, options).then((request) => request(this.axios, this.basePath));
+    public getAllAlbums(shared?: boolean, assetId?: string, options?: AxiosRequestConfig) {
+        return AlbumApiFp(this.configuration).getAllAlbums(shared, assetId, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**

+ 10 - 2
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -8,18 +8,26 @@
 	import DetailPanel from './detail-panel.svelte';
 	import { downloadAssets } from '$lib/stores/download';
 	import VideoViewer from './video-viewer.svelte';
-	import { api, AssetResponseDto, AssetTypeEnum } from '@api';
+	import { api, AssetResponseDto, AssetTypeEnum, AlbumResponseDto } from '@api';
 	import {
 		notificationController,
 		NotificationType
 	} from '../shared-components/notification/notification';
 
 	export let asset: AssetResponseDto;
+	$: {
+		appearsInAlbums = [];
+
+		api.albumApi.getAllAlbums(undefined, asset.id).then(result => {
+			appearsInAlbums = result.data;
+		});
+	}
 
 	const dispatch = createEventDispatcher();
 	let halfLeftHover = false;
 	let halfRightHover = false;
 	let isShowDetail = false;
+	let appearsInAlbums: AlbumResponseDto[] = [];
 
 	onMount(() => {
 		document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
@@ -200,7 +208,7 @@
 			class="bg-immich-bg w-[360px] row-span-full transition-all "
 			translate="yes"
 		>
-			<DetailPanel {asset} on:close={() => (isShowDetail = false)} />
+			<DetailPanel {asset} albums={appearsInAlbums} on:close={() => (isShowDetail = false)} />
 		</div>
 	{/if}
 </section>

+ 34 - 1
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -7,7 +7,7 @@
 	import moment from 'moment';
 	import { createEventDispatcher, onMount } from 'svelte';
 	import { browser } from '$app/env';
-	import { AssetResponseDto } from '@api';
+	import { AssetResponseDto, AlbumResponseDto } from '@api';
 
 	// Map Property
 	let map: any;
@@ -19,6 +19,8 @@
 		drawMap(asset.exifInfo.latitude, asset.exifInfo.longitude);
 	}
 
+	export let albums: AlbumResponseDto[];
+
 	onMount(async () => {
 		if (browser) {
 			if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
@@ -201,6 +203,37 @@
 	<div class="h-[360px] w-full" id="map" />
 </div>
 
+<section class="p-2">
+	<div class="px-4 py-4">
+		{#if albums.length > 0}
+			<p class="text-sm pb-4">APPEARS IN</p>
+		{/if}
+		{#each albums as album}
+			<a sveltekit:prefetch href={`/albums/${album.id}`}>
+				<div class="flex gap-4 py-2 hover:cursor-pointer" on:click={() => dispatch('click', album)}>
+					<div>
+						<img
+							alt={album.albumName}
+							class="w-[50px] h-[50px] object-cover rounded"
+							src={`/api/asset/thumbnail/${album.albumThumbnailAssetId}?format=JPEG`}
+						/>
+					</div>
+
+					<div class="mt-auto mb-auto">
+						<p>{album.albumName}</p>
+						<div class="flex gap-2 text-sm">
+							<p>{album.assetCount} items</p>
+							{#if album.shared}
+								<p>· Shared</p>
+							{/if}
+						</div>
+					</div>
+				</div>
+			</a>
+		{/each}
+	</div>
+</section>
+
 <style>
 	@import 'https://unpkg.com/leaflet@1.7.1/dist/leaflet.css';
 </style>

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels