From 33d5f2e3810bfca8287630551d8b51554ded63b7 Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 26 Dec 2025 17:25:40 +0700
Subject: [PATCH] add rescan filesystem from embed, ignore errors options
---
backend/appsettings/serializers.py | 7 ++
backend/appsettings/src/filesystem.py | 114 ++++++++++++++----
backend/appsettings/urls.py | 5 +
backend/appsettings/views.py | 31 ++++-
backend/task/src/task_config.py | 2 +-
backend/task/src/task_manager.py | 4 +-
backend/task/tasks.py | 6 +-
backend/video/serializers.py | 4 +-
backend/video/src/meta_embed.py | 6 +-
.../api/actions/queueStartFilesystemRescan.ts | 9 ++
frontend/src/api/actions/updateTaskByName.ts | 3 +-
frontend/src/pages/SettingsActions.tsx | 31 ++++-
12 files changed, 183 insertions(+), 39 deletions(-)
create mode 100644 frontend/src/api/actions/queueStartFilesystemRescan.ts
diff --git a/backend/appsettings/serializers.py b/backend/appsettings/serializers.py
index 70564465..6da5ae1f 100644
--- a/backend/appsettings/serializers.py
+++ b/backend/appsettings/serializers.py
@@ -100,6 +100,13 @@ class PoTokenSerializer(serializers.Serializer):
potoken = serializers.CharField()
+class RescanFileSystemConfig(serializers.Serializer):
+ """serialize rescan filesystem config"""
+
+ ignore_error = serializers.BooleanField()
+ prefer_local = serializers.BooleanField()
+
+
class SnapshotItemSerializer(serializers.Serializer):
"""serialize snapshot response"""
diff --git a/backend/appsettings/src/filesystem.py b/backend/appsettings/src/filesystem.py
index 0a609289..13324331 100644
--- a/backend/appsettings/src/filesystem.py
+++ b/backend/appsettings/src/filesystem.py
@@ -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 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,25 +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,
- )
- json_data = index_new_video(youtube_id)
- if json_data:
+ for idx, (youtube_id, media_url) in enumerate(self.to_index):
+ self._notify(total, youtube_id, idx)
+
+ 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",
+ )
diff --git a/backend/appsettings/urls.py b/backend/appsettings/urls.py
index 4dde4188..d4f96d4f 100644
--- a/backend/appsettings/urls.py
+++ b/backend/appsettings/urls.py
@@ -44,6 +44,11 @@ urlpatterns = [
views.TokenView.as_view(),
name="api-token",
),
+ path(
+ "rescan-filesystem/",
+ views.RescanFileSystem.as_view(),
+ name="api-rescan-filesystem",
+ ),
path(
"membership/profile/",
views_mb.MembershipProfileView.as_view(),
diff --git a/backend/appsettings/views.py b/backend/appsettings/views.py
index edb87ca3..cb909219 100644
--- a/backend/appsettings/views.py
+++ b/backend/appsettings/views.py
@@ -6,6 +6,7 @@ from appsettings.serializers import (
CookieUpdateSerializer,
CookieValidationSerializer,
PoTokenSerializer,
+ RescanFileSystemConfig,
SnapshotCreateResponseSerializer,
SnapshotItemSerializer,
SnapshotListSerializer,
@@ -291,7 +292,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 +393,30 @@ class SnapshotApiListView(ApiBaseView):
return Response(serializer.data)
+class RescanFileSystem(ApiBaseView):
+ """resolves to /api/appsettings/rescan-filesystem/
+ POST: start new task rescan filesystem task
+ """
+
+ @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 SnapshotApiView(ApiBaseView):
"""resolves to /api/appsettings/snapshot//
GET: return a single snapshot
diff --git a/backend/task/src/task_config.py b/backend/task/src/task_config.py
index 9fd494e9..d2e3b02e 100644
--- a/backend/task/src/task_config.py
+++ b/backend/task/src/task_config.py
@@ -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,
}
diff --git a/backend/task/src/task_manager.py b/backend/task/src/task_manager.py
index ea48e5e8..81b612ec 100644
--- a/backend/task/src/task_manager.py
+++ b/backend/task/src/task_manager.py
@@ -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,
diff --git a/backend/task/tasks.py b/backend/task/tasks.py
index 995e1c85..914e4036 100644
--- a/backend/task/tasks.py
+++ b/backend/task/tasks.py
@@ -260,7 +260,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 +269,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()
diff --git a/backend/video/serializers.py b/backend/video/serializers.py
index bc90a1c9..5a5d7e3e 100644
--- a/backend/video/serializers.py
+++ b/backend/video/serializers.py
@@ -100,7 +100,9 @@ class VideoSerializer(serializers.Serializer):
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, required=False)
stats = StatsSerializer()
diff --git a/backend/video/src/meta_embed.py b/backend/video/src/meta_embed.py
index e3be281c..a568d04f 100644
--- a/backend/video/src/meta_embed.py
+++ b/backend/video/src/meta_embed.py
@@ -90,14 +90,14 @@ class IndexFromEmbed:
self.use_user_conf = use_user_conf
self.config = config
- def run_index(self) -> None:
+ 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
+ return None
channel_data_clean = self.index_channel(json_embed)
video = self.index_video(json_embed, channel_data_clean)
@@ -105,6 +105,8 @@ class IndexFromEmbed:
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)
diff --git a/frontend/src/api/actions/queueStartFilesystemRescan.ts b/frontend/src/api/actions/queueStartFilesystemRescan.ts
new file mode 100644
index 00000000..e6eeab0b
--- /dev/null
+++ b/frontend/src/api/actions/queueStartFilesystemRescan.ts
@@ -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;
diff --git a/frontend/src/api/actions/updateTaskByName.ts b/frontend/src/api/actions/updateTaskByName.ts
index 7053dca9..dabd492b 100644
--- a/frontend/src/api/actions/updateTaskByName.ts
+++ b/frontend/src/api/actions/updateTaskByName.ts
@@ -5,8 +5,7 @@ type TaskNamesType =
| 'update_subscribed'
| 'manual_import'
| 'resync_thumbs'
- | 'resync_metadata'
- | 'rescan_filesystem';
+ | 'resync_metadata';
const updateTaskByName = async (taskName: TaskNamesType) => {
return APIClient(`/api/task/by-name/${taskName}/`, {
diff --git a/frontend/src/pages/SettingsActions.tsx b/frontend/src/pages/SettingsActions.tsx
index a226019d..9104dab7 100644
--- a/frontend/src/pages/SettingsActions.tsx
+++ b/frontend/src/pages/SettingsActions.tsx
@@ -7,6 +7,8 @@ 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';
const SettingsActions = () => {
const [deleteIgnored, setDeleteIgnored] = useState(false);
@@ -17,6 +19,8 @@ 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 [backupListResponse, setBackupListResponse] = useState>();
@@ -196,23 +200,42 @@ const SettingsActions = () => {
deleted videos from the filesystem.
- 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{' '}
- Wiki
+ Docs
.