Fix AniList token persistence and AVIF timing

- Defer AniList setup prompts until app-ready and reuse stored tokens
- Add AVIF lead-in padding so motion stays aligned with sentence audio
This commit is contained in:
2026-03-29 22:07:15 -07:00
parent 54324df3be
commit 55b350c3a2
7 changed files with 268 additions and 5 deletions

View File

@@ -19,6 +19,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
media: {
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
audioPadding: 0,
},
},
noteInfo: {
@@ -49,6 +50,46 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
assert.equal(leadInSeconds, 1.25);
});
test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
audio: 'ExpressionAudio',
},
media: {
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
audioPadding: 0.5,
},
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: {
value: '[sound:word.mp3][sound:alt.ogg]',
},
},
},
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
for (const preferredName of preferredNames) {
if (!preferredName) continue;
const resolved = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
);
if (resolved) return resolved;
}
return null;
},
retrieveMediaFileBase64: async (filename) =>
filename === 'word.mp3' ? 'd29yZA==' : filename === 'alt.ogg' ? 'YWx0' : '',
probeAudioDurationSeconds: async (_buffer, filename) =>
filename === 'word.mp3' ? 0.41 : filename === 'alt.ogg' ? 0.84 : null,
logWarn: () => undefined,
});
assert.equal(leadInSeconds, 1.75);
});
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {

View File

@@ -39,6 +39,14 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
}
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): number {
const configuredPadding = config.media?.audioPadding;
if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) {
return configuredPadding;
}
return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding;
}
export async function probeAudioDurationSeconds(
buffer: Buffer,
filename: string,
@@ -127,5 +135,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
totalLeadInSeconds += durationSeconds;
}
return totalLeadInSeconds;
return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
}

View File

@@ -2591,6 +2591,7 @@ const {
function refreshAnilistClientSecretStateIfEnabled(options?: {
force?: boolean;
allowSetupPrompt?: boolean;
}): Promise<string | null> {
if (!isAnilistTrackingEnabled(getResolvedConfig())) {
return Promise.resolve(null);
@@ -4480,7 +4481,10 @@ const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup;
const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup;
if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartup)) {
if (isAnilistTrackingEnabled(getResolvedConfig())) {
void refreshAnilistClientSecretStateIfEnabled({ force: true }).catch((error) => {
void refreshAnilistClientSecretStateIfEnabled({
force: true,
allowSetupPrompt: false,
}).catch((error) => {
logger.error('Failed to refresh AniList client secret state during startup', error);
});
anilistStateRuntime.refreshRetryQueueState();

View File

@@ -82,7 +82,42 @@ test('refresh handler prefers cached token when not forced', async () => {
assert.equal(loadCalls, 0);
});
test('refresh handler falls back to stored token then opens setup when missing', async () => {
test('refresh handler falls back to stored token without opening setup', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
const states: Array<{ status: string; source: string }> = [];
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: () => {},
loadStoredToken: () => ' stored-token ',
setClientSecretState: (state) => {
states.push({ status: state.status, source: state.source });
},
getAnilistSetupPageOpened: () => opened,
setAnilistSetupPageOpened: (next) => {
opened = next;
},
openAnilistSetupWindow: () => {
openCalls += 1;
},
now: () => 400,
});
const token = await refresh({ force: true });
assert.equal(token, 'stored-token');
assert.equal(cached, 'stored-token');
assert.equal(opened, false);
assert.equal(openCalls, 0);
assert.deepEqual(states, [{ status: 'resolved', source: 'stored' }]);
});
test('refresh handler opens setup when missing token and prompting allowed', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
@@ -111,3 +146,44 @@ test('refresh handler falls back to stored token then opens setup when missing',
assert.equal(cached, null);
assert.equal(openCalls, 1);
});
test('refresh handler skips setup open when missing token and prompting disabled', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
const states: Array<{ status: string; source: string; message: string | null }> = [];
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: () => {},
loadStoredToken: () => '',
setClientSecretState: (state) => {
states.push({ status: state.status, source: state.source, message: state.message });
},
getAnilistSetupPageOpened: () => opened,
setAnilistSetupPageOpened: (next) => {
opened = next;
},
openAnilistSetupWindow: () => {
openCalls += 1;
},
now: () => 500,
});
const token = await refresh({ force: true, allowSetupPrompt: false });
assert.equal(token, null);
assert.equal(cached, null);
assert.equal(opened, false);
assert.equal(openCalls, 0);
assert.deepEqual(states, [
{
status: 'error',
source: 'none',
message: 'cannot authenticate without anilist.accessToken',
},
]);
});

View File

@@ -27,7 +27,10 @@ export function createRefreshAnilistClientSecretStateHandler<
openAnilistSetupWindow: () => void;
now: () => number;
}) {
return async (options?: { force?: boolean }): Promise<string | null> => {
return async (options?: {
force?: boolean;
allowSetupPrompt?: boolean;
}): Promise<string | null> => {
const resolved = deps.getResolvedConfig();
const now = deps.now();
if (!deps.isAnilistTrackingEnabled(resolved)) {
@@ -87,7 +90,11 @@ export function createRefreshAnilistClientSecretStateHandler<
resolvedAt: null,
errorAt: now,
});
if (deps.isAnilistTrackingEnabled(resolved) && !deps.getAnilistSetupPageOpened()) {
if (
options?.allowSetupPrompt !== false &&
deps.isAnilistTrackingEnabled(resolved) &&
!deps.getAnilistSetupPageOpened()
) {
deps.openAnilistSetupWindow();
}
return null;