Merge pull request #773 from CrazyTim71/anilist

AniList: Add Oauth import for private profiles
This commit is contained in:
Xila Cai
2025-10-20 21:50:33 +02:00
committed by GitHub
8 changed files with 260 additions and 29 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ src/staticfiles
src/db/db.sqlite3
db.sqlite3-shm
db.sqlite3-wal
.vscode/launch.json

View File

@@ -363,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(
@@ -405,6 +407,22 @@ TRAKT_API_SECRET = config(
),
)
ANILIST_ID = config(
"ANILIST_ID",
default=secret(
"ANILIST_ID_FILE",
"",
),
)
ANILIST_SECRET = config(
"ANILIST_SECRET",
default=secret(
"ANILIST_SECRET_FILE",
"",
),
)
SIMKL_ID = config(
"SIMKL_ID",
default=secret(

View File

@@ -4,38 +4,112 @@ 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
logger = logging.getLogger(__name__)
def importer(username, user, mode):
def get_token(request):
"""View for getting the AniList OAuth2 token."""
domain = request.get_host()
scheme = request.scheme
code = request.GET["code"]
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/private",
}
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 {
"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:
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 = 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)
@@ -126,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:
@@ -134,6 +216,7 @@ class AniListImporter:
"POST",
url,
params={"query": query, "variables": variables},
headers=headers,
)
except requests.exceptions.HTTPError as error:
error_message = error.response.json()["errors"][0].get("message")

View File

@@ -95,9 +95,9 @@ 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):
"""Celery task for importing media data from AniList."""
return import_media(anilist.importer, token, user_id, mode, username)
@shared_task(name="Import from Kitsu")
@@ -117,16 +117,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."""

View File

@@ -120,13 +120,47 @@ class ImportAniList(TestCase):
self.user = get_user_model().objects.create_user(**self.credentials)
@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
anilist.importer("bloodthirstiness", self.user, "new")
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)
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),
)
@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(
@@ -151,9 +185,10 @@ class ImportAniList(TestCase):
self.assertRaises(
helpers.MediaImportError,
anilist.importer,
"fhdsufdsu",
None,
self.user,
"new",
"fhdsufdsu",
)

View File

@@ -8,7 +8,15 @@ 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("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"),

View File

@@ -17,7 +17,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__)
@@ -150,7 +150,62 @@ def import_mal(request):
@require_POST
def import_anilist(request):
def anilist_oauth(request):
"""Initiate AniList OAuth flow."""
redirect_uri = request.build_absolute_uri(reverse("import_anilist_private"))
url = "https://anilist.co/api/v2/oauth/authorize"
state = {
"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={state_token}",
)
@require_GET
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"]
if frequency == "once":
tasks.import_anilist.delay(
user_id=request.user.id,
mode=mode,
username=username,
token=enc_token,
)
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",
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:
@@ -159,23 +214,23 @@ def import_anilist(request):
mode = request.POST["mode"]
frequency = request.POST["frequency"]
import_time = request.POST["time"]
if frequency == "once":
tasks.import_anilist.delay(
username=username,
user_id=request.user.id,
mode=mode,
username=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,
request,
mode,
frequency,
import_time,
"AniList",
username=username,
request=request,
mode=mode,
frequency=frequency,
import_time=import_time,
source="AniList",
)
return redirect("import_data")

View File

@@ -167,29 +167,57 @@
</div>
{# AniList #}
<div class="bg-[#39404b] p-4 rounded-lg">
<div class="bg-[#39404b] p-4 rounded-lg" x-data="{ usePrivateImport: false }">
<div class="flex items-center mb-3">{% source_display "anilist" %}</div>
<p class="text-sm text-gray-400 mb-3">Import anime and manga</p>
<div class="mb-3">
<label class="flex items-center text-sm text-gray-300">
<input type="checkbox"
x-model="usePrivateImport"
class="mr-2 rounded bg-[#2a2f35] border-gray-600 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-0">
Import private profile (requires authentication)
</label>
</div>
<form class="flex items-center"
method="post"
action="{% url 'import_anilist' %}">
x-show="!usePrivateImport"
action="{% url 'import_anilist_public' %}">
{% csrf_token %}
<input type="hidden" name="frequency" x-model="importFrequency">
<input type="hidden" name="time" x-model="importTime">
<input type="hidden" name="mode" x-model="importMode">
<div class="flex w-full">
<input placeholder="AniList username"
name="user"
class="relative w-full py-2 px-3 bg-[#2a2f35] rounded-l-md text-white text-sm border border-gray-600 focus:border-indigo-500 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
type="text">
<input placeholder="Public AniList username"
name="user"
class="relative w-full py-2 px-3 bg-[#2a2f35] rounded-l-md text-white text-sm border border-gray-600 focus:border-indigo-500 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
type="text"
required>
<button type="submit"
class="px-3 py-2 bg-indigo-600 text-white rounded-r-md hover:bg-indigo-700 transition-colors text-sm border border-indigo-600 cursor-pointer">
Import
</button>
</div>
</form>
<form class="flex items-center"
method="post"
x-show="usePrivateImport"
action="{% url 'import_anilist_oauth' %}">
{% csrf_token %}
<input type="hidden" name="frequency" x-model="importFrequency">
<input type="hidden" name="time" x-model="importTime">
<input type="hidden" name="mode" x-model="importMode">
<button type="submit"
class="w-full px-4 py-2 text-sm rounded-md transition-colors cursor-pointer bg-indigo-600 text-white hover:bg-indigo-700">
Connect with AniList & Import
</button>
</form>
</div>
{# Kitsu #}
<div class="bg-[#39404b] p-4 rounded-lg">
<div class="flex items-center mb-3">{% source_display "kitsu" %}</div>