feat(models,dao): danmaku prefs/docs tables and match source

This commit is contained in:
noelorin 2026-09-26 19:33:37 +08:00
parent 5ce1f91a26
commit 3467e397e7
8 changed files with 157 additions and 1 deletions

View File

@ -0,0 +1,42 @@
import { and, eq } from "drizzle-orm";
import { danmakuDocs, type DanmakuDocRow } from "@app/models";
import { db } from "@app/db";
export const danmakuDocsDao = {
async get(userId: string, mediaItemId: string, source: string): Promise<DanmakuDocRow | null> {
const [row] = await db
.select()
.from(danmakuDocs)
.where(
and(
eq(danmakuDocs.userId, userId),
eq(danmakuDocs.mediaItemId, mediaItemId),
eq(danmakuDocs.source, source),
),
)
.limit(1);
return row ?? null;
},
async upsert(data: {
userId: string;
mediaItemId: string;
source: string;
xml: string;
byteSize: number;
}): Promise<DanmakuDocRow> {
const existing = await danmakuDocsDao.get(data.userId, data.mediaItemId, data.source);
if (existing) {
const [row] = await db
.update(danmakuDocs)
.set({ xml: data.xml, byteSize: data.byteSize, updatedAt: new Date() })
.where(eq(danmakuDocs.id, existing.id))
.returning();
if (!row) throw new Error("Failed to update danmaku doc");
return row;
}
const rows = await db.insert(danmakuDocs).values(data).returning();
const row = rows[0];
if (!row) throw new Error("Failed to insert danmaku doc");
return row;
},
};

View File

@ -0,0 +1,41 @@
import { eq } from "drizzle-orm";
import { danmakuPrefs, type DanmakuPrefsRow } from "@app/models";
import { db } from "@app/db";
export const danmakuPrefsDao = {
async getByUser(userId: string): Promise<DanmakuPrefsRow | null> {
const [row] = await db
.select()
.from(danmakuPrefs)
.where(eq(danmakuPrefs.userId, userId))
.limit(1);
return row ?? null;
},
async upsert(data: {
userId: string;
enabled: boolean;
opacity: number;
density: number;
blockKeywords: string;
blockTypes: string;
}): Promise<DanmakuPrefsRow> {
const existing = await db
.select()
.from(danmakuPrefs)
.where(eq(danmakuPrefs.userId, data.userId))
.limit(1);
if (existing[0]) {
const [row] = await db
.update(danmakuPrefs)
.set({ ...data, updatedAt: new Date() })
.where(eq(danmakuPrefs.id, existing[0].id))
.returning();
if (!row) throw new Error("Failed to update danmaku prefs");
return row;
}
const rows = await db.insert(danmakuPrefs).values(data).returning();
const row = rows[0];
if (!row) throw new Error("Failed to insert danmaku prefs");
return row;
},
};

View File

@ -11,3 +11,5 @@ export { mediaItemDao } from "./media-items.js";
export type { MediaItemRow } from "./media-items.js";
export { playbackProgressDao } from "./playback-progress.js";
export { danmakuCacheDao } from "./danmaku-cache.js";
export { danmakuPrefsDao } from "./danmaku-prefs.js";
export { danmakuDocsDao } from "./danmaku-docs.js";

View File

@ -280,13 +280,18 @@ export const mediaItemDao = {
async setDanmakuMatch(
id: string,
userId: string,
data: { episodeId: number; matchedHash?: string | null },
data: {
episodeId: number;
matchedHash?: string | null;
source?: "auto" | "manual" | "none";
},
): Promise<MediaItemRow | null> {
const [row] = await db
.update(mediaItems)
.set({
dandanplayEpisodeId: data.episodeId,
matchedHash: data.matchedHash ?? null,
danmakuMatchSource: data.source ?? "auto",
updatedAt: new Date(),
})
.where(and(eq(mediaItems.id, id), eq(mediaItems.userId, userId)))

View File

@ -0,0 +1,32 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { users } from "./users.js";
import { mediaItems } from "./media-items.js";
export const danmakuDocs = sqliteTable(
"danmaku_docs",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
mediaItemId: text("media_item_id")
.notNull()
.references(() => mediaItems.id, { onDelete: "cascade" }),
/** `open-network` | `local-xml` */
source: text("source").notNull(),
xml: text("xml").notNull(),
byteSize: integer("byte_size").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(t) => [uniqueIndex("danmaku_docs_user_media_src_uq").on(t.userId, t.mediaItemId, t.source)],
);
export type DanmakuDocRow = typeof danmakuDocs.$inferSelect;
export type NewDanmakuDocRow = typeof danmakuDocs.$inferInsert;

View File

@ -0,0 +1,28 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { users } from "./users.js";
export const danmakuPrefs = sqliteTable(
"danmaku_prefs",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
/** 整数百分比 0–100,API 层换算 0–1 小数 */
opacity: integer("opacity").notNull().default(80),
/** 整数百分比 10–100 */
density: integer("density").notNull().default(100),
blockKeywords: text("block_keywords").notNull().default("[]"),
blockTypes: text("block_types").notNull().default("[]"),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(t) => [uniqueIndex("danmaku_prefs_user_uq").on(t.userId)],
);
export type DanmakuPrefsRow = typeof danmakuPrefs.$inferSelect;
export type NewDanmakuPrefsRow = typeof danmakuPrefs.$inferInsert;

View File

@ -13,3 +13,7 @@ export { playbackProgress } from "./playback-progress.js";
export type { PlaybackProgress } from "./playback-progress.js";
export { danmakuCache } from "./danmaku-cache.js";
export type { DanmakuCacheRow } from "./danmaku-cache.js";
export { danmakuPrefs } from "./danmaku-prefs.js";
export type { DanmakuPrefsRow, NewDanmakuPrefsRow } from "./danmaku-prefs.js";
export { danmakuDocs } from "./danmaku-docs.js";
export type { DanmakuDocRow, NewDanmakuDocRow } from "./danmaku-docs.js";

View File

@ -26,6 +26,8 @@ export const mediaItems = sqliteTable(
/** 弹弹play 弹幕库编号(文件识别成功后落库,弹幕拉取直达) */
dandanplayEpisodeId: integer("dandanplay_episode_id"),
matchedHash: text("matched_hash"),
/** auto=高置信自动 | manual=手选 | none */
danmakuMatchSource: text("danmaku_match_source").notNull().default("none"),
scannedAt: integer("scanned_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),