From 407edc3cec25c232b2acd9a2c59d19b507058e48 Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Sun, 3 Aug 2025 16:33:49 +0200 Subject: [PATCH 01/11] working import --- src/config/settings.py | 27 ++++++--- src/integrations/imports/anilist.py | 85 ++++++++++++++++++++++++++-- src/integrations/tasks.py | 23 +++----- src/integrations/urls.py | 1 + src/integrations/views.py | 40 ++++++++----- src/templates/users/import_data.html | 16 ++---- 6 files changed, 137 insertions(+), 55 deletions(-) diff --git a/src/config/settings.py b/src/config/settings.py index 0affbac6..f9a8194c 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -7,15 +7,8 @@ from pathlib import Path from urllib.parse import urljoin, urlparse from celery.schedules import crontab -from decouple import ( - Config, - Csv, - RepositorySecret, - Undefined, - UndefinedValueError, - config, - undefined, -) +from decouple import (Config, Csv, RepositorySecret, Undefined, + UndefinedValueError, config, undefined) from django.core.cache import CacheKeyWarning BASE_URL = config("BASE_URL", default=None) @@ -405,6 +398,22 @@ TRAKT_API_SECRET = config( ), ) +ANILIST_ID = config( + "ANILIST_ID", + default=secret( + "ANILIST_ID_FILE", + "UNSET", + ) +) + +ANILIST_SECRET = config( + "ANILIST_SECRET", + default=secret( + "ANILIST_SECRET_FILE", + "UNSET", + ) +) + SIMKL_ID = config( "SIMKL_ID", default=secret( diff --git a/src/integrations/imports/anilist.py b/src/integrations/imports/anilist.py index cc0226ad..695f68d0 100644 --- a/src/integrations/imports/anilist.py +++ b/src/integrations/imports/anilist.py @@ -1,29 +1,100 @@ +import json import logging from collections import defaultdict from datetime import UTC import requests from django.apps import apps +from django.conf import settings from django.utils import timezone import app from app.models import MediaTypes, Sources, Status +from app.providers import services from integrations.imports import helpers -from integrations.imports.helpers import MediaImportError, MediaImportUnexpectedError +from integrations.imports.helpers import (MediaImportError, + MediaImportUnexpectedError) logger = logging.getLogger(__name__) +def get_token(request): + """View for getting the AniList OAuth2 token.""" + domain = request.get_host() + scheme = request.scheme + code = request.GET["code"] + state = json.loads(request.GET["state"]) -def importer(username, user, mode): + url = "https://anilist.co/api/v2/oauth/token" + + params = { + "client_id": settings.ANILIST_ID, + "client_secret": settings.ANILIST_SECRET, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": f"{scheme}://{domain}/import/anilist", + } + + try: + token_response = app.providers.services.api_request( + "ANILIST", + "POST", + url, + params=params, + ) + except services.ProviderAPIError as error: + if error.status_code == requests.codes.unauthorized: + msg = "Invalid Anilist secret key." + raise MediaImportError(msg) from error + raise + + return { + "state": state, + "access_token": token_response["access_token"], + "username": get_username_from_oauth(token_response["access_token"]), + } + +def get_username_from_oauth(access_token): + """Get AniList username from access token.""" + + query = """ + query { + Viewer { + name + } + } + """ + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + try: + response = app.providers.services.api_request( + "ANILIST", + "POST", + "https://graphql.anilist.co", + headers=headers, + params={"query": query}, + ) + except services.ProviderAPIError as error: + if error.status_code == requests.codes.unauthorized: + msg = "Invalid AniList access token." + raise MediaImportError(msg) from error + raise + + return response["data"]["Viewer"]["name"] + +def importer(token, user, mode, username): """Import anime and manga ratings from Anilist.""" - anilist_importer = AniListImporter(username, user, mode) + anilist_importer = AniListImporter(token, user, mode, username) return anilist_importer.import_data() class AniListImporter: """Class to handle importing user data from AniList.""" - def __init__(self, username, user, mode): + def __init__(self, token, user, mode, username): """Initialize the importer with username, user, and mode. Args: @@ -32,6 +103,7 @@ class AniListImporter: mode (str): Import mode ("new" or "overwrite") """ self.username = username + self.token = token self.user = user self.mode = mode self.warnings = [] @@ -134,6 +206,11 @@ class AniListImporter: "POST", url, params={"query": query, "variables": variables}, + headers={ + 'Authorization': f"Bearer {self.token}", + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } ) except requests.exceptions.HTTPError as error: error_message = error.response.json()["errors"][0].get("message") diff --git a/src/integrations/tasks.py b/src/integrations/tasks.py index bf329990..5a03dae9 100644 --- a/src/integrations/tasks.py +++ b/src/integrations/tasks.py @@ -7,19 +7,8 @@ import events from app.mixins import disable_fetch_releases from app.models import MediaTypes from app.templatetags import app_tags -from integrations.imports import ( - anilist, - goodreads, - helpers, - hltb, - imdb, - kitsu, - mal, - simkl, - steam, - trakt, - yamtrack, -) +from integrations.imports import (anilist, goodreads, helpers, hltb, imdb, + kitsu, mal, simkl, steam, trakt, yamtrack) logger = logging.getLogger(__name__) ERROR_TITLE = "\n\n\n Couldn't import the following media: \n\n" @@ -99,9 +88,11 @@ def import_mal(username, user_id, mode): @shared_task(name="Import from AniList") -def import_anilist(username, user_id, mode): - """Celery task for importing anime and manga data from AniList.""" - return import_media(anilist.importer, username, user_id, mode) +def import_anilist(user_id, mode, token=None, username=None): + token_dec = None + if token is not None: + token_dec = helpers.decrypt(token) + return import_media(anilist.importer, token_dec, user_id, mode, username) @shared_task(name="Import from Kitsu") diff --git a/src/integrations/urls.py b/src/integrations/urls.py index b6a220c2..116fc8ae 100644 --- a/src/integrations/urls.py +++ b/src/integrations/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path("simkl-oauth", views.simkl_oauth, name="simkl_oauth"), path("import/simkl", views.import_simkl, name="import_simkl"), path("import/mal", views.import_mal, name="import_mal"), + path("anilist-oauth", views.anilist_oauth, name="anilist_oauth"), path("import/anilist", views.import_anilist, name="import_anilist"), path("import/kitsu", views.import_kitsu, name="import_kitsu"), path("import/yamtrack", views.import_yamtrack, name="import_yamtrack"), diff --git a/src/integrations/views.py b/src/integrations/views.py index 592b17d2..12f243ef 100644 --- a/src/integrations/views.py +++ b/src/integrations/views.py @@ -16,7 +16,7 @@ from django.views.decorators.http import require_GET, require_POST import users from integrations import exports, tasks -from integrations.imports import helpers, simkl, trakt +from integrations.imports import anilist, helpers, simkl, trakt from integrations.webhooks import emby, jellyfin, plex logger = logging.getLogger(__name__) @@ -139,38 +139,48 @@ def import_mal(request): ) return redirect("import_data") - @require_POST -def import_anilist(request): - """View for importing anime and manga data from AniList.""" - username = request.POST.get("user") - if not username: - messages.error(request, "AniList username is required.") - return redirect("import_data") +def anilist_oauth(request): + """Initiate AniList OAuth flow.""" + redirect_uri = request.build_absolute_uri(reverse("import_anilist")) + url = "https://anilist.co/api/v2/oauth/authorize" + state = { + "anilist_import_mode": request.POST["mode"], + "anilist_import_frequency": request.POST["frequency"], + "anilist_import_time": request.POST["time"], + } + return redirect( + f"{url}?client_id={settings.ANILIST_ID}&redirect_uri={redirect_uri}&response_type=code&state={json.dumps(state)}" + ) - mode = request.POST["mode"] - frequency = request.POST["frequency"] +@require_GET +def import_anilist(request): + oauth_callback = anilist.get_token(request) + enc_token = helpers.encrypt(oauth_callback["access_token"]) + frequency = oauth_callback["state"]["anilist_import_frequency"] + mode = oauth_callback["state"]["anilist_import_mode"] + import_time = oauth_callback["state"]["anilist_import_time"] if frequency == "once": tasks.import_anilist.delay( - username=username, + token=enc_token, user_id=request.user.id, mode=mode, + username=oauth_callback["username"], ) - messages.info(request, "The task to import media from AniList has been queued.") + messages.info(request, "AniList import queued.") else: - import_time = request.POST["time"] helpers.create_import_schedule( - username, + oauth_callback["username"], request, mode, frequency, import_time, "AniList", + token=enc_token, ) return redirect("import_data") - @require_POST def import_kitsu(request): """View for importing anime and manga data from Kitsu by user ID.""" diff --git a/src/templates/users/import_data.html b/src/templates/users/import_data.html index 2ce4f4cf..f85ecca3 100644 --- a/src/templates/users/import_data.html +++ b/src/templates/users/import_data.html @@ -172,21 +172,15 @@

Import anime and manga

+ action="{% url 'anilist_oauth' %}"> {% csrf_token %} -
- - -
+
From 08ee24f598e5c43aeaef5b7dd40146ef856f8d6b Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:25:52 +0200 Subject: [PATCH 02/11] formatting --- src/config/settings.py | 19 ++++++++++++++----- src/integrations/imports/anilist.py | 22 ++++++++++++---------- src/integrations/tasks.py | 18 ++++++++++++++++-- src/integrations/views.py | 4 ++++ 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/config/settings.py b/src/config/settings.py index f9a8194c..5bd01fa3 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -7,8 +7,15 @@ from pathlib import Path from urllib.parse import urljoin, urlparse from celery.schedules import crontab -from decouple import (Config, Csv, RepositorySecret, Undefined, - UndefinedValueError, config, undefined) +from decouple import ( + Config, + Csv, + RepositorySecret, + Undefined, + UndefinedValueError, + config, + undefined, +) from django.core.cache import CacheKeyWarning BASE_URL = config("BASE_URL", default=None) @@ -356,7 +363,9 @@ IGDB_NSFW = config("IGDB_NSFW", default=False, cast=bool) STEAM_API_KEY = config( "STEAM_API_KEY", - default=secret("STEAM_API_KEY_FILE", ""), # Generate default key https://steamcommunity.com/dev/apikey + default=secret( + "STEAM_API_KEY_FILE", "" + ), # Generate default key https://steamcommunity.com/dev/apikey ) HARDCOVER_API = config( @@ -403,7 +412,7 @@ ANILIST_ID = config( default=secret( "ANILIST_ID_FILE", "UNSET", - ) + ), ) ANILIST_SECRET = config( @@ -411,7 +420,7 @@ ANILIST_SECRET = config( default=secret( "ANILIST_SECRET_FILE", "UNSET", - ) + ), ) SIMKL_ID = config( diff --git a/src/integrations/imports/anilist.py b/src/integrations/imports/anilist.py index 695f68d0..4535ff14 100644 --- a/src/integrations/imports/anilist.py +++ b/src/integrations/imports/anilist.py @@ -12,11 +12,11 @@ import app from app.models import MediaTypes, Sources, Status from app.providers import services from integrations.imports import helpers -from integrations.imports.helpers import (MediaImportError, - MediaImportUnexpectedError) +from integrations.imports.helpers import MediaImportError, MediaImportUnexpectedError logger = logging.getLogger(__name__) + def get_token(request): """View for getting the AniList OAuth2 token.""" domain = request.get_host() @@ -53,9 +53,10 @@ def get_token(request): "username": get_username_from_oauth(token_response["access_token"]), } + def get_username_from_oauth(access_token): """Get AniList username from access token.""" - + query = """ query { Viewer { @@ -63,12 +64,12 @@ def get_username_from_oauth(access_token): } } """ - + headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } - + try: response = app.providers.services.api_request( "ANILIST", @@ -82,9 +83,10 @@ def get_username_from_oauth(access_token): msg = "Invalid AniList access token." raise MediaImportError(msg) from error raise - + return response["data"]["Viewer"]["name"] + def importer(token, user, mode, username): """Import anime and manga ratings from Anilist.""" anilist_importer = AniListImporter(token, user, mode, username) @@ -207,10 +209,10 @@ class AniListImporter: url, params={"query": query, "variables": variables}, headers={ - 'Authorization': f"Bearer {self.token}", - 'Content-Type': 'application/json', - 'Accept': 'application/json', - } + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, ) except requests.exceptions.HTTPError as error: error_message = error.response.json()["errors"][0].get("message") diff --git a/src/integrations/tasks.py b/src/integrations/tasks.py index 5a03dae9..543a0565 100644 --- a/src/integrations/tasks.py +++ b/src/integrations/tasks.py @@ -7,8 +7,19 @@ import events from app.mixins import disable_fetch_releases from app.models import MediaTypes from app.templatetags import app_tags -from integrations.imports import (anilist, goodreads, helpers, hltb, imdb, - kitsu, mal, simkl, steam, trakt, yamtrack) +from integrations.imports import ( + anilist, + goodreads, + helpers, + hltb, + imdb, + kitsu, + mal, + simkl, + steam, + trakt, + yamtrack, +) logger = logging.getLogger(__name__) ERROR_TITLE = "\n\n\n Couldn't import the following media: \n\n" @@ -112,16 +123,19 @@ def import_hltb(file, user_id, mode): """Celery task for importing media data from HowLongToBeat.""" return import_media(hltb.importer, file, user_id, mode) + @shared_task(name="Import from Steam") def import_steam(username, user_id, mode): """Celery task for importing game data from Steam.""" return import_media(steam.importer, username, user_id, mode) + @shared_task(name="Import from IMDB") def import_imdb(file, user_id, mode): """Celery task for importing media data from IMDB.""" return import_media(imdb.importer, file, user_id, mode) + @shared_task(name="Import from GoodReads") def import_goodreads(file, user_id, mode): """Celery task for importing media data from GoodReads.""" diff --git a/src/integrations/views.py b/src/integrations/views.py index 12f243ef..e47aa2ab 100644 --- a/src/integrations/views.py +++ b/src/integrations/views.py @@ -139,6 +139,7 @@ def import_mal(request): ) return redirect("import_data") + @require_POST def anilist_oauth(request): """Initiate AniList OAuth flow.""" @@ -153,6 +154,7 @@ def anilist_oauth(request): f"{url}?client_id={settings.ANILIST_ID}&redirect_uri={redirect_uri}&response_type=code&state={json.dumps(state)}" ) + @require_GET def import_anilist(request): oauth_callback = anilist.get_token(request) @@ -181,6 +183,7 @@ def import_anilist(request): ) return redirect("import_data") + @require_POST def import_kitsu(request): """View for importing anime and manga data from Kitsu by user ID.""" @@ -278,6 +281,7 @@ def import_steam(request): ) return redirect("import_data") + def import_imdb(request): """View for importing data from IMDB.""" file = request.FILES.get("imdb_csv") From 00023794be7f27f3b002280f54d806a39a32b249 Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:35:24 +0200 Subject: [PATCH 03/11] formatting --- src/config/settings.py | 2 +- src/integrations/imports/anilist.py | 1 - src/integrations/tasks.py | 1 + src/integrations/views.py | 3 ++- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/config/settings.py b/src/config/settings.py index 5bd01fa3..17ab39a0 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -364,7 +364,7 @@ IGDB_NSFW = config("IGDB_NSFW", default=False, cast=bool) STEAM_API_KEY = config( "STEAM_API_KEY", default=secret( - "STEAM_API_KEY_FILE", "" + "STEAM_API_KEY_FILE", "", ), # Generate default key https://steamcommunity.com/dev/apikey ) diff --git a/src/integrations/imports/anilist.py b/src/integrations/imports/anilist.py index 4535ff14..e9874a07 100644 --- a/src/integrations/imports/anilist.py +++ b/src/integrations/imports/anilist.py @@ -56,7 +56,6 @@ def get_token(request): def get_username_from_oauth(access_token): """Get AniList username from access token.""" - query = """ query { Viewer { diff --git a/src/integrations/tasks.py b/src/integrations/tasks.py index 543a0565..750b1d14 100644 --- a/src/integrations/tasks.py +++ b/src/integrations/tasks.py @@ -100,6 +100,7 @@ def import_mal(username, user_id, mode): @shared_task(name="Import from AniList") def import_anilist(user_id, mode, token=None, username=None): + """Celery task for importing media data from AniList.""" token_dec = None if token is not None: token_dec = helpers.decrypt(token) diff --git a/src/integrations/views.py b/src/integrations/views.py index e47aa2ab..2e62c208 100644 --- a/src/integrations/views.py +++ b/src/integrations/views.py @@ -151,12 +151,13 @@ def anilist_oauth(request): "anilist_import_time": request.POST["time"], } return redirect( - f"{url}?client_id={settings.ANILIST_ID}&redirect_uri={redirect_uri}&response_type=code&state={json.dumps(state)}" + f"{url}?client_id={settings.ANILIST_ID}&redirect_uri={redirect_uri}&response_type=code&state={json.dumps(state)}", ) @require_GET def import_anilist(request): + """View for getting the AniList OAuth2 token.""" oauth_callback = anilist.get_token(request) enc_token = helpers.encrypt(oauth_callback["access_token"]) frequency = oauth_callback["state"]["anilist_import_frequency"] From 4b55da4ff60df67506e3f77b78294a3ca73e6bbd Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Sun, 3 Aug 2025 21:43:24 +0200 Subject: [PATCH 04/11] fix missing entry in the import history Idk what tf is happening here. It only seem to work when the token is at the end. maybe the filter function in get_import_tasks() is bugging because of the massive length of the token (like 1500 chars). --- src/integrations/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integrations/views.py b/src/integrations/views.py index 2e62c208..1171b760 100644 --- a/src/integrations/views.py +++ b/src/integrations/views.py @@ -166,10 +166,10 @@ def import_anilist(request): if frequency == "once": tasks.import_anilist.delay( - token=enc_token, user_id=request.user.id, mode=mode, username=oauth_callback["username"], + token=enc_token, ) messages.info(request, "AniList import queued.") else: From f2e91c520f945d69e73cd422035718d227767c43 Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Fri, 15 Aug 2025 02:39:03 +0200 Subject: [PATCH 05/11] adapt to recent changes --- src/config/settings.py | 4 ++-- src/integrations/imports/anilist.py | 5 +---- src/integrations/tasks.py | 5 +---- src/integrations/tests/test_imports.py | 9 ++++++++- src/integrations/views.py | 20 +++++++++++++------- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/config/settings.py b/src/config/settings.py index 0b2d7e3a..695a50c5 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -411,7 +411,7 @@ ANILIST_ID = config( "ANILIST_ID", default=secret( "ANILIST_ID_FILE", - "UNSET", + "", ), ) @@ -419,7 +419,7 @@ ANILIST_SECRET = config( "ANILIST_SECRET", default=secret( "ANILIST_SECRET_FILE", - "UNSET", + "", ), ) diff --git a/src/integrations/imports/anilist.py b/src/integrations/imports/anilist.py index e9874a07..c584c5a0 100644 --- a/src/integrations/imports/anilist.py +++ b/src/integrations/imports/anilist.py @@ -1,4 +1,3 @@ -import json import logging from collections import defaultdict from datetime import UTC @@ -22,7 +21,6 @@ def get_token(request): domain = request.get_host() scheme = request.scheme code = request.GET["code"] - state = json.loads(request.GET["state"]) url = "https://anilist.co/api/v2/oauth/token" @@ -48,7 +46,6 @@ def get_token(request): raise return { - "state": state, "access_token": token_response["access_token"], "username": get_username_from_oauth(token_response["access_token"]), } @@ -104,7 +101,7 @@ class AniListImporter: mode (str): Import mode ("new" or "overwrite") """ self.username = username - self.token = token + self.token = helpers.decrypt(token) self.user = user self.mode = mode self.warnings = [] diff --git a/src/integrations/tasks.py b/src/integrations/tasks.py index 3a611b47..ae57c66b 100644 --- a/src/integrations/tasks.py +++ b/src/integrations/tasks.py @@ -97,10 +97,7 @@ def import_mal(username, user_id, mode): @shared_task(name="Import from AniList") def import_anilist(user_id, mode, token=None, username=None): """Celery task for importing media data from AniList.""" - token_dec = None - if token is not None: - token_dec = helpers.decrypt(token) - return import_media(anilist.importer, token_dec, user_id, mode, username) + return import_media(anilist.importer, token, user_id, mode, username) @shared_task(name="Import from Kitsu") diff --git a/src/integrations/tests/test_imports.py b/src/integrations/tests/test_imports.py index e6af2cf0..e576b2b4 100644 --- a/src/integrations/tests/test_imports.py +++ b/src/integrations/tests/test_imports.py @@ -118,6 +118,12 @@ class ImportAniList(TestCase): """Create user for the tests.""" self.credentials = {"username": "test", "password": "12345"} self.user = get_user_model().objects.create_user(**self.credentials) + self.importer = anilist.AniListImporter( + helpers.encrypt("token"), + self.user, + "new", + "username", + ) @patch("requests.Session.post") def test_import_anilist(self, mock_request): @@ -126,7 +132,8 @@ class ImportAniList(TestCase): anilist_response = json.load(file) mock_request.return_value.json.return_value = anilist_response - anilist.importer("bloodthirstiness", self.user, "new") + self.importer.import_data() + self.assertEqual(Anime.objects.filter(user=self.user).count(), 4) self.assertEqual(Manga.objects.filter(user=self.user).count(), 3) self.assertEqual( diff --git a/src/integrations/views.py b/src/integrations/views.py index 77fd5ec1..8897fe3f 100644 --- a/src/integrations/views.py +++ b/src/integrations/views.py @@ -155,12 +155,16 @@ def anilist_oauth(request): redirect_uri = request.build_absolute_uri(reverse("import_anilist")) url = "https://anilist.co/api/v2/oauth/authorize" state = { - "anilist_import_mode": request.POST["mode"], - "anilist_import_frequency": request.POST["frequency"], - "anilist_import_time": request.POST["time"], + "mode": request.POST["mode"], + "frequency": request.POST["frequency"], + "time": request.POST["time"], } + + state_token = secrets.token_urlsafe(32) + request.session[state_token] = state + return redirect( - f"{url}?client_id={settings.ANILIST_ID}&redirect_uri={redirect_uri}&response_type=code&state={json.dumps(state)}", + f"{url}?client_id={settings.ANILIST_ID}&redirect_uri={redirect_uri}&response_type=code&state={state_token}", ) @@ -169,9 +173,11 @@ def import_anilist(request): """View for getting the AniList OAuth2 token.""" oauth_callback = anilist.get_token(request) enc_token = helpers.encrypt(oauth_callback["access_token"]) - frequency = oauth_callback["state"]["anilist_import_frequency"] - mode = oauth_callback["state"]["anilist_import_mode"] - import_time = oauth_callback["state"]["anilist_import_time"] + state_token = request.GET["state"] + + frequency = request.session[state_token]["frequency"] + mode = request.session[state_token]["mode"] + import_time = request.session[state_token]["time"] if frequency == "once": tasks.import_anilist.delay( From 14c7497e80d7613a27d6a9167bbd9caca9b74833 Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Fri, 15 Aug 2025 02:39:13 +0200 Subject: [PATCH 06/11] fix anilist test --- src/integrations/tests/test_imports.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/integrations/tests/test_imports.py b/src/integrations/tests/test_imports.py index e576b2b4..e0b80658 100644 --- a/src/integrations/tests/test_imports.py +++ b/src/integrations/tests/test_imports.py @@ -153,16 +153,6 @@ class ImportAniList(TestCase): datetime(2025, 6, 4, 10, 11, 17, tzinfo=UTC), ) - def test_user_not_found(self): - """Test that an error is raised if the user is not found.""" - self.assertRaises( - helpers.MediaImportError, - anilist.importer, - "fhdsufdsu", - self.user, - "new", - ) - class ImportYamtrack(TestCase): """Test importing media from Yamtrack CSV.""" From 5037369613b421121461e77d3b76150cbc5f498b Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:58:32 +0200 Subject: [PATCH 07/11] readd public import --- src/integrations/imports/anilist.py | 22 +++++++---- src/integrations/urls.py | 5 ++- src/integrations/views.py | 55 +++++++++++++++++++++++----- src/templates/users/import_data.html | 44 +++++++++++++++++++--- 4 files changed, 102 insertions(+), 24 deletions(-) diff --git a/src/integrations/imports/anilist.py b/src/integrations/imports/anilist.py index c584c5a0..608ddec6 100644 --- a/src/integrations/imports/anilist.py +++ b/src/integrations/imports/anilist.py @@ -29,7 +29,7 @@ def get_token(request): "client_secret": settings.ANILIST_SECRET, "code": code, "grant_type": "authorization_code", - "redirect_uri": f"{scheme}://{domain}/import/anilist", + "redirect_uri": f"{scheme}://{domain}/import/anilist/private", } try: @@ -97,15 +97,19 @@ class AniListImporter: Args: username (str): AniList username to import from + token (str): Encrypted access token for private imports (optional) user: Django user object to import data for mode (str): Import mode ("new" or "overwrite") """ self.username = username - self.token = helpers.decrypt(token) + self.token = token self.user = user self.mode = mode self.warnings = [] + if self.token is not None: + self.token = helpers.decrypt(self.token) + # Track existing media for "new" mode self.existing_media = helpers.get_existing_media(user) @@ -196,6 +200,14 @@ class AniListImporter: variables = {"userName": self.username} url = "https://graphql.anilist.co" + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + logger.info("Fetching anime and manga from AniList account") try: @@ -204,11 +216,7 @@ class AniListImporter: "POST", url, params={"query": query, "variables": variables}, - headers={ - "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json", - "Accept": "application/json", - }, + headers=headers, ) except requests.exceptions.HTTPError as error: error_message = error.response.json()["errors"][0].get("message") diff --git a/src/integrations/urls.py b/src/integrations/urls.py index 116fc8ae..24dada26 100644 --- a/src/integrations/urls.py +++ b/src/integrations/urls.py @@ -8,8 +8,9 @@ urlpatterns = [ path("simkl-oauth", views.simkl_oauth, name="simkl_oauth"), path("import/simkl", views.import_simkl, name="import_simkl"), path("import/mal", views.import_mal, name="import_mal"), - path("anilist-oauth", views.anilist_oauth, name="anilist_oauth"), - path("import/anilist", views.import_anilist, name="import_anilist"), + path("import/anilist/oauth", views.anilist_oauth, name="import_anilist_oauth"), + path("import/anilist/private", views.import_anilist_private, name="import_anilist_private"), + path("import/anilist/public", views.import_anilist_public, name="import_anilist_public"), path("import/kitsu", views.import_kitsu, name="import_kitsu"), path("import/yamtrack", views.import_yamtrack, name="import_yamtrack"), path("import/hltb", views.import_hltb, name="import_hltb"), diff --git a/src/integrations/views.py b/src/integrations/views.py index 8897fe3f..b0d51d22 100644 --- a/src/integrations/views.py +++ b/src/integrations/views.py @@ -152,7 +152,7 @@ def import_mal(request): @require_POST def anilist_oauth(request): """Initiate AniList OAuth flow.""" - redirect_uri = request.build_absolute_uri(reverse("import_anilist")) + redirect_uri = request.build_absolute_uri(reverse("import_anilist_private")) url = "https://anilist.co/api/v2/oauth/authorize" state = { "mode": request.POST["mode"], @@ -167,14 +167,18 @@ def anilist_oauth(request): f"{url}?client_id={settings.ANILIST_ID}&redirect_uri={redirect_uri}&response_type=code&state={state_token}", ) - @require_GET -def import_anilist(request): +def import_anilist_private(request): """View for getting the AniList OAuth2 token.""" oauth_callback = anilist.get_token(request) enc_token = helpers.encrypt(oauth_callback["access_token"]) state_token = request.GET["state"] + username = oauth_callback["username"] + if not username: + messages.error(request, "AniList username is required.") + return redirect("import_data") + frequency = request.session[state_token]["frequency"] mode = request.session[state_token]["mode"] import_time = request.session[state_token]["time"] @@ -183,23 +187,54 @@ def import_anilist(request): tasks.import_anilist.delay( user_id=request.user.id, mode=mode, - username=oauth_callback["username"], + username=username, token=enc_token, ) messages.info(request, "AniList import queued.") else: helpers.create_import_schedule( - oauth_callback["username"], - request, - mode, - frequency, - import_time, - "AniList", + username=username, + request=request, + mode=mode, + frequency=frequency, + import_time=import_time, + source="AniList", token=enc_token, ) return redirect("import_data") +@require_POST +def import_anilist_public(request): + """View for importing anime and manga data from AniList.""" + username = request.POST.get("user") + if not username: + messages.error(request, "AniList username is required.") + return redirect("import_data") + + mode = request.POST["mode"] + frequency = request.POST["frequency"] + import_time = request.POST["time"] + + if frequency == "once": + tasks.import_anilist.delay( + user_id=request.user.id, + mode=mode, + username=username, + ) + messages.info(request, "AniList import queued.") + else: + helpers.create_import_schedule( + username=username, + request=request, + mode=mode, + frequency=frequency, + import_time=import_time, + source="AniList", + ) + return redirect("import_data") + + @require_POST def import_kitsu(request): """View for importing anime and manga data from Kitsu by user ID.""" diff --git a/src/templates/users/import_data.html b/src/templates/users/import_data.html index 14239067..0211ff85 100644 --- a/src/templates/users/import_data.html +++ b/src/templates/users/import_data.html @@ -167,23 +167,57 @@ {# AniList #} -
+
{% source_display "anilist" %}

Import anime and manga

+ +
+ +
+
+ x-show="!usePrivateImport" + action="{% url 'import_anilist_public' %}"> {% csrf_token %} + +
+ + +
+
+ +
+ {% csrf_token %} + + + + + Connect with AniList & Import +
- + {# Kitsu #}
{% source_display "kitsu" %}
From 5e59d6e553ab9689bf9a55648842308f8cbab328 Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:01:13 +0200 Subject: [PATCH 08/11] formatting --- src/integrations/imports/anilist.py | 4 ++-- src/integrations/urls.py | 10 ++++++++-- src/integrations/views.py | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/integrations/imports/anilist.py b/src/integrations/imports/anilist.py index 608ddec6..0b09e202 100644 --- a/src/integrations/imports/anilist.py +++ b/src/integrations/imports/anilist.py @@ -109,7 +109,7 @@ class AniListImporter: if self.token is not None: self.token = helpers.decrypt(self.token) - + # Track existing media for "new" mode self.existing_media = helpers.get_existing_media(user) @@ -204,7 +204,7 @@ class AniListImporter: "Content-Type": "application/json", "Accept": "application/json", } - + if self.token: headers["Authorization"] = f"Bearer {self.token}" diff --git a/src/integrations/urls.py b/src/integrations/urls.py index 24dada26..487c8dfe 100644 --- a/src/integrations/urls.py +++ b/src/integrations/urls.py @@ -9,8 +9,14 @@ urlpatterns = [ path("import/simkl", views.import_simkl, name="import_simkl"), path("import/mal", views.import_mal, name="import_mal"), path("import/anilist/oauth", views.anilist_oauth, name="import_anilist_oauth"), - path("import/anilist/private", views.import_anilist_private, name="import_anilist_private"), - path("import/anilist/public", views.import_anilist_public, name="import_anilist_public"), + path("import/anilist/private", + views.import_anilist_private, + name="import_anilist_private", + ), + path("import/anilist/public", + views.import_anilist_public, + name="import_anilist_public", + ), path("import/kitsu", views.import_kitsu, name="import_kitsu"), path("import/yamtrack", views.import_yamtrack, name="import_yamtrack"), path("import/hltb", views.import_hltb, name="import_hltb"), diff --git a/src/integrations/views.py b/src/integrations/views.py index b0d51d22..05246c01 100644 --- a/src/integrations/views.py +++ b/src/integrations/views.py @@ -178,7 +178,7 @@ def import_anilist_private(request): if not username: messages.error(request, "AniList username is required.") return redirect("import_data") - + frequency = request.session[state_token]["frequency"] mode = request.session[state_token]["mode"] import_time = request.session[state_token]["time"] @@ -215,7 +215,7 @@ def import_anilist_public(request): mode = request.POST["mode"] frequency = request.POST["frequency"] import_time = request.POST["time"] - + if frequency == "once": tasks.import_anilist.delay( user_id=request.user.id, From 7c651c5f8e819c00834e870f2f5e0b11e303c51e Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:17:20 +0200 Subject: [PATCH 09/11] fix tests --- src/integrations/tests/test_imports.py | 53 ++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/integrations/tests/test_imports.py b/src/integrations/tests/test_imports.py index e0b80658..ba226e6d 100644 --- a/src/integrations/tests/test_imports.py +++ b/src/integrations/tests/test_imports.py @@ -118,21 +118,15 @@ class ImportAniList(TestCase): """Create user for the tests.""" self.credentials = {"username": "test", "password": "12345"} self.user = get_user_model().objects.create_user(**self.credentials) - self.importer = anilist.AniListImporter( - helpers.encrypt("token"), - self.user, - "new", - "username", - ) @patch("requests.Session.post") - def test_import_anilist(self, mock_request): + def test_import_anilist_public(self, mock_request): """Basic test importing anime and manga from AniList.""" with Path(mock_path / "import_anilist.json").open() as file: anilist_response = json.load(file) mock_request.return_value.json.return_value = anilist_response - self.importer.import_data() + anilist.importer(None, self.user, "new", "bloodthirstiness") self.assertEqual(Anime.objects.filter(user=self.user).count(), 4) self.assertEqual(Manga.objects.filter(user=self.user).count(), 3) @@ -153,6 +147,49 @@ class ImportAniList(TestCase): datetime(2025, 6, 4, 10, 11, 17, tzinfo=UTC), ) + @patch("requests.Session.post") + def test_import_anilist_private(self, mock_request): + """Basic test importing anime and manga from AniList.""" + with Path(mock_path / "import_anilist.json").open() as file: + anilist_response = json.load(file) + mock_request.return_value.json.return_value = anilist_response + + anilist.importer( + helpers.encrypt("token"), + self.user, + "new", + "username", + ) + + self.assertEqual(Anime.objects.filter(user=self.user).count(), 4) + self.assertEqual(Manga.objects.filter(user=self.user).count(), 3) + self.assertEqual( + Anime.objects.get(user=self.user, item__title="FLCL").status, + Status.PAUSED.value, + ) + self.assertEqual( + Manga.objects.filter(user=self.user, item__title="One Punch-Man") + .first() + .score, + 9, + ) + self.assertEqual( + Anime.objects.get(user=self.user, item__title="FLCL") + .history.first() + .history_date, + datetime(2025, 6, 4, 10, 11, 17, tzinfo=UTC), + ) + + def test_user_not_found(self): + """Test that an error is raised if the user is not found.""" + self.assertRaises( + helpers.MediaImportError, + anilist.importer, + "fhdsufdsu", + self.user, + "new", + ) + class ImportYamtrack(TestCase): """Test importing media from Yamtrack CSV.""" From ce0e735c4ef86bf73f1dcd00282e2675f4d7401d Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Tue, 26 Aug 2025 19:10:58 +0200 Subject: [PATCH 10/11] fix test --- src/integrations/tests/test_imports.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/integrations/tests/test_imports.py b/src/integrations/tests/test_imports.py index ba226e6d..4da203c0 100644 --- a/src/integrations/tests/test_imports.py +++ b/src/integrations/tests/test_imports.py @@ -185,9 +185,10 @@ class ImportAniList(TestCase): self.assertRaises( helpers.MediaImportError, anilist.importer, - "fhdsufdsu", + None, self.user, "new", + "fhdsufdsu", ) From 0254f53a3b6c404428a33f193d82cf9d73084283 Mon Sep 17 00:00:00 2001 From: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:34:02 +0200 Subject: [PATCH 11/11] ignore launch.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index aa10b29a..01800a45 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ src/staticfiles src/db/db.sqlite3 db.sqlite3-shm db.sqlite3-wal +.vscode/launch.json