diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index 3918429e4e..5ae49ab66c 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -204,6 +204,12 @@ describe('/shared-links', () => { ); }); + it('should increment the view count', async () => { + const request1 = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key }); + const request2 = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key }); + expect(request2.body.viewCount).toBe(request1.body.viewCount + 1); + }); + it('should return unauthorized for incorrect shared link', async () => { const { status, body } = await request(app) .get('/shared-links/me') diff --git a/i18n/en.json b/i18n/en.json index 72559d4502..51b5b75e19 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -816,6 +816,7 @@ "invite_people": "Invite People", "invite_to_album": "Invite to album", "items_count": "{count, plural, one {# item} other {# items}}", + "views_count": "{count, plural, one {# view} other {# views}}", "jobs": "Jobs", "keep": "Keep", "keep_all": "Keep All", diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 708aec603f..9e220558db 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -13,6 +13,13 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'sharedLinks', SharedLinksResponse().toJson()); } break; + + case 'SharedLinkResponseDto': + if (value is Map) { + addDefault(value, 'viewCount', 0); + } + break; + case 'ServerConfigDto': if (value is Map) { addDefault( @@ -26,11 +33,14 @@ dynamic upgradeDto(dynamic value, String targetType) { 'https://tiles.immich.cloud/v1/style/dark.json', ); } + break; + case 'UserResponseDto': if (value is Map) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); } break; + case 'UserAdminResponseDto': if (value is Map) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index 9cc8b3ac80..bf2c481240 100644 --- a/mobile/openapi/lib/model/shared_link_response_dto.dart +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -27,6 +27,7 @@ class SharedLinkResponseDto { this.token, required this.type, required this.userId, + required this.viewCount, }); /// @@ -63,6 +64,8 @@ class SharedLinkResponseDto { String userId; + num viewCount; + @override bool operator ==(Object other) => identical(this, other) || other is SharedLinkResponseDto && other.album == album && @@ -78,7 +81,8 @@ class SharedLinkResponseDto { other.showMetadata == showMetadata && other.token == token && other.type == type && - other.userId == userId; + other.userId == userId && + other.viewCount == viewCount; @override int get hashCode => @@ -96,10 +100,11 @@ class SharedLinkResponseDto { (showMetadata.hashCode) + (token == null ? 0 : token!.hashCode) + (type.hashCode) + - (userId.hashCode); + (userId.hashCode) + + (viewCount.hashCode); @override - String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, password=$password, showMetadata=$showMetadata, token=$token, type=$type, userId=$userId]'; + String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, password=$password, showMetadata=$showMetadata, token=$token, type=$type, userId=$userId, viewCount=$viewCount]'; Map toJson() { final json = {}; @@ -137,6 +142,7 @@ class SharedLinkResponseDto { } json[r'type'] = this.type; json[r'userId'] = this.userId; + json[r'viewCount'] = this.viewCount; return json; } @@ -163,6 +169,7 @@ class SharedLinkResponseDto { token: mapValueOfType(json, r'token'), type: SharedLinkType.fromJson(json[r'type'])!, userId: mapValueOfType(json, r'userId')!, + viewCount: num.parse('${json[r'viewCount']}'), ); } return null; @@ -222,6 +229,7 @@ class SharedLinkResponseDto { 'showMetadata', 'type', 'userId', + 'viewCount', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5b5c3a1503..e8e1c39fed 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11415,6 +11415,9 @@ }, "userId": { "type": "string" + }, + "viewCount": { + "type": "number" } }, "required": [ @@ -11429,7 +11432,8 @@ "password", "showMetadata", "type", - "userId" + "userId", + "viewCount" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d4b36a04f0..c051a26abe 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1060,6 +1060,7 @@ export type SharedLinkResponseDto = { token?: string | null; "type": SharedLinkType; userId: string; + viewCount: number; }; export type SharedLinkCreateDto = { albumId?: string; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 2e10e1aded..53d64e77b3 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -312,6 +312,7 @@ export interface SharedLinks { showExif: Generated; type: string; userId: string; + viewCount: Generated; } export interface SmartSearch { diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index e3f8c72e19..be9996169c 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -94,6 +94,7 @@ export class SharedLinkResponseDto { type!: SharedLinkType; createdAt!: Date; expiresAt!: Date | null; + viewCount!: number; assets!: AssetResponseDto[]; album?: AlbumResponseDto; allowUpload!: boolean; @@ -102,7 +103,7 @@ export class SharedLinkResponseDto { showMetadata!: boolean; } -export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto { +const map = (sharedLink: SharedLinkEntity, { stripMetadata }: { stripMetadata: boolean }) => { const linkAssets = sharedLink.assets || []; const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); @@ -115,35 +116,16 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD userId: sharedLink.userId, key: sharedLink.key.toString('base64url'), type: sharedLink.type, + viewCount: sharedLink.viewCount, createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, - assets: assets.map((asset) => mapAsset(asset)), - album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, showMetadata: sharedLink.showExif, - }; -} - -export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): SharedLinkResponseDto { - const linkAssets = sharedLink.assets || []; - const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); - - const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id); - - return { - id: sharedLink.id, - description: sharedLink.description, - password: sharedLink.password, - userId: sharedLink.userId, - key: sharedLink.key.toString('base64url'), - type: sharedLink.type, - createdAt: sharedLink.createdAt, - expiresAt: sharedLink.expiresAt, - assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[], + assets: assets.map((asset) => mapAsset(asset, { stripMetadata })), album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, - allowUpload: sharedLink.allowUpload, - allowDownload: sharedLink.allowDownload, - showMetadata: sharedLink.showExif, }; -} +}; + +export const mapSharedLink = (sharedLink: SharedLinkEntity) => map(sharedLink, { stripMetadata: false }); +export const mapSharedLinkWithoutMetadata = (sharedLink: SharedLinkEntity) => map(sharedLink, { stripMetadata: true }); diff --git a/server/src/entities/shared-link.entity.ts b/server/src/entities/shared-link.entity.ts index 1fed44b301..9109ed0059 100644 --- a/server/src/entities/shared-link.entity.ts +++ b/server/src/entities/shared-link.entity.ts @@ -62,4 +62,7 @@ export class SharedLinkEntity { @Column({ type: 'varchar', nullable: true }) albumId!: string | null; + + @Column({ default: 0 }) + viewCount!: number; } diff --git a/server/src/migrations/1739916305466-AddViewCount.ts b/server/src/migrations/1739916305466-AddViewCount.ts new file mode 100644 index 0000000000..4254291c14 --- /dev/null +++ b/server/src/migrations/1739916305466-AddViewCount.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddViewCount1739916305466 implements MigrationInterface { + name = 'AddViewCount1739916305466' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shared_links" ADD "viewCount" integer NOT NULL DEFAULT '0'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "viewCount"`); + } + +} diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 641996e2f4..ba05726b76 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -194,3 +194,12 @@ where "shared_links"."type" = $2 or "albums"."id" is not null ) + +-- SharedLinkRepository.incrementViewCount +update "shared_links" +set + "viewCount" = viewCount + 1 +where + "shared_links"."id" = $1 +returning + * diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 52b5b7a2fe..11c64a41e4 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -216,6 +216,15 @@ export class SharedLinkRepository { await this.db.deleteFrom('shared_links').where('shared_links.id', '=', entity.id).execute(); } + @GenerateSql({ params: [DummyValue.UUID] }) + async incrementViewCount(id: string) { + await this.db + .updateTable('shared_links') + .set('viewCount', sql`"viewCount" + 1`) + .where('shared_links.id', '=', id) + .execute(); + } + private getSharedLinks(id: string) { return this.db .selectFrom('shared_links') diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 74595bb9a2..21cdcfcab6 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -35,6 +35,9 @@ export class SharedLinkService extends BaseService { response.token = this.validateAndRefreshToken(sharedLink, dto); } + await this.sharedLinkRepository.incrementViewCount(sharedLink.id); + sharedLink.viewCount++; + return response; } @@ -195,7 +198,7 @@ export class SharedLinkService extends BaseService { }; } - private mapToSharedLink(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) { + private mapToSharedLink(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }): SharedLinkResponseDto { return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink); } diff --git a/web/src/lib/components/album-page/album-summary.svelte b/web/src/lib/components/album-page/album-summary.svelte index 3e6e160c9c..9584b4bcad 100644 --- a/web/src/lib/components/album-page/album-summary.svelte +++ b/web/src/lib/components/album-page/album-summary.svelte @@ -4,14 +4,19 @@ import { t } from 'svelte-i18n'; interface Props { + viewCount?: number; album: AlbumResponseDto; } - let { album }: Props = $props(); + let { album, viewCount }: Props = $props(); {getAlbumDateRange(album)} {$t('items_count', { values: { count: album.assetCount } })} + {#if viewCount} + + {$t('views_count', { values: { count: viewCount } })} + {/if} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 02544e3e07..f704fcd86a 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -111,7 +111,7 @@ {#if album.assetCount > 0} - + {/if}