Compare commits
3 Commits
f2b1043cbc
...
61a00286fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 61a00286fb | |||
| 79aeac7f37 | |||
| fca247c3f2 |
@@ -10,6 +10,7 @@ import { LogsModule } from "./modules/logs/logs.module";
|
|||||||
import { MediaModule } from "./modules/media/media.module";
|
import { MediaModule } from "./modules/media/media.module";
|
||||||
import { OrdersModule } from "./modules/orders/orders.module";
|
import { OrdersModule } from "./modules/orders/orders.module";
|
||||||
import { PlayerModule } from "./modules/player/player.module";
|
import { PlayerModule } from "./modules/player/player.module";
|
||||||
|
import { HistoryModule } from "./modules/history/history.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,6 +27,7 @@ import { PlayerModule } from "./modules/player/player.module";
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
PlayerModule,
|
PlayerModule,
|
||||||
|
HistoryModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Cover } from '../media/entities/cover.entity';
|
|||||||
import { MediaUploadJob } from '../media/entities/media-upload-job.entity';
|
import { MediaUploadJob } from '../media/entities/media-upload-job.entity';
|
||||||
import { Order } from '../orders/entities/order.entity';
|
import { Order } from '../orders/entities/order.entity';
|
||||||
import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity';
|
import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity';
|
||||||
|
import { UserPlaybackHistory } from '../history/entities/user-playback-history.entity';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
|
|
||||||
const databaseImports = process.env.DB_HOST
|
const databaseImports = process.env.DB_HOST
|
||||||
@@ -40,6 +41,7 @@ const databaseImports = process.env.DB_HOST
|
|||||||
MediaUploadJob,
|
MediaUploadJob,
|
||||||
Order,
|
Order,
|
||||||
UserEpisodeUnlock,
|
UserEpisodeUnlock,
|
||||||
|
UserPlaybackHistory,
|
||||||
User,
|
User,
|
||||||
],
|
],
|
||||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ export class DramasController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
@RequirePermissions("drama:read")
|
@RequirePermissions("drama:read")
|
||||||
listAdminDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
listAdminDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string, @Query("isRecommended") isRecommended?: string) {
|
||||||
return this.dramasService.listAdminDramas({
|
return this.dramasService.listAdminDramas({
|
||||||
page: Number(page) || 1,
|
page: Number(page) || 1,
|
||||||
pageSize: Number(pageSize) || 20,
|
pageSize: Number(pageSize) || 20,
|
||||||
|
title,
|
||||||
|
isRecommended: this.parseOptionalBoolean(isRecommended),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +113,27 @@ export class DramasController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("/api/app/v1/dramas")
|
@Get("/api/app/v1/dramas")
|
||||||
listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string) {
|
listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||||
return this.dramasService.listPublishedDramas({
|
return this.dramasService.listPublishedDramas({
|
||||||
page: Number(page) || 1,
|
page: Number(page) || 1,
|
||||||
pageSize: Number(pageSize) || 10,
|
pageSize: Number(pageSize) || 10,
|
||||||
title,
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/api/app/v1/dramas/recommended")
|
||||||
|
listRecommendedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||||
|
return this.dramasService.listRecommendedDramas({
|
||||||
|
page: Number(page) || 1,
|
||||||
|
pageSize: Number(pageSize) || 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/api/app/v1/dramas/presearch")
|
||||||
|
presearchVideos(@Query("keyword") keyword?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||||
|
return this.dramasService.presearchVideos({
|
||||||
|
page: Number(page) || 1,
|
||||||
|
pageSize: Number(pageSize) || 10,
|
||||||
|
keyword,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,4 +149,14 @@ export class DramasController {
|
|||||||
getPublishedDrama(@Param("id") id: string) {
|
getPublishedDrama(@Param("id") id: string) {
|
||||||
return this.dramasService.getPublishedDramaOrThrow(Number(id));
|
return this.dramasService.getPublishedDramaOrThrow(Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseOptionalBoolean(value?: string) {
|
||||||
|
if (value === "true") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (value === "false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
30
src/modules/history/dto/upsert-history.dto.ts
Normal file
30
src/modules/history/dto/upsert-history.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class UpsertPlaybackHistoryDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
episodeId: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
progressSeconds: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
completed?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
occurredAt?: string;
|
||||||
|
}
|
||||||
43
src/modules/history/entities/user-playback-history.entity.ts
Normal file
43
src/modules/history/entities/user-playback-history.entity.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('user_playback_histories')
|
||||||
|
@Index(['userId', 'episodeId'], { unique: true })
|
||||||
|
@Index(['userId', 'lastPlayedAt'])
|
||||||
|
export class UserPlaybackHistory {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
userId: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
dramaId: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
episodeId: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
progressSeconds: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
durationSeconds: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
completed: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime' })
|
||||||
|
lastPlayedAt: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
37
src/modules/history/history.controller.ts
Normal file
37
src/modules/history/history.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { UserRecord } from '../users/interfaces/user-record.interface';
|
||||||
|
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
|
||||||
|
import { HistoryService } from './history.service';
|
||||||
|
|
||||||
|
@ApiTags('History')
|
||||||
|
@Controller('/api/app/v1/history')
|
||||||
|
export class HistoryController {
|
||||||
|
constructor(private readonly historyService: HistoryService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
upsertHistory(
|
||||||
|
@CurrentUser() user: UserRecord,
|
||||||
|
@Body() dto: UpsertPlaybackHistoryDto,
|
||||||
|
) {
|
||||||
|
return this.historyService.upsertHistory(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
listHistory(
|
||||||
|
@CurrentUser() user: UserRecord,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.historyService.listHistory(user, {
|
||||||
|
page: Number(page) || 1,
|
||||||
|
pageSize: Number(pageSize) || 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/history/history.module.ts
Normal file
13
src/modules/history/history.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { DramasModule } from '../dramas/dramas.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { HistoryController } from './history.controller';
|
||||||
|
import { HistoryService } from './history.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, DramasModule, UsersModule],
|
||||||
|
controllers: [HistoryController],
|
||||||
|
providers: [HistoryService],
|
||||||
|
})
|
||||||
|
export class HistoryModule {}
|
||||||
223
src/modules/history/history.service.ts
Normal file
223
src/modules/history/history.service.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
||||||
|
import { DramasService } from '../dramas/dramas.service';
|
||||||
|
import {
|
||||||
|
DramaRecord,
|
||||||
|
EpisodeRecord,
|
||||||
|
} from '../dramas/interfaces/drama-records.interface';
|
||||||
|
import { UserRecord } from '../users/interfaces/user-record.interface';
|
||||||
|
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
|
||||||
|
import { UserPlaybackHistory } from './entities/user-playback-history.entity';
|
||||||
|
import {
|
||||||
|
PlaybackHistoryListItem,
|
||||||
|
PlaybackHistoryRecord,
|
||||||
|
} from './interfaces/history-record.interface';
|
||||||
|
|
||||||
|
interface PaginationInput {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HistoryService {
|
||||||
|
private historySequence = 1;
|
||||||
|
private readonly histories: PlaybackHistoryRecord[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dramasService: DramasService,
|
||||||
|
@Optional()
|
||||||
|
@InjectDataSource()
|
||||||
|
readonly dataSource?: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upsertHistory(
|
||||||
|
user: UserRecord,
|
||||||
|
dto: UpsertPlaybackHistoryDto,
|
||||||
|
): Promise<PlaybackHistoryRecord> {
|
||||||
|
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
|
||||||
|
dto.episodeId,
|
||||||
|
);
|
||||||
|
const progressSeconds = this.resolveProgress(
|
||||||
|
dto.progressSeconds,
|
||||||
|
episode.durationSeconds,
|
||||||
|
);
|
||||||
|
const completed =
|
||||||
|
Boolean(dto.completed) || progressSeconds >= episode.durationSeconds;
|
||||||
|
const lastPlayedAt = dto.occurredAt
|
||||||
|
? new Date(dto.occurredAt)
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const repository = this.dataSource.getRepository(UserPlaybackHistory);
|
||||||
|
const existing = await repository.findOne({
|
||||||
|
where: { userId: user.id, episodeId: episode.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const entity =
|
||||||
|
existing ??
|
||||||
|
repository.create({
|
||||||
|
userId: user.id,
|
||||||
|
dramaId: episode.dramaId,
|
||||||
|
episodeId: episode.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
entity.dramaId = episode.dramaId;
|
||||||
|
entity.progressSeconds = progressSeconds;
|
||||||
|
entity.durationSeconds = episode.durationSeconds;
|
||||||
|
entity.completed = completed;
|
||||||
|
entity.lastPlayedAt = lastPlayedAt;
|
||||||
|
|
||||||
|
return this.toRecord(await repository.save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existing = this.histories.find(
|
||||||
|
(item) => item.userId === user.id && item.episodeId === episode.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.dramaId = episode.dramaId;
|
||||||
|
existing.progressSeconds = progressSeconds;
|
||||||
|
existing.durationSeconds = episode.durationSeconds;
|
||||||
|
existing.completed = completed;
|
||||||
|
existing.lastPlayedAt = lastPlayedAt.toISOString();
|
||||||
|
existing.updatedAt = now;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record: PlaybackHistoryRecord = {
|
||||||
|
id: this.historySequence++,
|
||||||
|
userId: user.id,
|
||||||
|
dramaId: episode.dramaId,
|
||||||
|
episodeId: episode.id,
|
||||||
|
progressSeconds,
|
||||||
|
durationSeconds: episode.durationSeconds,
|
||||||
|
completed,
|
||||||
|
lastPlayedAt: lastPlayedAt.toISOString(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.histories.push(record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listHistory(
|
||||||
|
user: UserRecord,
|
||||||
|
pagination: PaginationInput,
|
||||||
|
): Promise<PaginatedResponse<PlaybackHistoryListItem>> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
||||||
|
const repository = this.dataSource.getRepository(UserPlaybackHistory);
|
||||||
|
const [records, total] = await repository.findAndCount({
|
||||||
|
where: { userId: user.id },
|
||||||
|
order: { lastPlayedAt: 'DESC', id: 'DESC' },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
});
|
||||||
|
const list = await Promise.all(
|
||||||
|
records.map((record) => this.toListItem(this.toRecord(record))),
|
||||||
|
);
|
||||||
|
return { list, pagination: { page, pageSize, total } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = this.paginate(
|
||||||
|
this.histories
|
||||||
|
.filter((item) => item.userId === user.id)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const timeDiff =
|
||||||
|
new Date(right.lastPlayedAt).getTime() -
|
||||||
|
new Date(left.lastPlayedAt).getTime();
|
||||||
|
return timeDiff || right.id - left.id;
|
||||||
|
}),
|
||||||
|
pagination,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
list: await Promise.all(
|
||||||
|
page.list.map((record) => this.toListItem(record)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toListItem(
|
||||||
|
record: PlaybackHistoryRecord,
|
||||||
|
): Promise<PlaybackHistoryListItem> {
|
||||||
|
const [drama, episode] = await Promise.all([
|
||||||
|
this.dramasService.getPublishedDramaOrThrow(record.dramaId),
|
||||||
|
this.dramasService.getPublishedEpisodeOrThrow(record.episodeId),
|
||||||
|
]);
|
||||||
|
return this.mergeListItem(record, drama, episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeListItem(
|
||||||
|
record: PlaybackHistoryRecord,
|
||||||
|
drama: DramaRecord,
|
||||||
|
episode: EpisodeRecord,
|
||||||
|
): PlaybackHistoryListItem {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
dramaId: record.dramaId,
|
||||||
|
episodeId: record.episodeId,
|
||||||
|
episodeNo: episode.episodeNo,
|
||||||
|
title: episode.title,
|
||||||
|
coverUrl: episode.coverUrl,
|
||||||
|
progressSeconds: record.progressSeconds,
|
||||||
|
durationSeconds: record.durationSeconds,
|
||||||
|
completed: record.completed,
|
||||||
|
lastPlayedAt: record.lastPlayedAt,
|
||||||
|
dramaTitle: drama.title,
|
||||||
|
dramaCoverUrl: drama.coverUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProgress(progressSeconds: number, durationSeconds: number) {
|
||||||
|
return Math.min(Math.max(progressSeconds, 0), durationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||||
|
return Boolean(this.dataSource?.isInitialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRecord(entity: UserPlaybackHistory): PlaybackHistoryRecord {
|
||||||
|
return {
|
||||||
|
id: Number(entity.id),
|
||||||
|
userId: Number(entity.userId),
|
||||||
|
dramaId: Number(entity.dramaId),
|
||||||
|
episodeId: Number(entity.episodeId),
|
||||||
|
progressSeconds: entity.progressSeconds,
|
||||||
|
durationSeconds: entity.durationSeconds,
|
||||||
|
completed: entity.completed,
|
||||||
|
lastPlayedAt: this.toIso(entity.lastPlayedAt),
|
||||||
|
createdAt: this.toIso(entity.createdAt),
|
||||||
|
updatedAt: this.toIso(entity.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toIso(value?: Date | string): string {
|
||||||
|
if (!value) {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePagination(input: PaginationInput) {
|
||||||
|
const page = Math.max(input.page, 1);
|
||||||
|
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||||
|
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
private paginate<T>(
|
||||||
|
records: T[],
|
||||||
|
input: PaginationInput,
|
||||||
|
): PaginatedResponse<T> {
|
||||||
|
const { page, pageSize, skip, take } = this.resolvePagination(input);
|
||||||
|
return {
|
||||||
|
list: records.slice(skip, skip + take),
|
||||||
|
pagination: { page, pageSize, total: records.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/modules/history/interfaces/history-record.interface.ts
Normal file
27
src/modules/history/interfaces/history-record.interface.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export interface PlaybackHistoryRecord {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
dramaId: number;
|
||||||
|
episodeId: number;
|
||||||
|
progressSeconds: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
completed: boolean;
|
||||||
|
lastPlayedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackHistoryListItem {
|
||||||
|
id: number;
|
||||||
|
dramaId: number;
|
||||||
|
episodeId: number;
|
||||||
|
episodeNo: number;
|
||||||
|
title: string;
|
||||||
|
coverUrl: string;
|
||||||
|
progressSeconds: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
completed: boolean;
|
||||||
|
lastPlayedAt: string;
|
||||||
|
dramaTitle: string;
|
||||||
|
dramaCoverUrl: string;
|
||||||
|
}
|
||||||
@@ -212,6 +212,8 @@ describe('Dramas and episodes', () => {
|
|||||||
.send({
|
.send({
|
||||||
title: 'Published Drama',
|
title: 'Published Drama',
|
||||||
coverUrl: 'https://cdn.example.com/pub.jpg',
|
coverUrl: 'https://cdn.example.com/pub.jpg',
|
||||||
|
portraitCoverUrl: 'https://cdn.example.com/pub-portrait.jpg',
|
||||||
|
landscapeCoverUrl: 'https://cdn.example.com/pub-landscape.jpg',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
status: 'published',
|
status: 'published',
|
||||||
})
|
})
|
||||||
@@ -265,6 +267,11 @@ describe('Dramas and episodes', () => {
|
|||||||
expect.objectContaining({ title: 'Published Drama' }),
|
expect.objectContaining({ title: 'Published Drama' }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
const publishedDrama = dramas.body.data.list.find(
|
||||||
|
(item: { title: string }) => item.title === 'Published Drama',
|
||||||
|
);
|
||||||
|
expect(publishedDrama).not.toHaveProperty('portraitCoverUrl');
|
||||||
|
expect(publishedDrama).not.toHaveProperty('landscapeCoverUrl');
|
||||||
expect(dramas.body.data.list).not.toEqual(
|
expect(dramas.body.data.list).not.toEqual(
|
||||||
expect.arrayContaining([expect.objectContaining({ title: 'Draft Drama' })]),
|
expect.arrayContaining([expect.objectContaining({ title: 'Draft Drama' })]),
|
||||||
);
|
);
|
||||||
@@ -321,4 +328,178 @@ describe('Dramas and episodes', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters admin dramas by title and recommended status', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
title: 'Recommended Search Drama',
|
||||||
|
coverUrl: 'https://cdn.example.com/recommended-search.jpg',
|
||||||
|
type: 'romance',
|
||||||
|
status: 'draft',
|
||||||
|
isRecommended: true,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
title: 'Recommended Hidden Drama',
|
||||||
|
coverUrl: 'https://cdn.example.com/recommended-hidden.jpg',
|
||||||
|
type: 'romance',
|
||||||
|
status: 'draft',
|
||||||
|
isRecommended: false,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/admin/v1/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.query({ title: 'Recommended', isRecommended: 'true' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.data.list).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Recommended Search Drama',
|
||||||
|
isRecommended: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(response.body.data.list).not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ title: 'Recommended Hidden Drama' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a published recommended drama list to app APIs', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
title: 'Published Recommended Drama',
|
||||||
|
coverUrl: 'https://cdn.example.com/published-recommended.jpg',
|
||||||
|
type: 'romance',
|
||||||
|
status: 'published',
|
||||||
|
isRecommended: true,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
title: 'Published Normal Drama',
|
||||||
|
coverUrl: 'https://cdn.example.com/published-normal.jpg',
|
||||||
|
type: 'romance',
|
||||||
|
status: 'published',
|
||||||
|
isRecommended: false,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/app/v1/dramas/recommended')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.data.list).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Published Recommended Drama',
|
||||||
|
isRecommended: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(response.body.data.list).not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ title: 'Published Normal Drama' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('presearches published approved videos by keyword for mini app clients', async () => {
|
||||||
|
const drama = await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
title: 'Skyline Keyword Saga',
|
||||||
|
description: 'A searchable drama description.',
|
||||||
|
coverUrl: 'https://cdn.example.com/skyline-drama.jpg',
|
||||||
|
type: 'romance',
|
||||||
|
status: 'published',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/episodes')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
dramaId: drama.body.data.id,
|
||||||
|
episodeNo: 1,
|
||||||
|
title: 'Pilot Video Match',
|
||||||
|
description: 'The first skyline episode.',
|
||||||
|
coverUrl: 'https://cdn.example.com/skyline-ep1.jpg',
|
||||||
|
videoUrl: 'https://cdn.example.com/skyline-ep1.mp4',
|
||||||
|
byteplusVid: 'vid_skyline_001',
|
||||||
|
durationSeconds: 180,
|
||||||
|
freeType: 'free',
|
||||||
|
price: 0,
|
||||||
|
status: 'published',
|
||||||
|
publishStatus: 'published',
|
||||||
|
reviewStatus: 'approved',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/episodes')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
dramaId: drama.body.data.id,
|
||||||
|
episodeNo: 2,
|
||||||
|
title: 'Hidden Draft Video',
|
||||||
|
description: 'Contains skyline but is not published.',
|
||||||
|
coverUrl: 'https://cdn.example.com/skyline-ep2.jpg',
|
||||||
|
videoUrl: 'https://cdn.example.com/skyline-ep2.mp4',
|
||||||
|
byteplusVid: 'vid_skyline_002',
|
||||||
|
durationSeconds: 180,
|
||||||
|
status: 'draft',
|
||||||
|
publishStatus: 'draft',
|
||||||
|
reviewStatus: 'approved',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/app/v1/dramas/presearch')
|
||||||
|
.query({ keyword: 'skyline' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.data.pagination.pageSize).toBe(10);
|
||||||
|
expect(response.body.data.list).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
dramaId: drama.body.data.id,
|
||||||
|
episodeNo: 1,
|
||||||
|
title: 'Pilot Video Match',
|
||||||
|
coverUrl: 'https://cdn.example.com/skyline-ep1.jpg',
|
||||||
|
videoUrl: 'https://cdn.example.com/skyline-ep1.mp4',
|
||||||
|
byteplusVid: 'vid_skyline_001',
|
||||||
|
durationSeconds: 180,
|
||||||
|
freeType: 'free',
|
||||||
|
price: 0,
|
||||||
|
dramaTitle: 'Skyline Keyword Saga',
|
||||||
|
dramaCoverUrl: 'https://cdn.example.com/skyline-drama.jpg',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const emptyKeyword = await request(app.getHttpServer())
|
||||||
|
.get('/api/app/v1/dramas/presearch')
|
||||||
|
.query({ keyword: ' ' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(emptyKeyword.body.data).toEqual({
|
||||||
|
list: [],
|
||||||
|
pagination: { page: 1, pageSize: 10, total: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -128,4 +128,164 @@ describe('Auth, orders, and playback', () => {
|
|||||||
videoUrl: 'https://cdn.example.com/paid-ep.mp4',
|
videoUrl: 'https://cdn.example.com/paid-ep.mp4',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records, updates, and isolates mini app playback history', async () => {
|
||||||
|
const firstLogin = await request(app.getHttpServer())
|
||||||
|
.post('/api/app/v1/auth/tiktok-login')
|
||||||
|
.send({
|
||||||
|
code: 'history-user-code-1',
|
||||||
|
mockOpenId: 'history-user-1',
|
||||||
|
nickname: 'History User 1',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const firstToken = firstLogin.body.data.accessToken;
|
||||||
|
|
||||||
|
const secondLogin = await request(app.getHttpServer())
|
||||||
|
.post('/api/app/v1/auth/tiktok-login')
|
||||||
|
.send({
|
||||||
|
code: 'history-user-code-2',
|
||||||
|
mockOpenId: 'history-user-2',
|
||||||
|
nickname: 'History User 2',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const secondToken = secondLogin.body.data.accessToken;
|
||||||
|
|
||||||
|
const drama = await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
title: 'History Drama',
|
||||||
|
coverUrl: 'https://cdn.example.com/history-drama.jpg',
|
||||||
|
type: 'history',
|
||||||
|
status: 'published',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const episode = await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/episodes')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
dramaId: drama.body.data.id,
|
||||||
|
episodeNo: 1,
|
||||||
|
title: 'History Episode',
|
||||||
|
coverUrl: 'https://cdn.example.com/history-episode.jpg',
|
||||||
|
videoUrl: 'https://cdn.example.com/history-episode.mp4',
|
||||||
|
durationSeconds: 300,
|
||||||
|
status: 'published',
|
||||||
|
publishStatus: 'published',
|
||||||
|
reviewStatus: 'approved',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/app/v1/history')
|
||||||
|
.send({
|
||||||
|
episodeId: episode.body.data.id,
|
||||||
|
progressSeconds: 86,
|
||||||
|
completed: false,
|
||||||
|
occurredAt: '2026-07-02T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/app/v1/history')
|
||||||
|
.set('Authorization', `Bearer ${firstToken}`)
|
||||||
|
.send({
|
||||||
|
episodeId: episode.body.data.id,
|
||||||
|
progressSeconds: 86,
|
||||||
|
completed: false,
|
||||||
|
occurredAt: '2026-07-02T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/app/v1/history')
|
||||||
|
.set('Authorization', `Bearer ${firstToken}`)
|
||||||
|
.send({
|
||||||
|
episodeId: episode.body.data.id,
|
||||||
|
progressSeconds: 301,
|
||||||
|
completed: false,
|
||||||
|
occurredAt: '2026-07-02T10:05:00.000Z',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const firstHistory = await request(app.getHttpServer())
|
||||||
|
.get('/api/app/v1/history')
|
||||||
|
.set('Authorization', `Bearer ${firstToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(firstHistory.body.data).toMatchObject({
|
||||||
|
pagination: { page: 1, pageSize: 20, total: 1 },
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
dramaId: drama.body.data.id,
|
||||||
|
episodeId: episode.body.data.id,
|
||||||
|
episodeNo: 1,
|
||||||
|
title: 'History Episode',
|
||||||
|
coverUrl: 'https://cdn.example.com/history-episode.jpg',
|
||||||
|
progressSeconds: 300,
|
||||||
|
durationSeconds: 300,
|
||||||
|
completed: true,
|
||||||
|
lastPlayedAt: '2026-07-02T10:05:00.000Z',
|
||||||
|
dramaTitle: 'History Drama',
|
||||||
|
dramaCoverUrl: 'https://cdn.example.com/history-drama.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondHistory = await request(app.getHttpServer())
|
||||||
|
.get('/api/app/v1/history')
|
||||||
|
.set('Authorization', `Bearer ${secondToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(secondHistory.body.data).toMatchObject({
|
||||||
|
pagination: { page: 1, pageSize: 20, total: 0 },
|
||||||
|
list: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects playback history for unavailable episodes', async () => {
|
||||||
|
const login = await request(app.getHttpServer())
|
||||||
|
.post('/api/app/v1/auth/tiktok-login')
|
||||||
|
.send({
|
||||||
|
code: 'history-user-code-3',
|
||||||
|
mockOpenId: 'history-user-3',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const drama = await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
title: 'Draft History Drama',
|
||||||
|
coverUrl: 'https://cdn.example.com/draft-history-drama.jpg',
|
||||||
|
type: 'history',
|
||||||
|
status: 'published',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const episode = await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/episodes')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
dramaId: drama.body.data.id,
|
||||||
|
episodeNo: 1,
|
||||||
|
title: 'Draft History Episode',
|
||||||
|
coverUrl: 'https://cdn.example.com/draft-history-episode.jpg',
|
||||||
|
durationSeconds: 120,
|
||||||
|
status: 'draft',
|
||||||
|
publishStatus: 'draft',
|
||||||
|
reviewStatus: 'not_submitted',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/app/v1/history')
|
||||||
|
.set('Authorization', `Bearer ${login.body.data.accessToken}`)
|
||||||
|
.send({
|
||||||
|
episodeId: episode.body.data.id,
|
||||||
|
progressSeconds: 10,
|
||||||
|
})
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user