diff --git a/backend/appsettings/serializers.py b/backend/appsettings/serializers.py index 8130c19d..70564465 100644 --- a/backend/appsettings/serializers.py +++ b/backend/appsettings/serializers.py @@ -136,4 +136,4 @@ class SnapshotRestoreResponseSerializer(serializers.Serializer): class TokenResponseSerializer(serializers.Serializer): """serialize token response""" - token = serializers.CharField() + token = serializers.CharField(allow_null=True) diff --git a/backend/appsettings/serializers_mb.py b/backend/appsettings/serializers_mb.py new file mode 100644 index 00000000..18bbba4f --- /dev/null +++ b/backend/appsettings/serializers_mb.py @@ -0,0 +1,31 @@ +"""membership platform serializers""" + +# pylint: disable=abstract-method + +from rest_framework import serializers + + +class MembershipUserSerializer(serializers.Serializer): + """serialize user""" + + id = serializers.IntegerField() + username = serializers.CharField() + + +class SponsortierSerializer(serializers.Serializer): + """serialize sponsor tier""" + + tier_id = serializers.IntegerField() + name = serializers.CharField() + description = serializers.CharField() + max_subs = serializers.IntegerField() + + +class MembershipProfileSerializer(serializers.Serializer): + """serialize membership profile""" + + id = serializers.IntegerField() + user = MembershipUserSerializer() + sponsor_tier = SponsortierSerializer() + subscription_count = serializers.IntegerField() + subscription_is_max = serializers.BooleanField() diff --git a/backend/appsettings/src/membership.py b/backend/appsettings/src/membership.py new file mode 100644 index 00000000..42aa12c8 --- /dev/null +++ b/backend/appsettings/src/membership.py @@ -0,0 +1,89 @@ +""" +interact with members.tubearchivist.com +code related to sponsor perks +""" + +from os import environ + +import requests +from appsettings.src.config import AppConfig +from common.src.helper import get_channels +from common.src.ta_redis import RedisArchivist + + +class Membership: + """membership""" + + BASE_URL = environ.get("MB_URL", "https://members.tubearchivist.com") + REDIS_KEY = "MB:KEY" + + def __init__(self): + self.config = AppConfig().config + + def get_profile(self): + """get profile""" + response = requests.get( + f"{self.BASE_URL}/api/profile/me/", + headers=self._get_headers(), + timeout=30, + ) + return response + + def _get_headers(self): + """get headers with api key""" + token = RedisArchivist().get_message_dict(self.REDIS_KEY) + if not token: + raise ValueError("expected MB_API_KEY") + + token_str = token["token"] + + return {"Authorization": f"Token {token_str}"} + + def sync_subs(self): + """sync subscriptions, works if within max limits""" + to_sync = self._get_to_sync() + response = requests.post( + f"{self.BASE_URL}/api/profile/subscription/?delete=true", + headers=self._get_headers(), + json=to_sync, + timeout=30, + ) + return response + + def _get_to_sync(self): + """get channels to sync""" + to_sync = [] + subscribed = get_channels(subscribed_only=True) + for channel in subscribed: + overwrites = channel.get("channel_overwrites", {}) + to_sync.append( + { + "channel_id": channel["channel_id"], + "notify_videos": self._notify_videos(overwrites), + "notify_streams": self._notify_streams(overwrites), + "notify_shorts": self._notify_shorts(overwrites), + } + ) + + return to_sync + + def _notify_videos(self, overwrites: dict) -> bool: + """notify videos""" + if overwrites.get("subscriptions_channel_size") == 0: + return False + + return self.config["subscriptions"].get("channel_size") != 0 + + def _notify_streams(self, overwrites: dict) -> bool: + """notify streams""" + if overwrites.get("subscriptions_live_channel_size") == 0: + return False + + return self.config["subscriptions"].get("live_channel_size") != 0 + + def _notify_shorts(self, overwrites: dict) -> bool: + """notify shorts""" + if overwrites.get("subscriptions_shorts_channel_size") == 0: + return False + + return self.config["subscriptions"].get("shorts_channel_size") != 0 diff --git a/backend/appsettings/urls.py b/backend/appsettings/urls.py index 14d1c416..4dde4188 100644 --- a/backend/appsettings/urls.py +++ b/backend/appsettings/urls.py @@ -1,6 +1,6 @@ """all app settings API urls""" -from appsettings import views +from appsettings import views, views_mb from django.urls import path urlpatterns = [ @@ -44,4 +44,19 @@ urlpatterns = [ views.TokenView.as_view(), name="api-token", ), + path( + "membership/profile/", + views_mb.MembershipProfileView.as_view(), + name="api-membership-profile", + ), + path( + "membership/sync/", + views_mb.MembershipSubscriptionSync.as_view(), + name="api-membership-sync", + ), + path( + "membership/token/", + views_mb.MembershipToken.as_view(), + name="api-membership-token", + ), ] diff --git a/backend/appsettings/views_mb.py b/backend/appsettings/views_mb.py new file mode 100644 index 00000000..ad0d7713 --- /dev/null +++ b/backend/appsettings/views_mb.py @@ -0,0 +1,122 @@ +"""membership platform views""" + +from json import JSONDecodeError + +from appsettings.serializers import TokenResponseSerializer +from appsettings.serializers_mb import MembershipProfileSerializer +from appsettings.src.membership import Membership +from common.serializers import ErrorResponseSerializer +from common.src.ta_redis import RedisArchivist +from common.views_base import AdminOnly, ApiBaseView +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.response import Response + + +class MembershipProfileView(ApiBaseView): + """resolves to /api/appsettings/membership/profile/ + GET: get profile status + """ + + permission_classes = [AdminOnly] + + @staticmethod + @extend_schema( + responses={ + 200: OpenApiResponse(MembershipProfileSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + } + ) + def get(request): + """get profile""" + + try: + profile_response = Membership().get_profile() + except ValueError as error: + error = ErrorResponseSerializer({"message": str(error)}) + return Response(error.data, status=400) + + try: + response_json = profile_response.json() + except JSONDecodeError: + code = profile_response.status_code + message = f"Connection to remote server failed: {code}" + error_message = {"message": message} + return Response(error_message, status=400) + + if profile_response.status_code == 403: + message = response_json.get("detail", "undefined error") + error_message = {"message": message} + return Response(error_message, status=400) + + serializer = MembershipProfileSerializer(data=response_json) + serializer.is_valid(raise_exception=True) + + return Response(serializer.data) + + +class MembershipSubscriptionSync(ApiBaseView): + """resolves to /api/appsettings/membership/sync/ + POST: trigger sync task + """ + + permission_classes = [AdminOnly] + + @staticmethod + def post(request): + """post request""" + response = Membership().sync_subs() + + if not response.ok: + try: + response_json = response.json() + message = response_json.get("detail", "undefined error") + except JSONDecodeError: + code = response.status_code + message = f"Connection to remote server failed: {code}" + + error_message = {"message": message} + return Response(error_message, status=400) + + return Response(status=204) + + +class MembershipToken(ApiBaseView): + """resolves to /api/appsettings/membership/token/ + GET: get masked token + POST: add token + DELETE: delete token + """ + + permission_classes = [AdminOnly] + REDIS_KEY = "MB:KEY" + + def get(self, request): + """get token""" + token = RedisArchivist().get_message_dict(self.REDIS_KEY) + if token: + serializer = TokenResponseSerializer(data=token) + serializer.is_valid(raise_exception=True) + data = serializer.data + else: + data = {"token": None} + + return Response(data) + + def post(self, request): + """add token""" + serializer = TokenResponseSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + RedisArchivist().set_message( + self.REDIS_KEY, message=serializer.data, save=True + ) + + return Response(serializer.data) + + def delete(self, request): + """delete token""" + RedisArchivist().del_message(self.REDIS_KEY) + + return Response(status=204) diff --git a/frontend/src/components/MembershipAppsettings.tsx b/frontend/src/components/MembershipAppsettings.tsx new file mode 100644 index 00000000..9474c689 --- /dev/null +++ b/frontend/src/components/MembershipAppsettings.tsx @@ -0,0 +1,219 @@ +import { useEffect, useState } from 'react'; +import APIClient, { ApiError } from '../functions/APIClient'; +import LoadingIndicator from './LoadingIndicator'; + +type ApiTokenResponse = { + token: string; +}; + +type ProfileUserType = { + id: number; + username: string; +}; + +type SponsorTierType = { + tier_id: number; + name: string; + description: string; + max_subs: number; +}; + +type ProfileResponseType = { + id: number; + user: ProfileUserType; + sponsor_tier: SponsorTierType; + subscription_count: number; + subscription_is_max: boolean; +}; + +export default function MembershipAppsettings({ show_help_text }: { show_help_text: boolean }) { + const [inputType, setInputType] = useState('password'); + const [membershipApiToken, setMembershipApiToken] = useState(null); + const [newToken, setNewToken] = useState(null); + const [profileResponse, setProfileResponse] = useState(null); + const [profileResponseError, setProfileResponseError] = useState(''); + const [isLoadingProfile, setIsLoadingProfile] = useState(false); + const [isLoadingSync, setIsLoadingSync] = useState(false); + const [subSyncMessage, setSubSyncMessage] = useState(''); + + const fetchMembershipToken = async () => { + const apiTokenResponse = await APIClient( + '/api/appsettings/membership/token/', + ); + setMembershipApiToken(apiTokenResponse.data?.token || null); + }; + + const deleteMembershipToken = async () => { + await APIClient('/api/appsettings/membership/token/', { method: 'DELETE' }); + setMembershipApiToken(null); + setProfileResponseError(''); + setProfileResponse(null); + }; + + const updateToken = async () => { + const { data } = await APIClient('/api/appsettings/membership/token/', { + method: 'POST', + body: { token: newToken }, + }); + if (data) { + setNewToken(null); + setMembershipApiToken(data.token); + setInputType('password'); + } + }; + + useEffect(() => { + fetchMembershipToken(); + }, []); + + const fetchProfile = async () => { + setProfileResponse(null); + setProfileResponseError(''); + setSubSyncMessage(''); + + try { + setIsLoadingProfile(true); + const { data } = await APIClient('/api/appsettings/membership/profile/'); + if (data) setProfileResponse(data); + } catch (error) { + const apiError = error as ApiError; + if (apiError.status && apiError.message) { + setProfileResponseError(apiError.message); + } + } finally { + setIsLoadingProfile(false); + } + }; + + const fetchSyncSubscriptions = async () => { + setProfileResponseError(''); + setSubSyncMessage(''); + + try { + setIsLoadingSync(true); + await APIClient('/api/appsettings/membership/sync/', { method: 'POST' }); + setSubSyncMessage('Task created'); + } catch (error) { + const apiError = error as ApiError; + if (apiError.status && apiError.message) { + setProfileResponseError(apiError.message); + } + } finally { + setIsLoadingSync(false); + } + }; + + const toggleShowKey = () => { + if (inputType === 'password') { + setInputType('text'); + } else { + setInputType('password'); + } + }; + + const handleInputChange = (value: string) => { + setNewToken(value); + }; + + return ( + <> +

Membership

+ {show_help_text && ( +
+

+ Unlock additional perks by sponsoring this project. More details on{' '} + + members.tubearchivist.com + + . +

+
    +
  • + Enter the API token from{' '} + + members.tubearchivist.com/profile + + . +
  • +
  • Click on validate to verify everything is working.
  • +
  • + If you are subscribed to less channels than your sponsor tier allows, you can directly + sync all your subscriptions here. +
  • +
      +
    • Repeat the sync after changing subscriptions here.
    • +
    • + That will unsubscribe from channels on the membership platform if you are no longer + subscribed here. +
    • +
    +
+
+ )} +
+
+

Membership API key

+
+
+ handleInputChange(e.target.value)} + /> +
+ {(membershipApiToken || newToken) && ( + + )} + {newToken && ( + <> + + + + )} + {membershipApiToken && ( + + )} +
+
+ {membershipApiToken && ( + <> +
+

Your Profile

+
+
+
+ {isLoadingProfile ? ( + + ) : ( + + )} + {isLoadingSync ? ( + + ) : ( + + )} +
+ {profileResponseError &&

Error: {profileResponseError}

} + {profileResponse && ( + <> +

+ Username: {profileResponse.user.username} +
+ Sponsortier: {profileResponse.sponsor_tier.name} -{' '} + {profileResponse.sponsor_tier.description} +
+ Subscriptions: {profileResponse.subscription_count}/ + {profileResponse.sponsor_tier.max_subs} +

+ + )} + {subSyncMessage &&

Sync: {subSyncMessage}

} +
+ + )} +
+ + ); +} diff --git a/frontend/src/pages/SettingsApplication.tsx b/frontend/src/pages/SettingsApplication.tsx index 25847766..3ad87975 100644 --- a/frontend/src/pages/SettingsApplication.tsx +++ b/frontend/src/pages/SettingsApplication.tsx @@ -20,6 +20,7 @@ import validateCookie from '../api/actions/validateCookie'; import deletePoToken from '../api/actions/deletePoToken'; import updatePoToken from '../api/actions/updatePoToken'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import MembershipAppsettings from '../components/MembershipAppsettings'; type SettingsApplicationReponses = { snapshots?: SnapshotListType; @@ -911,6 +912,9 @@ const SettingsApplication = () => { /> +
+ +

Snapshots

{userConfig.show_help_text && ( diff --git a/frontend/src/style.css b/frontend/src/style.css index cccede24..7db5d0d2 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1231,6 +1231,10 @@ video:-webkit-full-screen { min-width: 300px; } +.settings-box-wrapper div { + padding: 3px 0; +} + /* settings */ .settings-group { background-color: var(--highlight-bg);