diff --git a/src/modules/dramas/dramas.controller.ts b/src/modules/dramas/dramas.controller.ts index 8d502ef..275f996 100644 --- a/src/modules/dramas/dramas.controller.ts +++ b/src/modules/dramas/dramas.controller.ts @@ -28,10 +28,12 @@ export class DramasController { @ApiBearerAuth() @UseGuards(AdminJwtAuthGuard, PermissionsGuard) @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({ page: Number(page) || 1, pageSize: Number(pageSize) || 20, + title, + isRecommended: this.parseOptionalBoolean(isRecommended), }); } @@ -111,11 +113,27 @@ export class DramasController { } @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({ page: Number(page) || 1, 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) { return this.dramasService.getPublishedDramaOrThrow(Number(id)); } + + private parseOptionalBoolean(value?: string) { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + return undefined; + } } diff --git a/src/modules/dramas/dramas.service.ts b/src/modules/dramas/dramas.service.ts index 87d6806..51543cd 100644 --- a/src/modules/dramas/dramas.service.ts +++ b/src/modules/dramas/dramas.service.ts @@ -1,992 +1,1011 @@ -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'; +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; + page: number; + pageSize: number; } -interface PublishedDramaListInput extends PaginationInput { - title?: string; +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[] = []; + private dramaSequence = 1; + private episodeSequence = 1; + private readonly dramas: DramaRecord[] = []; + private readonly episodes: EpisodeRecord[] = []; - constructor( - @Optional() - @InjectDataSource() - readonly dataSource?: DataSource, - ) {} + 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( - pagination: PaginationInput, - ): Promise> { - if (this.hasDatabase()) { - const { page, pageSize, skip, take } = this.resolvePagination(pagination); - const [records, total] = await this.dataSource - .getRepository(Drama) - .findAndCount({ - 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.map((drama) => this.withEpisodeCounts(drama))), - pagination, - ); - } - - 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', - ); + 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); } - 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 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, + }; - 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); + this.dramas.push(drama); + if (totalEpisodes) { + await this.configureEpisodes(drama.id, { episodeCount: totalEpisodes }); + return this.getDramaOrThrow(drama.id); } - } + return drama; } - 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, - }); + 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, + ); } - 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 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); + } - async listAdminEpisodes( - dramaId: number, - pagination: PaginationInput, - ): Promise> { - await this.getDramaOrThrow(dramaId); + const drama = this.dramas.find((item) => item.id === id); + if (!drama) { + throw new NotFoundException("Drama not found"); + } - 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 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(), }); - return { - list: records.map((episode) => this.toEpisodeRecord(episode)), - pagination: { page, pageSize, total }, - }; + if (totalEpisodes) { + await this.configureEpisodes(drama.id, { episodeCount: totalEpisodes }); + } + return this.withEpisodeCounts(drama); } - const records = this.episodes - .filter((episode) => episode.dramaId === dramaId) - .sort((left, right) => left.episodeNo - right.episodeNo); - return this.paginate(records, pagination); - } + 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"); + } - 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 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"); - 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; - } + 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"); + } - 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'); - } - } + 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"); + } - return this.updateEpisode(episodeId, { ...dto, dramaId }); - } + 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); + } - 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 drama = this.dramas.find((item) => item.id === dramaId); + if (!drama) { + throw new NotFoundException("Drama not found"); + } - 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; - } + const duplicate = this.episodes.some((item) => item.dramaId === dramaId && item.episodeNo === episodeNo); + if (duplicate) { + throw new ConflictException("Episode number already exists in this drama"); + } - 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}%`) } : {}), - }, - 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) => item.status === PublishStatus.Published) - .filter((item) => !title || item.title.includes(title)) - .map((drama) => this.withEpisodeCounts(drama)), - ), - 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: { + const now = new Date().toISOString(); + const episode: EpisodeRecord = { + id: this.episodeSequence++, dramaId, - status: PublishStatus.Published, - publishStatus: PublishStatus.Published, - reviewStatus: 'approved', - }, - order: { episodeNo: 'ASC' }, - skip, - take, + 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 { - list: records.map((episode) => this.toEpisodeRecord(episode)), - pagination: { page, pageSize, total }, - }; + return episode; } - const drama = this.dramas.find((item) => item.id === dramaId); - if (!drama || drama.status !== PublishStatus.Published) { - throw new NotFoundException('Drama not found'); + 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 }); } - 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); + 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); + } - 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'; + 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 { 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 episode; } - return this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))); - } + 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); + } - async listAllEpisodes(): Promise { - if (this.hasDatabase()) { - const records = await this.dataSource - .getRepository(Episode) - .find({ order: { id: 'ASC' } }); - return records.map((episode) => this.toEpisodeRecord(episode)); + const episode = await this.getMemEpisodeOrThrow(episodeId); + episode.reviewStatus = reviewStatus; + episode.reviewMessage = reviewMessage; + episode.updatedAt = new Date().toISOString(); + return episode; } - return [...this.episodes].sort((left, right) => left.id - right.id); - } + 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 } }; + } - 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 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)), + }; } - 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'); + async listRecommendedDramas(input: PublishedDramaListInput): Promise> { + return this.listPublishedDramas({ + ...input, + isRecommended: true, + }); } - return episode; - } + 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 } }; + } - 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); - } + 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 }); - 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), - }; - } + 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(); - 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), - }; - } + 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 }, + }; + } - private toIso(value?: Date | string): string { - if (!value) { - return new Date().toISOString(); + 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); } - return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); - } - private toIsoOrUndefined(value?: Date | string): string | undefined { - if (!value) { - return undefined; + 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); } - 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 }; - } + 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 }, + }; + } - private sortDramas(records: DramaRecord[]) { - return [...records].sort((left, right) => { - if (right.sortOrder !== left.sortOrder) { - return right.sortOrder - left.sortOrder; - } + const drama = this.dramas.find((item) => item.id === dramaId); + if (!drama || drama.status !== PublishStatus.Published) { + throw new NotFoundException("Drama not found"); + } - return right.id - left.id; - }); - } + 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); - 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, - }; - } + return this.paginate(episodes, pagination); + } - 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; + 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); + } - return { - list: records.slice(start, start + pageSize), - pagination: { - page, - pageSize, - total: records.length, - }, - }; - } + 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, + }, + }; + } } diff --git a/src/modules/playback-history/dto/upsert-playback-history.dto.ts b/src/modules/playback-history/dto/upsert-playback-history.dto.ts new file mode 100644 index 0000000..6ae2056 --- /dev/null +++ b/src/modules/playback-history/dto/upsert-playback-history.dto.ts @@ -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; +} diff --git a/src/modules/playback-history/entities/user-playback-history.entity.ts b/src/modules/playback-history/entities/user-playback-history.entity.ts new file mode 100644 index 0000000..8fb38ac --- /dev/null +++ b/src/modules/playback-history/entities/user-playback-history.entity.ts @@ -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; +} diff --git a/src/modules/playback-history/interfaces/playback-history-record.interface.ts b/src/modules/playback-history/interfaces/playback-history-record.interface.ts new file mode 100644 index 0000000..a847463 --- /dev/null +++ b/src/modules/playback-history/interfaces/playback-history-record.interface.ts @@ -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; +} diff --git a/test/dramas.e2e-spec.ts b/test/dramas.e2e-spec.ts index e6c2002..b94d33a 100644 --- a/test/dramas.e2e-spec.ts +++ b/test/dramas.e2e-spec.ts @@ -212,6 +212,8 @@ describe('Dramas and episodes', () => { .send({ title: 'Published Drama', 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', status: 'published', }) @@ -265,6 +267,11 @@ describe('Dramas and episodes', () => { 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.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 }, + }); + }); }); diff --git a/test/orders-playback.e2e-spec.ts b/test/orders-playback.e2e-spec.ts index 39c0426..dbb8ed5 100644 --- a/test/orders-playback.e2e-spec.ts +++ b/test/orders-playback.e2e-spec.ts @@ -128,4 +128,164 @@ describe('Auth, orders, and playback', () => { 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); + }); });