Implement restore from embed, #build

Changed:
- Added restore from embedded metadata
- Extend and error handling for embedding
- Embed artwork
- Add error handling for rescan filesystem and manual import
This commit is contained in:
Simon 2025-12-26 21:32:30 +07:00
commit b97a990237
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
36 changed files with 1580 additions and 592 deletions

View File

@ -100,6 +100,20 @@ class PoTokenSerializer(serializers.Serializer):
potoken = serializers.CharField()
class RescanFileSystemConfig(serializers.Serializer):
"""serialize rescan filesystem config"""
ignore_error = serializers.BooleanField()
prefer_local = serializers.BooleanField()
class ManualImportConfig(serializers.Serializer):
"""serialize for manual import task"""
ignore_error = serializers.BooleanField()
prefer_local = serializers.BooleanField()
class SnapshotItemSerializer(serializers.Serializer):
"""serialize snapshot response"""

View File

@ -5,11 +5,13 @@ Functionality:
import os
from appsettings.src.config import AppConfig
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import IndexPaginate
from common.src.helper import ignore_filelist
from video.src.comments import CommentList
from common.src.helper import ignore_filelist, rand_sleep
from video.src.comments import Comments
from video.src.index import YoutubeVideo, index_new_video
from video.src.meta_embed import IndexFromEmbed
class Scanner:
@ -17,19 +19,27 @@ class Scanner:
VIDEOS: str = EnvironmentSettings.MEDIA_DIR
def __init__(self, task=False) -> None:
def __init__(
self,
task=False,
ignore_error: bool = False,
prefer_local: bool = False,
) -> None:
self.task = task
self.to_delete: set[str] = set()
self.to_index: set[str] = set()
self.ignore_error = ignore_error
self.prefer_local = prefer_local
self.config = None
self.to_delete: set[tuple[str, str]] = set()
self.to_index: set[tuple[str, str]] = set()
def scan(self) -> None:
"""scan the filesystem"""
downloaded: set[str] = self._get_downloaded()
indexed: set[str] = self._get_indexed()
downloaded = self._get_downloaded()
indexed = self._get_indexed()
self.to_index = downloaded - indexed
self.to_delete = indexed - downloaded
def _get_downloaded(self) -> set[str]:
def _get_downloaded(self) -> set[tuple[str, str]]:
"""get downloaded ids"""
if self.task:
self.task.send_progress(["Scan your filesystem for videos."])
@ -39,28 +49,40 @@ class Scanner:
for channel in channels:
folder = os.path.join(self.VIDEOS, channel)
files = ignore_filelist(os.listdir(folder))
downloaded.update({i.split(".")[0] for i in files})
downloaded.update(
{
(i.split(".")[0], f"{channel}/{i}")
for i in files
if i.endswith(".mp4")
}
)
return downloaded
def _get_indexed(self) -> set:
def _get_indexed(self) -> set[tuple[str, str]]:
"""get all indexed ids"""
if self.task:
self.task.send_progress(["Get all videos indexed."])
data = {"query": {"match_all": {}}, "_source": ["youtube_id"]}
data = {
"query": {"match_all": {}},
"_source": ["youtube_id", "media_url"],
}
response = IndexPaginate("ta_video", data).get_results()
return {i["youtube_id"] for i in response}
return {(i["youtube_id"], i["media_url"]) for i in response}
def apply(self) -> None:
"""apply all changes"""
if not self.config:
self.config = AppConfig().config
self.delete()
self.index()
def delete(self) -> None:
"""delete videos from index"""
if not self.to_delete:
print("nothing to delete")
print("[scanner] nothing to delete")
return
if self.task:
@ -68,26 +90,69 @@ class Scanner:
[f"Remove {len(self.to_delete)} videos from index."]
)
for youtube_id in self.to_delete:
for youtube_id, _ in self.to_delete:
YoutubeVideo(youtube_id).delete_media_file()
def index(self) -> None:
"""index new"""
if not self.to_index:
print("nothing to index")
print("[scanner] nothing to index")
return
total = len(self.to_index)
for idx, youtube_id in enumerate(self.to_index):
if self.task:
self.task.send_progress(
message_lines=[
f"Index missing video {youtube_id}, {idx + 1}/{total}"
],
progress=(idx + 1) / total,
)
index_new_video(youtube_id)
for idx, (youtube_id, media_url) in enumerate(self.to_index):
self._notify(total, youtube_id, idx)
comment_list = CommentList(task=self.task)
comment_list.add(video_ids=list(self.to_index))
comment_list.index()
file_path = os.path.join(self.VIDEOS, media_url)
if self.prefer_local:
# try index from embed
json_data = IndexFromEmbed(
file_path, use_user_conf=True, config=self.config
).run_index()
if json_data:
continue
try:
# try index from remote
json_data = index_new_video(youtube_id)
Comments(youtube_id).build_json(upload=True)
YoutubeVideo(youtube_id).embed_metadata()
rand_sleep(self.config)
except ValueError as err:
# fallback from index from embed
json_data = IndexFromEmbed(
file_path, use_user_conf=True, config=self.config
).run_index()
if json_data:
continue
if self.ignore_error:
self._notify_error(youtube_id)
rand_sleep(self.config)
continue
raise ValueError from err
def _notify(self, total, youtube_id, idx):
"""send notification"""
if not self.task:
return
self.task.send_progress(
message_lines=[
f"Index missing video {youtube_id}, {idx + 1}/{total}"
],
progress=(idx + 1) / total,
)
def _notify_error(self, youtube_id):
"""notify error"""
if not self.task:
return
message = f"[scanner] Failed to index {youtube_id}, no metadata"
print(f"[scanner] {message}")
self.task.send_progress(
message_lines=[message, "Continue..."],
level="error",
)

View File

@ -16,8 +16,9 @@ from common.src.env_settings import EnvironmentSettings
from common.src.helper import ignore_filelist
from download.src.thumbnails import ThumbManager
from PIL import Image
from video.src.comments import CommentList
from video.src.comments import Comments
from video.src.index import YoutubeVideo
from video.src.meta_embed import IndexFromEmbed
from yt_dlp.utils import ISO639Utils
@ -41,9 +42,16 @@ class ImportFolderScanner:
"subtitle": [".vtt"],
}
def __init__(self, task=False):
def __init__(
self,
task=False,
ignore_error: bool = False,
prefer_local: bool = False,
):
self.task = task
self.to_import = False
self.ignore_error = ignore_error
self.prefer_local = prefer_local
def scan(self):
"""scan and match media files"""
@ -142,14 +150,14 @@ class ImportFolderScanner:
self._convert_thumb(current_video)
self._get_subtitles(current_video)
self._convert_video(current_video)
print(f"manual import: {current_video}")
ManualImport(current_video, config).run()
video_ids = [i["video_id"] for i in self.to_import]
comment_list = CommentList(task=self.task)
comment_list.add(video_ids=video_ids)
comment_list.index()
ManualImport(
current_video,
config,
ignore_error=self.ignore_error,
prefer_local=self.prefer_local,
).run()
def _notify(self, idx, current_video):
"""send notification back to task"""
@ -185,17 +193,27 @@ class ImportFolderScanner:
expects filename ending in [<youtube_id>].<ext>
"""
base_name, _ = os.path.splitext(file_name)
# yt-dlp default like [youtubeid]
id_search = re.search(r"\[([a-zA-Z0-9_-]{11})\]$", base_name)
if id_search:
youtube_id = id_search.group(1)
return youtube_id
file_name_search = re.search(r"([a-zA-Z0-9_-]{11})$", base_name)
if file_name_search:
youtube_id = file_name_search.group(1)
return youtube_id
print(f"id extraction failed from filename: {file_name}")
return False
def _extract_id_from_json(self, json_file):
def _extract_id_from_json(self, json_file: str | bool) -> str | None:
"""open json file and extract id"""
if not json_file or not isinstance(json_file, str):
return None
json_path = os.path.join(self.CACHE_DIR, "import", json_file)
with open(json_path, "r", encoding="utf-8") as f:
json_content = f.read()
@ -388,17 +406,49 @@ class ImportFolderScanner:
class ManualImport:
"""import single identified video"""
def __init__(self, current_video, config):
def __init__(
self, current_video, config, ignore_error: bool, prefer_local: bool
):
self.current_video = current_video
self.config = config
self.ignore_error: bool = ignore_error
self.prefer_local: bool = prefer_local
def run(self):
"""run all"""
json_data = self.index_metadata()
self._move_to_archive(json_data)
self._cleanup(json_data)
json_data = None
if self.prefer_local:
# embedded first
json_data = IndexFromEmbed(
self.current_video["media"],
use_user_conf=False,
config=self.config,
).run_index()
if json_data:
self._cleanup()
return
def index_metadata(self):
try:
json_data = self.index_metadata()
except ValueError as err:
json_data = IndexFromEmbed(
self.current_video["media"],
use_user_conf=False,
config=self.config,
).run_index()
if not json_data and not self.ignore_error:
raise ValueError from err
if not json_data:
return
self._move_to_archive(json_data)
self._cleanup()
Comments(self.current_video["video_id"]).build_json(upload=True)
YoutubeVideo(self.current_video["video_id"]).embed_metadata()
def index_metadata(self) -> dict | None:
"""get metadata from yt or json"""
video_id = self.current_video["video_id"]
video = YoutubeVideo(video_id)
@ -411,6 +461,9 @@ class ManualImport:
f"{video_id}: manual import failed, and no metadata found."
)
print(message)
if self.ignore_error:
return None
raise ValueError(message)
video.check_subtitles(subtitle_files=self.current_video["subtitle"])
@ -463,7 +516,7 @@ class ManualImport:
new_path = f"{base_name}.{lang}.vtt"
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
def _cleanup(self, json_data):
def _cleanup(self):
"""cleanup leftover files"""
meta_data = self.current_video["metadata"]
if meta_data and os.path.exists(meta_data):
@ -476,11 +529,3 @@ class ManualImport:
for subtitle_file in self.current_video["subtitle"]:
if os.path.exists(subtitle_file):
os.remove(subtitle_file)
channel_info = os.path.join(
EnvironmentSettings.CACHE_DIR,
"import",
f"{json_data['channel']['channel_id']}.info.json",
)
if os.path.exists(channel_info):
os.remove(channel_info)

View File

@ -345,6 +345,7 @@ class Reindex(ReindexBase):
thumb_handler.download_video_thumb(video.json_data["vid_thumb_url"])
Comments(youtube_id, config=self.config).reindex_comments()
video.get_from_es()
video.embed_metadata()
self.processed["videos"] += 1

View File

@ -44,6 +44,16 @@ urlpatterns = [
views.TokenView.as_view(),
name="api-token",
),
path(
"rescan-filesystem/",
views.RescanFileSystem.as_view(),
name="api-rescan-filesystem",
),
path(
"manual-import/",
views.ManualImportView.as_view(),
name="api-manual-import",
),
path(
"membership/profile/",
views_mb.MembershipProfileView.as_view(),

View File

@ -5,7 +5,9 @@ from appsettings.serializers import (
BackupFileSerializer,
CookieUpdateSerializer,
CookieValidationSerializer,
ManualImportConfig,
PoTokenSerializer,
RescanFileSystemConfig,
SnapshotCreateResponseSerializer,
SnapshotItemSerializer,
SnapshotListSerializer,
@ -291,7 +293,11 @@ class CookieView(ApiBaseView):
class POTokenView(ApiBaseView):
"""handle PO token"""
"""resolves to /api/appsettings/potoken/
GET: get potoken
POST: update potoken
DELETE: revoke potoken
"""
permission_classes = [AdminOnly]
@ -388,6 +394,58 @@ class SnapshotApiListView(ApiBaseView):
return Response(serializer.data)
class RescanFileSystem(ApiBaseView):
"""resolves to /api/appsettings/rescan-filesystem/
POST: start new rescan filesystem task
"""
permission_classes = [AdminOnly]
@staticmethod
@extend_schema(
request=RescanFileSystemConfig,
responses={
200: OpenApiResponse(AsyncTaskResponseSerializer()),
},
)
def post(request):
"""start new task rescan filesystem task"""
data_serializer = RescanFileSystemConfig(data=request.data)
data_serializer.is_valid(raise_exception=True)
validated_data = data_serializer.validated_data
message = TaskCommand().start("rescan_filesystem", validated_data)
serializer = AsyncTaskResponseSerializer(message)
return Response(serializer.data)
class ManualImportView(ApiBaseView):
"""resolves to /api/appsettings/manual-import/
POST: start new manual import task
"""
permission_classes = [AdminOnly]
@staticmethod
@extend_schema(
request=ManualImportConfig,
responses={
200: OpenApiResponse(AsyncTaskResponseSerializer()),
},
)
def post(request):
"""manual import"""
data_serializer = ManualImportConfig(data=request.data)
data_serializer.is_valid(raise_exception=True)
validated_data = data_serializer.validated_data
message = TaskCommand().start("manual_import", validated_data)
serializer = AsyncTaskResponseSerializer(message)
return Response(serializer.data)
class SnapshotApiView(ApiBaseView):
"""resolves to /api/appsettings/snapshot/<snapshot-id>/
GET: return a single snapshot

View File

@ -110,6 +110,8 @@ def date_parser(timestamp: int | str | None) -> str | None:
if isinstance(timestamp, int):
date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc)
elif isinstance(timestamp, str) and timestamp.isdigit():
date_obj = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
elif isinstance(timestamp, str):
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
date_obj = date_obj.replace(tzinfo=timezone.utc)

View File

@ -113,6 +113,7 @@ class SearchParser:
"index": "ta_subtitle",
"lang": [],
"source": [],
"channel": [],
},
}
@ -345,6 +346,22 @@ class QueryBuilder:
"""build query for fulltext search"""
must_list = []
if (channel := self.query_map.get("channel")) is not None:
must_list.append(
{
"multi_match": {
"query": channel,
"type": "bool_prefix",
"fuzziness": self._get_fuzzy(),
"operator": "and",
"fields": [
"subtitle_channel",
"subtitle_channel.keyword",
],
}
}
)
if (term := self.query_map.get("term")) is not None:
must_list.append(
{

View File

@ -26,6 +26,13 @@ def test_date_parser_with_int():
assert date_parser(timestamp) == expected_date
def test_date_parser_with_digit():
"""unix timestamp"""
timestamp = "1621539600"
expected_date = "2021-05-20T19:40:00+00:00"
assert date_parser(timestamp) == expected_date
def test_date_parser_with_str():
"""iso timestamp"""
date_str = "2021-05-21"

View File

@ -10,13 +10,15 @@ from video.src.constants import VideoTypeEnum
class DownloadItemSerializer(serializers.Serializer):
"""serialize download item"""
auto_start = serializers.BooleanField()
auto_start = serializers.BooleanField(required=False)
channel_id = serializers.CharField()
channel_indexed = serializers.BooleanField()
channel_name = serializers.CharField()
duration = serializers.CharField()
published = serializers.CharField(allow_null=True)
status = serializers.ChoiceField(choices=["pending", "ignore"])
status = serializers.ChoiceField(
choices=["pending", "ignore"], required=False
)
timestamp = serializers.IntegerField(allow_null=True)
title = serializers.CharField()
vid_thumb_url = serializers.CharField(allow_null=True)

View File

@ -20,6 +20,7 @@ from common.src.helper import (
rand_sleep,
)
from common.src.urlparser import ParsedURLType
from download.serializers import DownloadItemSerializer
from download.src.queue_interact import PendingInteract
from download.src.thumbnails import ThumbManager
from playlist.src.index import YoutubePlaylist
@ -379,6 +380,12 @@ class PendingList(PendingIndex):
"channel_id": video_data["channel_id"],
"channel_indexed": video_data["channel_id"] in self.all_channels,
}
serializer = DownloadItemSerializer(data=to_add)
is_valid = serializer.is_valid()
if not is_valid:
print(f"{youtube_id}: serializer failed: {serializer.errors}")
self._notify_fail(403, youtube_id)
return None
return to_add
@ -393,18 +400,24 @@ class PendingList(PendingIndex):
return None
@staticmethod
def _extract_published(video_data) -> str | int | None:
def _extract_published(video_data) -> int | None:
"""build published date or timestamp"""
timestamp = video_data.get("timestamp")
if timestamp:
if timestamp and isinstance(timestamp, int):
return timestamp
upload_date = video_data.get("upload_date")
if upload_date:
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
return upload_date_time.replace(
tzinfo=ZoneInfo(EnvironmentSettings.TZ)
).timestamp()
try:
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
except ValueError:
youtube_id = video_data["id"]
print(f"{youtube_id}: published date extraction failed.")
return None
tz = ZoneInfo(EnvironmentSettings.TZ)
timestamp = int(upload_date_time.replace(tzinfo=tz).timestamp())
return timestamp
return None

View File

@ -4,9 +4,7 @@ functionality:
- check for missing thumbnails
"""
import base64
import os
from io import BytesIO
from time import sleep
import requests
@ -14,7 +12,7 @@ from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import is_missing
from mutagen.mp4 import MP4, MP4Cover
from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError
from PIL import Image, ImageFile, UnidentifiedImageError
ImageFile.LOAD_TRUNCATED_IMAGES = True
@ -23,6 +21,7 @@ class ThumbManagerBase:
"""base class for thumbnail management"""
CACHE_DIR = EnvironmentSettings.CACHE_DIR
MEDIA_DIR = EnvironmentSettings.MEDIA_DIR
VIDEO_DIR = os.path.join(CACHE_DIR, "videos")
CHANNEL_DIR = os.path.join(CACHE_DIR, "channels")
PLAYLIST_DIR = os.path.join(CACHE_DIR, "playlists")
@ -217,6 +216,56 @@ class ThumbManager(ThumbManagerBase):
img_raw = img_raw.resize((336, 189))
img_raw.convert("RGB").save(thumb_path)
def embed_video_art(self, json_data: dict):
"""embed video artwork"""
file_path = os.path.join(self.MEDIA_DIR, json_data["media_url"])
video = MP4(file_path)
thumb_path = self.vid_thumb_path(absolute=True)
if os.path.exists(thumb_path):
with open(thumb_path, "rb") as f:
cover_data = f.read()
video["covr"] = [
MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)
]
channel_id = json_data["channel"]["channel_id"]
banner_path = os.path.join(
self.CHANNEL_DIR, f"{channel_id}_banner.jpg"
)
if os.path.exists(banner_path):
with open(banner_path, "rb") as f:
banner_data = f.read()
video["----:com.tubearchivist:channel_banner"] = [
MP4Cover(banner_data, imageformat=MP4Cover.FORMAT_JPEG)
]
channel_icon_path = os.path.join(
self.CHANNEL_DIR, f"{channel_id}_thumb.jpg"
)
if os.path.exists(channel_icon_path):
with open(channel_icon_path, "rb") as f:
icon_data = f.read()
video["----:com.tubearchivist:channel_icon"] = [
MP4Cover(icon_data, imageformat=MP4Cover.FORMAT_JPEG)
]
channel_tv_path = os.path.join(
self.CHANNEL_DIR, f"{channel_id}_tvart.jpg"
)
if os.path.exists(channel_tv_path):
with open(channel_tv_path, "rb") as f:
tv_data = f.read()
video["----:com.tubearchivist:channel_tv"] = [
MP4Cover(tv_data, imageformat=MP4Cover.FORMAT_JPEG)
]
video.save()
def delete_video_thumb(self):
"""delete video thumbnail if exists"""
thumb_path = self.vid_thumb_path()
@ -228,10 +277,13 @@ class ThumbManager(ThumbManagerBase):
"""delete all artwork of channel"""
thumb = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_thumb.jpg")
banner = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_banner.jpg")
tv = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_tvart.jpg")
if os.path.exists(thumb):
os.remove(thumb)
if os.path.exists(banner):
os.remove(banner)
if os.path.exists(tv):
os.remove(tv)
def delete_playlist_thumb(self):
"""delete playlist thumbnail"""
@ -239,20 +291,6 @@ class ThumbManager(ThumbManagerBase):
if os.path.exists(thumb_path):
os.remove(thumb_path)
def get_vid_base64_blur(self):
"""return base64 encoded placeholder"""
file_path = os.path.join(self.CACHE_DIR, self.vid_thumb_path())
img_raw = Image.open(file_path)
img_raw.thumbnail((img_raw.width // 20, img_raw.height // 20))
img_blur = img_raw.filter(ImageFilter.BLUR)
buffer = BytesIO()
img_blur.save(buffer, format="JPEG")
img_data = buffer.getvalue()
img_base64 = base64.b64encode(img_data).decode()
data_url = f"data:image/jpg;base64,{img_base64}"
return data_url
class ValidatorCallback:
"""handle callback validate thumbnails page by page"""
@ -458,7 +496,7 @@ class ThumbFilesystem:
"""entry point"""
data = {
"query": {"match_all": {}},
"_source": ["media_url", "youtube_id"],
"_source": ["media_url", "youtube_id", "channel.channel_id"],
}
paginate = IndexPaginate(
index_name=self.INDEX_NAME,
@ -481,9 +519,7 @@ class ThumbFilesystem:
class EmbedCallback:
"""callback class to embed thumbnails"""
CACHE_DIR = EnvironmentSettings.CACHE_DIR
MEDIA_DIR = EnvironmentSettings.MEDIA_DIR
FORMAT = MP4Cover.FORMAT_JPEG
def __init__(self, source, index_name, counter=0):
self.source = source
@ -494,19 +530,4 @@ class EmbedCallback:
"""run embed"""
for video in self.source:
video_id = video["_source"]["youtube_id"]
media_url = os.path.join(
self.MEDIA_DIR, video["_source"]["media_url"]
)
thumb_path = os.path.join(
self.CACHE_DIR, ThumbManager(video_id).vid_thumb_path()
)
if os.path.exists(thumb_path):
self.embed(media_url, thumb_path)
def embed(self, media_url, thumb_path):
"""embed thumb in single media file"""
video = MP4(media_url)
with open(thumb_path, "rb") as f:
video["covr"] = [MP4Cover(f.read(), imageformat=self.FORMAT)]
video.save()
ThumbManager(video_id).embed_video_art(video["_source"])

View File

@ -196,15 +196,6 @@ class VideoDownloader(DownloaderBase):
}
)
if self.config["downloads"]["add_thumbnail"]:
postprocessors.append(
{
"key": "EmbedThumbnail",
"already_have_thumbnail": True,
}
)
self.obs["writethumbnail"] = True
self.obs["postprocessors"] = postprocessors
def _set_overwrites(self, obs: dict, channel_id: str) -> None:
@ -326,6 +317,9 @@ class DownloadPostProcess(DownloaderBase):
for channel_id, value in self.channel_overwrites.items():
if "autodelete_days" in value:
autodelete_days = value.get("autodelete_days")
if autodelete_days is None:
continue
print(f"{channel_id}: delete older than {autodelete_days}d")
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
must_list = [
@ -488,7 +482,10 @@ class DownloadPostProcess(DownloaderBase):
def embed_metadata(self):
"""embed metadata in media file"""
if not self.config["downloads"].get("add_metadata"):
meta = self.config["downloads"].get("add_metadata")
thumb = self.config["downloads"].get("add_thumbnail")
if not meta and not thumb:
return
queue = RedisQueue(self.VIDEO_QUEUE)

View File

@ -1,14 +1,14 @@
apprise==1.9.5
celery==5.5.3
apprise==1.9.6
celery==5.6.0
django-auth-ldap==5.2.0
django-celery-beat==2.8.1
django-cors-headers==4.9.0
Django==5.2.8
Django==5.2.9
djangorestframework==3.16.1
drf-spectacular==0.29.0
drf-spectacular==0.28.0 # rc:ignore
Pillow==12.0.0
redis==7.0.1
redis==7.1.0
requests==2.32.5
ryd-client==0.0.6
uvicorn==0.38.0
yt-dlp[default]==2025.11.12
yt-dlp[default]==2025.12.8

View File

@ -48,7 +48,7 @@ CHECK_REINDEX: TaskItemConfig = {
MANUAL_IMPORT: TaskItemConfig = {
"title": "Manual video import",
"group": "setting:import",
"api_start": True,
"api_start": False,
"api_stop": False,
}
@ -69,7 +69,7 @@ RESTORE_BACKUP: TaskItemConfig = {
RESCAN_FILESYSTEM: TaskItemConfig = {
"title": "Rescan your Filesystem",
"group": "setting:filesystemscan",
"api_start": True,
"api_start": False,
"api_stop": False,
}

View File

@ -84,9 +84,9 @@ class TaskManager:
class TaskCommand:
"""run commands on task"""
def start(self, task_name):
def start(self, task_name, kwargs: dict | None = None):
"""start task by task_name, only pass task that don't take args"""
task = celery_app.tasks.get(task_name).delay()
task = celery_app.tasks.get(task_name).delay(**(kwargs or {}))
message = {
"task_id": task.id,
"status": task.status,

View File

@ -216,7 +216,7 @@ def check_reindex(self, data=False, extract_videos=False):
@shared_task(bind=True, name="manual_import", base=BaseTask)
def run_manual_import(self):
def manual_import(self, ignore_error, prefer_local):
"""called from settings page, to go through import folder"""
manager = TaskManager()
if manager.is_pending(self):
@ -225,7 +225,9 @@ def run_manual_import(self):
return
manager.init(self)
ImportFolderScanner(task=self).scan()
ImportFolderScanner(
task=self, ignore_error=ignore_error, prefer_local=prefer_local
).scan()
@shared_task(bind=True, name="run_backup", base=BaseTask)
@ -260,7 +262,7 @@ def run_restore_backup(self, filename):
@shared_task(bind=True, name="rescan_filesystem", base=BaseTask)
def rescan_filesystem(self):
def rescan_filesystem(self, ignore_error, prefer_local):
"""check the media folder for mismatches"""
manager = TaskManager()
if manager.is_pending(self):
@ -269,7 +271,9 @@ def rescan_filesystem(self):
return
manager.init(self)
handler = Scanner(task=self)
handler = Scanner(
task=self, ignore_error=ignore_error, prefer_local=prefer_local
)
handler.scan()
handler.apply()
thumbnail_check.delay()

View File

@ -60,6 +60,23 @@ class StreamItemSerializer(serializers.Serializer):
height = serializers.IntegerField(required=False)
class SubtitleFragmentSerializer(serializers.Serializer):
"""serialize subtitle fragment"""
subtitle_index = serializers.IntegerField()
subtitle_line = serializers.CharField()
subtitle_start = serializers.CharField()
subtitle_fragment_id = serializers.CharField()
subtitle_end = serializers.CharField()
youtube_id = serializers.CharField()
title = serializers.CharField()
subtitle_channel = serializers.CharField()
subtitle_channel_id = serializers.CharField()
subtitle_last_refresh = serializers.IntegerField()
subtitle_lang = serializers.CharField()
subtitle_source = serializers.ChoiceField(choices=["user", "auto"])
class SubtitleItemSerializer(serializers.Serializer):
"""serialize subtitle item"""
@ -76,16 +93,18 @@ class VideoSerializer(serializers.Serializer):
active = serializers.BooleanField()
category = serializers.ListField(child=serializers.CharField())
channel = ChannelSerializer()
comment_count = serializers.IntegerField(allow_null=True)
channel = ChannelSerializer(required=False)
comment_count = serializers.IntegerField(allow_null=True, required=False)
date_downloaded = serializers.IntegerField()
description = serializers.CharField()
media_size = serializers.IntegerField()
media_url = serializers.CharField()
player = PlayerSerializer()
playlist = serializers.ListField(child=serializers.CharField())
playlist = serializers.ListField(
child=serializers.CharField(), required=False
)
published = serializers.CharField()
sponsorblock = SponsorBlockSerializer(allow_null=True)
sponsorblock = SponsorBlockSerializer(allow_null=True, required=False)
stats = StatsSerializer()
streams = StreamItemSerializer(many=True)
subtitles = SubtitleItemSerializer(many=True)
@ -153,7 +172,16 @@ class CommentItemSerializer(serializers.Serializer):
comment_author_thumbnail = serializers.URLField()
comment_author_is_uploader = serializers.BooleanField()
comment_parent = serializers.CharField()
comment_replies = CommentThreadItemSerializer(many=True)
comment_replies = CommentThreadItemSerializer(many=True, required=False)
class CommentsSerializer(serializers.Serializer):
"""serialize comments as indexed"""
youtube_id = serializers.CharField()
comment_last_refresh = serializers.IntegerField()
comment_channel_id = serializers.CharField()
comment_comments = CommentItemSerializer(many=True)
class PlaylistNavMetaSerializer(serializers.Serializer):

View File

@ -25,7 +25,7 @@ class Comments:
self.is_activated = False
self.comments_format = False
def build_json(self):
def build_json(self, upload: bool = False):
"""build json document for es"""
print(f"{self.youtube_id}: get comments")
self.check_config()
@ -44,6 +44,8 @@ class Comments:
"comment_channel_id": channel_id,
"comment_comments": self.comments_format,
}
if upload:
self.upload_comments()
def check_config(self):
"""read config if not attached"""
@ -142,14 +144,13 @@ class Comments:
def upload_comments(self):
"""upload comments to es"""
if not self.is_activated:
return
print(f"{self.youtube_id}: upload comments")
_, _ = ElasticWrap(self.es_path).put(self.json_data)
vid_path = f"ta_video/_update/{self.youtube_id}"
data = {"doc": {"comment_count": len(self.comments_format)}}
data = {
"doc": {"comment_count": len(self.json_data["comment_comments"])}
}
_, _ = ElasticWrap(vid_path).post(data=data)
def delete_comments(self):

View File

@ -220,7 +220,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
def _build_published(self):
"""build published date or timestamp"""
timestamp = self.youtube_meta.get("timestamp")
if timestamp:
if timestamp and isinstance(timestamp, int):
return timestamp
upload_date = self.youtube_meta["upload_date"]
@ -424,10 +424,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
def embed_metadata(self):
"""embed metadata for video"""
if not self.config["downloads"].get("add_metadata"):
return
print(f"{self.youtube_id}: embed metadata")
if not self.json_data:
self.get_from_es()
@ -435,6 +431,15 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
print(f"{self.youtube_id}: skip embed, video not indexed")
return
if self.config["downloads"].get("add_metadata"):
self._embed_text_data()
if self.config["downloads"].get("add_thumbnail"):
self._embed_artwork()
def _embed_text_data(self):
"""embed text metadata"""
print(f"{self.youtube_id}: embed metadata")
video_base = EnvironmentSettings.MEDIA_DIR
media_url = self.json_data.get("media_url")
file_path = os.path.join(video_base, media_url)
@ -479,11 +484,17 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
"comments": comments,
"subtitles": subtitles,
"playlists": playlists,
"version": settings.TA_VERSION,
}
)
return to_embed
def _embed_artwork(self):
"""embed artwork"""
print(f"{self.youtube_id}: embed artwork")
ThumbManager(self.youtube_id).embed_video_art(self.json_data)
def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
"""combined classes to create new video in index"""

View File

@ -1,7 +1,28 @@
"""bulk metadata embedding"""
"""
Functionality:
- bulk metadata embedding
- restore from embedded metadata
"""
import json
import os
import shutil
from appsettings.src.config import AppConfig, AppConfigType
from channel.serializers import ChannelSerializer
from channel.src.index import YoutubeChannel
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from download.src.thumbnails import ThumbManager
from mutagen.mp4 import MP4, MP4FreeForm
from video.serializers import (
CommentsSerializer,
SubtitleFragmentSerializer,
VideoSerializer,
)
from video.src.comments import Comments
from video.src.index import YoutubeVideo
from video.src.subtitle import SubtitleParser, YoutubeSubtitle
class MetadataEmbed:
@ -49,3 +70,302 @@ class MetadataEmbedCallback:
for video in self.source:
youtube_id = video["_source"]["youtube_id"]
YoutubeVideo(youtube_id).embed_metadata()
class IndexFromEmbed:
"""restore from embedded metadata, potential untrusted"""
VIDEOS_BASE = EnvironmentSettings.MEDIA_DIR
CACHE_DIR = EnvironmentSettings.CACHE_DIR
HOST_UID = EnvironmentSettings.HOST_UID
HOST_GID = EnvironmentSettings.HOST_GID
def __init__(
self,
file_path: str,
use_user_conf: bool = True,
config: AppConfigType | None = None,
):
self.file_path = file_path
self.use_user_conf = use_user_conf
self.config = config
def run_index(self) -> None | dict:
"""run index"""
if not self.config:
self.config = AppConfig().config
json_embed = self._get_embedded()
if not json_embed:
return None
channel_data_clean = self.index_channel(json_embed)
video = self.index_video(json_embed, channel_data_clean)
self.archive_video(video)
self.index_subtitles(json_embed, video)
self.index_comments(json_embed)
return video.json_data
def _get_embedded(self) -> dict | None:
"""get embedded metadata"""
video_mutagen = MP4(self.file_path)
ta_data = video_mutagen.get("----:com.tubearchivist:ta")
if not ta_data:
return None
if not isinstance(ta_data, list):
raise ValueError(f"[{self.file_path}] unexpected embedded data")
to_decode = ta_data[0]
if not isinstance(to_decode, MP4FreeForm):
raise ValueError(f"[{self.file_path}] unexpected embedded data")
try:
json_embed = json.loads(to_decode.decode())
except Exception as exc: # pylint: disable=broad-exception-caught
err = f"[{self.file_path}] embedded decoding failed: {str(exc)}"
raise ValueError(err) from exc
if not json_embed.get("video"):
err = f"[{self.file_path}] embedded does not contain video key"
raise ValueError(err)
return json_embed
def index_channel(self, json_embed):
"""index channel"""
channel_data = json_embed["video"].get("channel")
if not channel_data:
raise ValueError(f"[{self.file_path}] missing channel metadata")
serializer = ChannelSerializer(data=channel_data)
is_valid = serializer.is_valid()
if not is_valid:
err = serializer.errors
raise ValueError(
f"[{self.file_path}] channel serializer failed: {err}"
)
channel_data_clean = dict(serializer.data)
if not self.use_user_conf:
if "channel_overwrites" in channel_data_clean:
channel_data_clean.pop("channel_overwrites")
channel_data_clean["channel_subscribed"] = False
channel = YoutubeChannel(youtube_id=channel_data_clean["channel_id"])
channel.get_from_es()
if not channel.json_data:
channel.json_data = channel_data_clean
channel.upload_to_es()
return channel.json_data
def index_video(self, json_embed, channel_data_clean):
"""index video"""
video_data = json_embed["video"]
video_data.pop("channel")
serializer = VideoSerializer(data=video_data)
is_valid = serializer.is_valid()
if not is_valid:
err = serializer.errors
raise ValueError(
f"[{self.file_path}] video serializer failed: {err}"
)
video_data_clean = dict(serializer.data)
if not self.use_user_conf:
video_data_clean["player"]["watched"] = False
video_data_clean["channel"] = channel_data_clean
video = YoutubeVideo(youtube_id=video_data_clean["youtube_id"])
video.get_from_es()
if not video.json_data:
video.json_data = video_data_clean
video.upload_to_es()
return video
def archive_video(self, video):
"""archive video file"""
channel_id = video.json_data["channel"]["channel_id"]
folder = os.path.join(self.VIDEOS_BASE, channel_id)
if not os.path.exists(folder):
os.makedirs(folder)
if self.HOST_UID and self.HOST_GID:
os.chown(folder, self.HOST_UID, self.HOST_GID)
new_path = os.path.join(folder, f"{video.youtube_id}.mp4")
if self.file_path == new_path:
# already archived
return
shutil.move(self.file_path, new_path, copy_function=shutil.copyfile)
if self.HOST_UID and self.HOST_GID:
os.chown(new_path, self.HOST_UID, self.HOST_GID)
def restore_artwork(self, video):
"""restore artwork if needed"""
video_mutagen = MP4(self.file_path)
thumb = ThumbManager(video.youtube_id).vid_thumb_path(absolute=True)
self._restore_art_item(video_mutagen, "covr", thumb)
channel_id = video.json_data["channel"]["channel_id"]
banner_path = os.path.join(
self.CACHE_DIR, "channels", f"{channel_id}_banner.jpg"
)
self._restore_art_item(
video_mutagen, "----:com.tubearchivist:channel_banner", banner_path
)
channel_icon = os.path.join(
self.CACHE_DIR, "channels", f"{channel_id}_thumb.jpg"
)
self._restore_art_item(
video_mutagen, "----:com.tubearchivist:channel_icon", channel_icon
)
tv_art_path = os.path.join(
self.CACHE_DIR, "channels", f"{channel_id}_tvart.jpg"
)
self._restore_art_item(
video_mutagen, "----:com.tubearchivist:channel_tv", tv_art_path
)
def _restore_art_item(
self, video_mutagen, mutagen_key: str, target_path: str
) -> None:
"""restore single art item"""
if os.path.exists(target_path):
# don't overwrite
return
art_item = video_mutagen.get(mutagen_key)
if not art_item and not isinstance(art_item, list):
# is not embedded
return
with open(target_path, "wb") as f:
f.write(bytes(art_item[0]))
def index_subtitles(self, json_embed, video):
"""index subtitles"""
subtitle_data = json_embed.get("subtitles")
if not subtitle_data:
return
serializer = SubtitleFragmentSerializer(data=subtitle_data, many=True)
is_valid = serializer.is_valid()
if not is_valid:
err = serializer.errors
raise ValueError(
f"[{self.file_path}] subtitle serializer failed: {err}"
)
self._process_embedded_subs(video, subtitle_data=serializer.data)
def _process_embedded_subs(self, video, subtitle_data):
"""process single embedded subtitle"""
embedded_subs = {
(i["subtitle_lang"], i["subtitle_source"]) for i in subtitle_data
}
subs = YoutubeSubtitle(video)
response = subs.get_es_subtitles()
indexed = {
(i["subtitle_lang"], i["subtitle_source"]) for i in response
}
for embedded_lang, embedded_source in embedded_subs:
needs_processing = self._process_subtitle(
indexed, embedded_lang, embedded_source
)
if not needs_processing:
continue
segments = [
i
for i in subtitle_data
if i["subtitle_lang"] == embedded_lang
and i["subtitle_source"] == embedded_source
]
to_index = sorted(segments, key=lambda d: d["subtitle_index"])
parser = SubtitleParser(
subtitle_str="{}", lang=embedded_lang, source=embedded_source
)
for segment in to_index:
parser.all_cues.append(
{
"start": segment["subtitle_start"],
"end": segment["subtitle_end"],
"text": segment["subtitle_line"],
"idx": segment["subtitle_index"],
}
)
subtitle_str = parser.get_subtitle_str()
query_str = parser.create_bulk_import(to_index)
subs.index_subtitle(query_str)
media_url = subs.get_media_url(lang=embedded_lang)
dest_path = os.path.join(self.VIDEOS_BASE, media_url)
subs.write_subtitle_file(dest_path, subtitle_str)
def _process_subtitle(
self, indexed, embedded_lang, embedded_source
) -> bool:
"""check if subtitle should be processed"""
for sub_indexed in indexed:
if (
sub_indexed.get("lang") == embedded_lang
and sub_indexed.get("source") == embedded_source
):
# already indexed
return False
if not self.use_user_conf:
return True
if not self.config:
return False
langs = self.config["downloads"]["subtitle"]
source = self.config["downloads"]["subtitle_source"]
if not langs or not source:
return False
lang_codes = [i.strip() for i in langs.split(",")]
if embedded_lang not in lang_codes:
return True
return False
def index_comments(self, json_embed):
"""index comments"""
comment_data = json_embed.get("comments")
if not comment_data:
return
serializer = CommentsSerializer(data=comment_data)
is_valid = serializer.is_valid()
if not is_valid:
err = serializer.errors
raise ValueError(
f"[{self.file_path}] comments serializer failed: {err}"
)
if self.use_user_conf:
if self.config and not self.config["downloads"]["comment_max"]:
return
comments = Comments(youtube_id=serializer.data["youtube_id"])
existing = comments.get_es_comments()
if existing:
return
comments.json_data = dict(serializer.data)
comments.upload_comments()

View File

@ -10,14 +10,25 @@ import os
import re
from datetime import datetime
from operator import itemgetter
from typing import TypedDict
import requests
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import rand_sleep, requests_headers
from download.src.yt_dlp_base import CookieHandler
from yt_dlp.utils import orderedSet_from_options
class SubtitleCue(TypedDict):
"""describe single subtitle queue"""
start: str
end: str
text: str
idx: int
class YoutubeSubtitle:
"""handle video subtitle functionality"""
@ -54,7 +65,9 @@ class YoutubeSubtitle:
)
]
except re.error as e:
raise ValueError(f"wrong regex in subtitle config: {e.pattern}")
raise ValueError(
f"wrong regex in subtitle config: {e.pattern}"
) from e
return relevant_subtitles
@ -74,8 +87,7 @@ class YoutubeSubtitle:
# not supported yet
continue
video_media_url = self.video.json_data["media_url"]
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
media_url = self.get_media_url(lang)
if not all_formats:
# no subtitles found
continue
@ -93,6 +105,12 @@ class YoutubeSubtitle:
return candidate_subtitles
def get_media_url(self, lang: str) -> str:
"""get media url"""
video_media_url = self.video.json_data["media_url"]
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
return media_url
def get_es_subtitles(self) -> list[dict]:
"""get subtitles from elastic"""
data = {
@ -113,39 +131,66 @@ class YoutubeSubtitle:
dest_path = os.path.join(videos_base, subtitle["media_url"])
source = subtitle["source"]
lang = subtitle.get("lang")
response = requests.get(
subtitle["url"], headers=requests_headers(), timeout=30
)
if not response.ok:
subtitle_key = f"{self.video.youtube_id}-{lang}"
print(f"{subtitle_key}: failed to download subtitle")
print(response.text)
rand_sleep(self.video.config)
response_text = self._make_request(subtitle["url"], lang)
if not response_text:
continue
if not response.text:
print(f"{subtitle_key}: skip empty subtitle")
rand_sleep(self.video.config)
continue
parser = SubtitleParser(response.text, lang, source)
parser = SubtitleParser(response_text, lang, source)
parser.process()
if not parser.all_cues:
rand_sleep(self.video.config)
continue
subtitle_str = parser.get_subtitle_str()
self._write_subtitle_file(dest_path, subtitle_str)
self.write_subtitle_file(dest_path, subtitle_str)
if self.video.config["downloads"]["subtitle_index"]:
query_str = parser.create_bulk_import(self.video, source)
self._index_subtitle(query_str)
documents = parser.create_documents(self.video, source)
query_str = parser.create_bulk_import(documents)
self.index_subtitle(query_str)
indexed.append(subtitle)
rand_sleep(self.video.config)
return indexed
def _write_subtitle_file(self, dest_path, subtitle_str):
def _make_request(self, url: str, lang: str | None) -> str | None:
"""make the request"""
request_kwargs: dict = {
"timeout": 30,
"headers": requests_headers(),
}
if self.video.config["downloads"].get("cookie_import"):
cookie = CookieHandler(self.video.config).get()
if cookie:
cookies_txt = cookie.read()
jar = requests.cookies.RequestsCookieJar()
for line in cookies_txt.split("\n"):
words = line.split()
if (len(words) == 7) and (words[0] != "#"):
jar.set(
words[5], words[6], domain=words[0], path=words[2]
)
request_kwargs["cookies"] = jar
response = requests.get(url, **request_kwargs)
if not response.ok:
subtitle_key = f"{self.video.youtube_id}-{lang}"
print(f"{subtitle_key}: failed to download subtitle")
print(response.text)
rand_sleep(self.video.config)
return None
if not response.text:
print(f"{subtitle_key}: skip empty subtitle")
rand_sleep(self.video.config)
return None
return response.text
def write_subtitle_file(self, dest_path, subtitle_str):
"""write subtitle file to disk"""
# create folder here for first video of channel
os.makedirs(os.path.split(dest_path)[0], exist_ok=True)
@ -158,7 +203,7 @@ class YoutubeSubtitle:
os.chown(dest_path, host_uid, host_gid)
@staticmethod
def _index_subtitle(query_str):
def index_subtitle(query_str):
"""send subtitle to es for indexing"""
_, _ = ElasticWrap("_bulk").post(data=query_str, ndjson=True)
@ -190,15 +235,14 @@ class YoutubeSubtitle:
class SubtitleParser:
"""parse subtitle str from youtube"""
def __init__(self, subtitle_str, lang, source):
def __init__(self, subtitle_str: str, lang: str, source: str) -> None:
self.subtitle_raw = json.loads(subtitle_str)
self.lang = lang
self.source = source
self.all_cues = False
self.all_cues: list[SubtitleCue] = []
def process(self):
def process(self) -> None:
"""extract relevant que data"""
self.all_cues = []
all_events = self.subtitle_raw.get("events")
if not all_events:
@ -213,12 +257,12 @@ class SubtitleParser:
print(f"skipping subtitle event without content: {event}")
continue
cue = {
"start": self._ms_conv(event["tStartMs"]),
"end": self._ms_conv(event["tStartMs"] + event["dDurationMs"]),
"text": "".join([i.get("utf8") for i in event["segs"]]),
"idx": idx + 1,
}
cue = SubtitleCue(
start=self._ms_conv(event["tStartMs"]),
end=self._ms_conv(event["tStartMs"] + event["dDurationMs"]),
text="".join([i.get("utf8") for i in event["segs"]]),
idx=idx + 1,
)
self.all_cues.append(cue)
@staticmethod
@ -272,9 +316,8 @@ class SubtitleParser:
return subtitle_str
def create_bulk_import(self, video, source):
def create_bulk_import(self, documents):
"""subtitle lines for es import"""
documents = self._create_documents(video, source)
bulk_list = []
for document in documents:
@ -288,7 +331,7 @@ class SubtitleParser:
return query_str
def _create_documents(self, video, source):
def create_documents(self, video, source):
"""process documents"""
documents = self._chunk_list(video.youtube_id)
channel = video.json_data.get("channel")

File diff suppressed because it is too large Load Diff

View File

@ -11,27 +11,27 @@
"preview": "vite preview"
},
"dependencies": {
"dompurify": "^3.2.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.0",
"zustand": "^5.0.7"
"dompurify": "^3.3.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-router-dom": "^7.10.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react-swc": "^4.0.0",
"eslint": "^9.33.0",
"@vitejs/plugin-react-swc": "^4.2.2",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"prettier": "3.6.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.39.0",
"vite": ">=7.1.1",
"vite-plugin-checker": "^0.10.2"
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "3.7.4",
"typescript": "^5.9.3",
"typescript-eslint": "^8.49.0",
"vite": ">=7.2.7",
"vite-plugin-checker": "^0.12.0"
}
}

View File

@ -0,0 +1,9 @@
import APIClient from '../../functions/APIClient';
const queueManualImport = async (ignore_error: boolean, prefer_local: boolean) => {
return APIClient('/api/appsettings/manual-import/', {
method: 'POST',
body: { ignore_error, prefer_local },
});
};
export default queueManualImport;

View File

@ -0,0 +1,9 @@
import APIClient from '../../functions/APIClient';
const queueStartFilesystemRescan = async (ignore_error: boolean, prefer_local: boolean) => {
return APIClient('/api/appsettings/rescan-filesystem/', {
method: 'POST',
body: { ignore_error, prefer_local },
});
};
export default queueStartFilesystemRescan;

View File

@ -1,12 +1,6 @@
import APIClient from '../../functions/APIClient';
type TaskNamesType =
| 'download_pending'
| 'update_subscribed'
| 'manual_import'
| 'resync_thumbs'
| 'resync_metadata'
| 'rescan_filesystem';
type TaskNamesType = 'download_pending' | 'update_subscribed' | 'resync_thumbs' | 'resync_metadata';
const updateTaskByName = async (taskName: TaskNamesType) => {
return APIClient(`/api/task/by-name/${taskName}/`, {

View File

@ -60,6 +60,7 @@ const Filterbar = ({
}
if (currentViewStyle === ViewStylesEnum.Table) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowHidden(true);
} else {
setShowHidden(false);

View File

@ -104,6 +104,10 @@ const SearchExampleQueries = () => {
auto-generated subtitles only, or <i>user</i> to search through user-uploaded
subtitles only
</li>
<li>
<span>channel:</span> limit subtitle search to a specific channel name (for
example: <code>full:javascript channel:corey schafer</code>)
</li>
</ul>
</li>
</ul>

View File

@ -146,6 +146,7 @@ const VideoPlayer = ({
}
if (setSeekToTimestamp) setSeekToTimestamp(undefined);
window.scroll(0, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seekToTimestamp]);
const [searchParams] = useSearchParams();
@ -389,6 +390,7 @@ const VideoPlayer = ({
} else if (!theaterModePressed) {
setTheaterModeKeyPressed(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theaterModePressed, isTheaterMode, theaterModeKeyPressed]);
useEffect(() => {
@ -401,6 +403,7 @@ const VideoPlayer = ({
infoDialog('Normal mode');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [escapePressed, isTheaterMode]);
return (

View File

@ -108,7 +108,6 @@ const Playlist = () => {
}
setRefresh(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
playlistId,
userConfig.hide_watched_playlist,

View File

@ -65,6 +65,13 @@ const Search = () => {
const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
const fetchResults = async (searchQuery: string) => {
const searchResults = await loadSearch(searchQuery);
setSearchResults(searchResults);
setRefresh(false);
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
@ -77,19 +84,13 @@ const Search = () => {
useEffect(() => {
if (debouncedSearchTerm.trim() !== '') {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchResults(debouncedSearchTerm);
} else {
setSearchResults(EmptySearchResponse);
}
}, [debouncedSearchTerm, refresh, videoId]);
const fetchResults = async (searchQuery: string) => {
const searchResults = await loadSearch(searchQuery);
setSearchResults(searchResults);
setRefresh(false);
};
return (
<>
<title>TubeArchivist</title>

View File

@ -7,6 +7,9 @@ import restoreBackup from '../api/actions/restoreBackup';
import Notifications from '../components/Notifications';
import Button from '../components/Button';
import { ApiResponseType } from '../functions/APIClient';
import ToggleConfig from '../components/ToggleConfig';
import queueStartFilesystemRescan from '../api/actions/queueStartFilesystemRescan';
import queueManualImport from '../api/actions/queueManualImport';
const SettingsActions = () => {
const [deleteIgnored, setDeleteIgnored] = useState(false);
@ -17,6 +20,10 @@ const SettingsActions = () => {
const [backupStarted, setBackupStarted] = useState(false);
const [isRestoringBackup, setIsRestoringBackup] = useState(false);
const [reScanningFileSystem, setReScanningFileSystem] = useState(false);
const [rescanIgnoreErrors, setRescanIgnoreErrors] = useState(false);
const [rescanPreferLocal, setRescanPreferLocal] = useState(false);
const [manualPreferLocal, setManualPreferLocal] = useState(false);
const [manualIgnoreErrors, setManualIgnoreErrors] = useState(false);
const [backupListResponse, setBackupListResponse] = useState<ApiResponseType<BackupListType>>();
@ -69,22 +76,42 @@ const SettingsActions = () => {
<h2>Manual media files import.</h2>
<p>
Add files to the <span className="settings-current">cache/import</span> folder. Make
sure to follow the instructions in the Github{' '}
sure to follow the instructions in the{' '}
<a
href="https://docs.tubearchivist.com/settings/actions/#manual-media-files-import"
target="_blank"
>
Wiki
Docs
</a>
.
</p>
<div id="manual-import">
<div className="settings-box-wrapper">
<div>
<p>Prefer embedded metadata</p>
</div>
<ToggleConfig
name="manual_prefer_local"
value={manualPreferLocal}
updateCallback={() => setManualPreferLocal(!manualPreferLocal)}
/>
</div>
<div className="settings-box-wrapper">
<div>
<p>Ignore missing metadata errors</p>
</div>
<ToggleConfig
name="manual_ignore_error"
value={manualIgnoreErrors}
updateCallback={() => setManualIgnoreErrors(!manualIgnoreErrors)}
/>
</div>
{processingImports && <p>Processing import</p>}
{!processingImports && (
<Button
label="Start import"
onClick={async () => {
await updateTaskByName('manual_import');
await queueManualImport(manualIgnoreErrors, manualPreferLocal);
setProcessingImports(true);
}}
/>
@ -196,23 +223,42 @@ const SettingsActions = () => {
deleted videos from the filesystem.
</p>
<p>
Rescan your media folder looking for missing videos and clean up index. More info on the
Github{' '}
Rescan your media folder looking for missing videos and clean up index. More info on the{' '}
<a
href="https://docs.tubearchivist.com/settings/actions/#rescan-filesystem"
target="_blank"
>
Wiki
Docs
</a>
.
</p>
<div id="fs-rescan">
<div className="settings-box-wrapper">
<div>
<p>Prefer embedded metadata</p>
</div>
<ToggleConfig
name="prefer_local"
value={rescanPreferLocal}
updateCallback={() => setRescanPreferLocal(!rescanPreferLocal)}
/>
</div>
<div className="settings-box-wrapper">
<div>
<p>Ignore missing metadata errors</p>
</div>
<ToggleConfig
name="ignore_error"
value={rescanIgnoreErrors}
updateCallback={() => setRescanIgnoreErrors(!rescanIgnoreErrors)}
/>
</div>
{reScanningFileSystem && <p>File system scan in progress</p>}
{!reScanningFileSystem && (
<Button
label="Rescan filesystem"
onClick={async () => {
await updateTaskByName('rescan_filesystem');
await queueStartFilesystemRescan(rescanIgnoreErrors, rescanPreferLocal);
setReScanningFileSystem(true);
}}
/>

View File

@ -33,6 +33,7 @@ const SettingsApplication = () => {
const { userConfig } = useUserConfigStore();
const [response, setResponse] = useState<SettingsApplicationReponses>();
const [refresh, setRefresh] = useState(false);
const [visibleSnapshotCount, setVisibleSnapshotCount] = useState(10);
const snapshots = response?.snapshots;
const appSettingsConfig = response?.appSettingsConfig;
@ -182,11 +183,13 @@ const SettingsApplication = () => {
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchData();
}, []);
useEffect(() => {
if (refresh) {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchData();
setRefresh(false);
}
@ -974,10 +977,9 @@ const SettingsApplication = () => {
</p>
<br />
{restoringSnapshot && <p>Snapshot restore started</p>}
{!restoringSnapshot &&
snapshots.snapshots &&
snapshots.snapshots.map(snapshot => {
return (
{!restoringSnapshot && snapshots.snapshots && (
<>
{snapshots.snapshots?.slice(0, visibleSnapshotCount).map(snapshot => (
<p key={snapshot.id}>
<Button
label="Restore"
@ -992,8 +994,17 @@ const SettingsApplication = () => {
<span className="settings-current">{snapshot.duration_s}s</span> to
create. State: <i>{snapshot.state}</i>
</p>
);
})}
))}
{visibleSnapshotCount < snapshots.snapshots.length && (
<Button
label="Load More"
onClick={() => {
setVisibleSnapshotCount(visibleSnapshotCount + 10);
}}
/>
)}
</>
)}
</>
)}
</div>

View File

@ -103,6 +103,7 @@ const SettingsScheduling = () => {
}, [refresh]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRefresh(true);
}, []);

View File

@ -1,10 +1,10 @@
-r backend/requirements.txt
ipython==9.7.0
pre-commit==4.4.0
ipython==9.8.0
pre-commit==4.5.0
pylint-django==2.6.1
pylint==3.3.9
pytest-django==4.11.1
pytest==9.0.1
pytest==9.0.2
python-dotenv==1.2.1
requirementscheck==0.1.0
types-requests==2.32.4.20250913