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; 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 { 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> { 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 { 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 { 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 { 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> { 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> { 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 { 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 { 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 { 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 { 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> { 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> { return this.listPublishedDramas({ ...input, isRecommended: true, }); } async presearchVideos(input: VideoPresearchInput): Promise> { 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(); 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 { 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> { 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 { 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 { 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 { 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 { 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 { 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 { 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(records: T[], input: PaginationInput): PaginatedResponse { 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, }, }; } }