Skip to content

Commit 4b846d6

Browse files
authored
Merge pull request #773 from CrazyTim71/anilist
AniList: Add Oauth import for private profiles
2 parents f291c89 + 1a01667 commit 4b846d6

8 files changed

Lines changed: 260 additions & 29 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ src/staticfiles
77
src/db/db.sqlite3
88
db.sqlite3-shm
99
db.sqlite3-wal
10+
.vscode/launch.json

src/config/settings.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,9 @@ def secret(key, default=undefined, **kwargs):
363363

364364
STEAM_API_KEY = config(
365365
"STEAM_API_KEY",
366-
default=secret("STEAM_API_KEY_FILE", ""), # Generate default key https://steamcommunity.com/dev/apikey
366+
default=secret(
367+
"STEAM_API_KEY_FILE", "",
368+
), # Generate default key https://steamcommunity.com/dev/apikey
367369
)
368370

369371
HARDCOVER_API = config(
@@ -405,6 +407,22 @@ def secret(key, default=undefined, **kwargs):
405407
),
406408
)
407409

410+
ANILIST_ID = config(
411+
"ANILIST_ID",
412+
default=secret(
413+
"ANILIST_ID_FILE",
414+
"",
415+
),
416+
)
417+
418+
ANILIST_SECRET = config(
419+
"ANILIST_SECRET",
420+
default=secret(
421+
"ANILIST_SECRET_FILE",
422+
"",
423+
),
424+
)
425+
408426
SIMKL_ID = config(
409427
"SIMKL_ID",
410428
default=secret(

src/integrations/imports/anilist.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,112 @@
44

55
import requests
66
from django.apps import apps
7+
from django.conf import settings
78
from django.utils import timezone
89

910
import app
1011
from app.models import MediaTypes, Sources, Status
12+
from app.providers import services
1113
from integrations.imports import helpers
1214
from integrations.imports.helpers import MediaImportError, MediaImportUnexpectedError
1315

1416
logger = logging.getLogger(__name__)
1517

1618

17-
def importer(username, user, mode):
19+
def get_token(request):
20+
"""View for getting the AniList OAuth2 token."""
21+
domain = request.get_host()
22+
scheme = request.scheme
23+
code = request.GET["code"]
24+
25+
url = "https://anilist.co/api/v2/oauth/token"
26+
27+
params = {
28+
"client_id": settings.ANILIST_ID,
29+
"client_secret": settings.ANILIST_SECRET,
30+
"code": code,
31+
"grant_type": "authorization_code",
32+
"redirect_uri": f"{scheme}://{domain}/import/anilist/private",
33+
}
34+
35+
try:
36+
token_response = app.providers.services.api_request(
37+
"ANILIST",
38+
"POST",
39+
url,
40+
params=params,
41+
)
42+
except services.ProviderAPIError as error:
43+
if error.status_code == requests.codes.unauthorized:
44+
msg = "Invalid Anilist secret key."
45+
raise MediaImportError(msg) from error
46+
raise
47+
48+
return {
49+
"access_token": token_response["access_token"],
50+
"username": get_username_from_oauth(token_response["access_token"]),
51+
}
52+
53+
54+
def get_username_from_oauth(access_token):
55+
"""Get AniList username from access token."""
56+
query = """
57+
query {
58+
Viewer {
59+
name
60+
}
61+
}
62+
"""
63+
64+
headers = {
65+
"Authorization": f"Bearer {access_token}",
66+
"Content-Type": "application/json",
67+
}
68+
69+
try:
70+
response = app.providers.services.api_request(
71+
"ANILIST",
72+
"POST",
73+
"https://graphql.anilist.co",
74+
headers=headers,
75+
params={"query": query},
76+
)
77+
except services.ProviderAPIError as error:
78+
if error.status_code == requests.codes.unauthorized:
79+
msg = "Invalid AniList access token."
80+
raise MediaImportError(msg) from error
81+
raise
82+
83+
return response["data"]["Viewer"]["name"]
84+
85+
86+
def importer(token, user, mode, username):
1887
"""Import anime and manga ratings from Anilist."""
19-
anilist_importer = AniListImporter(username, user, mode)
88+
anilist_importer = AniListImporter(token, user, mode, username)
2089
return anilist_importer.import_data()
2190

2291

2392
class AniListImporter:
2493
"""Class to handle importing user data from AniList."""
2594

26-
def __init__(self, username, user, mode):
95+
def __init__(self, token, user, mode, username):
2796
"""Initialize the importer with username, user, and mode.
2897
2998
Args:
3099
username (str): AniList username to import from
100+
token (str): Encrypted access token for private imports (optional)
31101
user: Django user object to import data for
32102
mode (str): Import mode ("new" or "overwrite")
33103
"""
34104
self.username = username
105+
self.token = token
35106
self.user = user
36107
self.mode = mode
37108
self.warnings = []
38109

110+
if self.token is not None:
111+
self.token = helpers.decrypt(self.token)
112+
39113
# Track existing media for "new" mode
40114
self.existing_media = helpers.get_existing_media(user)
41115

@@ -126,6 +200,14 @@ def import_data(self):
126200
variables = {"userName": self.username}
127201
url = "https://graphql.anilist.co"
128202

203+
headers = {
204+
"Content-Type": "application/json",
205+
"Accept": "application/json",
206+
}
207+
208+
if self.token:
209+
headers["Authorization"] = f"Bearer {self.token}"
210+
129211
logger.info("Fetching anime and manga from AniList account")
130212

131213
try:
@@ -134,6 +216,7 @@ def import_data(self):
134216
"POST",
135217
url,
136218
params={"query": query, "variables": variables},
219+
headers=headers,
137220
)
138221
except requests.exceptions.HTTPError as error:
139222
error_message = error.response.json()["errors"][0].get("message")

src/integrations/tasks.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ def import_mal(username, user_id, mode):
9595

9696

9797
@shared_task(name="Import from AniList")
98-
def import_anilist(username, user_id, mode):
99-
"""Celery task for importing anime and manga data from AniList."""
100-
return import_media(anilist.importer, username, user_id, mode)
98+
def import_anilist(user_id, mode, token=None, username=None):
99+
"""Celery task for importing media data from AniList."""
100+
return import_media(anilist.importer, token, user_id, mode, username)
101101

102102

103103
@shared_task(name="Import from Kitsu")
@@ -117,16 +117,19 @@ def import_hltb(file, user_id, mode):
117117
"""Celery task for importing media data from HowLongToBeat."""
118118
return import_media(hltb.importer, file, user_id, mode)
119119

120+
120121
@shared_task(name="Import from Steam")
121122
def import_steam(username, user_id, mode):
122123
"""Celery task for importing game data from Steam."""
123124
return import_media(steam.importer, username, user_id, mode)
124125

126+
125127
@shared_task(name="Import from IMDB")
126128
def import_imdb(file, user_id, mode):
127129
"""Celery task for importing media data from IMDB."""
128130
return import_media(imdb.importer, file, user_id, mode)
129131

132+
130133
@shared_task(name="Import from GoodReads")
131134
def import_goodreads(file, user_id, mode):
132135
"""Celery task for importing media data from GoodReads."""

src/integrations/tests/test_imports.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,47 @@ def setUp(self):
120120
self.user = get_user_model().objects.create_user(**self.credentials)
121121

122122
@patch("requests.Session.post")
123-
def test_import_anilist(self, mock_request):
123+
def test_import_anilist_public(self, mock_request):
124124
"""Basic test importing anime and manga from AniList."""
125125
with Path(mock_path / "import_anilist.json").open() as file:
126126
anilist_response = json.load(file)
127127
mock_request.return_value.json.return_value = anilist_response
128128

129-
anilist.importer("bloodthirstiness", self.user, "new")
129+
anilist.importer(None, self.user, "new", "bloodthirstiness")
130+
131+
self.assertEqual(Anime.objects.filter(user=self.user).count(), 4)
132+
self.assertEqual(Manga.objects.filter(user=self.user).count(), 3)
133+
self.assertEqual(
134+
Anime.objects.get(user=self.user, item__title="FLCL").status,
135+
Status.PAUSED.value,
136+
)
137+
self.assertEqual(
138+
Manga.objects.filter(user=self.user, item__title="One Punch-Man")
139+
.first()
140+
.score,
141+
9,
142+
)
143+
self.assertEqual(
144+
Anime.objects.get(user=self.user, item__title="FLCL")
145+
.history.first()
146+
.history_date,
147+
datetime(2025, 6, 4, 10, 11, 17, tzinfo=UTC),
148+
)
149+
150+
@patch("requests.Session.post")
151+
def test_import_anilist_private(self, mock_request):
152+
"""Basic test importing anime and manga from AniList."""
153+
with Path(mock_path / "import_anilist.json").open() as file:
154+
anilist_response = json.load(file)
155+
mock_request.return_value.json.return_value = anilist_response
156+
157+
anilist.importer(
158+
helpers.encrypt("token"),
159+
self.user,
160+
"new",
161+
"username",
162+
)
163+
130164
self.assertEqual(Anime.objects.filter(user=self.user).count(), 4)
131165
self.assertEqual(Manga.objects.filter(user=self.user).count(), 3)
132166
self.assertEqual(
@@ -151,9 +185,10 @@ def test_user_not_found(self):
151185
self.assertRaises(
152186
helpers.MediaImportError,
153187
anilist.importer,
154-
"fhdsufdsu",
188+
None,
155189
self.user,
156190
"new",
191+
"fhdsufdsu",
157192
)
158193

159194

src/integrations/urls.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
88
path("simkl-oauth", views.simkl_oauth, name="simkl_oauth"),
99
path("import/simkl", views.import_simkl, name="import_simkl"),
1010
path("import/mal", views.import_mal, name="import_mal"),
11-
path("import/anilist", views.import_anilist, name="import_anilist"),
11+
path("import/anilist/oauth", views.anilist_oauth, name="import_anilist_oauth"),
12+
path("import/anilist/private",
13+
views.import_anilist_private,
14+
name="import_anilist_private",
15+
),
16+
path("import/anilist/public",
17+
views.import_anilist_public,
18+
name="import_anilist_public",
19+
),
1220
path("import/kitsu", views.import_kitsu, name="import_kitsu"),
1321
path("import/yamtrack", views.import_yamtrack, name="import_yamtrack"),
1422
path("import/hltb", views.import_hltb, name="import_hltb"),

src/integrations/views.py

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import users
1919
from integrations import exports, tasks
20-
from integrations.imports import helpers, simkl, trakt
20+
from integrations.imports import anilist, helpers, simkl, trakt
2121
from integrations.webhooks import emby, jellyfin, plex
2222

2323
logger = logging.getLogger(__name__)
@@ -150,7 +150,62 @@ def import_mal(request):
150150

151151

152152
@require_POST
153-
def import_anilist(request):
153+
def anilist_oauth(request):
154+
"""Initiate AniList OAuth flow."""
155+
redirect_uri = request.build_absolute_uri(reverse("import_anilist_private"))
156+
url = "https://anilist.co/api/v2/oauth/authorize"
157+
state = {
158+
"mode": request.POST["mode"],
159+
"frequency": request.POST["frequency"],
160+
"time": request.POST["time"],
161+
}
162+
163+
state_token = secrets.token_urlsafe(32)
164+
request.session[state_token] = state
165+
166+
return redirect(
167+
f"{url}?client_id={settings.ANILIST_ID}&redirect_uri={redirect_uri}&response_type=code&state={state_token}",
168+
)
169+
170+
@require_GET
171+
def import_anilist_private(request):
172+
"""View for getting the AniList OAuth2 token."""
173+
oauth_callback = anilist.get_token(request)
174+
enc_token = helpers.encrypt(oauth_callback["access_token"])
175+
state_token = request.GET["state"]
176+
username = oauth_callback["username"]
177+
178+
if not username:
179+
messages.error(request, "AniList username is required.")
180+
return redirect("import_data")
181+
182+
frequency = request.session[state_token]["frequency"]
183+
mode = request.session[state_token]["mode"]
184+
import_time = request.session[state_token]["time"]
185+
186+
if frequency == "once":
187+
tasks.import_anilist.delay(
188+
user_id=request.user.id,
189+
mode=mode,
190+
username=username,
191+
token=enc_token,
192+
)
193+
messages.info(request, "AniList import queued.")
194+
else:
195+
helpers.create_import_schedule(
196+
username=username,
197+
request=request,
198+
mode=mode,
199+
frequency=frequency,
200+
import_time=import_time,
201+
source="AniList",
202+
token=enc_token,
203+
)
204+
return redirect("import_data")
205+
206+
207+
@require_POST
208+
def import_anilist_public(request):
154209
"""View for importing anime and manga data from AniList."""
155210
username = request.POST.get("user")
156211
if not username:
@@ -159,23 +214,23 @@ def import_anilist(request):
159214

160215
mode = request.POST["mode"]
161216
frequency = request.POST["frequency"]
217+
import_time = request.POST["time"]
162218

163219
if frequency == "once":
164220
tasks.import_anilist.delay(
165-
username=username,
166221
user_id=request.user.id,
167222
mode=mode,
223+
username=username,
168224
)
169-
messages.info(request, "The task to import media from AniList has been queued.")
225+
messages.info(request, "AniList import queued.")
170226
else:
171-
import_time = request.POST["time"]
172227
helpers.create_import_schedule(
173-
username,
174-
request,
175-
mode,
176-
frequency,
177-
import_time,
178-
"AniList",
228+
username=username,
229+
request=request,
230+
mode=mode,
231+
frequency=frequency,
232+
import_time=import_time,
233+
source="AniList",
179234
)
180235
return redirect("import_data")
181236

0 commit comments

Comments
 (0)