mirror of
https://git.vectorsigma.ru/public/tubearchivist.git
synced 2026-08-04 20:19:30 +00:00
add membership handling endpoints
This commit is contained in:
@@ -136,4 +136,4 @@ class SnapshotRestoreResponseSerializer(serializers.Serializer):
|
|||||||
class TokenResponseSerializer(serializers.Serializer):
|
class TokenResponseSerializer(serializers.Serializer):
|
||||||
"""serialize token response"""
|
"""serialize token response"""
|
||||||
|
|
||||||
token = serializers.CharField()
|
token = serializers.CharField(allow_null=True)
|
||||||
|
|||||||
31
backend/appsettings/serializers_mb.py
Normal file
31
backend/appsettings/serializers_mb.py
Normal file
@@ -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()
|
||||||
89
backend/appsettings/src/membership.py
Normal file
89
backend/appsettings/src/membership.py
Normal file
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""all app settings API urls"""
|
"""all app settings API urls"""
|
||||||
|
|
||||||
from appsettings import views
|
from appsettings import views, views_mb
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -44,4 +44,19 @@ urlpatterns = [
|
|||||||
views.TokenView.as_view(),
|
views.TokenView.as_view(),
|
||||||
name="api-token",
|
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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
122
backend/appsettings/views_mb.py
Normal file
122
backend/appsettings/views_mb.py
Normal file
@@ -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)
|
||||||
219
frontend/src/components/MembershipAppsettings.tsx
Normal file
219
frontend/src/components/MembershipAppsettings.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [newToken, setNewToken] = useState<string | null>(null);
|
||||||
|
const [profileResponse, setProfileResponse] = useState<ProfileResponseType | null>(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<ApiTokenResponse>(
|
||||||
|
'/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<ApiTokenResponse>('/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<ProfileResponseType>('/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 (
|
||||||
|
<>
|
||||||
|
<h2>Membership</h2>
|
||||||
|
{show_help_text && (
|
||||||
|
<div className="help-text">
|
||||||
|
<p>
|
||||||
|
Unlock additional perks by sponsoring this project. More details on{' '}
|
||||||
|
<a href="https://members.tubearchivist.com/" target="_blank" rel="noopener noreferrer">
|
||||||
|
members.tubearchivist.com
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Enter the API token from{' '}
|
||||||
|
<a href="https://members.tubearchivist.com/profile">
|
||||||
|
members.tubearchivist.com/profile
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</li>
|
||||||
|
<li>Click on validate to verify everything is working.</li>
|
||||||
|
<li>
|
||||||
|
If you are subscribed to less channels than your sponsor tier allows, you can directly
|
||||||
|
sync all your subscriptions here.
|
||||||
|
</li>
|
||||||
|
<ul>
|
||||||
|
<li>Repeat the sync after changing subscriptions here.</li>
|
||||||
|
<li>
|
||||||
|
That will unsubscribe from channels on the membership platform if you are no longer
|
||||||
|
subscribed here.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="settings-box-wrapper">
|
||||||
|
<div>
|
||||||
|
<p>Membership API key</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type={inputType}
|
||||||
|
value={newToken || membershipApiToken || ''}
|
||||||
|
onChange={e => handleInputChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="button-box">
|
||||||
|
{(membershipApiToken || newToken) && (
|
||||||
|
<button onClick={toggleShowKey}>{inputType === 'password' ? 'Show' : 'Hide'}</button>
|
||||||
|
)}
|
||||||
|
{newToken && (
|
||||||
|
<>
|
||||||
|
<button onClick={updateToken}>Save</button>
|
||||||
|
<button onClick={() => setNewToken(null)}>Cancel</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{membershipApiToken && (
|
||||||
|
<button className="danger-button" onClick={deleteMembershipToken}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{membershipApiToken && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p>Your Profile</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="button-box">
|
||||||
|
{isLoadingProfile ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : (
|
||||||
|
<button onClick={fetchProfile}>Validate</button>
|
||||||
|
)}
|
||||||
|
{isLoadingSync ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : (
|
||||||
|
<button onClick={fetchSyncSubscriptions}>Sync Subscriptions</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{profileResponseError && <p className="danger-zone">Error: {profileResponseError}</p>}
|
||||||
|
{profileResponse && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Username: {profileResponse.user.username}
|
||||||
|
<br />
|
||||||
|
Sponsortier: {profileResponse.sponsor_tier.name} -{' '}
|
||||||
|
{profileResponse.sponsor_tier.description}
|
||||||
|
<br />
|
||||||
|
Subscriptions: {profileResponse.subscription_count}/
|
||||||
|
{profileResponse.sponsor_tier.max_subs}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{subSyncMessage && <p>Sync: {subSyncMessage}</p>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import validateCookie from '../api/actions/validateCookie';
|
|||||||
import deletePoToken from '../api/actions/deletePoToken';
|
import deletePoToken from '../api/actions/deletePoToken';
|
||||||
import updatePoToken from '../api/actions/updatePoToken';
|
import updatePoToken from '../api/actions/updatePoToken';
|
||||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||||
|
import MembershipAppsettings from '../components/MembershipAppsettings';
|
||||||
|
|
||||||
type SettingsApplicationReponses = {
|
type SettingsApplicationReponses = {
|
||||||
snapshots?: SnapshotListType;
|
snapshots?: SnapshotListType;
|
||||||
@@ -911,6 +912,9 @@ const SettingsApplication = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="info-box-item">
|
||||||
|
<MembershipAppsettings show_help_text={userConfig.show_help_text} />
|
||||||
|
</div>
|
||||||
<div className="info-box-item">
|
<div className="info-box-item">
|
||||||
<h2>Snapshots</h2>
|
<h2>Snapshots</h2>
|
||||||
{userConfig.show_help_text && (
|
{userConfig.show_help_text && (
|
||||||
|
|||||||
@@ -1231,6 +1231,10 @@ video:-webkit-full-screen {
|
|||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-box-wrapper div {
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* settings */
|
/* settings */
|
||||||
.settings-group {
|
.settings-group {
|
||||||
background-color: var(--highlight-bg);
|
background-color: var(--highlight-bg);
|
||||||
|
|||||||
Reference in New Issue
Block a user