mirror of
https://git.vectorsigma.ru/public/tubearchivist.git
synced 2026-08-09 09:49:36 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
005d2d1cf5 | ||
|
|
bc3463bdd8 | ||
|
|
39c66019eb | ||
|
|
a9264e348f | ||
|
|
ba44ab252e | ||
|
|
b6e95c6125 | ||
|
|
90e3a5c634 | ||
|
|
3fe26fa6a9 | ||
|
|
dc6276803d | ||
|
|
64a05d561f | ||
|
|
4766f703f2 | ||
|
|
d1da9f2f02 | ||
|
|
d2a6bdb18c |
2
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
2
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
@@ -16,7 +16,7 @@ body:
|
|||||||
- label: I'm running the latest version of Tube Archivist and have read the [release notes](https://github.com/tubearchivist/tubearchivist/releases/latest).
|
- label: I'm running the latest version of Tube Archivist and have read the [release notes](https://github.com/tubearchivist/tubearchivist/releases/latest).
|
||||||
required: true
|
required: true
|
||||||
- label: I'm [beta testing](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#beta-testing) and am running the latest unstable build.
|
- label: I'm [beta testing](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#beta-testing) and am running the latest unstable build.
|
||||||
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [bug report](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#bug-report) section.
|
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [bug report](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#bug-report) section. I've double checked that I don't open a yt-dlp issue here.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
|
|||||||
2
.github/workflows/unit_tests.yml
vendored
2
.github/workflows/unit_tests.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r backend/requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pytest backend
|
run: pytest backend
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ Please read this carefully before opening any [issue](https://github.com/tubearc
|
|||||||
Bug reports are highly welcome! This project has improved a lot due to your help by providing feedback when something doesn't work as expected. The developers can't possibly cover all edge cases in an ever changing environment like YouTube and yt-dlp.
|
Bug reports are highly welcome! This project has improved a lot due to your help by providing feedback when something doesn't work as expected. The developers can't possibly cover all edge cases in an ever changing environment like YouTube and yt-dlp.
|
||||||
|
|
||||||
Please keep in mind:
|
Please keep in mind:
|
||||||
|
- Don't report bugs from yt-dlp here. There is a [dedicated repo](https://github.com/yt-dlp/yt-dlp/issues) for that. Make sure to check for duplicates before opening a new issue there.
|
||||||
- Docker logs are the easiest way to understand what's happening when something goes wrong, *always* provide the logs upfront.
|
- Docker logs are the easiest way to understand what's happening when something goes wrong, *always* provide the logs upfront.
|
||||||
- Set the environment variable `DJANGO_DEBUG=True` to Tube Archivist and reproduce the bug for a better log output. Don't forget to remove that variable again after.
|
- Set the environment variable `DJANGO_DEBUG=True` to Tube Archivist and reproduce the bug for a better log output. Don't forget to remove that variable again after.
|
||||||
- A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*.
|
- A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -223,7 +223,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
|
|||||||
|
|
||||||
# TA application settings
|
# TA application settings
|
||||||
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
|
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
|
||||||
TA_VERSION = "v0.5.6"
|
TA_VERSION = "v0.5.8-unstable"
|
||||||
|
|
||||||
# API
|
# API
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ djangorestframework==3.16.1
|
|||||||
drf-spectacular==0.28.0
|
drf-spectacular==0.28.0
|
||||||
Pillow==11.3.0
|
Pillow==11.3.0
|
||||||
redis==6.4.0
|
redis==6.4.0
|
||||||
requests==2.32.4
|
requests==2.32.5
|
||||||
ryd-client==0.0.6
|
ryd-client==0.0.6
|
||||||
uvicorn==0.35.0
|
uvicorn==0.35.0
|
||||||
whitenoise==6.9.0
|
whitenoise==6.9.0
|
||||||
yt-dlp[default]==2025.8.11
|
yt-dlp[default]==2025.8.22
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class UserMeConfigSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
grid_items = serializers.IntegerField(max_value=7, min_value=3)
|
grid_items = serializers.IntegerField(max_value=7, min_value=3)
|
||||||
hide_watched = serializers.BooleanField(allow_null=True)
|
hide_watched = serializers.BooleanField(allow_null=True)
|
||||||
|
hide_watched_channel = serializers.BooleanField(allow_null=True)
|
||||||
|
hide_watched_playlist = serializers.BooleanField(allow_null=True)
|
||||||
file_size_unit = serializers.ChoiceField(choices=["binary", "metric"])
|
file_size_unit = serializers.ChoiceField(choices=["binary", "metric"])
|
||||||
show_ignored_only = serializers.BooleanField()
|
show_ignored_only = serializers.BooleanField()
|
||||||
show_subed_only = serializers.BooleanField(allow_null=True)
|
show_subed_only = serializers.BooleanField(allow_null=True)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class UserConfigType(TypedDict, total=False):
|
|||||||
vid_type_filter: str | None
|
vid_type_filter: str | None
|
||||||
grid_items: int
|
grid_items: int
|
||||||
hide_watched: bool | None
|
hide_watched: bool | None
|
||||||
|
hide_watched_channel: bool | None
|
||||||
|
hide_watched_playlist: bool | None
|
||||||
file_size_unit: str
|
file_size_unit: str
|
||||||
show_ignored_only: bool
|
show_ignored_only: bool
|
||||||
show_subed_only: bool | None
|
show_subed_only: bool | None
|
||||||
@@ -48,6 +50,8 @@ class UserConfig:
|
|||||||
vid_type_filter=None,
|
vid_type_filter=None,
|
||||||
grid_items=3,
|
grid_items=3,
|
||||||
hide_watched=False,
|
hide_watched=False,
|
||||||
|
hide_watched_channel=None,
|
||||||
|
hide_watched_playlist=None,
|
||||||
file_size_unit="binary",
|
file_size_unit="binary",
|
||||||
show_ignored_only=False,
|
show_ignored_only=False,
|
||||||
show_subed_only=None,
|
show_subed_only=None,
|
||||||
|
|||||||
@@ -50,7 +50,17 @@ server {
|
|||||||
root /app/static;
|
root /app/static;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location ~* .(?:css|js)$ {
|
||||||
|
try_files $uri $uri/ /index.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control 'no-store';
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
add_header Cache-Control 'no-store';
|
||||||
try_files $uri $uri/ /index.html =404;
|
try_files $uri $uri/ /index.html =404;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export type UserConfigType = {
|
|||||||
vid_type_filter: VideoTypes | null;
|
vid_type_filter: VideoTypes | null;
|
||||||
grid_items: number;
|
grid_items: number;
|
||||||
hide_watched: boolean | null;
|
hide_watched: boolean | null;
|
||||||
|
hide_watched_channel: boolean | null;
|
||||||
|
hide_watched_playlist: boolean | null;
|
||||||
file_size_unit: 'binary' | 'metric';
|
file_size_unit: 'binary' | 'metric';
|
||||||
show_ignored_only: boolean;
|
show_ignored_only: boolean;
|
||||||
show_subed_only: boolean | null;
|
show_subed_only: boolean | null;
|
||||||
|
|||||||
@@ -21,14 +21,21 @@ import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
|
|||||||
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
|
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
import updateDownloadQueue from '../api/actions/updateDownloadQueue';
|
import updateDownloadQueue from '../api/actions/updateDownloadQueue';
|
||||||
|
import { HideWatchedType } from '../configuration/constants/HideWatched';
|
||||||
|
|
||||||
type FilterbarProps = {
|
type FilterbarProps = {
|
||||||
viewStyle: ViewStyleNamesType;
|
viewStyle: ViewStyleNamesType;
|
||||||
|
hideWatched: HideWatchedType;
|
||||||
showSort?: boolean;
|
showSort?: boolean;
|
||||||
showTypeFilter?: boolean;
|
showTypeFilter?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Filterbar = ({ viewStyle, showSort = true, showTypeFilter = false }: FilterbarProps) => {
|
const Filterbar = ({
|
||||||
|
viewStyle,
|
||||||
|
hideWatched,
|
||||||
|
showSort = true,
|
||||||
|
showTypeFilter = false,
|
||||||
|
}: FilterbarProps) => {
|
||||||
const { userConfig, setUserConfig } = useUserConfigStore();
|
const { userConfig, setUserConfig } = useUserConfigStore();
|
||||||
const {
|
const {
|
||||||
selectedVideoIds,
|
selectedVideoIds,
|
||||||
@@ -44,6 +51,7 @@ const Filterbar = ({ viewStyle, showSort = true, showTypeFilter = false }: Filte
|
|||||||
useFilterBarTempConf();
|
useFilterBarTempConf();
|
||||||
|
|
||||||
const currentViewStyle = userConfig[viewStyle];
|
const currentViewStyle = userConfig[viewStyle];
|
||||||
|
const currentHideWatched = userConfig[hideWatched];
|
||||||
const isGridView = currentViewStyle === ViewStylesEnum.Grid;
|
const isGridView = currentViewStyle === ViewStylesEnum.Grid;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -114,10 +122,10 @@ const Filterbar = ({ viewStyle, showSort = true, showTypeFilter = false }: Filte
|
|||||||
<div>
|
<div>
|
||||||
<span>Filter:</span>
|
<span>Filter:</span>
|
||||||
<select
|
<select
|
||||||
value={userConfig.hide_watched === null ? '' : userConfig.hide_watched.toString()}
|
value={currentHideWatched === null ? '' : currentHideWatched.toString()}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
handleUserConfigUpdate({
|
handleUserConfigUpdate({
|
||||||
hide_watched: event.target.value === '' ? null : event.target.value === 'true',
|
[hideWatched]: event.target.value === '' ? null : event.target.value === 'true',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/configuration/constants/HideWatched.ts
Normal file
7
frontend/src/configuration/constants/HideWatched.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type HideWatchedType = 'hide_watched' | 'hide_watched_channel' | 'hide_watched_playlist';
|
||||||
|
|
||||||
|
export const HideWatchedName = {
|
||||||
|
Home: 'hide_watched',
|
||||||
|
Channel: 'hide_watched_channel',
|
||||||
|
Playlist: 'hide_watched_playlist',
|
||||||
|
};
|
||||||
@@ -49,7 +49,7 @@ const APIClient = async <T>(
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw {
|
throw {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
message: data?.message || 'An error occurred while processing the request.',
|
message: data?.message || data?.error || 'An error occurred while processing the request.',
|
||||||
} as ApiError;
|
} as ApiError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useUserConfigStore } from '../stores/UserConfigStore';
|
|||||||
import { FileSizeUnits } from '../api/actions/updateUserConfig';
|
import { FileSizeUnits } from '../api/actions/updateUserConfig';
|
||||||
import { ApiResponseType } from '../functions/APIClient';
|
import { ApiResponseType } from '../functions/APIClient';
|
||||||
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
|
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
|
||||||
|
import { HideWatchedName, HideWatchedType } from '../configuration/constants/HideWatched';
|
||||||
|
|
||||||
type ChannelParams = {
|
type ChannelParams = {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
@@ -76,9 +77,9 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
|
|||||||
channel: channelId,
|
channel: channelId,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
watch:
|
watch:
|
||||||
userConfig.hide_watched === null
|
userConfig.hide_watched_channel === null
|
||||||
? null
|
? null
|
||||||
: ((userConfig.hide_watched
|
: ((userConfig.hide_watched_channel
|
||||||
? WatchTypesEnum.Watched
|
? WatchTypesEnum.Watched
|
||||||
: WatchTypesEnum.Unwatched) as WatchTypes),
|
: WatchTypesEnum.Unwatched) as WatchTypes),
|
||||||
sort: userConfig.sort_by,
|
sort: userConfig.sort_by,
|
||||||
@@ -97,7 +98,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
|
|||||||
refresh,
|
refresh,
|
||||||
userConfig.sort_by,
|
userConfig.sort_by,
|
||||||
userConfig.sort_order,
|
userConfig.sort_order,
|
||||||
userConfig.hide_watched,
|
userConfig.hide_watched_channel,
|
||||||
filterHeight,
|
filterHeight,
|
||||||
currentPage,
|
currentPage,
|
||||||
channelId,
|
channelId,
|
||||||
@@ -167,7 +168,10 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`boxed-content ${gridView}`}>
|
<div className={`boxed-content ${gridView}`}>
|
||||||
<Filterbar viewStyle={ViewStyleNames.Home as ViewStyleNamesType} />
|
<Filterbar
|
||||||
|
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
|
||||||
|
hideWatched={HideWatchedName.Channel as HideWatchedType}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmbeddableVideoPlayer videoId={videoId} />
|
<EmbeddableVideoPlayer videoId={videoId} />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { SponsorBlockType } from './Video';
|
|||||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||||
import { ApiResponseType } from '../functions/APIClient';
|
import { ApiResponseType } from '../functions/APIClient';
|
||||||
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
|
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
|
||||||
|
import { HideWatchedName, HideWatchedType } from '../configuration/constants/HideWatched';
|
||||||
|
|
||||||
export type PlayerType = {
|
export type PlayerType = {
|
||||||
watched: boolean;
|
watched: boolean;
|
||||||
@@ -202,7 +203,11 @@ const Home = () => {
|
|||||||
<h1>Recent Videos</h1>
|
<h1>Recent Videos</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Filterbar viewStyle={ViewStyleNames.Home as ViewStyleNamesType} showTypeFilter={true} />
|
<Filterbar
|
||||||
|
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
|
||||||
|
hideWatched={HideWatchedName.Home as HideWatchedType}
|
||||||
|
showTypeFilter={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`boxed-content ${gridView}`}>
|
<div className={`boxed-content ${gridView}`}>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { ApiResponseType } from '../functions/APIClient';
|
|||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import updatePlaylistSortOrder from '../api/actions/updatePlaylistSortOrder';
|
import updatePlaylistSortOrder from '../api/actions/updatePlaylistSortOrder';
|
||||||
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
|
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
|
||||||
|
import { HideWatchedType, HideWatchedName } from '../configuration/constants/HideWatched';
|
||||||
|
|
||||||
export type VideoResponseType = {
|
export type VideoResponseType = {
|
||||||
data?: VideoType[];
|
data?: VideoType[];
|
||||||
@@ -86,9 +87,9 @@ const Playlist = () => {
|
|||||||
playlist: playlistId,
|
playlist: playlistId,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
watch:
|
watch:
|
||||||
userConfig.hide_watched === null
|
userConfig.hide_watched_playlist === null
|
||||||
? null
|
? null
|
||||||
: ((userConfig.hide_watched
|
: ((userConfig.hide_watched_playlist
|
||||||
? WatchTypesEnum.Watched
|
? WatchTypesEnum.Watched
|
||||||
: WatchTypesEnum.Unwatched) as WatchTypes),
|
: WatchTypesEnum.Unwatched) as WatchTypes),
|
||||||
type: userConfig.vid_type_filter as VideoTypes,
|
type: userConfig.vid_type_filter as VideoTypes,
|
||||||
@@ -110,7 +111,7 @@ const Playlist = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
playlistId,
|
playlistId,
|
||||||
userConfig.hide_watched,
|
userConfig.hide_watched_playlist,
|
||||||
userConfig.vid_type_filter,
|
userConfig.vid_type_filter,
|
||||||
filterHeight,
|
filterHeight,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -351,6 +352,7 @@ const Playlist = () => {
|
|||||||
<div className={`boxed-content ${gridView}`}>
|
<div className={`boxed-content ${gridView}`}>
|
||||||
<Filterbar
|
<Filterbar
|
||||||
viewStyle={ViewStyleNames.Home as ViewStyleNamesType} // its a list of videos, so ViewStyleNames.Home
|
viewStyle={ViewStyleNames.Home as ViewStyleNamesType} // its a list of videos, so ViewStyleNames.Home
|
||||||
|
hideWatched={HideWatchedName.Playlist as HideWatchedType}
|
||||||
showSort={false}
|
showSort={false}
|
||||||
showTypeFilter={true}
|
showTypeFilter={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export const useUserConfigStore = create<UserConfigState>(set => ({
|
|||||||
view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
|
view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
|
||||||
vid_type_filter: null,
|
vid_type_filter: null,
|
||||||
grid_items: 3,
|
grid_items: 3,
|
||||||
hide_watched: false,
|
hide_watched: null,
|
||||||
|
hide_watched_channel: null,
|
||||||
|
hide_watched_playlist: null,
|
||||||
file_size_unit: 'binary',
|
file_size_unit: 'binary',
|
||||||
show_ignored_only: false,
|
show_ignored_only: false,
|
||||||
show_subed_only: null,
|
show_subed_only: null,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-r requirements.txt
|
-r backend/requirements.txt
|
||||||
ipython==9.4.0
|
ipython==9.4.0
|
||||||
pre-commit==4.3.0
|
pre-commit==4.3.0
|
||||||
pylint-django==2.6.1
|
pylint-django==2.6.1
|
||||||
Reference in New Issue
Block a user