refactor: better oauth handling for trakt and simkl

Get username/user ID from service and store in periodic task to show in UI instead of encrypted token
Remove old Trakt importer, make new oauth importer backwards-compatible
Add migration for SIMKL periodic tasks to new username+token format
This commit is contained in:
Bastian Wagner
2025-07-29 16:04:23 +02:00
parent d35b083afa
commit 58035bdff0
9 changed files with 154 additions and 86 deletions

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.2 on 2025-07-29 13:15
from django.db import migrations
from integrations.imports import helpers, simkl
import json
def migrate_simkl_tasks(apps, schema_editor):
PeriodicTask = apps.get_model('django_celery_beat', 'PeriodicTask')
tasks = PeriodicTask.objects.filter(name__startswith='Import from SIMKL')
for task in tasks:
# Update the task name to match the new import function
kwargs = json.loads(task.kwargs)
if hasattr(kwargs, "token"):
continue
kwargs["token"]= kwargs["username"]
kwargs["username"]=simkl.get_username(helpers.decrypt(kwargs["token"]))
task.name = task.name.replace(kwargs["token"], kwargs["username"])
task.kwargs = json.dumps(kwargs)
task.save()
class Migration(migrations.Migration):
dependencies = [
('app', '0050_fix_null_history_users'),
]
operations = [
migrations.RunPython(migrate_simkl_tasks, reverse_code=migrations.RunPython.noop),
]

View File

@@ -190,7 +190,15 @@ def bulk_create_media(bulk_media_list, user):
)
def create_import_schedule(username, request, mode, frequency, import_time, source):
def create_import_schedule(
username,
request,
mode,
frequency,
import_time,
source,
token=None,
):
"""Create an import schedule."""
try:
import_time = (
@@ -218,18 +226,27 @@ def create_import_schedule(username, request, mode, frequency, import_time, sour
day_of_week="*" if frequency == "daily" else "*/2",
timezone=timezone.get_default_timezone(),
)
if token is None:
kwargs = {
"username": username,
"user_id": request.user.id,
"mode": mode,
}
else:
kwargs = {
"username": username,
"user_id": request.user.id,
"mode": mode,
"token": token,
}
# Create new periodic task
PeriodicTask.objects.create(
name=task_name,
task=f"Import from {source}",
crontab=crontab,
kwargs=json.dumps(
{
"username": username,
"user_id": request.user.id,
"mode": mode,
},
),
kwargs=json.dumps(kwargs),
start_time=timezone.now(),
)
messages.success(request, f"{source} import task scheduled.")

View File

@@ -35,7 +35,7 @@ def get_token(request):
}
try:
request = app.providers.services.api_request(
token_response = app.providers.services.api_request(
"SIMKL",
"POST",
url,
@@ -48,7 +48,32 @@ def get_token(request):
raise MediaImportError(msg) from error
raise
return request["access_token"]
return {
"access_token": token_response["access_token"],
"username": get_username(token_response["access_token"]),
}
def get_username(token):
"""Get the username from SIMKL using the provided token."""
try:
user_info = app.providers.services.api_request(
"SIMKL",
"POST",
"https://api.simkl.com/users/settings",
headers={
"Authorization": f"Bearer {token}",
"simkl-api-key": settings.SIMKL_ID,
"Content-Type": "application/json",
},
)
except services.ProviderAPIError as error:
if error.status_code == requests.codes.unauthorized:
msg = "Invalid SIMKL secret key."
raise MediaImportError(msg) from error
raise
return user_info["account"]["id"]
def importer(token, user, mode):

View File

@@ -32,11 +32,11 @@ def handle_oauth_callback(request):
"client_secret": settings.TRAKT_API_SECRET,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": f"{scheme}://{domain}/import/trakt/oauth/callback",
"redirect_uri": f"{scheme}://{domain}/import/trakt",
}
try:
request = app.providers.services.api_request(
token_response = app.providers.services.api_request(
"TRAKT",
"POST",
url,
@@ -50,10 +50,38 @@ def handle_oauth_callback(request):
return {
"state": state,
"refresh_token": request["refresh_token"],
"refresh_token": token_response["refresh_token"],
"username": get_username_from_oauth(token_response["access_token"]),
}
def get_username_from_oauth(access_token):
"""View for getting the Trakt OAuth2 username."""
url = "https://api.trakt.tv/users/me"
headers = {
"Content-Type": "application/json",
"trakt-api-version": "2",
"trakt-api-key": settings.TRAKT_API,
"Authorization": f"Bearer {access_token}",
}
try:
request = app.providers.services.api_request(
"TRAKT",
"GET",
url,
headers=headers,
)
except services.ProviderAPIError as error:
if error.status_code == requests.codes.unauthorized:
msg = "Invalid Trakt secret key."
raise MediaImportError(msg) from error
raise
return request["username"]
def get_access_token(refresh_token):
"""View for getting the Trakt OAuth2 access token."""
url = "https://api.trakt.tv/oauth/token"
@@ -63,7 +91,7 @@ def get_access_token(refresh_token):
"client_secret": settings.TRAKT_API_SECRET,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
"redirect_uri": f"{settings.BASE_URL}/import/trakt/oauth/callback",
"redirect_uri": f"{settings.BASE_URL}/import/trakt",
}
try:
@@ -82,16 +110,12 @@ def get_access_token(refresh_token):
return request["access_token"]
def importer(username, user, mode):
"""Import the user's data from Trakt. DEPRECATED: Use importer_oauth instead."""
trakt_importer = TraktImporter(username, user, mode)
return trakt_importer.import_data()
def importer_oauth(token, user, mode):
def importer(token, user, mode, username=None):
"""Import the user's data from Trakt using OAuth."""
# Use "me" as Trakt username for OAuth authenticated user
trakt_importer = TraktImporter("me", user, mode, refresh_token=token)
if username is None:
username = "me"
trakt_importer = TraktImporter(username, user, mode, refresh_token=token)
return trakt_importer.import_data()

View File

@@ -49,12 +49,24 @@ def format_import_message(imported_counts, warning_messages=None):
return info_message
def import_media(importer_func, identifier, user_id, mode):
def import_media(importer_func, identifier, user_id, mode, oauth_username=None):
"""Handle the import process for different media services."""
user = get_user_model().objects.get(id=user_id)
with disable_fetch_releases():
imported_counts, warnings = importer_func(identifier, user, mode)
if oauth_username is None:
imported_counts, warnings = importer_func(
identifier,
user,
mode,
)
else:
imported_counts, warnings = importer_func(
identifier,
user,
mode,
username=oauth_username,
)
events.tasks.reload_calendar.delay()
@@ -62,23 +74,19 @@ def import_media(importer_func, identifier, user_id, mode):
@shared_task(name="Import from Trakt")
def import_trakt(username, user_id, mode):
"""Celery task for importing media data from Trakt. Deprecated in favor of OAuth."""
return import_media(trakt.importer, username, user_id, mode)
@shared_task(name="Import from Trakt via OAuth")
def import_trakt_oauth(username, user_id, mode):
"""Celery task for importing media data from Trakt using OAuth."""
token_dec = helpers.decrypt(username)
return import_media(trakt.importer_oauth, token_dec, user_id, mode)
def import_trakt(user_id, mode, token=None, username=None):
"""Celery task for importing media data from Trakt."""
token_dec = None
if token is not None:
token_dec = helpers.decrypt(token)
return import_media(trakt.importer, token_dec, user_id, mode, username)
@shared_task(name="Import from SIMKL")
def import_simkl(username, user_id, mode):
def import_simkl(token, user_id, mode, username=None): # noqa: ARG001
"""Celery task for importing media data from SIMKL."""
token = helpers.decrypt(username)
return import_media(simkl.importer, token, user_id, mode)
token_dec = helpers.decrypt(token)
return import_media(simkl.importer, token_dec, user_id, mode)
@shared_task(name="Import from MyAnimeList")

View File

@@ -3,14 +3,8 @@ from django.urls import path
from integrations import views
urlpatterns = [
# Deprecated in favor of OAuth
path("trakt-oauth", views.trakt_oauth, name="trakt_oauth"),
path("import/trakt", views.import_trakt, name="import_trakt"),
path("import/trakt/oauth", views.trakt_oauth, name="trakt_oauth"),
path(
"import/trakt/oauth/callback",
views.import_trakt_oauth,
name="import_trakt_oauth",
),
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"),

View File

@@ -22,37 +22,10 @@ from integrations.webhooks import emby, jellyfin, plex
logger = logging.getLogger(__name__)
@require_POST
def import_trakt(request):
"""View for importing data from Trakt. Deprecated in favor of OAuth."""
username = request.POST.get("user")
if not username:
messages.error(request, "Trakt username is required.")
return redirect("import_data")
mode = request.POST["mode"]
frequency = request.POST["frequency"]
if frequency == "once":
tasks.import_trakt.delay(username=username, user_id=request.user.id, mode=mode)
messages.info(request, "The task to import media from Trakt has been queued.")
else:
import_time = request.POST["time"]
helpers.create_import_schedule(
username,
request,
mode,
frequency,
import_time,
"Trakt",
)
return redirect("import_data")
@require_POST
def trakt_oauth(request):
"""View for initiating Trakt OAuth2 authorization flow."""
redirect_uri = request.build_absolute_uri(reverse("import_trakt_oauth"))
redirect_uri = request.build_absolute_uri(reverse("import_trakt"))
url = "https://trakt.tv/oauth/authorize"
state = {
"trakt_import_mode": request.POST["mode"],
@@ -65,7 +38,7 @@ def trakt_oauth(request):
@require_GET
def import_trakt_oauth(request):
def import_trakt(request):
"""View for getting the Trakt OAuth2 token."""
oauth_callback = trakt.handle_oauth_callback(request)
@@ -75,20 +48,22 @@ def import_trakt_oauth(request):
import_time = oauth_callback["state"]["trakt_import_time"]
if frequency == "once":
tasks.import_trakt_oauth.delay(
tasks.import_trakt.delay(
token=enc_token,
user_id=request.user.id,
mode=mode,
username=oauth_callback["username"],
)
messages.info(request, "The task to import media from Trakt has been queued.")
else:
helpers.create_import_schedule(
enc_token,
oauth_callback["username"],
request,
mode,
frequency,
import_time,
"Trakt via OAuth",
"Trakt",
token=enc_token,
)
return redirect("import_data")
@@ -112,23 +87,24 @@ def simkl_oauth(request):
@require_GET
def import_simkl(request):
"""View for getting the SIMKL OAuth2 token."""
token = simkl.get_token(request)
enc_token = helpers.encrypt(token)
oauth_callback = simkl.get_token(request)
enc_token = helpers.encrypt(oauth_callback["access_token"])
frequency = request.session.pop("simkl_import_frequency")
mode = request.session.pop("simkl_import_mode")
import_time = request.session.pop("simkl_import_time")
if frequency == "once":
tasks.import_simkl.delay(username=enc_token, user_id=request.user.id, mode=mode)
tasks.import_simkl.delay(token=enc_token, user_id=request.user.id, mode=mode)
messages.info(request, "The task to import media from Simkl has been queued.")
else:
helpers.create_import_schedule(
enc_token,
oauth_callback["username"],
request,
mode,
frequency,
import_time,
"SIMKL",
token=enc_token,
)
return redirect("import_data")

View File

@@ -468,8 +468,7 @@ class User(AbstractUser):
def get_import_tasks(self):
"""Return import tasks history and schedules for the user."""
import_tasks = {
"trakt": "Import from Trakt", # Deprecated in favor of OAuth
"trakt_oauth": "Import from Trakt via OAuth",
"trakt": "Import from Trakt",
"simkl": "Import from SIMKL",
"myanimelist": "Import from MyAnimeList",
"anilist": "Import from AniList",

View File

@@ -27,10 +27,6 @@ def source_display(source_name):
"name": "Kitsu",
"logo": "https://kitsu.app/favicon-194x194-2f4dbec5ffe82b8f61a3c6d28a77bc6e.png",
},
"trakt_oauth": {
"name": "Trakt via OAuth",
"logo": "https://trakt.tv/assets/logos/logomark.square.gradient-b644b16c38ff775861b4b1f58c1230f6a097a2466ab33ae00445a505c33fcb91.svg",
},
"trakt": {
"name": "Trakt",
"logo": "https://trakt.tv/assets/logos/logomark.square.gradient-b644b16c38ff775861b4b1f58c1230f6a097a2466ab33ae00445a505c33fcb91.svg",