• Update a single manga entry in AniList.

    Parameters

    • entry: AniListMediaEntry

      The AniList media entry to update.

    • token: string

      The user's authentication token.

    Returns Promise<SyncResult>

    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,
    };
    }
    }