From 58035bdff006ada172074bcbe628b0834f65d034 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 29 Jul 2025 16:04:23 +0200 Subject: [PATCH] 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 --- .../0051_migrate_simkl_periodoc_tasks.py | 29 +++++++++++ src/integrations/imports/helpers.py | 33 +++++++++---- src/integrations/imports/simkl.py | 29 ++++++++++- src/integrations/imports/trakt.py | 48 ++++++++++++++----- src/integrations/tasks.py | 38 +++++++++------ src/integrations/urls.py | 8 +--- src/integrations/views.py | 48 +++++-------------- src/users/models.py | 3 +- src/users/templatetags/user_tags.py | 4 -- 9 files changed, 154 insertions(+), 86 deletions(-) create mode 100644 src/app/migrations/0051_migrate_simkl_periodoc_tasks.py diff --git a/src/app/migrations/0051_migrate_simkl_periodoc_tasks.py b/src/app/migrations/0051_migrate_simkl_periodoc_tasks.py new file mode 100644 index 00000000..9773c095 --- /dev/null +++ b/src/app/migrations/0051_migrate_simkl_periodoc_tasks.py @@ -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), + ] \ No newline at end of file diff --git a/src/integrations/imports/helpers.py b/src/integrations/imports/helpers.py index 5809e145..b461bfca 100644 --- a/src/integrations/imports/helpers.py +++ b/src/integrations/imports/helpers.py @@ -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.") diff --git a/src/integrations/imports/simkl.py b/src/integrations/imports/simkl.py index 7373b48d..0f1cbe02 100644 --- a/src/integrations/imports/simkl.py +++ b/src/integrations/imports/simkl.py @@ -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): diff --git a/src/integrations/imports/trakt.py b/src/integrations/imports/trakt.py index 58f61da5..5080b87b 100644 --- a/src/integrations/imports/trakt.py +++ b/src/integrations/imports/trakt.py @@ -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() diff --git a/src/integrations/tasks.py b/src/integrations/tasks.py index e8f1a35e..c566047c 100644 --- a/src/integrations/tasks.py +++ b/src/integrations/tasks.py @@ -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") diff --git a/src/integrations/urls.py b/src/integrations/urls.py index 75bdd772..7cd4bb1e 100644 --- a/src/integrations/urls.py +++ b/src/integrations/urls.py @@ -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"), diff --git a/src/integrations/views.py b/src/integrations/views.py index fea53545..dc5f8d0b 100644 --- a/src/integrations/views.py +++ b/src/integrations/views.py @@ -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") diff --git a/src/users/models.py b/src/users/models.py index 73ea7f71..461bf2cb 100644 --- a/src/users/models.py +++ b/src/users/models.py @@ -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", diff --git a/src/users/templatetags/user_tags.py b/src/users/templatetags/user_tags.py index cddddb3c..74fe42f7 100644 --- a/src/users/templatetags/user_tags.py +++ b/src/users/templatetags/user_tags.py @@ -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",