feat(dramas): 新增剧集搜索、推荐接口与管理筛选功能
1. 为后台剧集列表添加标题和推荐状态筛选参数 2. 新增前台推荐剧集列表接口和前置搜索接口 3. 添加可选布尔值解析工具方法 4. 新增对应接口的端到端测试用例
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,49 @@
|
|||||||
import {
|
import { ConflictException, Injectable, NotFoundException, Optional, UnprocessableEntityException } from "@nestjs/common";
|
||||||
ConflictException,
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
Injectable,
|
import { DataSource, Like } from "typeorm";
|
||||||
NotFoundException,
|
import { PaginatedResponse } from "../../common/dto/paginated-response.dto";
|
||||||
Optional,
|
import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto";
|
||||||
UnprocessableEntityException,
|
import { CreateDramaDto } from "./dto/create-drama.dto";
|
||||||
} from '@nestjs/common';
|
import { CreateEpisodeDto } from "./dto/create-episode.dto";
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { UpdateDramaDto } from "./dto/update-drama.dto";
|
||||||
import { DataSource, Like } from 'typeorm';
|
import { UpdateEpisodeDto } from "./dto/update-episode.dto";
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { Drama } from "./entities/drama.entity";
|
||||||
import { ConfigureEpisodesDto } from './dto/configure-episodes.dto';
|
import { Episode } from "./entities/episode.entity";
|
||||||
import { CreateDramaDto } from './dto/create-drama.dto';
|
import { PublishStatus } from "./drama-status.enum";
|
||||||
import { CreateEpisodeDto } from './dto/create-episode.dto';
|
import { DramaRecord, EpisodeRecord } from "./interfaces/drama-records.interface";
|
||||||
import { UpdateDramaDto } from './dto/update-drama.dto';
|
|
||||||
import { UpdateEpisodeDto } from './dto/update-episode.dto';
|
|
||||||
import { Drama } from './entities/drama.entity';
|
|
||||||
import { Episode } from './entities/episode.entity';
|
|
||||||
import { PublishStatus } from './drama-status.enum';
|
|
||||||
import {
|
|
||||||
DramaRecord,
|
|
||||||
EpisodeRecord,
|
|
||||||
} from './interfaces/drama-records.interface';
|
|
||||||
|
|
||||||
interface PaginationInput {
|
interface PaginationInput {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublishedDramaListInput extends PaginationInput {
|
interface DramaListInput extends PaginationInput {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
isRecommended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishedDramaListInput = DramaListInput;
|
||||||
|
|
||||||
|
interface VideoPresearchInput extends PaginationInput {
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicDramaRecord = Omit<DramaRecord, "portraitCoverUrl" | "landscapeCoverUrl">;
|
||||||
|
|
||||||
|
export interface VideoPresearchRecord {
|
||||||
|
id: number;
|
||||||
|
dramaId: number;
|
||||||
|
episodeNo: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
coverUrl: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
byteplusVid?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
freeType: "free" | "paid";
|
||||||
|
price: number;
|
||||||
|
dramaTitle: string;
|
||||||
|
dramaCoverUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -108,44 +124,46 @@ export class DramasService {
|
|||||||
return drama;
|
return drama;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAdminDramas(
|
async listAdminDramas(input: DramaListInput): Promise<PaginatedResponse<DramaRecord>> {
|
||||||
pagination: PaginationInput,
|
const title = input.title?.trim();
|
||||||
): Promise<PaginatedResponse<DramaRecord>> {
|
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
const { page, pageSize, skip, take } = this.resolvePagination(input);
|
||||||
const [records, total] = await this.dataSource
|
const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
|
||||||
.getRepository(Drama)
|
where: {
|
||||||
.findAndCount({
|
...(title ? { title: Like(`%${title}%`) } : {}),
|
||||||
order: { sortOrder: 'DESC', id: 'DESC' },
|
...(input.isRecommended !== undefined ? { isRecommended: input.isRecommended } : {}),
|
||||||
|
},
|
||||||
|
order: { sortOrder: "DESC", id: "DESC" },
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
});
|
});
|
||||||
const list = await Promise.all(
|
const list = await Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
|
||||||
records.map((drama) => this.toDramaRecordWithCounts(drama)),
|
|
||||||
);
|
|
||||||
return { list, pagination: { page, pageSize, total } };
|
return { list, pagination: { page, pageSize, total } };
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.paginate(
|
return this.paginate(
|
||||||
this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))),
|
this.sortDramas(
|
||||||
pagination,
|
this.dramas
|
||||||
|
.filter((item) => !title || item.title.includes(title))
|
||||||
|
.filter((item) => input.isRecommended === undefined || item.isRecommended === input.isRecommended)
|
||||||
|
.map((drama) => this.withEpisodeCounts(drama)),
|
||||||
|
),
|
||||||
|
input,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDramaOrThrow(id: number): Promise<DramaRecord> {
|
async getDramaOrThrow(id: number): Promise<DramaRecord> {
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const drama = await this.dataSource
|
const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id } });
|
||||||
.getRepository(Drama)
|
|
||||||
.findOne({ where: { id } });
|
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
return this.toDramaRecordWithCounts(drama);
|
return this.toDramaRecordWithCounts(drama);
|
||||||
}
|
}
|
||||||
|
|
||||||
const drama = this.dramas.find((item) => item.id === id);
|
const drama = this.dramas.find((item) => item.id === id);
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.withEpisodeCounts(drama);
|
return this.withEpisodeCounts(drama);
|
||||||
@@ -157,7 +175,7 @@ export class DramasService {
|
|||||||
const repository = this.dataSource.getRepository(Drama);
|
const repository = this.dataSource.getRepository(Drama);
|
||||||
const drama = await repository.findOne({ where: { id } });
|
const drama = await repository.findOne({ where: { id } });
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
repository.merge(drama, {
|
repository.merge(drama, {
|
||||||
...dramaUpdates,
|
...dramaUpdates,
|
||||||
@@ -174,7 +192,7 @@ export class DramasService {
|
|||||||
|
|
||||||
const drama = this.dramas.find((item) => item.id === id);
|
const drama = this.dramas.find((item) => item.id === id);
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(drama, {
|
Object.assign(drama, {
|
||||||
@@ -192,21 +210,19 @@ export class DramasService {
|
|||||||
const dramaId = dto.dramaId ?? dto.albumId;
|
const dramaId = dto.dramaId ?? dto.albumId;
|
||||||
const episodeNo = dto.episodeNo ?? dto.seq;
|
const episodeNo = dto.episodeNo ?? dto.seq;
|
||||||
if (!dramaId || !episodeNo) {
|
if (!dramaId || !episodeNo) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const freeType = dto.freeType ?? (dto.isPaid ? 'paid' : 'free');
|
const freeType = dto.freeType ?? (dto.isPaid ? "paid" : "free");
|
||||||
const price = dto.price ?? dto.unlockPrice ?? 0;
|
const price = dto.price ?? dto.unlockPrice ?? 0;
|
||||||
const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft;
|
const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft;
|
||||||
const reviewStatus =
|
const reviewStatus = dto.reviewStatus ?? (publishStatus === PublishStatus.Published ? "approved" : "not_submitted");
|
||||||
dto.reviewStatus ??
|
|
||||||
(publishStatus === PublishStatus.Published ? 'approved' : 'not_submitted');
|
|
||||||
|
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const dramaRepository = this.dataSource.getRepository(Drama);
|
const dramaRepository = this.dataSource.getRepository(Drama);
|
||||||
const drama = await dramaRepository.findOne({ where: { id: dramaId } });
|
const drama = await dramaRepository.findOne({ where: { id: dramaId } });
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const episodeRepository = this.dataSource.getRepository(Episode);
|
const episodeRepository = this.dataSource.getRepository(Episode);
|
||||||
@@ -214,9 +230,7 @@ export class DramasService {
|
|||||||
where: { dramaId, episodeNo },
|
where: { dramaId, episodeNo },
|
||||||
});
|
});
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
throw new ConflictException(
|
throw new ConflictException("Episode number already exists in this drama");
|
||||||
'Episode number already exists in this drama',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const entity = episodeRepository.create({
|
const entity = episodeRepository.create({
|
||||||
@@ -227,13 +241,13 @@ export class DramasService {
|
|||||||
description: dto.description,
|
description: dto.description,
|
||||||
byteplusVid: dto.byteplusVid,
|
byteplusVid: dto.byteplusVid,
|
||||||
coverId: dto.coverId,
|
coverId: dto.coverId,
|
||||||
coverUrl: dto.coverUrl ?? '',
|
coverUrl: dto.coverUrl ?? "",
|
||||||
videoUrl: dto.videoUrl,
|
videoUrl: dto.videoUrl,
|
||||||
trialDurationSeconds: dto.trialDurationSeconds,
|
trialDurationSeconds: dto.trialDurationSeconds,
|
||||||
durationSeconds: dto.durationSeconds ?? 0,
|
durationSeconds: dto.durationSeconds ?? 0,
|
||||||
width: dto.width,
|
width: dto.width,
|
||||||
height: dto.height,
|
height: dto.height,
|
||||||
isPaid: freeType === 'paid',
|
isPaid: freeType === "paid",
|
||||||
freeType,
|
freeType,
|
||||||
unlockPrice: price,
|
unlockPrice: price,
|
||||||
price,
|
price,
|
||||||
@@ -249,14 +263,12 @@ export class DramasService {
|
|||||||
|
|
||||||
const drama = this.dramas.find((item) => item.id === dramaId);
|
const drama = this.dramas.find((item) => item.id === dramaId);
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const duplicate = this.episodes.some(
|
const duplicate = this.episodes.some((item) => item.dramaId === dramaId && item.episodeNo === episodeNo);
|
||||||
(item) => item.dramaId === dramaId && item.episodeNo === episodeNo,
|
|
||||||
);
|
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
throw new ConflictException('Episode number already exists in this drama');
|
throw new ConflictException("Episode number already exists in this drama");
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -271,13 +283,13 @@ export class DramasService {
|
|||||||
description: dto.description,
|
description: dto.description,
|
||||||
byteplusVid: dto.byteplusVid,
|
byteplusVid: dto.byteplusVid,
|
||||||
coverId: dto.coverId,
|
coverId: dto.coverId,
|
||||||
coverUrl: dto.coverUrl ?? '',
|
coverUrl: dto.coverUrl ?? "",
|
||||||
videoUrl: dto.videoUrl,
|
videoUrl: dto.videoUrl,
|
||||||
trialDurationSeconds: dto.trialDurationSeconds,
|
trialDurationSeconds: dto.trialDurationSeconds,
|
||||||
durationSeconds: dto.durationSeconds ?? 0,
|
durationSeconds: dto.durationSeconds ?? 0,
|
||||||
width: dto.width,
|
width: dto.width,
|
||||||
height: dto.height,
|
height: dto.height,
|
||||||
isPaid: freeType === 'paid',
|
isPaid: freeType === "paid",
|
||||||
freeType,
|
freeType,
|
||||||
unlockPrice: price,
|
unlockPrice: price,
|
||||||
price,
|
price,
|
||||||
@@ -294,53 +306,39 @@ export class DramasService {
|
|||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
async configureEpisodes(
|
async configureEpisodes(dramaId: number, dto: ConfigureEpisodesDto): Promise<PaginatedResponse<EpisodeRecord>> {
|
||||||
dramaId: number,
|
|
||||||
dto: ConfigureEpisodesDto,
|
|
||||||
): Promise<PaginatedResponse<EpisodeRecord>> {
|
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const dramaRepository = this.dataSource.getRepository(Drama);
|
const dramaRepository = this.dataSource.getRepository(Drama);
|
||||||
const drama = await dramaRepository.findOne({ where: { id: dramaId } });
|
const drama = await dramaRepository.findOne({ where: { id: dramaId } });
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const episodeRepository = this.dataSource.getRepository(Episode);
|
const episodeRepository = this.dataSource.getRepository(Episode);
|
||||||
const existing = await episodeRepository.find({
|
const existing = await episodeRepository.find({
|
||||||
where: { dramaId },
|
where: { dramaId },
|
||||||
order: { episodeNo: 'ASC' },
|
order: { episodeNo: "ASC" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.episodeCount < existing.length) {
|
if (dto.episodeCount < existing.length) {
|
||||||
const removable = existing.slice(dto.episodeCount);
|
const removable = existing.slice(dto.episodeCount);
|
||||||
const hasLockedEpisode = removable.some(
|
const hasLockedEpisode = removable.some((episode) => episode.byteplusVid || episode.publishStatus !== PublishStatus.Draft || episode.reviewStatus !== "not_submitted");
|
||||||
(episode) =>
|
|
||||||
episode.byteplusVid ||
|
|
||||||
episode.publishStatus !== PublishStatus.Draft ||
|
|
||||||
episode.reviewStatus !== 'not_submitted',
|
|
||||||
);
|
|
||||||
if (hasLockedEpisode) {
|
if (hasLockedEpisode) {
|
||||||
throw new UnprocessableEntityException(
|
throw new UnprocessableEntityException("Only empty draft tail episodes can be removed");
|
||||||
'Only empty draft tail episodes can be removed',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await episodeRepository.remove(removable);
|
await episodeRepository.remove(removable);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (
|
for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
|
||||||
let episodeNo = existing.length + 1;
|
|
||||||
episodeNo <= dto.episodeCount;
|
|
||||||
episodeNo++
|
|
||||||
) {
|
|
||||||
await this.createEpisode({
|
await this.createEpisode({
|
||||||
dramaId,
|
dramaId,
|
||||||
episodeNo,
|
episodeNo,
|
||||||
title: `第${episodeNo}集`,
|
title: `第${episodeNo}集`,
|
||||||
coverUrl: drama.coverUrl,
|
coverUrl: drama.coverUrl,
|
||||||
durationSeconds: 0,
|
durationSeconds: 0,
|
||||||
freeType: 'free',
|
freeType: "free",
|
||||||
price: 0,
|
price: 0,
|
||||||
reviewStatus: 'not_submitted',
|
reviewStatus: "not_submitted",
|
||||||
publishStatus: PublishStatus.Draft,
|
publishStatus: PublishStatus.Draft,
|
||||||
status: PublishStatus.Draft,
|
status: PublishStatus.Draft,
|
||||||
});
|
});
|
||||||
@@ -348,7 +346,7 @@ export class DramasService {
|
|||||||
|
|
||||||
const list = await episodeRepository.find({
|
const list = await episodeRepository.find({
|
||||||
where: { dramaId },
|
where: { dramaId },
|
||||||
order: { episodeNo: 'ASC' },
|
order: { episodeNo: "ASC" },
|
||||||
});
|
});
|
||||||
const records = list.map((episode) => this.toEpisodeRecord(episode));
|
const records = list.map((episode) => this.toEpisodeRecord(episode));
|
||||||
return {
|
return {
|
||||||
@@ -359,24 +357,15 @@ export class DramasService {
|
|||||||
|
|
||||||
const drama = this.dramas.find((item) => item.id === dramaId);
|
const drama = this.dramas.find((item) => item.id === dramaId);
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = this.episodes
|
const existing = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
|
||||||
.filter((episode) => episode.dramaId === dramaId)
|
|
||||||
.sort((left, right) => left.episodeNo - right.episodeNo);
|
|
||||||
if (dto.episodeCount < existing.length) {
|
if (dto.episodeCount < existing.length) {
|
||||||
const removable = existing.slice(dto.episodeCount);
|
const removable = existing.slice(dto.episodeCount);
|
||||||
const hasLockedEpisode = removable.some(
|
const hasLockedEpisode = removable.some((episode) => episode.byteplusVid || episode.publishStatus !== PublishStatus.Draft || episode.reviewStatus !== "not_submitted");
|
||||||
(episode) =>
|
|
||||||
episode.byteplusVid ||
|
|
||||||
episode.publishStatus !== PublishStatus.Draft ||
|
|
||||||
episode.reviewStatus !== 'not_submitted',
|
|
||||||
);
|
|
||||||
if (hasLockedEpisode) {
|
if (hasLockedEpisode) {
|
||||||
throw new UnprocessableEntityException(
|
throw new UnprocessableEntityException("Only empty draft tail episodes can be removed");
|
||||||
'Only empty draft tail episodes can be removed',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const episode of removable) {
|
for (const episode of removable) {
|
||||||
@@ -387,47 +376,36 @@ export class DramasService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (
|
for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
|
||||||
let episodeNo = existing.length + 1;
|
|
||||||
episodeNo <= dto.episodeCount;
|
|
||||||
episodeNo++
|
|
||||||
) {
|
|
||||||
await this.createEpisode({
|
await this.createEpisode({
|
||||||
dramaId,
|
dramaId,
|
||||||
episodeNo,
|
episodeNo,
|
||||||
title: `第${episodeNo}集`,
|
title: `第${episodeNo}集`,
|
||||||
coverUrl: drama.coverUrl,
|
coverUrl: drama.coverUrl,
|
||||||
durationSeconds: 0,
|
durationSeconds: 0,
|
||||||
freeType: 'free',
|
freeType: "free",
|
||||||
price: 0,
|
price: 0,
|
||||||
reviewStatus: 'not_submitted',
|
reviewStatus: "not_submitted",
|
||||||
publishStatus: PublishStatus.Draft,
|
publishStatus: PublishStatus.Draft,
|
||||||
status: PublishStatus.Draft,
|
status: PublishStatus.Draft,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = this.episodes
|
const list = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
|
||||||
.filter((episode) => episode.dramaId === dramaId)
|
|
||||||
.sort((left, right) => left.episodeNo - right.episodeNo);
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
pagination: { page: 1, pageSize: list.length, total: list.length },
|
pagination: { page: 1, pageSize: list.length, total: list.length },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAdminEpisodes(
|
async listAdminEpisodes(dramaId: number, pagination: PaginationInput): Promise<PaginatedResponse<EpisodeRecord>> {
|
||||||
dramaId: number,
|
|
||||||
pagination: PaginationInput,
|
|
||||||
): Promise<PaginatedResponse<EpisodeRecord>> {
|
|
||||||
await this.getDramaOrThrow(dramaId);
|
await this.getDramaOrThrow(dramaId);
|
||||||
|
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
||||||
const [records, total] = await this.dataSource
|
const [records, total] = await this.dataSource.getRepository(Episode).findAndCount({
|
||||||
.getRepository(Episode)
|
|
||||||
.findAndCount({
|
|
||||||
where: { dramaId },
|
where: { dramaId },
|
||||||
order: { episodeNo: 'ASC' },
|
order: { episodeNo: "ASC" },
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
});
|
});
|
||||||
@@ -437,9 +415,7 @@ export class DramasService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = this.episodes
|
const records = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
|
||||||
.filter((episode) => episode.dramaId === dramaId)
|
|
||||||
.sort((left, right) => left.episodeNo - right.episodeNo);
|
|
||||||
return this.paginate(records, pagination);
|
return this.paginate(records, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,42 +424,28 @@ export class DramasService {
|
|||||||
const repository = this.dataSource.getRepository(Episode);
|
const repository = this.dataSource.getRepository(Episode);
|
||||||
const episode = await repository.findOne({ where: { id } });
|
const episode = await repository.findOne({ where: { id } });
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
const freeType =
|
const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? "paid" : "free");
|
||||||
dto.freeType ??
|
|
||||||
(dto.isPaid === undefined
|
|
||||||
? episode.freeType
|
|
||||||
: dto.isPaid
|
|
||||||
? 'paid'
|
|
||||||
: 'free');
|
|
||||||
repository.merge(episode, {
|
repository.merge(episode, {
|
||||||
...dto,
|
...dto,
|
||||||
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
|
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
|
||||||
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
|
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
|
||||||
freeType,
|
freeType,
|
||||||
isPaid: freeType === 'paid',
|
isPaid: freeType === "paid",
|
||||||
price: dto.price ?? dto.unlockPrice ?? episode.price,
|
price: dto.price ?? dto.unlockPrice ?? episode.price,
|
||||||
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
|
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
|
||||||
status: dto.status ?? dto.publishStatus ?? episode.status,
|
status: dto.status ?? dto.publishStatus ?? episode.status,
|
||||||
publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus,
|
publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus,
|
||||||
publishAt: dto.publishAt ? new Date(dto.publishAt) : episode.publishAt,
|
publishAt: dto.publishAt ? new Date(dto.publishAt) : episode.publishAt,
|
||||||
nextReleaseAt: dto.nextReleaseAt
|
nextReleaseAt: dto.nextReleaseAt ? new Date(dto.nextReleaseAt) : episode.nextReleaseAt,
|
||||||
? new Date(dto.nextReleaseAt)
|
|
||||||
: episode.nextReleaseAt,
|
|
||||||
});
|
});
|
||||||
const saved = await repository.save(episode);
|
const saved = await repository.save(episode);
|
||||||
return this.toEpisodeRecord(saved);
|
return this.toEpisodeRecord(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
const episode = await this.getMemEpisodeOrThrow(id);
|
const episode = await this.getMemEpisodeOrThrow(id);
|
||||||
const freeType =
|
const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? "paid" : "free");
|
||||||
dto.freeType ??
|
|
||||||
(dto.isPaid === undefined
|
|
||||||
? episode.freeType
|
|
||||||
: dto.isPaid
|
|
||||||
? 'paid'
|
|
||||||
: 'free');
|
|
||||||
Object.assign(episode, {
|
Object.assign(episode, {
|
||||||
...dto,
|
...dto,
|
||||||
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
|
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
|
||||||
@@ -491,7 +453,7 @@ export class DramasService {
|
|||||||
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
|
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
|
||||||
seq: dto.seq ?? dto.episodeNo ?? episode.seq,
|
seq: dto.seq ?? dto.episodeNo ?? episode.seq,
|
||||||
freeType,
|
freeType,
|
||||||
isPaid: freeType === 'paid',
|
isPaid: freeType === "paid",
|
||||||
price: dto.price ?? dto.unlockPrice ?? episode.price,
|
price: dto.price ?? dto.unlockPrice ?? episode.price,
|
||||||
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
|
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
|
||||||
status: dto.status ?? dto.publishStatus ?? episode.status,
|
status: dto.status ?? dto.publishStatus ?? episode.status,
|
||||||
@@ -501,47 +463,32 @@ export class DramasService {
|
|||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateDramaEpisode(
|
async updateDramaEpisode(dramaId: number, episodeId: number, dto: UpdateEpisodeDto): Promise<EpisodeRecord> {
|
||||||
dramaId: number,
|
|
||||||
episodeId: number,
|
|
||||||
dto: UpdateEpisodeDto,
|
|
||||||
): Promise<EpisodeRecord> {
|
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const episode = await this.dataSource.getRepository(Episode).findOne({
|
const episode = await this.dataSource.getRepository(Episode).findOne({
|
||||||
where: { id: episodeId, dramaId },
|
where: { id: episodeId, dramaId },
|
||||||
});
|
});
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const episode = await this.getMemEpisodeOrThrow(episodeId);
|
const episode = await this.getMemEpisodeOrThrow(episodeId);
|
||||||
if (episode.dramaId !== dramaId) {
|
if (episode.dramaId !== dramaId) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.updateEpisode(episodeId, { ...dto, dramaId });
|
return this.updateEpisode(episodeId, { ...dto, dramaId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async attachEpisodeMedia(input: {
|
async attachEpisodeMedia(input: { episodeId: number; byteplusVid: string; videoUrl: string; storageBucket: string; objectKey: string; durationSeconds: number; width: number; height: number; reviewStatus: EpisodeRecord["reviewStatus"]; reviewMessage?: string }): Promise<EpisodeRecord> {
|
||||||
episodeId: number;
|
|
||||||
byteplusVid: string;
|
|
||||||
videoUrl: string;
|
|
||||||
storageBucket: string;
|
|
||||||
objectKey: string;
|
|
||||||
durationSeconds: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
reviewStatus: EpisodeRecord['reviewStatus'];
|
|
||||||
reviewMessage?: string;
|
|
||||||
}): Promise<EpisodeRecord> {
|
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const repository = this.dataSource.getRepository(Episode);
|
const repository = this.dataSource.getRepository(Episode);
|
||||||
const episode = await repository.findOne({
|
const episode = await repository.findOne({
|
||||||
where: { id: input.episodeId },
|
where: { id: input.episodeId },
|
||||||
});
|
});
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
episode.byteplusVid = input.byteplusVid;
|
episode.byteplusVid = input.byteplusVid;
|
||||||
episode.videoUrl = input.videoUrl;
|
episode.videoUrl = input.videoUrl;
|
||||||
@@ -570,16 +517,12 @@ export class DramasService {
|
|||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateEpisodeReviewStatus(
|
async updateEpisodeReviewStatus(episodeId: number, reviewStatus: EpisodeRecord["reviewStatus"], reviewMessage?: string): Promise<EpisodeRecord> {
|
||||||
episodeId: number,
|
|
||||||
reviewStatus: EpisodeRecord['reviewStatus'],
|
|
||||||
reviewMessage?: string,
|
|
||||||
): Promise<EpisodeRecord> {
|
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const repository = this.dataSource.getRepository(Episode);
|
const repository = this.dataSource.getRepository(Episode);
|
||||||
const episode = await repository.findOne({ where: { id: episodeId } });
|
const episode = await repository.findOne({ where: { id: episodeId } });
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
episode.reviewStatus = reviewStatus;
|
episode.reviewStatus = reviewStatus;
|
||||||
episode.reviewMessage = reviewMessage;
|
episode.reviewMessage = reviewMessage;
|
||||||
@@ -594,83 +537,168 @@ export class DramasService {
|
|||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listPublishedDramas(
|
async listPublishedDramas(input: PublishedDramaListInput): Promise<PaginatedResponse<PublicDramaRecord>> {
|
||||||
input: PublishedDramaListInput,
|
|
||||||
): Promise<PaginatedResponse<DramaRecord>> {
|
|
||||||
const title = input.title?.trim();
|
const title = input.title?.trim();
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const { page, pageSize, skip, take } = this.resolvePagination(input);
|
const { page, pageSize, skip, take } = this.resolvePagination(input);
|
||||||
const [records, total] = await this.dataSource
|
const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
|
||||||
.getRepository(Drama)
|
|
||||||
.findAndCount({
|
|
||||||
where: {
|
where: {
|
||||||
status: PublishStatus.Published,
|
status: PublishStatus.Published,
|
||||||
...(title ? { title: Like(`%${title}%`) } : {}),
|
...(title ? { title: Like(`%${title}%`) } : {}),
|
||||||
|
...(input.isRecommended !== undefined ? { isRecommended: input.isRecommended } : {}),
|
||||||
},
|
},
|
||||||
order: { sortOrder: 'DESC', id: 'DESC' },
|
order: { sortOrder: "DESC", id: "DESC" },
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
});
|
});
|
||||||
const list = await Promise.all(
|
const list = await Promise.all(records.map(async (drama) => this.toPublicDramaRecord(await this.toDramaRecordWithCounts(drama))));
|
||||||
records.map((drama) => this.toDramaRecordWithCounts(drama)),
|
|
||||||
);
|
|
||||||
return { list, pagination: { page, pageSize, total } };
|
return { list, pagination: { page, pageSize, total } };
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.paginate(
|
const page = this.paginate(
|
||||||
this.sortDramas(
|
this.sortDramas(
|
||||||
this.dramas
|
this.dramas
|
||||||
.filter((item) => item.status === PublishStatus.Published)
|
.filter((item) => item.status === PublishStatus.Published)
|
||||||
.filter((item) => !title || item.title.includes(title))
|
.filter((item) => !title || item.title.includes(title))
|
||||||
|
.filter((item) => input.isRecommended === undefined || item.isRecommended === input.isRecommended)
|
||||||
.map((drama) => this.withEpisodeCounts(drama)),
|
.map((drama) => this.withEpisodeCounts(drama)),
|
||||||
),
|
),
|
||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
list: page.list.map((drama) => this.toPublicDramaRecord(drama)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRecommendedDramas(input: PublishedDramaListInput): Promise<PaginatedResponse<PublicDramaRecord>> {
|
||||||
|
return this.listPublishedDramas({
|
||||||
|
...input,
|
||||||
|
isRecommended: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async presearchVideos(input: VideoPresearchInput): Promise<PaginatedResponse<VideoPresearchRecord>> {
|
||||||
|
const keyword = input.keyword?.trim();
|
||||||
|
if (!keyword) {
|
||||||
|
const { page, pageSize } = this.resolvePagination(input);
|
||||||
|
return { list: [], pagination: { page, pageSize, total: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const { page, pageSize, skip, take } = this.resolvePagination(input);
|
||||||
|
const likeKeyword = `%${keyword}%`;
|
||||||
|
const baseQuery = this.dataSource
|
||||||
|
.getRepository(Episode)
|
||||||
|
.createQueryBuilder("episode")
|
||||||
|
.innerJoin(Drama, "drama", "drama.id = episode.dramaId")
|
||||||
|
.where("drama.status = :published", {
|
||||||
|
published: PublishStatus.Published,
|
||||||
|
})
|
||||||
|
.andWhere("episode.status = :published", {
|
||||||
|
published: PublishStatus.Published,
|
||||||
|
})
|
||||||
|
.andWhere("episode.publishStatus = :published", {
|
||||||
|
published: PublishStatus.Published,
|
||||||
|
})
|
||||||
|
.andWhere("episode.reviewStatus = :approved", {
|
||||||
|
approved: "approved",
|
||||||
|
})
|
||||||
|
.andWhere("(episode.title LIKE :keyword OR episode.description LIKE :keyword OR drama.title LIKE :keyword OR drama.description LIKE :keyword)", { keyword: likeKeyword });
|
||||||
|
|
||||||
|
const total = await baseQuery.clone().getCount();
|
||||||
|
const rows = await baseQuery
|
||||||
|
.clone()
|
||||||
|
.select([
|
||||||
|
"episode.id AS id",
|
||||||
|
"episode.dramaId AS dramaId",
|
||||||
|
"episode.episodeNo AS episodeNo",
|
||||||
|
"episode.title AS title",
|
||||||
|
"episode.description AS description",
|
||||||
|
"episode.coverUrl AS coverUrl",
|
||||||
|
"episode.durationSeconds AS durationSeconds",
|
||||||
|
"episode.byteplusVid AS byteplusVid",
|
||||||
|
"episode.videoUrl AS videoUrl",
|
||||||
|
"episode.freeType AS freeType",
|
||||||
|
"episode.price AS price",
|
||||||
|
"drama.title AS dramaTitle",
|
||||||
|
"drama.coverUrl AS dramaCoverUrl",
|
||||||
|
])
|
||||||
|
.orderBy("drama.sortOrder", "DESC")
|
||||||
|
.addOrderBy("drama.id", "DESC")
|
||||||
|
.addOrderBy("episode.episodeNo", "ASC")
|
||||||
|
.offset(skip)
|
||||||
|
.limit(take)
|
||||||
|
.getRawMany<VideoPresearchRecord>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
id: Number(row.id),
|
||||||
|
dramaId: Number(row.dramaId),
|
||||||
|
episodeNo: Number(row.episodeNo),
|
||||||
|
durationSeconds: Number(row.durationSeconds),
|
||||||
|
price: Number(row.price),
|
||||||
|
})),
|
||||||
|
pagination: { page, pageSize, total },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedKeyword = keyword.toLowerCase();
|
||||||
|
const records = this.episodes
|
||||||
|
.filter((episode) => episode.status === PublishStatus.Published && episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved")
|
||||||
|
.map((episode) => {
|
||||||
|
const drama = this.dramas.find((item) => item.id === episode.dramaId);
|
||||||
|
return { episode, drama };
|
||||||
|
})
|
||||||
|
.filter((item): item is { episode: EpisodeRecord; drama: DramaRecord } => Boolean(item.drama) && item.drama?.status === PublishStatus.Published)
|
||||||
|
.filter(({ episode, drama }) => [episode.title, episode.description, drama.title, drama.description].some((value) => value?.toLowerCase().includes(normalizedKeyword)))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (right.drama.sortOrder !== left.drama.sortOrder) {
|
||||||
|
return right.drama.sortOrder - left.drama.sortOrder;
|
||||||
|
}
|
||||||
|
if (right.drama.id !== left.drama.id) {
|
||||||
|
return right.drama.id - left.drama.id;
|
||||||
|
}
|
||||||
|
return left.episode.episodeNo - right.episode.episodeNo;
|
||||||
|
})
|
||||||
|
.map(({ episode, drama }) => this.toVideoPresearchRecord(episode, drama));
|
||||||
|
|
||||||
|
return this.paginate(records, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPublishedDramaOrThrow(id: number): Promise<DramaRecord> {
|
async getPublishedDramaOrThrow(id: number): Promise<DramaRecord> {
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const drama = await this.dataSource
|
const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id, status: PublishStatus.Published } });
|
||||||
.getRepository(Drama)
|
|
||||||
.findOne({ where: { id, status: PublishStatus.Published } });
|
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
return this.toDramaRecordWithCounts(drama);
|
return this.toDramaRecordWithCounts(drama);
|
||||||
}
|
}
|
||||||
|
|
||||||
const drama = this.dramas.find(
|
const drama = this.dramas.find((item) => item.id === id && item.status === PublishStatus.Published);
|
||||||
(item) => item.id === id && item.status === PublishStatus.Published,
|
|
||||||
);
|
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.withEpisodeCounts(drama);
|
return this.withEpisodeCounts(drama);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listPublishedEpisodes(
|
async listPublishedEpisodes(dramaId: number, pagination: PaginationInput): Promise<PaginatedResponse<EpisodeRecord>> {
|
||||||
dramaId: number,
|
|
||||||
pagination: PaginationInput,
|
|
||||||
): Promise<PaginatedResponse<EpisodeRecord>> {
|
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const drama = await this.dataSource
|
const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id: dramaId, status: PublishStatus.Published } });
|
||||||
.getRepository(Drama)
|
|
||||||
.findOne({ where: { id: dramaId, status: PublishStatus.Published } });
|
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
||||||
const [records, total] = await this.dataSource
|
const [records, total] = await this.dataSource.getRepository(Episode).findAndCount({
|
||||||
.getRepository(Episode)
|
|
||||||
.findAndCount({
|
|
||||||
where: {
|
where: {
|
||||||
dramaId,
|
dramaId,
|
||||||
status: PublishStatus.Published,
|
status: PublishStatus.Published,
|
||||||
publishStatus: PublishStatus.Published,
|
publishStatus: PublishStatus.Published,
|
||||||
reviewStatus: 'approved',
|
reviewStatus: "approved",
|
||||||
},
|
},
|
||||||
order: { episodeNo: 'ASC' },
|
order: { episodeNo: "ASC" },
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
});
|
});
|
||||||
@@ -682,19 +710,12 @@ export class DramasService {
|
|||||||
|
|
||||||
const drama = this.dramas.find((item) => item.id === dramaId);
|
const drama = this.dramas.find((item) => item.id === dramaId);
|
||||||
if (!drama || drama.status !== PublishStatus.Published) {
|
if (!drama || drama.status !== PublishStatus.Published) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const episodes = this.episodes
|
const episodes = this.episodes
|
||||||
.filter(
|
.filter((item) => item.dramaId === dramaId && item.status === PublishStatus.Published)
|
||||||
(item) =>
|
.filter((item) => item.publishStatus === PublishStatus.Published && item.reviewStatus === "approved")
|
||||||
item.dramaId === dramaId && item.status === PublishStatus.Published,
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
(item) =>
|
|
||||||
item.publishStatus === PublishStatus.Published &&
|
|
||||||
item.reviewStatus === 'approved',
|
|
||||||
)
|
|
||||||
.sort((left, right) => left.episodeNo - right.episodeNo);
|
.sort((left, right) => left.episodeNo - right.episodeNo);
|
||||||
|
|
||||||
return this.paginate(episodes, pagination);
|
return this.paginate(episodes, pagination);
|
||||||
@@ -707,36 +728,29 @@ export class DramasService {
|
|||||||
id: episodeId,
|
id: episodeId,
|
||||||
status: PublishStatus.Published,
|
status: PublishStatus.Published,
|
||||||
publishStatus: PublishStatus.Published,
|
publishStatus: PublishStatus.Published,
|
||||||
reviewStatus: 'approved',
|
reviewStatus: "approved",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
const drama = await this.dataSource
|
const drama = await this.dataSource.getRepository(Drama).findOne({
|
||||||
.getRepository(Drama)
|
|
||||||
.findOne({
|
|
||||||
where: { id: episode.dramaId, status: PublishStatus.Published },
|
where: { id: episode.dramaId, status: PublishStatus.Published },
|
||||||
});
|
});
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
return this.toEpisodeRecord(episode);
|
return this.toEpisodeRecord(episode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const episode = this.episodes.find((item) => item.id === episodeId);
|
const episode = this.episodes.find((item) => item.id === episodeId);
|
||||||
if (
|
if (!episode || episode.status !== PublishStatus.Published || episode.publishStatus !== PublishStatus.Published || episode.reviewStatus !== "approved") {
|
||||||
!episode ||
|
throw new NotFoundException("Episode not found");
|
||||||
episode.status !== PublishStatus.Published ||
|
|
||||||
episode.publishStatus !== PublishStatus.Published ||
|
|
||||||
episode.reviewStatus !== 'approved'
|
|
||||||
) {
|
|
||||||
throw new NotFoundException('Episode not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const drama = this.dramas.find((item) => item.id === episode.dramaId);
|
const drama = this.dramas.find((item) => item.id === episode.dramaId);
|
||||||
if (!drama || drama.status !== PublishStatus.Published) {
|
if (!drama || drama.status !== PublishStatus.Published) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return episode;
|
return episode;
|
||||||
@@ -744,11 +758,9 @@ export class DramasService {
|
|||||||
|
|
||||||
async getEpisodeOrThrow(episodeId: number): Promise<EpisodeRecord> {
|
async getEpisodeOrThrow(episodeId: number): Promise<EpisodeRecord> {
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const episode = await this.dataSource
|
const episode = await this.dataSource.getRepository(Episode).findOne({ where: { id: episodeId } });
|
||||||
.getRepository(Episode)
|
|
||||||
.findOne({ where: { id: episodeId } });
|
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
return this.toEpisodeRecord(episode);
|
return this.toEpisodeRecord(episode);
|
||||||
}
|
}
|
||||||
@@ -761,41 +773,39 @@ export class DramasService {
|
|||||||
const dramaRepository = this.dataSource.getRepository(Drama);
|
const dramaRepository = this.dataSource.getRepository(Drama);
|
||||||
const drama = await dramaRepository.findOne({ where: { id: albumId } });
|
const drama = await dramaRepository.findOne({ where: { id: albumId } });
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
const episodeRepository = this.dataSource.getRepository(Episode);
|
const episodeRepository = this.dataSource.getRepository(Episode);
|
||||||
const episodes = await episodeRepository.find({
|
const episodes = await episodeRepository.find({
|
||||||
where: { dramaId: albumId },
|
where: { dramaId: albumId },
|
||||||
});
|
});
|
||||||
for (const episode of episodes) {
|
for (const episode of episodes) {
|
||||||
episode.reviewStatus = 'approved';
|
episode.reviewStatus = "approved";
|
||||||
episode.reviewMessage = 'Mock review approved';
|
episode.reviewMessage = "Mock review approved";
|
||||||
}
|
}
|
||||||
await episodeRepository.save(episodes);
|
await episodeRepository.save(episodes);
|
||||||
return { albumId, reviewStatus: 'approved' };
|
return { albumId, reviewStatus: "approved" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const album = this.dramas.find((item) => item.id === albumId);
|
const album = this.dramas.find((item) => item.id === albumId);
|
||||||
if (!album) {
|
if (!album) {
|
||||||
throw new NotFoundException('Drama not found');
|
throw new NotFoundException("Drama not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.episodes
|
this.episodes
|
||||||
.filter((episode) => episode.albumId === albumId)
|
.filter((episode) => episode.albumId === albumId)
|
||||||
.forEach((episode) => {
|
.forEach((episode) => {
|
||||||
episode.reviewStatus = 'approved';
|
episode.reviewStatus = "approved";
|
||||||
episode.reviewMessage = 'Mock review approved';
|
episode.reviewMessage = "Mock review approved";
|
||||||
episode.updatedAt = new Date().toISOString();
|
episode.updatedAt = new Date().toISOString();
|
||||||
});
|
});
|
||||||
|
|
||||||
return { albumId, reviewStatus: 'approved' };
|
return { albumId, reviewStatus: "approved" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAllDramas(): Promise<DramaRecord[]> {
|
async listAllDramas(): Promise<DramaRecord[]> {
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const records = await this.dataSource
|
const records = await this.dataSource.getRepository(Drama).find({ order: { sortOrder: "DESC", id: "DESC" } });
|
||||||
.getRepository(Drama)
|
|
||||||
.find({ order: { sortOrder: 'DESC', id: 'DESC' } });
|
|
||||||
return Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
|
return Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,24 +814,19 @@ export class DramasService {
|
|||||||
|
|
||||||
async listAllEpisodes(): Promise<EpisodeRecord[]> {
|
async listAllEpisodes(): Promise<EpisodeRecord[]> {
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const records = await this.dataSource
|
const records = await this.dataSource.getRepository(Episode).find({ order: { id: "ASC" } });
|
||||||
.getRepository(Episode)
|
|
||||||
.find({ order: { id: 'ASC' } });
|
|
||||||
return records.map((episode) => this.toEpisodeRecord(episode));
|
return records.map((episode) => this.toEpisodeRecord(episode));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...this.episodes].sort((left, right) => left.id - right.id);
|
return [...this.episodes].sort((left, right) => left.id - right.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateEpisodePublishStatus(
|
async updateEpisodePublishStatus(episodeId: number, publishStatus: PublishStatus): Promise<EpisodeRecord> {
|
||||||
episodeId: number,
|
|
||||||
publishStatus: PublishStatus,
|
|
||||||
): Promise<EpisodeRecord> {
|
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const repository = this.dataSource.getRepository(Episode);
|
const repository = this.dataSource.getRepository(Episode);
|
||||||
const episode = await repository.findOne({ where: { id: episodeId } });
|
const episode = await repository.findOne({ where: { id: episodeId } });
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
episode.publishStatus = publishStatus;
|
episode.publishStatus = publishStatus;
|
||||||
episode.status = publishStatus;
|
episode.status = publishStatus;
|
||||||
@@ -843,7 +848,7 @@ export class DramasService {
|
|||||||
private getMemEpisodeOrThrow(episodeId: number): EpisodeRecord {
|
private getMemEpisodeOrThrow(episodeId: number): EpisodeRecord {
|
||||||
const episode = this.episodes.find((item) => item.id === episodeId);
|
const episode = this.episodes.find((item) => item.id === episodeId);
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
throw new NotFoundException('Episode not found');
|
throw new NotFoundException("Episode not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return episode;
|
return episode;
|
||||||
@@ -857,17 +862,13 @@ export class DramasService {
|
|||||||
where: {
|
where: {
|
||||||
dramaId,
|
dramaId,
|
||||||
publishStatus: PublishStatus.Published,
|
publishStatus: PublishStatus.Published,
|
||||||
reviewStatus: 'approved',
|
reviewStatus: "approved",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes);
|
return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDramaRecord(
|
private toDramaRecord(entity: Drama, totalEpisodes: number, publishedEpisodes: number): DramaRecord {
|
||||||
entity: Drama,
|
|
||||||
totalEpisodes: number,
|
|
||||||
publishedEpisodes: number,
|
|
||||||
): DramaRecord {
|
|
||||||
return {
|
return {
|
||||||
id: Number(entity.id),
|
id: Number(entity.id),
|
||||||
tiktokAlbumId: entity.tiktokAlbumId,
|
tiktokAlbumId: entity.tiktokAlbumId,
|
||||||
@@ -893,6 +894,31 @@ export class DramasService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toPublicDramaRecord(record: DramaRecord): PublicDramaRecord {
|
||||||
|
const { portraitCoverUrl, landscapeCoverUrl, ...publicRecord } = record;
|
||||||
|
void portraitCoverUrl;
|
||||||
|
void landscapeCoverUrl;
|
||||||
|
return publicRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toVideoPresearchRecord(episode: EpisodeRecord, drama: DramaRecord): VideoPresearchRecord {
|
||||||
|
return {
|
||||||
|
id: episode.id,
|
||||||
|
dramaId: episode.dramaId,
|
||||||
|
episodeNo: episode.episodeNo,
|
||||||
|
title: episode.title,
|
||||||
|
description: episode.description,
|
||||||
|
coverUrl: episode.coverUrl,
|
||||||
|
durationSeconds: episode.durationSeconds,
|
||||||
|
byteplusVid: episode.byteplusVid,
|
||||||
|
videoUrl: episode.videoUrl,
|
||||||
|
freeType: episode.freeType,
|
||||||
|
price: episode.price,
|
||||||
|
dramaTitle: drama.title,
|
||||||
|
dramaCoverUrl: drama.coverUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private toEpisodeRecord(entity: Episode): EpisodeRecord {
|
private toEpisodeRecord(entity: Episode): EpisodeRecord {
|
||||||
const dramaId = Number(entity.dramaId);
|
const dramaId = Number(entity.dramaId);
|
||||||
return {
|
return {
|
||||||
@@ -964,18 +990,11 @@ export class DramasService {
|
|||||||
return {
|
return {
|
||||||
...drama,
|
...drama,
|
||||||
totalEpisodes: episodes.length,
|
totalEpisodes: episodes.length,
|
||||||
publishedEpisodes: episodes.filter(
|
publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length,
|
||||||
(episode) =>
|
|
||||||
episode.publishStatus === PublishStatus.Published &&
|
|
||||||
episode.reviewStatus === 'approved',
|
|
||||||
).length,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private paginate<T>(
|
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
|
||||||
records: T[],
|
|
||||||
input: PaginationInput,
|
|
||||||
): PaginatedResponse<T> {
|
|
||||||
const page = Math.max(input.page, 1);
|
const page = Math.max(input.page, 1);
|
||||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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/playback-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/playback-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/playback-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/playback-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/playback-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/playback-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