add membership handling endpoints

This commit is contained in:
Simon 2025-08-21 17:00:54 +07:00
parent dc6276803d
commit 3fe26fa6a9
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
8 changed files with 486 additions and 2 deletions

View File

@ -136,4 +136,4 @@ class SnapshotRestoreResponseSerializer(serializers.Serializer):
class TokenResponseSerializer(serializers.Serializer):
"""serialize token response"""
token = serializers.CharField()
token = serializers.CharField(allow_null=True)

View 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()

View 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

View File

@ -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",
),
]

View 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)

View 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>
</>
);
}

View File

@ -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 = () => {
/>
</div>
</div>
<div className="info-box-item">
<MembershipAppsettings show_help_text={userConfig.show_help_text} />
</div>
<div className="info-box-item">
<h2>Snapshots</h2>
{userConfig.show_help_text && (

View File

@ -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);