feat: show shared link view count

This commit is contained in:
Jason Rasmussen 2025-02-18 17:34:51 -05:00
parent 7bf142dc43
commit 8b0684ee9c
No known key found for this signature in database
GPG Key ID: 2EF24B77EAFA4A41
15 changed files with 89 additions and 33 deletions

View File

@ -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 () => { it('should return unauthorized for incorrect shared link', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/shared-links/me') .get('/shared-links/me')

View File

@ -816,6 +816,7 @@
"invite_people": "Invite People", "invite_people": "Invite People",
"invite_to_album": "Invite to album", "invite_to_album": "Invite to album",
"items_count": "{count, plural, one {# item} other {# items}}", "items_count": "{count, plural, one {# item} other {# items}}",
"views_count": "{count, plural, one {# view} other {# views}}",
"jobs": "Jobs", "jobs": "Jobs",
"keep": "Keep", "keep": "Keep",
"keep_all": "Keep All", "keep_all": "Keep All",

View File

@ -13,6 +13,13 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'sharedLinks', SharedLinksResponse().toJson()); addDefault(value, 'sharedLinks', SharedLinksResponse().toJson());
} }
break; break;
case 'SharedLinkResponseDto':
if (value is Map) {
addDefault(value, 'viewCount', 0);
}
break;
case 'ServerConfigDto': case 'ServerConfigDto':
if (value is Map) { if (value is Map) {
addDefault( addDefault(
@ -26,11 +33,14 @@ dynamic upgradeDto(dynamic value, String targetType) {
'https://tiles.immich.cloud/v1/style/dark.json', 'https://tiles.immich.cloud/v1/style/dark.json',
); );
} }
break;
case 'UserResponseDto': case 'UserResponseDto':
if (value is Map) { if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
} }
break; break;
case 'UserAdminResponseDto': case 'UserAdminResponseDto':
if (value is Map) { if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());

View File

@ -27,6 +27,7 @@ class SharedLinkResponseDto {
this.token, this.token,
required this.type, required this.type,
required this.userId, required this.userId,
required this.viewCount,
}); });
/// ///
@ -63,6 +64,8 @@ class SharedLinkResponseDto {
String userId; String userId;
num viewCount;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SharedLinkResponseDto && bool operator ==(Object other) => identical(this, other) || other is SharedLinkResponseDto &&
other.album == album && other.album == album &&
@ -78,7 +81,8 @@ class SharedLinkResponseDto {
other.showMetadata == showMetadata && other.showMetadata == showMetadata &&
other.token == token && other.token == token &&
other.type == type && other.type == type &&
other.userId == userId; other.userId == userId &&
other.viewCount == viewCount;
@override @override
int get hashCode => int get hashCode =>
@ -96,10 +100,11 @@ class SharedLinkResponseDto {
(showMetadata.hashCode) + (showMetadata.hashCode) +
(token == null ? 0 : token!.hashCode) + (token == null ? 0 : token!.hashCode) +
(type.hashCode) + (type.hashCode) +
(userId.hashCode); (userId.hashCode) +
(viewCount.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -137,6 +142,7 @@ class SharedLinkResponseDto {
} }
json[r'type'] = this.type; json[r'type'] = this.type;
json[r'userId'] = this.userId; json[r'userId'] = this.userId;
json[r'viewCount'] = this.viewCount;
return json; return json;
} }
@ -163,6 +169,7 @@ class SharedLinkResponseDto {
token: mapValueOfType<String>(json, r'token'), token: mapValueOfType<String>(json, r'token'),
type: SharedLinkType.fromJson(json[r'type'])!, type: SharedLinkType.fromJson(json[r'type'])!,
userId: mapValueOfType<String>(json, r'userId')!, userId: mapValueOfType<String>(json, r'userId')!,
viewCount: num.parse('${json[r'viewCount']}'),
); );
} }
return null; return null;
@ -222,6 +229,7 @@ class SharedLinkResponseDto {
'showMetadata', 'showMetadata',
'type', 'type',
'userId', 'userId',
'viewCount',
}; };
} }

View File

@ -11415,6 +11415,9 @@
}, },
"userId": { "userId": {
"type": "string" "type": "string"
},
"viewCount": {
"type": "number"
} }
}, },
"required": [ "required": [
@ -11429,7 +11432,8 @@
"password", "password",
"showMetadata", "showMetadata",
"type", "type",
"userId" "userId",
"viewCount"
], ],
"type": "object" "type": "object"
}, },

View File

@ -1060,6 +1060,7 @@ export type SharedLinkResponseDto = {
token?: string | null; token?: string | null;
"type": SharedLinkType; "type": SharedLinkType;
userId: string; userId: string;
viewCount: number;
}; };
export type SharedLinkCreateDto = { export type SharedLinkCreateDto = {
albumId?: string; albumId?: string;

1
server/src/db.d.ts vendored
View File

@ -312,6 +312,7 @@ export interface SharedLinks {
showExif: Generated<boolean>; showExif: Generated<boolean>;
type: string; type: string;
userId: string; userId: string;
viewCount: Generated<number>;
} }
export interface SmartSearch { export interface SmartSearch {

View File

@ -94,6 +94,7 @@ export class SharedLinkResponseDto {
type!: SharedLinkType; type!: SharedLinkType;
createdAt!: Date; createdAt!: Date;
expiresAt!: Date | null; expiresAt!: Date | null;
viewCount!: number;
assets!: AssetResponseDto[]; assets!: AssetResponseDto[];
album?: AlbumResponseDto; album?: AlbumResponseDto;
allowUpload!: boolean; allowUpload!: boolean;
@ -102,7 +103,7 @@ export class SharedLinkResponseDto {
showMetadata!: boolean; showMetadata!: boolean;
} }
export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto { const map = (sharedLink: SharedLinkEntity, { stripMetadata }: { stripMetadata: boolean }) => {
const linkAssets = sharedLink.assets || []; const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
@ -115,35 +116,16 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
userId: sharedLink.userId, userId: sharedLink.userId,
key: sharedLink.key.toString('base64url'), key: sharedLink.key.toString('base64url'),
type: sharedLink.type, type: sharedLink.type,
viewCount: sharedLink.viewCount,
createdAt: sharedLink.createdAt, createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt, expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset)),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload, allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload, allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif, showMetadata: sharedLink.showExif,
}; assets: assets.map((asset) => mapAsset(asset, { stripMetadata })),
}
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[],
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, 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 });

View File

@ -62,4 +62,7 @@ export class SharedLinkEntity {
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
albumId!: string | null; albumId!: string | null;
@Column({ default: 0 })
viewCount!: number;
} }

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddViewCount1739916305466 implements MigrationInterface {
name = 'AddViewCount1739916305466'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" ADD "viewCount" integer NOT NULL DEFAULT '0'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "viewCount"`);
}
}

View File

@ -194,3 +194,12 @@ where
"shared_links"."type" = $2 "shared_links"."type" = $2
or "albums"."id" is not null or "albums"."id" is not null
) )
-- SharedLinkRepository.incrementViewCount
update "shared_links"
set
"viewCount" = viewCount + 1
where
"shared_links"."id" = $1
returning
*

View File

@ -216,6 +216,15 @@ export class SharedLinkRepository {
await this.db.deleteFrom('shared_links').where('shared_links.id', '=', entity.id).execute(); 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<number>`"viewCount" + 1`)
.where('shared_links.id', '=', id)
.execute();
}
private getSharedLinks(id: string) { private getSharedLinks(id: string) {
return this.db return this.db
.selectFrom('shared_links') .selectFrom('shared_links')

View File

@ -35,6 +35,9 @@ export class SharedLinkService extends BaseService {
response.token = this.validateAndRefreshToken(sharedLink, dto); response.token = this.validateAndRefreshToken(sharedLink, dto);
} }
await this.sharedLinkRepository.incrementViewCount(sharedLink.id);
sharedLink.viewCount++;
return response; 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); return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
} }

View File

@ -4,14 +4,19 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
viewCount?: number;
album: AlbumResponseDto; album: AlbumResponseDto;
} }
let { album }: Props = $props(); let { album, viewCount }: Props = $props();
</script> </script>
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details"> <span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<span>{getAlbumDateRange(album)}</span> <span>{getAlbumDateRange(album)}</span>
<span></span> <span></span>
<span>{$t('items_count', { values: { count: album.assetCount } })}</span> <span>{$t('items_count', { values: { count: album.assetCount } })}</span>
{#if viewCount}
<span></span>
<span>{$t('views_count', { values: { count: viewCount } })}</span>
{/if}
</span> </span>

View File

@ -111,7 +111,7 @@
</h1> </h1>
{#if album.assetCount > 0} {#if album.assetCount > 0}
<AlbumSummary {album} /> <AlbumSummary {album} viewCount={sharedLink.viewCount} />
{/if} {/if}
<!-- ALBUM DESCRIPTION --> <!-- ALBUM DESCRIPTION -->