feat(trpc): playback progress + danmaku open-network relay
This commit is contained in:
parent
c37f9dcf62
commit
8839763f24
|
|
@ -38,3 +38,7 @@ export {
|
||||||
buildSearchQuery,
|
buildSearchQuery,
|
||||||
} from "./services/scrape.service.js";
|
} from "./services/scrape.service.js";
|
||||||
export type { BangumiSearchHit } from "./services/scrape.service.js";
|
export type { BangumiSearchHit } from "./services/scrape.service.js";
|
||||||
|
|
||||||
|
// Playback / danmaku
|
||||||
|
export { playbackService } from "./services/playback.service.js";
|
||||||
|
export { danmakuService } from "./services/danmaku.service.js";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao";
|
||||||
|
import type { DanmakuFetchOutput } from "@app/types";
|
||||||
|
import { decryptSecret } from "./secret.js";
|
||||||
|
import { createWebdav } from "./webdav-client.js";
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function openApiBase(): string {
|
||||||
|
return process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net";
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchKeyFor(path: string, size: number): string {
|
||||||
|
return createHash("sha256").update(`${path}:${size}`).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取 WebDAV 文件 hash(首 64KiB + 尺寸),用于开放网络识别 */
|
||||||
|
async function remoteSampleHash(userId: string, mediaId: string): Promise<string | null> {
|
||||||
|
const item = await mediaItemDao.getByIdForUser(mediaId, userId);
|
||||||
|
if (!item?.mountId) return null;
|
||||||
|
const mount = await mountDao.getByIdForUser(item.mountId, userId);
|
||||||
|
if (!mount) return null;
|
||||||
|
const client = createWebdav({
|
||||||
|
baseUrl: mount.baseUrl,
|
||||||
|
username: mount.username ?? undefined,
|
||||||
|
password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const abs = mount.rootPath.replace(/\/+$/, "") + item.path;
|
||||||
|
const stream = client.createReadStream(abs, { range: { start: 0, end: 65535 } });
|
||||||
|
const sample = await new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
||||||
|
});
|
||||||
|
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
stream.on("error", reject);
|
||||||
|
});
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(sample)
|
||||||
|
.update(String(item.size))
|
||||||
|
.digest("hex")
|
||||||
|
.toUpperCase();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOpenNetworkXml(
|
||||||
|
path: string,
|
||||||
|
size: number,
|
||||||
|
hash: string | null,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const base = openApiBase();
|
||||||
|
try {
|
||||||
|
const identifyRes = await fetch(`${base}/api/v2/identify`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileHash: hash ?? "",
|
||||||
|
fileSize: size,
|
||||||
|
fileName: path.split("/").pop() ?? path,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!identifyRes.ok) return null;
|
||||||
|
const identified = (await identifyRes.json()) as {
|
||||||
|
isMatched?: boolean;
|
||||||
|
matched?: boolean;
|
||||||
|
episodeId?: number;
|
||||||
|
};
|
||||||
|
const episodeId = identified.episodeId;
|
||||||
|
if (!episodeId || !(identified.isMatched || identified.matched)) return null;
|
||||||
|
const commentRes = await fetch(`${base}/api/v2/comment/${episodeId}?withResponses=false`);
|
||||||
|
if (!commentRes.ok) return null;
|
||||||
|
const body = (await commentRes.json()) as { danmaku?: string; xml?: string };
|
||||||
|
return body.danmaku ?? body.xml ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const danmakuService = {
|
||||||
|
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
|
||||||
|
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
|
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
|
||||||
|
|
||||||
|
const matchKey = matchKeyFor(item.path, item.size);
|
||||||
|
const cached = await danmakuCacheDao.getValid(matchKey);
|
||||||
|
if (cached) {
|
||||||
|
return { ok: true, source: "cache", xml: cached.payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await remoteSampleHash(userId, mediaItemId);
|
||||||
|
const xml = await fetchOpenNetworkXml(item.path, item.size, hash);
|
||||||
|
if (!xml) {
|
||||||
|
return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" };
|
||||||
|
}
|
||||||
|
await danmakuCacheDao.upsert({
|
||||||
|
matchKey,
|
||||||
|
payload: xml,
|
||||||
|
source: "open-network",
|
||||||
|
expiresAt: new Date(Date.now() + CACHE_TTL_MS),
|
||||||
|
});
|
||||||
|
return { ok: true, source: "open-network", xml };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 本地 XML 由前端解析后直接喂播放器;此处仅确认媒体存在并记 size 日志约束 */
|
||||||
|
async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> {
|
||||||
|
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
|
if (!item) return Promise.reject(new Error("媒体不存在"));
|
||||||
|
if (byteSize > 20_000_000) return Promise.reject(new Error("XML 过大"));
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { playbackProgressDao } from "@app/dao";
|
||||||
|
import type { PlaybackReportInput } from "@app/types";
|
||||||
|
|
||||||
|
export const playbackService = {
|
||||||
|
async report(userId: string, input: PlaybackReportInput) {
|
||||||
|
const row = await playbackProgressDao.upsert(
|
||||||
|
userId,
|
||||||
|
input.mediaItemId,
|
||||||
|
input.positionMs,
|
||||||
|
input.durationMs,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
mediaItemId: row.mediaItemId,
|
||||||
|
positionMs: row.positionMs,
|
||||||
|
durationMs: row.durationMs,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async get(userId: string, mediaItemId: string) {
|
||||||
|
const row = await playbackProgressDao.get(userId, mediaItemId);
|
||||||
|
if (!row) return { mediaItemId, positionMs: 0, durationMs: 0, updatedAt: null };
|
||||||
|
return {
|
||||||
|
mediaItemId: row.mediaItemId,
|
||||||
|
positionMs: row.positionMs,
|
||||||
|
durationMs: row.durationMs,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue