Compare commits

..

13 Commits

Author SHA1 Message Date
Simon
005d2d1cf5 Separate watched filter, #build
Changed:
- Split watched state filter by home, channel videos and playlist videos
- Bumped yt-dlp
2025-08-23 12:33:31 +07:00
Simon
bc3463bdd8 add unstable tag 2025-08-23 12:32:39 +07:00
Simon
39c66019eb bump yt-dlp 2025-08-23 12:18:51 +07:00
Simon
a9264e348f watched filter split by channel and playlist 2025-08-23 12:17:55 +07:00
Simon
ba44ab252e bump TA_VERSION 2025-08-21 17:27:02 +07:00
Simon
b6e95c6125 fix unittest workflow 2025-08-21 17:10:36 +07:00
Simon
90e3a5c634 don't open yt-dlp issues here 2025-08-21 17:08:09 +07:00
Simon
3fe26fa6a9 add membership handling endpoints 2025-08-21 17:00:54 +07:00
Simon
dc6276803d Updated yt-dlp, #build 2025-08-20 15:49:35 +07:00
Simon
64a05d561f moved dev requirements to root 2025-08-20 15:48:50 +07:00
Simon
4766f703f2 bump requirements 2025-08-20 15:47:19 +07:00
João Ferreira Batista
d1da9f2f02 Fixes error message presented in the settins scheduling frontend (#1035)
When you submit an incorrect cron (for example ``0 5``) the frontend doesn't
surface the error message that the API returns, but only the default generic
one. This fix makes it surface when the api returns the message in the error
attribute and not in the message attribute.
2025-08-20 15:34:42 +07:00
MerlinScheurer
d2a6bdb18c Add no-store cache header on index.html in nginx 2025-08-19 23:09:09 +02:00
25 changed files with 552 additions and 21 deletions

View File

@@ -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).
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 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
- type: input

View File

@@ -37,7 +37,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements-dev.txt
pip install -r requirements-dev.txt
- name: Run unit tests
run: pytest backend

View File

@@ -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.
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.
- 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*.

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

@@ -223,7 +223,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.5.6"
TA_VERSION = "v0.5.8-unstable"
# API
REST_FRAMEWORK = {

View File

@@ -8,8 +8,8 @@ djangorestframework==3.16.1
drf-spectacular==0.28.0
Pillow==11.3.0
redis==6.4.0
requests==2.32.4
requests==2.32.5
ryd-client==0.0.6
uvicorn==0.35.0
whitenoise==6.9.0
yt-dlp[default]==2025.8.11
yt-dlp[default]==2025.8.22

View File

@@ -42,6 +42,8 @@ class UserMeConfigSerializer(serializers.Serializer):
)
grid_items = serializers.IntegerField(max_value=7, min_value=3)
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"])
show_ignored_only = serializers.BooleanField()
show_subed_only = serializers.BooleanField(allow_null=True)

View File

@@ -23,6 +23,8 @@ class UserConfigType(TypedDict, total=False):
vid_type_filter: str | None
grid_items: int
hide_watched: bool | None
hide_watched_channel: bool | None
hide_watched_playlist: bool | None
file_size_unit: str
show_ignored_only: bool
show_subed_only: bool | None
@@ -48,6 +50,8 @@ class UserConfig:
vid_type_filter=None,
grid_items=3,
hide_watched=False,
hide_watched_channel=None,
hide_watched_playlist=None,
file_size_unit="binary",
show_ignored_only=False,
show_subed_only=None,

View File

@@ -50,7 +50,17 @@ server {
root /app/static;
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 / {
add_header Cache-Control 'no-store';
try_files $uri $uri/ /index.html =404;
}
}

View File

@@ -34,6 +34,8 @@ export type UserConfigType = {
vid_type_filter: VideoTypes | null;
grid_items: number;
hide_watched: boolean | null;
hide_watched_channel: boolean | null;
hide_watched_playlist: boolean | null;
file_size_unit: 'binary' | 'metric';
show_ignored_only: boolean;
show_subed_only: boolean | null;

View File

@@ -21,14 +21,21 @@ import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
import Button from './Button';
import updateDownloadQueue from '../api/actions/updateDownloadQueue';
import { HideWatchedType } from '../configuration/constants/HideWatched';
type FilterbarProps = {
viewStyle: ViewStyleNamesType;
hideWatched: HideWatchedType;
showSort?: 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 {
selectedVideoIds,
@@ -44,6 +51,7 @@ const Filterbar = ({ viewStyle, showSort = true, showTypeFilter = false }: Filte
useFilterBarTempConf();
const currentViewStyle = userConfig[viewStyle];
const currentHideWatched = userConfig[hideWatched];
const isGridView = currentViewStyle === ViewStylesEnum.Grid;
useEffect(() => {
@@ -114,10 +122,10 @@ const Filterbar = ({ viewStyle, showSort = true, showTypeFilter = false }: Filte
<div>
<span>Filter:</span>
<select
value={userConfig.hide_watched === null ? '' : userConfig.hide_watched.toString()}
value={currentHideWatched === null ? '' : currentHideWatched.toString()}
onChange={event => {
handleUserConfigUpdate({
hide_watched: event.target.value === '' ? null : event.target.value === 'true',
[hideWatched]: event.target.value === '' ? null : event.target.value === 'true',
});
}}
>

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

@@ -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',
};

View File

@@ -49,7 +49,7 @@ const APIClient = async <T>(
const data = await response.json();
throw {
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;
}

View File

@@ -28,6 +28,7 @@ import { useUserConfigStore } from '../stores/UserConfigStore';
import { FileSizeUnits } from '../api/actions/updateUserConfig';
import { ApiResponseType } from '../functions/APIClient';
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
import { HideWatchedName, HideWatchedType } from '../configuration/constants/HideWatched';
type ChannelParams = {
channelId: string;
@@ -76,9 +77,9 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
channel: channelId,
page: currentPage,
watch:
userConfig.hide_watched === null
userConfig.hide_watched_channel === null
? null
: ((userConfig.hide_watched
: ((userConfig.hide_watched_channel
? WatchTypesEnum.Watched
: WatchTypesEnum.Unwatched) as WatchTypes),
sort: userConfig.sort_by,
@@ -97,7 +98,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
refresh,
userConfig.sort_by,
userConfig.sort_order,
userConfig.hide_watched,
userConfig.hide_watched_channel,
filterHeight,
currentPage,
channelId,
@@ -167,7 +168,10 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
</div>
<div className={`boxed-content ${gridView}`}>
<Filterbar viewStyle={ViewStyleNames.Home as ViewStyleNamesType} />
<Filterbar
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
hideWatched={HideWatchedName.Channel as HideWatchedType}
/>
</div>
<EmbeddableVideoPlayer videoId={videoId} />

View File

@@ -23,6 +23,7 @@ import { SponsorBlockType } from './Video';
import { useUserConfigStore } from '../stores/UserConfigStore';
import { ApiResponseType } from '../functions/APIClient';
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
import { HideWatchedName, HideWatchedType } from '../configuration/constants/HideWatched';
export type PlayerType = {
watched: boolean;
@@ -202,7 +203,11 @@ const Home = () => {
<h1>Recent Videos</h1>
</div>
<Filterbar viewStyle={ViewStyleNames.Home as ViewStyleNamesType} showTypeFilter={true} />
<Filterbar
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
hideWatched={HideWatchedName.Home as HideWatchedType}
showTypeFilter={true}
/>
</div>
<div className={`boxed-content ${gridView}`}>

View File

@@ -34,6 +34,7 @@ import { ApiResponseType } from '../functions/APIClient';
import NotFound from './NotFound';
import updatePlaylistSortOrder from '../api/actions/updatePlaylistSortOrder';
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
import { HideWatchedType, HideWatchedName } from '../configuration/constants/HideWatched';
export type VideoResponseType = {
data?: VideoType[];
@@ -86,9 +87,9 @@ const Playlist = () => {
playlist: playlistId,
page: currentPage,
watch:
userConfig.hide_watched === null
userConfig.hide_watched_playlist === null
? null
: ((userConfig.hide_watched
: ((userConfig.hide_watched_playlist
? WatchTypesEnum.Watched
: WatchTypesEnum.Unwatched) as WatchTypes),
type: userConfig.vid_type_filter as VideoTypes,
@@ -110,7 +111,7 @@ const Playlist = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
playlistId,
userConfig.hide_watched,
userConfig.hide_watched_playlist,
userConfig.vid_type_filter,
filterHeight,
refresh,
@@ -351,6 +352,7 @@ const Playlist = () => {
<div className={`boxed-content ${gridView}`}>
<Filterbar
viewStyle={ViewStyleNames.Home as ViewStyleNamesType} // its a list of videos, so ViewStyleNames.Home
hideWatched={HideWatchedName.Playlist as HideWatchedType}
showSort={false}
showTypeFilter={true}
/>

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

@@ -20,7 +20,9 @@ export const useUserConfigStore = create<UserConfigState>(set => ({
view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
vid_type_filter: null,
grid_items: 3,
hide_watched: false,
hide_watched: null,
hide_watched_channel: null,
hide_watched_playlist: null,
file_size_unit: 'binary',
show_ignored_only: false,
show_subed_only: null,

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

View File

@@ -1,4 +1,4 @@
-r requirements.txt
-r backend/requirements.txt
ipython==9.4.0
pre-commit==4.3.0
pylint-django==2.6.1