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 () => {
const { status, body } = await request(app)
.get('/shared-links/me')

View File

@ -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",

View File

@ -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());

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'token'),
type: SharedLinkType.fromJson(json[r'type'])!,
userId: mapValueOfType<String>(json, r'userId')!,
viewCount: num.parse('${json[r'viewCount']}'),
);
}
return null;
@ -222,6 +229,7 @@ class SharedLinkResponseDto {
'showMetadata',
'type',
'userId',
'viewCount',
};
}

View File

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

View File

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

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

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

View File

@ -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 });

View File

@ -62,4 +62,7 @@ export class SharedLinkEntity {
@Column({ type: 'varchar', nullable: true })
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
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();
}
@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) {
return this.db
.selectFrom('shared_links')

View File

@ -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);
}

View File

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

View File

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