Repository: tournesol-app/tournesol

Issue:
Push Tournesol recommendations into public YouTube playlists
Example: "Tournesol Recommendations FR": https://www.youtube.com/playlist?list=PLXX93NlSmUpZkR9NaBXMaPlPalESb5tDv

This would make Tournesol recommendations more easily accessible from mobile devices

- 2 playlists (FR and EN)
- Synchronize playlist every day to keep
  - videos published between 2 months and 3 days ago
  - with Tournesol Score >= 20





Discussion:

Resolving patch:
diff --git a/backend/scripts/youtube/youtube_channel_oauth_flow.py b/backend/scripts/youtube/youtube_channel_oauth_flow.py
new file mode 100644
--- /dev/null
+++ b/backend/scripts/youtube/youtube_channel_oauth_flow.py
@@ -0,0 +1,85 @@
+"""
+Script to generate credentials to be authenticated on Youtube API
+as the owner of a Youtube channel.
+
+Follow these instructions:
+
+1. Go to https://console.cloud.google.com/apis/credentials
+
+    a. Enable access to Youtube API
+        (only if you never used the Youtube API on this Google Cloud project)
+
+        * In the left menu, go to "Enabled APIs and Services"
+        * Search for "Youtube Data API v3"
+        * "Enable" the Youtube API on your project
+
+    b. Configure OAuth consent screen
+        (only if this is not yet configured on your current Google Cloud project,
+        or if you need to edit its scope to include Youtube access)
+
+        * In the left menu, go to "OAuth consent screen"
+        * Select "External" user type
+        * Configure the minimal required fields: app name (e.g "Tournesol") and email address
+        * On "Scopes", add scope "https://www.googleapis.com/auth/youtube" ("Manage your
+        YouTube account")
+        * No test user is required
+        * "Back to dashboard"
+        * At the top of the page in Publishing status, click on "Publish App"
+        As the app will only be used by your own user, you won't need to submit the app
+        for verification by Google.
+
+    c. Create a new Oauth application and get client secret
+
+        * In the left menu, go to "Credentials"
+        * "Create credentials" -> "OAuth client ID" -> "Web application"
+        * Choose a name for the application, such as "Tournesol jobs on Youtube channel"
+        * In the last section, add `http://localhost:8080/` as a Redirect URI (note the
+        trailing slash)
+        You can also use any port available locally, as configured below in LOCALHOST_PORT.
+        * Download json file that contains your client secret for your new app.
+        Configure its path below as CLIENT_SECRET_FILE_PATH.
+
+2. Run the present script on your machine: `python3 ./youtube_channel_oauth_flow.py`
+    (You may need to install "google-auth-oauthlib" via pip)
+
+    * A browser window should open automatically to log into your Google account,
+      and select the Youtube channel you want to authenticate with.
+    * Once the authentication flow has completed, you may close the browser.
+    * The credentials are visible in the console.
+      The json string can be used as YOUTUBE_CHANNEL_CREDENTIALS_JSON in backend settings.
+"""
+
+import google_auth_oauthlib.flow
+import json
+
+LOCALHOST_PORT = 8080
+CLIENT_SECRET_FILE_PATH = "path/to/your/client_secret.json"  # nosec B105
+
+
+def credentials_to_dict(credentials):
+    return {
+        "token": credentials.token,
+        "refresh_token": credentials.refresh_token,
+        "token_uri": credentials.token_uri,
+        "client_id": credentials.client_id,
+        "client_secret": credentials.client_secret,
+        "scopes": credentials.scopes,
+    }
+
+
+def auth_flow():
+    flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(
+        CLIENT_SECRET_FILE_PATH,
+        scopes=["https://www.googleapis.com/auth/youtube"],
+    )
+    credentials = flow.run_local_server(port=LOCALHOST_PORT)
+    return credentials
+
+
+if __name__ == "__main__":
+    creds = auth_flow()
+    creds_dict = credentials_to_dict(creds)
+    print()
+    print("The authentication flow has completed!")
+    print("Find your JSON credentials below:\n")
+    print(json.dumps(creds_dict))
diff --git a/backend/settings/settings.py b/backend/settings/settings.py
--- a/backend/settings/settings.py
+++ b/backend/settings/settings.py
@@ -339,6 +339,7 @@
 }
 
 YOUTUBE_API_KEY = server_settings.get("YOUTUBE_API_KEY", "")
+YOUTUBE_CHANNEL_CREDENTIALS_JSON = server_settings.get("YOUTUBE_CHANNEL_CREDENTIALS_JSON", "")
 ENABLE_API_WIKIDATA = server_settings.get("ENABLE_API_WIKIDATA", {"MIGRATIONS": False})
 
 DISCORD_CHANNEL_WEBHOOKS = server_settings.get("DISCORD_CHANNEL_WEBHOOKS", {})
diff --git a/backend/tournesol/management/commands/sync_yt_playlists.py b/backend/tournesol/management/commands/sync_yt_playlists.py
new file mode 100644
--- /dev/null
+++ b/backend/tournesol/management/commands/sync_yt_playlists.py
@@ -0,0 +1,97 @@
+import json
+import sys
+
+import google.oauth2.credentials
+import googleapiclient.discovery
+from django.conf import settings
+from django.core.management.base import BaseCommand
+
+from core.utils.time import time_ago
+from tournesol.models import Entity
+from tournesol.models.entity import TYPE_VIDEO
+
+PLAYLISTS = [
+    {"lang": "en", "playlist_id": "PLXX93NlSmUpayYeaNvKaeftX_QwEei3fU"},
+    {"lang": "fr", "playlist_id": "PLXX93NlSmUpZkR9NaBXMaPlPalESb5tDv"},
+]
+
+
+class Command(BaseCommand):
+    help = "Sync Tournesol recommendations with public playlists on Tournesol Youtube channel"
+
+    def get_top_recent_videos(self, language):
+        """
+        Get top recent video that should be present in the YouTube playlist
+        for a certain language.
+        """
+        return Entity.objects.filter(
+            type=TYPE_VIDEO,
+            add_time__lte=time_ago(days=3),
+            metadata__publication_date__gte=time_ago(days=60).isoformat(),
+            metadata__language=language,
+            tournesol_score__gt=20,
+        ).order_by("tournesol_score")
+
+    def get_existing_items(self, yt_client, playlist_id):
+        items_dict = {}
+        items_client = yt_client.playlistItems()
+        request = items_client.list(
+            playlistId=playlist_id,
+            part="contentDetails",
+            maxResults=50,
+        )
+
+        while request is not None:
+            response = request.execute()
+            for item in response["items"]:
+                items_dict[item["contentDetails"]["videoId"]] = item["id"]
+            request = items_client.list_next(request, response)
+
+        return items_dict
+
+    def sync_playlist(self, yt_client, playlist_id, lang):
+        videos_to_publish = {v.video_id: v for v in self.get_top_recent_videos(lang)}
+        existing_items = self.get_existing_items(yt_client, playlist_id)
+
+        video_ids_to_delete = set(existing_items.keys()) - set(videos_to_publish.keys())
+        for video_id in video_ids_to_delete:
+            yt_client.playlistItems().delete(id=existing_items[video_id]).execute()
+
+        video_ids_to_insert = set(videos_to_publish.keys()) - set(existing_items.keys())
+        # Insert in order of increasing tournesol_score
+        # to put videos with higher scores at the top of playlist
+        for video_id in videos_to_publish:
+            if video_id in video_ids_to_insert:
+                yt_client.playlistItems().insert(
+                    part="snippet",
+                    body={
+                        "snippet": {
+                            "playlistId": playlist_id,
+                            "position": 0,
+                            "resourceId": {
+                                "kind": "youtube#video",
+                                "videoId": video_id,
+                            },
+                        }
+                    },
+                ).execute()
+
+    def handle(self, *args, **options):
+        if not settings.YOUTUBE_CHANNEL_CREDENTIALS_JSON:
+            self.stderr.write(
+                "Impossible to sync YouTube playlists. "
+                "`YOUTUBE_CHANNEL_CREDENTIALS_JSON` is not configured."
+            )
+            self.stderr.write(
+                "To get these credentials, follow the instructions "
+                "in scripts/youtube/youtube_channel_oauth_flow.py"
+            )
+            return sys.exit(1)
+
+        channel_credentials = json.loads(settings.YOUTUBE_CHANNEL_CREDENTIALS_JSON)
+        credentials = google.oauth2.credentials.Credentials(**channel_credentials)
+        yt_client = googleapiclient.discovery.build(
+            "youtube", "v3", credentials=credentials
+        )
+        for playlist in PLAYLISTS:
+            self.sync_playlist(yt_client, playlist["playlist_id"], playlist["lang"])


Test patch:
diff --git a/backend/tournesol/tests/test_sync_yt_playlists.py b/backend/tournesol/tests/test_sync_yt_playlists.py
new file mode 100644
--- /dev/null
+++ b/backend/tournesol/tests/test_sync_yt_playlists.py
@@ -0,0 +1,66 @@
+from datetime import date, timedelta
+from unittest import mock
+
+from django.core.management import call_command
+from django.db.models import F
+from django.test import TestCase, override_settings
+
+from tournesol.models import Entity
+
+from .factories.entity import VideoFactory
+
+
+class SyncYtPlaylistsCommandTest(TestCase):
+    @mock.patch("googleapiclient.discovery.build")
+    @override_settings(YOUTUBE_CHANNEL_CREDENTIALS_JSON='{"token": "test-token"}')
+    def test_sync_playlist(self, build_mock):
+        mocked_playlist_client = build_mock.return_value.playlistItems.return_value
+
+        # The playlist items will listed twice: once for each playlist EN and FR
+        mocked_playlist_client.list.return_value.execute.side_effect = [
+            {
+                # 1 existing item in the EN playlist
+                "items": [
+                    {
+                        "id": "existing-video",
+                        "contentDetails": {"videoId": "existing-video"},
+                    }
+                ],
+            },
+            {   
+                # No item in the FR playlist
+                "items": [],
+            },
+        ]
+        mocked_playlist_client.list_next.return_value = None
+
+        # Set up 3 videos that should be inserted into playlist EN
+        video = VideoFactory.create_batch(
+            3,
+            tournesol_score=100,
+            metadata__language="en",
+            metadata__publication_date=date.today().isoformat(),
+        )
+
+        # Set up 1 video that should NOT be inserted into the playlist
+        VideoFactory(
+            tournesol_score=-10,
+            metadata__language="en",
+            metadata__publication_date=date.today().isoformat(),
+        )
+
+        # Update add_time, to make videos old enough
+        Entity.objects.update(add_time=F("add_time") - timedelta(days=10))
+
+        # Run the management command
+        call_command("sync_yt_playlists")
+
+        # 1 plaulistItem should be deleted, 3 videos should be inserted
+        self.assertEqual(
+            mocked_playlist_client.delete.return_value.execute.call_count,
+            1,
+        )
+        self.assertEqual(
+            mocked_playlist_client.insert.return_value.execute.call_count,
+            3,
+        )