POT handling changes, again, #build

Changed:
- switch to forked pot provider plugin repo
- avoid layoutshift with fixed thumb placeholder
- add multiselect bulk reindex
- move state update out of effects
- fix google cast loading
This commit is contained in:
Simon 2026-05-16 14:20:21 +07:00
commit 92af2f586f
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
30 changed files with 1124 additions and 1314 deletions

View File

@ -21,7 +21,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: '23' node-version: '24'
- name: Install frontend dependencies - name: Install frontend dependencies
run: | run: |

View File

@ -4,14 +4,14 @@ repos:
hooks: hooks:
- id: end-of-file-fixer - id: end-of-file-fixer
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 25.9.0 rev: 26.3.1
hooks: hooks:
- id: black - id: black
alias: python alias: python
files: ^backend/ files: ^backend/
args: ["--line-length=79"] args: ["--line-length=79"]
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort
rev: 6.0.1 rev: 8.0.1
hooks: hooks:
- id: isort - id: isort
name: isort (python) name: isort (python)
@ -24,14 +24,14 @@ repos:
- id: flake8 - id: flake8
alias: python alias: python
files: ^backend/ files: ^backend/
args: ["--max-complexity=10", "--max-line-length=79"] args: ["--jobs=1", "--max-complexity=10", "--max-line-length=79"]
- repo: https://github.com/codespell-project/codespell - repo: https://github.com/codespell-project/codespell
rev: v2.4.1 rev: v2.4.2
hooks: hooks:
- id: codespell - id: codespell
exclude: ^frontend/package-lock.json exclude: ^frontend/package-lock.json
- repo: https://github.com/pre-commit/mirrors-eslint - repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.36.0 rev: v10.2.0
hooks: hooks:
- id: eslint - id: eslint
name: eslint name: eslint

View File

@ -126,9 +126,9 @@ Some of you might have created useful scripts or API integrations around this pr
--- ---
## Improve to the Documentation ## Improving the Documentation
The documentation available at [docs.tubearchivist.com](https://docs.tubearchivist.com/) and is build from a separate repo [tubearchivist/docs](https://github.com/tubearchivist/docs). The Readme there has additional instructions on how to make changes. The documentation is available at [docs.tubearchivist.com](https://docs.tubearchivist.com/), and is built from a separate repo: [tubearchivist/docs](https://github.com/tubearchivist/docs). The Readme there has additional instructions on how to make changes.
--- ---

View File

@ -1,11 +1,11 @@
# multi stage to build tube archivist # multi stage to build tube archivist
# build python wheel, download and extract ffmpeg, copy into final image # build python wheel, download and extract ffmpeg, copy into final image
FROM node:22.13.0-alpine AS npm-builder FROM node:24.14.1-alpine AS npm-builder
COPY frontend/package.json frontend/package-lock.json / COPY frontend/package.json frontend/package-lock.json /
RUN npm i RUN npm i
FROM node:22.13.0-alpine AS node-builder FROM node:24.14.1-alpine AS node-builder
# RUN npm config set registry https://registry.npmjs.org/ # RUN npm config set registry https://registry.npmjs.org/
@ -25,9 +25,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# install requirements # install requirements
COPY ./backend/requirements.txt /requirements.txt COPY ./backend/requirements.txt /requirements.txt
COPY ./backend/requirements.plugins.txt /requirements.plugins.txt RUN pip install --user -r requirements.txt
RUN pip install --user -r /requirements.txt \
&& python -m pip install --target /opt/yt_plugins/bgutil -r /requirements.plugins.txt
# build ffmpeg # build ffmpeg
FROM python:3.13.11-slim-trixie AS ffmpeg-builder FROM python:3.13.11-slim-trixie AS ffmpeg-builder
@ -48,7 +46,6 @@ COPY --from=denoland/deno:bin /deno /usr/local/bin/deno
# copy build requirements # copy build requirements
COPY --from=builder /root/.local /root/.local COPY --from=builder /root/.local /root/.local
COPY --from=builder /opt/yt_plugins /opt/yt_plugins
ENV PATH=/root/.local/bin:$PATH ENV PATH=/root/.local/bin:$PATH
# copy ffmpeg # copy ffmpeg

View File

@ -54,7 +54,7 @@ Take a look at the example [docker-compose.yml](https://github.com/tubearchivist
All environment variables are explained in detail in the docs [here](https://docs.tubearchivist.com/installation/env-vars/). All environment variables are explained in detail in the docs [here](https://docs.tubearchivist.com/installation/env-vars/).
Both `TA_PASSWORD` and `ELASTIC_PASSWORD` can be suffixed with `_FILE` to allow passing in passwords as secretes. `_FILE` is a convention used by some images including [ElasticSearch](https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-configure) Both `TA_PASSWORD` and `ELASTIC_PASSWORD` can be suffixed with `_FILE` to allow passing in passwords as secrets. `_FILE` is a convention used by some images including [ElasticSearch](https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-configure)
### TubeArchivist ### TubeArchivist

View File

@ -278,6 +278,12 @@ class ChannelApiSearchView(ApiBaseView):
return Response(error.data, status=400) return Response(error.data, status=400)
self.get_document(parsed["url"]) self.get_document(parsed["url"])
if not self.response:
error = ErrorResponseSerializer(
{"error": f"channel not found: {query}"}
)
return Response(error.data, status=404)
serializer = ChannelSerializer(self.response) serializer = ChannelSerializer(self.response)
return Response(serializer.data, status=self.status_code) return Response(serializer.data, status=self.status_code)

View File

@ -93,8 +93,7 @@ class EnvironmentSettings:
def print_generic(self): def print_generic(self):
"""print generic env vars""" """print generic env vars"""
print( print(f"""
f"""
HOST_UID: {self.HOST_UID} HOST_UID: {self.HOST_UID}
HOST_GID: {self.HOST_GID} HOST_GID: {self.HOST_GID}
TZ: {self.TZ} TZ: {self.TZ}
@ -102,36 +101,29 @@ class EnvironmentSettings:
TA_PORT: {self.TA_PORT} TA_PORT: {self.TA_PORT}
TA_BACKEND_PORT: {self.TA_BACKEND_PORT} TA_BACKEND_PORT: {self.TA_BACKEND_PORT}
TA_USERNAME: {self.TA_USERNAME} TA_USERNAME: {self.TA_USERNAME}
TA_PASSWORD: *****""" TA_PASSWORD: *****""")
)
def print_paths(self): def print_paths(self):
"""debug paths set""" """debug paths set"""
print( print(f"""
f"""
MEDIA_DIR: {self.MEDIA_DIR} MEDIA_DIR: {self.MEDIA_DIR}
APP_DIR: {self.APP_DIR} APP_DIR: {self.APP_DIR}
CACHE_DIR: {self.CACHE_DIR}""" CACHE_DIR: {self.CACHE_DIR}""")
)
def print_redis_conf(self): def print_redis_conf(self):
"""debug redis conf paths""" """debug redis conf paths"""
print( print(f"""
f"""
REDIS_CON: {self.REDIS_CON} REDIS_CON: {self.REDIS_CON}
REDIS_NAME_SPACE: {self.REDIS_NAME_SPACE}""" REDIS_NAME_SPACE: {self.REDIS_NAME_SPACE}""")
)
def print_es_paths(self): def print_es_paths(self):
"""debug es conf""" """debug es conf"""
print( print(f"""
f"""
ES_URL: {self.ES_URL} ES_URL: {self.ES_URL}
ES_PASS: ***** ES_PASS: *****
ES_USER: {self.ES_USER} ES_USER: {self.ES_USER}
ES_SNAPSHOT_DIR: {self.ES_SNAPSHOT_DIR} ES_SNAPSHOT_DIR: {self.ES_SNAPSHOT_DIR}
ES_DISABLE_VERIFY_SSL: {self.ES_DISABLE_VERIFY_SSL}""" ES_DISABLE_VERIFY_SSL: {self.ES_DISABLE_VERIFY_SSL}""")
)
def print_all(self): def print_all(self):
"""print all""" """print all"""

View File

@ -78,9 +78,17 @@ class YtWrap:
} }
}, },
) )
if EnvironmentSettings.APP_DIR == "/app": return
# container internal only
self.obs["plugin_dirs"].append("/opt/yt_plugins/bgutil") # from fork: https://github.com/bbilly1/bgutil-ytdlp-pot-provider
deep_merge(
self.obs,
{
"extractor_args": {
"youtubepot-bgutilhttp": {"disable": ["True"]}
}
},
)
def download(self, url): def download(self, url):
"""make download request""" """make download request"""

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys

View File

@ -1,2 +0,0 @@
# install plugins in separate folder for runtime whitelisting
bgutil-ytdlp-pot-provider==1.3.1

View File

@ -1,15 +1,16 @@
apprise==1.9.9 apprise==1.9.9
bgutil-ytdlp-pot-provider @ git+https://github.com/bbilly1/bgutil-ytdlp-pot-provider@68578674650bade31cd77fb80ce84f7045191ba7#subdirectory=plugin
celery==5.6.3 celery==5.6.3
deepdiff==8.6.2 deepdiff==9.0.0
django-auth-ldap==5.3.0 django-auth-ldap==5.3.0
django-celery-beat==2.9.0 django-celery-beat==2.9.0
django-cors-headers==4.9.0 django-cors-headers==4.9.0
Django==6.0.3 Django==6.0.4
djangorestframework==3.17.1 djangorestframework==3.17.1
drf-spectacular==0.28.0 # rc:ignore drf-spectacular==0.28.0 # rc:ignore
Pillow==12.1.1 Pillow==12.2.0
redis==7.4.0 redis==7.4.0
requests==2.33.0 requests==2.33.1
ryd-client==0.0.6 ryd-client==0.0.6
uvicorn==0.42.0 uvicorn==0.44.0
yt-dlp[default]==2026.3.17 yt-dlp[default]==2026.3.17

View File

@ -1 +1 @@
22.13.0 24.14.1

File diff suppressed because it is too large Load Diff

View File

@ -11,27 +11,27 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.3.1", "dompurify": "^3.3.3",
"react": "^19.2.3", "react": "^19.2.4",
"react-dom": "^19.2.3", "react-dom": "^19.2.4",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.14.0",
"zustand": "^5.0.9" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.7", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.51.0", "@typescript-eslint/parser": "^8.58.0",
"@vitejs/plugin-react-swc": "^4.2.2", "@vitejs/plugin-react-swc": "^4.3.0",
"eslint": "^9.39.2", "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.0.0", "globals": "^17.4.0",
"prettier": "3.7.4", "prettier": "3.8.1",
"typescript": "^5.9.3", "typescript": "^6.0.2",
"typescript-eslint": "^8.51.0", "typescript-eslint": "^8.58.0",
"vite": "^7.3.0", "vite": "^8.0.5",
"vite-plugin-checker": "^0.12.0" "vite-plugin-checker": "^0.12.0"
} }
} }

View File

@ -8,7 +8,7 @@ export const ReindexTypeEnum = {
playlist: 'playlist', playlist: 'playlist',
}; };
const queueReindex = async (id: string, type: ReindexType, reindexVideos = false) => { const queueReindex = async (id: string[], type: ReindexType, reindexVideos = false) => {
let params = ''; let params = '';
if (reindexVideos) { if (reindexVideos) {
params = '?extract_videos=true'; params = '?extract_videos=true';
@ -16,7 +16,7 @@ const queueReindex = async (id: string, type: ReindexType, reindexVideos = false
return APIClient(`/api/refresh/${params}`, { return APIClient(`/api/refresh/${params}`, {
method: 'POST', method: 'POST',
body: { [type]: [id] }, body: { [type]: id },
}); });
}; };

View File

@ -11,7 +11,7 @@ import VideoThumbnail from './VideoThumbail';
type DownloadListItemProps = { type DownloadListItemProps = {
download: Download; download: Download;
setRefresh: (status: boolean) => void; setRefresh: () => void;
}; };
const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => { const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
@ -62,7 +62,11 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
<span>{download.youtube_id}</span> <span>{download.youtube_id}</span>
</p> </p>
{download.message && <p className="danger-zone">{download.message}</p>} {download.message && (
<div>
<p className="danger-zone">{download.message}</p>
</div>
)}
<div> <div>
{showIgnored && ( {showIgnored && (
@ -72,7 +76,7 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
label="Forget" label="Forget"
onClick={async () => { onClick={async () => {
await deleteDownloadById(download.youtube_id); await deleteDownloadById(download.youtube_id);
setRefresh(true); setRefresh();
}} }}
/> />
</div> </div>
@ -82,7 +86,7 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
label="Add to queue" label="Add to queue"
onClick={async () => { onClick={async () => {
await updateDownloadQueueStatusById(download.youtube_id, 'pending'); await updateDownloadQueueStatusById(download.youtube_id, 'pending');
setRefresh(true); setRefresh();
}} }}
/> />
</div> </div>
@ -96,7 +100,7 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
onClick={async () => { onClick={async () => {
await updateDownloadQueueStatusById(download.youtube_id, 'ignore'); await updateDownloadQueueStatusById(download.youtube_id, 'ignore');
setRefresh(true); setRefresh();
}} }}
/> />
</div> </div>
@ -110,7 +114,7 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
await updateDownloadQueueStatusById(download.youtube_id, 'priority'); await updateDownloadQueueStatusById(download.youtube_id, 'priority');
setRefresh(true); setRefresh();
}} }}
/> />
</div> </div>
@ -125,7 +129,7 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
className="danger-button" className="danger-button"
onClick={async () => { onClick={async () => {
await deleteDownloadById(download.youtube_id); await deleteDownloadById(download.youtube_id);
setRefresh(true); setRefresh();
}} }}
/> />
</div> </div>

View File

@ -22,6 +22,9 @@ import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
import Button from './Button'; import Button from './Button';
import updateDownloadQueue from '../api/actions/updateDownloadQueue'; import updateDownloadQueue from '../api/actions/updateDownloadQueue';
import { HideWatchedType } from '../configuration/constants/HideWatched'; import { HideWatchedType } from '../configuration/constants/HideWatched';
import queueReindex from '../api/actions/queueReindex';
import { useOutletContext } from 'react-router-dom';
import { ChannelBaseOutletContextType } from '../pages/ChannelAbout';
type FilterbarProps = { type FilterbarProps = {
viewStyle: ViewStyleNamesType; viewStyle: ViewStyleNamesType;
@ -49,6 +52,7 @@ const Filterbar = ({
const [showHidden, setShowHidden] = useState(false); const [showHidden, setShowHidden] = useState(false);
const { filterHeight, setFilterHeight, showFilterItems, setShowFilterItems } = const { filterHeight, setFilterHeight, showFilterItems, setShowFilterItems } =
useFilterBarTempConf(); useFilterBarTempConf();
const { setStartNotification } = useOutletContext() as ChannelBaseOutletContextType;
const currentViewStyle = userConfig[viewStyle]; const currentViewStyle = userConfig[viewStyle];
const currentHideWatched = userConfig[hideWatched]; const currentHideWatched = userConfig[hideWatched];
@ -84,11 +88,20 @@ const Filterbar = ({
}); });
}; };
const reindexSelected = async (ids: string[]) => {
queueReindex(ids, 'video');
if (setStartNotification !== undefined) setStartNotification(true);
};
const actionList = [ const actionList = [
{ {
label: 'Redownload', label: 'Redownload',
handler: redownloadSelected, handler: redownloadSelected,
}, },
{
label: 'Reindex',
handler: reindexSelected,
},
]; ];
const handleActionSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => { const handleActionSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {

View File

@ -216,17 +216,16 @@ const GoogleCast = ({ video, setRefresh, onWatchStateChanged }: GoogleCastProps)
} }
return ( return (
<> <div>
<> <script
<script async
type="text/javascript" type="text/javascript"
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1" src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
></script> />
{/* @ts-expect-error React does not know what to do with the google-cast-launcher, but it works. */} {/* @ts-expect-error React does not know what to do with the google-cast-launcher, but it works. */}
<google-cast-launcher id="castbutton"></google-cast-launcher> <google-cast-launcher id="castbutton"></google-cast-launcher>
</> </div>
</>
); );
}; };

View File

@ -37,13 +37,6 @@ export default function MembershipAppsettings({ show_help_text }: { show_help_te
const [isLoadingSync, setIsLoadingSync] = useState(false); const [isLoadingSync, setIsLoadingSync] = useState(false);
const [subSyncMessage, setSubSyncMessage] = useState(''); const [subSyncMessage, setSubSyncMessage] = useState('');
const fetchMembershipToken = async () => {
const apiTokenResponse = await APIClient<ApiTokenResponse>(
'/api/appsettings/membership/token/',
);
setMembershipApiToken(apiTokenResponse.data?.token || null);
};
const deleteMembershipToken = async () => { const deleteMembershipToken = async () => {
await APIClient('/api/appsettings/membership/token/', { method: 'DELETE' }); await APIClient('/api/appsettings/membership/token/', { method: 'DELETE' });
setMembershipApiToken(null); setMembershipApiToken(null);
@ -64,6 +57,12 @@ export default function MembershipAppsettings({ show_help_text }: { show_help_te
}; };
useEffect(() => { useEffect(() => {
const fetchMembershipToken = async () => {
const apiTokenResponse = await APIClient<ApiTokenResponse>(
'/api/appsettings/membership/token/',
);
setMembershipApiToken(apiTokenResponse.data?.token || null);
};
fetchMembershipToken(); fetchMembershipToken();
}, []); }, []);

View File

@ -8,6 +8,7 @@ import { useUserConfigStore } from '../stores/UserConfigStore';
import { useVideoSelectionStore } from '../stores/VideoSelectionStore'; import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
import iconChecked from '/img/icon-seen.svg'; import iconChecked from '/img/icon-seen.svg';
import iconUnchecked from '/img/icon-unseen.svg'; import iconUnchecked from '/img/icon-unseen.svg';
import bitsToBytes from '../functions/bitsToBytes';
const StreamsTypeEmun = { const StreamsTypeEmun = {
Video: 'video', Video: 'video',
@ -97,9 +98,9 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
<td>{`${videoStream?.width || '-'}x${videoStream?.height || '-'}`}</td> <td>{`${videoStream?.width || '-'}x${videoStream?.height || '-'}`}</td>
<td>{humanFileSize(media_size, useSiUnits)}</td> <td>{humanFileSize(media_size, useSiUnits)}</td>
<td>{videoStream?.codec || '-'}</td> <td>{videoStream?.codec || '-'}</td>
<td>{humanFileSize(videoStream?.bitrate || 0, useSiUnits)}</td> <td>{humanFileSize(bitsToBytes(videoStream?.bitrate || 0), useSiUnits)}</td>
<td>{audioStream?.codec || '-'}</td> <td>{audioStream?.codec || '-'}</td>
<td>{humanFileSize(audioStream?.bitrate || 0, useSiUnits)}</td> <td>{humanFileSize(bitsToBytes(audioStream?.bitrate || 0), useSiUnits)}</td>
</tr> </tr>
); );
})} })}

View File

@ -167,20 +167,6 @@ const VideoPlayer = ({
const [showInfoDialog, setShowInfoDialog] = useState(false); const [showInfoDialog, setShowInfoDialog] = useState(false);
const [infoDialogContent, setInfoDialogContent] = useState(''); const [infoDialogContent, setInfoDialogContent] = useState('');
const [isTheaterMode, setIsTheaterMode] = useState(false); const [isTheaterMode, setIsTheaterMode] = useState(false);
const [theaterModeKeyPressed, setTheaterModeKeyPressed] = useState(false);
const questionmarkPressed = useKeyPress('?');
const mutePressed = useKeyPress('m');
const fullscreenPressed = useKeyPress('f');
const subtitlesPressed = useKeyPress('c');
const increasePlaybackSpeedPressed = useKeyPress('>');
const decreasePlaybackSpeedPressed = useKeyPress('<');
const resetPlaybackSpeedPressed = useKeyPress('=');
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 videoId = video.youtube_id;
const videoUrl = video.media_url; const videoUrl = video.media_url;
@ -205,6 +191,152 @@ const VideoPlayer = ({
}, 500); }, 500);
}; };
useKeyPress('m', () => {
setIsMuted(current => !current);
});
useKeyPress('p', () => {
if (videoRef.current?.paused) {
videoRef.current.play();
} else {
videoRef.current?.pause();
}
});
useKeyPress('>', () => {
const newSpeed = playbackSpeedIndex + 1;
if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeed]) {
const speed = VIDEO_PLAYBACK_SPEEDS[newSpeed];
videoRef.current.playbackRate = speed;
setPlaybackSpeedIndex(newSpeed);
infoDialog(`${speed}x`);
}
});
useKeyPress('<', () => {
const newSpeedIndex = playbackSpeedIndex - 1;
if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) {
const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex];
videoRef.current.playbackRate = speed;
setPlaybackSpeedIndex(newSpeedIndex);
infoDialog(`${speed}x`);
}
});
useKeyPress('=', () => {
const newSpeedIndex = 3;
if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) {
const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex];
videoRef.current.playbackRate = speed;
setPlaybackSpeedIndex(newSpeedIndex);
infoDialog(`${speed}x`);
}
});
useKeyPress('f', () => {
if (videoRef.current && videoRef.current.requestFullscreen && !document.fullscreenElement) {
videoRef.current.requestFullscreen().catch(e => {
console.error(e);
infoDialog('Unable to enter fullscreen');
});
} else {
document.exitFullscreen().catch(e => {
console.error(e);
infoDialog('Unable to exit fullscreen');
});
}
});
useKeyPress('c', () => {
if (!videoRef.current) {
return;
}
const tracks = [...videoRef.current.textTracks];
if (tracks.length === 0) {
return;
}
const lastIndex = tracks.findIndex(x => x.mode === 'showing');
const active = tracks[lastIndex];
if (!active && lastSubtitleTack !== 0) {
tracks[lastSubtitleTack - 1].mode = 'showing';
} else if (active) {
active.mode = 'hidden';
setLastSubtitleTack(lastIndex + 1);
}
});
useKeyPress('ArrowLeft', () => {
const currentCurrentTime = videoRef.current?.currentTime;
if (currentCurrentTime !== undefined && videoRef.current) {
infoDialog('- 5 seconds');
videoRef.current.currentTime = currentCurrentTime - 5;
}
});
useKeyPress('ArrowRight', () => {
const currentCurrentTime = videoRef.current?.currentTime;
if (currentCurrentTime !== undefined && videoRef.current) {
infoDialog('+ 5 seconds');
videoRef.current.currentTime = currentCurrentTime + 5;
}
});
useKeyPress('?', () => {
setShowHelpDialog(current => {
const next = !current;
if (next) {
setTimeout(() => {
setShowHelpDialog(false);
}, 3000);
}
return next;
});
});
useKeyPress('t', () => {
if (embed) {
return;
}
setIsTheaterMode(current => {
const next = !current;
infoDialog(next ? 'Theater mode' : 'Normal mode');
return next;
});
});
useKeyPress('Escape', () => {
if (embed) {
return;
}
setIsTheaterMode(current => {
if (!current) {
return current;
}
infoDialog('Normal mode');
return false;
});
});
const handleVideoEnd = const handleVideoEnd =
( (
youtubeId: string, youtubeId: string,
@ -238,174 +370,6 @@ const VideoPlayer = ({
onVideoEnd?.(); onVideoEnd?.();
}; };
useEffect(() => {
if (mutePressed) {
setIsMuted(!isMuted);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mutePressed]);
useEffect(() => {
if (pPausedPressed) {
if (videoRef.current?.paused) {
videoRef.current.play();
} else {
videoRef.current?.pause();
}
}
}, [pPausedPressed]);
useEffect(() => {
if (increasePlaybackSpeedPressed) {
const newSpeed = playbackSpeedIndex + 1;
if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeed]) {
const speed = VIDEO_PLAYBACK_SPEEDS[newSpeed];
videoRef.current.playbackRate = speed;
setPlaybackSpeedIndex(newSpeed);
infoDialog(`${speed}x`);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [increasePlaybackSpeedPressed]);
useEffect(() => {
if (decreasePlaybackSpeedPressed) {
const newSpeedIndex = playbackSpeedIndex - 1;
if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) {
const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex];
videoRef.current.playbackRate = speed;
setPlaybackSpeedIndex(newSpeedIndex);
infoDialog(`${speed}x`);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decreasePlaybackSpeedPressed]);
useEffect(() => {
if (resetPlaybackSpeedPressed) {
const newSpeedIndex = 3;
if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) {
const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex];
videoRef.current.playbackRate = speed;
setPlaybackSpeedIndex(newSpeedIndex);
infoDialog(`${speed}x`);
}
}
}, [resetPlaybackSpeedPressed]);
useEffect(() => {
if (fullscreenPressed) {
if (videoRef.current && videoRef.current.requestFullscreen && !document.fullscreenElement) {
videoRef.current.requestFullscreen().catch(e => {
console.error(e);
infoDialog('Unable to enter fullscreen');
});
} else {
document.exitFullscreen().catch(e => {
console.error(e);
infoDialog('Unable to exit fullscreen');
});
}
}
}, [fullscreenPressed]);
useEffect(() => {
if (subtitlesPressed) {
if (videoRef.current) {
const tracks = [...videoRef.current.textTracks];
if (tracks.length === 0) {
return;
}
const lastIndex = tracks.findIndex(x => x.mode === 'showing');
const active = tracks[lastIndex];
if (!active && lastSubtitleTack !== 0) {
tracks[lastSubtitleTack - 1].mode = 'showing';
} else {
if (active) {
active.mode = 'hidden';
setLastSubtitleTack(lastIndex + 1);
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subtitlesPressed]);
useEffect(() => {
if (arrowLeftPressed || arrowRightPressed) {
let timeStep = 5;
if (arrowLeftPressed) {
infoDialog('- 5 seconds');
timeStep *= -1;
}
if (arrowRightPressed) {
infoDialog('+ 5 seconds');
}
const currentCurrentTime = videoRef.current?.currentTime;
if (currentCurrentTime !== undefined && videoRef.current) {
videoRef.current.currentTime = currentCurrentTime + timeStep;
}
}
}, [arrowLeftPressed, arrowRightPressed]);
useEffect(() => {
if (questionmarkPressed) {
if (!showHelpDialog) {
setTimeout(() => {
setShowHelpDialog(false);
}, 3000);
}
setShowHelpDialog(!showHelpDialog);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [questionmarkPressed]);
useEffect(() => {
if (embed) {
return;
}
if (theaterModePressed && !theaterModeKeyPressed) {
setTheaterModeKeyPressed(true);
const newTheaterMode = !isTheaterMode;
setIsTheaterMode(newTheaterMode);
infoDialog(newTheaterMode ? 'Theater mode' : 'Normal mode');
} else if (!theaterModePressed) {
setTheaterModeKeyPressed(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theaterModePressed, isTheaterMode, theaterModeKeyPressed]);
useEffect(() => {
if (embed) {
return;
}
if (escapePressed && isTheaterMode) {
setIsTheaterMode(false);
infoDialog('Normal mode');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [escapePressed, isTheaterMode]);
return ( return (
<> <>
<div <div

View File

@ -0,0 +1,5 @@
function bitsToBytes(bits: number) {
return bits / 8;
}
export default bitsToBytes;

View File

@ -1,8 +1,15 @@
import { useEffect, useState } from 'react'; import { useEffect, useEffectEvent, useRef, useState } from 'react';
// source: https://thibault.sh/react-hooks/use-key-press // source: https://thibault.sh/react-hooks/use-key-press
export function useKeyPress(targetKey: string) { export function useKeyPress(targetKey: string, onKeyDown?: () => void, onKeyUp?: () => void) {
const [isKeyPressed, setIsKeyPressed] = useState(false); const [isKeyPressed, setIsKeyPressed] = useState(false);
const isKeyPressedRef = useRef(false);
const handleKeyDownEvent = useEffectEvent(() => {
onKeyDown?.();
});
const handleKeyUpEvent = useEffectEvent(() => {
onKeyUp?.();
});
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@ -13,13 +20,21 @@ export function useKeyPress(targetKey: string) {
!event.altKey && !event.altKey &&
!event.altKey !event.altKey
) { ) {
if (isKeyPressedRef.current) {
return;
}
isKeyPressedRef.current = true;
setIsKeyPressed(true); setIsKeyPressed(true);
handleKeyDownEvent();
} }
}; };
const handleKeyUp = (event: KeyboardEvent) => { const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === targetKey) { if (event.key === targetKey) {
isKeyPressedRef.current = false;
setIsKeyPressed(false); setIsKeyPressed(false);
handleKeyUpEvent();
} }
}; };

View File

@ -1,8 +1,8 @@
import { Outlet, useLoaderData, useLocation, useSearchParams } from 'react-router-dom'; import { Outlet, useLoaderData, useSearchParams } from 'react-router-dom';
import Footer from '../components/Footer'; import Footer from '../components/Footer';
import Colours from '../configuration/colours/Colours'; import Colours from '../configuration/colours/Colours';
import { UserConfigType } from '../api/actions/updateUserConfig'; import { UserConfigType } from '../api/actions/updateUserConfig';
import { useEffect, useState } from 'react'; import { useCallback, useEffect } from 'react';
import Navigation from '../components/Navigation'; import Navigation from '../components/Navigation';
import { useAuthStore } from '../stores/AuthDataStore'; import { useAuthStore } from '../stores/AuthDataStore';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
@ -43,12 +43,8 @@ const Base = () => {
const { setAppSettingsConfig } = useAppSettingsStore(); const { setAppSettingsConfig } = useAppSettingsStore();
const { userConfig, userAccount, appSettings, auth } = useLoaderData() as BaseLoaderData; const { userConfig, userAccount, appSettings, auth } = useLoaderData() as BaseLoaderData;
const location = useLocation();
const currentPageFromUrl = Number(searchParams.get('page')); const currentPageFromUrl = Number(searchParams.get('page'));
const currentPage = Number.isNaN(currentPageFromUrl) ? 0 : currentPageFromUrl;
const [currentPage, setCurrentPage] = useState(currentPageFromUrl);
useEffect(() => { useEffect(() => {
setAuth(auth); setAuth(auth);
@ -59,40 +55,20 @@ const Base = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => { const setCurrentPage = useCallback(
if (currentPageFromUrl !== currentPage) { (page: number) => {
setCurrentPage(0);
}
// This should only be executed when location.pathname changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
useEffect(() => {
if (currentPageFromUrl !== currentPage) {
setCurrentPage(currentPageFromUrl);
}
// This should only be executed when currentPageFromUrl changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPageFromUrl]);
useEffect(() => {
if (currentPageFromUrl !== currentPage) {
setSearchParams(params => { setSearchParams(params => {
if (currentPage == 0) { if (page === 0) {
params.delete('page'); params.delete('page');
} else { } else {
params.set('page', currentPage.toString()); params.set('page', page.toString());
} }
return params; return params;
}); });
} },
[setSearchParams],
// This should only be executed when currentPage changes. );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
return ( return (
<> <>

View File

@ -183,7 +183,7 @@ const ChannelAbout = () => {
label="Reindex" label="Reindex"
title={`Reindex Channel ${channel.channel_name}`} title={`Reindex Channel ${channel.channel_name}`}
onClick={async () => { onClick={async () => {
await queueReindex(channelId, ReindexTypeEnum.channel as ReindexType); await queueReindex([channelId], ReindexTypeEnum.channel as ReindexType);
setReindex(true); setReindex(true);
setStartNotification(true); setStartNotification(true);
@ -194,7 +194,7 @@ const ChannelAbout = () => {
title={`Reindex Videos of ${channel.channel_name}`} title={`Reindex Videos of ${channel.channel_name}`}
onClick={async () => { onClick={async () => {
await queueReindex( await queueReindex(
channelId, [channelId],
ReindexTypeEnum.channel as ReindexType, ReindexTypeEnum.channel as ReindexType,
true, true,
); );

View File

@ -60,7 +60,7 @@ const Download = () => {
const vidTypeFilterFromUrl = searchParams.get('vid-type'); const vidTypeFilterFromUrl = searchParams.get('vid-type');
const errorFilterFromUrl = searchParams.get('error'); const errorFilterFromUrl = searchParams.get('error');
const [refresh, setRefresh] = useState(false); const [refreshNonce, setRefreshNonce] = useState(0);
const [showHiddenForm, setShowHiddenForm] = useState(false); const [showHiddenForm, setShowHiddenForm] = useState(false);
const [addAsAutoStart, setAddAsAutoStart] = useState(false); const [addAsAutoStart, setAddAsAutoStart] = useState(false);
const [addAsFlat, setAddAsFlat] = useState(false); const [addAsFlat, setAddAsFlat] = useState(false);
@ -113,34 +113,31 @@ const Download = () => {
} }
}; };
const refreshDownloadQueue = () => {
setRefreshNonce(current => current + 1);
};
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (refresh) { const videosResponse = await loadDownloadQueue(
const videosResponse = await loadDownloadQueue( currentPage,
currentPage, channelFilterFromUrl,
channelFilterFromUrl, vidTypeFilterFromUrl,
vidTypeFilterFromUrl, errorFilterFromUrl,
errorFilterFromUrl, showIgnored,
showIgnored, searchInput,
searchInput, );
); const { data: channelResponseData } = videosResponse ?? {};
const { data: channelResponseData } = videosResponse ?? {}; const videoCount = channelResponseData?.paginate?.total_hits;
const videoCount = channelResponseData?.paginate?.total_hits;
if (videoCount && lastVideoCount !== videoCount) { if (videoCount && lastVideoCount !== videoCount) {
setLastVideoCount(videoCount); setLastVideoCount(videoCount);
}
setDownloadResponse(videosResponse);
setRefresh(false);
} }
setDownloadResponse(videosResponse);
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh]);
useEffect(() => {
setRefresh(true);
}, [ }, [
channelFilterFromUrl, channelFilterFromUrl,
vidTypeFilterFromUrl, vidTypeFilterFromUrl,
@ -148,6 +145,7 @@ const Download = () => {
currentPage, currentPage,
showIgnored, showIgnored,
searchInput, searchInput,
refreshNonce,
]); ]);
useEffect(() => { useEffect(() => {
@ -166,7 +164,7 @@ const Download = () => {
errorFilterFromUrl, errorFilterFromUrl,
status, status,
); );
setRefresh(true); refreshDownloadQueue();
}; };
return ( return (
@ -184,7 +182,7 @@ const Download = () => {
if (!isDone) { if (!isDone) {
setRescanPending(false); setRescanPending(false);
setDownloadPending(false); setDownloadPending(false);
setRefresh(true); refreshDownloadQueue();
} }
}} }}
/> />
@ -308,7 +306,7 @@ const Download = () => {
force: addAsForce, force: addAsForce,
}); });
setDownloadQueueText(''); setDownloadQueueText('');
setRefresh(true); refreshDownloadQueue();
setShowHiddenForm(false); setShowHiddenForm(false);
} }
}} }}
@ -329,7 +327,7 @@ const Download = () => {
const newParams = new URLSearchParams(); const newParams = new URLSearchParams();
newParams.set('ignored', String(!showIgnored)); newParams.set('ignored', String(!showIgnored));
setSearchParams(newParams); setSearchParams(newParams);
setRefresh(true); refreshDownloadQueue();
}} }}
type="checkbox" type="checkbox"
checked={showIgnored} checked={showIgnored}
@ -539,7 +537,7 @@ const Download = () => {
channelFilterFromUrl, channelFilterFromUrl,
vidTypeFilterFromUrl, vidTypeFilterFromUrl,
); );
setRefresh(true); refreshDownloadQueue();
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
}} }}
> >
@ -563,7 +561,7 @@ const Download = () => {
<Fragment <Fragment
key={`${download.channel_id}_${download.timestamp}_${download.youtube_id}`} key={`${download.channel_id}_${download.timestamp}_${download.youtube_id}`}
> >
<DownloadListItem download={download} setRefresh={setRefresh} /> <DownloadListItem download={download} setRefresh={refreshDownloadQueue} />
</Fragment> </Fragment>
); );
})} })}

View File

@ -284,7 +284,7 @@ const Playlist = () => {
onClick={async () => { onClick={async () => {
setReindex(true); setReindex(true);
await queueReindex(playlist.playlist_id, 'playlist'); await queueReindex([playlist.playlist_id], 'playlist');
}} }}
/> />
)}{' '} )}{' '}
@ -294,7 +294,7 @@ const Playlist = () => {
onClick={async () => { onClick={async () => {
setReindex(true); setReindex(true);
await queueReindex(playlist.playlist_id, 'playlist', true); await queueReindex([playlist.playlist_id], 'playlist', true);
}} }}
/> />
</div> </div>

View File

@ -45,6 +45,7 @@ import NotFound from './NotFound';
import { ApiResponseType } from '../functions/APIClient'; import { ApiResponseType } from '../functions/APIClient';
import VideoThumbnail from '../components/VideoThumbail'; import VideoThumbnail from '../components/VideoThumbail';
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle'; import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
import bitsToBytes from '../functions/bitsToBytes';
const isInPlaylist = (videoId: string, playlist: PlaylistType) => { const isInPlaylist = (videoId: string, playlist: PlaylistType) => {
return playlist.playlist_entries.some(entry => { return playlist.playlist_entries.some(entry => {
@ -108,7 +109,6 @@ const Video = () => {
const { appSettingsConfig } = useAppSettingsStore(); const { appSettingsConfig } = useAppSettingsStore();
const { userConfig } = useUserConfigStore(); const { userConfig } = useUserConfigStore();
const [videoEnded, setVideoEnded] = useState(false);
const [seekToTimestamp, setSeekToTimestamp] = useState<number>(); const [seekToTimestamp, setSeekToTimestamp] = useState<number>();
const [playlistAutoplay, setPlaylistAutoplay] = useState( const [playlistAutoplay, setPlaylistAutoplay] = useState(
localStorage.getItem('playlistAutoplay') === 'true', localStorage.getItem('playlistAutoplay') === 'true',
@ -179,24 +179,6 @@ const Video = () => {
localStorage.setItem('playlistIdForAutoplay', playlistIdForAutoplay || ''); localStorage.setItem('playlistIdForAutoplay', playlistIdForAutoplay || '');
}, [playlistAutoplay, playlistIdForAutoplay]); }, [playlistAutoplay, playlistIdForAutoplay]);
useEffect(() => {
if (videoEnded && playlistAutoplay) {
const playlist = videoPlaylistNavResponseData?.find(playlist => {
return playlist.playlist_meta.playlist_id === playlistIdForAutoplay;
});
if (playlist) {
const nextYoutubeId = playlist.playlist_next?.youtube_id;
if (nextYoutubeId) {
setVideoEnded(false);
navigate(Routes.Video(nextYoutubeId));
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoEnded, playlistAutoplay]);
const errorMessage = videoResponseError?.error; const errorMessage = videoResponseError?.error;
if (errorMessage) { if (errorMessage) {
@ -235,7 +217,18 @@ const Video = () => {
setRefreshVideoList(true); setRefreshVideoList(true);
}} }}
onVideoEnd={() => { onVideoEnd={() => {
setVideoEnded(true); if (!playlistAutoplay) {
return;
}
const playlist = videoPlaylistNavResponseData?.find(playlist => {
return playlist.playlist_meta.playlist_id === playlistIdForAutoplay;
});
const nextYoutubeId = playlist?.playlist_next?.youtube_id;
if (nextYoutubeId) {
navigate(Routes.Video(nextYoutubeId));
}
}} }}
/> />
@ -342,7 +335,7 @@ const Video = () => {
label="Reindex" label="Reindex"
title={`Reindex ${video.title}`} title={`Reindex ${video.title}`}
onClick={async () => { onClick={async () => {
await queueReindex(video.youtube_id, 'video'); await queueReindex([video.youtube_id], 'video');
setReindex(true); setReindex(true);
}} }}
/> />
@ -468,7 +461,7 @@ const Video = () => {
return ( return (
<p key={stream.index}> <p key={stream.index}>
{capitalizeFirstLetter(stream.type)}: {stream.codec}{' '} {capitalizeFirstLetter(stream.type)}: {stream.codec}{' '}
{humanFileSize(stream.bitrate, useSiUnits)}/s {humanFileSize(bitsToBytes(stream.bitrate), useSiUnits)}/s
{stream.width && ( {stream.width && (
<> <>
<span className="space-carrot">|</span> {stream.width}x{stream.height} <span className="space-carrot">|</span> {stream.width}x{stream.height}

View File

@ -625,6 +625,8 @@ video:-webkit-full-screen {
.video-thumb img { .video-thumb img {
width: 100%; width: 100%;
position: relative; position: relative;
aspect-ratio: 16 / 9;
background: var(--highlight-bg-transparent);
} }
.video-tags { .video-tags {
@ -1088,6 +1090,8 @@ video:-webkit-full-screen {
.channel-banner img { .channel-banner img {
width: 100%; width: 100%;
aspect-ratio: 18 / 3;
background: var(--highlight-bg-transparent);
} }
.channel-banner.grid { .channel-banner.grid {

View File

@ -1,11 +1,10 @@
-r backend/requirements.plugins.txt
-r backend/requirements.txt -r backend/requirements.txt
ipython==9.12.0 ipython==9.12.0
pre-commit==4.5.1 pre-commit==4.5.1
pylint-django==2.7.0 pylint-django==2.7.0
pylint==4.0.5 pylint==4.0.5
pytest-django==4.12.0 pytest-django==4.12.0
pytest==9.0.2 pytest==9.0.3
python-dotenv==1.2.2 python-dotenv==1.2.2
requirementscheck==0.1.0 requirementscheck==0.1.0
types-requests==2.33.0.20260327 types-requests==2.33.0.20260408