137 lines
4.1 KiB
TypeScript
137 lines
4.1 KiB
TypeScript
import { randomUUID } from "crypto";
|
|
import { prisma } from "../database/client";
|
|
import { nodeConnectionManager } from "../websocket/manager";
|
|
import type { SubtitleHit } from "../opensubtitles/client";
|
|
|
|
type LocalSubCacheEntry = {
|
|
contentBase64: string;
|
|
fileName: string;
|
|
format: string;
|
|
language: string;
|
|
mediaFileId: string;
|
|
expiresAt: number;
|
|
};
|
|
|
|
const localSubCache = new Map<number, LocalSubCacheEntry>();
|
|
const CACHE_TTL_MS = 30 * 60_000;
|
|
|
|
export function stableLocalFileId(key: string): number {
|
|
let h = 2166136261;
|
|
for (let i = 0; i < key.length; i++) {
|
|
h ^= key.charCodeAt(i);
|
|
h = Math.imul(h, 16777619);
|
|
}
|
|
const n = Math.abs(h) % 2_000_000_000;
|
|
return n === 0 ? 1 : n;
|
|
}
|
|
|
|
function putLocalCache(fileId: number, entry: Omit<LocalSubCacheEntry, "expiresAt">) {
|
|
localSubCache.set(fileId, { ...entry, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
}
|
|
|
|
export function getLocalSubtitleCache(fileId: number): LocalSubCacheEntry | null {
|
|
const hit = localSubCache.get(fileId);
|
|
if (!hit) return null;
|
|
if (Date.now() > hit.expiresAt) {
|
|
localSubCache.delete(fileId);
|
|
return null;
|
|
}
|
|
return hit;
|
|
}
|
|
|
|
/** Ask node for sidecar .srt/.ass next to the video; cache + upsert library_subtitles. */
|
|
export async function discoverSidecarSubtitles(mediaFileId: string): Promise<SubtitleHit[]> {
|
|
const file = await prisma.mediaFile.findUnique({
|
|
where: { id: mediaFileId },
|
|
include: {
|
|
movie: { select: { id: true, tmdbId: true, imdbId: true } },
|
|
episode: {
|
|
select: {
|
|
imdbId: true,
|
|
series: { select: { imdbId: true, tmdbId: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!file || !nodeConnectionManager.isOnline(file.nodeId)) return [];
|
|
|
|
const requestId = randomUUID();
|
|
const ack = await nodeConnectionManager.findSidecarSubtitlesAcked(
|
|
file.nodeId,
|
|
{ requestId, localFileId: file.localFileId },
|
|
15_000
|
|
);
|
|
if (!ack.ok || !ack.subtitles?.length) return [];
|
|
|
|
const hits: SubtitleHit[] = [];
|
|
for (const sub of ack.subtitles) {
|
|
const format = (sub.format || "srt").toLowerCase();
|
|
const language = (sub.language || "nl").toLowerCase();
|
|
const fileName = sub.fileName || `local.${format}`;
|
|
const fileId = stableLocalFileId(`${mediaFileId}:${fileName}`);
|
|
putLocalCache(fileId, {
|
|
contentBase64: sub.contentBase64,
|
|
fileName,
|
|
format,
|
|
language,
|
|
mediaFileId,
|
|
});
|
|
|
|
// Persist for Stremio stream.subtitles
|
|
try {
|
|
const content = Buffer.from(sub.contentBase64, "base64").toString("utf8");
|
|
if (content.trim()) {
|
|
const movieId = file.movieId ?? null;
|
|
const tmdbId = file.movie?.tmdbId ?? file.episode?.series?.tmdbId ?? null;
|
|
const imdbId =
|
|
file.movie?.imdbId || file.episode?.imdbId || file.episode?.series?.imdbId || null;
|
|
const linkFilter = [
|
|
...(movieId ? [{ movieId }] : []),
|
|
...(tmdbId != null ? [{ tmdbId }] : []),
|
|
...(imdbId ? [{ imdbId }] : []),
|
|
];
|
|
const existing =
|
|
linkFilter.length > 0
|
|
? await prisma.librarySubtitle.findFirst({
|
|
where: { fileName, OR: linkFilter },
|
|
})
|
|
: await prisma.librarySubtitle.findFirst({
|
|
where: { fileName, content },
|
|
});
|
|
if (existing) {
|
|
await prisma.librarySubtitle.update({
|
|
where: { id: existing.id },
|
|
data: { content, language, format, updatedAt: new Date() },
|
|
});
|
|
} else {
|
|
await prisma.librarySubtitle.create({
|
|
data: {
|
|
movieId,
|
|
tmdbId,
|
|
imdbId,
|
|
language,
|
|
format,
|
|
content,
|
|
fileName,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn("[sidecars] library_subtitles upsert:", err);
|
|
}
|
|
|
|
hits.push({
|
|
fileId,
|
|
language,
|
|
release: `Lokaal · ${fileName}`,
|
|
fileName,
|
|
downloadCount: 9_999_999,
|
|
hearingImpaired: /\b(sdh|hi|hearing)\b/i.test(fileName),
|
|
fps: null,
|
|
uploader: "lokaal",
|
|
source: "local",
|
|
});
|
|
}
|
|
return hits;
|
|
}
|