anilist integrated to my list

This commit is contained in:
2025-12-06 17:18:03 +01:00
parent e5ec8aa7e5
commit 822a9f83cf
13 changed files with 774 additions and 257 deletions

View File

@@ -0,0 +1,214 @@
import { queryOne } from '../../shared/database';
const USER_DB = 'userdata';
export async function getUserAniList(appUserId: number) {
const sql = `
SELECT access_token, anilist_user_id
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await queryOne(sql, [appUserId], USER_DB) as any;
if (!integration) return [];
const { access_token, anilist_user_id } = integration;
const query = `
query ($userId: Int) {
anime: MediaListCollection(userId: $userId, type: ANIME) {
lists {
entries {
mediaId
status
progress
score
}
}
}
manga: MediaListCollection(userId: $userId, type: MANGA) {
lists {
entries {
mediaId
status
progress
score
}
}
}
}
`;
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables: { userId: anilist_user_id }
}),
});
const json = await res.json();
const normalize = (lists: any[], type: 'ANIME' | 'MANGA') => {
const result: any[] = [];
for (const list of lists || []) {
for (const entry of list.entries || []) {
result.push({
user_id: appUserId,
entry_id: entry.mediaId,
source: 'anilist',
entry_type: type,
status: entry.status,
progress: entry.progress || 0,
score: entry.score || null,
});
}
}
return result;
};
return [
...normalize(json?.data?.anime?.lists, 'ANIME'),
...normalize(json?.data?.manga?.lists, 'MANGA')
];
}
export async function updateAniListEntry(token: string, params: {
mediaId: number | string;
status?: string | null;
progress?: number | null;
score?: number | null;
}) {
const mutation = `
mutation ($mediaId: Int, $status: MediaListStatus, $progress: Int, $score: Float) {
SaveMediaListEntry (
mediaId: $mediaId,
status: $status,
progress: $progress,
score: $score
) {
id
status
progress
score
}
}
`;
const variables: any = {
mediaId: Number(params.mediaId),
};
if (params.status != null) variables.status = params.status;
if (params.progress != null) variables.progress = params.progress;
if (params.score != null) variables.score = params.score;
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: mutation, variables }),
});
const json = await res.json();
if (!res.ok || json?.errors?.length) {
throw new Error("AniList update failed");
}
return json.data?.SaveMediaListEntry || null;
}
export async function deleteAniListEntry(token: string, mediaId: number) {
const query = `
query ($mediaId: Int) {
MediaList(mediaId: $mediaId) {
id
}
}
`;
const qRes = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables: { mediaId } }),
});
const qJson = await qRes.json();
const listEntryId = qJson?.data?.MediaList?.id;
if (!listEntryId) {
throw new Error("Entry not found or unauthorized to delete.");
}
const mutation = `
mutation ($id: Int) {
DeleteMediaListEntry(id: $id) {
deleted
}
}
`;
const mRes = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: mutation,
variables: { id: listEntryId }
}),
});
const mJson = await mRes.json();
if (mJson?.errors?.length) {
throw new Error("Error eliminando entrada en AniList");
}
return true;
}
export async function getSingleAniListEntry(
token: string,
mediaId: number,
type: 'ANIME' | 'MANGA'
) {
const query = `
query ($mediaId: Int, $type: MediaType) {
MediaList(mediaId: $mediaId, type: $type) {
status
progress
score
}
}
`;
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables: { mediaId, type }
})
});
const json = await res.json();
return json?.data?.MediaList || null;
}

View File

@@ -0,0 +1,91 @@
import { FastifyInstance } from "fastify";
import { run } from "../../shared/database";
async function anilist(fastify: FastifyInstance) {
fastify.get("/anilist", async (request, reply) => {
try {
const { code, state } = request.query as { code?: string; state?: string };
if (!code) return reply.status(400).send("No code");
if (!state) return reply.status(400).send("No user state");
const userId = Number(state);
if (!userId || isNaN(userId)) {
return reply.status(400).send("Invalid user id");
}
const tokenRes = await fetch("https://anilist.co/api/v2/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: process.env.ANILIST_CLIENT_ID,
client_secret: process.env.ANILIST_CLIENT_SECRET,
redirect_uri: "http://localhost:54322/api/anilist",
code
})
});
const tokenData = await tokenRes.json();
if (!tokenData.access_token) {
console.error("AniList token error:", tokenData);
return reply.status(500).send("Failed to get AniList token");
}
const userRes = await fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${tokenData.token_type} ${tokenData.access_token}`
},
body: JSON.stringify({
query: `query { Viewer { id } }`
})
});
const userData = await userRes.json();
const anilistUserId = userData?.data?.Viewer?.id;
if (!anilistUserId) {
console.error("AniList Viewer error:", userData);
return reply.status(500).send("Failed to fetch AniList user");
}
const expiresAt = new Date(
Date.now() + tokenData.expires_in * 1000
).toISOString();
await run(
`
INSERT INTO UserIntegration
(user_id, platform, access_token, refresh_token, token_type, anilist_user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
access_token = excluded.access_token,
refresh_token = excluded.refresh_token,
token_type = excluded.token_type,
anilist_user_id = excluded.anilist_user_id,
expires_at = excluded.expires_at
`,
[
userId,
"AniList",
tokenData.access_token,
tokenData.refresh_token,
tokenData.token_type,
anilistUserId,
expiresAt
],
"userdata"
);
return reply.redirect("http://localhost:54322/?anilist=success");
} catch (e) {
console.error("AniList error:", e);
return reply.redirect("http://localhost:54322/?anilist=error");
}
});
}
export default anilist;