Run prettier across source files

This commit is contained in:
2026-04-03 14:04:07 -07:00
parent acb490fa10
commit c31e55398d
60 changed files with 615 additions and 534 deletions

View File

@@ -369,7 +369,8 @@ export class AnkiIntegration {
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
},
findDuplicateNoteIds: (expression, noteInfo) => this.findDuplicateNoteIds(expression, noteInfo),
findDuplicateNoteIds: (expression, noteInfo) =>
this.findDuplicateNoteIds(expression, noteInfo),
recordCardsMinedCallback: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'card creation');
},
@@ -1082,10 +1083,7 @@ export class AnkiIntegration {
});
}
private async findDuplicateNoteIds(
expression: string,
noteInfo: NoteInfo,
): Promise<number[]> {
private async findDuplicateNoteIds(expression: string, noteInfo: NoteInfo): Promise<number[]> {
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,

View File

@@ -162,7 +162,8 @@ export class AnkiConnectProxyServer {
}
try {
const forwardedBody = req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
const forwardedBody =
req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
const contentType =
typeof req.headers['content-type'] === 'string'
@@ -272,7 +273,9 @@ export class AnkiConnectProxyServer {
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
const action =
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
typeof requestJson.action === 'string'
? requestJson.action
: String(requestJson.action ?? '');
if (action !== 'addNote') {
return requestJson;
}
@@ -301,9 +304,13 @@ export class AnkiConnectProxyServer {
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
? params.subminerDuplicateNoteIds
: [];
return [...new Set(rawNoteIds.filter((entry): entry is number => {
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
}))].sort((left, right) => left - right);
return [
...new Set(
rawNoteIds.filter((entry): entry is number => {
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
}),
),
].sort((left, right) => left - right);
}
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {

View File

@@ -113,10 +113,7 @@ interface CardCreationDeps {
setUpdateInProgress: (value: boolean) => void;
trackLastAddedNoteId?: (noteId: number) => void;
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
findDuplicateNoteIds?: (
expression: string,
noteInfo: CardCreationNoteInfo,
) => Promise<number[]>;
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
}
@@ -573,10 +570,7 @@ export class CardCreationService {
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
);
} catch (error) {
log.warn(
'Failed to capture pre-add duplicate note ids:',
(error as Error).message,
);
log.warn('Failed to capture pre-add duplicate note ids:', (error as Error).message);
}
}
@@ -728,9 +722,7 @@ export class CardCreationService {
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
return {
noteId: -1,
fields: Object.fromEntries(
Object.entries(fields).map(([name, value]) => [name, { value }]),
),
fields: Object.fromEntries(Object.entries(fields).map(([name, value]) => [name, { value }])),
};
}

View File

@@ -307,21 +307,27 @@ test('findDuplicateNoteIds returns no matches when maxMatches is zero', async ()
};
let notesInfoCalls = 0;
const duplicateIds = await findDuplicateNoteIds('貴様', 100, currentNote, {
findNotes: async () => [200],
notesInfo: async (noteIds) => {
notesInfoCalls += 1;
return noteIds.map((noteId) => ({
noteId,
fields: {
Expression: { value: '貴様' },
},
}));
const duplicateIds = await findDuplicateNoteIds(
'貴様',
100,
currentNote,
{
findNotes: async () => [200],
notesInfo: async (noteIds) => {
notesInfoCalls += 1;
return noteIds.map((noteId) => ({
noteId,
fields: {
Expression: { value: '貴様' },
},
}));
},
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
},
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
}, 0);
0,
);
assert.deepEqual(duplicateIds, []);
assert.equal(notesInfoCalls, 0);

View File

@@ -24,13 +24,7 @@ export async function findDuplicateNote(
noteInfo: NoteInfo,
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const duplicateNoteIds = await findDuplicateNoteIds(
expression,
excludeNoteId,
noteInfo,
deps,
1,
);
const duplicateNoteIds = await findDuplicateNoteIds(expression, excludeNoteId, noteInfo, deps, 1);
return duplicateNoteIds[0] ?? null;
}

View File

@@ -35,17 +35,8 @@ const {
startupWarmups,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const {
ankiConnect,
jimaku,
anilist,
mpv,
yomitan,
jellyfin,
discordPresence,
ai,
youtubeSubgen,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;

View File

@@ -131,7 +131,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
},
{
title: 'YouTube Playback Settings',
description: ['Defaults for managed subtitle language preferences and YouTube subtitle loading.'],
description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
],
key: 'youtube',
},
{

View File

@@ -83,7 +83,9 @@ const PRESENCE_STYLES: Record<DiscordPresenceStylePreset, PresenceStyleDefinitio
},
};
function resolvePresenceStyle(preset: DiscordPresenceStylePreset | undefined): PresenceStyleDefinition {
function resolvePresenceStyle(
preset: DiscordPresenceStylePreset | undefined,
): PresenceStyleDefinition {
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
}
@@ -130,9 +132,7 @@ export function buildDiscordPresenceActivity(
const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
const details =
snapshot.connected && snapshot.mediaPath
? trimField(title)
: style.fallbackDetails;
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
const state =
snapshot.connected && snapshot.mediaPath
@@ -157,10 +157,7 @@ export function buildDiscordPresenceActivity(
if (style.smallImageText.trim().length > 0) {
activity.smallImageText = trimField(style.smallImageText.trim());
}
if (
style.buttonLabel.trim().length > 0 &&
/^https?:\/\//.test(style.buttonUrl.trim())
) {
if (style.buttonLabel.trim().length > 0 && /^https?:\/\//.test(style.buttonUrl.trim())) {
activity.buttons = [
{
label: trimField(style.buttonLabel.trim(), 32),

View File

@@ -380,42 +380,22 @@ export class ImmersionTrackerService {
};
};
const eventsRetention = daysToRetentionWindow(
retention.eventsDays,
7,
3650,
);
const telemetryRetention = daysToRetentionWindow(
retention.telemetryDays,
30,
3650,
);
const sessionsRetention = daysToRetentionWindow(
retention.sessionsDays,
30,
3650,
);
const eventsRetention = daysToRetentionWindow(retention.eventsDays, 7, 3650);
const telemetryRetention = daysToRetentionWindow(retention.telemetryDays, 30, 3650);
const sessionsRetention = daysToRetentionWindow(retention.sessionsDays, 30, 3650);
this.eventsRetentionMs = eventsRetention.ms;
this.eventsRetentionDays = eventsRetention.days;
this.telemetryRetentionMs = telemetryRetention.ms;
this.telemetryRetentionDays = telemetryRetention.days;
this.sessionsRetentionMs = sessionsRetention.ms;
this.sessionsRetentionDays = sessionsRetention.days;
this.dailyRollupRetentionMs = daysToRetentionWindow(
retention.dailyRollupsDays,
365,
36500,
).ms;
this.dailyRollupRetentionMs = daysToRetentionWindow(retention.dailyRollupsDays, 365, 36500).ms;
this.monthlyRollupRetentionMs = daysToRetentionWindow(
retention.monthlyRollupsDays,
5 * 365,
36500,
).ms;
this.vacuumIntervalMs = daysToRetentionWindow(
retention.vacuumIntervalDays,
7,
3650,
).ms;
this.vacuumIntervalMs = daysToRetentionWindow(retention.vacuumIntervalDays, 7, 3650).ms;
this.db = new Database(this.dbPath);
applyPragmas(this.db);
ensureSchema(this.db);

View File

@@ -975,79 +975,79 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
);
}
const insertDailyRollup = db.prepare(
`
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const insertMonthlyRollup = db.prepare(
`
);
const insertMonthlyRollup = db.prepare(
`
INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
);
insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
db.prepare(
`
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'二月',
'二月',
'にがつ',
'noun',
'名詞',
'',
'',
(BigInt(febStartedAtMs) / 1000n).toString(),
(BigInt(febStartedAtMs) / 1000n).toString(),
1,
);
db.prepare(
`
).run(
'二月',
'二月',
'にがつ',
'noun',
'名詞',
'',
'',
(BigInt(febStartedAtMs) / 1000n).toString(),
(BigInt(febStartedAtMs) / 1000n).toString(),
1,
);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'三月',
'三月',
'さんがつ',
'noun',
'名詞',
'',
'',
(BigInt(marStartedAtMs) / 1000n).toString(),
(BigInt(marStartedAtMs) / 1000n).toString(),
1,
);
).run(
'三月',
'三月',
'さんがつ',
'noun',
'名詞',
'',
'',
(BigInt(marStartedAtMs) / 1000n).toString(),
(BigInt(marStartedAtMs) / 1000n).toString(),
1,
);
const dashboard = getTrendsDashboard(db, '30d', 'month');
const dashboard = getTrendsDashboard(db, '30d', 'month');
assert.equal(dashboard.activity.watchTime.length, 2);
assert.deepEqual(
dashboard.progress.newWords.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.deepEqual(
dashboard.progress.episodes.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.deepEqual(
dashboard.progress.lookups.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.equal(dashboard.activity.watchTime.length, 2);
assert.deepEqual(
dashboard.progress.newWords.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.deepEqual(
dashboard.progress.episodes.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.deepEqual(
dashboard.progress.lookups.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
} finally {
db.close();
cleanupDbPath(dbPath);
@@ -1230,18 +1230,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'猫',
'猫',
'ねこ',
'noun',
'名詞',
'',
'',
String(twoDaysAgo),
String(twoDaysAgo),
1,
);
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', String(twoDaysAgo), String(twoDaysAgo), 1);
const hints = getQueryHints(db);
assert.equal(hints.newWordsToday, 1);

View File

@@ -82,12 +82,9 @@ function hasRetainedPriorSession(
LIMIT 1
`,
)
.get(
videoId,
toDbTimestamp(startedAtMs),
toDbTimestamp(startedAtMs),
currentSessionId,
) as { found: number } | null;
.get(videoId, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs), currentSessionId) as {
found: number;
} | null;
return Boolean(row);
}
@@ -150,7 +147,7 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
LAST_UPDATE_DATE = ?
WHERE global_id = 1
`,
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
}
function rebuildLifetimeSummariesInternal(

View File

@@ -126,9 +126,9 @@ test('pruneRawRetention skips disabled retention windows', () => {
const remainingTelemetry = db
.prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry')
.get() as { count: number };
const remainingSessions = db
.prepare('SELECT COUNT(*) AS count FROM imm_sessions')
.get() as { count: number };
const remainingSessions = db.prepare('SELECT COUNT(*) AS count FROM imm_sessions').get() as {
count: number;
};
assert.equal(result.deletedSessionEvents, 0);
assert.equal(result.deletedTelemetryRows, 0);

View File

@@ -56,10 +56,7 @@ export function pruneRawRetention(
sessionsRetentionDays?: number;
},
): RawRetentionResult {
const resolveCutoff = (
retentionMs: number,
retentionDays: number | undefined,
): string => {
const resolveCutoff = (retentionMs: number, retentionDays: number | undefined): string => {
if (retentionDays !== undefined) {
return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
}
@@ -68,9 +65,11 @@ export function pruneRawRetention(
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
? (
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(
resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays),
) as { changes: number }
db
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
.run(resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays)) as {
changes: number;
}
).changes
: 0;
const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs)

View File

@@ -150,9 +150,11 @@ export function getSessionEvents(
ORDER BY ts_ms ASC
LIMIT ?
`);
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<SessionEventRow & {
tsMs: number | string;
}>;
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<
SessionEventRow & {
tsMs: number | string;
}
>;
return rows.map((row) => ({
...row,
tsMs: fromDbTimestamp(row.tsMs) ?? 0,

View File

@@ -355,9 +355,7 @@ export function upsertCoverArt(
const fetchedAtMs = toDbTimestamp(nowMs());
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
const computedCoverBlobHash =
coverBlob && coverBlob.length > 0
? createHash('sha256').update(coverBlob).digest('hex')
: null;
coverBlob && coverBlob.length > 0 ? createHash('sha256').update(coverBlob).digest('hex') : null;
let coverBlobHash = computedCoverBlobHash ?? sharedCoverBlobHash ?? null;
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
coverBlobHash = existing?.coverBlobHash ?? null;

View File

@@ -39,10 +39,12 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
ORDER BY s.started_at_ms DESC
LIMIT ?
`);
const rows = prepared.all(limit) as Array<SessionSummaryQueryRow & {
startedAtMs: number | string;
endedAtMs: number | string | null;
}>;
const rows = prepared.all(limit) as Array<
SessionSummaryQueryRow & {
startedAtMs: number | string;
endedAtMs: number | string | null;
}
>;
return rows.map((row) => ({
...row,
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
@@ -69,19 +71,21 @@ export function getSessionTimeline(
`;
if (limit === undefined) {
const rows = db.prepare(select).all(sessionId) as Array<SessionTimelineRow & {
sampleMs: number | string;
}>;
const rows = db.prepare(select).all(sessionId) as Array<
SessionTimelineRow & {
sampleMs: number | string;
}
>;
return rows.map((row) => ({
...row,
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
}));
}
const rows = db
.prepare(`${select}\n LIMIT ?`)
.all(sessionId, limit) as Array<SessionTimelineRow & {
sampleMs: number | string;
}>;
const rows = db.prepare(`${select}\n LIMIT ?`).all(sessionId, limit) as Array<
SessionTimelineRow & {
sampleMs: number | string;
}
>;
return rows.map((row) => ({
...row,
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,

View File

@@ -359,10 +359,7 @@ function getNumericCalendarValue(
return Number(row?.value ?? 0);
}
export function getLocalEpochDay(
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
export function getLocalEpochDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
return getNumericCalendarValue(
db,
`
@@ -375,10 +372,7 @@ export function getLocalEpochDay(
);
}
export function getLocalMonthKey(
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
export function getLocalMonthKey(db: DatabaseSync, timestampMs: number | bigint | string): number {
return getNumericCalendarValue(
db,
`
@@ -391,10 +385,7 @@ export function getLocalMonthKey(
);
}
export function getLocalDayOfWeek(
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
export function getLocalDayOfWeek(db: DatabaseSync, timestampMs: number | bigint | string): number {
return getNumericCalendarValue(
db,
`
@@ -407,10 +398,7 @@ export function getLocalDayOfWeek(
);
}
export function getLocalHourOfDay(
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
export function getLocalHourOfDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
return getNumericCalendarValue(
db,
`
@@ -458,7 +446,8 @@ export function getShiftedLocalDayTimestamp(
dayOffset: number,
): string {
const normalizedDayOffset = Math.trunc(dayOffset);
const modifier = normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
const modifier =
normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
const row = db
.prepare(
`

View File

@@ -87,7 +87,20 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
'90d': 90,
};
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -101,7 +114,11 @@ function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
}
const currentTimestamp = currentDbTimestamp();
const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
const cutoffMs = getShiftedLocalDayTimestamp(db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1));
const cutoffMs = getShiftedLocalDayTimestamp(
db,
currentTimestamp,
-(TREND_DAY_LIMITS[range] - 1),
);
const currentMonthKey = getLocalMonthKey(db, todayStartMs);
const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
const currentYear = Math.floor(currentMonthKey / 100);
@@ -630,8 +647,10 @@ export function getTrendsDashboard(
const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId, (rollup) =>
rollup.totalActiveMin,
watchTime: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalActiveMin,
),
cards: buildPerAnimeFromDailyRollups(
dailyRollups,

View File

@@ -102,7 +102,9 @@ type SubtitleTrackCandidate = {
externalFilename: string | null;
};
function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): SubtitleTrackCandidate | null {
function normalizeSubtitleTrackCandidate(
track: Record<string, unknown>,
): SubtitleTrackCandidate | null {
const id =
typeof track.id === 'number'
? track.id
@@ -122,8 +124,12 @@ function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): Subtit
return {
id,
lang: String(track.lang || '').trim().toLowerCase(),
title: String(track.title || '').trim().toLowerCase(),
lang: String(track.lang || '')
.trim()
.toLowerCase(),
title: String(track.title || '')
.trim()
.toLowerCase(),
selected: track.selected === true,
external: track.external === true,
externalFilename,
@@ -168,9 +174,7 @@ function pickSecondarySubtitleTrackId(
const uniqueTracks = [...dedupedTracks.values()];
for (const language of normalizedLanguages) {
const selectedMatch = uniqueTracks.find(
(track) => track.selected && track.lang === language,
);
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
if (selectedMatch) {
return selectedMatch.id;
}

View File

@@ -102,10 +102,7 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
res.end(Buffer.from(body));
}
function startNodeHttpServer(
app: Hono,
config: StatsServerConfig,
): { close: () => void } {
function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
const server = http.createServer((req, res) => {
void (async () => {
try {
@@ -1075,11 +1072,9 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
const bunRuntime = globalThis as typeof globalThis & {
Bun?: {
serve?: (options: {
fetch: (typeof app)['fetch'];
port: number;
hostname: string;
}) => { stop: () => void };
serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
stop: () => void;
};
};
};

View File

@@ -77,7 +77,11 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
}
function getSourceTrackIdentity(track: MpvTrack): string {
if (track.external && typeof track['external-filename'] === 'string' && track['external-filename'].length > 0) {
if (
track.external &&
typeof track['external-filename'] === 'string' &&
track['external-filename'].length > 0
) {
return `external:${track['external-filename'].toLowerCase()}`;
}
if (typeof track.id === 'number') {

View File

@@ -2029,7 +2029,8 @@ export async function addYomitanNoteViaSearch(
: null,
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
? envelope.duplicateNoteIds.filter(
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
(entry): entry is number =>
typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
)
: [],
};

View File

@@ -16,11 +16,12 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
function makeFakeYtDlpScript(dir: string, payload: string): void {
const scriptPath = path.join(dir, 'yt-dlp');
const script = process.platform === 'win32'
? `#!/usr/bin/env bun
const script =
process.platform === 'win32'
? `#!/usr/bin/env bun
process.stdout.write(${JSON.stringify(payload)});
`
: `#!/usr/bin/env sh
: `#!/usr/bin/env sh
cat <<'EOF' | base64 -d
${Buffer.from(payload).toString('base64')}
EOF

View File

@@ -16,11 +16,12 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
function makeFakeYtDlpScript(dir: string, payload: string): void {
const scriptPath = path.join(dir, 'yt-dlp');
const script = process.platform === 'win32'
? `#!/usr/bin/env bun
const script =
process.platform === 'win32'
? `#!/usr/bin/env bun
process.stdout.write(${JSON.stringify(payload)});
`
: `#!/usr/bin/env sh
: `#!/usr/bin/env sh
cat <<'EOF' | base64 -d
${Buffer.from(payload).toString('base64')}
EOF

View File

@@ -114,12 +114,7 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'],
);
assert.deepEqual(
normalizeLaunchMpvExtraArgs([
'SubMiner.exe',
'--launch-mpv',
'--fullscreen',
'C:\\a.mkv',
]),
normalizeLaunchMpvExtraArgs(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
['--fullscreen'],
);
assert.deepEqual(

View File

@@ -77,15 +77,15 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
return {
shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
shouldSkipHeavyStartup: Boolean(
initialArgs &&
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.setup),
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.setup),
),
};
}
@@ -123,7 +123,12 @@ import { AnkiIntegration } from './anki-integration';
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { RuntimeOptionsManager } from './runtime-options';
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
import { createLogger, setLogLevel, resolveDefaultLogFilePath, type LogLevelSource } from './logger';
import {
createLogger,
setLogLevel,
resolveDefaultLogFilePath,
type LogLevelSource,
} from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import {
commandNeedsOverlayStartupPrereqs,
@@ -496,7 +501,10 @@ import {
} from './config';
import { resolveConfigDir } from './config/path-resolution';
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
import { createSubtitlePrefetchService, type SubtitlePrefetchService } from './core/services/subtitle-prefetch';
import {
createSubtitlePrefetchService,
type SubtitlePrefetchService,
} from './core/services/subtitle-prefetch';
import {
buildSubtitleSidebarSourceKey,
resolveSubtitleSourcePath,
@@ -1412,8 +1420,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
getMpvClient: () => appState.mpvClient,
getLastObservedTimePos: () => lastObservedTimePos,
subtitlePrefetchInitController,
resolveActiveSubtitleSidebarSource: (input) =>
resolveActiveSubtitleSidebarSourceHandler(input),
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
});
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
@@ -1426,7 +1433,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
const subtitlePrefetchRuntime = {
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: (sourcePath: string) => refreshSubtitleSidebarFromSource(sourcePath),
refreshSubtitleSidebarFromSource: (sourcePath: string) =>
refreshSubtitleSidebarFromSource(sourcePath),
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
@@ -1861,10 +1869,11 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
},
})(),
);
const buildGetRuntimeOptionsStateMainDepsHandler =
createBuildGetRuntimeOptionsStateMainDepsHandler({
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
{
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
});
},
);
const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler();
const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler(
getRuntimeOptionsStateMainDeps,
@@ -3042,7 +3051,8 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
},
getImmersionTracker: () => appState.immersionTracker,
ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted(),
ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(),
ensureBackgroundStatsServerStarted: () =>
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
openExternal: (url: string) => shell.openExternal(url),
writeResponse: (responsePath, payload) => {
@@ -3258,8 +3268,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
shouldUseMinimalStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
shouldSkipHeavyStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
createImmersionTracker: () => {
ensureImmersionTrackerStarted();
},

View File

@@ -24,7 +24,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean },
{ getModalWindow: () => null },
{ inputState: boolean; getModalInputExclusive: () => boolean; handleModalInputStateChange: (isActive: boolean) => void },
{
inputState: boolean;
getModalInputExclusive: () => boolean;
handleModalInputStateChange: (isActive: boolean) => void;
},
{ measurementStore: boolean },
{ modalRuntime: boolean },
{ mpvSocketPath: string; texthookerPort: number },
@@ -80,7 +84,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createOverlayManager: () => ({
getModalWindow: () => null,
}),
createOverlayModalInputState: () => ({ inputState: true, getModalInputExclusive: () => false, handleModalInputStateChange: () => {} }),
createOverlayModalInputState: () => ({
inputState: true,
getModalInputExclusive: () => false,
handleModalInputStateChange: () => {},
}),
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {},
@@ -106,8 +114,14 @@ test('createMainBootServices builds boot-phase service bundle', () => {
mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174,
});
assert.equal(services.appLifecycleApp.on('ready', () => {}), services.appLifecycleApp);
assert.equal(services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp);
assert.equal(
services.appLifecycleApp.on('ready', () => {}),
services.appLifecycleApp,
);
assert.equal(
services.appLifecycleApp.on('second-instance', () => {}),
services.appLifecycleApp,
);
assert.deepEqual(appOnCalls, ['ready']);
assert.equal(secondInstanceHandlerRegistered, true);
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);

View File

@@ -56,9 +56,7 @@ export interface MainBootServicesParams<
};
shouldBypassSingleInstanceLock: () => boolean;
requestSingleInstanceLockEarly: () => boolean;
registerSecondInstanceHandlerEarly: (
listener: (_event: unknown, argv: string[]) => void,
) => void;
registerSecondInstanceHandlerEarly: (listener: (_event: unknown, argv: string[]) => void) => void;
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
createConfigService: (configDir: string) => TConfigService;
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
@@ -87,10 +85,7 @@ export interface MainBootServicesParams<
overlayModalInputState: TOverlayModalInputState;
onModalStateChange: (isActive: boolean) => void;
}) => TOverlayModalRuntime;
createAppState: (input: {
mpvSocketPath: string;
texthookerPort: number;
}) => TAppState;
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState;
}
export interface MainBootServicesResult<
@@ -239,9 +234,7 @@ export function createMainBootServices<
const appLifecycleApp = {
requestSingleInstanceLock: () =>
params.shouldBypassSingleInstanceLock()
? true
: params.requestSingleInstanceLockEarly(),
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
quit: () => params.app.quit(),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {

View File

@@ -31,9 +31,9 @@ function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
const extraLength = archive.readUInt16LE(cursor + 28);
const fileNameStart = cursor + 30;
const dataStart = fileNameStart + fileNameLength + extraLength;
const fileName = archive.subarray(fileNameStart, fileNameStart + fileNameLength).toString(
'utf8',
);
const fileName = archive
.subarray(fileNameStart, fileNameStart + fileNameLength)
.toString('utf8');
const data = archive.subarray(dataStart, dataStart + compressedSize);
entries.set(fileName, Buffer.from(data));
cursor = dataStart + compressedSize;
@@ -57,7 +57,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
}) as typeof fs.writeFileSync;
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`);
throw new Error(
`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`,
);
}) as typeof Buffer.concat;
const result = buildDictionaryZip(
@@ -91,8 +93,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
assert.equal(indexJson.revision, '2026-03-27');
assert.equal(indexJson.format, 3);
const termBank = JSON.parse(entries.get('term_bank_1.json')!.toString('utf8')) as
CharacterDictionaryTermEntry[];
const termBank = JSON.parse(
entries.get('term_bank_1.json')!.toString('utf8'),
) as CharacterDictionaryTermEntry[];
assert.equal(termBank.length, 1);
assert.equal(termBank[0]?.[0], 'アルファ');
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));

View File

@@ -138,7 +138,11 @@ function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
return central;
}
function createEndOfCentralDirectory(entriesLength: number, centralSize: number, centralStart: number): Buffer {
function createEndOfCentralDirectory(
entriesLength: number,
centralSize: number,
centralStart: number,
): Buffer {
const end = Buffer.alloc(22);
let cursor = 0;
writeUint32LE(end, 0x06054b50, cursor);

View File

@@ -37,13 +37,13 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
firstScheduled?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
['script-message', 'subminer-autoplay-ready'],
]);
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.ok(
commands.some(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
);
assert.equal(scheduled.length > 0, true);
@@ -84,13 +84,13 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
['script-message', 'subminer-autoplay-ready'],
]);
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.equal(
commands.filter(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length > 0,
true,
);
@@ -130,13 +130,15 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
await new Promise((resolve) => setTimeout(resolve, 0));
playbackPaused = true;
gate.maybeSignalPluginAutoplayReady({ text: '字幕その2', tokens: null }, { forceWhilePaused: true });
gate.maybeSignalPluginAutoplayReady(
{ text: '字幕その2', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
commands.filter(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length,
1,
);

View File

@@ -40,9 +40,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
}
const mediaPath =
deps.getCurrentMediaPath()?.trim() ||
deps.getCurrentVideoPath()?.trim() ||
'__unknown__';
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
@@ -85,7 +83,10 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const mpvClient = deps.getMpvClient();
if (!mpvClient?.connected) {
if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
deps.schedule(
() => attemptRelease(playbackGeneration, attempt + 1),
releaseRetryDelayMs,
);
}
return;
}

View File

@@ -50,6 +50,8 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
assert.equal(handled, false);
// handleAnilistSetupProtocolUrl returns true for subminer:// URLs
const handledProtocol = composed.handleAnilistSetupProtocolUrl('subminer://anilist-setup?code=abc');
const handledProtocol = composed.handleAnilistSetupProtocolUrl(
'subminer://anilist-setup?code=abc',
);
assert.equal(handledProtocol, true);
});

View File

@@ -36,8 +36,13 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({}) as never,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
generateCharacterDictionary: async () =>
({ zipPath: '/tmp/test.zip', fromCache: false, mediaId: 1, mediaTitle: 'Test', entryCount: 1 }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 1,
}),
runJellyfinCommand: async () => {},
runStatsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},

View File

@@ -30,9 +30,7 @@ export type CliStartupComposerResult = ComposerOutputs<{
export function composeCliStartupHandlers(
options: CliStartupComposerOptions,
): CliStartupComposerResult {
const createCliCommandContext = createCliCommandContextFactory(
options.cliCommandContextMainDeps,
);
const createCliCommandContext = createCliCommandContextFactory(options.cliCommandContextMainDeps);
const handleCliCommand = createCliCommandRuntimeHandler({
...options.cliCommandRuntimeHandlerMainDeps,
createCliCommandContext: () => createCliCommandContext(),

View File

@@ -8,28 +8,22 @@ type StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDep
typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>
>;
export type HeadlessStartupComposerOptions<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps,
> = ComposerInputs<{
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps
>;
}>;
export type HeadlessStartupComposerOptions<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
ComposerInputs<{
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps
>;
}>;
export type HeadlessStartupComposerResult<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps,
> = ComposerOutputs<
Pick<
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
>
>;
export type HeadlessStartupComposerResult<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
ComposerOutputs<
Pick<
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
>
>;
export function composeHeadlessStartupHandlers<
TCliArgs,

View File

@@ -4,7 +4,13 @@ import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => {
let lastProgressAt = 0;
let activePlayback: unknown = { itemId: 'item-1', mediaSourceId: 'src-1', playMethod: 'DirectPlay', audioStreamIndex: null, subtitleStreamIndex: null };
let activePlayback: unknown = {
itemId: 'item-1',
mediaSourceId: 'src-1',
playMethod: 'DirectPlay',
audioStreamIndex: null,
subtitleStreamIndex: null,
};
const calls: string[] = [];
const composed = composeJellyfinRemoteHandlers({

View File

@@ -85,7 +85,10 @@ function createDefaultMpvFixture() {
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current: MpvSubtitleRenderMetrics, patch: Partial<MpvSubtitleRenderMetrics>) => ({
applyPatch: (
current: MpvSubtitleRenderMetrics,
patch: Partial<MpvSubtitleRenderMetrics>,
) => ({
next: { ...current, ...patch },
changed: true,
}),

View File

@@ -58,7 +58,8 @@ export function composeOverlayVisibilityRuntime(
options: OverlayVisibilityRuntimeComposerOptions,
): OverlayVisibilityRuntimeComposerResult {
return {
updateVisibleOverlayVisibility: () => options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateVisibleOverlayVisibility: () =>
options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
restorePreviousSecondarySubVisibility: createRestorePreviousSecondarySubVisibilityHandler(
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler(
options.restorePreviousSecondarySubVisibilityMainDeps,

View File

@@ -31,7 +31,10 @@ function requireUser(client: DiscordRpcRawClient): DiscordRpcClientUserLike {
export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcClient {
return {
login: () => client.login(),
setActivity: (activity) => requireUser(client).setActivity(activity).then(() => undefined),
setActivity: (activity) =>
requireUser(client)
.setActivity(activity)
.then(() => undefined),
clearActivity: () => requireUser(client).clearActivity(),
destroy: () => client.destroy(),
};
@@ -39,7 +42,12 @@ export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcCli
export function createDiscordRpcClient(
clientId: string,
deps?: { createClient?: (options: { clientId: string; transport: { type: 'ipc' } }) => DiscordRpcRawClient },
deps?: {
createClient?: (options: {
clientId: string;
transport: { type: 'ipc' };
}) => DiscordRpcRawClient;
},
): DiscordRpcClient {
const client =
deps?.createClient?.({ clientId, transport: { type: 'ipc' } }) ??

View File

@@ -173,7 +173,10 @@ test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(installPaths.pluginConfigPath, 'binary_path=\nsocket_path=/tmp/subminer-socket\n');
fs.writeFileSync(
installPaths.pluginConfigPath,
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = syncInstalledFirstRunPluginBinaryPath({
platform: 'linux',

View File

@@ -187,7 +187,10 @@ export function syncInstalledFirstRunPluginBinaryPath(options: {
return { updated: false, configPath: installPaths.pluginConfigPath };
}
const updated = rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
const updated = rewriteInstalledPluginBinaryPath(
installPaths.pluginConfigPath,
options.binaryPath,
);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}

View File

@@ -139,7 +139,10 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
});
assert.match(html, /External profile configured/);
assert.match(html, /Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./);
assert.match(
html,
/Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./,
);
});
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
@@ -155,7 +158,10 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
});
assert.equal(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'), null);
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null,
);
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
});

View File

@@ -109,7 +109,9 @@ function pickBestTrackId(
if (left.track.external !== right.track.external) {
return left.track.external ? -1 : 1;
}
if (isLikelyHearingImpaired(left.track.title) !== isLikelyHearingImpaired(right.track.title)) {
if (
isLikelyHearingImpaired(left.track.title) !== isLikelyHearingImpaired(right.track.title)
) {
return isLikelyHearingImpaired(left.track.title) ? 1 : -1;
}
if (/\bdefault\b/i.test(left.track.title) !== /\bdefault\b/i.test(right.track.title)) {
@@ -130,7 +132,9 @@ export function resolveManagedLocalSubtitleSelection(input: {
secondaryLanguages: string[];
}): ManagedLocalSubtitleSelection {
const tracks = Array.isArray(input.trackList)
? input.trackList.map(normalizeTrack).filter((track): track is NormalizedSubtitleTrack => track !== null)
? input.trackList
.map(normalizeTrack)
.filter((track): track is NormalizedSubtitleTrack => track !== null)
: [];
const preferredPrimaryLanguages = normalizeLanguageList(
input.primaryLanguages,
@@ -165,12 +169,10 @@ function normalizeLocalMediaPath(mediaPath: string | null | undefined): string |
export function createManagedLocalSubtitleSelectionRuntime(deps: {
getCurrentMediaPath: () => string | null;
getMpvClient: () =>
| {
connected?: boolean;
requestProperty?: (name: string) => Promise<unknown>;
}
| null;
getMpvClient: () => {
connected?: boolean;
requestProperty?: (name: string) => Promise<unknown>;
} | null;
getPrimarySubtitleLanguages: () => string[];
getSecondarySubtitleLanguages: () => string[];
sendMpvCommand: (command: ['set_property', 'sid' | 'secondary-sid', number]) => void;

View File

@@ -38,7 +38,8 @@ export function createPlaylistBrowserIpcRuntime(
return {
playlistBrowserRuntimeDeps,
playlistBrowserMainDeps: {
getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
getPlaylistBrowserSnapshot: () =>
getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
appendPlaylistBrowserFile: (filePath: string) =>
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
playPlaylistBrowserIndex: (index: number) =>

View File

@@ -102,7 +102,8 @@ function createFakeMpvClient(options: {
if (removingCurrent) {
syncFlags();
this.currentVideoPath =
playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath;
playlist.find((item) => item.current || item.playing)?.filename ??
this.currentVideoPath;
}
return true;
}
@@ -276,7 +277,10 @@ test('playlist-browser mutation runtimes mutate queue and return refreshed snaps
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1],
]);
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
assert.deepEqual(
scheduled.map((entry) => entry.delayMs),
[400],
);
scheduled[0]?.callback();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(mpvClient.getCommands().slice(-2), [
@@ -382,10 +386,7 @@ test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', as
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true },
{ filename: episode2 },
],
playlist: [{ filename: episode1, current: true }, { filename: episode2 }],
});
const deps = {
@@ -486,17 +487,14 @@ test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm ca
scheduled[1]?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
mpvClient.getCommands().slice(-6),
[
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1],
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 2],
['set_property', 'sid', 'auto'],
['set_property', 'secondary-sid', 'auto'],
],
);
assert.deepEqual(mpvClient.getCommands().slice(-6), [
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1],
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 2],
['set_property', 'sid', 'auto'],
['set_property', 'secondary-sid', 'auto'],
]);
});
test('playPlaylistBrowserIndexRuntime aborts stale async subtitle rearm work', async (t) => {

View File

@@ -63,7 +63,10 @@ async function resolveCurrentFilePath(
function resolveDirectorySnapshot(
currentFilePath: string | null,
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
): Pick<
PlaylistBrowserSnapshot,
'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'
> {
if (!currentFilePath) {
return {
directoryAvailable: false,

View File

@@ -34,10 +34,7 @@ export function getConfiguredWindowsMpvPathStatus(
return fileExists(configPath) ? 'configured' : 'invalid';
}
export function resolveWindowsMpvPath(
deps: WindowsMpvLaunchDeps,
configuredMpvPath = '',
): string {
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps, configuredMpvPath = ''): string {
const configPath = normalizeCandidate(configuredMpvPath);
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
if (configuredPathStatus === 'configured') {
@@ -178,9 +175,7 @@ export function createWindowsMpvLaunchDeps(options: {
error: result.error ?? undefined,
};
},
fileExists:
options.fileExists ??
defaultWindowsMpvFileExists,
fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
spawnDetached: (command, args) =>
new Promise((resolve, reject) => {
try {

View File

@@ -92,7 +92,9 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
]);
launchedWindowsMpv = launchResult.ok;
if (launchResult.ok && launchResult.mpvPath) {
deps.logInfo(`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`);
deps.logInfo(
`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`,
);
}
if (!launchResult.ok) {
deps.logWarn('Unable to bootstrap Windows mpv for YouTube playback.');

View File

@@ -358,12 +358,10 @@ export function createKeyboardHandlers(
});
}
function isSubtitleSeekCommand(command: (string | number)[] | undefined): command is [string, number] {
return (
Array.isArray(command) &&
command[0] === 'sub-seek' &&
typeof command[1] === 'number'
);
function isSubtitleSeekCommand(
command: (string | number)[] | undefined,
): command is [string, number] {
return Array.isArray(command) && command[0] === 'sub-seek' && typeof command[1] === 'number';
}
function dispatchConfiguredMpvCommand(command: (string | number)[]): void {

View File

@@ -1,7 +1,4 @@
import type {
PlaylistBrowserDirectoryItem,
PlaylistBrowserQueueItem,
} from '../../types';
import type { PlaylistBrowserDirectoryItem, PlaylistBrowserQueueItem } from '../../types';
import type { RendererContext } from '../context';
type PlaylistBrowserRowRenderActions = {
@@ -55,7 +52,7 @@ export function renderPlaylistBrowserDirectoryRow(
? item.episodeLabel
? `${item.episodeLabel} · Current file`
: 'Current file'
: item.episodeLabel ?? 'Video file';
: (item.episodeLabel ?? 'Video file');
main.append(label, meta);
const trailing = document.createElement('div');

View File

@@ -236,9 +236,17 @@ function createPlaylistBrowserElectronApi(overrides?: Partial<ElectronAPI>): Ele
notifyOverlayModalClosed: () => {},
focusMainWindow: async () => {},
setIgnoreMouseEvents: () => {},
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
appendPlaylistBrowserFile: async () => ({
ok: true,
message: 'ok',
snapshot: createSnapshot(),
}),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
removePlaylistBrowserIndex: async () => ({
ok: true,
message: 'ok',
snapshot: createSnapshot(),
}),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
...overrides,
} as ElectronAPI;
@@ -348,15 +356,13 @@ test('playlist browser modal action buttons stop double-click propagation', asyn
await modal.openPlaylistBrowserModal();
const row =
env.dom.playlistBrowserDirectoryList.children[0] as
| ReturnType<typeof createPlaylistRow>
| undefined;
const row = env.dom.playlistBrowserDirectoryList.children[0] as
| ReturnType<typeof createPlaylistRow>
| undefined;
const trailing = row?.children?.[1] as ReturnType<typeof createPlaylistRow> | undefined;
const button =
trailing?.children?.at(-1) as
| { listeners?: Map<string, Array<(event?: unknown) => void>> }
| undefined;
const button = trailing?.children?.at(-1) as
| { listeners?: Map<string, Array<(event?: unknown) => void>> }
| undefined;
const dblclickHandler = button?.listeners?.get('dblclick')?.[0];
assert.equal(typeof dblclickHandler, 'function');

View File

@@ -31,7 +31,8 @@ function getDefaultDirectorySelectionIndex(snapshot: PlaylistBrowserSnapshot): n
function getDefaultPlaylistSelectionIndex(snapshot: PlaylistBrowserSnapshot): number {
const playlistIndex =
snapshot.playingIndex ?? snapshot.playlistItems.findIndex((item) => item.current || item.playing);
snapshot.playingIndex ??
snapshot.playlistItems.findIndex((item) => item.current || item.playing);
return clampIndex(playlistIndex >= 0 ? playlistIndex : 0, snapshot.playlistItems.length);
}
@@ -225,7 +226,10 @@ export function createPlaylistBrowserModal(
}
async function removePlaylistItem(index: number): Promise<void> {
await handleMutation(window.electronAPI.removePlaylistBrowserIndex(index), 'Removed queue item');
await handleMutation(
window.electronAPI.removePlaylistBrowserIndex(index),
'Removed queue item',
);
}
async function movePlaylistItem(index: number, direction: 1 | -1): Promise<void> {

View File

@@ -453,8 +453,7 @@ body {
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(110, 115, 141, 0.16);
background:
linear-gradient(180deg, rgba(54, 58, 79, 0.55), rgba(36, 39, 58, 0.6));
background: linear-gradient(180deg, rgba(54, 58, 79, 0.55), rgba(36, 39, 58, 0.6));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
@@ -496,8 +495,12 @@ body {
}
.playlist-browser-row.current {
background:
linear-gradient(90deg, rgba(138, 173, 244, 0.12), rgba(138, 173, 244, 0.03) 28%, transparent);
background: linear-gradient(
90deg,
rgba(138, 173, 244, 0.12),
rgba(138, 173, 244, 0.03) 28%,
transparent
);
box-shadow: inset 3px 0 0 #8aadf4;
}

View File

@@ -222,8 +222,12 @@ export function resolveRendererDom(): RendererDom {
playlistBrowserModal: getRequiredElement<HTMLDivElement>('playlistBrowserModal'),
playlistBrowserTitle: getRequiredElement<HTMLDivElement>('playlistBrowserTitle'),
playlistBrowserStatus: getRequiredElement<HTMLDivElement>('playlistBrowserStatus'),
playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>('playlistBrowserDirectoryList'),
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>('playlistBrowserPlaylistList'),
playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>(
'playlistBrowserDirectoryList',
),
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>(
'playlistBrowserPlaylistList',
),
playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'),
};
}

View File

@@ -47,13 +47,10 @@ test('RuntimeOptionsManager returns detached effective Anki config copies', () =
},
};
const manager = new RuntimeOptionsManager(
() => structuredClone(baseConfig),
{
applyAnkiPatch: () => undefined,
onOptionsChanged: () => undefined,
},
);
const manager = new RuntimeOptionsManager(() => structuredClone(baseConfig), {
applyAnkiPatch: () => undefined,
onOptionsChanged: () => undefined,
});
const effective = manager.getEffectiveAnkiConnectConfig();
effective.tags!.push('mutated');