The AniList media entry to update.
The user's authentication token.
A promise resolving to a SyncResult object.
export async function updateMangaEntry(
entry: AniListMediaEntry,
token: string,
): Promise<SyncResult> {
// Generate an operation ID for tracking in logs early
const operationId = `${entry.mediaId}-${Date.now().toString(36).substring(4, 10)}`;
// Build log prefix with sync type information
const syncType = entry.syncMetadata?.useIncrementalSync
? `INCREMENTAL[step=${entry.syncMetadata.step || 1}/${3}]`
: "STANDARD";
const retryInfo = entry.syncMetadata?.isRetry
? `RETRY[#${entry.syncMetadata.retryCount || 1}]`
: "";
console.log(
`🔄 [${operationId}] ${syncType} ${retryInfo} Starting update for entry ${entry.mediaId} (${entry.title || "Untitled"})`,
);
if (!token) {
console.error(`❌ [${operationId}] No authentication token provided`);
return {
success: false,
mediaId: entry.mediaId,
error: "No authentication token provided",
rateLimited: false,
retryAfter: null,
};
}
try {
// Build variables object with only the variables that should be sent
const variables: Record<string, string | number | boolean> = {
mediaId: entry.mediaId, // Always include mediaId
};
// Only include variables that are actually needed
if (entry.previousValues) {
// For existing entries, only include fields that have changed
if (entry.status !== entry.previousValues.status) {
variables.status = entry.status;
}
if (entry.progress !== entry.previousValues.progress) {
variables.progress = entry.progress;
}
if (entry.score !== entry.previousValues.score) {
variables.score = entry.score || 0;
}
// Only include private flag if it's explicitly set
if (entry.private !== undefined) {
variables.private = entry.private;
}
} else {
// For new entries, include all defined fields
variables.status = entry.status;
if (typeof entry.progress === "number" && entry.progress > 0) {
variables.progress = entry.progress;
}
if (typeof entry.score === "number" && entry.score > 0) {
variables.score = entry.score;
}
if (entry.private !== undefined) {
variables.private = entry.private;
}
}
// Handle incremental sync steps
if (entry.syncMetadata?.step) {
const step = entry.syncMetadata.step;
// Step 1: Only update progress by +1 from previous value
if (step === 1) {
// Reset variables and only include mediaId and progress
Object.keys(variables).forEach((key) => {
if (key !== "mediaId") delete variables[key];
});
// For step 1, we increment by +1 from previous progress
const previousProgress = entry.previousValues?.progress || 0;
variables.progress = previousProgress + 1;
console.log(
`📊 [${operationId}] Incremental sync step 1: Updating progress from ${previousProgress} to ${variables.progress} (incrementing by 1)`,
);
}
// Step 2: Update progress to final value
else if (step === 2) {
// Reset variables and include only mediaId and progress
Object.keys(variables).forEach((key) => {
if (key !== "mediaId") delete variables[key];
});
// Set to final progress value only (no other variables)
variables.progress = entry.progress;
console.log(
`📊 [${operationId}] Incremental sync step 2: Updating progress to final value ${entry.progress}`,
);
}
// Step 3: Update status and score (all remaining variables)
else if (step === 3) {
// Reset variables and include status and score
Object.keys(variables).forEach((key) => {
if (key !== "mediaId") delete variables[key];
});
// Always include status in step 3 if it's changed
if (
entry.previousValues &&
entry.status !== entry.previousValues.status
) {
variables.status = entry.status;
} else if (!entry.previousValues) {
// For new entries
variables.status = entry.status;
}
// Include score if available and changed
if (
entry.previousValues &&
entry.score !== entry.previousValues.score &&
entry.score
) {
variables.score = entry.score;
} else if (!entry.previousValues && entry.score) {
// For new entries
variables.score = entry.score;
}
// Include private flag if set
if (entry.private !== undefined) {
variables.private = entry.private;
}
// Build info string for logging
const changes = [];
if (variables.status) changes.push(`status to ${variables.status}`);
if (variables.score) changes.push(`score to ${variables.score}`);
if (variables.private !== undefined)
changes.push(`private to ${variables.private}`);
const updateInfo =
changes.length > 0 ? changes.join(", ") : "no additional fields";
console.log(
`📊 [${operationId}] Incremental sync step 3: Updating ${updateInfo}`,
);
}
}
// Log the variables being sent
console.log(
`📦 [${operationId}] Variables:`,
JSON.stringify(variables, null, 2),
);
// Generate a dynamic mutation with only the needed variables
const mutation = generateUpdateMangaEntryMutation(variables);
// Define the expected response structure to handle both direct and nested formats
interface SaveMediaListEntryData {
SaveMediaListEntry?: {
id: number;
status: string;
progress: number;
private: boolean;
score: number;
};
data?: {
SaveMediaListEntry?: {
id: number;
status: string;
progress: number;
private: boolean;
score: number;
};
};
}
// Make the API request with optimized variables and mutation
const response = await request<SaveMediaListEntryData>(
mutation,
variables,
token,
);
// Check for GraphQL errors
if (response.errors && response.errors.length > 0) {
const errorMessages = response.errors
.map((err) => err.message)
.join(", ");
console.error(`❌ [${operationId}] GraphQL errors:`, response.errors);
// Check for rate limiting errors
const isRateLimited = response.errors.some(
(err) =>
err.message.toLowerCase().includes("rate limit") ||
err.message.toLowerCase().includes("too many requests"),
);
if (isRateLimited) {
// Extract retry-after info if available
let retryAfter = 60000; // Default to 60 seconds if not specified
// Try to extract a specific retry time from error message or extensions
for (const err of response.errors) {
if (err.extensions?.retryAfter) {
retryAfter = Number(err.extensions.retryAfter) * 1000; // Convert to milliseconds
break;
}
// Try to extract from message using regex
const timeMatch = err.message.match(/(\d+)\s*(?:second|sec|s)/i);
if (timeMatch && timeMatch[1]) {
retryAfter = Number(timeMatch[1]) * 1000;
break;
}
}
console.warn(
`⚠️ [${operationId}] Rate limited! Will retry after ${retryAfter / 1000} seconds`,
);
return {
success: false,
mediaId: entry.mediaId,
error: `Rate limited: ${errorMessages}`,
rateLimited: true,
retryAfter,
};
}
return {
success: false,
mediaId: entry.mediaId,
error: `GraphQL error: ${errorMessages}`,
rateLimited: false,
retryAfter: null,
};
}
// Handle nested response structure - check both standard and nested formats
const responseData = response.data?.data
? response.data.data
: response.data;
// Check if the entry was created/updated successfully
if (responseData?.SaveMediaListEntry?.id) {
console.log(
`✅ [${operationId}] Successfully updated entry with ID ${responseData.SaveMediaListEntry.id}`,
);
return {
success: true,
mediaId: entry.mediaId,
entryId: responseData.SaveMediaListEntry.id,
rateLimited: false,
retryAfter: null,
};
} else {
// Log the full response for debugging
console.error(
`❌ [${operationId}] Missing SaveMediaListEntry in response:`,
JSON.stringify(response, null, 2),
);
return {
success: false,
mediaId: entry.mediaId,
error: "Update failed: No entry ID returned in response",
rateLimited: false,
retryAfter: null,
};
}
} catch (error) {
// Get a detailed error message
const errorMessage =
error instanceof Error
? `${error.name}: ${error.message}`
: String(error);
console.error(
`❌ [${operationId}] Error updating entry ${entry.mediaId}:`,
error,
);
// Try to get more detailed information from the error object
if (error instanceof Error) {
console.error(` [${operationId}] Error type: ${error.name}`);
console.error(` [${operationId}] Error message: ${error.message}`);
console.error(
` [${operationId}] Stack trace:`,
error.stack || "No stack trace available",
);
}
// Handle network errors specifically
if (error instanceof TypeError && error.message.includes("fetch")) {
console.error(
` [${operationId}] Network error detected. Possible connectivity issue.`,
);
}
// Log the entry details that caused the error
console.error(` [${operationId}] Entry details:`, {
mediaId: entry.mediaId,
title: entry.title,
status: entry.status,
progress: entry.progress,
score: entry.score,
});
// Check for server error (500)
let is500Error =
(error instanceof Error &&
(error.message.includes("500") ||
error.message.includes("Internal Server Error"))) ||
(typeof error === "object" &&
error !== null &&
"status" in error &&
(error as { status?: number }).status === 500);
// Check if the error message contains JSON with a 500 status
if (!is500Error && typeof errorMessage === "string") {
try {
// Try to parse error message as JSON
if (
errorMessage.includes('"status": 500') ||
errorMessage.includes('"status":500') ||
errorMessage.includes("Internal Server Error")
) {
is500Error = true;
}
} catch {
// Ignore parsing errors
}
}
if (is500Error) {
console.warn(
`⚠️ [${operationId}] 500 Server Error detected. Will perform automatic retry.`,
);
// Set a short retry delay (3 seconds)
const retryDelay = 3000;
return {
success: false,
mediaId: entry.mediaId,
error: `Server Error (500): ${errorMessage}. Automatic retry scheduled.`,
rateLimited: true, // Use rate limited mechanism for retry
retryAfter: retryDelay,
};
}
return {
success: false,
mediaId: entry.mediaId,
error: errorMessage,
rateLimited: false,
retryAfter: null,
};
}
}
Update a single manga entry in AniList.