From e62a4e0fcf83b5f39308432669fdc6c0e74ed783 Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 8 Jun 2025 10:04:45 +0700
Subject: [PATCH 01/55] fix none existing timestamp key in info json
---
backend/video/src/index.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/video/src/index.py b/backend/video/src/index.py
index 01b8435b..6f324eb3 100644
--- a/backend/video/src/index.py
+++ b/backend/video/src/index.py
@@ -195,7 +195,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
def _build_published(self):
"""build published date or timestamp"""
- timestamp = self.youtube_meta["timestamp"]
+ timestamp = self.youtube_meta.get("timestamp")
if timestamp:
return timestamp
From f8c5efb87abdedf422a3c466e955d108b3fe4db3 Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 8 Jun 2025 11:34:05 +0700
Subject: [PATCH 02/55] fix type hints
---
backend/config/settings.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/backend/config/settings.py b/backend/config/settings.py
index b2d9318f..93755321 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -242,7 +242,9 @@ if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
# Configure Authentication Backend Combinations
_login_auth_mode = (environ.get("TA_LOGIN_AUTH_MODE") or "single").casefold()
if _login_auth_mode == "local":
- AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",)
+ AUTHENTICATION_BACKENDS: tuple = (
+ "django.contrib.auth.backends.ModelBackend",
+ )
elif _login_auth_mode == "ldap":
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
elif _login_auth_mode == "forwardauth":
From ffe9444295a32d9ca4fe0cad1532add9cbde79e8 Mon Sep 17 00:00:00 2001
From: Baku <83136747+IAmABakuAMA@users.noreply.github.com>
Date: Sun, 8 Jun 2025 15:13:59 +1000
Subject: [PATCH 03/55] Update CONTRIBUTING.md (#987)
* Update CONTRIBUTING.md
Reworded the Beta Testing section for clarity and flow. Also fixed typos.
* Update CONTRIBUTING.md
Fixed additional typo
---
CONTRIBUTING.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c2f62d79..2a4425a9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -16,17 +16,17 @@ Welcome, and thanks for showing interest in improving Tube Archivist!
---
## Beta Testing
-Be the first to help test new features and improvements and provide feedback! There are regular `:unstable` builds for easy access. That's for the tinkerers and the breave. Ideally use a testing environment first, before a release be the first to install it on your main system.
+Be the first to help test new features/improvements and provide feedback! Regular `:unstable` builds are available for early access. These are for the tinkerers and the brave. Ideally, use a testing environment first, before upgrading your main installation.
-There is always something that can get missed during development. Look at the commit messages tagged with `#build`, these are the unstable builds and give a quick overview what has changed.
+There is always something that can get missed during development. Look at the commit messages tagged with `#build` - these are the unstable builds and give a quick overview of what has changed.
- Test the features mentioned, play around, try to break it.
-- Test the update path by installing the `:latest` release first, the upgrade to `:unstable` to check for any errors.
+- Test the update path by installing the `:latest` release first, then upgrade to `:unstable` to check for any errors.
- Test the unstable build on a fresh install.
-Then provide feedback, if there is a problem but also if there is no problem. Reach out on [Discord](https://tubearchivist.com/discord) in the `#beta-testing` channel with your findings.
+Then provide feedback - even if you don't encounter any issues! You can do this in the `#beta-testing` channel on the [Discord](https://tubearchivist.com/discord) Discord server.
-This will help with a smooth update for the regular release. Plus you get to test things out early!
+This helps ensure a smooth update for the stable release. Plus you get to test things out early!
## How to open an issue
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub.
From 2c2129179aef78e5a411faf1251df91412d034ff Mon Sep 17 00:00:00 2001
From: James Kerrane
Date: Sat, 7 Jun 2025 23:14:28 -0600
Subject: [PATCH 04/55] Remove obsolete "version" attribute (#988)
The attribute `version` is obsolete, and docker compose recommends removing it to avoid potential confusion, so this removes the attribute.
---
docker-compose.yml | 2 --
1 file changed, 2 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index a906ce7b..5a5bc2f3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.5'
-
services:
tubearchivist:
container_name: tubearchivist
From 22b53e682060aa47e87a192d9b5af2eafda0da52 Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 8 Jun 2025 12:16:48 +0700
Subject: [PATCH 05/55] remove unstable tag
---
backend/config/settings.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 93755321..b92e8ae5 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -316,7 +316,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
-TA_VERSION = "v0.5.3-unstable"
+TA_VERSION = "v0.5.3"
# API
REST_FRAMEWORK = {
From 178c25f2f06723cad15cc3666f6530e61f177eb7 Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 8 Jun 2025 19:52:55 +0700
Subject: [PATCH 06/55] really no more feature requests please
---
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
index fbdc7998..f84b1ab8 100644
--- a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
+++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
@@ -10,3 +10,5 @@ body:
options:
- label: I understand that this issue will be closed without comment.
required: true
+ - label: I will resist the temptation and I will not submit this issue. If I submit this, I understand I might get blocked from this repo.
+ required: true
From aa943567dede7398c054253f9e2c3057a2f8eeec Mon Sep 17 00:00:00 2001
From: Simon
Date: Tue, 10 Jun 2025 08:37:48 +0700
Subject: [PATCH 07/55] bump bump yt-dlp
---
backend/requirements.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index fa21380e..97f24a6c 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -12,4 +12,4 @@ requests==2.32.3
ryd-client==0.0.6
uvicorn==0.34.3
whitenoise==6.9.0
-yt-dlp[default]==2025.5.22
+yt-dlp[default]==2025.6.9
From dfcd46efbf80f28d29d23167a536a8c3604c0ca0 Mon Sep 17 00:00:00 2001
From: Simon
Date: Tue, 10 Jun 2025 08:38:58 +0700
Subject: [PATCH 08/55] add unstable tag
---
backend/config/settings.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/config/settings.py b/backend/config/settings.py
index b92e8ae5..938cc225 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -316,7 +316,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
-TA_VERSION = "v0.5.3"
+TA_VERSION = "v0.5.4-unstable"
# API
REST_FRAMEWORK = {
From 3631fa0b2c002b28c4c0b1fccc8e78c8eeb925f2 Mon Sep 17 00:00:00 2001
From: Simon
Date: Tue, 10 Jun 2025 08:47:57 +0700
Subject: [PATCH 09/55] bump yt-dlp, #build
---
backend/config/settings.py | 2 +-
backend/requirements.txt | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/config/settings.py b/backend/config/settings.py
index b92e8ae5..938cc225 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -316,7 +316,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
-TA_VERSION = "v0.5.3"
+TA_VERSION = "v0.5.4-unstable"
# API
REST_FRAMEWORK = {
diff --git a/backend/requirements.txt b/backend/requirements.txt
index fa21380e..97f24a6c 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -12,4 +12,4 @@ requests==2.32.3
ryd-client==0.0.6
uvicorn==0.34.3
whitenoise==6.9.0
-yt-dlp[default]==2025.5.22
+yt-dlp[default]==2025.6.9
From 12fc7e1663aae6952063ca69d99b145029596820 Mon Sep 17 00:00:00 2001
From: Simon
Date: Wed, 11 Jun 2025 08:34:22 +0700
Subject: [PATCH 10/55] bump requirements
---
backend/requirements.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 97f24a6c..9751c2cb 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -8,7 +8,7 @@ djangorestframework==3.16.0
drf-spectacular==0.28.0
Pillow==11.2.1
redis==6.2.0
-requests==2.32.3
+requests==2.32.4
ryd-client==0.0.6
uvicorn==0.34.3
whitenoise==6.9.0
From 21f0a09f5f9f94681d1a0a2c25c271f652a92161 Mon Sep 17 00:00:00 2001
From: Simon
Date: Wed, 11 Jun 2025 08:34:56 +0700
Subject: [PATCH 11/55] bump TA_VERSION
---
backend/config/settings.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 938cc225..39d2186e 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -316,7 +316,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
-TA_VERSION = "v0.5.4-unstable"
+TA_VERSION = "v0.5.4"
# API
REST_FRAMEWORK = {
From ff94c324b3db6ce8afb15c4038d19bffe6a28fed Mon Sep 17 00:00:00 2001
From: MerlinScheurer
Date: Fri, 13 Jun 2025 20:10:57 +0200
Subject: [PATCH 12/55] Refac extract loading indicator into its own component
---
frontend/src/components/InputConfig.tsx | 9 ++-------
frontend/src/components/LoadingIndicator.tsx | 9 +++++++++
frontend/src/components/WatchedCheckBox.tsx | 5 ++---
frontend/src/pages/Login.tsx | 6 ++----
4 files changed, 15 insertions(+), 14 deletions(-)
create mode 100644 frontend/src/components/LoadingIndicator.tsx
diff --git a/frontend/src/components/InputConfig.tsx b/frontend/src/components/InputConfig.tsx
index a378a5fb..cf1bfa13 100644
--- a/frontend/src/components/InputConfig.tsx
+++ b/frontend/src/components/InputConfig.tsx
@@ -1,4 +1,5 @@
import { useState } from 'react';
+import LoadingIndicator from './LoadingIndicator';
type InputTextProps = {
type: 'text' | 'number';
@@ -51,13 +52,7 @@ const InputConfig = ({ type, name, value, setValue, oldValue, updateCallback }:
>
)}
{oldValue !== null && handleUpdate(name, null)}>reset }
- {loading && (
- <>
-
- >
- )}
+ {loading && }
{success && ✅ }
diff --git a/frontend/src/components/LoadingIndicator.tsx b/frontend/src/components/LoadingIndicator.tsx
new file mode 100644
index 00000000..0eaeaeb4
--- /dev/null
+++ b/frontend/src/components/LoadingIndicator.tsx
@@ -0,0 +1,9 @@
+const LoadingIndicator = () => {
+ return (
+
+ );
+};
+
+export default LoadingIndicator;
diff --git a/frontend/src/components/WatchedCheckBox.tsx b/frontend/src/components/WatchedCheckBox.tsx
index 490bb3c3..0585ec1a 100644
--- a/frontend/src/components/WatchedCheckBox.tsx
+++ b/frontend/src/components/WatchedCheckBox.tsx
@@ -1,6 +1,7 @@
import iconUnseen from '/img/icon-unseen.svg';
import iconSeen from '/img/icon-seen.svg';
import { useEffect, useState } from 'react';
+import LoadingIndicator from './LoadingIndicator';
type WatchedCheckBoxProps = {
watched: boolean;
@@ -32,9 +33,7 @@ const WatchedCheckBox = ({ watched, onClick, onDone }: WatchedCheckBoxProps) =>
<>
{loading && (
<>
-
+
>
)}
{!loading && watched && (
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index da8a0385..d07c392e 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -5,6 +5,7 @@ import Colours from '../configuration/colours/Colours';
import Button from '../components/Button';
import signIn from '../api/actions/signIn';
import loadAuth from '../api/loader/loadAuth';
+import LoadingIndicator from '../components/LoadingIndicator';
const Login = () => {
const navigate = useNavigate();
@@ -134,10 +135,7 @@ const Login = () => {
{waitingForBackend && (
<>
- Waiting for backend{' '}
-
+ Waiting for backend
>
)}
From bb4e5ecb503e22ae1633806899c4c4d93c5d550f Mon Sep 17 00:00:00 2001
From: Craig Alexander
Date: Sun, 15 Jun 2025 05:19:14 -0400
Subject: [PATCH 13/55] Fix not found message showing as API is loading (#995)
---
.gitignore | 2 +
frontend/src/pages/ChannelVideo.tsx | 173 +++++++++++++---------------
2 files changed, 85 insertions(+), 90 deletions(-)
diff --git a/.gitignore b/.gitignore
index 812a03ba..474bdc25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,5 @@ backend/.env
# JavaScript stuff
node_modules
+
+.editorconfig
diff --git a/frontend/src/pages/ChannelVideo.tsx b/frontend/src/pages/ChannelVideo.tsx
index e5a123c5..981c9aae 100644
--- a/frontend/src/pages/ChannelVideo.tsx
+++ b/frontend/src/pages/ChannelVideo.tsx
@@ -97,104 +97,97 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
videoId,
]);
- if (!channel) {
- return (
-
-
-
Channel {channelId} not found!
-
- );
- }
-
return (
- <>
- {`TA | Channel: ${channel.channel_name}`}
-
-
-
-
-
- {videoAggs && (
- <>
-
- {videoAggs.total_items.value} videos | {' '}
- {videoAggs.total_duration.value_str} playback{' '}
- | Total size{' '}
- {humanFileSize(videoAggs.total_size.value, useSiUnits)}
-
-
-
{
- await updateWatchedState({
- id: channel.channel_id,
- is_watched: true,
- });
+ channel && (
+ <>
+ {`TA | Channel: ${channel.channel_name}`}
+
+
+
+
+
+ {videoAggs && (
+ <>
+
+ {videoAggs.total_items.value} videos | {' '}
+ {videoAggs.total_duration.value_str} playback{' '}
+ | Total size{' '}
+ {humanFileSize(videoAggs.total_size.value, useSiUnits)}
+
+
+ {
+ await updateWatchedState({
+ id: channel.channel_id,
+ is_watched: true,
+ });
- setRefresh(true);
- }}
- />{' '}
- {
- await updateWatchedState({
- id: channel.channel_id,
- is_watched: false,
- });
+ setRefresh(true);
+ }}
+ />{' '}
+ {
+ await updateWatchedState({
+ id: channel.channel_id,
+ is_watched: false,
+ });
- setRefresh(true);
- }}
- />
-
- >
- )}
+ setRefresh(true);
+ }}
+ />
+
+ >
+ )}
+
-
-
-
-
-
-
-
-
-
- {!hasVideos && (
- <>
-
No videos found...
-
- Try going to the downloads page to start the scan
- and download tasks.
-
- >
- )}
-
-
+
+
-
- {pagination && (
-
-
+
+
+
+
+
+ {!hasVideos && (
+ <>
+
No videos found...
+
+ Try going to the downloads page to start the
+ scan and download tasks.
+
+ >
+ )}
+
+
+
- )}
- >
+ {pagination && (
+
+ )}
+ >
+ )
);
};
From 990cb9aaecdc4c59628f09f1ddf5a347929f75ee Mon Sep 17 00:00:00 2001
From: Craig Alexander
Date: Fri, 20 Jun 2025 12:33:43 -0400
Subject: [PATCH 14/55] Move npm install into its own docker stage (#999)
---
Dockerfile | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index b8260679..25eec9ad 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,14 +1,18 @@
# multi stage to build tube archivist
# build python wheel, download and extract ffmpeg, copy into final image
+FROM node:lts-alpine AS npm-builder
+COPY frontend/package.json frontend/package-lock.json /
+RUN npm i
+
FROM node:lts-alpine AS node-builder
# RUN npm config set registry https://registry.npmjs.org/
+COPY --from=npm-builder ./node_modules /frontend/node_modules
COPY ./frontend /frontend
-
WORKDIR /frontend
-RUN npm i
+
RUN npm run build:deploy
WORKDIR /
@@ -54,9 +58,9 @@ RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recomm
# install debug tools for testing environment
RUN if [ "$INSTALL_DEBUG" ] ; then \
- apt-get -y update && apt-get -y install --no-install-recommends \
- vim htop bmon net-tools iputils-ping procps lsof \
- && pip install --user ipython pytest pytest-django \
+ apt-get -y update && apt-get -y install --no-install-recommends \
+ vim htop bmon net-tools iputils-ping procps lsof \
+ && pip install --user ipython pytest pytest-django \
; fi
# make folders
From b14309daebf3a3c69f4de615b069ff090185de8f Mon Sep 17 00:00:00 2001
From: MerlinScheurer
Date: Sun, 22 Jun 2025 11:41:00 +0200
Subject: [PATCH 15/55] Fix do not send referrer when opening youtube,
sponsorblock or returndislite links
---
frontend/src/components/DownloadListItem.tsx | 6 ++++-
frontend/src/components/VideoPlayer.tsx | 24 ++++++++++++++++----
frontend/src/pages/ChannelAbout.tsx | 8 +++++--
frontend/src/pages/Playlist.tsx | 1 +
frontend/src/pages/SettingsApplication.tsx | 20 ++++++++++++----
frontend/src/pages/Video.tsx | 6 ++++-
6 files changed, 53 insertions(+), 12 deletions(-)
diff --git a/frontend/src/components/DownloadListItem.tsx b/frontend/src/components/DownloadListItem.tsx
index bb0a82e3..ff0273dd 100644
--- a/frontend/src/components/DownloadListItem.tsx
+++ b/frontend/src/components/DownloadListItem.tsx
@@ -47,7 +47,11 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
{!download.channel_indexed && {download.channel_name} }
-
+
{download.title}
diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx
index 7684e24a..001c5e7c 100644
--- a/frontend/src/components/VideoPlayer.tsx
+++ b/frontend/src/components/VideoPlayer.tsx
@@ -467,11 +467,19 @@ const VideoPlayer = ({
@@ -480,11 +488,19 @@ const VideoPlayer = ({
diff --git a/frontend/src/pages/ChannelAbout.tsx b/frontend/src/pages/ChannelAbout.tsx
index 1ad1cf46..54898b92 100644
--- a/frontend/src/pages/ChannelAbout.tsx
+++ b/frontend/src/pages/ChannelAbout.tsx
@@ -130,7 +130,11 @@ const ChannelAbout = () => {
{channel.channel_active && (
Youtube:{' '}
-
+
Active
@@ -316,7 +320,7 @@ const ChannelAbout = () => {
Overwrite{' '}
-
+
SponsorBlock
diff --git a/frontend/src/pages/Playlist.tsx b/frontend/src/pages/Playlist.tsx
index 37657f64..f8affa20 100644
--- a/frontend/src/pages/Playlist.tsx
+++ b/frontend/src/pages/Playlist.tsx
@@ -180,6 +180,7 @@ const Playlist = () => {
Active
diff --git a/frontend/src/pages/SettingsApplication.tsx b/frontend/src/pages/SettingsApplication.tsx
index f8e1a39b..594ef29c 100644
--- a/frontend/src/pages/SettingsApplication.tsx
+++ b/frontend/src/pages/SettingsApplication.tsx
@@ -779,7 +779,11 @@ const SettingsApplication = () => {
Make sure to contribute to this excellent project.
More details{' '}
-
+
here
.
@@ -794,7 +798,11 @@ const SettingsApplication = () => {
Make sure to contribute to this excellent project.
More details{' '}
-
+
here
.
@@ -831,7 +839,11 @@ const SettingsApplication = () => {
Enable{' '}
-
+
returnyoutubedislike
@@ -846,7 +858,7 @@ const SettingsApplication = () => {
Enable{' '}
-
+
Sponsorblock
diff --git a/frontend/src/pages/Video.tsx b/frontend/src/pages/Video.tsx
index 785a6e23..68929f38 100644
--- a/frontend/src/pages/Video.tsx
+++ b/frontend/src/pages/Video.tsx
@@ -279,7 +279,11 @@ const Video = () => {
{video.active && (
Youtube:{' '}
-
+
Active
From d19190bf6a0228a7ed18e6ca692b8541be69aee9 Mon Sep 17 00:00:00 2001
From: MerlinScheurer
Date: Sun, 22 Jun 2025 12:10:29 +0200
Subject: [PATCH 16/55] Add theater mode to normal video player
---
frontend/src/components/VideoPlayer.tsx | 57 ++++++++++++++++++++++++-
frontend/src/style.css | 30 +++++++++++++
2 files changed, 85 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx
index 001c5e7c..47a19977 100644
--- a/frontend/src/components/VideoPlayer.tsx
+++ b/frontend/src/components/VideoPlayer.tsx
@@ -126,6 +126,7 @@ const VideoPlayer = ({
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
+ const theaterModeFromStorage = localStorage.getItem('theaterMode') === 'true';
const volumeFromStorage = Number(localStorage.getItem('playerVolume') ?? 1);
const playBackSpeedFromStorage = Number(localStorage.getItem('playerSpeed') || 1);
const playBackSpeedIndex =
@@ -140,6 +141,8 @@ const VideoPlayer = ({
const [showHelpDialog, setShowHelpDialog] = useState(false);
const [showInfoDialog, setShowInfoDialog] = useState(false);
const [infoDialogContent, setInfoDialogContent] = useState('');
+ const [isTheaterMode, setIsTheaterMode] = useState(theaterModeFromStorage);
+ const [theaterModeKeyPressed, setTheaterModeKeyPressed] = useState(false);
const questionmarkPressed = useKeyPress('?');
const mutePressed = useKeyPress('m');
@@ -151,6 +154,8 @@ const VideoPlayer = ({
const arrowRightPressed = useKeyPress('ArrowRight');
const arrowLeftPressed = useKeyPress('ArrowLeft');
const pPausedPressed = useKeyPress('p');
+ const theaterModePressed = useKeyPress('t');
+ const escapePressed = useKeyPress('Escape');
const videoId = video.youtube_id;
const videoUrl = video.media_url;
@@ -345,10 +350,46 @@ const VideoPlayer = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [questionmarkPressed]);
+ useEffect(() => {
+ if (embed) {
+ return;
+ }
+
+ if (theaterModePressed && !theaterModeKeyPressed) {
+ setTheaterModeKeyPressed(true);
+
+ const newTheaterMode = !isTheaterMode;
+ setIsTheaterMode(newTheaterMode);
+
+ localStorage.setItem('theaterMode', newTheaterMode.toString());
+
+ infoDialog(newTheaterMode ? 'Theater mode' : 'Normal mode');
+ } else if (!theaterModePressed) {
+ setTheaterModeKeyPressed(false);
+ }
+ }, [theaterModePressed, isTheaterMode, theaterModeKeyPressed]);
+
+ useEffect(() => {
+ if (embed) {
+ return;
+ }
+
+ if (escapePressed && isTheaterMode) {
+ setIsTheaterMode(false);
+
+ localStorage.setItem('theaterMode', 'false');
+
+ infoDialog('Normal mode');
+ }
+ }, [escapePressed, isTheaterMode]);
+
return (
<>
-
-
+
+
Toggle fullscreen
f
+ {!embed && (
+ <>
+
+ Toggle theater mode
+ t
+
+
+ Exit theater mode
+ Esc
+
+ >
+ )}
Toggle subtitles (if available)
c
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 13a70796..3e54a45f 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -458,6 +458,36 @@ button:hover {
margin: 20px 0;
}
+.player-wrapper.theater-mode {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 1000;
+ background-color: rgba(0, 0, 0, 0.9);
+ margin: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.video-main.theater-mode {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+}
+
+.video-main.theater-mode video {
+ max-height: 95vh;
+ max-width: 95vw;
+ width: auto;
+ height: auto;
+}
+
.video-player {
display: grid;
align-content: space-evenly;
From 28f2fbd6a771ca67c109aa51a7fc881f313b6556 Mon Sep 17 00:00:00 2001
From: MerlinScheurer
Date: Sun, 22 Jun 2025 12:14:32 +0200
Subject: [PATCH 17/55] Fix remove theater mode localstorage flag
---
frontend/src/components/VideoPlayer.tsx | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx
index 47a19977..de8fc4bb 100644
--- a/frontend/src/components/VideoPlayer.tsx
+++ b/frontend/src/components/VideoPlayer.tsx
@@ -126,7 +126,6 @@ const VideoPlayer = ({
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
- const theaterModeFromStorage = localStorage.getItem('theaterMode') === 'true';
const volumeFromStorage = Number(localStorage.getItem('playerVolume') ?? 1);
const playBackSpeedFromStorage = Number(localStorage.getItem('playerSpeed') || 1);
const playBackSpeedIndex =
@@ -141,7 +140,7 @@ const VideoPlayer = ({
const [showHelpDialog, setShowHelpDialog] = useState(false);
const [showInfoDialog, setShowInfoDialog] = useState(false);
const [infoDialogContent, setInfoDialogContent] = useState('');
- const [isTheaterMode, setIsTheaterMode] = useState(theaterModeFromStorage);
+ const [isTheaterMode, setIsTheaterMode] = useState(false);
const [theaterModeKeyPressed, setTheaterModeKeyPressed] = useState(false);
const questionmarkPressed = useKeyPress('?');
@@ -361,8 +360,6 @@ const VideoPlayer = ({
const newTheaterMode = !isTheaterMode;
setIsTheaterMode(newTheaterMode);
- localStorage.setItem('theaterMode', newTheaterMode.toString());
-
infoDialog(newTheaterMode ? 'Theater mode' : 'Normal mode');
} else if (!theaterModePressed) {
setTheaterModeKeyPressed(false);
@@ -377,8 +374,6 @@ const VideoPlayer = ({
if (escapePressed && isTheaterMode) {
setIsTheaterMode(false);
- localStorage.setItem('theaterMode', 'false');
-
infoDialog('Normal mode');
}
}, [escapePressed, isTheaterMode]);
From ab3b83ed3fad5e8d56a264b419447abb6b4bb4e4 Mon Sep 17 00:00:00 2001
From: Craig Alexander
Date: Mon, 30 Jun 2025 22:36:23 -0400
Subject: [PATCH 18/55] Upgrade Python to 3.11.13 (#991)
---
Dockerfile | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 25eec9ad..c4319f71 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,7 +18,7 @@ RUN npm run build:deploy
WORKDIR /
# First stage to build python wheel
-FROM python:3.11.8-slim-bookworm AS builder
+FROM python:3.11.13-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc libldap2-dev libsasl2-dev libssl-dev git
@@ -28,7 +28,7 @@ COPY ./backend/requirements.txt /requirements.txt
RUN pip install --user -r requirements.txt
# build ffmpeg
-FROM python:3.11.8-slim-bookworm AS ffmpeg-builder
+FROM python:3.11.13-slim-bookworm AS ffmpeg-builder
ARG TARGETPLATFORM
@@ -36,7 +36,7 @@ COPY docker_assets/ffmpeg_download.py ffmpeg_download.py
RUN python ffmpeg_download.py $TARGETPLATFORM
# build final image
-FROM python:3.11.8-slim-bookworm AS tubearchivist
+FROM python:3.11.13-slim-bookworm AS tubearchivist
ARG INSTALL_DEBUG
From 08681f0e335a59c6d50b664829c0583f1b42c90e Mon Sep 17 00:00:00 2001
From: Craig Alexander
Date: Mon, 30 Jun 2025 23:09:18 -0400
Subject: [PATCH 19/55] Add option to update yt-dlp on restart (#992)
* Add option to update yt-dlp on restart
* Address pr feedback
---
README.md | 1 +
docker_assets/run.sh | 9 +++++++++
2 files changed, 10 insertions(+)
diff --git a/README.md b/README.md
index 7d3b2a96..9c50cd8c 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,7 @@ All environment variables are explained in detail in the docs [here](https://doc
| ELASTIC_USER | Change the default ElasticSearch user | Optional |
| TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) |
| DISABLE_STATIC_AUTH | Remove authentication from media files, (Google Cast...) | [Read more](https://docs.tubearchivist.com/installation/env-vars/#disable_static_auth) |
+| TA_AUTO_UPDATE_YTDLP | Configure TA to automatically install the latest yt-dlp on container start | Optional |
| DJANGO_DEBUG | Return additional error messages, for debug only | Optional |
| TA_LOGIN_AUTH_MODE | Configure the order of login authentication backends (Default: single) | Optional |
diff --git a/docker_assets/run.sh b/docker_assets/run.sh
index 9f065d7b..b09bee22 100644
--- a/docker_assets/run.sh
+++ b/docker_assets/run.sh
@@ -9,6 +9,15 @@ else
LOGLEVEL="INFO"
fi
+# update yt-dlp if needed
+if [[ "${TA_AUTO_UPDATE_YTDLP,,}" =~ ^(release|nightly)$ ]]; then
+ echo "Updating yt-dlp..."
+ preflag=$([[ "${TA_AUTO_UPDATE_YTDLP,,}" == "nightly" ]] && echo "--pre" || echo "")
+ python -m pip install --target=/root/.local/bin --upgrade $preflag "yt-dlp[default]" || {
+ echo "yt-dlp update failed"
+ }
+fi
+
# stop on pending manual migration
python manage.py ta_stop_on_error
From dfb984590d926072a8b817310303473f97198268 Mon Sep 17 00:00:00 2001
From: Simon
Date: Tue, 1 Jul 2025 11:06:49 +0700
Subject: [PATCH 20/55] bump requirements
---
backend/requirements-dev.txt | 6 +++---
backend/requirements.txt | 6 +++---
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
index a5ee0352..30696ad3 100644
--- a/backend/requirements-dev.txt
+++ b/backend/requirements-dev.txt
@@ -4,7 +4,7 @@ pre-commit==4.2.0
pylint-django==2.6.1
pylint==3.3.7
pytest-django==4.11.1
-pytest==8.4.0
-python-dotenv==1.1.0
+pytest==8.4.1
+python-dotenv==1.1.1
requirementscheck==0.0.6
-types-requests==2.32.0.20250602
+types-requests==2.32.4.20250611
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 9751c2cb..b939b37f 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -3,13 +3,13 @@ celery==5.5.3
django-auth-ldap==5.2.0
django-celery-beat==2.8.1
django-cors-headers==4.7.0
-Django==5.2.2
+Django==5.2.3
djangorestframework==3.16.0
drf-spectacular==0.28.0
Pillow==11.2.1
redis==6.2.0
requests==2.32.4
ryd-client==0.0.6
-uvicorn==0.34.3
+uvicorn==0.35.0
whitenoise==6.9.0
-yt-dlp[default]==2025.6.9
+yt-dlp[default]==2025.6.30
From bc66b4bef62e9b5bb671e35c0c528509746cf5b6 Mon Sep 17 00:00:00 2001
From: Simon
Date: Tue, 1 Jul 2025 11:08:37 +0700
Subject: [PATCH 21/55] add unstable tag
---
backend/config/settings.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 39d2186e..abcc4aa8 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -316,7 +316,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
-TA_VERSION = "v0.5.4"
+TA_VERSION = "v0.5.5-unstable"
# API
REST_FRAMEWORK = {
From 79ee903f90bca22506991b80784ec7944dd293e3 Mon Sep 17 00:00:00 2001
From: MerlinScheurer
Date: Thu, 3 Jul 2025 00:05:14 +0200
Subject: [PATCH 22/55] Update frontend dependencies
---
frontend/package-lock.json | 508 ++++++++++++++++++-------------------
frontend/package.json | 18 +-
2 files changed, 263 insertions(+), 263 deletions(-)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a5be2f31..44cefb06 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,24 +11,24 @@
"dompurify": "^3.2.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
- "react-router-dom": "^7.6.0",
- "zustand": "^5.0.5"
+ "react-router-dom": "^7.6.3",
+ "zustand": "^5.0.6"
},
"devDependencies": {
- "@types/react": "^19.1.5",
- "@types/react-dom": "^19.1.5",
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
- "@vitejs/plugin-react-swc": "^3.10.0",
- "eslint": "^9.27.0",
+ "@vitejs/plugin-react-swc": "^3.10.2",
+ "eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
- "globals": "^16.1.0",
- "prettier": "3.5.3",
+ "globals": "^16.3.0",
+ "prettier": "3.6.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
- "vite": ">=6.3.5",
+ "vite": ">=7.0.0",
"vite-plugin-checker": "^0.9.3"
}
},
@@ -512,9 +512,9 @@
}
},
"node_modules/@eslint/config-array": {
- "version": "0.20.0",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
- "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
+ "version": "0.21.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
+ "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -527,9 +527,9 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -551,9 +551,9 @@
}
},
"node_modules/@eslint/config-helpers": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz",
- "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==",
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
+ "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -598,9 +598,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -635,9 +635,9 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.27.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz",
- "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==",
+ "version": "9.30.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz",
+ "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -776,16 +776,16 @@
}
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.9",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz",
- "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==",
+ "version": "1.0.0-beta.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
+ "integrity": "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
- "integrity": "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz",
+ "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==",
"cpu": [
"arm"
],
@@ -797,9 +797,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz",
- "integrity": "sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz",
+ "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==",
"cpu": [
"arm64"
],
@@ -811,9 +811,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz",
- "integrity": "sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz",
+ "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==",
"cpu": [
"arm64"
],
@@ -825,9 +825,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz",
- "integrity": "sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz",
+ "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==",
"cpu": [
"x64"
],
@@ -839,9 +839,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz",
- "integrity": "sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz",
+ "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==",
"cpu": [
"arm64"
],
@@ -853,9 +853,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz",
- "integrity": "sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz",
+ "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==",
"cpu": [
"x64"
],
@@ -867,9 +867,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz",
- "integrity": "sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz",
+ "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==",
"cpu": [
"arm"
],
@@ -881,9 +881,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz",
- "integrity": "sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz",
+ "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==",
"cpu": [
"arm"
],
@@ -895,9 +895,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz",
- "integrity": "sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz",
+ "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==",
"cpu": [
"arm64"
],
@@ -909,9 +909,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz",
- "integrity": "sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz",
+ "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==",
"cpu": [
"arm64"
],
@@ -923,9 +923,9 @@
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz",
- "integrity": "sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz",
+ "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==",
"cpu": [
"loong64"
],
@@ -937,9 +937,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz",
- "integrity": "sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz",
+ "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==",
"cpu": [
"ppc64"
],
@@ -951,9 +951,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz",
- "integrity": "sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz",
+ "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==",
"cpu": [
"riscv64"
],
@@ -965,9 +965,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz",
- "integrity": "sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz",
+ "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==",
"cpu": [
"riscv64"
],
@@ -979,9 +979,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz",
- "integrity": "sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz",
+ "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==",
"cpu": [
"s390x"
],
@@ -993,9 +993,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz",
- "integrity": "sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz",
+ "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==",
"cpu": [
"x64"
],
@@ -1007,9 +1007,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz",
- "integrity": "sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz",
+ "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==",
"cpu": [
"x64"
],
@@ -1021,9 +1021,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz",
- "integrity": "sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz",
+ "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==",
"cpu": [
"arm64"
],
@@ -1035,9 +1035,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz",
- "integrity": "sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz",
+ "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==",
"cpu": [
"ia32"
],
@@ -1049,9 +1049,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz",
- "integrity": "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz",
+ "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==",
"cpu": [
"x64"
],
@@ -1063,15 +1063,15 @@
]
},
"node_modules/@swc/core": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.29.tgz",
- "integrity": "sha512-g4mThMIpWbNhV8G2rWp5a5/Igv8/2UFRJx2yImrLGMgrDDYZIopqZ/z0jZxDgqNA1QDx93rpwNF7jGsxVWcMlA==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.9.tgz",
+ "integrity": "sha512-O+LfT2JlVMsIMWG9x+rdxg8GzpzeGtCZQfXV7cKc1PjIKUkLFf1QJ7okuseA4f/9vncu37dQ2ZcRrPKy0Ndd5g==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
- "@swc/types": "^0.1.21"
+ "@swc/types": "^0.1.23"
},
"engines": {
"node": ">=10"
@@ -1081,16 +1081,16 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
- "@swc/core-darwin-arm64": "1.11.29",
- "@swc/core-darwin-x64": "1.11.29",
- "@swc/core-linux-arm-gnueabihf": "1.11.29",
- "@swc/core-linux-arm64-gnu": "1.11.29",
- "@swc/core-linux-arm64-musl": "1.11.29",
- "@swc/core-linux-x64-gnu": "1.11.29",
- "@swc/core-linux-x64-musl": "1.11.29",
- "@swc/core-win32-arm64-msvc": "1.11.29",
- "@swc/core-win32-ia32-msvc": "1.11.29",
- "@swc/core-win32-x64-msvc": "1.11.29"
+ "@swc/core-darwin-arm64": "1.12.9",
+ "@swc/core-darwin-x64": "1.12.9",
+ "@swc/core-linux-arm-gnueabihf": "1.12.9",
+ "@swc/core-linux-arm64-gnu": "1.12.9",
+ "@swc/core-linux-arm64-musl": "1.12.9",
+ "@swc/core-linux-x64-gnu": "1.12.9",
+ "@swc/core-linux-x64-musl": "1.12.9",
+ "@swc/core-win32-arm64-msvc": "1.12.9",
+ "@swc/core-win32-ia32-msvc": "1.12.9",
+ "@swc/core-win32-x64-msvc": "1.12.9"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -1102,9 +1102,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.29.tgz",
- "integrity": "sha512-whsCX7URzbuS5aET58c75Dloby3Gtj/ITk2vc4WW6pSDQKSPDuONsIcZ7B2ng8oz0K6ttbi4p3H/PNPQLJ4maQ==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.12.9.tgz",
+ "integrity": "sha512-GACFEp4nD6V+TZNR2JwbMZRHB+Yyvp14FrcmB6UCUYmhuNWjkxi+CLnEvdbuiKyQYv0zA+TRpCHZ+whEs6gwfA==",
"cpu": [
"arm64"
],
@@ -1119,9 +1119,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.29.tgz",
- "integrity": "sha512-S3eTo/KYFk+76cWJRgX30hylN5XkSmjYtCBnM4jPLYn7L6zWYEPajsFLmruQEiTEDUg0gBEWLMNyUeghtswouw==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.12.9.tgz",
+ "integrity": "sha512-hv2kls7Ilkm2EpeJz+I9MCil7pGS3z55ZAgZfxklEuYsxpICycxeH+RNRv4EraggN44ms+FWCjtZFu0LGg2V3g==",
"cpu": [
"x64"
],
@@ -1136,9 +1136,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.29.tgz",
- "integrity": "sha512-o9gdshbzkUMG6azldHdmKklcfrcMx+a23d/2qHQHPDLUPAN+Trd+sDQUYArK5Fcm7TlpG4sczz95ghN0DMkM7g==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.12.9.tgz",
+ "integrity": "sha512-od9tDPiG+wMU9wKtd6y3nYJdNqgDOyLdgRRcrj1/hrbHoUPOM8wZQZdwQYGarw63iLXGgsw7t5HAF9Yc51ilFA==",
"cpu": [
"arm"
],
@@ -1153,9 +1153,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.29.tgz",
- "integrity": "sha512-sLoaciOgUKQF1KX9T6hPGzvhOQaJn+3DHy4LOHeXhQqvBgr+7QcZ+hl4uixPKTzxk6hy6Hb0QOvQEdBAAR1gXw==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.12.9.tgz",
+ "integrity": "sha512-6qx1ka9LHcLzxIgn2Mros+CZLkHK2TawlXzi/h7DJeNnzi8F1Hw0Yzjp8WimxNCg6s2n+o3jnmin1oXB7gg8rw==",
"cpu": [
"arm64"
],
@@ -1170,9 +1170,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.29.tgz",
- "integrity": "sha512-PwjB10BC0N+Ce7RU/L23eYch6lXFHz7r3NFavIcwDNa/AAqywfxyxh13OeRy+P0cg7NDpWEETWspXeI4Ek8otw==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.12.9.tgz",
+ "integrity": "sha512-yghFZWKPVVGbUdqiD7ft23G0JX6YFGDJPz9YbLLAwGuKZ9th3/jlWoQDAw1Naci31LQhVC+oIji6ozihSuwB2A==",
"cpu": [
"arm64"
],
@@ -1187,9 +1187,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.29.tgz",
- "integrity": "sha512-i62vBVoPaVe9A3mc6gJG07n0/e7FVeAvdD9uzZTtGLiuIfVfIBta8EMquzvf+POLycSk79Z6lRhGPZPJPYiQaA==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.12.9.tgz",
+ "integrity": "sha512-SFUxyhWLZRNL8QmgGNqdi2Q43PNyFVkRZ2zIif30SOGFSxnxcf2JNeSeBgKIGVgaLSuk6xFVVCtJ3KIeaStgRg==",
"cpu": [
"x64"
],
@@ -1204,9 +1204,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.29.tgz",
- "integrity": "sha512-YER0XU1xqFdK0hKkfSVX1YIyCvMDI7K07GIpefPvcfyNGs38AXKhb2byySDjbVxkdl4dycaxxhRyhQ2gKSlsFQ==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.12.9.tgz",
+ "integrity": "sha512-9FB0wM+6idCGTI20YsBNBg9xSWtkDBymnpaTCsZM3qDc0l4uOpJMqbfWhQvp17x7r/ulZfb2QY8RDvQmCL6AcQ==",
"cpu": [
"x64"
],
@@ -1221,9 +1221,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.29.tgz",
- "integrity": "sha512-po+WHw+k9g6FAg5IJ+sMwtA/fIUL3zPQ4m/uJgONBATCVnDDkyW6dBA49uHNVtSEvjvhuD8DVWdFP847YTcITw==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.12.9.tgz",
+ "integrity": "sha512-zHOusMVbOH9ik5RtRrMiGzLpKwxrPXgXkBm3SbUCa65HAdjV33NZ0/R9Rv1uPESALtEl2tzMYLUxYA5ECFDFhA==",
"cpu": [
"arm64"
],
@@ -1238,9 +1238,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.29.tgz",
- "integrity": "sha512-h+NjOrbqdRBYr5ItmStmQt6x3tnhqgwbj9YxdGPepbTDamFv7vFnhZR0YfB3jz3UKJ8H3uGJ65Zw1VsC+xpFkg==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.12.9.tgz",
+ "integrity": "sha512-aWZf0PqE0ot7tCuhAjRkDFf41AzzSQO0x2xRfTbnhpROp57BRJ/N5eee1VULO/UA2PIJRG7GKQky5bSGBYlFug==",
"cpu": [
"ia32"
],
@@ -1255,9 +1255,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
- "version": "1.11.29",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.29.tgz",
- "integrity": "sha512-Q8cs2BDV9wqDvqobkXOYdC+pLUSEpX/KvI0Dgfun1F+LzuLotRFuDhrvkU9ETJA6OnD2+Fn/ieHgloiKA/Mn/g==",
+ "version": "1.12.9",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.12.9.tgz",
+ "integrity": "sha512-C25fYftXOras3P3anSUeXXIpxmEkdAcsIL9yrr0j1xepTZ/yKwpnQ6g3coj8UXdeJy4GTVlR6+Ow/QiBgZQNOg==",
"cpu": [
"x64"
],
@@ -1279,9 +1279,9 @@
"license": "Apache-2.0"
},
"node_modules/@swc/types": {
- "version": "0.1.21",
- "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.21.tgz",
- "integrity": "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==",
+ "version": "0.1.23",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.23.tgz",
+ "integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1289,9 +1289,9 @@
}
},
"node_modules/@types/estree": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
- "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
@@ -1303,9 +1303,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
- "version": "19.1.5",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz",
- "integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==",
+ "version": "19.1.8",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
+ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -1313,9 +1313,9 @@
}
},
"node_modules/@types/react-dom": {
- "version": "19.1.5",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz",
- "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==",
+ "version": "19.1.6",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
+ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -1533,23 +1533,23 @@
}
},
"node_modules/@vitejs/plugin-react-swc": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.10.0.tgz",
- "integrity": "sha512-ZmkdHw3wo/o/Rk05YsXZs/DJAfY2CdQ5DUAjoWji+PEr+hYADdGMCGgEAILbiKj+CjspBTuTACBcWDrmC8AUfw==",
+ "version": "3.10.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.10.2.tgz",
+ "integrity": "sha512-xD3Rdvrt5LgANug7WekBn1KhcvLn1H3jNBfJRL3reeOIua/WnZOEV5qi5qIBq5T8R0jUDmRtxuvk4bPhzGHDWw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@rolldown/pluginutils": "1.0.0-beta.9",
- "@swc/core": "^1.11.22"
+ "@rolldown/pluginutils": "1.0.0-beta.11",
+ "@swc/core": "^1.11.31"
},
"peerDependencies": {
- "vite": "^4 || ^5 || ^6"
+ "vite": "^4 || ^5 || ^6 || ^7.0.0-beta.0"
}
},
"node_modules/acorn": {
- "version": "8.14.1",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
- "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -1630,9 +1630,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1722,6 +1722,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
+ "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1833,19 +1842,19 @@
}
},
"node_modules/eslint": {
- "version": "9.27.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz",
- "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==",
+ "version": "9.30.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz",
+ "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
- "@eslint/config-array": "^0.20.0",
- "@eslint/config-helpers": "^0.2.1",
+ "@eslint/config-array": "^0.21.0",
+ "@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.14.0",
"@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.27.0",
+ "@eslint/js": "9.30.1",
"@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@@ -1857,9 +1866,9 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
- "eslint-scope": "^8.3.0",
- "eslint-visitor-keys": "^4.2.0",
- "espree": "^10.3.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@@ -1933,9 +1942,9 @@
}
},
"node_modules/eslint-scope": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
- "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -1963,9 +1972,9 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1974,9 +1983,9 @@
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
- "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -2000,15 +2009,15 @@
}
},
"node_modules/espree": {
- "version": "10.3.0",
- "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
- "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
- "acorn": "^8.14.0",
+ "acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^4.2.0"
+ "eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2018,9 +2027,9 @@
}
},
"node_modules/espree/node_modules/eslint-visitor-keys": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
- "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -2230,9 +2239,9 @@
}
},
"node_modules/globals": {
- "version": "16.1.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz",
- "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==",
+ "version": "16.3.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
+ "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2631,9 +2640,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.3",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
- "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -2651,7 +2660,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.8",
+ "nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2670,9 +2679,9 @@
}
},
"node_modules/prettier": {
- "version": "3.5.3",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
- "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -2738,9 +2747,9 @@
}
},
"node_modules/react-router": {
- "version": "7.6.0",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz",
- "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==",
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz",
+ "integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -2760,12 +2769,12 @@
}
},
"node_modules/react-router-dom": {
- "version": "7.6.0",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz",
- "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==",
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.3.tgz",
+ "integrity": "sha512-DiWJm9qdUAmiJrVWaeJdu4TKu13+iB/8IEi0EW/XgaHCjW/vWGrwzup0GVvaMteuZjKnh5bEvJP/K0MDnzawHw==",
"license": "MIT",
"dependencies": {
- "react-router": "7.6.0"
+ "react-router": "7.6.3"
},
"engines": {
"node": ">=20.0.0"
@@ -2775,15 +2784,6 @@
"react-dom": ">=18"
}
},
- "node_modules/react-router/node_modules/cookie": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
- "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -2820,13 +2820,13 @@
}
},
"node_modules/rollup": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz",
- "integrity": "sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==",
+ "version": "4.44.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz",
+ "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@types/estree": "1.0.7"
+ "@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
@@ -2836,26 +2836,26 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.39.0",
- "@rollup/rollup-android-arm64": "4.39.0",
- "@rollup/rollup-darwin-arm64": "4.39.0",
- "@rollup/rollup-darwin-x64": "4.39.0",
- "@rollup/rollup-freebsd-arm64": "4.39.0",
- "@rollup/rollup-freebsd-x64": "4.39.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.39.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.39.0",
- "@rollup/rollup-linux-arm64-gnu": "4.39.0",
- "@rollup/rollup-linux-arm64-musl": "4.39.0",
- "@rollup/rollup-linux-loongarch64-gnu": "4.39.0",
- "@rollup/rollup-linux-powerpc64le-gnu": "4.39.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.39.0",
- "@rollup/rollup-linux-riscv64-musl": "4.39.0",
- "@rollup/rollup-linux-s390x-gnu": "4.39.0",
- "@rollup/rollup-linux-x64-gnu": "4.39.0",
- "@rollup/rollup-linux-x64-musl": "4.39.0",
- "@rollup/rollup-win32-arm64-msvc": "4.39.0",
- "@rollup/rollup-win32-ia32-msvc": "4.39.0",
- "@rollup/rollup-win32-x64-msvc": "4.39.0",
+ "@rollup/rollup-android-arm-eabi": "4.44.1",
+ "@rollup/rollup-android-arm64": "4.44.1",
+ "@rollup/rollup-darwin-arm64": "4.44.1",
+ "@rollup/rollup-darwin-x64": "4.44.1",
+ "@rollup/rollup-freebsd-arm64": "4.44.1",
+ "@rollup/rollup-freebsd-x64": "4.44.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.44.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.44.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.44.1",
+ "@rollup/rollup-linux-arm64-musl": "4.44.1",
+ "@rollup/rollup-linux-loongarch64-gnu": "4.44.1",
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.44.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.44.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.44.1",
+ "@rollup/rollup-linux-x64-gnu": "4.44.1",
+ "@rollup/rollup-linux-x64-musl": "4.44.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.44.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.44.1",
+ "@rollup/rollup-win32-x64-msvc": "4.44.1",
"fsevents": "~2.3.2"
}
},
@@ -2991,9 +2991,9 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
- "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
+ "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3135,24 +3135,24 @@
}
},
"node_modules/vite": {
- "version": "6.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
- "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz",
+ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
- "fdir": "^6.4.4",
+ "fdir": "^6.4.6",
"picomatch": "^4.0.2",
- "postcss": "^8.5.3",
- "rollup": "^4.34.9",
- "tinyglobby": "^0.2.13"
+ "postcss": "^8.5.6",
+ "rollup": "^4.40.0",
+ "tinyglobby": "^0.2.14"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -3161,14 +3161,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
- "less": "*",
+ "less": "^4.0.0",
"lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@@ -3285,9 +3285,9 @@
}
},
"node_modules/vite/node_modules/fdir": {
- "version": "6.4.4",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
- "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
+ "version": "6.4.6",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
+ "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -3359,9 +3359,9 @@
}
},
"node_modules/zustand": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz",
- "integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==",
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz",
+ "integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
diff --git a/frontend/package.json b/frontend/package.json
index 6d205445..2407f73b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,24 +14,24 @@
"dompurify": "^3.2.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
- "react-router-dom": "^7.6.0",
- "zustand": "^5.0.5"
+ "react-router-dom": "^7.6.3",
+ "zustand": "^5.0.6"
},
"devDependencies": {
- "@types/react": "^19.1.5",
- "@types/react-dom": "^19.1.5",
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
- "@vitejs/plugin-react-swc": "^3.10.0",
- "eslint": "^9.27.0",
+ "@vitejs/plugin-react-swc": "^3.10.2",
+ "eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
- "globals": "^16.1.0",
- "prettier": "3.5.3",
+ "globals": "^16.3.0",
+ "prettier": "3.6.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
- "vite": ">=6.3.5",
+ "vite": ">=7.0.0",
"vite-plugin-checker": "^0.9.3"
}
}
From e88ae9e2f0a91375f70ab7dc097224ab74d03f18 Mon Sep 17 00:00:00 2001
From: MerlinScheurer
Date: Sun, 6 Jul 2025 10:08:58 +0200
Subject: [PATCH 23/55] Fix show unknown codec in TableView when codec is
missing
---
frontend/src/components/VideoListItemTable.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/VideoListItemTable.tsx b/frontend/src/components/VideoListItemTable.tsx
index afd62216..83edb39a 100644
--- a/frontend/src/components/VideoListItemTable.tsx
+++ b/frontend/src/components/VideoListItemTable.tsx
@@ -48,9 +48,9 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
{vid_type}
{`${videoStream.width}x${videoStream.height}`}
{humanFileSize(media_size, useSiUnits)}
- {videoStream.codec}
+ {videoStream.codec || 'unknown'}
{humanFileSize(videoStream.bitrate, useSiUnits)}
- {audioStream.codec}
+ {audioStream.codec || 'unknown'}
{humanFileSize(audioStream.bitrate, useSiUnits)}
);
From 28ac0ba620fe822ce3b639a5c722fbe64a5d3491 Mon Sep 17 00:00:00 2001
From: MerlinScheurer
Date: Sun, 6 Jul 2025 10:36:51 +0200
Subject: [PATCH 24/55] Fix video and audio streams can be undefined (#997)
---
frontend/src/components/VideoListItemTable.tsx | 18 ++++++++++++------
frontend/src/pages/Home.tsx | 2 +-
2 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/frontend/src/components/VideoListItemTable.tsx b/frontend/src/components/VideoListItemTable.tsx
index 83edb39a..3542406c 100644
--- a/frontend/src/components/VideoListItemTable.tsx
+++ b/frontend/src/components/VideoListItemTable.tsx
@@ -6,6 +6,11 @@ import humanFileSize from '../functions/humanFileSize';
import { FileSizeUnits } from '../api/actions/updateUserConfig';
import { useUserConfigStore } from '../stores/UserConfigStore';
+const StreamsTypeEmun = {
+ Video: 'video',
+ Audio: 'audio',
+};
+
type VideoListItemProps = {
videoList: VideoType[] | undefined;
viewStyle: ViewStylesType;
@@ -35,7 +40,8 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
{videoList?.map(({ youtube_id, title, channel, vid_type, media_size, streams }) => {
- const [videoStream, audioStream] = streams;
+ const videoStream = streams?.find(s => s.type === StreamsTypeEmun.Video);
+ const audioStream = streams?.find(s => s.type === StreamsTypeEmun.Audio);
return (
@@ -46,12 +52,12 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
{title}
{vid_type}
- {`${videoStream.width}x${videoStream.height}`}
+ {`${videoStream?.width || '-'}x${videoStream?.height || '-'}`}
{humanFileSize(media_size, useSiUnits)}
- {videoStream.codec || 'unknown'}
- {humanFileSize(videoStream.bitrate, useSiUnits)}
- {audioStream.codec || 'unknown'}
- {humanFileSize(audioStream.bitrate, useSiUnits)}
+ {videoStream?.codec || '-'}
+ {humanFileSize(videoStream?.bitrate || 0, useSiUnits)}
+ {audioStream?.codec || '-'}
+ {humanFileSize(audioStream?.bitrate || 0, useSiUnits)}
);
})}
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
index 67fb7fac..291725a6 100644
--- a/frontend/src/pages/Home.tsx
+++ b/frontend/src/pages/Home.tsx
@@ -69,7 +69,7 @@ export type VideoType = {
sponsorblock?: SponsorBlockType;
playlist: string[];
stats: StatsType;
- streams: StreamType[];
+ streams: StreamType[] | undefined;
subtitles: Subtitles[];
tags: string[];
title: string;
From 8d9cb9261e67fb810aedcef77880c85f68fe50bb Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 6 Jul 2025 16:01:55 +0700
Subject: [PATCH 25/55] add vid_type filter for download list view
---
backend/download/serializers.py | 3 +++
backend/download/views.py | 6 +++++
frontend/src/api/loader/loadDownloadQueue.ts | 8 +++++-
frontend/src/pages/Download.tsx | 26 ++++++++++++++++++--
4 files changed, 40 insertions(+), 3 deletions(-)
diff --git a/backend/download/serializers.py b/backend/download/serializers.py
index 4ae1b180..82ceed0e 100644
--- a/backend/download/serializers.py
+++ b/backend/download/serializers.py
@@ -42,6 +42,9 @@ class DownloadListQuerySerializer(
filter = serializers.ChoiceField(
choices=["pending", "ignore"], required=False
)
+ vid_type = serializers.ChoiceField(
+ choices=VideoTypeEnum.values_known(), required=False
+ )
channel = serializers.CharField(required=False, help_text="channel ID")
page = serializers.IntegerField(required=False)
diff --git a/backend/download/views.py b/backend/download/views.py
index 6ef1f7ff..963b6365 100644
--- a/backend/download/views.py
+++ b/backend/download/views.py
@@ -65,6 +65,12 @@ class DownloadApiListView(ApiBaseView):
{"term": {"channel_id": {"value": filter_channel}}}
)
+ vid_type_filter = validated_data.get("vid_type")
+ if vid_type_filter:
+ must_list.append(
+ {"term": {"vid_type": {"value": vid_type_filter}}}
+ )
+
self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request)
diff --git a/frontend/src/api/loader/loadDownloadQueue.ts b/frontend/src/api/loader/loadDownloadQueue.ts
index d74f4bae..d4979f75 100644
--- a/frontend/src/api/loader/loadDownloadQueue.ts
+++ b/frontend/src/api/loader/loadDownloadQueue.ts
@@ -1,11 +1,17 @@
import APIClient from '../../functions/APIClient';
import { DownloadResponseType } from '../../pages/Download';
-const loadDownloadQueue = async (page: number, channelId: string | null, showIgnored: boolean) => {
+const loadDownloadQueue = async (
+ page: number,
+ channelId: string | null,
+ vid_type: string | null,
+ showIgnored: boolean,
+) => {
const searchParams = new URLSearchParams();
if (page) searchParams.append('page', page.toString());
if (channelId) searchParams.append('channel', channelId);
+ if (vid_type) searchParams.append('vid_type', vid_type);
searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx
index f08de4fa..a6778375 100644
--- a/frontend/src/pages/Download.tsx
+++ b/frontend/src/pages/Download.tsx
@@ -53,6 +53,7 @@ const Download = () => {
const channelFilterFromUrl = searchParams.get('channel');
const ignoredOnlyParam = searchParams.get('ignored');
+ const vidTypeFilterFromUrl = searchParams.get('vid-type');
const [refresh, setRefresh] = useState(false);
const [showHiddenForm, setShowHiddenForm] = useState(false);
@@ -104,6 +105,7 @@ const Download = () => {
const videosResponse = await loadDownloadQueue(
currentPage,
channelFilterFromUrl,
+ vidTypeFilterFromUrl,
showIgnored,
);
const { data: channelResponseData } = videosResponse ?? {};
@@ -123,7 +125,7 @@ const Download = () => {
useEffect(() => {
setRefresh(true);
- }, [channelFilterFromUrl, currentPage, showIgnored]);
+ }, [channelFilterFromUrl, vidTypeFilterFromUrl, currentPage, showIgnored]);
useEffect(() => {
(async () => {
@@ -257,6 +259,26 @@ const Download = () => {
+ {
+ const value = event.currentTarget.value;
+ const params = searchParams;
+ if (value !== 'all') {
+ params.set('vid-type', value);
+ } else {
+ params.delete('vid-type');
+ }
+ setSearchParams(params);
+ }}
+ >
+ all types
+ Videos
+ Streams
+ Shorts
+
{channelAggsList && channelAggsList.length > 1 && (
{
setSearchParams(params);
}}
>
- all
+ all channels
{channelAggsList.map(channel => {
const [name, id] = channel.key;
const count = channel.doc_count;
From a0f40d99708790a422c7aa6fea0676df12b1db5a Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 6 Jul 2025 16:36:16 +0700
Subject: [PATCH 26/55] backend bulk delete filter
---
backend/download/serializers.py | 4 ++++
backend/download/src/queue.py | 12 ++++++++++--
backend/download/views.py | 11 ++++++++++-
3 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/backend/download/serializers.py b/backend/download/serializers.py
index 82ceed0e..5ab3eca8 100644
--- a/backend/download/serializers.py
+++ b/backend/download/serializers.py
@@ -53,6 +53,10 @@ class DownloadListQueueDeleteQuerySerializer(serializers.Serializer):
"""serialize bulk delete download queue query string"""
filter = serializers.ChoiceField(choices=["pending", "ignore"])
+ channel = serializers.CharField(required=False, help_text="channel ID")
+ vid_type = serializers.ChoiceField(
+ choices=VideoTypeEnum.values_known(), required=False
+ )
class AddDownloadItemSerializer(serializers.Serializer):
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index 7110ceaf..eaae66c3 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -100,9 +100,17 @@ class PendingInteract:
path = f"ta_download/_doc/{self.youtube_id}"
_, _ = ElasticWrap(path).delete(refresh=True)
- def delete_by_status(self):
+ def delete_bulk(self, channel_id: str | None, vid_type: str | None):
"""delete all matching item by status"""
- data = {"query": {"term": {"status": {"value": self.status}}}}
+ must_list = [{"term": {"status": {"value": self.status}}}]
+ if channel_id:
+ must_list.append({"term": {"channel_id": {"value": channel_id}}})
+
+ if vid_type:
+ must_list.append({"term": {"vid_type": {"value": vid_type}}})
+
+ data = {"query": {"bool": {"must": must_list}}}
+
path = "ta_download/_delete_by_query"
_, _ = ElasticWrap(path).post(data=data)
diff --git a/backend/download/views.py b/backend/download/views.py
index 963b6365..b3cec1a9 100644
--- a/backend/download/views.py
+++ b/backend/download/views.py
@@ -138,9 +138,18 @@ class DownloadApiListView(ApiBaseView):
validated_query = serializer.validated_data
query_filter = validated_query["filter"]
+ channel = validated_query.get("channel")
+ vid_type = validated_query.get("vid_type")
message = f"delete queue by status: {query_filter}"
+ if channel:
+ message += f" - filter by channel: {channel}"
+ if vid_type:
+ message += f" - filter by vid_type: {vid_type}"
+
print(message)
- PendingInteract(status=query_filter).delete_by_status()
+ PendingInteract(status=query_filter).delete_bulk(
+ channel_id=channel, vid_type=vid_type
+ )
return Response(status=204)
From 2a70f7ab58026f4dc48ada35b5fcbda9727be44d Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 6 Jul 2025 17:36:04 +0700
Subject: [PATCH 27/55] move bulk delete to download actions section
---
backend/download/src/queue.py | 2 +-
.../actions/deleteDownloadQueueByFilter.ts | 8 ++-
frontend/src/pages/Download.tsx | 57 +++++++++++++++++++
frontend/src/pages/SettingsActions.tsx | 27 ---------
4 files changed, 65 insertions(+), 29 deletions(-)
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index eaae66c3..3b21fc68 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -111,7 +111,7 @@ class PendingInteract:
data = {"query": {"bool": {"must": must_list}}}
- path = "ta_download/_delete_by_query"
+ path = "ta_download/_delete_by_query?refresh=true"
_, _ = ElasticWrap(path).post(data=data)
def update_status(self):
diff --git a/frontend/src/api/actions/deleteDownloadQueueByFilter.ts b/frontend/src/api/actions/deleteDownloadQueueByFilter.ts
index 65a01a3c..9d4f5902 100644
--- a/frontend/src/api/actions/deleteDownloadQueueByFilter.ts
+++ b/frontend/src/api/actions/deleteDownloadQueueByFilter.ts
@@ -2,9 +2,15 @@ import APIClient from '../../functions/APIClient';
type FilterType = 'ignore' | 'pending';
-const deleteDownloadQueueByFilter = async (filter: FilterType) => {
+const deleteDownloadQueueByFilter = async (
+ filter: FilterType,
+ channel: string | null,
+ vid_type: string | null,
+) => {
const searchParams = new URLSearchParams();
if (filter) searchParams.append('filter', filter);
+ if (channel) searchParams.append('channel', channel);
+ if (vid_type) searchParams.append('vid_type', vid_type);
return APIClient(`/api/download/?${searchParams.toString()}`, {
method: 'DELETE',
diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx
index a6778375..a4a4388b 100644
--- a/frontend/src/pages/Download.tsx
+++ b/frontend/src/pages/Download.tsx
@@ -21,6 +21,7 @@ import loadDownloadAggs, { DownloadAggsType } from '../api/loader/loadDownloadAg
import { useUserConfigStore } from '../stores/UserConfigStore';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
import { ApiResponseType } from '../functions/APIClient';
+import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
type Download = {
auto_start: boolean;
@@ -57,6 +58,8 @@ const Download = () => {
const [refresh, setRefresh] = useState(false);
const [showHiddenForm, setShowHiddenForm] = useState(false);
+ const [showQueueActions, setShowQueueActions] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [downloadPending, setDownloadPending] = useState(false);
const [rescanPending, setRescanPending] = useState(false);
@@ -86,6 +89,9 @@ const Download = () => {
const gridItems = userConfig.grid_items;
const showIgnored =
ignoredOnlyParam !== null ? ignoredOnlyParam === 'true' : userConfig.show_ignored_only;
+
+ const showIgnoredFilter = showIgnored ? 'ignore' : 'pending';
+
const isGridView = viewStyle === ViewStylesEnum.Grid;
const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
@@ -259,6 +265,9 @@ const Download = () => {
+
setShowQueueActions(!showQueueActions)}>
+ {showQueueActions ? 'Hide Actions' : 'Show Actions'}
+
{
{channel_filter_name}
>
)}
+ {vidTypeFilterFromUrl && (
+ <>
+ {' - by type '}
+ {vidTypeFilterFromUrl}
+ >
+ )}
+ {showQueueActions && (
+
+
Bulk actions
+
+ Applied filtered by status '{showIgnoredFilter}'
+ {channelFilterFromUrl && (
+
+ {' '}
+ and by channel: '{channel_filter_name}'
+
+ )}
+ {vidTypeFilterFromUrl && (
+
+ {' '}
+ and by type: '{vidTypeFilterFromUrl}'
+
+ )}
+
+
+ {showDeleteConfirm ? (
+ <>
+ {
+ await deleteDownloadQueueByFilter(
+ showIgnoredFilter,
+ channelFilterFromUrl,
+ vidTypeFilterFromUrl,
+ );
+ setRefresh(true);
+ }}
+ >
+ Confirm
+
+ setShowDeleteConfirm(false)}>Cancel
+ >
+ ) : (
+ setShowDeleteConfirm(!showDeleteConfirm)}>Delete
+ )}
+
+
+ )}
diff --git a/frontend/src/pages/SettingsActions.tsx b/frontend/src/pages/SettingsActions.tsx
index 577c0262..8d7b44cf 100644
--- a/frontend/src/pages/SettingsActions.tsx
+++ b/frontend/src/pages/SettingsActions.tsx
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import loadBackupList, { BackupListType } from '../api/loader/loadBackupList';
import SettingsNavigation from '../components/SettingsNavigation';
-import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
import updateTaskByName from '../api/actions/updateTaskByName';
import queueBackup from '../api/actions/queueBackup';
import restoreBackup from '../api/actions/restoreBackup';
@@ -63,32 +62,6 @@ const SettingsActions = () => {
Actions
-
-
Delete download queue
-
Delete your pending or previously ignored videos from your download queue.
- {deleteIgnored &&
Deleting download queue: ignored
}
- {!deleteIgnored && (
-
{
- await deleteDownloadQueueByFilter('ignore');
- setDeleteIgnored(true);
- }}
- />
- )}{' '}
- {deletePending && Deleting download queue: pending
}
- {!deletePending && (
- {
- await deleteDownloadQueueByFilter('pending');
- setDeletePending(true);
- }}
- />
- )}
-
Manual media files import.
From 3947653595f20b821ab744e4f632c8db244ed13d Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 6 Jul 2025 20:14:56 +0700
Subject: [PATCH 28/55] add bulk status update in queue
---
backend/download/serializers.py | 16 +++++++++
backend/download/src/queue.py | 24 +++++++++++++
backend/download/views.py | 34 +++++++++++++++++++
.../actions/updateDownloadQueueByFilter.ts | 23 +++++++++++++
frontend/src/functions/APIClient.ts | 2 +-
frontend/src/pages/Download.tsx | 27 ++++++++++++++-
6 files changed, 124 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/api/actions/updateDownloadQueueByFilter.ts
diff --git a/backend/download/serializers.py b/backend/download/serializers.py
index 5ab3eca8..4b818fb5 100644
--- a/backend/download/serializers.py
+++ b/backend/download/serializers.py
@@ -78,6 +78,22 @@ class AddToDownloadQuerySerializer(serializers.Serializer):
autostart = serializers.BooleanField(required=False)
+class BulkUpdateDowloadQuerySerializer(serializers.Serializer):
+ """serialize bulk update query"""
+
+ filter = serializers.ChoiceField(choices=["pending", "ignore", "priority"])
+ channel = serializers.CharField(required=False)
+ vid_type = serializers.ChoiceField(
+ choices=VideoTypeEnum.values_known(), required=False
+ )
+
+
+class BulkUpdateDowloadDataSerializer(serializers.Serializer):
+ """serialize data"""
+
+ status = serializers.ChoiceField(choices=["pending", "ignore", "priority"])
+
+
class DownloadQueueItemUpdateSerializer(serializers.Serializer):
"""update single download queue item"""
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index 3b21fc68..000dae57 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -114,6 +114,30 @@ class PendingInteract:
path = "ta_download/_delete_by_query?refresh=true"
_, _ = ElasticWrap(path).post(data=data)
+ def update_bulk(
+ self, channel_id: str | None, vid_type: str | None, new_status: str
+ ):
+ """update status in bulk"""
+ must_list = [{"term": {"status": {"value": self.status}}}]
+ if channel_id:
+ must_list.append({"term": {"channel_id": {"value": channel_id}}})
+
+ if vid_type:
+ must_list.append({"term": {"vid_type": {"value": vid_type}}})
+
+ data = {
+ "query": {"bool": {"must": must_list}},
+ "script": {
+ "source": f"ctx._source.status = '{new_status}'",
+ "lang": "painless",
+ },
+ }
+ print(data)
+ path = "ta_download/_update_by_query?refresh=true"
+ response, status_code = ElasticWrap(path).post(data)
+ print(status_code)
+ print(response)
+
def update_status(self):
"""update status of pending item"""
if self.status == "priority":
diff --git a/backend/download/views.py b/backend/download/views.py
index b3cec1a9..0d66faca 100644
--- a/backend/download/views.py
+++ b/backend/download/views.py
@@ -8,6 +8,8 @@ from common.views_base import AdminOnly, ApiBaseView
from download.serializers import (
AddToDownloadListSerializer,
AddToDownloadQuerySerializer,
+ BulkUpdateDowloadDataSerializer,
+ BulkUpdateDowloadQuerySerializer,
DownloadAggsSerializer,
DownloadItemSerializer,
DownloadListQuerySerializer,
@@ -120,6 +122,38 @@ class DownloadApiListView(ApiBaseView):
return Response(response_serializer.data)
+ @staticmethod
+ @extend_schema(
+ request=BulkUpdateDowloadDataSerializer(),
+ parameters=[BulkUpdateDowloadQuerySerializer()],
+ responses={204: OpenApiResponse(description="Status updated")},
+ )
+ def patch(request):
+ """bulk update status"""
+ data_serializer = BulkUpdateDowloadDataSerializer(data=request.data)
+ data_serializer.is_valid(raise_exception=True)
+ validated_data = data_serializer.validated_data
+
+ new_status = validated_data["status"]
+
+ query_serializer = BulkUpdateDowloadQuerySerializer(
+ data=request.query_params
+ )
+ query_serializer.is_valid(raise_exception=True)
+ validated_query = query_serializer.validated_data
+ status_filter = validated_query.get("filter")
+ channel = validated_query.get("channel")
+ vid_type = validated_query.get("vid_type")
+
+ PendingInteract(status=status_filter).update_bulk(
+ channel_id=channel, vid_type=vid_type, new_status=new_status
+ )
+
+ if new_status == "priority":
+ download_pending.delay(auto_only=True)
+
+ return Response(status=204)
+
@extend_schema(
parameters=[DownloadListQueueDeleteQuerySerializer()],
responses={
diff --git a/frontend/src/api/actions/updateDownloadQueueByFilter.ts b/frontend/src/api/actions/updateDownloadQueueByFilter.ts
new file mode 100644
index 00000000..1410360e
--- /dev/null
+++ b/frontend/src/api/actions/updateDownloadQueueByFilter.ts
@@ -0,0 +1,23 @@
+import APIClient from '../../functions/APIClient';
+
+type FilterType = 'ignore' | 'pending';
+export type DownloadQueueStatus = 'ignore' | 'pending' | 'priority';
+
+const updateDownloadQueueByFilter = async (
+ filter: FilterType,
+ channel: string | null,
+ vid_type: string | null,
+ status: DownloadQueueStatus,
+) => {
+ const searchParams = new URLSearchParams();
+ if (filter) searchParams.append('filter', filter);
+ if (channel) searchParams.append('channel', channel);
+ if (vid_type) searchParams.append('vid_type', vid_type);
+
+ return APIClient(`/api/download/?${searchParams.toString()}`, {
+ method: 'PATCH',
+ body: { status: status },
+ });
+};
+
+export default updateDownloadQueueByFilter;
diff --git a/frontend/src/functions/APIClient.ts b/frontend/src/functions/APIClient.ts
index c0656a63..5b464f3d 100644
--- a/frontend/src/functions/APIClient.ts
+++ b/frontend/src/functions/APIClient.ts
@@ -6,7 +6,7 @@ import getCookie from './getCookie';
import Routes from '../configuration/routes/RouteList';
export interface ApiClientOptions extends Omit {
- method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: Record | string;
}
diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx
index a4a4388b..3cd98d0b 100644
--- a/frontend/src/pages/Download.tsx
+++ b/frontend/src/pages/Download.tsx
@@ -22,6 +22,9 @@ import { useUserConfigStore } from '../stores/UserConfigStore';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
import { ApiResponseType } from '../functions/APIClient';
import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
+import updateDownloadQueueByFilter, {
+ DownloadQueueStatus,
+} from '../api/actions/updateDownloadQueueByFilter';
type Download = {
auto_start: boolean;
@@ -141,6 +144,16 @@ const Download = () => {
})();
}, [lastVideoCount, showIgnored]);
+ const handleBulkStatusUpdate = async (status: DownloadQueueStatus) => {
+ await updateDownloadQueueByFilter(
+ showIgnoredFilter,
+ channelFilterFromUrl,
+ vidTypeFilterFromUrl,
+ status,
+ );
+ setRefresh(true);
+ };
+
return (
<>
TA | Downloads
@@ -397,6 +410,18 @@ const Download = () => {
)}
+
+ {showIgnored ? (
+
+ handleBulkStatusUpdate('pending')}>Add to Queue
+
+ ) : (
+
+ handleBulkStatusUpdate('ignore')}>Ignore
+ handleBulkStatusUpdate('priority')}>Download Now
+
+ )}
+
{showDeleteConfirm ? (
<>
@@ -416,7 +441,7 @@ const Download = () => {
setShowDeleteConfirm(false)}>Cancel
>
) : (
- setShowDeleteConfirm(!showDeleteConfirm)}>Delete
+ setShowDeleteConfirm(!showDeleteConfirm)}>Forget
)}
From f87309dbd12bdc420ea3e5c5dd2d9ff8d6df28dd Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 6 Jul 2025 20:19:40 +0700
Subject: [PATCH 29/55] change reset queue filtering on status filter change
---
frontend/src/pages/Download.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx
index 3cd98d0b..5c6cdfc7 100644
--- a/frontend/src/pages/Download.tsx
+++ b/frontend/src/pages/Download.tsx
@@ -257,7 +257,7 @@ const Download = () => {
id="showIgnored"
onChange={() => {
handleUserConfigUpdate({ show_ignored_only: !showIgnored });
- const newParams = new URLSearchParams(searchParams.toString());
+ const newParams = new URLSearchParams();
newParams.set('ignored', String(!showIgnored));
setSearchParams(newParams);
setRefresh(true);
From 74c708baa2ea9374cfdb03e00527e9a3881e0bd7 Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 6 Jul 2025 20:23:39 +0700
Subject: [PATCH 30/55] fix notification url delete
---
frontend/src/api/actions/deleteAppriseNotificationUrl.ts | 4 ++--
frontend/src/pages/SettingsScheduling.tsx | 5 ++++-
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/frontend/src/api/actions/deleteAppriseNotificationUrl.ts b/frontend/src/api/actions/deleteAppriseNotificationUrl.ts
index 5018b79f..33153c49 100644
--- a/frontend/src/api/actions/deleteAppriseNotificationUrl.ts
+++ b/frontend/src/api/actions/deleteAppriseNotificationUrl.ts
@@ -6,10 +6,10 @@ type AppriseTaskNameType =
| 'download_pending'
| 'check_reindex';
-const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType) => {
+const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => {
return APIClient('/api/task/notification/', {
method: 'DELETE',
- body: { task_name: taskName },
+ body: { task_name: taskName, url: url },
});
};
diff --git a/frontend/src/pages/SettingsScheduling.tsx b/frontend/src/pages/SettingsScheduling.tsx
index b2d064b5..7ee95012 100644
--- a/frontend/src/pages/SettingsScheduling.tsx
+++ b/frontend/src/pages/SettingsScheduling.tsx
@@ -519,7 +519,10 @@ const SettingsScheduling = () => {
className="danger-button"
label="Delete"
onClick={async () => {
- await deleteAppriseNotificationUrl(key as AppriseTaskNameType);
+ await deleteAppriseNotificationUrl(
+ key as AppriseTaskNameType,
+ url,
+ );
setRefresh(true);
}}
From 66f37a92d3a643bc766947d8776c75c6e5868a23 Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 6 Jul 2025 20:55:04 +0700
Subject: [PATCH 31/55] fix priority bulk download
---
backend/download/src/queue.py | 20 ++++++++++++--------
1 file changed, 12 insertions(+), 8 deletions(-)
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index 000dae57..aa214ac9 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -125,18 +125,22 @@ class PendingInteract:
if vid_type:
must_list.append({"term": {"vid_type": {"value": vid_type}}})
+ if new_status == "priority":
+ source = """
+ ctx._source.status = 'pending';
+ ctx._source.auto_start = true;
+ ctx._source.message = null;
+ """
+ else:
+ source = f"ctx._source.status = '{new_status}'"
+
data = {
"query": {"bool": {"must": must_list}},
- "script": {
- "source": f"ctx._source.status = '{new_status}'",
- "lang": "painless",
- },
+ "script": {"source": source, "lang": "painless"},
}
- print(data)
+
path = "ta_download/_update_by_query?refresh=true"
- response, status_code = ElasticWrap(path).post(data)
- print(status_code)
- print(response)
+ _, _ = ElasticWrap(path).post(data)
def update_status(self):
"""update status of pending item"""
From b6d38e9319fabefe83c9df76fe543367add3ee0b Mon Sep 17 00:00:00 2001
From: Simon
Date: Sun, 6 Jul 2025 21:59:26 +0700
Subject: [PATCH 32/55] return complete vid_entry dict from scan
---
backend/appsettings/src/reindex.py | 6 ++++--
backend/download/src/queue.py | 4 ++--
backend/download/src/subscriptions.py | 13 ++++++-------
3 files changed, 12 insertions(+), 11 deletions(-)
diff --git a/backend/appsettings/src/reindex.py b/backend/appsettings/src/reindex.py
index 56fd339e..712eac72 100644
--- a/backend/appsettings/src/reindex.py
+++ b/backend/appsettings/src/reindex.py
@@ -510,12 +510,14 @@ class ChannelFullScan:
self.to_update = []
for video in all_local_videos:
video_id = video["youtube_id"]
- remote_match = [i for i in all_remote_videos if i[0] == video_id]
+ remote_match = [
+ i for i in all_remote_videos if i["id"] == video_id
+ ]
if not remote_match:
print(f"{video_id}: no remote match found")
continue
- expected_type = remote_match[0][-1]
+ expected_type = remote_match[0]["vid_type"]
if video["vid_type"] != expected_type:
self.to_update.append(
{
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index aa214ac9..3bd202a1 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -258,8 +258,8 @@ class PendingList(PendingIndex):
video_results = ChannelSubscription().get_last_youtube_videos(
url, limit=False, query_filter=vid_type
)
- for video_id, _, vid_type in video_results:
- self._add_video(video_id, vid_type)
+ for video_entry in video_results:
+ self._add_video(video_entry["id"], video_entry["vid_type"])
def _parse_playlist(self, url):
"""add all videos of playlist to list"""
diff --git a/backend/download/src/subscriptions.py b/backend/download/src/subscriptions.py
index abc0a4eb..524202e5 100644
--- a/backend/download/src/subscriptions.py
+++ b/backend/download/src/subscriptions.py
@@ -65,12 +65,9 @@ class ChannelSubscription:
if not channel_query:
continue
- last_videos.extend(
- [
- (i["id"], i["title"], vid_type)
- for i in channel_query["entries"]
- ]
- )
+ for entry in channel_query["entries"]:
+ entry["vid_type"] = vid_type
+ last_videos.append(entry)
return last_videos
@@ -93,7 +90,9 @@ class ChannelSubscription:
if last_videos:
ids_to_add = is_missing([i[0] for i in last_videos])
- for video_id, _, vid_type in last_videos:
+ for video_entry in last_videos:
+ video_id = video_entry["id"]
+ vid_type = video_entry["vid_type"]
if video_id in ids_to_add:
missing_videos.append((video_id, vid_type))
From 15ec8b5ab645e98f00927d42a3c1c74e21815bd4 Mon Sep 17 00:00:00 2001
From: Simon
Date: Wed, 9 Jul 2025 16:16:01 +0700
Subject: [PATCH 33/55] refac pending list, implement flat add
---
backend/common/src/es_connect.py | 2 +-
backend/common/src/helper.py | 8 +-
backend/download/serializers.py | 5 +-
backend/download/src/queue.py | 359 ++++++++++++++++++-------------
backend/download/views.py | 5 +-
backend/task/tasks.py | 14 +-
6 files changed, 234 insertions(+), 159 deletions(-)
diff --git a/backend/common/src/es_connect.py b/backend/common/src/es_connect.py
index f7a59e3a..b2d11635 100644
--- a/backend/common/src/es_connect.py
+++ b/backend/common/src/es_connect.py
@@ -56,7 +56,7 @@ class ElasticWrap:
return response.json(), response.status_code
def post(
- self, data: bool | dict = False, ndjson: bool = False
+ self, data: bool | dict | str = False, ndjson: bool = False
) -> tuple[dict, int]:
"""post data to es"""
diff --git a/backend/common/src/helper.py b/backend/common/src/helper.py
index fcf75dfe..7b1d43be 100644
--- a/backend/common/src/helper.py
+++ b/backend/common/src/helper.py
@@ -103,8 +103,11 @@ def requests_headers() -> dict[str, str]:
return {"User-Agent": template}
-def date_parser(timestamp: int | str) -> str:
+def date_parser(timestamp: int | str | None) -> str | None:
"""return formatted date string"""
+ if timestamp is None:
+ return None
+
if isinstance(timestamp, int):
date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc)
elif isinstance(timestamp, str):
@@ -184,11 +187,12 @@ def get_duration_sec(file_path: str) -> int:
return duration_sec
-def get_duration_str(seconds: int) -> str:
+def get_duration_str(seconds: int | float) -> str:
"""Return a human-readable duration string from seconds."""
if not seconds:
return "NA"
+ seconds = int(seconds)
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
duration_parts = []
diff --git a/backend/download/serializers.py b/backend/download/serializers.py
index 4b818fb5..631541df 100644
--- a/backend/download/serializers.py
+++ b/backend/download/serializers.py
@@ -15,9 +15,9 @@ class DownloadItemSerializer(serializers.Serializer):
channel_indexed = serializers.BooleanField()
channel_name = serializers.CharField()
duration = serializers.CharField()
- published = serializers.CharField()
+ published = serializers.CharField(allow_null=True)
status = serializers.ChoiceField(choices=["pending", "ignore"])
- timestamp = serializers.IntegerField()
+ timestamp = serializers.IntegerField(allow_null=True)
title = serializers.CharField()
vid_thumb_url = serializers.CharField()
vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values())
@@ -76,6 +76,7 @@ class AddToDownloadQuerySerializer(serializers.Serializer):
"""add to queue query serializer"""
autostart = serializers.BooleanField(required=False)
+ flat = serializers.BooleanField(required=False)
class BulkUpdateDowloadQuerySerializer(serializers.Serializer):
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index 3bd202a1..8eb0a391 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -4,16 +4,18 @@ Functionality:
- linked with ta_dowload index
"""
+import json
from datetime import datetime
from appsettings.src.config import AppConfig
+from channel.src.index import YoutubeChannel
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import get_duration_str, is_shorts, rand_sleep
from download.src.subscriptions import ChannelSubscription
from download.src.thumbnails import ThumbManager
-from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist
from video.src.constants import VideoTypeEnum
+from video.src.index import YoutubeVideo
class PendingIndex:
@@ -195,22 +197,27 @@ class PendingList(PendingIndex):
"check_formats": None,
}
- def __init__(self, youtube_ids=False, task=False):
+ def __init__(
+ self, youtube_ids=False, task=False, auto_start=False, flat=False
+ ):
super().__init__()
self.config = AppConfig().config
self.youtube_ids = youtube_ids
self.task = task
+ self.auto_start = auto_start
+ self.flat = flat
self.to_skip = False
- self.missing_videos = False
-
- def parse_url_list(self, auto_start=False):
- """extract youtube ids from list"""
self.missing_videos = []
+
+ def parse_url_list(self):
+ """extract youtube ids from list"""
self.get_download()
self.get_indexed()
+ self.get_channels()
total = len(self.youtube_ids)
for idx, entry in enumerate(self.youtube_ids):
- self._process_entry(entry, auto_start=auto_start)
+ self._process_entry(entry)
+ rand_sleep(self.config)
if not self.task:
continue
@@ -219,102 +226,214 @@ class PendingList(PendingIndex):
progress=(idx + 1) / total,
)
- def _process_entry(self, entry, auto_start=False):
+ def _process_entry(self, entry: dict):
"""process single entry from url list"""
- vid_type = self._get_vid_type(entry)
if entry["type"] == "video":
- self._add_video(entry["url"], vid_type, auto_start=auto_start)
+ self._add_video(entry["url"], entry["vid_type"])
elif entry["type"] == "channel":
- self._parse_channel(entry["url"], vid_type)
+ self._parse_channel(entry["url"], entry["vid_type"])
elif entry["type"] == "playlist":
self._parse_playlist(entry["url"])
else:
raise ValueError(f"invalid url_type: {entry}")
- @staticmethod
- def _get_vid_type(entry):
- """add vid type enum if available"""
- vid_type_str = entry.get("vid_type")
- if not vid_type_str:
- return VideoTypeEnum.UNKNOWN
-
- return VideoTypeEnum(vid_type_str)
-
- def _add_video(self, url, vid_type, auto_start=False):
+ def _add_video(self, url, vid_type):
"""add video to list"""
- if auto_start and url in set(
+ if self.auto_start and url in set(
i["youtube_id"] for i in self.all_pending
):
PendingInteract(youtube_id=url, status="priority").update_status()
return
- if url not in self.missing_videos and url not in self.to_skip:
- self.missing_videos.append((url, vid_type))
- else:
+ if url in self.missing_videos or url in self.to_skip:
print(f"{url}: skipped adding already indexed video to download.")
+ else:
+ to_add = self._parse_video(url, vid_type)
+ if to_add:
+ self.missing_videos.append(to_add)
def _parse_channel(self, url, vid_type):
- """add all videos of channel to list"""
+ """parse channel"""
video_results = ChannelSubscription().get_last_youtube_videos(
url, limit=False, query_filter=vid_type
)
- for video_entry in video_results:
- self._add_video(video_entry["id"], video_entry["vid_type"])
+ channel_handler = YoutubeChannel(url)
+ channel_handler.build_json(upload=False)
- def _parse_playlist(self, url):
- """add all videos of playlist to list"""
- playlist = YoutubePlaylist(url)
- is_active = playlist.update_playlist()
- if not is_active:
- message = f"{playlist.youtube_id}: failed to extract metadata"
- print(message)
- raise ValueError(message)
-
- entries = playlist.json_data["playlist_entries"]
- to_add = [i["youtube_id"] for i in entries if not i["downloaded"]]
- if not to_add:
- return
-
- for video_id in to_add:
- # match vid_type later
- self._add_video(video_id, VideoTypeEnum.UNKNOWN)
-
- def add_to_pending(self, status="pending", auto_start=False):
- """add missing videos to pending list"""
- self.get_channels()
-
- total = len(self.missing_videos)
- videos_added = []
- for idx, (youtube_id, vid_type) in enumerate(self.missing_videos):
- if self.task and self.task.is_stopped():
- break
-
- print(f"{youtube_id}: [{idx + 1}/{total}]: add to queue")
- self._notify_add(idx, total)
- video_details = self.get_youtube_details(youtube_id, vid_type)
- if not video_details:
- rand_sleep(self.config)
+ for video_data in video_results:
+ video_id = video_data["id"]
+ if video_id in self.to_skip:
continue
- video_details.update(
+ if self.flat:
+ if not video_data.get("channel"):
+ channel_name = channel_handler.json_data["channel_name"]
+ video_data["channel"] = channel_name
+
+ if not video_data.get("channel_id"):
+ channel_id = channel_handler.json_data["channel_id"]
+ video_data["channel_id"] = channel_id
+
+ to_add = self._parse_entry(
+ youtube_id=video_id, video_data=video_data
+ )
+ else:
+ to_add = self._parse_video(video_id, vid_type)
+
+ if to_add:
+ self.missing_videos.append(to_add)
+
+ def _parse_playlist(self, url):
+ """fast parse playlist"""
+ playlist = YoutubePlaylist(url)
+ playlist.get_from_youtube()
+ video_results = playlist.youtube_meta["entries"]
+
+ for video_data in video_results:
+ video_id = video_data["id"]
+ if video_id in self.to_skip:
+ continue
+
+ if self.flat:
+ to_add = self._parse_entry(video_id, video_data)
+ else:
+ to_add = self._parse_video(video_id, vid_type=None)
+
+ if to_add:
+ self.missing_videos.append(to_add)
+
+ def _parse_video(self, url, vid_type):
+ """parse video"""
+ video = YoutubeVideo(youtube_id=url)
+ video.get_from_youtube()
+
+ video_data = video.youtube_meta
+ video_data["vid_type"] = vid_type
+ to_add = self._parse_entry(youtube_id=url, video_data=video_data)
+
+ return to_add
+
+ def _parse_entry(self, youtube_id: str, video_data: dict) -> dict | None:
+ """parse entry"""
+ if video_data.get("id") != youtube_id:
+ # skip premium videos with different id or redirects
+ print(f"{youtube_id}: skipping redirect, id not matching")
+ return None
+
+ if video_data.get("live_status") in ["is_upcoming", "is_live"]:
+ print(f"{youtube_id}: skip is_upcoming or is_live")
+ return None
+
+ to_add = {
+ "youtube_id": video_data["id"],
+ "title": video_data["title"],
+ "vid_thumb_url": self.__extract_thumb(video_data),
+ "duration": get_duration_str(video_data.get("duration", 0)),
+ "published": self.__extract_published(video_data),
+ "timestamp": int(datetime.now().timestamp()),
+ "vid_type": self.__extract_vid_type(video_data),
+ "channel_name": video_data["channel"],
+ "channel_id": video_data["channel_id"],
+ "channel_indexed": video_data["channel_id"] in self.all_channels,
+ }
+ thumb_url = to_add["vid_thumb_url"]
+ ThumbManager(to_add["youtube_id"]).download_video_thumb(thumb_url)
+
+ return to_add
+
+ def __extract_thumb(self, video_data) -> str | None:
+ """extract thumb"""
+ if "thumbnail" in video_data:
+ return video_data["thumbnail"]
+
+ if "thumbnails" in video_data:
+ return video_data["thumbnails"][-1]["url"]
+
+ return None
+
+ def __extract_published(self, video_data) -> str | int | None:
+ """build published date or timestamp"""
+ timestamp = video_data.get("timestamp")
+ if timestamp:
+ return timestamp
+
+ upload_date = video_data.get("upload_date")
+ if not upload_date:
+ return None
+
+ upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
+ published = upload_date_time.strftime("%Y-%m-%d")
+
+ return published
+
+ def __extract_vid_type(self, video_data) -> str:
+ """build vid type"""
+ if "vid_type" in video_data:
+ return video_data["vid_type"]
+
+ if video_data.get("live_status") == "was_live":
+ return VideoTypeEnum.STREAMS.value
+
+ if video_data.get("width", 0) > video_data.get("height", 0):
+ return VideoTypeEnum.VIDEOS.value
+
+ duration = video_data.get("duration")
+ if duration and isinstance(duration, int):
+ if duration > 3 * 60:
+ return VideoTypeEnum.VIDEOS.value
+
+ if is_shorts(video_data["id"]):
+ return VideoTypeEnum.SHORTS.value
+
+ return VideoTypeEnum.VIDEOS.value
+
+ def add_to_pending(self, status="pending") -> int:
+ """add missing videos to pending list"""
+
+ total = len(self.missing_videos)
+
+ if not self.missing_videos:
+ self._notify_empty()
+ return 0
+
+ self._notify_start(total)
+ bulk_list = []
+ for video_entry in self.missing_videos:
+ video_entry.update(
{
"status": status,
- "auto_start": auto_start,
+ "auto_start": self.auto_start,
}
)
+ video_id = video_entry["youtube_id"]
+ action = {"index": {"_index": "ta_download", "_id": video_id}}
+ bulk_list.append(json.dumps(action))
+ bulk_list.append(json.dumps(video_entry))
- url = video_details["vid_thumb_url"]
- ThumbManager(youtube_id).download_video_thumb(url)
- es_url = f"ta_download/_doc/{youtube_id}"
- _, _ = ElasticWrap(es_url).put(video_details)
- videos_added.append(youtube_id)
+ # add last newline
+ bulk_list.append("\n")
+ query_str = "\n".join(bulk_list)
+ _, status_code = ElasticWrap("_bulk").post(query_str, ndjson=True)
+ if status_code != 200:
+ self._notify_fail(status_code)
+ else:
+ self._notify_done(total)
- if idx != total:
- rand_sleep(self.config)
+ return len(self.missing_videos)
- return videos_added
+ def _notify_empty(self):
+ """notify nothing to add"""
+ if not self.task:
+ return
- def _notify_add(self, idx, total):
+ self.task.send_progress(
+ message_lines=[
+ "Extractinc videos completed.",
+ "No new videos found to add.",
+ ]
+ )
+
+ def _notify_start(self, total):
"""send notification for adding videos to download queue"""
if not self.task:
return
@@ -322,82 +441,30 @@ class PendingList(PendingIndex):
self.task.send_progress(
message_lines=[
"Adding new videos to download queue.",
- f"Extracting items {idx + 1}/{total}",
- ],
- progress=(idx + 1) / total,
+ f"Bulk adding {total} videos",
+ ]
)
- def get_youtube_details(self, youtube_id, vid_type=VideoTypeEnum.VIDEOS):
- """get details from youtubedl for single pending video"""
- vid = YtWrap(self.yt_obs, self.config).extract(youtube_id)
- if not vid:
- return False
+ def _notify_done(self, total):
+ """send done notification"""
+ if not self.task:
+ return
- if vid.get("id") != youtube_id:
- # skip premium videos with different id
- print(f"{youtube_id}: skipping premium video, id not matching")
- return False
- # stop if video is streaming live now
- if vid["live_status"] in ["is_upcoming", "is_live"]:
- print(f"{youtube_id}: skip is_upcoming or is_live")
- return False
+ self.task.send_progress(
+ message_lines=[
+ "Adding new videos to the queue completed.",
+ f"Added {total} videos.",
+ ]
+ )
- if vid["live_status"] == "was_live":
- vid_type = VideoTypeEnum.STREAMS
- else:
- if self._check_shorts(vid):
- vid_type = VideoTypeEnum.SHORTS
- else:
- vid_type = VideoTypeEnum.VIDEOS
+ def _notify_fail(self, status_code):
+ """failed to add"""
+ if not self.task:
+ return
- if not vid.get("channel"):
- print(f"{youtube_id}: skip video not part of channel")
- return False
-
- return self._parse_youtube_details(vid, vid_type)
-
- @staticmethod
- def _check_shorts(vid):
- """check if vid is shorts video"""
- if vid["width"] > vid["height"]:
- return False
-
- duration = vid.get("duration")
- if duration and isinstance(duration, int):
- if duration > 3 * 60:
- return False
-
- return is_shorts(vid["id"])
-
- def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
- """parse response"""
- vid_id = vid.get("id")
-
- # build dict
- youtube_details = {
- "youtube_id": vid_id,
- "channel_name": vid["channel"],
- "vid_thumb_url": vid["thumbnail"],
- "title": vid["title"],
- "channel_id": vid["channel_id"],
- "duration": get_duration_str(vid["duration"]),
- "published": self._build_published(vid),
- "timestamp": int(datetime.now().timestamp()),
- "vid_type": vid_type.value,
- "channel_indexed": vid["channel_id"] in self.all_channels,
- }
-
- return youtube_details
-
- @staticmethod
- def _build_published(vid):
- """build published date or timestamp"""
- timestamp = vid["timestamp"]
- if timestamp:
- return timestamp
-
- upload_date = vid["upload_date"]
- upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
- published = upload_date_time.strftime("%Y-%m-%d")
-
- return published
+ self.task.send_progress(
+ message_lines=[
+ "Adding extracted videos failed.",
+ f"Status code: {status_code}",
+ ]
+ )
diff --git a/backend/download/views.py b/backend/download/views.py
index 0d66faca..80ed95b5 100644
--- a/backend/download/views.py
+++ b/backend/download/views.py
@@ -107,12 +107,13 @@ class DownloadApiListView(ApiBaseView):
validated_query = query_serializer.validated_data
auto_start = validated_query.get("autostart")
- print(f"auto_start: {auto_start}")
+ flat = validated_query.get("flat", False)
+ print(f"auto_start: {auto_start}, flat: {flat}")
to_add = validated_data["data"]
pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"]
url_str = " ".join(pending)
- task = extrac_dl.delay(url_str, auto_start=auto_start)
+ task = extrac_dl.delay(url_str, auto_start=auto_start, flat=flat)
message = {
"message": "add to queue task started",
diff --git a/backend/task/tasks.py b/backend/task/tasks.py
index d879cb9e..8583e916 100644
--- a/backend/task/tasks.py
+++ b/backend/task/tasks.py
@@ -147,7 +147,9 @@ def download_pending(self, auto_only=False):
@shared_task(name="extract_download", bind=True, base=BaseTask)
-def extrac_dl(self, youtube_ids, auto_start=False, status="pending"):
+def extrac_dl(
+ self, youtube_ids, auto_start=False, flat=False, status="pending"
+):
"""parse list passed and add to pending"""
TaskManager().init(self)
if isinstance(youtube_ids, str):
@@ -155,17 +157,17 @@ def extrac_dl(self, youtube_ids, auto_start=False, status="pending"):
else:
to_add = youtube_ids
- pending_handler = PendingList(youtube_ids=to_add, task=self)
- pending_handler.parse_url_list(auto_start=auto_start)
- videos_added = pending_handler.add_to_pending(
- status=status, auto_start=auto_start
+ pending_handler = PendingList(
+ youtube_ids=to_add, task=self, auto_start=auto_start, flat=flat
)
+ pending_handler.parse_url_list()
+ videos_added = pending_handler.add_to_pending(status=status)
if auto_start:
download_pending.delay(auto_only=True)
if videos_added:
- return f"added {len(videos_added)} Videos to Queue"
+ return f"added {videos_added} Videos to Queue"
return None
From 97bc6f225f78578546b0b31bc5bd0b6eb222cc3e Mon Sep 17 00:00:00 2001
From: Simon
Date: Wed, 9 Jul 2025 17:09:45 +0700
Subject: [PATCH 34/55] add fast add, use toggles
---
.../src/api/actions/updateDownloadQueue.ts | 12 ++--
frontend/src/components/DownloadListItem.tsx | 5 +-
frontend/src/pages/Download.tsx | 59 ++++++++++++++-----
3 files changed, 54 insertions(+), 22 deletions(-)
diff --git a/frontend/src/api/actions/updateDownloadQueue.ts b/frontend/src/api/actions/updateDownloadQueue.ts
index 071707df..beb14120 100644
--- a/frontend/src/api/actions/updateDownloadQueue.ts
+++ b/frontend/src/api/actions/updateDownloadQueue.ts
@@ -1,6 +1,6 @@
import APIClient from '../../functions/APIClient';
-const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean) => {
+const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean, flat: boolean) => {
const urls = [];
const containsMultiple = youtubeIdStrings.includes('\n');
@@ -16,12 +16,12 @@ const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean)
urls.push({ youtube_id: youtubeIdStrings, status: 'pending' });
}
- let params = '';
- if (autostart) {
- params = '?autostart=true';
- }
+ const searchParams = new URLSearchParams();
+ if (autostart) searchParams.append('autostart', 'true');
+ if (flat) searchParams.append('flat', 'true');
+ const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
- return APIClient(`/api/download/${params}`, {
+ return APIClient(endpoint, {
method: 'POST',
body: { data: [...urls] },
});
diff --git a/frontend/src/components/DownloadListItem.tsx b/frontend/src/components/DownloadListItem.tsx
index ff0273dd..cd540c87 100644
--- a/frontend/src/components/DownloadListItem.tsx
+++ b/frontend/src/components/DownloadListItem.tsx
@@ -57,8 +57,9 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
- Published: {formatDate(download.published)} | Duration: {download.duration} |{' '}
- {download.youtube_id}
+ {download.published && Published: {formatDate(download.published)} | }
+ Duration: {download.duration} |
+ {download.youtube_id}
{download.message && {download.message}
}
diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx
index 5c6cdfc7..c141648e 100644
--- a/frontend/src/pages/Download.tsx
+++ b/frontend/src/pages/Download.tsx
@@ -33,9 +33,9 @@ type Download = {
channel_name: string;
duration: string;
message?: string;
- published: string;
+ published: string | null;
status: string;
- timestamp: number;
+ timestamp: number | null;
title: string;
vid_thumb_url: string;
vid_type: string;
@@ -61,6 +61,8 @@ const Download = () => {
const [refresh, setRefresh] = useState(false);
const [showHiddenForm, setShowHiddenForm] = useState(false);
+ const [addAsAutoStart, setAddAsAutoStart] = useState(false);
+ const [addAsFlat, setAddAsFlat] = useState(false);
const [showQueueActions, setShowQueueActions] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [downloadPending, setDownloadPending] = useState(false);
@@ -215,6 +217,46 @@ const Download = () => {
{showHiddenForm && (
+
+
+ setAddAsFlat(!addAsFlat)}
+ />
+ {addAsFlat ? (
+
+ On
+
+ ) : (
+
+ Off
+
+ )}
+
+
Fast add
+
+
+
+ setAddAsAutoStart(!addAsAutoStart)}
+ />
+ {addAsAutoStart ? (
+
+ On
+
+ ) : (
+
+ Off
+
+ )}
+
+
Auto Download
+
)}
From ae40df1b6b76d75b909fa9b49e99c8807252c042 Mon Sep 17 00:00:00 2001
From: Christian Heimlich
Date: Wed, 9 Jul 2025 12:30:47 -0400
Subject: [PATCH 35/55] fix: Country/language code mix-up for subtitles in
search examples (#1012)
---
frontend/src/components/SearchExampleQueries.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/SearchExampleQueries.tsx b/frontend/src/components/SearchExampleQueries.tsx
index 2e17792e..1c181c6e 100644
--- a/frontend/src/components/SearchExampleQueries.tsx
+++ b/frontend/src/components/SearchExampleQueries.tsx
@@ -95,8 +95,8 @@ const SearchExampleQueries = () => {
full: — search in video subtitles
- lang: — subtitles language (use two-letter ISO country code, same as
- the one from settings page)
+ lang: — subtitles language (use two-letter ISO 639 language code,
+ same as the one from settings page)
source:
From 5da7b2a3c91992714aa003f8d6e15ef429e03900 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 07:54:11 +0700
Subject: [PATCH 36/55] fix channel type filter, add channel playlist fallback
---
backend/download/src/queue.py | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index 8eb0a391..fd3e0166 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -254,8 +254,9 @@ class PendingList(PendingIndex):
def _parse_channel(self, url, vid_type):
"""parse channel"""
+ query_filter = getattr(VideoTypeEnum, vid_type.upper())
video_results = ChannelSubscription().get_last_youtube_videos(
- url, limit=False, query_filter=vid_type
+ url, limit=False, query_filter=query_filter
)
channel_handler = YoutubeChannel(url)
channel_handler.build_json(upload=False)
@@ -295,6 +296,13 @@ class PendingList(PendingIndex):
continue
if self.flat:
+ if not video_data.get("channel"):
+ video_data["channel"] = playlist.youtube_meta["channel"]
+
+ if not video_data.get("channel_id"):
+ channel_id = playlist.youtube_meta["channel_id"]
+ video_data["channel_id"] = channel_id
+
to_add = self._parse_entry(video_id, video_data)
else:
to_add = self._parse_video(video_id, vid_type=None)
@@ -310,6 +318,7 @@ class PendingList(PendingIndex):
video_data = video.youtube_meta
video_data["vid_type"] = vid_type
to_add = self._parse_entry(youtube_id=url, video_data=video_data)
+ rand_sleep(self.config)
return to_add
From 569d97e2f3f225ea0db64ee324d7c2c54ba010b0 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 10:00:50 +0700
Subject: [PATCH 37/55] handle add to queue progress
---
backend/common/src/search_processor.py | 14 ++--
backend/download/serializers.py | 2 +-
backend/download/src/queue.py | 101 ++++++++++++++++++++-----
backend/task/tasks.py | 2 +-
backend/video/src/index.py | 4 +
5 files changed, 95 insertions(+), 28 deletions(-)
diff --git a/backend/common/src/search_processor.py b/backend/common/src/search_processor.py
index 714d33b0..e5c0cac4 100644
--- a/backend/common/src/search_processor.py
+++ b/backend/common/src/search_processor.py
@@ -165,15 +165,17 @@ class SearchProcess:
def _process_download(self, download_dict):
"""run on single download item"""
- video_id = download_dict["youtube_id"]
- cache_root = EnvironmentSettings().get_cache_root()
- vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
- published = date_parser(download_dict["published"])
+ vid_thumb_url = None
+ if download_dict.get("vid_thumb_url"):
+ video_id = download_dict["youtube_id"]
+ cache_root = EnvironmentSettings().get_cache_root()
+ relative_path = ThumbManager(video_id).vid_thumb_path()
+ vid_thumb_url = f"{cache_root}/{relative_path}"
download_dict.update(
{
- "vid_thumb_url": f"{cache_root}/{vid_thumb_url}",
- "published": published,
+ "vid_thumb_url": vid_thumb_url,
+ "published": date_parser(download_dict["published"]),
}
)
return dict(sorted(download_dict.items()))
diff --git a/backend/download/serializers.py b/backend/download/serializers.py
index 631541df..c8afde33 100644
--- a/backend/download/serializers.py
+++ b/backend/download/serializers.py
@@ -19,7 +19,7 @@ class DownloadItemSerializer(serializers.Serializer):
status = serializers.ChoiceField(choices=["pending", "ignore"])
timestamp = serializers.IntegerField(allow_null=True)
title = serializers.CharField()
- vid_thumb_url = serializers.CharField()
+ vid_thumb_url = serializers.CharField(allow_null=True)
vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values())
youtube_id = serializers.CharField()
message = serializers.CharField(required=False)
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index fd3e0166..45311572 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -216,7 +216,7 @@ class PendingList(PendingIndex):
self.get_channels()
total = len(self.youtube_ids)
for idx, entry in enumerate(self.youtube_ids):
- self._process_entry(entry)
+ self._process_entry(entry, idx, total)
rand_sleep(self.config)
if not self.task:
continue
@@ -226,10 +226,12 @@ class PendingList(PendingIndex):
progress=(idx + 1) / total,
)
- def _process_entry(self, entry: dict):
+ def _process_entry(self, entry: dict, idx_url: int, total_url: int):
"""process single entry from url list"""
if entry["type"] == "video":
- self._add_video(entry["url"], entry["vid_type"])
+ self._add_video(
+ entry["url"], entry["vid_type"], idx_url, total_url
+ )
elif entry["type"] == "channel":
self._parse_channel(entry["url"], entry["vid_type"])
elif entry["type"] == "playlist":
@@ -237,7 +239,7 @@ class PendingList(PendingIndex):
else:
raise ValueError(f"invalid url_type: {entry}")
- def _add_video(self, url, vid_type):
+ def _add_video(self, url, vid_type, idx, total):
"""add video to list"""
if self.auto_start and url in set(
i["youtube_id"] for i in self.all_pending
@@ -248,7 +250,13 @@ class PendingList(PendingIndex):
if url in self.missing_videos or url in self.to_skip:
print(f"{url}: skipped adding already indexed video to download.")
else:
- to_add = self._parse_video(url, vid_type)
+ to_add = self._parse_video(
+ url,
+ vid_type,
+ url_type="video",
+ idx=idx,
+ total=total,
+ )
if to_add:
self.missing_videos.append(to_add)
@@ -261,7 +269,8 @@ class PendingList(PendingIndex):
channel_handler = YoutubeChannel(url)
channel_handler.build_json(upload=False)
- for video_data in video_results:
+ total = len(video_results)
+ for idx, video_data in enumerate(video_results):
video_id = video_data["id"]
if video_id in self.to_skip:
continue
@@ -276,10 +285,20 @@ class PendingList(PendingIndex):
video_data["channel_id"] = channel_id
to_add = self._parse_entry(
- youtube_id=video_id, video_data=video_data
+ youtube_id=video_id,
+ video_data=video_data,
+ url_type="channel",
+ idx=idx,
+ total=total,
)
else:
- to_add = self._parse_video(video_id, vid_type)
+ to_add = self._parse_video(
+ video_id,
+ vid_type,
+ url_type="channel",
+ idx=idx,
+ total=total,
+ )
if to_add:
self.missing_videos.append(to_add)
@@ -290,7 +309,8 @@ class PendingList(PendingIndex):
playlist.get_from_youtube()
video_results = playlist.youtube_meta["entries"]
- for video_data in video_results:
+ total = len(video_results)
+ for idx, video_data in enumerate(video_results):
video_id = video_data["id"]
if video_id in self.to_skip:
continue
@@ -303,26 +323,52 @@ class PendingList(PendingIndex):
channel_id = playlist.youtube_meta["channel_id"]
video_data["channel_id"] = channel_id
- to_add = self._parse_entry(video_id, video_data)
+ to_add = self._parse_entry(
+ video_id,
+ video_data,
+ url_type="playlist",
+ idx=idx,
+ total=total,
+ )
else:
- to_add = self._parse_video(video_id, vid_type=None)
+ to_add = self._parse_video(
+ video_id,
+ vid_type=None,
+ url_type="playlist",
+ idx=idx,
+ total=total,
+ )
if to_add:
self.missing_videos.append(to_add)
- def _parse_video(self, url, vid_type):
+ def _parse_video(self, url, vid_type, url_type, idx, total):
"""parse video"""
video = YoutubeVideo(youtube_id=url)
video.get_from_youtube()
video_data = video.youtube_meta
video_data["vid_type"] = vid_type
- to_add = self._parse_entry(youtube_id=url, video_data=video_data)
+ to_add = self._parse_entry(
+ youtube_id=url,
+ video_data=video_data,
+ url_type=url_type,
+ idx=idx,
+ total=total,
+ )
+ ThumbManager(item_id=url).download_video_thumb(to_add["vid_thumb_url"])
rand_sleep(self.config)
return to_add
- def _parse_entry(self, youtube_id: str, video_data: dict) -> dict | None:
+ def _parse_entry(
+ self,
+ youtube_id: str,
+ video_data: dict,
+ url_type: str,
+ idx: int,
+ total: int,
+ ) -> dict | None:
"""parse entry"""
if video_data.get("id") != youtube_id:
# skip premium videos with different id or redirects
@@ -345,8 +391,8 @@ class PendingList(PendingIndex):
"channel_id": video_data["channel_id"],
"channel_indexed": video_data["channel_id"] in self.all_channels,
}
- thumb_url = to_add["vid_thumb_url"]
- ThumbManager(to_add["youtube_id"]).download_video_thumb(thumb_url)
+
+ self._notify_progress(url_type, video_data["title"], idx, total)
return to_add
@@ -355,9 +401,6 @@ class PendingList(PendingIndex):
if "thumbnail" in video_data:
return video_data["thumbnail"]
- if "thumbnails" in video_data:
- return video_data["thumbnails"][-1]["url"]
-
return None
def __extract_published(self, video_data) -> str | int | None:
@@ -430,6 +473,24 @@ class PendingList(PendingIndex):
return len(self.missing_videos)
+ def _notify_progress(self, url_type, name, idx, total):
+ """notify extraction progress"""
+ if not self.task:
+ return
+
+ if self.flat:
+ second_line = f"Bulk processing {total} items."
+ else:
+ second_line = f"Processing video {idx + 1}/{total}."
+
+ self.task.send_progress(
+ message_lines=[
+ f"Extracting '{name}' from {url_type.title()}.",
+ second_line,
+ ],
+ progress=(idx + 1) / total,
+ )
+
def _notify_empty(self):
"""notify nothing to add"""
if not self.task:
@@ -437,7 +498,7 @@ class PendingList(PendingIndex):
self.task.send_progress(
message_lines=[
- "Extractinc videos completed.",
+ "Extracting videos completed.",
"No new videos found to add.",
]
)
diff --git a/backend/task/tasks.py b/backend/task/tasks.py
index 8583e916..413c1683 100644
--- a/backend/task/tasks.py
+++ b/backend/task/tasks.py
@@ -261,7 +261,7 @@ def rescan_filesystem(self):
handler = Scanner(task=self)
handler.scan()
handler.apply()
- ThumbValidator(task=self).validate()
+ thumbnail_check.delay()
@shared_task(bind=True, name="thumbnail_check", base=BaseTask)
diff --git a/backend/video/src/index.py b/backend/video/src/index.py
index 6f324eb3..636bc4bb 100644
--- a/backend/video/src/index.py
+++ b/backend/video/src/index.py
@@ -14,6 +14,7 @@ from common.src.es_connect import ElasticWrap
from common.src.helper import get_duration_sec, get_duration_str, randomizor
from common.src.index_generic import YouTubeItem
from django.conf import settings
+from download.src.thumbnails import ThumbManager
from playlist.src import index as ta_playlist
from ryd_client import ryd_client
from user.src.user_config import UserConfig
@@ -409,5 +410,8 @@ def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
raise ValueError("failed to get metadata for " + youtube_id)
video.check_subtitles()
+ url = video.json_data["vid_thumb_url"]
+ ThumbManager(item_id=video.youtube_id).download_video_thumb(url=url)
video.upload_to_es()
+
return video.json_data
From 8fbd94b12027594294a859a995f75e6fdc8c2f55 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 10:10:49 +0700
Subject: [PATCH 38/55] add to queue error handling
---
backend/common/src/helper.py | 10 +++++++---
backend/download/src/queue.py | 20 +++++++++++++++++---
2 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/backend/common/src/helper.py b/backend/common/src/helper.py
index 7b1d43be..bfb5e474 100644
--- a/backend/common/src/helper.py
+++ b/backend/common/src/helper.py
@@ -155,9 +155,13 @@ def is_shorts(youtube_id: str) -> bool:
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
cookies = {"SOCS": "CAI"}
- response = requests.head(
- shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
- )
+ try:
+ response = requests.head(
+ shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
+ )
+ except requests.exceptions.RequestException:
+ # assume video on error
+ return False
return response.status_code == 200
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index 45311572..3d8c1522 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -266,8 +266,15 @@ class PendingList(PendingIndex):
video_results = ChannelSubscription().get_last_youtube_videos(
url, limit=False, query_filter=query_filter
)
+ if not video_results:
+ print(f"{url}: no videos to add from channel, skipping")
+ return
+
channel_handler = YoutubeChannel(url)
channel_handler.build_json(upload=False)
+ if not channel_handler.json_data:
+ print(f"{url}: channel metadata extraction failed, skipping")
+ return
total = len(video_results)
for idx, video_data in enumerate(video_results):
@@ -307,6 +314,10 @@ class PendingList(PendingIndex):
"""fast parse playlist"""
playlist = YoutubePlaylist(url)
playlist.get_from_youtube()
+ if not playlist.youtube_meta:
+ print(f"{url}: playlist metadata extraction failed, skipping")
+ return
+
video_results = playlist.youtube_meta["entries"]
total = len(video_results)
@@ -347,11 +358,14 @@ class PendingList(PendingIndex):
video = YoutubeVideo(youtube_id=url)
video.get_from_youtube()
- video_data = video.youtube_meta
- video_data["vid_type"] = vid_type
+ if not video.youtube_meta:
+ print(f"{url}: video metadata extraction failed, skipping")
+ return None
+
+ video.youtube_meta["vid_type"] = vid_type
to_add = self._parse_entry(
youtube_id=url,
- video_data=video_data,
+ video_data=video.youtube_meta,
url_type=url_type,
idx=idx,
total=total,
From f45214714c4bf81367728cde086aa5ee66790ef8 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 10:18:04 +0700
Subject: [PATCH 39/55] handle API stop
---
backend/download/src/queue.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index 3d8c1522..3a6f4100 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -217,6 +217,9 @@ class PendingList(PendingIndex):
total = len(self.youtube_ids)
for idx, entry in enumerate(self.youtube_ids):
self._process_entry(entry, idx, total)
+ if self.task and self.task.is_stopped():
+ break
+
rand_sleep(self.config)
if not self.task:
continue
@@ -282,6 +285,9 @@ class PendingList(PendingIndex):
if video_id in self.to_skip:
continue
+ if self.task and self.task.is_stopped():
+ break
+
if self.flat:
if not video_data.get("channel"):
channel_name = channel_handler.json_data["channel_name"]
@@ -326,6 +332,9 @@ class PendingList(PendingIndex):
if video_id in self.to_skip:
continue
+ if self.task and self.task.is_stopped():
+ break
+
if self.flat:
if not video_data.get("channel"):
video_data["channel"] = playlist.youtube_meta["channel"]
From 83404628e66675ab3f288debc53a37dfab37dcba Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 10:58:26 +0700
Subject: [PATCH 40/55] refact yt-dlp info extract, show errors
---
backend/channel/src/index.py | 2 +-
backend/common/src/index_generic.py | 5 ++++-
backend/common/src/urlparser.py | 4 ++--
backend/download/src/queue.py | 11 +++++++++-
backend/download/src/subscriptions.py | 2 +-
backend/download/src/yt_dlp_base.py | 29 +++++++++++++++------------
backend/task/tasks.py | 10 +++++----
backend/video/src/comments.py | 4 +++-
8 files changed, 43 insertions(+), 24 deletions(-)
diff --git a/backend/channel/src/index.py b/backend/channel/src/index.py
index 65503ef7..ddb66d96 100644
--- a/backend/channel/src/index.py
+++ b/backend/channel/src/index.py
@@ -301,7 +301,7 @@ class YoutubeChannel(YouTubeItem):
+ "/playlists?view=1&sort=dd&shelf_id=0"
)
obs = {"skip_download": True, "extract_flat": True}
- playlists = YtWrap(obs, self.config).extract(url)
+ playlists, _ = YtWrap(obs, self.config).extract(url)
if not playlists:
self.all_playlists = []
return
diff --git a/backend/common/src/index_generic.py b/backend/common/src/index_generic.py
index 450c1f88..116d4965 100644
--- a/backend/common/src/index_generic.py
+++ b/backend/common/src/index_generic.py
@@ -26,6 +26,7 @@ class YouTubeItem:
self.youtube_id = youtube_id
self.es_path = f"{self.index_name}/_doc/{youtube_id}"
self.config = AppConfig().config
+ self.error = None
self.youtube_meta = False
self.json_data = False
@@ -43,7 +44,9 @@ class YouTubeItem:
obs_request["extractor_args"] = {"youtube": {"lang": langs_list}}
url = self.build_yt_url()
- self.youtube_meta = YtWrap(obs_request, self.config).extract(url)
+ self.youtube_meta, self.error = YtWrap(
+ obs_request, self.config
+ ).extract(url)
def get_from_es(self):
"""get indexed data from elastic search"""
diff --git a/backend/common/src/urlparser.py b/backend/common/src/urlparser.py
index 3db07ad6..3c75c82f 100644
--- a/backend/common/src/urlparser.py
+++ b/backend/common/src/urlparser.py
@@ -124,9 +124,9 @@ class Parser:
"extract_flat": True,
"playlistend": 0,
}
- url_info = YtWrap(obs_request).extract(url)
+ url_info, error = YtWrap(obs_request).extract(url)
if not url_info:
- raise ValueError(f"failed to retrieve content from URL: {url}")
+ raise ValueError(f"failed to retrieve URL: {error}")
channel_id = url_info.get("channel_id", False)
if channel_id:
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index 3a6f4100..da4bc646 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -369,6 +369,14 @@ class PendingList(PendingIndex):
if not video.youtube_meta:
print(f"{url}: video metadata extraction failed, skipping")
+ if self.task:
+ self.task.send_progress(
+ message_lines=[
+ "Video extraction failed.",
+ f"{video.error}",
+ ],
+ level="error",
+ )
return None
video.youtube_meta["vid_type"] = vid_type
@@ -559,5 +567,6 @@ class PendingList(PendingIndex):
message_lines=[
"Adding extracted videos failed.",
f"Status code: {status_code}",
- ]
+ ],
+ level="error",
)
diff --git a/backend/download/src/subscriptions.py b/backend/download/src/subscriptions.py
index 524202e5..65080dd7 100644
--- a/backend/download/src/subscriptions.py
+++ b/backend/download/src/subscriptions.py
@@ -61,7 +61,7 @@ class ChannelSubscription:
obs["playlistend"] = limit_amount
url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
- channel_query = YtWrap(obs, self.config).extract(url)
+ channel_query, _ = YtWrap(obs, self.config).extract(url)
if not channel_query:
continue
diff --git a/backend/download/src/yt_dlp_base.py b/backend/download/src/yt_dlp_base.py
index c80f9d7d..68332da0 100644
--- a/backend/download/src/yt_dlp_base.py
+++ b/backend/download/src/yt_dlp_base.py
@@ -80,30 +80,33 @@ class YtWrap:
return True, True
- def extract(self, url):
- """make extract request"""
+ def extract(self, url) -> tuple[dict | None, str | None]:
+ """
+ make extract request
+ returns response, error
+ """
with yt_dlp.YoutubeDL(self.obs) as ydl:
try:
response = ydl.extract_info(url)
except cookiejar.LoadError as err:
print(f"cookie file is invalid: {err}")
- return False
+ return None, str(err)
except yt_dlp.utils.ExtractorError as err:
print(f"{url}: failed to extract: {err}, continue...")
- return False
+ return None, str(err)
except yt_dlp.utils.DownloadError as err:
if "This channel does not have a" in str(err):
- return False
+ return None, None
print(f"{url}: failed to get info from youtube: {err}")
if "Temporary failure in name resolution" in str(err):
raise ConnectionError("lost the internet, abort!") from err
- return False
+ return None, str(err)
self._validate_cookie()
- return response
+ return response, None
def _validate_cookie(self):
"""check cookie and write it back for next use"""
@@ -146,7 +149,7 @@ class CookieHandler:
AppConfig().update_config({"downloads": {"cookie_import": False}})
print("[cookie]: revoked")
- def validate(self):
+ def validate(self) -> bool:
"""validate cookie using the liked videos playlist"""
validation = RedisArchivist().get_message_dict("cookie:valid")
if validation:
@@ -159,8 +162,8 @@ class CookieHandler:
"extract_flat": True,
}
validator = YtWrap(obs_request, self.config)
- response = bool(validator.extract("LL"))
- self.store_validation(response)
+ response, error = validator.extract("LL")
+ self.store_validation(bool(response))
# update in redis to avoid expiring
modified = validator.obs["cookiefile"].getvalue().strip("\x00")
@@ -173,15 +176,15 @@ class CookieHandler:
"status": "message:download",
"level": "error",
"title": "Cookie validation failed, exiting...",
- "message": "",
+ "message": error,
}
RedisArchivist().set_message(
"message:download", mess_dict, expire=4
)
print("[cookie]: validation failed, exiting...")
- print(f"[cookie]: validation success: {response}")
- return response
+ print(f"[cookie]: validation success: {bool(response)}")
+ return bool(response)
@staticmethod
def store_validation(response):
diff --git a/backend/task/tasks.py b/backend/task/tasks.py
index 413c1683..cb76b8e8 100644
--- a/backend/task/tasks.py
+++ b/backend/task/tasks.py
@@ -39,10 +39,10 @@ class BaseTask(Task):
RedisArchivist().set_message(key, message, expire=20)
def on_success(self, retval, task_id, args, kwargs):
- """callback task completed successfully"""
+ """callback task completed"""
print(f"{task_id} success callback")
message, key = self._build_message()
- message.update({"messages": ["Task completed successfully"]})
+ message.update({"messages": ["Task completed"]})
RedisArchivist().set_message(key, message, expire=5)
def before_start(self, task_id, args, kwargs):
@@ -58,9 +58,11 @@ class BaseTask(Task):
task_title = TASK_CONFIG.get(self.name).get("title")
Notifications(self.name).send(task_id, task_title)
- def send_progress(self, message_lines, progress=False, title=False):
+ def send_progress(
+ self, message_lines, progress=False, title=False, level="info"
+ ):
"""send progress message"""
- message, key = self._build_message()
+ message, key = self._build_message(level=level)
message.update(
{
"messages": message_lines,
diff --git a/backend/video/src/comments.py b/backend/video/src/comments.py
index 2d13448c..4b145a75 100644
--- a/backend/video/src/comments.py
+++ b/backend/video/src/comments.py
@@ -79,7 +79,9 @@ class Comments:
def get_yt_comments(self):
"""get comments from youtube"""
yt_obs = self.build_yt_obs()
- info_json = YtWrap(yt_obs, config=self.config).extract(self.youtube_id)
+ info_json, _ = YtWrap(yt_obs, config=self.config).extract(
+ self.youtube_id
+ )
if not info_json:
return False, False
From ffd3bab9483e65c11a8adac6b18c0d199fe8fe92 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 11:29:50 +0700
Subject: [PATCH 41/55] handle download queue search
---
backend/download/serializers.py | 1 +
backend/download/views.py | 4 +++
frontend/src/api/loader/loadDownloadQueue.ts | 2 ++
frontend/src/pages/Download.tsx | 32 +++++++++++++++++---
4 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/backend/download/serializers.py b/backend/download/serializers.py
index c8afde33..1479edad 100644
--- a/backend/download/serializers.py
+++ b/backend/download/serializers.py
@@ -47,6 +47,7 @@ class DownloadListQuerySerializer(
)
channel = serializers.CharField(required=False, help_text="channel ID")
page = serializers.IntegerField(required=False)
+ q = serializers.CharField(required=False, help_text="Search Query")
class DownloadListQueueDeleteQuerySerializer(serializers.Serializer):
diff --git a/backend/download/views.py b/backend/download/views.py
index 80ed95b5..71729a5c 100644
--- a/backend/download/views.py
+++ b/backend/download/views.py
@@ -73,6 +73,10 @@ class DownloadApiListView(ApiBaseView):
{"term": {"vid_type": {"value": vid_type_filter}}}
)
+ search_query = validated_data.get("q")
+ if search_query:
+ must_list.append({"match_phrase_prefix": {"title": search_query}})
+
self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request)
diff --git a/frontend/src/api/loader/loadDownloadQueue.ts b/frontend/src/api/loader/loadDownloadQueue.ts
index d4979f75..2a2f4087 100644
--- a/frontend/src/api/loader/loadDownloadQueue.ts
+++ b/frontend/src/api/loader/loadDownloadQueue.ts
@@ -6,12 +6,14 @@ const loadDownloadQueue = async (
channelId: string | null,
vid_type: string | null,
showIgnored: boolean,
+ search: string,
) => {
const searchParams = new URLSearchParams();
if (page) searchParams.append('page', page.toString());
if (channelId) searchParams.append('channel', channelId);
if (vid_type) searchParams.append('vid_type', vid_type);
+ if (search) searchParams.append('q', encodeURIComponent(search));
searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx
index c141648e..9d361459 100644
--- a/frontend/src/pages/Download.tsx
+++ b/frontend/src/pages/Download.tsx
@@ -4,6 +4,7 @@ import iconAdd from '/img/icon-add.svg';
import iconSubstract from '/img/icon-substract.svg';
import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg';
+import iconSearch from '/img/icon-search.svg';
import { Fragment, useEffect, useState } from 'react';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import { ConfigType } from './Home';
@@ -64,6 +65,8 @@ const Download = () => {
const [addAsAutoStart, setAddAsAutoStart] = useState(false);
const [addAsFlat, setAddAsFlat] = useState(false);
const [showQueueActions, setShowQueueActions] = useState(false);
+ const [showSearchInput, setShowSearchInput] = useState(false);
+ const [searchInput, setSearchInput] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [downloadPending, setDownloadPending] = useState(false);
const [rescanPending, setRescanPending] = useState(false);
@@ -118,6 +121,7 @@ const Download = () => {
channelFilterFromUrl,
vidTypeFilterFromUrl,
showIgnored,
+ searchInput,
);
const { data: channelResponseData } = videosResponse ?? {};
const videoCount = channelResponseData?.paginate?.total_hits;
@@ -136,7 +140,7 @@ const Download = () => {
useEffect(() => {
setRefresh(true);
- }, [channelFilterFromUrl, vidTypeFilterFromUrl, currentPage, showIgnored]);
+ }, [channelFilterFromUrl, vidTypeFilterFromUrl, currentPage, showIgnored, searchInput]);
useEffect(() => {
(async () => {
@@ -156,6 +160,11 @@ const Download = () => {
setRefresh(true);
};
+ const handleShowSearchInput = () => {
+ if (showSearchInput) setSearchInput('');
+ setShowSearchInput(!showSearchInput);
+ };
+
return (
<>
TA | Downloads
@@ -309,9 +318,21 @@ const Download = () => {
-
setShowQueueActions(!showQueueActions)}>
- {showQueueActions ? 'Hide Actions' : 'Show Actions'}
-
+ {showSearchInput && (
+
setSearchInput(e.target.value)}
+ autoFocus
+ />
+ )}
+
{
})}
)}
+
setShowQueueActions(!showQueueActions)}>
+ {showQueueActions ? 'Hide Actions' : 'Show Actions'}
+
{isGridView && (
From 29be11cf759ae034bf4646d7c6c68453219fe06e Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 12:28:49 +0700
Subject: [PATCH 42/55] add error state filtering
---
backend/download/serializers.py | 1 +
backend/download/views.py | 6 +
frontend/src/api/loader/loadDownloadQueue.ts | 2 +
frontend/src/pages/Download.tsx | 165 ++++++++++---------
4 files changed, 99 insertions(+), 75 deletions(-)
diff --git a/backend/download/serializers.py b/backend/download/serializers.py
index 1479edad..b838b69c 100644
--- a/backend/download/serializers.py
+++ b/backend/download/serializers.py
@@ -48,6 +48,7 @@ class DownloadListQuerySerializer(
channel = serializers.CharField(required=False, help_text="channel ID")
page = serializers.IntegerField(required=False)
q = serializers.CharField(required=False, help_text="Search Query")
+ error = serializers.BooleanField(required=False, allow_null=True)
class DownloadListQueueDeleteQuerySerializer(serializers.Serializer):
diff --git a/backend/download/views.py b/backend/download/views.py
index 71729a5c..bc30d12b 100644
--- a/backend/download/views.py
+++ b/backend/download/views.py
@@ -77,6 +77,12 @@ class DownloadApiListView(ApiBaseView):
if search_query:
must_list.append({"match_phrase_prefix": {"title": search_query}})
+ if validated_data.get("error") is not None:
+ operator = "must" if validated_data["error"] else "must_not"
+ must_list.append(
+ {"bool": {operator: [{"exists": {"field": "message"}}]}}
+ )
+
self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request)
diff --git a/frontend/src/api/loader/loadDownloadQueue.ts b/frontend/src/api/loader/loadDownloadQueue.ts
index 2a2f4087..367638ab 100644
--- a/frontend/src/api/loader/loadDownloadQueue.ts
+++ b/frontend/src/api/loader/loadDownloadQueue.ts
@@ -5,6 +5,7 @@ const loadDownloadQueue = async (
page: number,
channelId: string | null,
vid_type: string | null,
+ errorFilterFromUrl: string | null,
showIgnored: boolean,
search: string,
) => {
@@ -14,6 +15,7 @@ const loadDownloadQueue = async (
if (channelId) searchParams.append('channel', channelId);
if (vid_type) searchParams.append('vid_type', vid_type);
if (search) searchParams.append('q', encodeURIComponent(search));
+ if (errorFilterFromUrl !== null) searchParams.append('error', errorFilterFromUrl);
searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx
index 9d361459..824a97c7 100644
--- a/frontend/src/pages/Download.tsx
+++ b/frontend/src/pages/Download.tsx
@@ -4,7 +4,6 @@ import iconAdd from '/img/icon-add.svg';
import iconSubstract from '/img/icon-substract.svg';
import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg';
-import iconSearch from '/img/icon-search.svg';
import { Fragment, useEffect, useState } from 'react';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import { ConfigType } from './Home';
@@ -59,13 +58,13 @@ const Download = () => {
const channelFilterFromUrl = searchParams.get('channel');
const ignoredOnlyParam = searchParams.get('ignored');
const vidTypeFilterFromUrl = searchParams.get('vid-type');
+ const errorFilterFromUrl = searchParams.get('error');
const [refresh, setRefresh] = useState(false);
const [showHiddenForm, setShowHiddenForm] = useState(false);
const [addAsAutoStart, setAddAsAutoStart] = useState(false);
const [addAsFlat, setAddAsFlat] = useState(false);
const [showQueueActions, setShowQueueActions] = useState(false);
- const [showSearchInput, setShowSearchInput] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [downloadPending, setDownloadPending] = useState(false);
@@ -120,6 +119,7 @@ const Download = () => {
currentPage,
channelFilterFromUrl,
vidTypeFilterFromUrl,
+ errorFilterFromUrl,
showIgnored,
searchInput,
);
@@ -140,7 +140,14 @@ const Download = () => {
useEffect(() => {
setRefresh(true);
- }, [channelFilterFromUrl, vidTypeFilterFromUrl, currentPage, showIgnored, searchInput]);
+ }, [
+ channelFilterFromUrl,
+ vidTypeFilterFromUrl,
+ errorFilterFromUrl,
+ currentPage,
+ showIgnored,
+ searchInput,
+ ]);
useEffect(() => {
(async () => {
@@ -160,11 +167,6 @@ const Download = () => {
setRefresh(true);
};
- const handleShowSearchInput = () => {
- if (showSearchInput) setSearchInput('');
- setShowSearchInput(!showSearchInput);
- };
-
return (
<>
TA | Downloads
@@ -318,74 +320,8 @@ const Download = () => {
- {showSearchInput && (
-
setSearchInput(e.target.value)}
- autoFocus
- />
- )}
-
-
{
- const value = event.currentTarget.value;
- const params = searchParams;
- if (value !== 'all') {
- params.set('vid-type', value);
- } else {
- params.delete('vid-type');
- }
- setSearchParams(params);
- }}
- >
- all types
- Videos
- Streams
- Shorts
-
- {channelAggsList && channelAggsList.length > 1 && (
-
{
- const value = event.currentTarget.value;
-
- const params = searchParams;
- if (value !== 'all') {
- params.set('channel', value);
- } else {
- params.delete('channel');
- }
-
- setSearchParams(params);
- }}
- >
- all channels
- {channelAggsList.map(channel => {
- const [name, id] = channel.key;
- const count = channel.doc_count;
-
- return (
-
- {name} ({count})
-
- );
- })}
-
- )}
setShowQueueActions(!showQueueActions)}>
- {showQueueActions ? 'Hide Actions' : 'Show Actions'}
+ {showQueueActions ? 'Hide Advanced' : 'Show Advanced'}
{isGridView && (
@@ -449,6 +385,85 @@ const Download = () => {
{showQueueActions && (
+
Search & Filter
+
{
+ const value = event.currentTarget.value;
+ const params = searchParams;
+ if (value !== 'all') {
+ params.set('vid-type', value);
+ } else {
+ params.delete('vid-type');
+ }
+ setSearchParams(params);
+ }}
+ >
+ all types
+ Videos
+ Streams
+ Shorts
+
+ {channelAggsList && channelAggsList.length > 1 && (
+
{
+ const value = event.currentTarget.value;
+
+ const params = searchParams;
+ if (value !== 'all') {
+ params.set('channel', value);
+ } else {
+ params.delete('channel');
+ }
+
+ setSearchParams(params);
+ }}
+ >
+ all channels
+ {channelAggsList.map(channel => {
+ const [name, id] = channel.key;
+ const count = channel.doc_count;
+
+ return (
+
+ {name} ({count})
+
+ );
+ })}
+
+ )}
+
{
+ const value = event.currentTarget.value;
+ const params = searchParams;
+ if (value !== 'all') {
+ params.set('error', value);
+ } else {
+ params.delete('error');
+ }
+ setSearchParams(params);
+ }}
+ >
+ all error state
+ has error
+ has no error
+
+
setSearchInput(e.target.value)}
+ />
+ {searchInput &&
setSearchInput('')}>Clear }
+
Bulk actions
Applied filtered by status '{showIgnoredFilter}'
From b91408ada2a366e2cee8140ba42af1dce503cab1 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 15:02:40 +0700
Subject: [PATCH 43/55] remove unused
---
backend/video/src/index.py | 7 -------
1 file changed, 7 deletions(-)
diff --git a/backend/video/src/index.py b/backend/video/src/index.py
index 636bc4bb..34e23a8c 100644
--- a/backend/video/src/index.py
+++ b/backend/video/src/index.py
@@ -10,7 +10,6 @@ from datetime import datetime
import requests
from channel.src import index as ta_channel
from common.src.env_settings import EnvironmentSettings
-from common.src.es_connect import ElasticWrap
from common.src.helper import get_duration_sec, get_duration_str, randomizor
from common.src.index_generic import YouTubeItem
from django.conf import settings
@@ -395,12 +394,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
return subtitles
- def update_media_url(self):
- """update only media_url in es for reindex channel rename"""
- data = {"doc": {"media_url": self.json_data["media_url"]}}
- path = f"{self.index_name}/_update/{self.youtube_id}"
- _, _ = ElasticWrap(path).post(data=data)
-
def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
"""combined classes to create new video in index"""
From 59e9ee7eed8afacc356804497dd7b269e44fa844 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 15:26:15 +0700
Subject: [PATCH 44/55] complete playlist mapping
---
backend/appsettings/index_mapping.json | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/backend/appsettings/index_mapping.json b/backend/appsettings/index_mapping.json
index d1bbd500..b2e57cf8 100644
--- a/backend/appsettings/index_mapping.json
+++ b/backend/appsettings/index_mapping.json
@@ -461,6 +461,15 @@
"playlist_description": {
"type": "text"
},
+ "playlist_subscribed": {
+ "type": "boolean"
+ },
+ "playlist_type": {
+ "type": "keyword"
+ },
+ "playlist_active": {
+ "type": "boolean"
+ },
"playlist_name": {
"type": "text",
"analyzer": "english",
From bbfd3f44236872309321a2be8de5689f493f4e29 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 16:34:00 +0700
Subject: [PATCH 45/55] implement dynamic obs overwrite
---
backend/common/src/index_generic.py | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/backend/common/src/index_generic.py b/backend/common/src/index_generic.py
index 116d4965..354f83ea 100644
--- a/backend/common/src/index_generic.py
+++ b/backend/common/src/index_generic.py
@@ -34,14 +34,19 @@ class YouTubeItem:
"""build youtube url"""
return self.yt_base + self.youtube_id
- def get_from_youtube(self):
+ def get_from_youtube(self, obs_overwrite: dict | None = None):
"""use yt-dlp to get meta data from youtube"""
print(f"{self.youtube_id}: get metadata from youtube")
obs_request = self.yt_obs.copy()
if self.config["downloads"]["extractor_lang"]:
langs = self.config["downloads"]["extractor_lang"]
langs_list = [i.strip() for i in langs.split(",")]
- obs_request["extractor_args"] = {"youtube": {"lang": langs_list}}
+ obs_request["extractor_args"] = {
+ "youtube": {"lang": langs_list}
+ } # type: ignore
+
+ if obs_overwrite:
+ obs_request.update(obs_overwrite)
url = self.build_yt_url()
self.youtube_meta, self.error = YtWrap(
From 90611dbe758d789a31c81cb6919a8b4437e2e92b Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 16:39:08 +0700
Subject: [PATCH 46/55] add playlist sort order toggle, #171
---
backend/appsettings/index_mapping.json | 3 ++
.../config/management/commands/ta_startup.py | 35 +++++++++++++++++++
backend/playlist/serializers.py | 6 +++-
backend/playlist/src/index.py | 26 ++++++++++++--
backend/playlist/views.py | 21 +++++++++--
.../api/actions/updatePlaylistSortOrder.ts | 10 ++++++
frontend/src/api/loader/loadPlaylistById.ts | 1 +
frontend/src/pages/Playlist.tsx | 26 ++++++++++++++
8 files changed, 121 insertions(+), 7 deletions(-)
create mode 100644 frontend/src/api/actions/updatePlaylistSortOrder.ts
diff --git a/backend/appsettings/index_mapping.json b/backend/appsettings/index_mapping.json
index b2e57cf8..72845492 100644
--- a/backend/appsettings/index_mapping.json
+++ b/backend/appsettings/index_mapping.json
@@ -506,6 +506,9 @@
"type": "date",
"format": "epoch_second"
},
+ "playlist_sort_order": {
+ "type": "keyword"
+ },
"playlist_entries": {
"properties": {
"downloaded": {
diff --git a/backend/config/management/commands/ta_startup.py b/backend/config/management/commands/ta_startup.py
index 980354b1..4b75f05b 100644
--- a/backend/config/management/commands/ta_startup.py
+++ b/backend/config/management/commands/ta_startup.py
@@ -56,6 +56,7 @@ class Command(BaseCommand):
self._mig_channel_tags()
self._mig_video_channel_tags()
self._mig_fix_download_channel_indexed()
+ self._mig_add_default_playlist_sort()
def _make_folders(self):
"""make expected cache folders"""
@@ -389,3 +390,37 @@ class Command(BaseCommand):
self.stdout.write(response)
sleep(60)
raise CommandError(message)
+
+ def _mig_add_default_playlist_sort(self) -> None:
+ """migrate from 0.5.4 to 0.5.5 set default playlist sortorder"""
+ self.stdout.write("[MIGRATION] set default playlist sort order")
+ path = "ta_playlist/_update_by_query"
+ data = {
+ "query": {
+ "bool": {
+ "must_not": [{"exists": {"field": "playlist_sort_order"}}]
+ }
+ },
+ "script": {
+ "source": "ctx._source.playlist_sort_order = 'top'",
+ "lang": "painless",
+ },
+ }
+ response, status_code = ElasticWrap(path).post(data)
+ if status_code in [200, 201]:
+ updated = response.get("updated")
+ if updated:
+ self.stdout.write(
+ self.style.SUCCESS(f" ✓ updated {updated} playlists")
+ )
+ else:
+ self.stdout.write(
+ self.style.SUCCESS(" no playlists need updating")
+ )
+ return
+
+ message = " 🗙 failed to set default playlist sort order"
+ self.stdout.write(self.style.ERROR(message))
+ self.stdout.write(response)
+ sleep(60)
+ raise CommandError(message)
diff --git a/backend/playlist/serializers.py b/backend/playlist/serializers.py
index 9362f5b6..741c770d 100644
--- a/backend/playlist/serializers.py
+++ b/backend/playlist/serializers.py
@@ -28,6 +28,7 @@ class PlaylistSerializer(serializers.Serializer):
playlist_last_refresh = serializers.CharField()
playlist_name = serializers.CharField()
playlist_subscribed = serializers.BooleanField()
+ playlist_sort_order = serializers.ChoiceField(choices=["top", "bottom"])
playlist_thumbnail = serializers.CharField()
playlist_type = serializers.ChoiceField(choices=["regular", "custom"])
_index = serializers.CharField(required=False)
@@ -68,7 +69,10 @@ class PlaylistBulkAddSerializer(serializers.Serializer):
class PlaylistSingleUpdate(serializers.Serializer):
"""update state of single playlist"""
- playlist_subscribed = serializers.BooleanField()
+ playlist_subscribed = serializers.BooleanField(required=False)
+ playlist_sort_order = serializers.ChoiceField(
+ choices=["top", "bottom"], required=False
+ )
class PlaylistListCustomPostSerializer(serializers.Serializer):
diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py
index 3533205a..5c892509 100644
--- a/backend/playlist/src/index.py
+++ b/backend/playlist/src/index.py
@@ -35,11 +35,17 @@ class YoutubePlaylist(YouTubeItem):
self.get_from_es()
if self.json_data:
subscribed = self.json_data.get("playlist_subscribed")
+ playlist_sort_order = self.json_data.get("playlist_sort_order")
else:
subscribed = False
+ playlist_sort_order = "top"
+
+ playlist_items = "::1" if playlist_sort_order == "top" else "::-1"
if scrape or not self.json_data:
- self.get_from_youtube()
+ self.get_from_youtube(
+ obs_overwrite={"playlist_items": playlist_items}
+ )
if not self.youtube_meta:
self.json_data = False
return
@@ -48,8 +54,13 @@ class YoutubePlaylist(YouTubeItem):
self._ensure_channel()
ids_found = self.get_local_vids()
self.get_entries(ids_found)
- self.json_data["playlist_entries"] = self.all_members
- self.json_data["playlist_subscribed"] = subscribed
+ self.json_data.update(
+ {
+ "playlist_entries": self.all_members,
+ "playlist_subscribed": subscribed,
+ "playlist_sort_order": playlist_sort_order,
+ }
+ )
def process_youtube_meta(self):
"""extract relevant fields from youtube"""
@@ -190,6 +201,15 @@ class YoutubePlaylist(YouTubeItem):
self.get_playlist_art()
return True
+ def change_sort_order(self, new_sort_order):
+ """update sort order of playlist"""
+ playlist = YoutubePlaylist(self.youtube_id)
+ playlist.build_json()
+ playlist.json_data["playlist_sort_order"] = new_sort_order
+ playlist.upload_to_es()
+
+ return playlist.json_data
+
def build_nav(self, youtube_id):
"""find next and previous in playlist of a given youtube_id"""
cache_root = EnvironmentSettings().get_cache_root()
diff --git a/backend/playlist/views.py b/backend/playlist/views.py
index 6ec102ff..1ce9261a 100644
--- a/backend/playlist/views.py
+++ b/backend/playlist/views.py
@@ -240,9 +240,24 @@ class PlaylistApiView(ApiBaseView):
error = ErrorResponseSerializer({"error": "playlist not found"})
return Response(error.data, status=404)
- subscribed = validated_data["playlist_subscribed"]
- playlist_sub = PlaylistSubscription()
- json_data = playlist_sub.change_subscribe(playlist_id, subscribed)
+ subscribed = validated_data.get("playlist_subscribed")
+ sort_order = validated_data.get("playlist_sort_order")
+
+ json_data = None
+ if subscribed is not None:
+ playlist_sub = PlaylistSubscription()
+ json_data = playlist_sub.change_subscribe(playlist_id, subscribed)
+
+ if sort_order:
+ json_data = YoutubePlaylist(playlist_id).change_sort_order(
+ new_sort_order=sort_order
+ )
+
+ if not json_data:
+ error = ErrorResponseSerializer(
+ {"error": "expect playlist_subscribed or playlist_sort_order"}
+ )
+ return Response(error.data, status=400)
response_serializer = PlaylistSerializer(json_data)
return Response(response_serializer.data)
diff --git a/frontend/src/api/actions/updatePlaylistSortOrder.ts b/frontend/src/api/actions/updatePlaylistSortOrder.ts
new file mode 100644
index 00000000..a7234da2
--- /dev/null
+++ b/frontend/src/api/actions/updatePlaylistSortOrder.ts
@@ -0,0 +1,10 @@
+import APIClient from '../../functions/APIClient';
+
+const updatePlaylistSortOrder = async (playlistId: string, newSortOrder: 'top' | 'bottom') => {
+ return APIClient(`/api/playlist/${playlistId}/`, {
+ method: 'POST',
+ body: { playlist_sort_order: newSortOrder },
+ });
+};
+
+export default updatePlaylistSortOrder;
diff --git a/frontend/src/api/loader/loadPlaylistById.ts b/frontend/src/api/loader/loadPlaylistById.ts
index b800410f..5a339baa 100644
--- a/frontend/src/api/loader/loadPlaylistById.ts
+++ b/frontend/src/api/loader/loadPlaylistById.ts
@@ -14,6 +14,7 @@ export type PlaylistType = {
playlist_channel_id: string;
playlist_description: string;
playlist_entries: PlaylistEntryType[];
+ playlist_sort_order: 'top' | 'bottom';
playlist_id: string;
playlist_last_refresh: string;
playlist_name: string;
diff --git a/frontend/src/pages/Playlist.tsx b/frontend/src/pages/Playlist.tsx
index f8affa20..6d1227d4 100644
--- a/frontend/src/pages/Playlist.tsx
+++ b/frontend/src/pages/Playlist.tsx
@@ -31,6 +31,7 @@ import useIsAdmin from '../functions/useIsAdmin';
import { useUserConfigStore } from '../stores/UserConfigStore';
import { ApiResponseType } from '../functions/APIClient';
import NotFound from './NotFound';
+import updatePlaylistSortOrder from '../api/actions/updatePlaylistSortOrder';
export type VideoResponseType = {
data?: VideoType[];
@@ -287,6 +288,31 @@ const Playlist = () => {
/>
)}
+
+
Switch sort order:
+
+ {
+ const newSortOrder =
+ playlist.playlist_sort_order === 'top' ? 'bottom' : 'top';
+ await updatePlaylistSortOrder(playlist.playlist_id, newSortOrder);
+ setRefresh(true);
+ }}
+ />
+ {playlist.playlist_sort_order === 'bottom' ? (
+
+ On
+
+ ) : (
+
+ Off
+
+ )}
+
+
From f51e0947458d8c7b0c3a2efca405cb567cd456b7 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 16:57:47 +0700
Subject: [PATCH 47/55] remove channel json file parsing, #1004
---
backend/channel/src/index.py | 22 ----------------------
1 file changed, 22 deletions(-)
diff --git a/backend/channel/src/index.py b/backend/channel/src/index.py
index ddb66d96..bf36b5a3 100644
--- a/backend/channel/src/index.py
+++ b/backend/channel/src/index.py
@@ -4,7 +4,6 @@ functionality:
- index and update in es
"""
-import json
import os
from datetime import datetime
@@ -121,27 +120,6 @@ class YoutubeChannel(YouTubeItem):
"channel_thumb_url": False,
"channel_views": 0,
}
- self._info_json_fallback()
-
- def _info_json_fallback(self):
- """read channel info.json for additional metadata"""
- info_json = os.path.join(
- EnvironmentSettings.CACHE_DIR,
- "import",
- f"{self.youtube_id}.info.json",
- )
- if os.path.exists(info_json):
- print(f"{self.youtube_id}: read info.json file")
- with open(info_json, "r", encoding="utf-8") as f:
- content = json.loads(f.read())
-
- self.json_data.update(
- {
- "channel_subs": content.get("channel_follower_count", 0),
- "channel_description": content.get("description", False),
- }
- )
- os.remove(info_json)
def get_channel_art(self):
"""download channel art for new channels"""
From cefa0093ba2c9d6a0e41f6877acc4d0c0f4a09c6 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 17:09:11 +0700
Subject: [PATCH 48/55] handle form hide on delete confirm
---
frontend/src/pages/Download.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx
index 824a97c7..10510f99 100644
--- a/frontend/src/pages/Download.tsx
+++ b/frontend/src/pages/Download.tsx
@@ -504,6 +504,7 @@ const Download = () => {
vidTypeFilterFromUrl,
);
setRefresh(true);
+ setShowDeleteConfirm(false);
}}
>
Confirm
From 2868dae09d824b8e24d76d0cc4f585ef0227aa4e Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 17:31:37 +0700
Subject: [PATCH 49/55] raise on playlist channel ID extraction error, #1008
---
backend/playlist/src/index.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py
index 5c892509..4f5a1951 100644
--- a/backend/playlist/src/index.py
+++ b/backend/playlist/src/index.py
@@ -70,6 +70,9 @@ class YoutubePlaylist(YouTubeItem):
print(f"{self.youtube_id}: thumbnail extraction failed")
playlist_thumbnail = False
+ if not self.youtube_meta.get("channel_id"):
+ raise ValueError("Failed to extract Channel ID for Playlist")
+
self.json_data = {
"playlist_id": self.youtube_id,
"playlist_active": True,
From 59f0c74e545a1227934334bff82151c1ca7323d9 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 17:32:27 +0700
Subject: [PATCH 50/55] fix missing channel index for playlist
---
backend/download/src/queue.py | 15 +++++++--------
1 file changed, 7 insertions(+), 8 deletions(-)
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index da4bc646..f0bea7e8 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -216,18 +216,17 @@ class PendingList(PendingIndex):
self.get_channels()
total = len(self.youtube_ids)
for idx, entry in enumerate(self.youtube_ids):
+ if self.task:
+ self.task.send_progress(
+ message_lines=[f"Extracting items {idx + 1}/{total}"],
+ progress=(idx + 1) / total,
+ )
+
self._process_entry(entry, idx, total)
if self.task and self.task.is_stopped():
break
rand_sleep(self.config)
- if not self.task:
- continue
-
- self.task.send_progress(
- message_lines=[f"Extracting items {idx + 1}/{total}"],
- progress=(idx + 1) / total,
- )
def _process_entry(self, entry: dict, idx_url: int, total_url: int):
"""process single entry from url list"""
@@ -319,7 +318,7 @@ class PendingList(PendingIndex):
def _parse_playlist(self, url):
"""fast parse playlist"""
playlist = YoutubePlaylist(url)
- playlist.get_from_youtube()
+ playlist.update_playlist()
if not playlist.youtube_meta:
print(f"{url}: playlist metadata extraction failed, skipping")
return
From de0dd8eeec12ea17224d1d778e837cd679f787f1 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 18:25:21 +0700
Subject: [PATCH 51/55] cleanup duplicate
---
backend/playlist/src/index.py | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py
index 4f5a1951..493ad6ac 100644
--- a/backend/playlist/src/index.py
+++ b/backend/playlist/src/index.py
@@ -159,12 +159,11 @@ class YoutubePlaylist(YouTubeItem):
"""remove playlist ids from videos if needed"""
needed = [i["youtube_id"] for i in self.json_data["playlist_entries"]]
data = {
- "query": {"match": {"playlist": self.youtube_id}},
+ "query": {
+ "term": {"playlist.keyword": {"value": self.youtube_id}}
+ },
"_source": ["youtube_id"],
}
- data = {
- "query": {"term": {"playlist.keyword": {"value": self.youtube_id}}}
- }
result = IndexPaginate("ta_video", data).get_results()
to_remove = [
i["youtube_id"] for i in result if i["youtube_id"] not in needed
From e6c13698bd132fb792a5e90281cff6d3c19d38de Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 18:43:16 +0700
Subject: [PATCH 52/55] reject progress below thresh, handle progress clean up
from bulk update, #1009
---
backend/common/src/watched.py | 30 ++++++++++++++++++++++++++++++
backend/playlist/src/index.py | 20 +++++++++++++-------
backend/video/views.py | 3 ++-
3 files changed, 45 insertions(+), 8 deletions(-)
diff --git a/backend/common/src/watched.py b/backend/common/src/watched.py
index 5cdef464..27e135c2 100644
--- a/backend/common/src/watched.py
+++ b/backend/common/src/watched.py
@@ -28,6 +28,11 @@ class WatchState:
self.change_vid_state()
return
+ if url_type == "channel":
+ self.reset_channel_progress()
+ if url_type == "playlist":
+ self.reset_playlist_progress()
+
self._add_pipeline()
path = f"ta_video/_update_by_query?pipeline=watch_{self.youtube_id}"
data = self._build_update_data(url_type)
@@ -53,6 +58,31 @@ class WatchState:
print(response)
raise ValueError("failed to mark video as watched")
+ def reset_channel_progress(self):
+ """reset channel progress positions"""
+ from channel.src.index import YoutubeChannel
+
+ videos = YoutubeChannel(self.youtube_id).get_channel_videos()
+ video_ids = [i["youtube_id"] for i in videos]
+ self._reset_list(video_ids)
+
+ def reset_playlist_progress(self):
+ """reset playlist progress positions"""
+ from playlist.src.index import YoutubePlaylist
+
+ videos = YoutubePlaylist(self.youtube_id).get_playlist_videos()
+ video_ids = [i["youtube_id"] for i in videos]
+ self._reset_list(video_ids)
+
+ def _reset_list(self, video_ids: list[str]):
+ """reset list of video ids"""
+ redis_con = RedisArchivist()
+ all_ids = redis_con.list_keys(f"{self.user_id}:progress")
+ for progress_id in all_ids:
+ video_id = progress_id.split(":")[-1]
+ if video_id in video_ids:
+ redis_con.del_message(progress_id)
+
def _build_update_data(self, url_type):
"""build update by query data based on url_type"""
term_key_map = {
diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py
index 493ad6ac..d0d938d3 100644
--- a/backend/playlist/src/index.py
+++ b/backend/playlist/src/index.py
@@ -93,6 +93,18 @@ class YoutubePlaylist(YouTubeItem):
channel_handler = YoutubeChannel(channel_id)
channel_handler.build_json(upload=True)
+ def get_playlist_videos(self):
+ """get all playlist videos"""
+ data = {
+ "query": {
+ "term": {"playlist.keyword": {"value": self.youtube_id}}
+ },
+ "_source": ["youtube_id"],
+ }
+ result = IndexPaginate("ta_video", data).get_results()
+
+ return result
+
def get_local_vids(self) -> list[str]:
"""get local video ids from youtube entries"""
entries = self.youtube_meta["entries"]
@@ -158,13 +170,7 @@ class YoutubePlaylist(YouTubeItem):
def remove_vids_from_playlist(self):
"""remove playlist ids from videos if needed"""
needed = [i["youtube_id"] for i in self.json_data["playlist_entries"]]
- data = {
- "query": {
- "term": {"playlist.keyword": {"value": self.youtube_id}}
- },
- "_source": ["youtube_id"],
- }
- result = IndexPaginate("ta_video", data).get_results()
+ result = self.get_playlist_videos()
to_remove = [
i["youtube_id"] for i in result if i["youtube_id"] not in needed
]
diff --git a/backend/video/views.py b/backend/video/views.py
index a679552c..ef5c011a 100644
--- a/backend/video/views.py
+++ b/backend/video/views.py
@@ -227,7 +227,8 @@ class VideoProgressView(ApiBaseView):
expire = False
current_progress.update({"watched": watched})
- redis_con.set_message(key, current_progress, expire=expire)
+ if position > 5:
+ redis_con.set_message(key, current_progress, expire=expire)
response_serializer = PlayerSerializer(current_progress)
From 21f1d9cc00fe2965f7933c57ba9c689119aa7c7a Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 21:45:09 +0700
Subject: [PATCH 53/55] update docstring
---
backend/appsettings/src/reindex.py | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/backend/appsettings/src/reindex.py b/backend/appsettings/src/reindex.py
index 712eac72..e1cedcca 100644
--- a/backend/appsettings/src/reindex.py
+++ b/backend/appsettings/src/reindex.py
@@ -493,10 +493,7 @@ class ReindexProgress(ReindexBase):
class ChannelFullScan:
- """
- update from v0.3.0 to v0.3.1
- full scan of channel to fix vid_type mismatch
- """
+ """full scan of channel to fix vid_type mismatch"""
def __init__(self, channel_id):
self.channel_id = channel_id
From 05ce2a7034829747bbbfaf42f4f394f983e321e1 Mon Sep 17 00:00:00 2001
From: Simon
Date: Thu, 10 Jul 2025 22:09:13 +0700
Subject: [PATCH 54/55] refac, split pending interact to separate module
---
backend/download/src/queue.py | 98 +-----------------------
backend/download/src/queue_interact.py | 100 +++++++++++++++++++++++++
backend/download/views.py | 2 +-
3 files changed, 102 insertions(+), 98 deletions(-)
create mode 100644 backend/download/src/queue_interact.py
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index f0bea7e8..1451e307 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -11,6 +11,7 @@ from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import get_duration_str, is_shorts, rand_sleep
+from download.src.queue_interact import PendingInteract
from download.src.subscriptions import ChannelSubscription
from download.src.thumbnails import ThumbManager
from playlist.src.index import YoutubePlaylist
@@ -90,103 +91,6 @@ class PendingIndex:
self.video_overwrites.update({video_id: overwrites})
-class PendingInteract:
- """interact with items in download queue"""
-
- def __init__(self, youtube_id=False, status=False):
- self.youtube_id = youtube_id
- self.status = status
-
- def delete_item(self):
- """delete single item from pending"""
- path = f"ta_download/_doc/{self.youtube_id}"
- _, _ = ElasticWrap(path).delete(refresh=True)
-
- def delete_bulk(self, channel_id: str | None, vid_type: str | None):
- """delete all matching item by status"""
- must_list = [{"term": {"status": {"value": self.status}}}]
- if channel_id:
- must_list.append({"term": {"channel_id": {"value": channel_id}}})
-
- if vid_type:
- must_list.append({"term": {"vid_type": {"value": vid_type}}})
-
- data = {"query": {"bool": {"must": must_list}}}
-
- path = "ta_download/_delete_by_query?refresh=true"
- _, _ = ElasticWrap(path).post(data=data)
-
- def update_bulk(
- self, channel_id: str | None, vid_type: str | None, new_status: str
- ):
- """update status in bulk"""
- must_list = [{"term": {"status": {"value": self.status}}}]
- if channel_id:
- must_list.append({"term": {"channel_id": {"value": channel_id}}})
-
- if vid_type:
- must_list.append({"term": {"vid_type": {"value": vid_type}}})
-
- if new_status == "priority":
- source = """
- ctx._source.status = 'pending';
- ctx._source.auto_start = true;
- ctx._source.message = null;
- """
- else:
- source = f"ctx._source.status = '{new_status}'"
-
- data = {
- "query": {"bool": {"must": must_list}},
- "script": {"source": source, "lang": "painless"},
- }
-
- path = "ta_download/_update_by_query?refresh=true"
- _, _ = ElasticWrap(path).post(data)
-
- def update_status(self):
- """update status of pending item"""
- if self.status == "priority":
- data = {
- "doc": {
- "status": "pending",
- "auto_start": True,
- "message": None,
- }
- }
- else:
- data = {"doc": {"status": self.status}}
-
- path = f"ta_download/_update/{self.youtube_id}/?refresh=true"
- _, _ = ElasticWrap(path).post(data=data)
-
- def get_item(self):
- """return pending item dict"""
- path = f"ta_download/_doc/{self.youtube_id}"
- response, status_code = ElasticWrap(path).get()
- return response["_source"], status_code
-
- def get_channel(self):
- """
- get channel metadata from queue to not depend on channel to be indexed
- """
- data = {
- "size": 1,
- "query": {"term": {"channel_id": {"value": self.youtube_id}}},
- }
- response, _ = ElasticWrap("ta_download/_search").get(data=data)
- hits = response["hits"]["hits"]
- if not hits:
- channel_name = "NA"
- else:
- channel_name = hits[0]["_source"].get("channel_name", "NA")
-
- return {
- "channel_id": self.youtube_id,
- "channel_name": channel_name,
- }
-
-
class PendingList(PendingIndex):
"""manage the pending videos list"""
diff --git a/backend/download/src/queue_interact.py b/backend/download/src/queue_interact.py
new file mode 100644
index 00000000..260b5e5a
--- /dev/null
+++ b/backend/download/src/queue_interact.py
@@ -0,0 +1,100 @@
+"""interact with queue items"""
+
+from common.src.es_connect import ElasticWrap
+
+
+class PendingInteract:
+ """interact with items in download queue"""
+
+ def __init__(self, youtube_id=False, status=False):
+ self.youtube_id = youtube_id
+ self.status = status
+
+ def delete_item(self):
+ """delete single item from pending"""
+ path = f"ta_download/_doc/{self.youtube_id}"
+ _, _ = ElasticWrap(path).delete(refresh=True)
+
+ def delete_bulk(self, channel_id: str | None, vid_type: str | None):
+ """delete all matching item by status"""
+ must_list = [{"term": {"status": {"value": self.status}}}]
+ if channel_id:
+ must_list.append({"term": {"channel_id": {"value": channel_id}}})
+
+ if vid_type:
+ must_list.append({"term": {"vid_type": {"value": vid_type}}})
+
+ data = {"query": {"bool": {"must": must_list}}}
+
+ path = "ta_download/_delete_by_query?refresh=true"
+ _, _ = ElasticWrap(path).post(data=data)
+
+ def update_bulk(
+ self, channel_id: str | None, vid_type: str | None, new_status: str
+ ):
+ """update status in bulk"""
+ must_list = [{"term": {"status": {"value": self.status}}}]
+ if channel_id:
+ must_list.append({"term": {"channel_id": {"value": channel_id}}})
+
+ if vid_type:
+ must_list.append({"term": {"vid_type": {"value": vid_type}}})
+
+ if new_status == "priority":
+ source = """
+ ctx._source.status = 'pending';
+ ctx._source.auto_start = true;
+ ctx._source.message = null;
+ """
+ else:
+ source = f"ctx._source.status = '{new_status}'"
+
+ data = {
+ "query": {"bool": {"must": must_list}},
+ "script": {"source": source, "lang": "painless"},
+ }
+
+ path = "ta_download/_update_by_query?refresh=true"
+ _, _ = ElasticWrap(path).post(data)
+
+ def update_status(self):
+ """update status of pending item"""
+ if self.status == "priority":
+ data = {
+ "doc": {
+ "status": "pending",
+ "auto_start": True,
+ "message": None,
+ }
+ }
+ else:
+ data = {"doc": {"status": self.status}}
+
+ path = f"ta_download/_update/{self.youtube_id}/?refresh=true"
+ _, _ = ElasticWrap(path).post(data=data)
+
+ def get_item(self):
+ """return pending item dict"""
+ path = f"ta_download/_doc/{self.youtube_id}"
+ response, status_code = ElasticWrap(path).get()
+ return response["_source"], status_code
+
+ def get_channel(self):
+ """
+ get channel metadata from queue to not depend on channel to be indexed
+ """
+ data = {
+ "size": 1,
+ "query": {"term": {"channel_id": {"value": self.youtube_id}}},
+ }
+ response, _ = ElasticWrap("ta_download/_search").get(data=data)
+ hits = response["hits"]["hits"]
+ if not hits:
+ channel_name = "NA"
+ else:
+ channel_name = hits[0]["_source"].get("channel_name", "NA")
+
+ return {
+ "channel_id": self.youtube_id,
+ "channel_name": channel_name,
+ }
diff --git a/backend/download/views.py b/backend/download/views.py
index bc30d12b..05f3deb5 100644
--- a/backend/download/views.py
+++ b/backend/download/views.py
@@ -17,7 +17,7 @@ from download.serializers import (
DownloadListSerializer,
DownloadQueueItemUpdateSerializer,
)
-from download.src.queue import PendingInteract
+from download.src.queue_interact import PendingInteract
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.response import Response
from task.tasks import download_pending, extrac_dl
From fa7643e903ddefeafe4ebb751e709bd0d78c0740 Mon Sep 17 00:00:00 2001
From: Simon
Date: Fri, 11 Jul 2025 16:45:00 +0700
Subject: [PATCH 55/55] implement bulk add subscriptions in appsettings
---
backend/appsettings/serializers.py | 1 +
backend/appsettings/src/config.py | 2 +
backend/appsettings/src/reindex.py | 12 +-
backend/channel/src/index.py | 198 +++++----
backend/channel/src/remote_query.py | 124 ++++++
backend/channel/views.py | 9 +-
backend/common/src/helper.py | 44 ++
backend/common/src/urlparser.py | 16 +-
backend/download/src/queue.py | 112 +++--
backend/download/src/subscriptions.py | 395 ++++--------------
backend/download/src/yt_dlp_handler.py | 6 +-
backend/playlist/src/index.py | 17 +-
backend/playlist/views.py | 6 +-
backend/task/tasks.py | 12 +-
.../src/api/loader/loadAppsettingsConfig.ts | 1 +
frontend/src/pages/SettingsApplication.tsx | 16 +
frontend/src/stores/AppSettingsStore.ts | 1 +
17 files changed, 486 insertions(+), 486 deletions(-)
create mode 100644 backend/channel/src/remote_query.py
diff --git a/backend/appsettings/serializers.py b/backend/appsettings/serializers.py
index 5e6e434b..a690f37d 100644
--- a/backend/appsettings/serializers.py
+++ b/backend/appsettings/serializers.py
@@ -25,6 +25,7 @@ class AppConfigSubSerializer(
live_channel_size = serializers.IntegerField(required=False)
shorts_channel_size = serializers.IntegerField(required=False)
auto_start = serializers.BooleanField(required=False)
+ extract_flat = serializers.BooleanField(required=False)
class AppConfigDownloadsSerializer(
diff --git a/backend/appsettings/src/config.py b/backend/appsettings/src/config.py
index 3c14a2c0..52c69bb6 100644
--- a/backend/appsettings/src/config.py
+++ b/backend/appsettings/src/config.py
@@ -22,6 +22,7 @@ class SubscriptionsConfigType(TypedDict):
live_channel_size: int
shorts_channel_size: int
auto_start: bool
+ extract_flat: bool
class DownloadsConfigType(TypedDict):
@@ -73,6 +74,7 @@ class AppConfig:
"live_channel_size": 50,
"shorts_channel_size": 50,
"auto_start": False,
+ "extract_flat": False,
},
"downloads": {
"limit_speed": None,
diff --git a/backend/appsettings/src/reindex.py b/backend/appsettings/src/reindex.py
index e1cedcca..20f82425 100644
--- a/backend/appsettings/src/reindex.py
+++ b/backend/appsettings/src/reindex.py
@@ -11,11 +11,11 @@ from typing import Callable, TypedDict
from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
+from channel.src.remote_query import get_last_channel_videos
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import rand_sleep
from common.src.ta_redis import RedisQueue
-from download.src.subscriptions import ChannelSubscription
from download.src.thumbnails import ThumbManager
from download.src.yt_dlp_base import CookieHandler
from playlist.src.index import YoutubePlaylist
@@ -376,7 +376,7 @@ class Reindex(ReindexBase):
channel.upload_to_es()
channel.sync_to_videos()
- ChannelFullScan(channel_id).scan()
+ ChannelFullScan(channel_id, self.config).scan()
self.processed["channels"] += 1
def _reindex_single_playlist(self, playlist_id: str) -> None:
@@ -495,8 +495,9 @@ class ReindexProgress(ReindexBase):
class ChannelFullScan:
"""full scan of channel to fix vid_type mismatch"""
- def __init__(self, channel_id):
+ def __init__(self, channel_id, config):
self.channel_id = channel_id
+ self.config = config
self.to_update = False
def scan(self):
@@ -527,9 +528,8 @@ class ChannelFullScan:
def _get_all_remote(self):
"""get all channel videos"""
- sub = ChannelSubscription()
- all_remote_videos = sub.get_last_youtube_videos(
- self.channel_id, limit=False
+ all_remote_videos = get_last_channel_videos(
+ self.channel_id, self.config, limit=False
)
return all_remote_videos
diff --git a/backend/channel/src/index.py b/backend/channel/src/index.py
index bf36b5a3..17bf9526 100644
--- a/backend/channel/src/index.py
+++ b/backend/channel/src/index.py
@@ -155,48 +155,15 @@ class YoutubeChannel(YouTubeItem):
update_path = f"ta_video/_update_by_query?pipeline={self.youtube_id}"
_, _ = ElasticWrap(update_path).post(data)
- def get_folder_path(self):
- """get folder where media files get stored"""
- folder_path = os.path.join(
- EnvironmentSettings.MEDIA_DIR,
- self.json_data["channel_id"],
- )
- return folder_path
+ def change_subscribe(self, new_subscribe_state: bool):
+ """change subscribe status"""
+ if not self.json_data:
+ self.build_json()
- def delete_es_videos(self):
- """delete all channel documents from elasticsearch"""
- data = {
- "query": {
- "term": {"channel.channel_id": {"value": self.youtube_id}}
- }
- }
- _, _ = ElasticWrap("ta_video/_delete_by_query").post(data)
-
- def delete_es_comments(self):
- """delete all comments from this channel"""
- data = {
- "query": {
- "term": {"comment_channel_id": {"value": self.youtube_id}}
- }
- }
- _, _ = ElasticWrap("ta_comment/_delete_by_query").post(data)
-
- def delete_es_subtitles(self):
- """delete all subtitles from this channel"""
- data = {
- "query": {
- "term": {"subtitle_channel_id": {"value": self.youtube_id}}
- }
- }
- _, _ = ElasticWrap("ta_subtitle/_delete_by_query").post(data)
-
- def delete_playlists(self):
- """delete all indexed playlist from es"""
- from playlist.src.index import YoutubePlaylist
-
- all_playlists = self.get_indexed_playlists()
- for playlist in all_playlists:
- YoutubePlaylist(playlist["playlist_id"]).delete_metadata()
+ self.json_data["channel_subscribed"] = new_subscribe_state
+ self.upload_to_es()
+ self.sync_to_videos()
+ return self.json_data
def delete_channel(self):
"""delete channel and all videos"""
@@ -205,24 +172,7 @@ class YoutubeChannel(YouTubeItem):
if not self.json_data:
raise FileNotFoundError
- folder_path = self.get_folder_path()
- print(f"{self.youtube_id}: delete all media files")
- try:
- all_videos = os.listdir(folder_path)
- for video in all_videos:
- video_path = os.path.join(folder_path, video)
- os.remove(video_path)
- os.rmdir(folder_path)
- except FileNotFoundError:
- print(f"no videos found for {folder_path}")
-
- print(f"{self.youtube_id}: delete indexed playlists")
- self.delete_playlists()
- print(f"{self.youtube_id}: delete indexed videos")
- self.delete_es_videos()
- self.delete_es_comments()
- self.delete_es_subtitles()
- self.del_in_es()
+ ChannelDelete(json_data=self.json_data).delete()
def index_channel_playlists(self):
"""add all playlists of channel to index"""
@@ -244,6 +194,21 @@ class YoutubeChannel(YouTubeItem):
print("add playlist: " + playlist[1])
rand_sleep(self.config)
+ def get_all_playlists(self):
+ """get all playlists owned by this channel"""
+ url = (
+ f"https://www.youtube.com/channel/{self.youtube_id}"
+ + "/playlists?view=1&sort=dd&shelf_id=0"
+ )
+ obs = {"skip_download": True, "extract_flat": True}
+ playlists, _ = YtWrap(obs, self.config).extract(url)
+ if not playlists:
+ self.all_playlists = []
+ return
+
+ all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
+ self.all_playlists = all_entries
+
def _notify_single_playlist(self, idx, total):
"""send notification"""
channel_name = self.json_data["channel_name"]
@@ -272,34 +237,6 @@ class YoutubeChannel(YouTubeItem):
all_videos = IndexPaginate("ta_video", data).get_results()
return all_videos
- def get_all_playlists(self):
- """get all playlists owned by this channel"""
- url = (
- f"https://www.youtube.com/channel/{self.youtube_id}"
- + "/playlists?view=1&sort=dd&shelf_id=0"
- )
- obs = {"skip_download": True, "extract_flat": True}
- playlists, _ = YtWrap(obs, self.config).extract(url)
- if not playlists:
- self.all_playlists = []
- return
-
- all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
- self.all_playlists = all_entries
-
- def get_indexed_playlists(self, active_only=False):
- """get all indexed playlists from channel"""
- must_list = [
- {"term": {"playlist_channel_id": {"value": self.youtube_id}}}
- ]
- if active_only:
- must_list.append({"term": {"playlist_active": {"value": True}}})
-
- data = {"query": {"bool": {"must": must_list}}}
-
- all_playlists = IndexPaginate("ta_playlist", data).get_results()
- return all_playlists
-
def get_overwrites(self) -> dict:
"""get all per channel overwrites"""
return self.json_data.get("channel_overwrites", {})
@@ -330,6 +267,93 @@ class YoutubeChannel(YouTubeItem):
self.json_data["channel_overwrites"] = to_write
+class ChannelDelete(YouTubeItem):
+ """delete and cleanup"""
+
+ index_name = "ta_channel"
+
+ def __init__(self, json_data):
+ super().__init__(youtube_id=json_data["channel_id"])
+ self.json_data = json_data
+
+ def delete(self):
+ """delete channel and all videos"""
+ folder_path = self._get_folder_path()
+ print(f"{self.youtube_id}: delete all media files")
+ try:
+ all_videos = os.listdir(folder_path)
+ for video in all_videos:
+ video_path = os.path.join(folder_path, video)
+ os.remove(video_path)
+ os.rmdir(folder_path)
+ except FileNotFoundError:
+ print(f"no videos found for {folder_path}")
+
+ print(f"{self.youtube_id}: delete indexed playlists")
+ self._delete_playlists()
+ print(f"{self.youtube_id}: delete indexed videos")
+ self._delete_es_videos()
+ self._delete_es_comments()
+ self._delete_es_subtitles()
+ self.del_in_es()
+
+ def _get_folder_path(self):
+ """get folder where media files get stored"""
+ folder_path = os.path.join(
+ EnvironmentSettings.MEDIA_DIR,
+ self.json_data["channel_id"],
+ )
+ return folder_path
+
+ def _delete_es_videos(self):
+ """delete all channel documents from elasticsearch"""
+ data = {
+ "query": {
+ "term": {"channel.channel_id": {"value": self.youtube_id}}
+ }
+ }
+ _, _ = ElasticWrap("ta_video/_delete_by_query").post(data)
+
+ def _delete_es_comments(self):
+ """delete all comments from this channel"""
+ data = {
+ "query": {
+ "term": {"comment_channel_id": {"value": self.youtube_id}}
+ }
+ }
+ _, _ = ElasticWrap("ta_comment/_delete_by_query").post(data)
+
+ def _delete_es_subtitles(self):
+ """delete all subtitles from this channel"""
+ data = {
+ "query": {
+ "term": {"subtitle_channel_id": {"value": self.youtube_id}}
+ }
+ }
+ _, _ = ElasticWrap("ta_subtitle/_delete_by_query").post(data)
+
+ def _delete_playlists(self):
+ """delete all indexed playlist from es"""
+ from playlist.src.index import YoutubePlaylist
+
+ all_playlists = self._get_indexed_playlists()
+ for playlist in all_playlists:
+ YoutubePlaylist(playlist["playlist_id"]).delete_metadata()
+
+ def _get_indexed_playlists(self, active_only=False):
+ """get all indexed playlists from channel"""
+ must_list = [
+ {"term": {"playlist_channel_id": {"value": self.youtube_id}}}
+ ]
+ if active_only:
+ must_list.append({"term": {"playlist_active": {"value": True}}})
+
+ data = {"query": {"bool": {"must": must_list}}}
+
+ all_playlists = IndexPaginate("ta_playlist", data).get_results()
+ return all_playlists
+
+
def channel_overwrites(channel_id, overwrites):
"""collection to overwrite settings per channel"""
channel = YoutubeChannel(channel_id)
diff --git a/backend/channel/src/remote_query.py b/backend/channel/src/remote_query.py
new file mode 100644
index 00000000..a38d1cce
--- /dev/null
+++ b/backend/channel/src/remote_query.py
@@ -0,0 +1,124 @@
+"""build queries for video extraction from channel subscriptions"""
+
+from download.src.yt_dlp_base import YtWrap
+from video.src.constants import VideoTypeEnum
+
+
+class VideoQueryBuilder:
+ """Build queries for yt-dlp."""
+
+ def __init__(self, config: dict, channel_overwrites: dict | None = None):
+ self.config = config
+ self.channel_overwrites = channel_overwrites or {}
+
+ def build_queries(
+ self, video_type: VideoTypeEnum | None, limit: bool = True
+ ) -> list[tuple[VideoTypeEnum, int | None]]:
+ """Build queries for all or specific video type."""
+ query_methods = {
+ VideoTypeEnum.VIDEOS: self.videos_query,
+ VideoTypeEnum.STREAMS: self.streams_query,
+ VideoTypeEnum.SHORTS: self.shorts_query,
+ }
+
+ if video_type:
+ # build query for specific type
+ query_method = query_methods.get(video_type)
+ if query_method:
+ query = query_method(limit)
+ if query[1] != 0:
+ return [query]
+ return []
+
+ # Build and return queries for all video types
+ queries = []
+ for build_query in query_methods.values():
+ query = build_query(limit)
+ if query[1] != 0:
+ queries.append(query)
+
+ return queries
+
+ def videos_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
+ """Build query for videos."""
+ return self._build_generic_query(
+ video_type=VideoTypeEnum.VIDEOS,
+ overwrite_key="subscriptions_channel_size",
+ config_key="channel_size",
+ limit=limit,
+ )
+
+ def streams_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
+ """Build query for streams."""
+ return self._build_generic_query(
+ video_type=VideoTypeEnum.STREAMS,
+ overwrite_key="subscriptions_live_channel_size",
+ config_key="live_channel_size",
+ limit=limit,
+ )
+
+ def shorts_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
+ """Build query for shorts."""
+ return self._build_generic_query(
+ video_type=VideoTypeEnum.SHORTS,
+ overwrite_key="subscriptions_shorts_channel_size",
+ config_key="shorts_channel_size",
+ limit=limit,
+ )
+
+ def _build_generic_query(
+ self,
+ video_type: VideoTypeEnum,
+ overwrite_key: str,
+ config_key: str,
+ limit: bool,
+ ) -> tuple[VideoTypeEnum, int | None]:
+ """Generic query for video page scraping."""
+ if not limit:
+ return (video_type, None)
+
+ if (
+ overwrite_key in self.channel_overwrites
+ and self.channel_overwrites[overwrite_key] is not None
+ ):
+ overwrite = self.channel_overwrites[overwrite_key]
+ return (video_type, overwrite)
+
+ if overwrite := self.config["subscriptions"].get(config_key):
+ return (video_type, overwrite)
+
+ return (video_type, 0)
+
+
+def get_last_channel_videos(
+ channel_id,
+ config,
+ limit=None,
+ query_filter=None,
+ channel_overwrites=None,
+):
+ """get a list of last videos from channel"""
+ query_handler = VideoQueryBuilder(config, channel_overwrites)
+ queries = query_handler.build_queries(query_filter)
+ last_videos = []
+
+ for vid_type_enum, limit_amount in queries:
+ obs = {
+ "skip_download": True,
+ "extract_flat": True,
+ }
+ vid_type = vid_type_enum.value
+
+ if limit is not None:
+ obs.update({"playlist_items": f":{limit_amount}:1"})
+
+ url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
+ channel_query, _ = YtWrap(obs, config).extract(url)
+ if not channel_query:
+ continue
+
+ for entry in channel_query["entries"]:
+ entry["vid_type"] = vid_type
+ last_videos.append(entry)
+
+ return last_videos
diff --git a/backend/channel/views.py b/backend/channel/views.py
index f915ecec..1f0daf57 100644
--- a/backend/channel/views.py
+++ b/backend/channel/views.py
@@ -14,7 +14,6 @@ from channel.src.nav import ChannelNav
from common.serializers import ErrorResponseSerializer
from common.src.urlparser import Parser
from common.views_base import AdminWriteOnly, ApiBaseView
-from download.src.subscriptions import ChannelSubscription
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
@@ -89,9 +88,7 @@ class ChannelApiListView(ApiBaseView):
def _unsubscribe(channel_id: str):
"""unsubscribe"""
print(f"[{channel_id}] unsubscribe from channel")
- ChannelSubscription().change_subscribe(
- channel_id, channel_subscribed=False
- )
+ YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=False)
class ChannelApiView(ApiBaseView):
@@ -146,7 +143,9 @@ class ChannelApiView(ApiBaseView):
subscribed = validated_data.get("channel_subscribed")
if subscribed is not None:
- ChannelSubscription().change_subscribe(channel_id, subscribed)
+ YoutubeChannel(channel_id).change_subscribe(
+ new_subscribe_state=subscribed
+ )
overwrites = validated_data.get("channel_overwrites")
if overwrites:
diff --git a/backend/common/src/helper.py b/backend/common/src/helper.py
index bfb5e474..cf647a4e 100644
--- a/backend/common/src/helper.py
+++ b/backend/common/src/helper.py
@@ -292,6 +292,50 @@ def get_channel_overwrites() -> dict[str, dict[str, Any]]:
return overwrites
+def get_channels(
+ subscribed_only: bool, source: list[str] | None = None
+) -> list[dict]:
+ """get a list of all channels"""
+ data = {
+ "sort": [{"channel_name.keyword": {"order": "asc"}}],
+ }
+ if subscribed_only:
+ query = {"term": {"channel_subscribed": {"value": True}}}
+ else:
+ query = {"match_all": {}}
+
+ data["query"] = query # type: ignore
+
+ if source:
+ data["_source"] = source # type: ignore
+
+ all_channels = IndexPaginate("ta_channel", data).get_results()
+
+ return all_channels
+
+
+def get_playlists(
+ subscribed_only: bool, source: list[str] | None = None
+) -> list[dict]:
+ """get list of playlists"""
+
+ data = {
+ "sort": [{"playlist_channel.keyword": {"order": "desc"}}],
+ }
+
+ must_list = [{"term": {"playlist_active": {"value": True}}}]
+ if subscribed_only:
+ must_list.append({"term": {"playlist_subscribed": {"value": True}}})
+
+ data = {"query": {"bool": {"must": must_list}}} # type: ignore
+ if source:
+ data["_source"] = source # type: ignore
+
+ all_playlists = IndexPaginate("ta_playlist", data).get_results()
+
+ return all_playlists
+
+
def calc_is_watched(duration: float, position: float) -> bool:
"""considered watched based on duration position"""
diff --git a/backend/common/src/urlparser.py b/backend/common/src/urlparser.py
index 3c75c82f..c3b80690 100644
--- a/backend/common/src/urlparser.py
+++ b/backend/common/src/urlparser.py
@@ -4,6 +4,7 @@ Functionality:
- identify vid_type if possible
"""
+from typing import Literal, NotRequired, TypedDict
from urllib.parse import parse_qs, urlparse
from common.src.ta_redis import RedisArchivist
@@ -11,19 +12,28 @@ from download.src.yt_dlp_base import YtWrap
from video.src.constants import VideoTypeEnum
+class ParsedURLType(TypedDict):
+ """represents single parsed url"""
+
+ type: Literal["video", "channel", "playlist"]
+ url: str
+ vid_type: VideoTypeEnum
+ limit: NotRequired[int | None]
+
+
class Parser:
"""
take a multi line string and detect valid youtube ids
channel handle lookup is cached, can be disabled for unittests
"""
- def __init__(self, url_str, use_cache=True):
+ def __init__(self, url_str: str, use_cache: bool = True):
self.url_list = [i.strip() for i in url_str.split()]
self.use_cache = use_cache
- def parse(self):
+ def parse(self) -> list[ParsedURLType]:
"""parse the list"""
- ids = []
+ ids: list[ParsedURLType] = []
for url in self.url_list:
parsed = urlparse(url)
if parsed.netloc:
diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py
index 1451e307..a35b985b 100644
--- a/backend/download/src/queue.py
+++ b/backend/download/src/queue.py
@@ -9,10 +9,16 @@ from datetime import datetime
from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
+from channel.src.remote_query import get_last_channel_videos
from common.src.es_connect import ElasticWrap, IndexPaginate
-from common.src.helper import get_duration_str, is_shorts, rand_sleep
+from common.src.helper import (
+ get_channels,
+ get_duration_str,
+ is_shorts,
+ rand_sleep,
+)
+from common.src.urlparser import ParsedURLType
from download.src.queue_interact import PendingInteract
-from download.src.subscriptions import ChannelSubscription
from download.src.thumbnails import ThumbManager
from playlist.src.index import YoutubePlaylist
from video.src.constants import VideoTypeEnum
@@ -64,11 +70,7 @@ class PendingIndex:
"""get a list of all channels indexed"""
self.all_channels = []
self.channel_overwrites = {}
- data = {
- "query": {"match_all": {}},
- "sort": [{"channel_id": {"order": "asc"}}],
- }
- channels = IndexPaginate("ta_channel", data).get_results()
+ channels = get_channels(subscribed_only=False)
for channel in channels:
channel_id = channel["channel_id"]
@@ -102,7 +104,11 @@ class PendingList(PendingIndex):
}
def __init__(
- self, youtube_ids=False, task=False, auto_start=False, flat=False
+ self,
+ youtube_ids: list[ParsedURLType],
+ task=None,
+ auto_start=False,
+ flat=False,
):
super().__init__()
self.config = AppConfig().config
@@ -111,7 +117,7 @@ class PendingList(PendingIndex):
self.auto_start = auto_start
self.flat = flat
self.to_skip = False
- self.missing_videos = []
+ self.missing_videos: list[dict] = []
def parse_url_list(self):
"""extract youtube ids from list"""
@@ -139,9 +145,9 @@ class PendingList(PendingIndex):
entry["url"], entry["vid_type"], idx_url, total_url
)
elif entry["type"] == "channel":
- self._parse_channel(entry["url"], entry["vid_type"])
+ self._parse_channel(entry)
elif entry["type"] == "playlist":
- self._parse_playlist(entry["url"])
+ self._parse_playlist(entry["url"], entry.get("limit"))
else:
raise ValueError(f"invalid url_type: {entry}")
@@ -166,11 +172,20 @@ class PendingList(PendingIndex):
if to_add:
self.missing_videos.append(to_add)
- def _parse_channel(self, url, vid_type):
+ def _parse_channel(self, entry):
"""parse channel"""
- query_filter = getattr(VideoTypeEnum, vid_type.upper())
- video_results = ChannelSubscription().get_last_youtube_videos(
- url, limit=False, query_filter=query_filter
+ url = entry["url"]
+ vid_type = entry["vid_type"]
+ if isinstance(vid_type, str):
+ # lookup enum
+ vid_type = getattr(VideoTypeEnum, vid_type.upper())
+
+ limit = entry.get("limit")
+ video_results = get_last_channel_videos(
+ channel_id=url,
+ config=self.config,
+ limit=limit,
+ query_filter=vid_type,
)
if not video_results:
print(f"{url}: no videos to add from channel, skipping")
@@ -184,45 +199,54 @@ class PendingList(PendingIndex):
total = len(video_results)
for idx, video_data in enumerate(video_results):
- video_id = video_data["id"]
- if video_id in self.to_skip:
- continue
+ to_add = self.__parse_channel_video(
+ video_data, vid_type, idx, total, channel_handler
+ )
+ if to_add:
+ self.missing_videos.append(to_add)
if self.task and self.task.is_stopped():
break
- if self.flat:
- if not video_data.get("channel"):
- channel_name = channel_handler.json_data["channel_name"]
- video_data["channel"] = channel_name
+ def __parse_channel_video(
+ self, video_data, vid_type, idx, total, channel_handler
+ ) -> dict | None:
+ """parse video of channel"""
+ video_id = video_data["id"]
+ if video_id in self.to_skip:
+ return None
- if not video_data.get("channel_id"):
- channel_id = channel_handler.json_data["channel_id"]
- video_data["channel_id"] = channel_id
+ if self.flat:
+ if not video_data.get("channel"):
+ channel_name = channel_handler.json_data["channel_name"]
+ video_data["channel"] = channel_name
- to_add = self._parse_entry(
- youtube_id=video_id,
- video_data=video_data,
- url_type="channel",
- idx=idx,
- total=total,
- )
- else:
- to_add = self._parse_video(
- video_id,
- vid_type,
- url_type="channel",
- idx=idx,
- total=total,
- )
+ if not video_data.get("channel_id"):
+ channel_id = channel_handler.json_data["channel_id"]
+ video_data["channel_id"] = channel_id
- if to_add:
- self.missing_videos.append(to_add)
+ to_add = self._parse_entry(
+ youtube_id=video_id,
+ video_data=video_data,
+ url_type="channel",
+ idx=idx,
+ total=total,
+ )
+ else:
+ to_add = self._parse_video(
+ video_id,
+ vid_type,
+ url_type="channel",
+ idx=idx,
+ total=total,
+ )
- def _parse_playlist(self, url):
+ return to_add
+
+ def _parse_playlist(self, url: str, limit: int | None):
"""fast parse playlist"""
playlist = YoutubePlaylist(url)
- playlist.update_playlist()
+ playlist.update_playlist(limit=limit)
if not playlist.youtube_meta:
print(f"{url}: playlist metadata extraction failed, skipping")
return
diff --git a/backend/download/src/subscriptions.py b/backend/download/src/subscriptions.py
index 65080dd7..3c8df7dc 100644
--- a/backend/download/src/subscriptions.py
+++ b/backend/download/src/subscriptions.py
@@ -6,323 +6,97 @@ Functionality:
from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
-from common.src.es_connect import IndexPaginate
-from common.src.helper import is_missing, rand_sleep
-from common.src.urlparser import Parser
-from download.src.thumbnails import ThumbManager
-from download.src.yt_dlp_base import YtWrap
+from channel.src.remote_query import VideoQueryBuilder
+from common.src.helper import get_channels, get_playlists
+from common.src.urlparser import ParsedURLType, Parser
+from download.src.queue import PendingList
from playlist.src.index import YoutubePlaylist
from video.src.constants import VideoTypeEnum
from video.src.index import YoutubeVideo
class ChannelSubscription:
- """manage the list of channels subscribed"""
+ """scan subscribed channels to find missing videos to add to pending"""
- def __init__(self, task=False):
+ def __init__(self, task=None):
self.config = AppConfig().config
self.task = task
- @staticmethod
- def get_channels(subscribed_only=True):
- """get a list of all channels subscribed to"""
- data = {
- "sort": [{"channel_name.keyword": {"order": "asc"}}],
- }
- if subscribed_only:
- data["query"] = {"term": {"channel_subscribed": {"value": True}}}
- else:
- data["query"] = {"match_all": {}}
-
- all_channels = IndexPaginate("ta_channel", data).get_results()
-
- return all_channels
-
- def get_last_youtube_videos(
- self,
- channel_id,
- limit=True,
- query_filter=None,
- channel_overwrites=None,
- ):
- """get a list of last videos from channel"""
- query_handler = VideoQueryBuilder(self.config, channel_overwrites)
- queries = query_handler.build_queries(query_filter)
- last_videos = []
-
- for vid_type_enum, limit_amount in queries:
- obs = {
- "skip_download": True,
- "extract_flat": True,
- }
- vid_type = vid_type_enum.value
-
- if limit:
- obs["playlistend"] = limit_amount
-
- url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
- channel_query, _ = YtWrap(obs, self.config).extract(url)
- if not channel_query:
- continue
-
- for entry in channel_query["entries"]:
- entry["vid_type"] = vid_type
- last_videos.append(entry)
-
- return last_videos
-
- def find_missing(self):
- """add missing videos from subscribed channels to pending"""
- all_channels = self.get_channels()
+ def find_missing(self) -> int:
+ """find missing videos from channel subscriptions"""
+ all_channels = get_channels(
+ subscribed_only=True, source=["channel_id", "channel_overwrites"]
+ )
if not all_channels:
- return False
+ return 0
- missing_videos = []
+ all_channel_urls: list[ParsedURLType] = []
- total = len(all_channels)
- for idx, channel in enumerate(all_channels):
- channel_id = channel["channel_id"]
- print(f"{channel_id}: find missing videos.")
- last_videos = self.get_last_youtube_videos(
- channel_id,
- channel_overwrites=channel.get("channel_overwrites"),
- )
+ for channel in all_channels:
+ queries = VideoQueryBuilder(
+ config=self.config,
+ channel_overwrites=channel.get("channel_overwrites", {}),
+ ).build_queries(video_type=None)
- if last_videos:
- ids_to_add = is_missing([i[0] for i in last_videos])
- for video_entry in last_videos:
- video_id = video_entry["id"]
- vid_type = video_entry["vid_type"]
- if video_id in ids_to_add:
- missing_videos.append((video_id, vid_type))
+ for query in queries:
+ all_channel_urls.append(
+ ParsedURLType(
+ type="channel",
+ url=channel["channel_id"],
+ vid_type=query[0],
+ limit=query[1],
+ )
+ )
- if not self.task:
- continue
-
- if self.task.is_stopped():
- self.task.send_progress(["Received Stop signal."])
- break
-
- self.task.send_progress(
- message_lines=[f"Scanning Channel {idx + 1}/{total}"],
- progress=(idx + 1) / total,
- )
- rand_sleep(self.config)
-
- return missing_videos
-
- @staticmethod
- def change_subscribe(channel_id, channel_subscribed):
- """subscribe or unsubscribe from channel and update"""
- channel = YoutubeChannel(channel_id)
- channel.build_json()
- channel.json_data["channel_subscribed"] = channel_subscribed
- channel.upload_to_es()
- channel.sync_to_videos()
-
- return channel.json_data
-
-
-class VideoQueryBuilder:
- """Build queries for yt-dlp."""
-
- def __init__(self, config: dict, channel_overwrites: dict | None = None):
- self.config = config
- self.channel_overwrites = channel_overwrites or {}
-
- def build_queries(
- self, video_type: VideoTypeEnum | None, limit: bool = True
- ) -> list[tuple[VideoTypeEnum, int | None]]:
- """Build queries for all or specific video type."""
- query_methods = {
- VideoTypeEnum.VIDEOS: self.videos_query,
- VideoTypeEnum.STREAMS: self.streams_query,
- VideoTypeEnum.SHORTS: self.shorts_query,
- }
-
- if video_type:
- # build query for specific type
- query_method = query_methods.get(video_type)
- if query_method:
- query = query_method(limit)
- if query[1] != 0:
- return [query]
- return []
-
- # Build and return queries for all video types
- queries = []
- for build_query in query_methods.values():
- query = build_query(limit)
- if query[1] != 0:
- queries.append(query)
-
- return queries
-
- def videos_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
- """Build query for videos."""
- return self._build_generic_query(
- video_type=VideoTypeEnum.VIDEOS,
- overwrite_key="subscriptions_channel_size",
- config_key="channel_size",
- limit=limit,
+ pending_handler = PendingList(
+ youtube_ids=all_channel_urls,
+ task=self.task,
+ auto_start=self.config["subscriptions"].get("auto_start", False),
+ flat=self.config["subscriptions"].get("extract_flat", False),
)
+ pending_handler.parse_url_list()
+ added = pending_handler.add_to_pending()
- def streams_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
- """Build query for streams."""
- return self._build_generic_query(
- video_type=VideoTypeEnum.STREAMS,
- overwrite_key="subscriptions_live_channel_size",
- config_key="live_channel_size",
- limit=limit,
- )
-
- def shorts_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
- """Build query for shorts."""
- return self._build_generic_query(
- video_type=VideoTypeEnum.SHORTS,
- overwrite_key="subscriptions_shorts_channel_size",
- config_key="shorts_channel_size",
- limit=limit,
- )
-
- def _build_generic_query(
- self,
- video_type: VideoTypeEnum,
- overwrite_key: str,
- config_key: str,
- limit: bool,
- ) -> tuple[VideoTypeEnum, int | None]:
- """Generic query for video page scraping."""
- if not limit:
- return (video_type, None)
-
- if (
- overwrite_key in self.channel_overwrites
- and self.channel_overwrites[overwrite_key] is not None
- ):
- overwrite = self.channel_overwrites[overwrite_key]
- return (video_type, overwrite)
-
- if overwrite := self.config["subscriptions"].get(config_key):
- return (video_type, overwrite)
-
- return (video_type, 0)
+ return added
class PlaylistSubscription:
- """manage the playlist download functionality"""
+ """scan subscribed playlists for videos to add to pending"""
- def __init__(self, task=False):
+ def __init__(self, task=None):
self.config = AppConfig().config
self.task = task
- @staticmethod
- def get_playlists(subscribed_only=True):
- """get a list of all active playlists"""
- data = {
- "sort": [{"playlist_channel.keyword": {"order": "desc"}}],
- }
- data["query"] = {
- "bool": {"must": [{"term": {"playlist_active": {"value": True}}}]}
- }
- if subscribed_only:
- data["query"]["bool"]["must"].append(
- {"term": {"playlist_subscribed": {"value": True}}}
- )
-
- all_playlists = IndexPaginate("ta_playlist", data).get_results()
-
- return all_playlists
-
- def process_url_str(self, new_playlists, subscribed=True):
- """process playlist subscribe form url_str"""
- for idx, playlist in enumerate(new_playlists):
- playlist_id = playlist["url"]
- if not playlist["type"] == "playlist":
- print(f"{playlist_id} not a playlist, skipping...")
- continue
-
- playlist_h = YoutubePlaylist(playlist_id)
- playlist_h.build_json()
- if not playlist_h.json_data:
- message = f"{playlist_h.youtube_id}: failed to extract data"
- print(message)
- raise ValueError(message)
-
- playlist_h.json_data["playlist_subscribed"] = subscribed
- playlist_h.upload_to_es()
- playlist_h.add_vids_to_playlist()
- self.channel_validate(playlist_h.json_data["playlist_channel_id"])
-
- url = playlist_h.json_data["playlist_thumbnail"]
- thumb = ThumbManager(playlist_id, item_type="playlist")
- thumb.download_playlist_thumb(url)
-
- if self.task:
- self.task.send_progress(
- message_lines=[
- f"Processing {idx + 1} of {len(new_playlists)}"
- ],
- progress=(idx + 1) / len(new_playlists),
- )
-
- @staticmethod
- def channel_validate(channel_id):
- """make sure channel of playlist is there"""
- channel = YoutubeChannel(channel_id)
- channel.build_json(upload=True)
-
- @staticmethod
- def change_subscribe(playlist_id, subscribe_status):
- """change the subscribe status of a playlist"""
- playlist = YoutubePlaylist(playlist_id)
- playlist.build_json()
- playlist.json_data["playlist_subscribed"] = subscribe_status
- playlist.upload_to_es()
- return playlist.json_data
-
- def find_missing(self):
- """find videos in subscribed playlists not downloaded yet"""
- all_playlists = [i["playlist_id"] for i in self.get_playlists()]
+ def find_missing(self) -> int:
+ """find missing"""
+ all_playlists = get_playlists(
+ subscribed_only=True, source=["playlist_id"]
+ )
if not all_playlists:
- return False
+ return 0
- missing_videos = []
- total = len(all_playlists)
- for idx, playlist_id in enumerate(all_playlists):
- playlist = YoutubePlaylist(playlist_id)
- is_active = playlist.update_playlist()
- if not is_active:
- playlist.deactivate()
- continue
-
- playlist_entries = playlist.json_data["playlist_entries"]
- size_limit = self.config["subscriptions"]["channel_size"]
- if size_limit:
- del playlist_entries[size_limit:]
-
- to_check = [
- i["youtube_id"]
- for i in playlist_entries
- if i["downloaded"] is False
- ]
- needs_downloading = is_missing(to_check)
- missing_videos.extend(needs_downloading)
-
- if not self.task:
- continue
-
- if self.task.is_stopped():
- self.task.send_progress(["Received Stop signal."])
- break
-
- self.task.send_progress(
- message_lines=[f"Scanning Playlists {idx + 1}/{total}"],
- progress=(idx + 1) / total,
+ size_limit = self.config["subscriptions"]["channel_size"]
+ all_playlist_urls: list[ParsedURLType] = []
+ for playlist in all_playlists:
+ all_playlist_urls.append(
+ ParsedURLType(
+ type="playlist",
+ url=playlist["playlist_id"],
+ vid_type=VideoTypeEnum.UNKNOWN,
+ limit=size_limit,
+ )
)
- rand_sleep(self.config)
- return missing_videos
+ pending_handler = PendingList(
+ youtube_ids=all_playlist_urls,
+ task=self.task,
+ auto_start=self.config["subscriptions"].get("auto_start", False),
+ flat=self.config["subscriptions"].get("extract_flat", False),
+ )
+ pending_handler.parse_url_list()
+ added = pending_handler.add_to_pending()
+
+ return added
class SubscriptionScanner:
@@ -338,40 +112,12 @@ class SubscriptionScanner:
if self.task:
self.task.send_progress(["Rescanning channels and playlists."])
- self.missing_videos = []
- self.scan_channels()
+ added = 0
+ added += ChannelSubscription(task=self.task).find_missing()
if self.task and not self.task.is_stopped():
- self.scan_playlists()
+ added += PlaylistSubscription(task=self.task).find_missing()
- return self.missing_videos
-
- def scan_channels(self):
- """get missing from channels"""
- channel_handler = ChannelSubscription(task=self.task)
- missing = channel_handler.find_missing()
- if not missing:
- return
-
- for vid_id, vid_type in missing:
- self.missing_videos.append(
- {"type": "video", "vid_type": vid_type, "url": vid_id}
- )
-
- def scan_playlists(self):
- """get missing from playlists"""
- playlist_handler = PlaylistSubscription(task=self.task)
- missing = playlist_handler.find_missing()
- if not missing:
- return
-
- for i in missing:
- self.missing_videos.append(
- {
- "type": "video",
- "vid_type": VideoTypeEnum.VIDEOS.value,
- "url": i,
- }
- )
+ return added
class SubscriptionHandler:
@@ -403,7 +149,8 @@ class SubscriptionHandler:
f"expected {expected_type} url but got {item.get('type')}"
)
- PlaylistSubscription().process_url_str([item])
+ playlist = YoutubePlaylist(item["url"])
+ playlist.change_subscribe(new_subscribe_state=True)
return
if item["type"] == "video":
@@ -426,9 +173,7 @@ class SubscriptionHandler:
def _subscribe(self, channel_id):
"""subscribe to channel"""
- _ = ChannelSubscription().change_subscribe(
- channel_id, channel_subscribed=True
- )
+ YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=True)
def _notify(self, idx, item, total):
"""send notification message to redis"""
diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py
index 6348e3ab..f74af4eb 100644
--- a/backend/download/src/yt_dlp_handler.py
+++ b/backend/download/src/yt_dlp_handler.py
@@ -16,12 +16,12 @@ from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import (
get_channel_overwrites,
+ get_playlists,
ignore_filelist,
rand_sleep,
)
from common.src.ta_redis import RedisQueue
from download.src.queue import PendingList
-from download.src.subscriptions import PlaylistSubscription
from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist
from video.src.comments import CommentList
@@ -403,8 +403,8 @@ class DownloadPostProcess(DownloaderBase):
def _add_playlist_sub(self):
"""add subscribed playlists to refresh"""
- subs = PlaylistSubscription().get_playlists()
- to_add = [i["playlist_id"] for i in subs]
+ playlists = get_playlists(subscribed_only=True, source=["playlist_id"])
+ to_add = [i["playlist_id"] for i in playlists]
RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add)
def _add_channel_playlists(self):
diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py
index d0d938d3..165860dc 100644
--- a/backend/playlist/src/index.py
+++ b/backend/playlist/src/index.py
@@ -30,7 +30,7 @@ class YoutubePlaylist(YouTubeItem):
self.all_members = False
self.nav = False
- def build_json(self, scrape=False):
+ def build_json(self, scrape=False, limit: int | None = None):
"""collection to create json_data"""
self.get_from_es()
if self.json_data:
@@ -40,7 +40,9 @@ class YoutubePlaylist(YouTubeItem):
subscribed = False
playlist_sort_order = "top"
- playlist_items = "::1" if playlist_sort_order == "top" else "::-1"
+ limit_str = limit if limit is not None else ""
+ sort_order = 1 if playlist_sort_order == "top" else -1
+ playlist_items = f":{limit_str}:{sort_order}"
if scrape or not self.json_data:
self.get_from_youtube(
@@ -137,6 +139,13 @@ class YoutubePlaylist(YouTubeItem):
url = self.json_data["playlist_thumbnail"]
ThumbManager(self.youtube_id, item_type="playlist").download(url)
+ def change_subscribe(self, new_subscribe_state: bool):
+ """change subscribe status"""
+ self.build_json()
+ self.json_data["playlist_subscribed"] = new_subscribe_state
+ self.upload_to_es()
+ return self.json_data
+
def add_vids_to_playlist(self):
"""sync the playlist id to videos"""
script = (
@@ -189,9 +198,9 @@ class YoutubePlaylist(YouTubeItem):
if status_code == 200:
print(f"{self.youtube_id}: removed {video_id} from playlist")
- def update_playlist(self, skip_on_empty=False):
+ def update_playlist(self, skip_on_empty=False, limit: int | None = None):
"""update metadata for playlist with data from YouTube"""
- self.build_json(scrape=True)
+ self.build_json(scrape=True, limit=limit)
if not self.json_data:
# return false to deactivate
return False
diff --git a/backend/playlist/views.py b/backend/playlist/views.py
index 1ce9261a..bb756081 100644
--- a/backend/playlist/views.py
+++ b/backend/playlist/views.py
@@ -7,7 +7,6 @@ from common.serializers import (
ErrorResponseSerializer,
)
from common.views_base import AdminWriteOnly, ApiBaseView
-from download.src.subscriptions import PlaylistSubscription
from drf_spectacular.utils import OpenApiResponse, extend_schema
from playlist.serializers import (
PlaylistBulkAddSerializer,
@@ -245,8 +244,9 @@ class PlaylistApiView(ApiBaseView):
json_data = None
if subscribed is not None:
- playlist_sub = PlaylistSubscription()
- json_data = playlist_sub.change_subscribe(playlist_id, subscribed)
+ json_data = YoutubePlaylist(playlist_id).change_subscribe(
+ new_subscribe_state=subscribed
+ )
if sort_order:
json_data = YoutubePlaylist(playlist_id).change_sort_order(
diff --git a/backend/task/tasks.py b/backend/task/tasks.py
index cb76b8e8..63a843f0 100644
--- a/backend/task/tasks.py
+++ b/backend/task/tasks.py
@@ -103,13 +103,13 @@ def update_subscribed(self):
manager.init(self)
handler = SubscriptionScanner(task=self)
- missing_videos = handler.scan()
+ added = handler.scan()
auto_start = handler.auto_start
- if missing_videos:
- print(missing_videos)
- extrac_dl.delay(missing_videos, auto_start=auto_start)
- message = f"Found {len(missing_videos)} videos to add to the queue."
- return message
+ if added:
+ if auto_start:
+ download_pending.delay(auto_only=True)
+
+ return f"Found {added} videos to add to the queue."
return None
diff --git a/frontend/src/api/loader/loadAppsettingsConfig.ts b/frontend/src/api/loader/loadAppsettingsConfig.ts
index 44f22e8a..3a6489fd 100644
--- a/frontend/src/api/loader/loadAppsettingsConfig.ts
+++ b/frontend/src/api/loader/loadAppsettingsConfig.ts
@@ -6,6 +6,7 @@ export type AppSettingsConfigType = {
live_channel_size: number | null;
shorts_channel_size: number | null;
auto_start: boolean;
+ extract_flat: boolean;
};
downloads: {
limit_speed: number | null;
diff --git a/frontend/src/pages/SettingsApplication.tsx b/frontend/src/pages/SettingsApplication.tsx
index 594ef29c..d4a45f7f 100644
--- a/frontend/src/pages/SettingsApplication.tsx
+++ b/frontend/src/pages/SettingsApplication.tsx
@@ -42,6 +42,7 @@ const SettingsApplication = () => {
const [livePageSize, setLivePageSize] = useState(null);
const [shortPageSize, setShortPageSize] = useState(null);
const [isAutostart, setIsAutostart] = useState(false);
+ const [isExtractFlat, setIsExtractFlat] = useState(false);
// Downloads
const [currentDownloadSpeed, setCurrentDownloadSpeed] = useState(null);
@@ -98,6 +99,7 @@ const SettingsApplication = () => {
setLivePageSize(appSettingsConfigData?.subscriptions.live_channel_size || null);
setShortPageSize(appSettingsConfigData?.subscriptions.shorts_channel_size || null);
setIsAutostart(appSettingsConfigData?.subscriptions.auto_start || false);
+ setIsExtractFlat(appSettingsConfigData?.subscriptions.extract_flat || false);
// Downloads
setCurrentDownloadSpeed(appSettingsConfigData?.downloads.limit_speed || null);
@@ -212,6 +214,10 @@ const SettingsApplication = () => {
Autostart automatically starts downloading videos from subscriptions with
priority.
+
+ Fast add extracts and adds videos in bulk. That is much faster but is not able
+ to extract as much metadata during adding to the queue.
+
)}
@@ -264,6 +270,16 @@ const SettingsApplication = () => {
updateCallback={handleUpdateConfig}
/>
+
Downloads
diff --git a/frontend/src/stores/AppSettingsStore.ts b/frontend/src/stores/AppSettingsStore.ts
index 2166e7e5..d3410431 100644
--- a/frontend/src/stores/AppSettingsStore.ts
+++ b/frontend/src/stores/AppSettingsStore.ts
@@ -13,6 +13,7 @@ export const useAppSettingsStore = create
(set => ({
live_channel_size: null,
shorts_channel_size: null,
auto_start: false,
+ extract_flat: false,
},
downloads: {
limit_speed: null,