Update yt-dlp, small fixes, #build

Changed:
- bumped yt-dlp
- improved index alias creation
- add password from file lookup
- removed old manual POT field
- embed metadata fixes
This commit is contained in:
Simon 2026-02-28 18:00:22 +07:00
commit b2a58b44d4
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
27 changed files with 659 additions and 743 deletions

View File

@ -1,11 +1,11 @@
# multi stage to build tube archivist # multi stage to build tube archivist
# build python wheel, download and extract ffmpeg, copy into final image # build python wheel, download and extract ffmpeg, copy into final image
FROM node:22.12.0-alpine AS npm-builder FROM node:22.13.0-alpine AS npm-builder
COPY frontend/package.json frontend/package-lock.json / COPY frontend/package.json frontend/package-lock.json /
RUN npm i RUN npm i
FROM node:22.12.0-alpine AS node-builder FROM node:22.13.0-alpine AS node-builder
# RUN npm config set registry https://registry.npmjs.org/ # RUN npm config set registry https://registry.npmjs.org/

View File

@ -55,7 +55,6 @@ class AppConfigDownloadsSerializer(
) )
cookie_import = serializers.BooleanField() cookie_import = serializers.BooleanField()
pot_provider_url = serializers.CharField(allow_null=True) pot_provider_url = serializers.CharField(allow_null=True)
potoken = serializers.BooleanField()
throttledratelimit = serializers.IntegerField(allow_null=True) throttledratelimit = serializers.IntegerField(allow_null=True)
extractor_lang = serializers.CharField(allow_null=True) extractor_lang = serializers.CharField(allow_null=True)
integrate_ryd = serializers.BooleanField() integrate_ryd = serializers.BooleanField()
@ -94,12 +93,6 @@ class CookieUpdateSerializer(serializers.Serializer):
cookie = serializers.CharField() cookie = serializers.CharField()
class PoTokenSerializer(serializers.Serializer):
"""serialize PO token"""
potoken = serializers.CharField()
class RescanFileSystemConfig(serializers.Serializer): class RescanFileSystemConfig(serializers.Serializer):
"""serialize rescan filesystem config""" """serialize rescan filesystem config"""

View File

@ -42,7 +42,6 @@ class DownloadsConfigType(TypedDict):
comment_sort: Literal["top", "new"] | None comment_sort: Literal["top", "new"] | None
cookie_import: bool cookie_import: bool
pot_provider_url: str | None pot_provider_url: str | None
potoken: bool
throttledratelimit: int | None throttledratelimit: int | None
extractor_lang: str | None extractor_lang: str | None
integrate_ryd: bool integrate_ryd: bool
@ -92,7 +91,6 @@ class AppConfig:
"comment_sort": "top", "comment_sort": "top",
"cookie_import": False, "cookie_import": False,
"pot_provider_url": None, "pot_provider_url": None,
"potoken": False,
"throttledratelimit": None, "throttledratelimit": None,
"extractor_lang": None, "extractor_lang": None,
"integrate_ryd": False, "integrate_ryd": False,
@ -179,6 +177,34 @@ class AppConfig:
return updated return updated
def clear_old_keys(self) -> list[str]:
"""clear old unused keys"""
cleared = []
for key, value in self.config.items():
if key not in self.CONFIG_DEFAULTS:
# complete key removed
self.config.pop(key)
cleared.append(str({key: value}))
continue
expected_keys = set(
self.CONFIG_DEFAULTS[key].keys() # type: ignore
)
is_keys = set(self.config[key].keys())
for to_delete in is_keys - expected_keys:
self.config[key].pop(to_delete)
cleared.append(f"{key}.{to_delete}")
if not cleared:
return []
response, status_code = ElasticWrap(self.ES_PATH).post(self.config)
if not status_code == 200:
print(response)
return cleared
class ReleaseVersion: class ReleaseVersion:
"""compare local version with remote version""" """compare local version with remote version"""

View File

@ -13,6 +13,7 @@ from appsettings.src.snapshot import ElasticSnapshot
from common.src.es_connect import ElasticWrap from common.src.es_connect import ElasticWrap
from common.src.helper import get_mapping from common.src.helper import get_mapping
from deepdiff import DeepDiff from deepdiff import DeepDiff
from deepdiff.model import DiffLevel
from django.conf import settings from django.conf import settings
@ -113,6 +114,8 @@ class ElasticIndex:
now_set = self.details["settings"]["index"] now_set = self.details["settings"]["index"]
for key, value in self.expected_set.items(): for key, value in self.expected_set.items():
if key == "number_of_replicas":
continue
if key not in now_set.keys(): if key not in now_set.keys():
print(key, value) print(key, value)
return True return True
@ -150,24 +153,37 @@ class ElasticIndex:
return MappingAction.REINDEX return MappingAction.REINDEX
added = diff.get("dictionary_item_added", []) added = diff.get("dictionary_item_added", [])
has_additions = bool(added) reindex_from_added = self._needs_reindex(diff_items=added)
if reindex_from_added:
return MappingAction.REINDEX
for item in diff.get("values_changed", []): removed = diff.get("dictionary_item_removed", [])
path = item.path(output_format="list") reindex_from_removed = self._needs_reindex(diff_items=removed)
if not path: if reindex_from_removed:
continue return MappingAction.REINDEX
if path[-1] in self.REINDEX_KEYS: changed = diff.get("values_changed", [])
return MappingAction.REINDEX reindex_from_changed = self._needs_reindex(diff_items=changed)
if reindex_from_changed:
return MappingAction.REINDEX
# compatible addition if added or changed:
has_additions = True
if has_additions:
return MappingAction.PUT_MAPPING return MappingAction.PUT_MAPPING
return MappingAction.NOOP return MappingAction.NOOP
def _needs_reindex(self, diff_items: list[DiffLevel]) -> bool:
"""check if diff has fields that need reindex"""
for item in diff_items:
path = item.path(output_format="list")
if not path:
return False
if path[-1] in self.REINDEX_KEYS:
return True
return False
def _get_fields_to_delete(self, diff: DeepDiff) -> set[str]: def _get_fields_to_delete(self, diff: DeepDiff) -> set[str]:
"""fields to remove during next reindex""" """fields to remove during next reindex"""
removed_fields = set() removed_fields = set()
@ -192,7 +208,7 @@ class ElasticIndex:
self.create_blank(new_version=new_version) self.create_blank(new_version=new_version)
self.reindex(new_version=new_version, removed_fields=removed_fields) self.reindex(new_version=new_version, removed_fields=removed_fields)
self.delete_index(by_version=current_version) self.delete_index(by_version=current_version)
self.create_alias(new_version=new_version, old_version=current_version) self.create_alias(new_version=new_version)
def delete_index(self, by_version: int | None): def delete_index(self, by_version: int | None):
"""delete index passed as argument""" """delete index passed as argument"""
@ -257,7 +273,7 @@ class ElasticIndex:
print(f"{status_code}: {response}") print(f"{status_code}: {response}")
raise ValueError("reindex failed failed") raise ValueError("reindex failed failed")
def create_alias(self, new_version: int, old_version: int | None = None): def create_alias(self, new_version: int):
"""create aliast for moved index""" """create aliast for moved index"""
index_new = f"{self.index_namespace}_v{new_version}" index_new = f"{self.index_namespace}_v{new_version}"
index_old = None index_old = None
@ -273,16 +289,6 @@ class ElasticIndex:
}, },
] ]
} }
if old_version:
index_old = f"{self.index_namespace}_v{old_version}"
data["actions"].append(
{
"remove": {
"index": index_old,
"alias": self.index_namespace,
}
}
)
message = f"create new alias {index_new}" message = f"create new alias {index_new}"
if index_old: if index_old:
@ -292,7 +298,7 @@ class ElasticIndex:
if settings.DEBUG: if settings.DEBUG:
print(f"create alias with data: {data}") print(f"create alias with data: {data}")
response, status_code = ElasticWrap("_alias").put(data=data) response, status_code = ElasticWrap("_aliases").post(data=data)
if status_code not in [200, 201]: if status_code not in [200, 201]:
print(f"{status_code}: {response}") print(f"{status_code}: {response}")
raise ValueError("alias update failed") raise ValueError("alias update failed")

View File

@ -7,7 +7,7 @@ functionality:
import json import json
import os import os
from datetime import datetime from datetime import datetime
from typing import Callable, TypedDict from typing import TypedDict
from appsettings.src.config import AppConfig from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel from channel.src.index import YoutubeChannel
@ -277,7 +277,6 @@ class Reindex(ReindexBase):
def reindex_type(self, name: str, index_config: ReindexConfigType) -> None: def reindex_type(self, name: str, index_config: ReindexConfigType) -> None:
"""reindex all of a single index""" """reindex all of a single index"""
reindex = self._get_reindex_map(index_config["index_name"])
queue = RedisQueue(index_config["queue_name"]) queue = RedisQueue(index_config["queue_name"])
while True: while True:
total = queue.max_score() total = queue.max_score()
@ -288,44 +287,48 @@ class Reindex(ReindexBase):
if self.task: if self.task:
self._notify(name, total, idx) self._notify(name, total, idx)
reindex(youtube_id) index_name = index_config["index_name"]
if index_name == "ta_vide":
video = self.reindex_single_video(youtube_id)
if video:
self._reindex_video_related(video)
elif index_name == "ta_channel":
self._reindex_single_channel(channel_id=youtube_id)
elif index_name == "ta_playlist":
self._reindex_single_playlist(playlist_id=youtube_id)
rand_sleep(self.config) rand_sleep(self.config)
def _get_reindex_map(self, index_name: str) -> Callable:
"""return def to run for index"""
def_map = {
"ta_video": self._reindex_single_video,
"ta_channel": self._reindex_single_channel,
"ta_playlist": self._reindex_single_playlist,
}
return def_map[index_name]
def _notify(self, name: str, total: int, idx: int) -> None: def _notify(self, name: str, total: int, idx: int) -> None:
"""send notification back to task""" """send notification back to task"""
message = [f"Reindexing {name.title()}s {idx}/{total}"] message = [f"Reindexing {name.title()}s {idx}/{total}"]
progress = idx / total progress = idx / total
self.task.send_progress(message, progress=progress) self.task.send_progress(message, progress=progress)
def _reindex_single_video(self, youtube_id: str) -> None: def reindex_single_video(self, youtube_id: str) -> YoutubeVideo | None:
"""refresh data for single video""" """refresh data for single video"""
video = YoutubeVideo(youtube_id) video = YoutubeVideo(youtube_id)
# read current state # read current state
video.get_from_es() video.get_from_es()
if not video.json_data: if not video.json_data:
return return None
es_meta = video.json_data.copy() es_meta = video.json_data.copy()
# get new # get new
media_url = os.path.join( media_url: str | bool = os.path.join(
EnvironmentSettings.MEDIA_DIR, es_meta["media_url"] EnvironmentSettings.MEDIA_DIR, es_meta["media_url"]
) )
if not os.path.exists(media_url):
# fallback to cache path
media_url = False
video.build_json(media_path=media_url) video.build_json(media_path=media_url)
if not video.youtube_meta: if not video.youtube_meta:
video.deactivate() video.deactivate()
return return None
video.delete_subtitles(subtitles=es_meta.get("subtitles")) video.delete_subtitles(subtitles=es_meta.get("subtitles"))
video.check_subtitles() video.check_subtitles()
@ -339,15 +342,19 @@ class Reindex(ReindexBase):
video.json_data["playlist"] = es_meta.get("playlist") video.json_data["playlist"] = es_meta.get("playlist")
video.upload_to_es() video.upload_to_es()
self.processed["videos"] += 1
thumb_handler = ThumbManager(youtube_id) return video
def _reindex_video_related(self, video: YoutubeVideo) -> None:
"""reindex video related metadata and fields"""
thumb_handler = ThumbManager(video.youtube_id)
thumb_handler.delete_video_thumb() thumb_handler.delete_video_thumb()
thumb_handler.download_video_thumb(video.json_data["vid_thumb_url"]) thumb_handler.download_video_thumb(video.json_data["vid_thumb_url"])
Comments(youtube_id, config=self.config).reindex_comments() Comments(video.youtube_id, config=self.config).reindex_comments()
video.get_from_es() video.get_from_es()
video.embed_metadata() video.embed_metadata()
self.processed["videos"] += 1
def _reindex_single_channel(self, channel_id: str) -> None: def _reindex_single_channel(self, channel_id: str) -> None:
"""refresh channel data and sync to videos""" """refresh channel data and sync to videos"""

View File

@ -34,11 +34,6 @@ urlpatterns = [
views.CookieView.as_view(), views.CookieView.as_view(),
name="api-cookie", name="api-cookie",
), ),
path(
"potoken/",
views.POTokenView.as_view(),
name="api-potoken",
),
path( path(
"token/", "token/",
views.TokenView.as_view(), views.TokenView.as_view(),

View File

@ -6,7 +6,6 @@ from appsettings.serializers import (
CookieUpdateSerializer, CookieUpdateSerializer,
CookieValidationSerializer, CookieValidationSerializer,
ManualImportConfig, ManualImportConfig,
PoTokenSerializer,
RescanFileSystemConfig, RescanFileSystemConfig,
SnapshotCreateResponseSerializer, SnapshotCreateResponseSerializer,
SnapshotItemSerializer, SnapshotItemSerializer,
@ -24,7 +23,7 @@ from common.serializers import (
from common.src.ta_redis import RedisArchivist from common.src.ta_redis import RedisArchivist
from common.views_base import AdminOnly, AdminWriteOnly, ApiBaseView from common.views_base import AdminOnly, AdminWriteOnly, ApiBaseView
from django.conf import settings from django.conf import settings
from download.src.yt_dlp_base import CookieHandler, POTokenHandler from download.src.yt_dlp_base import CookieHandler
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.response import Response from rest_framework.response import Response
@ -292,73 +291,6 @@ class CookieView(ApiBaseView):
return validation return validation
class POTokenView(ApiBaseView):
"""resolves to /api/appsettings/potoken/
GET: get potoken
POST: update potoken
DELETE: revoke potoken
"""
permission_classes = [AdminOnly]
@extend_schema(
responses={
200: OpenApiResponse(PoTokenSerializer()),
404: OpenApiResponse(
ErrorResponseSerializer(), description="PO token not found"
),
}
)
def get(self, request):
"""get PO token"""
config = AppConfig().config
potoken = POTokenHandler(config).get()
if not potoken:
error = ErrorResponseSerializer({"error": "PO token not found"})
return Response(error.data, status=404)
serializer = PoTokenSerializer(data={"potoken": potoken})
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
@extend_schema(
responses={
200: OpenApiResponse(PoTokenSerializer()),
400: OpenApiResponse(
ErrorResponseSerializer(), description="Bad request"
),
}
)
def post(self, request):
"""Update PO token"""
serializer = PoTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
if not validated_data:
error = ErrorResponseSerializer(
{"error": "missing PO token key in request data"}
)
return Response(error.data, status=400)
config = AppConfig().config
new_token = validated_data["potoken"]
POTokenHandler(config).set_token(new_token)
return Response(serializer.data)
@extend_schema(
responses={
204: OpenApiResponse(description="PO token revoked"),
},
)
def delete(self, request):
"""delete PO token"""
config = AppConfig().config
POTokenHandler(config).revoke_token()
return Response(status=204)
class SnapshotApiListView(ApiBaseView): class SnapshotApiListView(ApiBaseView):
"""resolves to /api/appsettings/snapshot/ """resolves to /api/appsettings/snapshot/
GET: returns snapshot config plus list of existing snapshots GET: returns snapshot config plus list of existing snapshots

View File

@ -4,7 +4,7 @@ Functionality:
- encapsulate persistence of application properties - encapsulate persistence of application properties
""" """
from os import environ from os import environ, path
try: try:
from dotenv import load_dotenv from dotenv import load_dotenv
@ -15,6 +15,32 @@ except ModuleNotFoundError:
pass pass
def get_password_from_file(env_var_name) -> str:
"""get password from file"""
env_var_file: str = env_var_name + "_FILE"
env_var_name_val = environ.get(env_var_name)
env_var_path_val = environ.get(env_var_file)
if env_var_name_val is not None:
return str(env_var_name_val)
if env_var_path_val is None:
print(f"either {env_var_name} or {env_var_file} must be set")
return ""
is_path = path.isfile(env_var_path_val)
if not is_path:
print(f"{env_var_path_val} is not a path")
return ""
with open(env_var_path_val, "r", encoding="utf-8") as f:
file_content = f.read().strip()
return file_content
class EnvironmentSettings: class EnvironmentSettings:
""" """
Handle settings for the application that are driven from the environment. Handle settings for the application that are driven from the environment.
@ -29,7 +55,7 @@ class EnvironmentSettings:
TA_PORT: int = int(environ.get("TA_PORT", False)) TA_PORT: int = int(environ.get("TA_PORT", False))
TA_BACKEND_PORT: int = int(environ.get("TA_BACKEND_PORT", False)) TA_BACKEND_PORT: int = int(environ.get("TA_BACKEND_PORT", False))
TA_USERNAME: str = str(environ.get("TA_USERNAME")) TA_USERNAME: str = str(environ.get("TA_USERNAME"))
TA_PASSWORD: str = str(environ.get("TA_PASSWORD")) TA_PASSWORD: str = get_password_from_file("TA_PASSWORD")
# Application Paths # Application Paths
MEDIA_DIR: str = str(environ.get("TA_MEDIA_DIR", "/youtube")) MEDIA_DIR: str = str(environ.get("TA_MEDIA_DIR", "/youtube"))
@ -42,7 +68,7 @@ class EnvironmentSettings:
# ElasticSearch # ElasticSearch
ES_URL: str = str(environ.get("ES_URL")) ES_URL: str = str(environ.get("ES_URL"))
ES_PASS: str = str(environ.get("ELASTIC_PASSWORD")) ES_PASS: str = get_password_from_file("ELASTIC_PASSWORD")
ES_USER: str = str(environ.get("ELASTIC_USER", "elastic")) ES_USER: str = str(environ.get("ELASTIC_USER", "elastic"))
ES_SNAPSHOT_DIR: str = str( ES_SNAPSHOT_DIR: str = str(
environ.get( environ.get(

View File

@ -149,6 +149,7 @@ class IndexPaginate:
- task: task object to send notification - task: task object to send notification
- total: int, total items in index for progress message - total: int, total items in index for progress message
- timeout: int, overwrite timeout in get request - timeout: int, overwrite timeout in get request
- pit_keep_alive: int, overwrite pit valid
""" """
DEFAULT_SIZE = 500 DEFAULT_SIZE = 500
@ -169,7 +170,8 @@ class IndexPaginate:
def get_pit(self): def get_pit(self):
"""get pit for index""" """get pit for index"""
path = f"{self.index_name}/_pit?keep_alive=15m" keep_alive = self.kwargs.get("pit_keep_alive", 15)
path = f"{self.index_name}/_pit?keep_alive={keep_alive}m"
response, _ = ElasticWrap(path).post() response, _ = ElasticWrap(path).post()
self.pit_id = response["id"] self.pit_id = response["id"]

View File

@ -53,11 +53,11 @@ class YouTubeItem:
obs_request, self.config obs_request, self.config
).extract(url) ).extract(url)
def get_from_es(self): def get_from_es(self, print_error: bool = True) -> None:
"""get indexed data from elastic search""" """get indexed data from elastic search"""
print(f"{self.youtube_id}: get metadata from es") print(f"{self.youtube_id}: get metadata from es")
response, _ = ElasticWrap(f"{self.es_path}").get() resp, _ = ElasticWrap(f"{self.es_path}").get(print_error=print_error)
source = response.get("_source") source = resp.get("_source")
self.json_data = source self.json_data = source
def upload_to_es(self): def upload_to_es(self):

View File

@ -31,13 +31,13 @@ class SearchForm:
fulltext_results = [] fulltext_results = []
if search_results: if search_results:
for result in search_results: for result in search_results:
if result["_index"] == "ta_video": if result["_index"].startswith("ta_video"):
video_results.append(result) video_results.append(result)
elif result["_index"] == "ta_channel": elif result["_index"].startswith("ta_channel"):
channel_results.append(result) channel_results.append(result)
elif result["_index"] == "ta_playlist": elif result["_index"].startswith("ta_playlist"):
playlist_results.append(result) playlist_results.append(result)
elif result["_index"] == "ta_subtitle": elif result["_index"].startswith("ta_subtitle"):
fulltext_results.append(result) fulltext_results.append(result)
all_results = { all_results = {

View File

@ -61,6 +61,10 @@ EXPECTED_ENV_VARS = [
"ES_URL", "ES_URL",
"TA_HOST", "TA_HOST",
] ]
FILE_FALLBACK = [
"ELASTIC_PASSWORD",
"TA_PASSWORD",
]
UNEXPECTED_ENV_VARS = { UNEXPECTED_ENV_VARS = {
"TA_UWSGI_PORT": "Has been replaced with 'TA_BACKEND_PORT'", "TA_UWSGI_PORT": "Has been replaced with 'TA_BACKEND_PORT'",
"REDIS_HOST": "Has been replaced with 'REDIS_CON' connection string", "REDIS_HOST": "Has been replaced with 'REDIS_CON' connection string",
@ -139,11 +143,16 @@ class Command(BaseCommand):
self.stdout.write("[1] checking expected env vars") self.stdout.write("[1] checking expected env vars")
env = os.environ env = os.environ
for var in EXPECTED_ENV_VARS: for var in EXPECTED_ENV_VARS:
if not env.get(var): if var in env:
message = f" 🗙 expected env var {var} not set\n {INST}" continue
self.stdout.write(self.style.ERROR(message))
sleep(60) if var in FILE_FALLBACK and f"{var}_FILE" in env:
raise CommandError(message) continue
message = f" 🗙 expected env var {var} not set\n {INST}"
self.stdout.write(self.style.ERROR(message))
sleep(60)
raise CommandError(message)
message = " ✓ all expected env vars are set" message = " ✓ all expected env vars are set"
self.stdout.write(self.style.SUCCESS(message)) self.stdout.write(self.style.SUCCESS(message))

View File

@ -280,6 +280,12 @@ class Command(BaseCommand):
self.style.SUCCESS(f" added new default: {new_default}") self.style.SUCCESS(f" added new default: {new_default}")
) )
cleared = AppConfig().clear_old_keys()
for removed_key in cleared:
self.stdout.write(
self.style.SUCCESS(f" removed old key: {removed_key}")
)
return return
if status_code != 404: if status_code != 404:

View File

@ -221,7 +221,7 @@ CORS_EXPOSE_HEADERS = ["X-Start-Timestamp"]
# TA application settings # TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist" TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.5.9" TA_VERSION = "v0.5.10-unstable"
try: try:
TA_START = RedisArchivist().get_message_str("STARTTIMESTAMP") TA_START = RedisArchivist().get_message_str("STARTTIMESTAMP")
except ValueError: except ValueError:

View File

@ -42,7 +42,6 @@ class YtWrap:
deep_merge(self.obs, self.obs_request) deep_merge(self.obs, self.obs_request)
if self.config: if self.config:
self._add_cookie() self._add_cookie()
self._add_potoken()
self._add_potoken_url() self._add_potoken_url()
if getattr(settings, "DEBUG", False): if getattr(settings, "DEBUG", False):
@ -58,22 +57,6 @@ class YtWrap:
self.obs["cookiefile"] = cookie_io self.obs["cookiefile"] = cookie_io
def _add_potoken(self):
"""add potoken if enabled"""
if self.config["downloads"].get("potoken"):
potoken = POTokenHandler(self.config).get()
deep_merge(
self.obs,
{
"extractor_args": {
"youtube": {
"po_token": [potoken],
"player-client": ["mweb", "default"],
},
}
},
)
def _add_potoken_url(self): def _add_potoken_url(self):
"""add bgutils token url""" """add bgutils token url"""
if pot_provider_url := self.config["downloads"].get( if pot_provider_url := self.config["downloads"].get(
@ -248,27 +231,3 @@ class CookieHandler:
"validated_str": now.strftime("%Y-%m-%d %H:%M"), "validated_str": now.strftime("%Y-%m-%d %H:%M"),
} }
RedisArchivist().set_message("cookie:valid", message, expire=3600) RedisArchivist().set_message("cookie:valid", message, expire=3600)
class POTokenHandler:
"""handle po token"""
REDIS_KEY = "potoken"
def __init__(self, config):
self.config = config
def get(self) -> str | None:
"""get PO token"""
potoken = RedisArchivist().get_message_str(self.REDIS_KEY)
return potoken
def set_token(self, new_token: str) -> None:
"""set new PO token"""
RedisArchivist().set_message(self.REDIS_KEY, new_token)
AppConfig().update_config({"downloads": {"potoken": True}})
def revoke_token(self) -> None:
"""revoke token"""
RedisArchivist().del_message(self.REDIS_KEY)
AppConfig().update_config({"downloads": {"potoken": False}})

View File

@ -314,7 +314,10 @@ class YoutubePlaylist(YouTubeItem):
self.del_in_es() self.del_in_es()
def is_custom_playlist(self): def is_custom_playlist(self):
self.get_from_es() """check if is custom playlist"""
if not self.json_data:
self.get_from_es()
return self.json_data["playlist_type"] == "custom" return self.json_data["playlist_type"] == "custom"
def delete_videos_metadata(self, channel_id=None): def delete_videos_metadata(self, channel_id=None):
@ -392,13 +395,16 @@ class YoutubePlaylist(YouTubeItem):
return True return True
def remove_playlist_from_video(self, video_id): def remove_playlist_from_video(self, video_id):
"""remove playlist id from video metadata"""
video = ta_video.YoutubeVideo(video_id) video = ta_video.YoutubeVideo(video_id)
video.get_from_es() video.get_from_es()
if video.json_data is not None and "playlist" in video.json_data: if video.json_data is not None and "playlist" in video.json_data:
video.json_data["playlist"].remove(self.youtube_id) if self.youtube_id in video.json_data["playlist"]:
video.upload_to_es() video.json_data["playlist"].remove(self.youtube_id)
video.upload_to_es()
def move_video(self, video_id, action, hide_watched=False): def move_video(self, video_id, action, hide_watched=False):
"""move video within custion playlist based on action"""
self.get_from_es() self.get_from_es()
video_index = self.get_video_index(video_id) video_index = self.get_video_index(video_id)
playlist = self.json_data["playlist_entries"] playlist = self.json_data["playlist_entries"]

View File

@ -1,5 +1,5 @@
apprise==1.9.7 apprise==1.9.7
bgutil-ytdlp-pot-provider @ git+https://github.com/bbilly1/bgutil-ytdlp-pot-provider@c3f7bf4c99f8eb62015cb1c4142324f3d681cf07#subdirectory=plugin bgutil-ytdlp-pot-provider @ git+https://github.com/bbilly1/bgutil-ytdlp-pot-provider@47c4f8c21c6748d4522c82de0e78a2f1e38714a7#subdirectory=plugin
celery==5.6.2 celery==5.6.2
deepdiff==8.6.1 deepdiff==8.6.1
django-auth-ldap==5.3.0 django-auth-ldap==5.3.0
@ -8,9 +8,9 @@ django-cors-headers==4.9.0
Django==5.2.11 Django==5.2.11
djangorestframework==3.16.1 djangorestframework==3.16.1
drf-spectacular==0.28.0 # rc:ignore drf-spectacular==0.28.0 # rc:ignore
Pillow==12.1.0 Pillow==12.1.1
redis==7.1.0 redis==7.2.1
requests==2.32.5 requests==2.32.5
ryd-client==0.0.6 ryd-client==0.0.6
uvicorn==0.40.0 uvicorn==0.41.0
yt-dlp[default]==2026.2.4 yt-dlp[default]==2026.2.21

View File

@ -406,6 +406,10 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
self.json_data["subtitles"] = indexed self.json_data["subtitles"] = indexed
return return
if not self.youtube_meta:
print(f"{self.youtube_id}: skip subtitle check without metadata")
return
handler = YoutubeSubtitle(self) handler = YoutubeSubtitle(self)
subtitles = handler.get_subtitles() subtitles = handler.get_subtitles()
if subtitles: if subtitles:
@ -465,8 +469,10 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
video = MP4(file_path) video = MP4(file_path)
video["\xa9nam"] = [title] # title video["\xa9nam"] = [title] # title
video["\xa9ART"] = [artist] # artist video["\xa9ART"] = [artist] # artist
video["desc"] = [description] # description if description:
video["ldes"] = [description] # synopsis video["desc"] = [description] # description
video["ldes"] = [description] # synopsis
video["----:com.tubearchivist:ta"] = [to_embed.encode("utf-8")] video["----:com.tubearchivist:ta"] = [to_embed.encode("utf-8")]
video.save() video.save()
@ -508,8 +514,16 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS): def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
"""combined classes to create new video in index""" """combined classes to create new video in index"""
from appsettings.src.reindex import Reindex
video = YoutubeVideo(youtube_id, video_type=video_type) video = YoutubeVideo(youtube_id, video_type=video_type)
video.build_json() video.get_from_es(print_error=False)
if video.json_data:
# reindex only for force redownload
video = Reindex().reindex_single_video(youtube_id=youtube_id)
else:
video.build_json()
if not video.json_data: if not video.json_data:
raise ValueError("failed to get metadata for " + youtube_id) raise ValueError("failed to get metadata for " + youtube_id)

View File

@ -48,6 +48,7 @@ class MetadataEmbed:
callback=MetadataEmbedCallback, callback=MetadataEmbedCallback,
task=self.task, task=self.task,
total=self._get_total(), total=self._get_total(),
pit_keep_alive=1000,
) )
_ = paginate.get_results() _ = paginate.get_results()

View File

@ -1 +1 @@
22.12.0 22.13.0

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +0,0 @@
import APIClient from '../../functions/APIClient';
const deletePoToken = async () => {
return APIClient('/api/appsettings/potoken/', {
method: 'DELETE',
});
};
export default deletePoToken;

View File

@ -1,10 +0,0 @@
import APIClient from '../../functions/APIClient';
const updatePoToken = async (potoken: string) => {
return APIClient('/api/appsettings/potoken/', {
method: 'POST',
body: { potoken },
});
};
export default updatePoToken;

View File

@ -23,7 +23,6 @@ export type AppSettingsConfigType = {
comment_sort: string; comment_sort: string;
cookie_import: boolean; cookie_import: boolean;
pot_provider_url: string | null; pot_provider_url: string | null;
potoken: boolean;
throttledratelimit: number | null; throttledratelimit: number | null;
extractor_lang: string | null; extractor_lang: string | null;
integrate_ryd: boolean; integrate_ryd: boolean;

View File

@ -17,8 +17,6 @@ import updateCookie from '../api/actions/updateCookie';
import loadCookie, { CookieStateType } from '../api/loader/loadCookie'; import loadCookie, { CookieStateType } from '../api/loader/loadCookie';
import deleteCookie from '../api/actions/deleteCookie'; import deleteCookie from '../api/actions/deleteCookie';
import validateCookie from '../api/actions/validateCookie'; import validateCookie from '../api/actions/validateCookie';
import deletePoToken from '../api/actions/deletePoToken';
import updatePoToken from '../api/actions/updatePoToken';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
import MembershipAppsettings from '../components/MembershipAppsettings'; import MembershipAppsettings from '../components/MembershipAppsettings';
@ -71,8 +69,6 @@ const SettingsApplication = () => {
// Cookie // Cookie
const [cookieFormData, setCookieFormData] = useState<string>(''); const [cookieFormData, setCookieFormData] = useState<string>('');
const [showCookieForm, setShowCookieForm] = useState<boolean>(false); const [showCookieForm, setShowCookieForm] = useState<boolean>(false);
const [poTokenFormData, setPoTokenFormData] = useState<string>('web+');
const [showPoTokenForm, setShowPoTokenForm] = useState<boolean>(false);
const [potProviderUrl, setPotProviderUrl] = useState<string | null>(null); const [potProviderUrl, setPotProviderUrl] = useState<string | null>(null);
// Integrations // Integrations
@ -172,18 +168,6 @@ const SettingsApplication = () => {
setRefresh(true); setRefresh(true);
}; };
const handlePoTokenRevoke = async () => {
await deletePoToken();
setRefresh(true);
};
const handlePoTokenUpdate = async () => {
await updatePoToken(poTokenFormData);
setPoTokenFormData('web+');
setShowPoTokenForm(false);
setRefresh(true);
};
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
fetchData(); fetchData();
@ -695,16 +679,6 @@ const SettingsApplication = () => {
</a> </a>
. .
</li> </li>
<li>
The PO Token <i>(Proof of origin token)</i> can authenticate your request.
Make sure to read the{' '}
<a
target="_blank"
href="https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide"
>
PO guide
</a>
</li>
<li> <li>
The PO Token Provider URL running external to tubearchivist. Make sure to The PO Token Provider URL running external to tubearchivist. Make sure to
review{' '} review{' '}
@ -765,55 +739,6 @@ const SettingsApplication = () => {
)} )}
</div> </div>
</div> </div>
<div className="settings-box-wrapper">
<div>
<p>Add PO Token</p>
</div>
<div>
{response?.appSettingsConfig?.downloads.potoken ? (
<>
<p>PO Token enabled.</p>
<button onClick={handlePoTokenRevoke} className="danger-button">
Revoke
</button>
</>
) : (
<p>PO Token disabled</p>
)}
{showPoTokenForm ? (
<div>
<input
type="text"
value={poTokenFormData}
onChange={async e => {
setPoTokenFormData(e.target.value);
}}
/>
{poTokenFormData !== 'web+' && (
<button onClick={handlePoTokenUpdate}>Update</button>
)}
<button
onClick={() => {
setShowPoTokenForm(false);
setPoTokenFormData('web+');
}}
>
Cancel
</button>
</div>
) : (
<div>
<button
onClick={async () => {
setShowPoTokenForm(true);
}}
>
Update PO Token
</button>
</div>
)}
</div>
</div>
<div className="settings-box-wrapper"> <div className="settings-box-wrapper">
<div> <div>
<p>PO Token Provider URL</p> <p>PO Token Provider URL</p>

View File

@ -30,7 +30,6 @@ export const useAppSettingsStore = create<AppSettingsState>(set => ({
comment_sort: 'asc', comment_sort: 'asc',
cookie_import: false, cookie_import: false,
pot_provider_url: null, pot_provider_url: null,
potoken: false,
throttledratelimit: null, throttledratelimit: null,
extractor_lang: null, extractor_lang: null,
integrate_ryd: false, integrate_ryd: false,

View File

@ -2,8 +2,8 @@
ipython==9.10.0 ipython==9.10.0
pre-commit==4.5.1 pre-commit==4.5.1
pylint-django==2.7.0 pylint-django==2.7.0
pylint==4.0.4 pylint==4.0.5
pytest-django==4.11.1 pytest-django==4.12.0
pytest==9.0.2 pytest==9.0.2
python-dotenv==1.2.1 python-dotenv==1.2.1
requirementscheck==0.1.0 requirementscheck==0.1.0