1. 为后台剧集列表添加标题和推荐状态筛选参数 2. 新增前台推荐剧集列表接口和前置搜索接口 3. 添加可选布尔值解析工具方法 4. 新增对应接口的端到端测试用例
1012 lines
41 KiB
TypeScript
1012 lines
41 KiB
TypeScript
import { ConflictException, Injectable, NotFoundException, Optional, UnprocessableEntityException } from "@nestjs/common";
|
|
import { InjectDataSource } from "@nestjs/typeorm";
|
|
import { DataSource, Like } from "typeorm";
|
|
import { PaginatedResponse } from "../../common/dto/paginated-response.dto";
|
|
import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto";
|
|
import { CreateDramaDto } from "./dto/create-drama.dto";
|
|
import { CreateEpisodeDto } from "./dto/create-episode.dto";
|
|
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 {
|
|
page: number;
|
|
pageSize: number;
|
|
}
|
|
|
|
interface DramaListInput extends PaginationInput {
|
|
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()
|
|
export class DramasService {
|
|
private dramaSequence = 1;
|
|
private episodeSequence = 1;
|
|
private readonly dramas: DramaRecord[] = [];
|
|
private readonly episodes: EpisodeRecord[] = [];
|
|
|
|
constructor(
|
|
@Optional()
|
|
@InjectDataSource()
|
|
readonly dataSource?: DataSource,
|
|
) {}
|
|
|
|
async createDrama(dto: CreateDramaDto): Promise<DramaRecord> {
|
|
const { totalEpisodes } = dto;
|
|
if (this.hasDatabase()) {
|
|
const repository = this.dataSource.getRepository(Drama);
|
|
const entity = repository.create({
|
|
tiktokAlbumId: dto.tiktokAlbumId,
|
|
title: dto.title,
|
|
description: dto.description,
|
|
coverId: dto.coverId,
|
|
coverUrl: dto.coverUrl,
|
|
portraitCoverUrl: dto.portraitCoverUrl,
|
|
landscapeCoverUrl: dto.landscapeCoverUrl,
|
|
type: dto.type,
|
|
tags: dto.tags ?? [],
|
|
region: dto.region,
|
|
language: dto.language,
|
|
year: dto.year,
|
|
status: dto.status ?? PublishStatus.Draft,
|
|
sortOrder: dto.sortOrder ?? 0,
|
|
isRecommended: dto.isRecommended ?? false,
|
|
onlineVersion: dto.onlineVersion ?? 1,
|
|
});
|
|
const saved = await repository.save(entity);
|
|
if (totalEpisodes) {
|
|
await this.configureEpisodes(Number(saved.id), {
|
|
episodeCount: totalEpisodes,
|
|
});
|
|
return this.getDramaOrThrow(Number(saved.id));
|
|
}
|
|
return this.toDramaRecord(saved, 0, 0);
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const drama: DramaRecord = {
|
|
id: this.dramaSequence++,
|
|
tiktokAlbumId: dto.tiktokAlbumId,
|
|
title: dto.title,
|
|
description: dto.description,
|
|
coverId: dto.coverId,
|
|
coverUrl: dto.coverUrl,
|
|
portraitCoverUrl: dto.portraitCoverUrl,
|
|
landscapeCoverUrl: dto.landscapeCoverUrl,
|
|
type: dto.type,
|
|
tags: dto.tags ?? [],
|
|
region: dto.region,
|
|
language: dto.language,
|
|
year: dto.year,
|
|
status: dto.status ?? PublishStatus.Draft,
|
|
sortOrder: dto.sortOrder ?? 0,
|
|
isRecommended: dto.isRecommended ?? false,
|
|
onlineVersion: dto.onlineVersion ?? 1,
|
|
totalEpisodes: 0,
|
|
publishedEpisodes: 0,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
this.dramas.push(drama);
|
|
if (totalEpisodes) {
|
|
await this.configureEpisodes(drama.id, { episodeCount: totalEpisodes });
|
|
return this.getDramaOrThrow(drama.id);
|
|
}
|
|
return drama;
|
|
}
|
|
|
|
async listAdminDramas(input: DramaListInput): Promise<PaginatedResponse<DramaRecord>> {
|
|
const title = input.title?.trim();
|
|
if (this.hasDatabase()) {
|
|
const { page, pageSize, skip, take } = this.resolvePagination(input);
|
|
const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
|
|
where: {
|
|
...(title ? { title: Like(`%${title}%`) } : {}),
|
|
...(input.isRecommended !== undefined ? { isRecommended: input.isRecommended } : {}),
|
|
},
|
|
order: { sortOrder: "DESC", id: "DESC" },
|
|
skip,
|
|
take,
|
|
});
|
|
const list = await Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
|
|
return { list, pagination: { page, pageSize, total } };
|
|
}
|
|
|
|
return this.paginate(
|
|
this.sortDramas(
|
|
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> {
|
|
if (this.hasDatabase()) {
|
|
const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id } });
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
return this.toDramaRecordWithCounts(drama);
|
|
}
|
|
|
|
const drama = this.dramas.find((item) => item.id === id);
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
|
|
return this.withEpisodeCounts(drama);
|
|
}
|
|
|
|
async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> {
|
|
const { totalEpisodes, ...dramaUpdates } = dto;
|
|
if (this.hasDatabase()) {
|
|
const repository = this.dataSource.getRepository(Drama);
|
|
const drama = await repository.findOne({ where: { id } });
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
repository.merge(drama, {
|
|
...dramaUpdates,
|
|
tags: dramaUpdates.tags ?? drama.tags,
|
|
});
|
|
const saved = await repository.save(drama);
|
|
if (totalEpisodes) {
|
|
await this.configureEpisodes(Number(saved.id), {
|
|
episodeCount: totalEpisodes,
|
|
});
|
|
}
|
|
return this.toDramaRecordWithCounts(saved);
|
|
}
|
|
|
|
const drama = this.dramas.find((item) => item.id === id);
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
|
|
Object.assign(drama, {
|
|
...dramaUpdates,
|
|
tags: dramaUpdates.tags ?? drama.tags,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
if (totalEpisodes) {
|
|
await this.configureEpisodes(drama.id, { episodeCount: totalEpisodes });
|
|
}
|
|
return this.withEpisodeCounts(drama);
|
|
}
|
|
|
|
async createEpisode(dto: CreateEpisodeDto): Promise<EpisodeRecord> {
|
|
const dramaId = dto.dramaId ?? dto.albumId;
|
|
const episodeNo = dto.episodeNo ?? dto.seq;
|
|
if (!dramaId || !episodeNo) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
|
|
const freeType = dto.freeType ?? (dto.isPaid ? "paid" : "free");
|
|
const price = dto.price ?? dto.unlockPrice ?? 0;
|
|
const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft;
|
|
const reviewStatus = dto.reviewStatus ?? (publishStatus === PublishStatus.Published ? "approved" : "not_submitted");
|
|
|
|
if (this.hasDatabase()) {
|
|
const dramaRepository = this.dataSource.getRepository(Drama);
|
|
const drama = await dramaRepository.findOne({ where: { id: dramaId } });
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
|
|
const episodeRepository = this.dataSource.getRepository(Episode);
|
|
const duplicate = await episodeRepository.findOne({
|
|
where: { dramaId, episodeNo },
|
|
});
|
|
if (duplicate) {
|
|
throw new ConflictException("Episode number already exists in this drama");
|
|
}
|
|
|
|
const entity = episodeRepository.create({
|
|
dramaId,
|
|
tiktokEpisodeId: dto.tiktokEpisodeId,
|
|
episodeNo,
|
|
title: dto.title,
|
|
description: dto.description,
|
|
byteplusVid: dto.byteplusVid,
|
|
coverId: dto.coverId,
|
|
coverUrl: dto.coverUrl ?? "",
|
|
videoUrl: dto.videoUrl,
|
|
trialDurationSeconds: dto.trialDurationSeconds,
|
|
durationSeconds: dto.durationSeconds ?? 0,
|
|
width: dto.width,
|
|
height: dto.height,
|
|
isPaid: freeType === "paid",
|
|
freeType,
|
|
unlockPrice: price,
|
|
price,
|
|
publishAt: dto.publishAt ? new Date(dto.publishAt) : undefined,
|
|
nextReleaseAt: dto.nextReleaseAt ? new Date(dto.nextReleaseAt) : undefined,
|
|
status: publishStatus,
|
|
reviewStatus,
|
|
publishStatus,
|
|
});
|
|
const saved = await episodeRepository.save(entity);
|
|
return this.toEpisodeRecord(saved);
|
|
}
|
|
|
|
const drama = this.dramas.find((item) => item.id === dramaId);
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
|
|
const duplicate = this.episodes.some((item) => item.dramaId === dramaId && item.episodeNo === episodeNo);
|
|
if (duplicate) {
|
|
throw new ConflictException("Episode number already exists in this drama");
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const episode: EpisodeRecord = {
|
|
id: this.episodeSequence++,
|
|
dramaId,
|
|
albumId: dramaId,
|
|
tiktokEpisodeId: dto.tiktokEpisodeId,
|
|
episodeNo,
|
|
seq: episodeNo,
|
|
title: dto.title,
|
|
description: dto.description,
|
|
byteplusVid: dto.byteplusVid,
|
|
coverId: dto.coverId,
|
|
coverUrl: dto.coverUrl ?? "",
|
|
videoUrl: dto.videoUrl,
|
|
trialDurationSeconds: dto.trialDurationSeconds,
|
|
durationSeconds: dto.durationSeconds ?? 0,
|
|
width: dto.width,
|
|
height: dto.height,
|
|
isPaid: freeType === "paid",
|
|
freeType,
|
|
unlockPrice: price,
|
|
price,
|
|
publishAt: dto.publishAt,
|
|
nextReleaseAt: dto.nextReleaseAt,
|
|
status: publishStatus,
|
|
reviewStatus,
|
|
publishStatus,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
this.episodes.push(episode);
|
|
return episode;
|
|
}
|
|
|
|
async configureEpisodes(dramaId: number, dto: ConfigureEpisodesDto): Promise<PaginatedResponse<EpisodeRecord>> {
|
|
if (this.hasDatabase()) {
|
|
const dramaRepository = this.dataSource.getRepository(Drama);
|
|
const drama = await dramaRepository.findOne({ where: { id: dramaId } });
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
|
|
const episodeRepository = this.dataSource.getRepository(Episode);
|
|
const existing = await episodeRepository.find({
|
|
where: { dramaId },
|
|
order: { episodeNo: "ASC" },
|
|
});
|
|
|
|
if (dto.episodeCount < existing.length) {
|
|
const removable = existing.slice(dto.episodeCount);
|
|
const hasLockedEpisode = removable.some((episode) => episode.byteplusVid || episode.publishStatus !== PublishStatus.Draft || episode.reviewStatus !== "not_submitted");
|
|
if (hasLockedEpisode) {
|
|
throw new UnprocessableEntityException("Only empty draft tail episodes can be removed");
|
|
}
|
|
await episodeRepository.remove(removable);
|
|
}
|
|
|
|
for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
|
|
await this.createEpisode({
|
|
dramaId,
|
|
episodeNo,
|
|
title: `第${episodeNo}集`,
|
|
coverUrl: drama.coverUrl,
|
|
durationSeconds: 0,
|
|
freeType: "free",
|
|
price: 0,
|
|
reviewStatus: "not_submitted",
|
|
publishStatus: PublishStatus.Draft,
|
|
status: PublishStatus.Draft,
|
|
});
|
|
}
|
|
|
|
const list = await episodeRepository.find({
|
|
where: { dramaId },
|
|
order: { episodeNo: "ASC" },
|
|
});
|
|
const records = list.map((episode) => this.toEpisodeRecord(episode));
|
|
return {
|
|
list: records,
|
|
pagination: { page: 1, pageSize: records.length, total: records.length },
|
|
};
|
|
}
|
|
|
|
const drama = this.dramas.find((item) => item.id === dramaId);
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
|
|
const existing = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
|
|
if (dto.episodeCount < existing.length) {
|
|
const removable = existing.slice(dto.episodeCount);
|
|
const hasLockedEpisode = removable.some((episode) => episode.byteplusVid || episode.publishStatus !== PublishStatus.Draft || episode.reviewStatus !== "not_submitted");
|
|
if (hasLockedEpisode) {
|
|
throw new UnprocessableEntityException("Only empty draft tail episodes can be removed");
|
|
}
|
|
|
|
for (const episode of removable) {
|
|
const index = this.episodes.findIndex((item) => item.id === episode.id);
|
|
if (index >= 0) {
|
|
this.episodes.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
|
|
await this.createEpisode({
|
|
dramaId,
|
|
episodeNo,
|
|
title: `第${episodeNo}集`,
|
|
coverUrl: drama.coverUrl,
|
|
durationSeconds: 0,
|
|
freeType: "free",
|
|
price: 0,
|
|
reviewStatus: "not_submitted",
|
|
publishStatus: PublishStatus.Draft,
|
|
status: PublishStatus.Draft,
|
|
});
|
|
}
|
|
|
|
const list = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
|
|
return {
|
|
list,
|
|
pagination: { page: 1, pageSize: list.length, total: list.length },
|
|
};
|
|
}
|
|
|
|
async listAdminEpisodes(dramaId: number, pagination: PaginationInput): Promise<PaginatedResponse<EpisodeRecord>> {
|
|
await this.getDramaOrThrow(dramaId);
|
|
|
|
if (this.hasDatabase()) {
|
|
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
|
const [records, total] = await this.dataSource.getRepository(Episode).findAndCount({
|
|
where: { dramaId },
|
|
order: { episodeNo: "ASC" },
|
|
skip,
|
|
take,
|
|
});
|
|
return {
|
|
list: records.map((episode) => this.toEpisodeRecord(episode)),
|
|
pagination: { page, pageSize, total },
|
|
};
|
|
}
|
|
|
|
const records = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
|
|
return this.paginate(records, pagination);
|
|
}
|
|
|
|
async updateEpisode(id: number, dto: UpdateEpisodeDto): Promise<EpisodeRecord> {
|
|
if (this.hasDatabase()) {
|
|
const repository = this.dataSource.getRepository(Episode);
|
|
const episode = await repository.findOne({ where: { id } });
|
|
if (!episode) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? "paid" : "free");
|
|
repository.merge(episode, {
|
|
...dto,
|
|
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
|
|
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
|
|
freeType,
|
|
isPaid: freeType === "paid",
|
|
price: dto.price ?? dto.unlockPrice ?? episode.price,
|
|
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
|
|
status: dto.status ?? dto.publishStatus ?? episode.status,
|
|
publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus,
|
|
publishAt: dto.publishAt ? new Date(dto.publishAt) : episode.publishAt,
|
|
nextReleaseAt: dto.nextReleaseAt ? new Date(dto.nextReleaseAt) : episode.nextReleaseAt,
|
|
});
|
|
const saved = await repository.save(episode);
|
|
return this.toEpisodeRecord(saved);
|
|
}
|
|
|
|
const episode = await this.getMemEpisodeOrThrow(id);
|
|
const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? "paid" : "free");
|
|
Object.assign(episode, {
|
|
...dto,
|
|
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
|
|
albumId: dto.albumId ?? dto.dramaId ?? episode.albumId,
|
|
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
|
|
seq: dto.seq ?? dto.episodeNo ?? episode.seq,
|
|
freeType,
|
|
isPaid: freeType === "paid",
|
|
price: dto.price ?? dto.unlockPrice ?? episode.price,
|
|
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
|
|
status: dto.status ?? dto.publishStatus ?? episode.status,
|
|
publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
return episode;
|
|
}
|
|
|
|
async updateDramaEpisode(dramaId: number, episodeId: number, dto: UpdateEpisodeDto): Promise<EpisodeRecord> {
|
|
if (this.hasDatabase()) {
|
|
const episode = await this.dataSource.getRepository(Episode).findOne({
|
|
where: { id: episodeId, dramaId },
|
|
});
|
|
if (!episode) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
} else {
|
|
const episode = await this.getMemEpisodeOrThrow(episodeId);
|
|
if (episode.dramaId !== dramaId) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
}
|
|
|
|
return this.updateEpisode(episodeId, { ...dto, dramaId });
|
|
}
|
|
|
|
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> {
|
|
if (this.hasDatabase()) {
|
|
const repository = this.dataSource.getRepository(Episode);
|
|
const episode = await repository.findOne({
|
|
where: { id: input.episodeId },
|
|
});
|
|
if (!episode) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
episode.byteplusVid = input.byteplusVid;
|
|
episode.videoUrl = input.videoUrl;
|
|
episode.storageBucket = input.storageBucket;
|
|
episode.objectKey = input.objectKey;
|
|
episode.durationSeconds = input.durationSeconds;
|
|
episode.width = input.width;
|
|
episode.height = input.height;
|
|
episode.reviewStatus = input.reviewStatus;
|
|
episode.reviewMessage = input.reviewMessage;
|
|
const saved = await repository.save(episode);
|
|
return this.toEpisodeRecord(saved);
|
|
}
|
|
|
|
const episode = await this.getMemEpisodeOrThrow(input.episodeId);
|
|
episode.byteplusVid = input.byteplusVid;
|
|
episode.videoUrl = input.videoUrl;
|
|
episode.storageBucket = input.storageBucket;
|
|
episode.objectKey = input.objectKey;
|
|
episode.durationSeconds = input.durationSeconds;
|
|
episode.width = input.width;
|
|
episode.height = input.height;
|
|
episode.reviewStatus = input.reviewStatus;
|
|
episode.reviewMessage = input.reviewMessage;
|
|
episode.updatedAt = new Date().toISOString();
|
|
return episode;
|
|
}
|
|
|
|
async updateEpisodeReviewStatus(episodeId: number, reviewStatus: EpisodeRecord["reviewStatus"], reviewMessage?: string): Promise<EpisodeRecord> {
|
|
if (this.hasDatabase()) {
|
|
const repository = this.dataSource.getRepository(Episode);
|
|
const episode = await repository.findOne({ where: { id: episodeId } });
|
|
if (!episode) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
episode.reviewStatus = reviewStatus;
|
|
episode.reviewMessage = reviewMessage;
|
|
const saved = await repository.save(episode);
|
|
return this.toEpisodeRecord(saved);
|
|
}
|
|
|
|
const episode = await this.getMemEpisodeOrThrow(episodeId);
|
|
episode.reviewStatus = reviewStatus;
|
|
episode.reviewMessage = reviewMessage;
|
|
episode.updatedAt = new Date().toISOString();
|
|
return episode;
|
|
}
|
|
|
|
async listPublishedDramas(input: PublishedDramaListInput): Promise<PaginatedResponse<PublicDramaRecord>> {
|
|
const title = input.title?.trim();
|
|
if (this.hasDatabase()) {
|
|
const { page, pageSize, skip, take } = this.resolvePagination(input);
|
|
const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
|
|
where: {
|
|
status: PublishStatus.Published,
|
|
...(title ? { title: Like(`%${title}%`) } : {}),
|
|
...(input.isRecommended !== undefined ? { isRecommended: input.isRecommended } : {}),
|
|
},
|
|
order: { sortOrder: "DESC", id: "DESC" },
|
|
skip,
|
|
take,
|
|
});
|
|
const list = await Promise.all(records.map(async (drama) => this.toPublicDramaRecord(await this.toDramaRecordWithCounts(drama))));
|
|
return { list, pagination: { page, pageSize, total } };
|
|
}
|
|
|
|
const page = this.paginate(
|
|
this.sortDramas(
|
|
this.dramas
|
|
.filter((item) => item.status === PublishStatus.Published)
|
|
.filter((item) => !title || item.title.includes(title))
|
|
.filter((item) => input.isRecommended === undefined || item.isRecommended === input.isRecommended)
|
|
.map((drama) => this.withEpisodeCounts(drama)),
|
|
),
|
|
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> {
|
|
if (this.hasDatabase()) {
|
|
const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id, status: PublishStatus.Published } });
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
return this.toDramaRecordWithCounts(drama);
|
|
}
|
|
|
|
const drama = this.dramas.find((item) => item.id === id && item.status === PublishStatus.Published);
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
|
|
return this.withEpisodeCounts(drama);
|
|
}
|
|
|
|
async listPublishedEpisodes(dramaId: number, pagination: PaginationInput): Promise<PaginatedResponse<EpisodeRecord>> {
|
|
if (this.hasDatabase()) {
|
|
const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id: dramaId, status: PublishStatus.Published } });
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
|
const [records, total] = await this.dataSource.getRepository(Episode).findAndCount({
|
|
where: {
|
|
dramaId,
|
|
status: PublishStatus.Published,
|
|
publishStatus: PublishStatus.Published,
|
|
reviewStatus: "approved",
|
|
},
|
|
order: { episodeNo: "ASC" },
|
|
skip,
|
|
take,
|
|
});
|
|
return {
|
|
list: records.map((episode) => this.toEpisodeRecord(episode)),
|
|
pagination: { page, pageSize, total },
|
|
};
|
|
}
|
|
|
|
const drama = this.dramas.find((item) => item.id === dramaId);
|
|
if (!drama || drama.status !== PublishStatus.Published) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
|
|
const episodes = this.episodes
|
|
.filter((item) => item.dramaId === dramaId && item.status === PublishStatus.Published)
|
|
.filter((item) => item.publishStatus === PublishStatus.Published && item.reviewStatus === "approved")
|
|
.sort((left, right) => left.episodeNo - right.episodeNo);
|
|
|
|
return this.paginate(episodes, pagination);
|
|
}
|
|
|
|
async getPublishedEpisodeOrThrow(episodeId: number): Promise<EpisodeRecord> {
|
|
if (this.hasDatabase()) {
|
|
const episode = await this.dataSource.getRepository(Episode).findOne({
|
|
where: {
|
|
id: episodeId,
|
|
status: PublishStatus.Published,
|
|
publishStatus: PublishStatus.Published,
|
|
reviewStatus: "approved",
|
|
},
|
|
});
|
|
if (!episode) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
const drama = await this.dataSource.getRepository(Drama).findOne({
|
|
where: { id: episode.dramaId, status: PublishStatus.Published },
|
|
});
|
|
if (!drama) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
return this.toEpisodeRecord(episode);
|
|
}
|
|
|
|
const episode = this.episodes.find((item) => item.id === episodeId);
|
|
if (!episode || 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);
|
|
if (!drama || drama.status !== PublishStatus.Published) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
|
|
return episode;
|
|
}
|
|
|
|
async getEpisodeOrThrow(episodeId: number): Promise<EpisodeRecord> {
|
|
if (this.hasDatabase()) {
|
|
const episode = await this.dataSource.getRepository(Episode).findOne({ where: { id: episodeId } });
|
|
if (!episode) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
return this.toEpisodeRecord(episode);
|
|
}
|
|
|
|
return this.getMemEpisodeOrThrow(episodeId);
|
|
}
|
|
|
|
async submitReview(albumId: number) {
|
|
if (this.hasDatabase()) {
|
|
const dramaRepository = this.dataSource.getRepository(Drama);
|
|
const drama = await dramaRepository.findOne({ where: { id: albumId } });
|
|
if (!drama) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
const episodeRepository = this.dataSource.getRepository(Episode);
|
|
const episodes = await episodeRepository.find({
|
|
where: { dramaId: albumId },
|
|
});
|
|
for (const episode of episodes) {
|
|
episode.reviewStatus = "approved";
|
|
episode.reviewMessage = "Mock review approved";
|
|
}
|
|
await episodeRepository.save(episodes);
|
|
return { albumId, reviewStatus: "approved" };
|
|
}
|
|
|
|
const album = this.dramas.find((item) => item.id === albumId);
|
|
if (!album) {
|
|
throw new NotFoundException("Drama not found");
|
|
}
|
|
|
|
this.episodes
|
|
.filter((episode) => episode.albumId === albumId)
|
|
.forEach((episode) => {
|
|
episode.reviewStatus = "approved";
|
|
episode.reviewMessage = "Mock review approved";
|
|
episode.updatedAt = new Date().toISOString();
|
|
});
|
|
|
|
return { albumId, reviewStatus: "approved" };
|
|
}
|
|
|
|
async listAllDramas(): Promise<DramaRecord[]> {
|
|
if (this.hasDatabase()) {
|
|
const records = await this.dataSource.getRepository(Drama).find({ order: { sortOrder: "DESC", id: "DESC" } });
|
|
return Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
|
|
}
|
|
|
|
return this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama)));
|
|
}
|
|
|
|
async listAllEpisodes(): Promise<EpisodeRecord[]> {
|
|
if (this.hasDatabase()) {
|
|
const records = await this.dataSource.getRepository(Episode).find({ order: { id: "ASC" } });
|
|
return records.map((episode) => this.toEpisodeRecord(episode));
|
|
}
|
|
|
|
return [...this.episodes].sort((left, right) => left.id - right.id);
|
|
}
|
|
|
|
async updateEpisodePublishStatus(episodeId: number, publishStatus: PublishStatus): Promise<EpisodeRecord> {
|
|
if (this.hasDatabase()) {
|
|
const repository = this.dataSource.getRepository(Episode);
|
|
const episode = await repository.findOne({ where: { id: episodeId } });
|
|
if (!episode) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
episode.publishStatus = publishStatus;
|
|
episode.status = publishStatus;
|
|
const saved = await repository.save(episode);
|
|
return this.toEpisodeRecord(saved);
|
|
}
|
|
|
|
const episode = await this.getMemEpisodeOrThrow(episodeId);
|
|
episode.publishStatus = publishStatus;
|
|
episode.status = publishStatus;
|
|
episode.updatedAt = new Date().toISOString();
|
|
return episode;
|
|
}
|
|
|
|
private hasDatabase(): this is this & { dataSource: DataSource } {
|
|
return Boolean(this.dataSource?.isInitialized);
|
|
}
|
|
|
|
private getMemEpisodeOrThrow(episodeId: number): EpisodeRecord {
|
|
const episode = this.episodes.find((item) => item.id === episodeId);
|
|
if (!episode) {
|
|
throw new NotFoundException("Episode not found");
|
|
}
|
|
|
|
return episode;
|
|
}
|
|
|
|
private async toDramaRecordWithCounts(entity: Drama): Promise<DramaRecord> {
|
|
const episodeRepository = this.dataSource!.getRepository(Episode);
|
|
const dramaId = Number(entity.id);
|
|
const totalEpisodes = await episodeRepository.count({ where: { dramaId } });
|
|
const publishedEpisodes = await episodeRepository.count({
|
|
where: {
|
|
dramaId,
|
|
publishStatus: PublishStatus.Published,
|
|
reviewStatus: "approved",
|
|
},
|
|
});
|
|
return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes);
|
|
}
|
|
|
|
private toDramaRecord(entity: Drama, totalEpisodes: number, publishedEpisodes: number): DramaRecord {
|
|
return {
|
|
id: Number(entity.id),
|
|
tiktokAlbumId: entity.tiktokAlbumId,
|
|
title: entity.title,
|
|
description: entity.description,
|
|
coverId: entity.coverId,
|
|
coverUrl: entity.coverUrl,
|
|
portraitCoverUrl: entity.portraitCoverUrl,
|
|
landscapeCoverUrl: entity.landscapeCoverUrl,
|
|
type: entity.type,
|
|
tags: entity.tags ?? [],
|
|
region: entity.region,
|
|
language: entity.language,
|
|
year: entity.year,
|
|
status: entity.status,
|
|
sortOrder: entity.sortOrder,
|
|
isRecommended: entity.isRecommended,
|
|
onlineVersion: entity.onlineVersion,
|
|
totalEpisodes,
|
|
publishedEpisodes,
|
|
createdAt: this.toIso(entity.createdAt),
|
|
updatedAt: this.toIso(entity.updatedAt),
|
|
};
|
|
}
|
|
|
|
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 {
|
|
const dramaId = Number(entity.dramaId);
|
|
return {
|
|
id: Number(entity.id),
|
|
dramaId,
|
|
albumId: dramaId,
|
|
tiktokEpisodeId: entity.tiktokEpisodeId,
|
|
episodeNo: entity.episodeNo,
|
|
seq: entity.episodeNo,
|
|
title: entity.title,
|
|
description: entity.description,
|
|
byteplusVid: entity.byteplusVid,
|
|
coverId: entity.coverId,
|
|
coverUrl: entity.coverUrl,
|
|
videoUrl: entity.videoUrl,
|
|
storageBucket: entity.storageBucket,
|
|
objectKey: entity.objectKey,
|
|
trialDurationSeconds: entity.trialDurationSeconds,
|
|
durationSeconds: entity.durationSeconds,
|
|
width: entity.width,
|
|
height: entity.height,
|
|
isPaid: entity.isPaid,
|
|
freeType: entity.freeType,
|
|
unlockPrice: entity.unlockPrice,
|
|
price: entity.price,
|
|
publishAt: this.toIsoOrUndefined(entity.publishAt),
|
|
nextReleaseAt: this.toIsoOrUndefined(entity.nextReleaseAt),
|
|
status: entity.status,
|
|
reviewStatus: entity.reviewStatus,
|
|
reviewMessage: entity.reviewMessage,
|
|
publishStatus: entity.publishStatus,
|
|
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 toIsoOrUndefined(value?: Date | string): string | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
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 sortDramas(records: DramaRecord[]) {
|
|
return [...records].sort((left, right) => {
|
|
if (right.sortOrder !== left.sortOrder) {
|
|
return right.sortOrder - left.sortOrder;
|
|
}
|
|
|
|
return right.id - left.id;
|
|
});
|
|
}
|
|
|
|
private withEpisodeCounts(drama: DramaRecord): DramaRecord {
|
|
const episodes = this.episodes.filter((episode) => episode.dramaId === drama.id);
|
|
return {
|
|
...drama,
|
|
totalEpisodes: episodes.length,
|
|
publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length,
|
|
};
|
|
}
|
|
|
|
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
|
|
const page = Math.max(input.page, 1);
|
|
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
|
const start = (page - 1) * pageSize;
|
|
|
|
return {
|
|
list: records.slice(start, start + pageSize),
|
|
pagination: {
|
|
page,
|
|
pageSize,
|
|
total: records.length,
|
|
},
|
|
};
|
|
}
|
|
}
|