mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-02 19:21:34 -07:00
184 lines
4.9 KiB
TypeScript
184 lines
4.9 KiB
TypeScript
import path from 'node:path';
|
|
import type { ProbeMetadata, SessionState } from './types';
|
|
import { SOURCE_TYPE_REMOTE } from './types';
|
|
|
|
export function createInitialSessionState(
|
|
sessionId: number,
|
|
videoId: number,
|
|
startedAtMs: number,
|
|
): SessionState {
|
|
return {
|
|
sessionId,
|
|
videoId,
|
|
startedAtMs,
|
|
currentLineIndex: 0,
|
|
totalWatchedMs: 0,
|
|
activeWatchedMs: 0,
|
|
linesSeen: 0,
|
|
tokensSeen: 0,
|
|
cardsMined: 0,
|
|
lookupCount: 0,
|
|
lookupHits: 0,
|
|
yomitanLookupCount: 0,
|
|
pauseCount: 0,
|
|
pauseMs: 0,
|
|
seekForwardCount: 0,
|
|
seekBackwardCount: 0,
|
|
mediaBufferEvents: 0,
|
|
lastWallClockMs: 0,
|
|
lastMediaMs: null,
|
|
lastPauseStartMs: null,
|
|
isPaused: false,
|
|
pendingTelemetry: true,
|
|
markedWatched: false,
|
|
};
|
|
}
|
|
|
|
export function resolveBoundedInt(
|
|
value: number | undefined,
|
|
fallback: number,
|
|
min: number,
|
|
max: number,
|
|
): number {
|
|
if (!Number.isFinite(value)) return fallback;
|
|
const candidate = Math.floor(value as number);
|
|
if (candidate < min || candidate > max) return fallback;
|
|
return candidate;
|
|
}
|
|
|
|
export function sanitizePayload(payload: Record<string, unknown>, maxPayloadBytes: number): string {
|
|
const json = JSON.stringify(payload);
|
|
return json.length <= maxPayloadBytes ? json : JSON.stringify({ truncated: true });
|
|
}
|
|
|
|
export function secToMs(seconds: number): number {
|
|
const coerced = Number(seconds);
|
|
if (!Number.isFinite(coerced)) return 0;
|
|
return Math.round(coerced * 1000);
|
|
}
|
|
|
|
export function normalizeMediaPath(mediaPath: string | null): string {
|
|
if (!mediaPath || !mediaPath.trim()) return '';
|
|
return mediaPath.trim();
|
|
}
|
|
|
|
export function normalizeText(value: string | null | undefined): string {
|
|
if (!value) return '';
|
|
return value.trim().replace(/\s+/g, ' ');
|
|
}
|
|
|
|
export interface ExtractedLineVocabulary {
|
|
words: Array<{ headword: string; word: string; reading: string }>;
|
|
kanji: string[];
|
|
}
|
|
|
|
export function isKanji(char: string): boolean {
|
|
if (!char) return false;
|
|
const code = char.codePointAt(0);
|
|
if (code === undefined) return false;
|
|
return (
|
|
(code >= 0x4e00 && code <= 0x9fff) ||
|
|
(code >= 0x3400 && code <= 0x4dbf) ||
|
|
(code >= 0x20000 && code <= 0x2a6df)
|
|
);
|
|
}
|
|
|
|
export function extractLineVocabulary(value: string): ExtractedLineVocabulary {
|
|
const cleaned = normalizeText(value);
|
|
if (!cleaned) return { words: [], kanji: [] };
|
|
|
|
const wordSet = new Set<string>();
|
|
const tokenPattern =
|
|
/[A-Za-z0-9']+|[\u3040-\u30ff]+|[\u3400-\u4dbf\u4e00-\u9fff\u20000-\u2a6df]+/g;
|
|
const rawWords = cleaned.match(tokenPattern) ?? [];
|
|
for (const rawWord of rawWords) {
|
|
const normalizedWord = normalizeText(rawWord.toLowerCase());
|
|
if (!normalizedWord) continue;
|
|
wordSet.add(normalizedWord);
|
|
}
|
|
|
|
const kanji = new Set<string>();
|
|
for (const char of cleaned) {
|
|
if (isKanji(char)) {
|
|
kanji.add(char);
|
|
}
|
|
}
|
|
|
|
const words = Array.from(wordSet).map((word) => ({
|
|
headword: word,
|
|
word,
|
|
reading: '',
|
|
}));
|
|
return {
|
|
words,
|
|
kanji: Array.from(kanji),
|
|
};
|
|
}
|
|
|
|
export function buildVideoKey(mediaPath: string, sourceType: number): string {
|
|
if (sourceType === SOURCE_TYPE_REMOTE) {
|
|
return `remote:${mediaPath}`;
|
|
}
|
|
return `local:${mediaPath}`;
|
|
}
|
|
|
|
export function isRemoteSource(mediaPath: string): boolean {
|
|
return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath);
|
|
}
|
|
|
|
export function deriveCanonicalTitle(mediaPath: string): string {
|
|
if (isRemoteSource(mediaPath)) {
|
|
try {
|
|
const parsed = new URL(mediaPath);
|
|
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
if (parts.length > 0) {
|
|
const leaf = decodeURIComponent(parts[parts.length - 1]!);
|
|
return normalizeText(leaf.replace(/\.[^/.]+$/, ''));
|
|
}
|
|
return normalizeText(parsed.hostname) || 'unknown';
|
|
} catch {
|
|
return normalizeText(mediaPath);
|
|
}
|
|
}
|
|
|
|
const filename = path.basename(mediaPath);
|
|
return normalizeText(filename.replace(/\.[^/.]+$/, ''));
|
|
}
|
|
|
|
export function parseFps(value?: string): number | null {
|
|
if (!value || typeof value !== 'string') return null;
|
|
const [num, den] = value.split('/');
|
|
const n = Number(num);
|
|
const d = Number(den);
|
|
if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null;
|
|
const fps = n / d;
|
|
return Number.isFinite(fps) ? Math.round(fps * 100) : null;
|
|
}
|
|
|
|
export function hashToCode(input?: string): number | null {
|
|
if (!input) return null;
|
|
let hash = 0;
|
|
for (let i = 0; i < input.length; i += 1) {
|
|
hash = (hash * 31 + input.charCodeAt(i)) & 0x7fffffff;
|
|
}
|
|
return hash || null;
|
|
}
|
|
|
|
export function emptyMetadata(): ProbeMetadata {
|
|
return {
|
|
durationMs: null,
|
|
codecId: null,
|
|
containerId: null,
|
|
widthPx: null,
|
|
heightPx: null,
|
|
fpsX100: null,
|
|
bitrateKbps: null,
|
|
audioCodecId: null,
|
|
};
|
|
}
|
|
|
|
export function toNullableInt(value: number | null | undefined): number | null {
|
|
if (value === null || value === undefined || !Number.isFinite(value)) return null;
|
|
return value;
|
|
}
|