refactor: extract jimaku, subtitle position, and render metric services

This commit is contained in:
2026-02-09 22:17:57 -08:00
parent 57d4d4602c
commit e773db7e88
4 changed files with 375 additions and 383 deletions

View File

@@ -0,0 +1,81 @@
import {
JimakuApiResponse,
JimakuConfig,
JimakuLanguagePreference,
} from "../../types";
import {
jimakuFetchJson as jimakuFetchJsonRequest,
resolveJimakuApiKey as resolveJimakuApiKeyFromConfig,
} from "../../jimaku/utils";
export function getJimakuConfigService(
getResolvedConfig: () => { jimaku?: JimakuConfig },
): JimakuConfig {
const config = getResolvedConfig();
return config.jimaku ?? {};
}
export function getJimakuBaseUrlService(
getResolvedConfig: () => { jimaku?: JimakuConfig },
defaultBaseUrl: string,
): string {
const config = getJimakuConfigService(getResolvedConfig);
return config.apiBaseUrl || defaultBaseUrl;
}
export function getJimakuLanguagePreferenceService(
getResolvedConfig: () => { jimaku?: JimakuConfig },
defaultPreference: JimakuLanguagePreference,
): JimakuLanguagePreference {
const config = getJimakuConfigService(getResolvedConfig);
return config.languagePreference || defaultPreference;
}
export function getJimakuMaxEntryResultsService(
getResolvedConfig: () => { jimaku?: JimakuConfig },
defaultValue: number,
): number {
const config = getJimakuConfigService(getResolvedConfig);
const value = config.maxEntryResults;
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
return defaultValue;
}
export async function resolveJimakuApiKeyService(
getResolvedConfig: () => { jimaku?: JimakuConfig },
): Promise<string | null> {
return resolveJimakuApiKeyFromConfig(getJimakuConfigService(getResolvedConfig));
}
export async function jimakuFetchJsonService<T>(
endpoint: string,
query: Record<string, string | number | boolean | null | undefined> = {},
options: {
getResolvedConfig: () => { jimaku?: JimakuConfig };
defaultBaseUrl: string;
defaultMaxEntryResults: number;
defaultLanguagePreference: JimakuLanguagePreference;
},
): Promise<JimakuApiResponse<T>> {
const apiKey = await resolveJimakuApiKeyService(options.getResolvedConfig);
if (!apiKey) {
return {
ok: false,
error: {
error:
"Jimaku API key not set. Configure jimaku.apiKey or jimaku.apiKeyCommand.",
code: 401,
},
};
}
return jimakuFetchJsonRequest<T>(endpoint, query, {
baseUrl: getJimakuBaseUrlService(
options.getResolvedConfig,
options.defaultBaseUrl,
),
apiKey,
});
}

View File

@@ -0,0 +1,57 @@
import { MpvSubtitleRenderMetrics } from "../../types";
import { asBoolean, asFiniteNumber, asString } from "../utils/coerce";
export function updateMpvSubtitleRenderMetricsService(
current: MpvSubtitleRenderMetrics,
patch: Partial<MpvSubtitleRenderMetrics>,
): MpvSubtitleRenderMetrics {
const patchOsd = patch.osdDimensions;
const nextOsdDimensions =
patchOsd &&
typeof patchOsd.w === "number" &&
typeof patchOsd.h === "number" &&
typeof patchOsd.ml === "number" &&
typeof patchOsd.mr === "number" &&
typeof patchOsd.mt === "number" &&
typeof patchOsd.mb === "number"
? {
w: asFiniteNumber(patchOsd.w, 0, 1, 100000),
h: asFiniteNumber(patchOsd.h, 0, 1, 100000),
ml: asFiniteNumber(patchOsd.ml, 0, 0, 100000),
mr: asFiniteNumber(patchOsd.mr, 0, 0, 100000),
mt: asFiniteNumber(patchOsd.mt, 0, 0, 100000),
mb: asFiniteNumber(patchOsd.mb, 0, 0, 100000),
}
: patchOsd === null
? null
: current.osdDimensions;
return {
subPos: asFiniteNumber(patch.subPos, current.subPos, 0, 150),
subFontSize: asFiniteNumber(patch.subFontSize, current.subFontSize, 1, 200),
subScale: asFiniteNumber(patch.subScale, current.subScale, 0.1, 10),
subMarginY: asFiniteNumber(patch.subMarginY, current.subMarginY, 0, 200),
subMarginX: asFiniteNumber(patch.subMarginX, current.subMarginX, 0, 200),
subFont: asString(patch.subFont, current.subFont),
subSpacing: asFiniteNumber(patch.subSpacing, current.subSpacing, -100, 100),
subBold: asBoolean(patch.subBold, current.subBold),
subItalic: asBoolean(patch.subItalic, current.subItalic),
subBorderSize: asFiniteNumber(
patch.subBorderSize,
current.subBorderSize,
0,
100,
),
subShadowOffset: asFiniteNumber(
patch.subShadowOffset,
current.subShadowOffset,
0,
100,
),
subAssOverride: asString(patch.subAssOverride, current.subAssOverride),
subScaleByWindow: asBoolean(patch.subScaleByWindow, current.subScaleByWindow),
subUseMargins: asBoolean(patch.subUseMargins, current.subUseMargins),
osdHeight: asFiniteNumber(patch.osdHeight, current.osdHeight, 1, 10000),
osdDimensions: nextOsdDimensions,
};
}

View File

@@ -0,0 +1,154 @@
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import { SubtitlePosition } from "../../types";
function getSubtitlePositionFilePath(
mediaPath: string,
subtitlePositionsDir: string,
): string {
const key = normalizeMediaPathForSubtitlePosition(mediaPath);
const hash = crypto.createHash("sha256").update(key).digest("hex");
return path.join(subtitlePositionsDir, `${hash}.json`);
}
function normalizeMediaPathForSubtitlePosition(mediaPath: string): string {
const trimmed = mediaPath.trim();
if (!trimmed) return trimmed;
if (
/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ||
/^ytsearch:/.test(trimmed)
) {
return trimmed;
}
const resolved = path.resolve(trimmed);
let normalized = resolved;
try {
if (fs.existsSync(resolved)) {
normalized = fs.realpathSync(resolved);
}
} catch {
normalized = resolved;
}
if (process.platform === "win32") {
normalized = normalized.toLowerCase();
}
return normalized;
}
function persistSubtitlePosition(
position: SubtitlePosition,
currentMediaPath: string | null,
subtitlePositionsDir: string,
): void {
if (!currentMediaPath) return;
if (!fs.existsSync(subtitlePositionsDir)) {
fs.mkdirSync(subtitlePositionsDir, { recursive: true });
}
const positionPath = getSubtitlePositionFilePath(
currentMediaPath,
subtitlePositionsDir,
);
fs.writeFileSync(positionPath, JSON.stringify(position, null, 2));
}
export function loadSubtitlePositionService(options: {
currentMediaPath: string | null;
fallbackPosition: SubtitlePosition;
} & { subtitlePositionsDir: string }): SubtitlePosition | null {
if (!options.currentMediaPath) {
return options.fallbackPosition;
}
try {
const positionPath = getSubtitlePositionFilePath(
options.currentMediaPath,
options.subtitlePositionsDir,
);
if (!fs.existsSync(positionPath)) {
return options.fallbackPosition;
}
const data = fs.readFileSync(positionPath, "utf-8");
const parsed = JSON.parse(data) as Partial<SubtitlePosition>;
if (
parsed &&
typeof parsed.yPercent === "number" &&
Number.isFinite(parsed.yPercent)
) {
return { yPercent: parsed.yPercent };
}
return options.fallbackPosition;
} catch (err) {
console.error("Failed to load subtitle position:", (err as Error).message);
return options.fallbackPosition;
}
}
export function saveSubtitlePositionService(options: {
position: SubtitlePosition;
currentMediaPath: string | null;
subtitlePositionsDir: string;
onQueuePending: (position: SubtitlePosition) => void;
onPersisted: () => void;
}): void {
if (!options.currentMediaPath) {
options.onQueuePending(options.position);
console.warn("Queued subtitle position save - no media path yet");
return;
}
try {
persistSubtitlePosition(
options.position,
options.currentMediaPath,
options.subtitlePositionsDir,
);
options.onPersisted();
} catch (err) {
console.error("Failed to save subtitle position:", (err as Error).message);
}
}
export function updateCurrentMediaPathService(options: {
mediaPath: unknown;
currentMediaPath: string | null;
pendingSubtitlePosition: SubtitlePosition | null;
subtitlePositionsDir: string;
loadSubtitlePosition: () => SubtitlePosition | null;
setCurrentMediaPath: (mediaPath: string | null) => void;
clearPendingSubtitlePosition: () => void;
setSubtitlePosition: (position: SubtitlePosition | null) => void;
broadcastSubtitlePosition: (position: SubtitlePosition | null) => void;
}): void {
const nextPath =
typeof options.mediaPath === "string" && options.mediaPath.trim().length > 0
? options.mediaPath
: null;
if (nextPath === options.currentMediaPath) return;
options.setCurrentMediaPath(nextPath);
if (nextPath && options.pendingSubtitlePosition) {
try {
persistSubtitlePosition(
options.pendingSubtitlePosition,
nextPath,
options.subtitlePositionsDir,
);
options.setSubtitlePosition(options.pendingSubtitlePosition);
options.clearPendingSubtitlePosition();
} catch (err) {
console.error(
"Failed to persist queued subtitle position:",
(err as Error).message,
);
}
}
const position = options.loadSubtitlePosition();
options.broadcastSubtitlePosition(position);
}