[codex] Make Windows mpv shortcut self-contained (#40)

This commit is contained in:
2026-04-03 21:35:18 -07:00
committed by GitHub
parent d6c72806bb
commit 7514985feb
131 changed files with 3367 additions and 716 deletions

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

@@ -184,6 +184,39 @@ test('dispatchMpvProtocolMessage sets secondary subtitle track based on track li
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]);
});
test('dispatchMpvProtocolMessage prefers the already selected matching secondary track', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
data: [
{
type: 'sub',
id: 2,
lang: 'ja',
title: 'ja.srt',
selected: false,
external: true,
'external-filename': '/tmp/dupe.srt',
},
{
type: 'sub',
id: 3,
lang: 'ja',
title: 'ja.srt',
selected: true,
external: true,
'external-filename': '/tmp/dupe.srt',
},
],
},
deps,
);
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
});
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
const { deps, state } = createDeps();

View File

@@ -93,6 +93,101 @@ export interface MpvProtocolHandleMessageDeps {
restorePreviousSecondarySubVisibility: () => void;
}
type SubtitleTrackCandidate = {
id: number;
lang: string;
title: string;
selected: boolean;
external: boolean;
externalFilename: string | null;
};
function normalizeSubtitleTrackCandidate(
track: Record<string, unknown>,
): SubtitleTrackCandidate | null {
const id =
typeof track.id === 'number'
? track.id
: typeof track.id === 'string'
? Number(track.id.trim())
: Number.NaN;
if (!Number.isInteger(id)) {
return null;
}
const externalFilename =
typeof track['external-filename'] === 'string' && track['external-filename'].trim().length > 0
? track['external-filename'].trim()
: typeof track.external_filename === 'string' && track.external_filename.trim().length > 0
? track.external_filename.trim()
: null;
return {
id,
lang: String(track.lang || '')
.trim()
.toLowerCase(),
title: String(track.title || '')
.trim()
.toLowerCase(),
selected: track.selected === true,
external: track.external === true,
externalFilename,
};
}
function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
if (track.externalFilename) {
return `external:${track.externalFilename.toLowerCase()}`;
}
if (track.title.length > 0) {
return `title:${track.title}`;
}
return `id:${track.id}`;
}
function pickSecondarySubtitleTrackId(
tracks: Array<Record<string, unknown>>,
preferredLanguages: string[],
): number | null {
const normalizedLanguages = preferredLanguages
.map((language) => language.trim().toLowerCase())
.filter((language) => language.length > 0);
if (normalizedLanguages.length === 0) {
return null;
}
const subtitleTracks = tracks
.filter((track) => track.type === 'sub')
.map(normalizeSubtitleTrackCandidate)
.filter((track): track is SubtitleTrackCandidate => track !== null);
const dedupedTracks = new Map<string, SubtitleTrackCandidate>();
for (const track of subtitleTracks) {
const identity = getSubtitleTrackIdentity(track);
const existing = dedupedTracks.get(identity);
if (!existing || (track.selected && !existing.selected)) {
dedupedTracks.set(identity, track);
}
}
const uniqueTracks = [...dedupedTracks.values()];
for (const language of normalizedLanguages) {
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
if (selectedMatch) {
return selectedMatch.id;
}
const match = uniqueTracks.find((track) => track.lang === language);
if (match) {
return match.id;
}
}
return null;
}
export function splitMpvMessagesFromBuffer(
buffer: string,
onMessage?: MpvMessageParser,
@@ -283,15 +378,11 @@ export async function dispatchMpvProtocolMessage(
if (Array.isArray(tracks)) {
const config = deps.getResolvedConfig();
const languages = config.secondarySub?.secondarySubLanguages || [];
const subTracks = tracks.filter((track) => track.type === 'sub');
for (const language of languages) {
const match = subTracks.find((track) => track.lang === language);
if (match) {
deps.sendCommand({
command: ['set_property', 'secondary-sid', match.id],
});
break;
}
const secondaryTrackId = pickSecondarySubtitleTrackId(tracks, languages);
if (secondaryTrackId !== null) {
deps.sendCommand({
command: ['set_property', 'secondary-sid', secondaryTrackId],
});
}
}
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {

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

@@ -92,6 +92,52 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
assert.equal(inProgressState, false);
});
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
let payloadTrackCount = 0;
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/video.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 2;
if (name === 'track-list') {
return [
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
{
id: 2,
type: 'sub',
selected: true,
external: true,
lang: 'eng',
'external-filename': '/tmp/ref.srt',
},
{
id: 3,
type: 'sub',
selected: false,
external: true,
lang: 'eng',
'external-filename': '/tmp/ref.srt',
},
];
}
return null;
},
}),
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
}),
);
assert.equal(payloadTrackCount, 1);
});
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
const osd: string[] = [];
await triggerSubsyncFromConfig(

View File

@@ -76,6 +76,35 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
});
}
function getSourceTrackIdentity(track: MpvTrack): string {
if (
track.external &&
typeof track['external-filename'] === 'string' &&
track['external-filename'].length > 0
) {
return `external:${track['external-filename'].toLowerCase()}`;
}
if (typeof track.id === 'number') {
return `id:${track.id}`;
}
if (typeof track.title === 'string' && track.title.length > 0) {
return `title:${track.title.toLowerCase()}`;
}
return 'unknown';
}
function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] {
const deduped = new Map<string, MpvTrack>();
for (const track of tracks) {
const identity = getSourceTrackIdentity(track);
const existing = deduped.get(identity);
if (!existing || (track.selected && !existing.selected)) {
deduped.set(identity, track);
}
}
return [...deduped.values()];
}
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
isSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void;
@@ -123,12 +152,13 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
const filename = track['external-filename'];
return typeof filename === 'string' && filename.length > 0;
});
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
return {
videoPath,
primaryTrack,
secondaryTrack,
sourceTracks,
sourceTracks: uniqueSourceTracks,
audioStreamIndex: client.currentAudioStreamIndex,
};
}

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

@@ -68,6 +68,15 @@ export function resolveExternalYomitanExtensionPath(
return null;
}
const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null;
const candidate = path.join(normalizedProfilePath, 'extensions', 'yomitan');
const fallbackCandidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
const candidates = candidate === fallbackCandidate ? [candidate] : [candidate, fallbackCandidate];
for (const root of candidates) {
if (existsSync(path.join(root, 'manifest.json'))) {
return root;
}
}
return null;
}

View File

@@ -16,8 +16,15 @@ 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 = `#!/usr/bin/env node
const script =
process.platform === 'win32'
? `#!/usr/bin/env bun
process.stdout.write(${JSON.stringify(payload)});
`
: `#!/usr/bin/env sh
cat <<'EOF' | base64 -d
${Buffer.from(payload).toString('base64')}
EOF
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {
@@ -28,8 +35,15 @@ process.stdout.write(${JSON.stringify(payload)});
function makeHangingFakeYtDlpScript(dir: string): void {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/usr/bin/env node
const script =
process.platform === 'win32'
? `#!/usr/bin/env bun
setInterval(() => {}, 1000);
`
: `#!/usr/bin/env sh
while :; do
sleep 1;
done
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {
@@ -44,11 +58,19 @@ async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir, payload);
const originalPath = process.env.PATH ?? '';
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
process.env.SUBMINER_YTDLP_BIN =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
try {
return await fn();
} finally {
process.env.PATH = originalPath;
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}
@@ -59,11 +81,19 @@ async function withHangingFakeYtDlp<T>(fn: () => Promise<T>): Promise<T> {
fs.mkdirSync(binDir, { recursive: true });
makeHangingFakeYtDlpScript(binDir);
const originalPath = process.env.PATH ?? '';
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
process.env.SUBMINER_YTDLP_BIN =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
try {
return await fn();
} finally {
process.env.PATH = originalPath;
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}

View File

@@ -1,5 +1,6 @@
import { spawn } from 'node:child_process';
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
import { getYoutubeYtDlpCommand } from './ytdlp-command';
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
@@ -87,7 +88,7 @@ function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string
export async function probeYoutubeVideoMetadata(
targetUrl: string,
): Promise<YoutubeVideoMetadata | null> {
const { stdout } = await runCapture('yt-dlp', [
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
'--dump-single-json',
'--no-warnings',
'--skip-download',

View File

@@ -16,8 +16,15 @@ 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 = `#!/usr/bin/env node
const script =
process.platform === 'win32'
? `#!/usr/bin/env bun
process.stdout.write(${JSON.stringify(payload)});
`
: `#!/usr/bin/env sh
cat <<'EOF' | base64 -d
${Buffer.from(payload).toString('base64')}
EOF
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {

View File

@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import { getYoutubeYtDlpCommand } from './ytdlp-command';
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
const DEFAULT_PLAYBACK_FORMAT = 'b';
@@ -88,8 +89,7 @@ export async function resolveYoutubePlaybackUrl(
targetUrl: string,
format = DEFAULT_PLAYBACK_FORMAT,
): Promise<string> {
const ytDlpCommand = process.env.SUBMINER_YTDLP_BIN?.trim() || 'yt-dlp';
const { stdout } = await runCapture(ytDlpCommand, [
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
'--get-url',
'--no-warnings',
'-f',

View File

@@ -16,7 +16,7 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
function makeFakeYtDlpScript(dir: string): string {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/usr/bin/env node
const script = `#!/usr/bin/env bun
const fs = require('node:fs');
const path = require('node:path');
@@ -87,6 +87,87 @@ if (process.env.YTDLP_FAKE_MODE === 'multi') {
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
}
process.exit(0);
`;
fs.writeFileSync(scriptPath, script, 'utf8');
fs.chmodSync(scriptPath, 0o755);
if (process.platform === 'win32') {
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nbun "${scriptPath}" %*\r\n`, 'utf8');
}
return scriptPath;
}
function makeFakeYtDlpShellScript(dir: string): string {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/bin/sh
has_auto_subs=0
wants_auto_subs=0
wants_manual_subs=0
sub_lang=''
output_template=''
while [ "$#" -gt 0 ]; do
case "$1" in
--write-auto-subs)
wants_auto_subs=1
;;
--write-subs)
wants_manual_subs=1
;;
--sub-langs)
sub_lang="$2"
shift
;;
-o)
output_template="$2"
shift
;;
esac
shift
done
if [ "$YTDLP_EXPECT_AUTO_SUBS" = "1" ] && [ "$wants_auto_subs" != "1" ]; then
exit 2
fi
if [ "$YTDLP_EXPECT_MANUAL_SUBS" = "1" ] && [ "$wants_manual_subs" != "1" ]; then
exit 3
fi
if [ -n "$YTDLP_EXPECT_SUB_LANG" ] && [ "$sub_lang" != "$YTDLP_EXPECT_SUB_LANG" ]; then
exit 4
fi
prefix="\${output_template%.%(ext)s}"
if [ -z "$prefix" ]; then
exit 1
fi
dir="\${prefix%/*}"
[ -d "$dir" ] || /bin/mkdir -p "$dir"
if [ "$YTDLP_FAKE_MODE" = "multi" ]; then
OLD_IFS="$IFS"
IFS=","
for lang in $sub_lang; do
if [ -n "$lang" ]; then
printf 'WEBVTT\\n' > "\${prefix}.\${lang}.vtt"
fi
done
IFS="$OLD_IFS"
elif [ "$YTDLP_FAKE_MODE" = "rolling-auto" ]; then
printf 'WEBVTT\\n\\n00:00:01.000 --> 00:00:02.000\\n今日は\\n\\n00:00:02.000 --> 00:00:03.000\\n今日はいい天気ですね\\n\\n00:00:03.000 --> 00:00:04.000\\n今日はいい天気ですね本当に\\n' > "\${prefix}.vtt"
elif [ "$YTDLP_FAKE_MODE" = "multi-primary-only-fail" ]; then
primary_lang="\${sub_lang%%,*}"
if [ -n "$primary_lang" ]; then
printf 'WEBVTT\\n' > "\${prefix}.\${primary_lang}.vtt"
fi
printf "ERROR: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\\n" 1>&2
exit 1
elif [ "$YTDLP_FAKE_MODE" = "both" ]; then
printf 'WEBVTT\\n' > "\${prefix}.vtt"
printf 'webp' > "\${prefix}.orig.webp"
elif [ "$YTDLP_FAKE_MODE" = "webp-only" ]; then
printf 'webp' > "\${prefix}.orig.webp"
else
printf 'WEBVTT\\n' > "\${prefix}.vtt"
fi
`;
fs.writeFileSync(scriptPath, script, 'utf8');
fs.chmodSync(scriptPath, 0o755);
@@ -100,7 +181,11 @@ async function withFakeYtDlp<T>(
return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir);
if (process.platform === 'win32') {
makeFakeYtDlpScript(binDir);
} else {
makeFakeYtDlpShellScript(binDir);
}
const originalPath = process.env.PATH ?? '';
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
@@ -114,6 +199,43 @@ async function withFakeYtDlp<T>(
});
}
async function withFakeYtDlpCommand<T>(
mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto',
fn: (dir: string, binDir: string) => Promise<T>,
): Promise<T> {
return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir, { recursive: true });
const originalPath = process.env.PATH;
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.PATH = '';
process.env.YTDLP_FAKE_MODE = mode;
process.env.SUBMINER_YTDLP_BIN =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
if (process.platform === 'win32') {
makeFakeYtDlpScript(binDir);
} else {
makeFakeYtDlpShellScript(binDir);
}
try {
return await fn(root, binDir);
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
delete process.env.YTDLP_FAKE_MODE;
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}
async function withFakeYtDlpExpectations<T>(
expectations: Partial<
Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>
@@ -179,6 +301,29 @@ test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifa
});
});
test('downloadYoutubeSubtitleTrack honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
if (process.platform === 'win32') {
return;
}
await withFakeYtDlpCommand('both', async (root) => {
const result = await downloadYoutubeSubtitleTrack({
targetUrl: 'https://www.youtube.com/watch?v=abc123',
outputDir: path.join(root, 'out'),
track: {
id: 'auto:ja-orig',
language: 'ja',
sourceLanguage: 'ja-orig',
kind: 'auto',
label: 'Japanese (auto)',
},
});
assert.equal(path.extname(result.path), '.vtt');
assert.match(path.basename(result.path), /^auto-ja-orig\./);
});
});
test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => {
if (process.platform === 'win32') {
return;

View File

@@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import type { YoutubeTrackOption } from './track-probe';
import { getYoutubeYtDlpCommand } from './ytdlp-command';
import {
convertYoutubeTimedTextToVtt,
isYoutubeTimedTextExtension,
@@ -237,7 +238,7 @@ export async function downloadYoutubeSubtitleTrack(input: {
}),
];
await runCapture('yt-dlp', args);
await runCapture(getYoutubeYtDlpCommand(), args);
const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix);
if (!subtitlePath) {
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
@@ -281,7 +282,7 @@ export async function downloadYoutubeSubtitleTracks(input: {
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
const result = await runCaptureDetailed(
'yt-dlp',
getYoutubeYtDlpCommand(),
buildDownloadArgs({
targetUrl: input.targetUrl,
outputTemplate,

View File

@@ -17,10 +17,18 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
function makeFakeYtDlpScript(dir: string, payload: unknown, rawScript = false): void {
const scriptPath = path.join(dir, 'yt-dlp');
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
const script = rawScript
? stdoutBody
: `#!/usr/bin/env node
const script =
process.platform === 'win32'
? rawScript
? stdoutBody
: `#!/usr/bin/env bun
process.stdout.write(${JSON.stringify(stdoutBody)});
`
: `#!/bin/sh
PATH=/usr/bin:/bin:/usr/local/bin
cat <<'SUBMINER_EOF' | base64 -d
${Buffer.from(stdoutBody).toString('base64')}
SUBMINER_EOF
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {
@@ -39,11 +47,50 @@ async function withFakeYtDlp<T>(
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
const originalPath = process.env.PATH ?? '';
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
process.env.SUBMINER_YTDLP_BIN =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
try {
return await fn();
} finally {
process.env.PATH = originalPath;
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}
async function withFakeYtDlpCommand<T>(
payload: unknown,
fn: () => Promise<T>,
options: { rawScript?: boolean } = {},
): Promise<T> {
return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
const originalPath = process.env.PATH;
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.PATH = '';
process.env.SUBMINER_YTDLP_BIN =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
try {
return await fn();
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}
@@ -69,6 +116,28 @@ test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async ()
);
});
test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
if (process.platform === 'win32') {
return;
}
await withFakeYtDlpCommand(
{
id: 'abc123',
title: 'Example',
subtitles: {
ja: [{ ext: 'vtt', url: 'https://example.com/ja.vtt', name: 'Japanese manual' }],
},
},
async () => {
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
assert.equal(result.videoId, 'abc123');
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.vtt');
assert.equal(result.tracks[0]?.fileExtension, 'vtt');
},
);
});
test('probeYoutubeTracks keeps preferring srt for manual captions', async () => {
await withFakeYtDlp(
{

View File

@@ -1,6 +1,7 @@
import { spawn } from 'node:child_process';
import type { YoutubeTrackOption } from '../../../types';
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
import { getYoutubeYtDlpCommand } from './ytdlp-command';
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
@@ -111,7 +112,11 @@ function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind:
export type { YoutubeTrackOption };
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]);
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
'--dump-single-json',
'--no-warnings',
targetUrl,
]);
const trimmedStdout = stdout.trim();
if (!trimmedStdout) {
throw new Error('yt-dlp returned empty output while probing subtitle tracks');

View File

@@ -0,0 +1,44 @@
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_YTDLP_COMMAND = 'yt-dlp';
const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp'];
function resolveFromPath(commandName: string): string | null {
if (!process.env.PATH) {
return null;
}
const searchPaths = process.env.PATH.split(path.delimiter);
for (const searchPath of searchPaths) {
const candidate = path.join(searchPath, commandName);
try {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch {
continue;
}
}
return null;
}
export function getYoutubeYtDlpCommand(): string {
const explicitCommand = process.env.SUBMINER_YTDLP_BIN?.trim();
if (explicitCommand) {
return explicitCommand;
}
if (process.platform !== 'win32') {
return DEFAULT_YTDLP_COMMAND;
}
for (const commandName of WINDOWS_YTDLP_COMMANDS) {
const resolved = resolveFromPath(commandName);
if (resolved) {
return resolved;
}
}
return DEFAULT_YTDLP_COMMAND;
}