diff --git a/tubearchivist/api/README.md b/tubearchivist/api/README.md new file mode 100644 index 0000000..73dd4fc --- /dev/null +++ b/tubearchivist/api/README.md @@ -0,0 +1,59 @@ +# TubeArchivist API +Documentation of available API endpoints. +**Note: This is very early alpha and will change!** + +## Authentication +API token will get automatically created, accessible on the settings page. Token needs to be passed as an authorization header with every request. Additionally session based authentication is enabled too: When you are logged into your TubeArchivist instance, you'll have access to the api in the browser for testing. + +Curl example: +```shell +curl -v /api/video// \ + -H "Authorization: Token xxxxxxxxxx" +``` + +Python requests example: +```python +import requests + +url = "/api/video//" +headers = {"Authorization": "Token xxxxxxxxxx"} +response = requests.get(url, headers=headers) +``` + +## Video Item View +/api/video/\/ + +## Channel List View +/api/channel/ + +### Subscribe to a list of channels +POST /api/channel/ +```json +{ + "data": [ + {"channel_id": "UC9-y-6csu5WGm29I7JiwpnA", "channel_subscribed": true} + ] +} +``` + +## Channel Item View +/api/channel/\/ + +## Playlists Item View +/api/playlist/\/ + +## Download Queue List View +/api/download/ + +### Add list of videos to download queue +POST /api/download/ +```json +{ + "data": [ + {"youtube_id": "NYj3DnI81AQ", "status": "pending"} + ] +} +``` + +## Download Queue Item View +/api/download/\/ diff --git a/tubearchivist/api/__init__.py b/tubearchivist/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tubearchivist/api/admin.py b/tubearchivist/api/admin.py new file mode 100644 index 0000000..4fd5490 --- /dev/null +++ b/tubearchivist/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin # noqa: F401 + +# Register your models here. diff --git a/tubearchivist/api/apps.py b/tubearchivist/api/apps.py new file mode 100644 index 0000000..9acf5fb --- /dev/null +++ b/tubearchivist/api/apps.py @@ -0,0 +1,10 @@ +"""apps file for api package""" + +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + """app config""" + + default_auto_field = "django.db.models.BigAutoField" + name = "api" diff --git a/tubearchivist/api/migrations/__init__.py b/tubearchivist/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tubearchivist/api/models.py b/tubearchivist/api/models.py new file mode 100644 index 0000000..b225e99 --- /dev/null +++ b/tubearchivist/api/models.py @@ -0,0 +1,3 @@ +"""api models""" + +# from django.db import models diff --git a/tubearchivist/api/serializers.py b/tubearchivist/api/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/tubearchivist/api/tests.py b/tubearchivist/api/tests.py new file mode 100644 index 0000000..e55d689 --- /dev/null +++ b/tubearchivist/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase # noqa: F401 + +# Create your tests here. diff --git a/tubearchivist/api/urls.py b/tubearchivist/api/urls.py new file mode 100644 index 0000000..d39dc30 --- /dev/null +++ b/tubearchivist/api/urls.py @@ -0,0 +1,44 @@ +"""all api urls""" + +from api.views import ( + ChannelApiListView, + ChannelApiView, + DownloadApiListView, + DownloadApiView, + PlaylistApiView, + VideoApiView, +) +from django.urls import path + +urlpatterns = [ + path( + "video//", + VideoApiView.as_view(), + name="api-video", + ), + path( + "channel/", + ChannelApiListView.as_view(), + name="api-channel-list", + ), + path( + "channel//", + ChannelApiView.as_view(), + name="api-channel", + ), + path( + "playlist//", + PlaylistApiView.as_view(), + name="api-playlist", + ), + path( + "download/", + DownloadApiListView.as_view(), + name="api-download-list", + ), + path( + "download//", + DownloadApiView.as_view(), + name="api-download", + ), +] diff --git a/tubearchivist/api/views.py b/tubearchivist/api/views.py new file mode 100644 index 0000000..ccbc22f --- /dev/null +++ b/tubearchivist/api/views.py @@ -0,0 +1,200 @@ +"""all API views""" + +import requests +from home.src.config import AppConfig +from home.src.helper import UrlListParser +from home.tasks import extrac_dl, subscribe_to +from rest_framework.authentication import ( + SessionAuthentication, + TokenAuthentication, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + + +class ApiBaseView(APIView): + """base view to inherit from""" + + authentication_classes = [SessionAuthentication, TokenAuthentication] + permission_classes = [IsAuthenticated] + search_base = False + + def __init__(self): + super().__init__() + self.response = {"data": False} + self.status_code = False + self.context = False + + def config_builder(self): + """build confic context""" + default_conf = AppConfig().config + self.context = { + "es_url": default_conf["application"]["es_url"], + "es_auth": default_conf["application"]["es_auth"], + } + + def get_document(self, document_id): + """get single document from es""" + es_url = self.context["es_url"] + url = f"{es_url}{self.search_base}{document_id}" + print(url) + response = requests.get(url, auth=self.context["es_auth"]) + try: + self.response["data"] = response.json()["_source"] + except KeyError: + print(f"item not found: {document_id}") + self.response["data"] = False + self.status_code = response.status_code + + def get_paginate(self): + """add pagination detail to response""" + self.response["paginate"] = False + + def get_document_list(self, data): + """get a list of results""" + es_url = self.context["es_url"] + url = f"{es_url}{self.search_base}" + print(url) + response = requests.get(url, json=data, auth=self.context["es_auth"]) + all_hits = response.json()["hits"]["hits"] + self.response["data"] = [i["_source"] for i in all_hits] + self.status_code = response.status_code + + +class VideoApiView(ApiBaseView): + """resolves to /api/video// + GET: returns metadata dict of video + """ + + search_base = "/ta_video/_doc/" + + def get(self, request, video_id): + # pylint: disable=unused-argument + """get request""" + self.config_builder() + self.get_document(video_id) + return Response(self.response, status=self.status_code) + + +class ChannelApiView(ApiBaseView): + """resolves to /api/channel// + GET: returns metadata dict of channel + """ + + search_base = "/ta_channel/_doc/" + + def get(self, request, channel_id): + # pylint: disable=unused-argument + """get request""" + self.config_builder() + self.get_document(channel_id) + return Response(self.response, status=self.status_code) + + +class ChannelApiListView(ApiBaseView): + """resolves to /api/channel/ + GET: returns list of channels + POST: edit a list of channels + """ + + search_base = "/ta_channel/_search/" + + def get(self, request): + # pylint: disable=unused-argument + """get request""" + data = {"query": {"match_all": {}}} + self.config_builder() + self.get_document_list(data) + self.get_paginate() + + return Response(self.response) + + @staticmethod + def post(request): + """subscribe to list of channels""" + data = request.data + try: + to_add = data["data"] + except KeyError: + message = "missing expected data key" + print(message) + return Response({"message": message}, status=400) + + pending = [i["channel_id"] for i in to_add if i["channel_subscribed"]] + url_str = " ".join(pending) + subscribe_to.delay(url_str) + + return Response(data) + + +class PlaylistApiView(ApiBaseView): + """resolves to /api/playlist// + GET: returns metadata dict of playlist + """ + + search_base = "/ta_playlist/_doc/" + + def get(self, request, playlist_id): + # pylint: disable=unused-argument + """get request""" + self.config_builder() + self.get_document(playlist_id) + return Response(self.response, status=self.status_code) + + +class DownloadApiView(ApiBaseView): + """resolves to /api/download// + GET: returns metadata dict of an item in the download queue + """ + + search_base = "/ta_download/_doc/" + + def get(self, request, video_id): + # pylint: disable=unused-argument + """get request""" + self.config_builder() + self.get_document(video_id) + return Response(self.response, status=self.status_code) + + +class DownloadApiListView(ApiBaseView): + """resolves to /api/download/ + GET: returns latest videos in the download queue + POST: add a list of videos to download queue + """ + + search_base = "/ta_download/_search/" + + def get(self, request): + # pylint: disable=unused-argument + """get request""" + data = {"query": {"match_all": {}}} + self.config_builder() + self.get_document_list(data) + self.get_paginate() + return Response(self.response) + + @staticmethod + def post(request): + """add list of videos to download queue""" + data = request.data + try: + to_add = data["data"] + except KeyError: + message = "missing expected data key" + print(message) + return Response({"message": message}, status=400) + + pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"] + url_str = " ".join(pending) + try: + youtube_ids = UrlListParser(url_str).process_list() + except ValueError: + message = f"failed to parse: {url_str}" + print(message) + return Response({"message": message}, status=400) + + extrac_dl.delay(youtube_ids) + + return Response(data) diff --git a/tubearchivist/config/settings.py b/tubearchivist/config/settings.py index 8324c89..cb12ad6 100644 --- a/tubearchivist/config/settings.py +++ b/tubearchivist/config/settings.py @@ -44,6 +44,9 @@ INSTALLED_APPS = [ "whitenoise.runserver_nostatic", "django.contrib.staticfiles", "django.contrib.humanize", + "rest_framework", + "rest_framework.authtoken", + "api", ] MIDDLEWARE = [ diff --git a/tubearchivist/config/urls.py b/tubearchivist/config/urls.py index b857f01..11a1ed7 100644 --- a/tubearchivist/config/urls.py +++ b/tubearchivist/config/urls.py @@ -18,5 +18,6 @@ from django.urls import include, path urlpatterns = [ path("", include("home.urls")), + path("api/", include("api.urls")), path("admin/", admin.site.urls), ] diff --git a/tubearchivist/home/templates/home/settings.html b/tubearchivist/home/templates/home/settings.html index f18a578..291e862 100644 --- a/tubearchivist/home/templates/home/settings.html +++ b/tubearchivist/home/templates/home/settings.html @@ -97,6 +97,10 @@

Integrations

+
+

API token:

+

{{ api_token }}

+

Integrate with returnyoutubedislike.com to get dislikes and average ratings back: {{ config.downloads.integrate_ryd }}

Before activating that, make sure you have a scraping sleep interval of at least 3 secs set to avoid ratelimiting issues.
diff --git a/tubearchivist/home/views.py b/tubearchivist/home/views.py index 5dbfdb7..62b22e7 100644 --- a/tubearchivist/home/views.py +++ b/tubearchivist/home/views.py @@ -31,6 +31,7 @@ from home.src.index import YoutubePlaylist from home.src.index_management import get_available_backups from home.src.searching import Pagination, SearchHandler from home.tasks import extrac_dl, subscribe_to +from rest_framework.authtoken.models import Token class ArchivistViewConfig(View): @@ -683,8 +684,7 @@ class SettingsView(View): take post request from the form to update settings """ - @staticmethod - def get(request): + def get(self, request): """read and display current settings""" config_handler = AppConfig(request.user.id) colors = config_handler.colors @@ -693,10 +693,12 @@ class SettingsView(View): user_form = UserSettingsForm() app_form = ApplicationSettingsForm() scheduler_form = SchedulerSettingsForm() + token = self.get_token(request) context = { "title": "Settings", "config": config_handler.config, + "api_token": token, "colors": colors, "available_backups": available_backups, "user_form": user_form, @@ -706,6 +708,14 @@ class SettingsView(View): return render(request, "home/settings.html", context) + @staticmethod + def get_token(request): + """get existing or create new token of user""" + # pylint: disable=no-member + token = Token.objects.get_or_create(user=request.user)[0] + print(token) + return token + @staticmethod def post(request): """handle form post to update settings""" diff --git a/tubearchivist/requirements.txt b/tubearchivist/requirements.txt index a3bc158..3ebbf26 100644 --- a/tubearchivist/requirements.txt +++ b/tubearchivist/requirements.txt @@ -1,6 +1,7 @@ beautifulsoup4==4.10.0 celery==5.2.3 Django==4.0.1 +djangorestframework==3.13.1 Pillow==9.0.0 redis==4.1.0 requests==2.27.1