diff --git a/drizzle/orm.ts b/drizzle/orm.ts index 13f05d2f..5117cce4 100644 --- a/drizzle/orm.ts +++ b/drizzle/orm.ts @@ -10,6 +10,7 @@ export type ISubject = typeof schema.chiiSubjects.$inferSelect; export type ISubjectFields = typeof schema.chiiSubjectFields.$inferSelect; export type ISubjectInterest = typeof schema.chiiSubjectInterests.$inferSelect; export type ISubjectRelation = typeof schema.chiiSubjectRelations.$inferSelect; +export type ISubjectEpStatus = typeof schema.chiiEpStatus.$inferSelect; export type IEpisode = typeof schema.chiiEpisodes.$inferSelect; diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 68a4b67e..da3fa366 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -222,16 +222,17 @@ export const chiiEpRevisions = mysqlTable( export const chiiEpStatus = mysqlTable( 'chii_ep_status', { - epSttId: mediumint('ep_stt_id').autoincrement().notNull(), - epSttUid: mediumint('ep_stt_uid').notNull(), - epSttSid: mediumint('ep_stt_sid').notNull(), - epSttOnPrg: tinyint('ep_stt_on_prg').default(0).notNull(), - epSttStatus: mediumtext('ep_stt_status').notNull(), + id: mediumint('ep_stt_id').autoincrement().notNull(), + uid: mediumint('ep_stt_uid').notNull(), + sid: mediumint('ep_stt_sid').notNull(), + // 未使用 + // onProgress: tinyint('ep_stt_on_prg').default(0).notNull(), + status: mediumtext('ep_stt_status').notNull(), updatedAt: int('ep_stt_lasttouch').notNull(), }, (table) => { return { - epSttUniq: unique('ep_stt_uniq').on(table.epSttUid, table.epSttSid), + epSttUniq: unique('ep_stt_uniq').on(table.uid, table.sid), }; }, ); diff --git a/lib/subject/type.ts b/lib/subject/type.ts index 41cd5c8d..e10e8b67 100644 --- a/lib/subject/type.ts +++ b/lib/subject/type.ts @@ -19,6 +19,18 @@ export enum CollectionType { export const CollectionTypeValues = new Set([1, 2, 3, 4, 5]); export const CollectionTypeProfileValues = new Set([1, 2]); +export enum EpisodeCollectionStatus { + None = 0, // 撤消/删除 + Wish = 1, // 想看 + Done = 2, // 看过 + Dropped = 3, // 抛弃 +} + +export interface UserEpisodeCollection { + id: number; + type: EpisodeCollectionStatus; +} + export enum PersonType { Character = 'crt', Person = 'prsn', diff --git a/lib/types/convert.ts b/lib/types/convert.ts index 18955af6..fed27a27 100644 --- a/lib/types/convert.ts +++ b/lib/types/convert.ts @@ -5,7 +5,7 @@ import * as php from '@trim21/php-serialize'; import type * as orm from '@app/drizzle/orm.ts'; import type * as ormold from '@app/lib/orm/index.ts'; import { avatar, personImages, subjectCover } from '@app/lib/response.ts'; -import { CollectionType } from '@app/lib/subject/type'; +import { CollectionType, type UserEpisodeCollection } from '@app/lib/subject/type'; import type * as res from '@app/lib/types/res.ts'; import { findSubjectPlatform, @@ -168,6 +168,7 @@ function toSubjectRating(fields: orm.ISubjectFields): res.ISubjectRating { const total = ratingCount.reduce((a, b) => a + b, 0); const totalScore = ratingCount.reduce((a, b, i) => a + b * (i + 1), 0); const rating = { + rank: fields.fieldRank, total: total, score: total === 0 ? 0 : Math.round((totalScore * 100) / total) / 100, count: ratingCount, @@ -259,6 +260,24 @@ export function toEpisode(episode: orm.IEpisode): res.IEpisode { }; } +export function toSubjectEpStatus( + status: orm.ISubjectEpStatus, +): Record { + const result: Record = {}; + if (!status.status) { + return result; + } + const epStatusList = php.parse(status.status) as Record; + for (const [eid, x] of Object.entries(epStatusList)) { + const episodeId = Number.parseInt(eid); + if (Number.isNaN(episodeId)) { + continue; + } + result[episodeId] = { id: episodeId, type: x.type }; + } + return result; +} + export function toSlimCharacter(character: orm.ICharacter): res.ISlimCharacter { return { id: character.id, diff --git a/lib/types/fetcher.ts b/lib/types/fetcher.ts index d972ed4b..da8e3e3e 100644 --- a/lib/types/fetcher.ts +++ b/lib/types/fetcher.ts @@ -1,5 +1,6 @@ import { db, op } from '@app/drizzle/db.ts'; import * as schema from '@app/drizzle/schema'; +import type { UserEpisodeCollection } from '@app/lib/subject/type.ts'; import * as convert from './convert.ts'; import type * as res from './res.ts'; @@ -83,6 +84,23 @@ export async function fetchSubjectsByIDs( return map; } +export async function fetchSubjectEpStatus( + userID: number, + subjectID: number, +): Promise | null> { + const data = await db + .select() + .from(schema.chiiEpStatus) + .where( + op.and(op.eq(schema.chiiEpStatus.uid, userID), op.eq(schema.chiiEpStatus.sid, subjectID)), + ) + .execute(); + for (const d of data) { + return convert.toSubjectEpStatus(d); + } + return null; +} + export async function fetchSlimCharacterByID( id: number, allowNsfw = false, diff --git a/lib/types/res.ts b/lib/types/res.ts index 28b0e991..bd9bb516 100644 --- a/lib/types/res.ts +++ b/lib/types/res.ts @@ -132,6 +132,7 @@ export const SubjectPlatform = t.Object( export type ISubjectRating = Static; export const SubjectRating = t.Object( { + rank: t.Integer(), count: t.Array(t.Integer()), score: t.Number(), total: t.Integer(), diff --git a/routes/__snapshots__/index.test.ts.snap b/routes/__snapshots__/index.test.ts.snap index 3c029df4..9d81acad 100644 --- a/routes/__snapshots__/index.test.ts.snap +++ b/routes/__snapshots__/index.test.ts.snap @@ -1481,11 +1481,14 @@ exports[`should build private api spec 1`] = ` items: type: integer type: array + rank: + type: integer score: type: number total: type: integer required: + - rank - count - score - total @@ -1998,6 +2001,28 @@ exports[`should build private api spec 1`] = ` - private - updatedAt type: object + UserSubjectEpisodeCollection: + properties: + episode: + $ref: '#/components/schemas/Episode' + type: + anyOf: + - enum: + - 0 + type: number + - enum: + - 1 + type: number + - enum: + - 2 + type: number + - enum: + - 3 + type: number + required: + - episode + - type + type: object WikiPlatform: properties: id: @@ -4698,6 +4723,120 @@ paths: summary: 获取 Turnstile 令牌 tags: - user + /p1/users/-/collections/subjects/-/episodes/{episodeID}: + get: + operationId: getUserSubjectCollectionEpisodeByEpisodeID + parameters: + - in: path + name: episodeID + required: true + schema: + minimum: 1 + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserSubjectEpisodeCollection' + description: Default Response + '500': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 意料之外的服务器错误 + description: 意料之外的服务器错误 + security: + - CookiesSession: [] + HTTPBearer: [] + summary: 获取用户单个条目的单个章节收藏 + tags: + - user + /p1/users/-/collections/subjects/{subjectID}/episodes: + get: + operationId: getUserSubjectCollectionEpisodesBySubjectID + parameters: + - description: 剧集类型 + in: query + name: type + required: false + schema: + anyOf: + - enum: + - 0 + type: number + - enum: + - 1 + type: number + - enum: + - 2 + type: number + - enum: + - 3 + type: number + - enum: + - 4 + type: number + - enum: + - 5 + type: number + - enum: + - 6 + type: number + - description: max 1000 + in: query + name: limit + required: false + schema: + default: 100 + maximum: 1000 + minimum: 1 + type: integer + - description: min 0 + in: query + name: offset + required: false + schema: + default: 0 + minimum: 0 + type: integer + - in: path + name: subjectID + required: true + schema: + minimum: 1 + type: integer + responses: + '200': + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/UserSubjectEpisodeCollection' + type: array + total: + type: integer + required: + - data + - total + type: object + description: Default Response + '500': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 意料之外的服务器错误 + description: 意料之外的服务器错误 + security: + - CookiesSession: [] + HTTPBearer: [] + summary: 获取用户单个条目的章节收藏 + tags: + - user /p1/users/{username}: get: operationId: getUser diff --git a/routes/private/routes/__snapshots__/subject.test.ts.snap b/routes/private/routes/__snapshots__/subject.test.ts.snap index d4708044..b667fa46 100644 --- a/routes/private/routes/__snapshots__/subject.test.ts.snap +++ b/routes/private/routes/__snapshots__/subject.test.ts.snap @@ -189,6 +189,7 @@ Object { 369, 168, ], + "rank": 731, "score": 7.57, "total": 3231, }, diff --git a/routes/private/routes/__snapshots__/topic.test.ts.snap b/routes/private/routes/__snapshots__/topic.test.ts.snap index 596c8d47..26f1aa14 100644 --- a/routes/private/routes/__snapshots__/topic.test.ts.snap +++ b/routes/private/routes/__snapshots__/topic.test.ts.snap @@ -329,6 +329,7 @@ Object { 3, 2, ], + "rank": 3560, "score": 6.9, "total": 143, }, diff --git a/routes/private/routes/__snapshots__/user.test.ts.snap b/routes/private/routes/__snapshots__/user.test.ts.snap index 35e56fbd..525ac5db 100644 --- a/routes/private/routes/__snapshots__/user.test.ts.snap +++ b/routes/private/routes/__snapshots__/user.test.ts.snap @@ -139,6 +139,48 @@ Object { } `; +exports[`user collection > should get episodes 1`] = ` +Object { + "data": Array [ + Object { + "episode": Object { + "airdate": "2009-09-23", + "comment": 0, + "desc": "", + "disc": 0, + "duration": "", + "id": 17224, + "lock": true, + "name": "Nameless, Faceless (Part 3)", + "nameCN": "", + "sort": 1, + "subjectID": 2703, + "type": 0, + }, + "type": 2, + }, + Object { + "episode": Object { + "airdate": "2009-09-30", + "comment": 0, + "desc": "", + "disc": 0, + "duration": "", + "id": 17225, + "lock": true, + "name": "Haunted", + "nameCN": "", + "sort": 2, + "subjectID": 2703, + "type": 0, + }, + "type": 2, + }, + ], + "total": 23, +} +`; + exports[`user collection > should get indexes 1`] = ` Object { "data": Array [], @@ -242,6 +284,26 @@ Object { } `; +exports[`user collection > should get single episode 1`] = ` +Object { + "episode": Object { + "airdate": "2009-10-14", + "comment": 0, + "desc": "", + "disc": 0, + "duration": "", + "id": 17227, + "lock": true, + "name": "Hopeless", + "nameCN": "", + "sort": 4, + "subjectID": 2703, + "type": 0, + }, + "type": 2, +} +`; + exports[`user collection > should get single index 1`] = ` Object { "code": "NOT_FOUND", @@ -446,6 +508,7 @@ Object { 2640, 1377, ], + "rank": 121, "score": 8.19, "total": 9438, }, @@ -777,6 +840,7 @@ Object { 2640, 1377, ], + "rank": 121, "score": 8.19, "total": 9438, }, diff --git a/routes/private/routes/subject.ts b/routes/private/routes/subject.ts index 98090d53..b0c14ae7 100644 --- a/routes/private/routes/subject.ts +++ b/routes/private/routes/subject.ts @@ -113,7 +113,7 @@ export async function setup(app: App) { }, }, }, - async ({ auth, params: { subjectID }, query: { limit = 100, offset = 0 } }) => { + async ({ auth, params: { subjectID }, query: { type, limit = 100, offset = 0 } }) => { const subject = await fetcher.fetchSlimSubjectByID(subjectID, auth.allowNsfw); if (!subject) { throw new NotFoundError(`subject ${subjectID}`); @@ -121,6 +121,7 @@ export async function setup(app: App) { const condition = op.and( op.eq(schema.chiiEpisodes.subjectID, subjectID), op.ne(schema.chiiEpisodes.ban, 1), + type ? op.eq(schema.chiiEpisodes.type, type) : undefined, ); const [{ count = 0 } = {}] = await db .select({ count: op.count() }) @@ -131,7 +132,11 @@ export async function setup(app: App) { .select() .from(schema.chiiEpisodes) .where(condition) - .orderBy(op.asc(schema.chiiEpisodes.type), op.asc(schema.chiiEpisodes.sort)) + .orderBy( + op.asc(schema.chiiEpisodes.disc), + op.asc(schema.chiiEpisodes.type), + op.asc(schema.chiiEpisodes.sort), + ) .limit(limit) .offset(offset) .execute(); diff --git a/routes/private/routes/user.test.ts b/routes/private/routes/user.test.ts index 6ae2b17b..f9865f12 100644 --- a/routes/private/routes/user.test.ts +++ b/routes/private/routes/user.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { createTestServer } from '@app/tests/utils.ts'; +import { emptyAuth } from '@app/lib/auth/index.ts'; import { setup } from './user.ts'; @@ -61,6 +62,39 @@ describe('user collection', () => { expect(res.json()).toMatchSnapshot(); }); + test('should get episodes', async () => { + const app = createTestServer({ + auth: { + ...emptyAuth(), + login: true, + userID: 382951, + }, + }); + await app.register(setup); + const res = await app.inject({ + method: 'get', + url: '/users/-/collections/subjects/2703/episodes', + query: { limit: '2', offset: '0' }, + }); + expect(res.json()).toMatchSnapshot(); + }); + + test('should get single episode', async () => { + const app = createTestServer({ + auth: { + ...emptyAuth(), + login: true, + userID: 382951, + }, + }); + await app.register(setup); + const res = await app.inject({ + method: 'get', + url: '/users/-/collections/subjects/-/episodes/17227', + }); + expect(res.json()).toMatchSnapshot(); + }); + test('should get characters', async () => { const app = createTestServer(); await app.register(setup); diff --git a/routes/private/routes/user.ts b/routes/private/routes/user.ts index 061ecdde..afba1c35 100644 --- a/routes/private/routes/user.ts +++ b/routes/private/routes/user.ts @@ -10,14 +10,18 @@ import { fetchUserByUsername } from '@app/lib/orm/index.ts'; import { CollectionType, CollectionTypeProfileValues, + EpisodeCollectionStatus, PersonType, SubjectType, SubjectTypeValues, + type UserEpisodeCollection, } from '@app/lib/subject/type.ts'; import * as convert from '@app/lib/types/convert.ts'; import * as examples from '@app/lib/types/examples.ts'; +import * as fetcher from '@app/lib/types/fetcher.ts'; import * as res from '@app/lib/types/res.ts'; import { formatErrors } from '@app/lib/types/res.ts'; +import { requireLogin } from '@app/routes/hooks/pre-handler.ts'; import type { App } from '@app/routes/type.ts'; export type IUserSubjectCollection = Static; @@ -36,6 +40,15 @@ const UserSubjectCollection = t.Object( { $id: 'UserSubjectCollection' }, ); +export type IUserSubjectEpisodeCollection = Static; +const UserSubjectEpisodeCollection = t.Object( + { + episode: t.Ref(res.Episode), + type: t.Enum(EpisodeCollectionStatus), + }, + { $id: 'UserSubjectEpisodeCollection' }, +); + export type IUserCharacterCollection = Static; const UserCharacterCollection = t.Object( { @@ -152,6 +165,16 @@ function toUserSubjectCollection( }; } +function toUserSubjectEpisodeCollection( + episode: orm.IEpisode, + epStatus: UserEpisodeCollection | undefined, +): IUserSubjectEpisodeCollection { + return { + episode: convert.toEpisode(episode), + type: epStatus?.type ?? EpisodeCollectionStatus.None, + }; +} + function toUserCharacterCollection( collect: orm.IPersonCollect, character: orm.ICharacter, @@ -186,6 +209,7 @@ function toUserIndexCollection( // eslint-disable-next-line @typescript-eslint/require-await export async function setup(app: App) { app.addSchema(UserSubjectCollection); + app.addSchema(UserSubjectEpisodeCollection); app.addSchema(UserCharacterCollection); app.addSchema(UserPersonCollection); app.addSchema(UserIndexCollection); @@ -697,6 +721,105 @@ export async function setup(app: App) { }, ); + app.get( + '/users/-/collections/subjects/:subjectID/episodes', + { + schema: { + summary: '获取用户单个条目的章节收藏', + operationId: 'getUserSubjectCollectionEpisodesBySubjectID', + tags: [Tag.User], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + subjectID: t.Integer({ minimum: 1 }), + }), + querystring: t.Object({ + type: t.Optional(t.Enum(res.EpisodeType, { description: '剧集类型' })), + limit: t.Optional( + t.Integer({ default: 100, minimum: 1, maximum: 1000, description: 'max 1000' }), + ), + offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), + }), + response: { + 200: res.Paged(t.Ref(UserSubjectEpisodeCollection)), + }, + }, + preHandler: [requireLogin('get subject episode collections')], + }, + async ({ auth, params: { subjectID }, query: { type, limit = 100, offset = 0 } }) => { + const subject = await fetcher.fetchSlimSubjectByID(subjectID, auth.allowNsfw); + if (!subject) { + throw new NotFoundError(`subject ${subjectID}`); + } + const epStatus = await fetcher.fetchSubjectEpStatus(auth.userID, subjectID); + if (!epStatus) { + return { data: [], total: 0 }; + } + const conditions = op.and( + op.eq(schema.chiiEpisodes.subjectID, subjectID), + op.ne(schema.chiiEpisodes.ban, 1), + type ? op.eq(schema.chiiEpisodes.type, type) : undefined, + ); + const [{ count = 0 } = {}] = await db + .select({ count: op.count() }) + .from(schema.chiiEpisodes) + .where(conditions) + .execute(); + + const data = await db + .select() + .from(schema.chiiEpisodes) + .where(conditions) + .orderBy( + op.asc(schema.chiiEpisodes.disc), + op.asc(schema.chiiEpisodes.type), + op.asc(schema.chiiEpisodes.sort), + ) + .limit(limit) + .offset(offset) + .execute(); + const collections = data.map((d) => toUserSubjectEpisodeCollection(d, epStatus[d.id])); + + return { + data: collections, + total: count, + }; + }, + ); + + app.get( + '/users/-/collections/subjects/-/episodes/:episodeID', + { + schema: { + summary: '获取用户单个条目的单个章节收藏', + operationId: 'getUserSubjectCollectionEpisodeByEpisodeID', + tags: [Tag.User], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + episodeID: t.Integer({ minimum: 1 }), + }), + response: { + 200: t.Ref(UserSubjectEpisodeCollection), + }, + }, + preHandler: [requireLogin('get subject episode collection')], + }, + async ({ auth, params: { episodeID } }) => { + const [episode] = await db + .select() + .from(schema.chiiEpisodes) + .where(op.and(op.eq(schema.chiiEpisodes.id, episodeID), op.ne(schema.chiiEpisodes.ban, 1))) + .execute(); + if (!episode) { + throw new NotFoundError(`episode ${episodeID}`); + } + const epStatus = await fetcher.fetchSubjectEpStatus(auth.userID, episode.subjectID); + if (!epStatus) { + throw new NotFoundError(`status of episode ${episodeID}`); + } + return toUserSubjectEpisodeCollection(episode, epStatus[episodeID]); + }, + ); + app.get( '/users/:username/collections/characters', {