mirror of
https://github.com/immich-app/immich.git
synced 2025-05-30 19:54:52 -04:00
feat: show shared link view count
This commit is contained in:
parent
7bf142dc43
commit
8b0684ee9c
@ -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')
|
||||||
|
@ -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",
|
||||||
|
@ -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());
|
||||||
|
@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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
1
server/src/db.d.ts
vendored
@ -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 {
|
||||||
|
@ -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 });
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
14
server/src/migrations/1739916305466-AddViewCount.ts
Normal file
14
server/src/migrations/1739916305466-AddViewCount.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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
|
||||||
|
*
|
||||||
|
@ -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')
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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 -->
|
||||||
|
Loading…
x
Reference in New Issue
Block a user