From 6fc2bf36b613c21b0dba5bb5b36fd44da9cd6f3b Mon Sep 17 00:00:00 2001 From: Dakota Gravitt <19499852+dakotagrvtt@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:33:43 -0600 Subject: [PATCH] feat: add MKV container support and video streaming endpoint - Add `container` config option (mp4/mkv) and `audio_languages` to downloads config with defaults - Add validation to enforce mkv container when audio_multistream is enabled, returning a 400 error otherwise - Add `download_container` channel-level overwrite (mp4/mkv) - Update filesystem scanner to detect both `.mp4` and `.mkv` files - Add video streaming API endpoint with fallback transcoding to mp4 for unsupported formats --- backend/appsettings/serializers.py | 2 + backend/appsettings/src/config.py | 4 + backend/appsettings/src/filesystem.py | 2 +- backend/appsettings/views.py | 20 ++ backend/channel/serializers.py | 3 + backend/channel/src/index.py | 1 + backend/channel/views.py | 27 +- backend/download/src/thumbnails.py | 4 + backend/download/src/yt_dlp_handler.py | 239 +++++++++++++++++- backend/video/src/index.py | 41 ++- backend/video/src/subtitle.py | 3 +- backend/video/urls.py | 5 + backend/video/views.py | 79 ++++++ .../src/api/loader/loadAppsettingsConfig.ts | 2 + frontend/src/components/ToggleConfig.tsx | 9 + frontend/src/components/VideoPlayer.tsx | 2 +- frontend/src/pages/ChannelAbout.tsx | 51 +++- frontend/src/pages/Channels.tsx | 1 + frontend/src/pages/SettingsApplication.tsx | 56 +++- frontend/src/stores/AppSettingsStore.ts | 2 + frontend/src/style.css | 17 ++ 21 files changed, 551 insertions(+), 19 deletions(-) diff --git a/backend/appsettings/serializers.py b/backend/appsettings/serializers.py index 6b8c9a12..889c9ca1 100644 --- a/backend/appsettings/serializers.py +++ b/backend/appsettings/serializers.py @@ -60,6 +60,8 @@ class AppConfigDownloadsSerializer( integrate_ryd = serializers.BooleanField() integrate_sponsorblock = serializers.BooleanField() audio_multistream = serializers.BooleanField() + audio_languages = serializers.CharField(allow_null=True) + container = serializers.ChoiceField(choices=["mp4", "mkv"]) class AppConfigAppSerializer( diff --git a/backend/appsettings/src/config.py b/backend/appsettings/src/config.py index d63ebaf0..e2b51ee6 100644 --- a/backend/appsettings/src/config.py +++ b/backend/appsettings/src/config.py @@ -47,6 +47,8 @@ class DownloadsConfigType(TypedDict): integrate_ryd: bool integrate_sponsorblock: bool audio_multistream: bool + audio_languages: str | None + container: Literal["mp4", "mkv"] class ApplicationConfigType(TypedDict): @@ -97,6 +99,8 @@ class AppConfig: "integrate_ryd": False, "integrate_sponsorblock": False, "audio_multistream": False, + "audio_languages": None, + "container": "mp4", }, "application": { "enable_snapshot": True, diff --git a/backend/appsettings/src/filesystem.py b/backend/appsettings/src/filesystem.py index 13324331..f42d0b8d 100644 --- a/backend/appsettings/src/filesystem.py +++ b/backend/appsettings/src/filesystem.py @@ -53,7 +53,7 @@ class Scanner: { (i.split(".")[0], f"{channel}/{i}") for i in files - if i.endswith(".mp4") + if i.endswith((".mp4", ".mkv")) } ) diff --git a/backend/appsettings/views.py b/backend/appsettings/views.py index 91b6b89d..208d7ca6 100644 --- a/backend/appsettings/views.py +++ b/backend/appsettings/views.py @@ -182,6 +182,26 @@ class AppConfigApiView(ApiBaseView): serializer = AppConfigSerializer(data=request.data, partial=True) serializer.is_valid(raise_exception=True) validated_data = serializer.validated_data + current_config = AppConfig().config + downloads_update = validated_data.get("downloads") or {} + if downloads_update: + audio_multistream = downloads_update.get( + "audio_multistream", + current_config["downloads"]["audio_multistream"], + ) + container = downloads_update.get( + "container", + current_config["downloads"]["container"], + ) + if audio_multistream and container != "mkv": + error = ErrorResponseSerializer( + { + "error": ( + "audio_multistream requires mkv container" + ) + } + ) + return Response(error.data, status=400) updated_config = AppConfig().update_config(validated_data) updated_serializer = AppConfigSerializer(updated_config) return Response(updated_serializer.data) diff --git a/backend/channel/serializers.py b/backend/channel/serializers.py index c38dcaf7..c16b3993 100644 --- a/backend/channel/serializers.py +++ b/backend/channel/serializers.py @@ -13,6 +13,9 @@ class ChannelOverwriteSerializer( """serialize channel overwrites""" download_format = serializers.CharField(required=False, allow_null=True) + download_container = serializers.ChoiceField( + choices=["mp4", "mkv"], required=False, allow_null=True + ) audio_multistream = serializers.BooleanField( required=False, allow_null=True ) diff --git a/backend/channel/src/index.py b/backend/channel/src/index.py index 806454c4..f68ae00a 100644 --- a/backend/channel/src/index.py +++ b/backend/channel/src/index.py @@ -266,6 +266,7 @@ class YoutubeChannel(YouTubeItem): """set per channel overwrites""" valid_keys = [ "download_format", + "download_container", "audio_multistream", "autodelete_days", "index_playlists", diff --git a/backend/channel/views.py b/backend/channel/views.py index 204aaf73..7eee11d3 100644 --- a/backend/channel/views.py +++ b/backend/channel/views.py @@ -10,6 +10,7 @@ from channel.serializers import ( ChannelUpdateSerializer, ) from channel.src.index import YoutubeChannel, channel_overwrites +from appsettings.src.config import AppConfig from channel.src.nav import ChannelNav from common.serializers import ErrorResponseSerializer from common.src.urlparser import Parser @@ -144,14 +145,36 @@ class ChannelApiView(ApiBaseView): serializer.is_valid(raise_exception=True) validated_data = serializer.validated_data + current_overwrites = self.response.get("channel_overwrites", {}) + global_container = AppConfig().config["downloads"]["container"] + if overwrites := validated_data.get("channel_overwrites"): + audio_multistream = overwrites.get( + "audio_multistream", + current_overwrites.get("audio_multistream"), + ) + container = overwrites.get( + "download_container", + current_overwrites.get("download_container"), + ) + if container is None: + container = global_container + if audio_multistream and container != "mkv": + error = ErrorResponseSerializer( + { + "error": ( + "audio_multistream requires mkv container" + ) + } + ) + return Response(error.data, status=400) + subscribed = validated_data.get("channel_subscribed") if subscribed is not None: YoutubeChannel(channel_id).change_subscribe( new_subscribe_state=subscribed ) - overwrites = validated_data.get("channel_overwrites") - if overwrites: + if overwrites := validated_data.get("channel_overwrites"): channel_overwrites(channel_id, overwrites) if overwrites.get("index_playlists"): index_channel_playlists.delay(channel_id) diff --git a/backend/download/src/thumbnails.py b/backend/download/src/thumbnails.py index 5004f932..7685fd7a 100644 --- a/backend/download/src/thumbnails.py +++ b/backend/download/src/thumbnails.py @@ -223,6 +223,10 @@ class ThumbManager(ThumbManagerBase): print(f"{self.item_id}: skip art embed, file not found") return + if not json_data.get("media_url", "").endswith(".mp4"): + print(f"{self.item_id}: skip art embed, mp4-only") + return + video = MP4(file_path) thumb_path = self.vid_thumb_path(absolute=True) diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index cfd3e4c7..b0133ffd 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -155,9 +155,10 @@ class VideoDownloader(DownloaderBase): def _build_obs_basic(self): """initial obs""" + container = self.config["downloads"].get("container", "mp4") self.obs = { - "merge_output_format": "mp4", - "outtmpl": (self.CACHE_DIR + "/download/%(id)s.mp4"), + "merge_output_format": container, + "outtmpl": (self.CACHE_DIR + f"/download/%(id)s.{container}"), "progress_hooks": [self._progress_hook], "noprogress": True, "continuedl": True, @@ -185,6 +186,166 @@ class VideoDownloader(DownloaderBase): if throttle: self.obs["throttledratelimit"] = throttle * 1024 + def _get_formats(self, youtube_id: str) -> list[dict]: + """extract available formats for a video""" + extract_obs = {"skip_download": True, "quiet": True} + yt_extract = YtWrap(extract_obs, self.config) + response, error = yt_extract.extract( + f"https://www.youtube.com/watch?v={youtube_id}" + ) + if not response or error: + print(f"{youtube_id}: failed to extract formats: {error}") + return [] + return response.get("formats", []) + + @staticmethod + def _resolve_audio_formats( + formats: list[dict], languages: list[str] + ) -> tuple[dict, dict]: + """classify each language as DASH audio-only or HLS-only + + Returns: + dash_formats: {lang: format_id} for DASH audio-only streams + hls_formats: {lang: format_id} for HLS muxed fallback streams + """ + dash_formats: dict[str, str] = {} + hls_formats: dict[str, str] = {} + + for lang in languages: + dash = [ + f + for f in formats + if (f.get("vcodec") or "none") == "none" + and (f.get("acodec") or "none") != "none" + and (f.get("language") or "").startswith(lang) + and f.get("protocol") in ("https", "http") + ] + if dash: + dash.sort(key=lambda f: f.get("tbr") or 0, reverse=True) + chosen = dash[0] + dash_formats[lang] = chosen["format_id"] + print( + f"[audio_languages] {lang}: DASH audio " + f"{chosen['format_id']} ({chosen.get('acodec')}, " + f"{chosen.get('tbr')}kbps)" + ) + continue + + # fall back to lowest-bitrate HLS muxed stream + hls = [ + f + for f in formats + if (f.get("acodec") or "none") != "none" + and (f.get("language") or "").startswith(lang) + and "m3u8" in (f.get("protocol") or "") + ] + if hls: + hls.sort(key=lambda f: f.get("tbr") or 0) + chosen = hls[0] + hls_formats[lang] = chosen["format_id"] + print( + f"[audio_languages] {lang}: HLS fallback " + f"{chosen['format_id']} ({chosen.get('acodec')}, " + f"{chosen.get('tbr')}kbps)" + ) + else: + print(f"[audio_languages] no audio found for: {lang}") + + return dash_formats, hls_formats + + @staticmethod + def _build_main_format( + formats: list[dict], dash_lang_formats: dict + ) -> str | None: + """build DASH-only format string: bestvideo + DASH audio per language""" + video_only = [ + f + for f in formats + if (f.get("vcodec") or "none") != "none" + and (f.get("acodec") or "none") == "none" + ] + if not video_only: + print("[audio_languages] no video-only formats found") + return None + + video_only.sort( + key=lambda f: (f.get("height") or 0, f.get("tbr") or 0), + reverse=True, + ) + best_video = video_only[0] + print( + f"[audio_languages] best video: {best_video['format_id']} " + f"({best_video.get('height')}p, {best_video.get('vcodec')})" + ) + + if not dash_lang_formats: + return None + + parts = [best_video["format_id"]] + list(dash_lang_formats.values()) + format_str = "+".join(parts) + print(f"[audio_languages] main format string: {format_str}") + return format_str + + def _download_hls_audio( + self, youtube_id: str, format_id: str, lang: str + ) -> str | None: + """download a single HLS muxed stream to a temp file for audio extraction + + Returns the path to the downloaded file, or None on failure. + """ + dl_dir = os.path.join(self.CACHE_DIR, "download") + base_name = f"{youtube_id}_audio_{lang}" + hls_obs = { + "format": format_id, + "outtmpl": os.path.join(dl_dir, f"{base_name}.%(ext)s"), + "audio_multistreams": False, + "quiet": True, + "noplaylist": True, + } + print(f"[audio_languages] downloading HLS audio {lang}: {format_id}") + success, _ = YtWrap(hls_obs, self.config).download(youtube_id) + if not success: + print(f"[audio_languages] failed HLS audio download for: {lang}") + return None + + # find the file yt-dlp created (extension varies: .ts, .mp4, etc.) + for fname in os.listdir(dl_dir): + if fname.startswith(base_name + "."): + return os.path.join(dl_dir, fname) + + print(f"[audio_languages] could not find HLS audio file for: {lang}") + return None + + @staticmethod + def _merge_audio_tracks(main_path: str, audio_files: list[str]) -> bool: + """ffmpeg-merge additional audio tracks into the main MKV file""" + import subprocess + + output_path = main_path + ".merging.mkv" + cmd = ["ffmpeg", "-y", "-i", main_path] + for af in audio_files: + cmd += ["-i", af] + # copy all streams from main + audio-only from each extra file + cmd += ["-map", "0"] + for i in range(1, len(audio_files) + 1): + cmd += ["-map", f"{i}:a"] + cmd += ["-c", "copy", output_path] + + print(f"[audio_languages] ffmpeg merge: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + err = result.stderr.decode("utf-8", errors="replace") + print(f"[audio_languages] ffmpeg merge failed: {err}") + try: + os.remove(output_path) + except FileNotFoundError: + pass + return False + + os.replace(output_path, main_path) + print(f"[audio_languages] merged audio tracks into {main_path}") + return True + def _build_obs_postprocessors(self): """add postprocessor to obs""" postprocessors = [] @@ -205,28 +366,93 @@ class VideoDownloader(DownloaderBase): overwrites = self.channel_overwrites.get(channel_id) if overwrites and overwrites.get("download_format"): obs["format"] = overwrites.get("download_format") + if overwrites and overwrites.get("download_container"): + container = overwrites.get("download_container") + obs["merge_output_format"] = container + obs["outtmpl"] = ( + self.CACHE_DIR + f"/download/%(id)s.{container}" + ) if overwrites and overwrites.get("audio_multistream") is not None: obs["audio_multistreams"] = overwrites.get("audio_multistream") + def _get_audio_languages(self, channel_id: str) -> list[str] | None: + """get audio languages from config or channel overwrites""" + overwrites = self.channel_overwrites.get(channel_id, {}) + audio_languages = overwrites.get( + "audio_languages", + self.config["downloads"].get("audio_languages"), + ) + if not audio_languages: + return None + + return [ + lang.strip() + for lang in audio_languages.split(",") + if lang.strip() + ] + def _dl_single_vid(self, youtube_id: str, channel_id: str) -> bool: - """download single video""" + """download single video, with optional multi-language audio merging""" obs = self.obs.copy() self._set_overwrites(obs, channel_id) dl_cache = os.path.join(self.CACHE_DIR, "download") + languages = self._get_audio_languages(channel_id) + hls_formats: dict[str, str] = {} # lang -> format_id for HLS post-process + + if languages: + print(f"{youtube_id}: applying audio languages {languages}") + formats = self._get_formats(youtube_id) + if formats: + dash_fmt, hls_formats = self._resolve_audio_formats( + formats, languages + ) + main_fmt = self._build_main_format(formats, dash_fmt) + if main_fmt: + obs["format"] = main_fmt + if len(dash_fmt) > 1: + obs["audio_multistreams"] = True + obs["merge_output_format"] = "mkv" + obs["outtmpl"] = self.CACHE_DIR + "/download/%(id)s.mkv" + print(f"{youtube_id}: main format: {main_fmt}") + elif hls_formats: + # all langs are HLS-only; keep default format for video+audio + obs["merge_output_format"] = "mkv" + obs["outtmpl"] = self.CACHE_DIR + "/download/%(id)s.mkv" + success, message = YtWrap(obs, self.config).download(youtube_id) if not success: self._handle_error(youtube_id, message) + return False + + # phase 2: merge HLS-only language audio tracks via ffmpeg + if hls_formats: + main_path = os.path.join(dl_cache, f"{youtube_id}.mkv") + audio_files = [] + try: + for lang, fmt_id in hls_formats.items(): + af = self._download_hls_audio(youtube_id, fmt_id, lang) + if af: + audio_files.append(af) + if audio_files: + self._merge_audio_tracks(main_path, audio_files) + finally: + for af in audio_files: + try: + os.remove(af) + except FileNotFoundError: + pass if self.obs["writethumbnail"]: # webp files don't get cleaned up automatically all_cached = ignore_filelist(os.listdir(dl_cache)) - to_clean = [i for i in all_cached if not i.endswith(".mp4")] + target_ext = os.path.splitext(obs["outtmpl"])[-1] + to_clean = [i for i in all_cached if not i.endswith(target_ext)] for file_name in to_clean: file_path = os.path.join(dl_cache, file_name) os.remove(file_path) - return success + return True @staticmethod def _handle_error(youtube_id, message): @@ -247,7 +473,8 @@ class VideoDownloader(DownloaderBase): if host_uid and host_gid: os.chown(folder, host_uid, host_gid) # move media file - media_file = vid_dict["youtube_id"] + ".mp4" + media_ext = os.path.splitext(vid_dict["media_url"])[-1] + media_file = vid_dict["youtube_id"] + media_ext old_path = os.path.join(self.CACHE_DIR, "download", media_file) new_path = os.path.join(self.MEDIA_DIR, vid_dict["media_url"]) # move media file and fix permission diff --git a/backend/video/src/index.py b/backend/video/src/index.py index 62ac7f74..b755ac5f 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -275,14 +275,24 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): """find video path in dl cache""" cache_dir = EnvironmentSettings.CACHE_DIR video_id = self.json_data["youtube_id"] - cache_path = f"{cache_dir}/download/{video_id}.mp4" + container = self._get_download_container() + cache_path = f"{cache_dir}/download/{video_id}.{container}" if os.path.exists(cache_path): return cache_path + # check for mkv from audio_languages override + mkv_cache = f"{cache_dir}/download/{video_id}.mkv" + if os.path.exists(mkv_cache): + return mkv_cache + + legacy_cache = f"{cache_dir}/download/{video_id}.mp4" + if os.path.exists(legacy_cache): + return legacy_cache + channel_path = os.path.join( EnvironmentSettings.MEDIA_DIR, self.json_data["channel"]["channel_id"], - f"{video_id}.mp4", + f"{video_id}.{container}", ) if os.path.exists(channel_path): return channel_path @@ -317,11 +327,31 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def add_file_path(self): """build media_url for where file will be located""" + container = self._get_download_container() self.json_data["media_url"] = os.path.join( self.json_data["channel"]["channel_id"], - self.json_data["youtube_id"] + ".mp4", + self.json_data["youtube_id"] + f".{container}", ) + def _get_download_container(self) -> str: + """resolve container with channel overwrites""" + container = self.config["downloads"].get("container", "mp4") + channel_overwrites = self.json_data.get("channel", {}).get( + "channel_overwrites", {} + ) + if channel_overwrites.get("download_container"): + container = channel_overwrites.get("download_container") + + # audio_languages forces mkv for multi-stream support + audio_languages = ( + channel_overwrites.get("audio_languages") + or self.config["downloads"].get("audio_languages") + ) + if audio_languages: + container = "mkv" + + return container + def delete_media_file(self): """delete video file, meta data""" print(f"{self.youtube_id}: delete video") @@ -441,6 +471,11 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): return if self.config["downloads"].get("add_metadata"): + if not self.json_data.get("media_url", "").endswith(".mp4"): + print( + f"{self.youtube_id}: skip embed, metadata is mp4-only" + ) + return try: self._embed_text_data() self._embed_artwork() diff --git a/backend/video/src/subtitle.py b/backend/video/src/subtitle.py index 1fdd0919..6f1c7db2 100644 --- a/backend/video/src/subtitle.py +++ b/backend/video/src/subtitle.py @@ -108,7 +108,8 @@ class YoutubeSubtitle: def get_media_url(self, lang: str) -> str: """get media url""" video_media_url = self.video.json_data["media_url"] - media_url = video_media_url.replace(".mp4", f".{lang}.vtt") + base_name, _ = os.path.splitext(video_media_url) + media_url = f"{base_name}.{lang}.vtt" return media_url def get_es_subtitles(self) -> list[dict]: diff --git a/backend/video/urls.py b/backend/video/urls.py index f520fb0b..851b56c8 100644 --- a/backend/video/urls.py +++ b/backend/video/urls.py @@ -25,6 +25,11 @@ urlpatterns = [ views.VideoCommentView.as_view(), name="api-video-comment", ), + path( + "/stream/", + views.VideoStreamView.as_view(), + name="api-video-stream", + ), path( "/similar/", views.VideoSimilarView.as_view(), diff --git a/backend/video/views.py b/backend/video/views.py index 4bb95d87..7797056a 100644 --- a/backend/video/views.py +++ b/backend/video/views.py @@ -7,6 +7,10 @@ from common.src.watched import WatchState from common.views_base import AdminWriteOnly, ApiBaseView from drf_spectacular.utils import OpenApiResponse, extend_schema from playlist.src.index import YoutubePlaylist +import os +import subprocess + +from django.http import FileResponse from rest_framework.response import Response from video.serializers import ( CommentItemSerializer, @@ -17,6 +21,7 @@ from video.serializers import ( VideoProgressUpdateSerializer, VideoSerializer, ) +from common.src.env_settings import EnvironmentSettings from video.src.index import YoutubeVideo from video.src.query_building import QueryBuilder @@ -287,3 +292,77 @@ class VideoSimilarView(ApiBaseView): self.get_document_list(request, pagination=False) serializer = VideoSerializer(self.response["data"], many=True) return Response(serializer.data) + + +class VideoStreamView(ApiBaseView): + """resolves to /api/video//stream/ + GET: return mp4 stream for playback + """ + + search_base = "ta_video/_doc/" + + @extend_schema( + responses={ + 200: OpenApiResponse(description="video stream"), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + }, + ) + def get(self, request, video_id): + """stream video or transcode to mp4""" + self.get_document(video_id) + if self.status_code == 404: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) + + media_url = self.response.get("media_url") + if not media_url: + error = ErrorResponseSerializer({"error": "video missing"}) + return Response(error.data, status=404) + + media_path = os.path.join(EnvironmentSettings.MEDIA_DIR, media_url) + if not os.path.exists(media_path): + error = ErrorResponseSerializer({"error": "video missing"}) + return Response(error.data, status=404) + + if media_url.endswith(".mp4"): + response = FileResponse(open(media_path, "rb")) + response["Content-Type"] = "video/mp4" + return response + + cache_dir = os.path.join(EnvironmentSettings.CACHE_DIR, "transcode") + os.makedirs(cache_dir, exist_ok=True) + cache_path = os.path.join(cache_dir, f"{video_id}.mp4") + + if not os.path.exists(cache_path): + cmd = [ + "ffmpeg", + "-y", + "-i", + media_path, + "-map", + "0:v:0", + "-map", + "0:a", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-c:a", + "aac", + "-movflags", + "+faststart", + cache_path, + ] + subprocess.run(cmd, check=False) + + if not os.path.exists(cache_path): + error = ErrorResponseSerializer({"error": "transcode failed"}) + return Response(error.data, status=404) + + response = FileResponse(open(cache_path, "rb")) + response["Content-Type"] = "video/mp4" + return response diff --git a/frontend/src/api/loader/loadAppsettingsConfig.ts b/frontend/src/api/loader/loadAppsettingsConfig.ts index 0174a05e..d17432dc 100644 --- a/frontend/src/api/loader/loadAppsettingsConfig.ts +++ b/frontend/src/api/loader/loadAppsettingsConfig.ts @@ -28,6 +28,8 @@ export type AppSettingsConfigType = { integrate_ryd: boolean; integrate_sponsorblock: boolean; audio_multistream: boolean; + audio_languages: string | null; + container: 'mp4' | 'mkv'; }; application: { enable_snapshot: boolean; diff --git a/frontend/src/components/ToggleConfig.tsx b/frontend/src/components/ToggleConfig.tsx index adaf69a7..2c1781b6 100644 --- a/frontend/src/components/ToggleConfig.tsx +++ b/frontend/src/components/ToggleConfig.tsx @@ -2,6 +2,8 @@ type ToggleConfigProps = { name: string; value: boolean; text?: string; + helperText?: string; + disabled?: boolean; updateCallback: (name: string, value: boolean) => void; resetCallback?: (arg0: boolean) => void; onValue?: boolean | string; @@ -12,18 +14,25 @@ const ToggleConfig = ({ name, value, text, + helperText, + disabled = false, updateCallback, resetCallback = undefined, }: ToggleConfigProps) => { return (
{text &&

{text}

} + {helperText &&

{helperText}

}
{ + if (disabled) { + return; + } updateCallback(name, event.target.checked); }} /> diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index b4660dac..17b73cfa 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -183,7 +183,7 @@ const VideoPlayer = ({ const escapePressed = useKeyPress('Escape'); const videoId = video.youtube_id; - const videoUrl = video.media_url; + const videoUrl = `/api/video/${video.youtube_id}/stream/`; const videoThumbUrl = video.vid_thumb_url; const watched = video.player.watched; const duration = video.player.duration; diff --git a/frontend/src/pages/ChannelAbout.tsx b/frontend/src/pages/ChannelAbout.tsx index fe4554fb..cb338aa1 100644 --- a/frontend/src/pages/ChannelAbout.tsx +++ b/frontend/src/pages/ChannelAbout.tsx @@ -47,7 +47,9 @@ const ChannelAbout = () => { const [channelResponse, setChannelResponse] = useState>(); const [downloadFormat, setDownloadFormat] = useState(null); + const [downloadContainer, setDownloadContainer] = useState<'mp4' | 'mkv' | null>(null); const [audioMultistream, setAudioMultistream] = useState(null); + const [audioMultistreamWarning, setAudioMultistreamWarning] = useState(null); const [autoDeleteAfter, setAutoDeleteAfter] = useState(null); const [indexPlaylists, setIndexPlaylists] = useState(false); const [enableSponsorblock, setEnableSponsorblock] = useState(null); @@ -67,9 +69,13 @@ const ChannelAbout = () => { setChannelResponse(channelResponse); setDownloadFormat(channelResponseData?.channel_overwrites?.download_format ?? null); + setDownloadContainer( + channelResponseData?.channel_overwrites?.download_container ?? null, + ); setAudioMultistream( channelResponseData?.channel_overwrites?.audio_multistream ?? null, ); + setAudioMultistreamWarning(null); setAutoDeleteAfter(channelResponseData?.channel_overwrites?.autodelete_days ?? null); setIndexPlaylists(channelResponseData?.channel_overwrites?.index_playlists ?? false); setEnableSponsorblock( @@ -95,11 +101,14 @@ const ChannelAbout = () => { configValue: string | boolean | number | null, ) => { if (!channel) return; - await updateChannelOverwrites(channel.channel_id, configKey, configValue); + const response = await updateChannelOverwrites(channel.channel_id, configKey, configValue); + if (response?.error?.error) { + setAudioMultistreamWarning(response.error.error); + return; + } + setAudioMultistreamWarning(null); if (configKey === 'audio_multistream') { - setAudioMultistream( - configValue === null ? null : Boolean(configValue), - ); + setAudioMultistream(configValue === null ? null : Boolean(configValue)); } setRefresh(true); }; @@ -299,6 +308,31 @@ const ChannelAbout = () => { updateCallback={handleUpdateConfig} />
+
+
+

Download Container

+
+
+ +
+

Enable multistream audio

@@ -306,12 +340,21 @@ const ChannelAbout = () => { { handleUpdateConfig('audio_multistream', null); setAudioMultistream(null); }} /> + {audioMultistreamWarning && ( +

{audioMultistreamWarning}

+ )}
diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index a6c6a106..acf5d6f4 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -20,6 +20,7 @@ import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewS type ChannelOverwritesType = { download_format: string | null; + download_container: 'mp4' | 'mkv' | null; audio_multistream: boolean | null; autodelete_days: number | null; index_playlists: boolean | null; diff --git a/frontend/src/pages/SettingsApplication.tsx b/frontend/src/pages/SettingsApplication.tsx index ef23f49d..b2fd8d34 100644 --- a/frontend/src/pages/SettingsApplication.tsx +++ b/frontend/src/pages/SettingsApplication.tsx @@ -57,6 +57,9 @@ const SettingsApplication = () => { const [downloadsExtractorLang, setDownloadsExtractorLang] = useState(null); const [embedMetadata, setEmbedMetadata] = useState(false); const [audioMultistream, setAudioMultistream] = useState(false); + const [downloadContainer, setDownloadContainer] = useState<'mp4' | 'mkv'>('mp4'); + const [audioLanguages, setAudioLanguages] = useState(null); + const [audioMultistreamWarning, setAudioMultistreamWarning] = useState(null); // Subtitles const [subtitleLang, setSubtitleLang] = useState(null); @@ -114,6 +117,9 @@ const SettingsApplication = () => { setDownloadsExtractorLang(appSettingsConfigData?.downloads.extractor_lang || null); setEmbedMetadata(appSettingsConfigData?.downloads.add_metadata || false); setAudioMultistream(appSettingsConfigData?.downloads.audio_multistream || false); + setAudioLanguages(appSettingsConfigData?.downloads.audio_languages || null); + setDownloadContainer(appSettingsConfigData?.downloads.container || 'mp4'); + setAudioMultistreamWarning(null); // Subtitles setSubtitleLang(appSettingsConfigData?.downloads.subtitle || null); @@ -149,7 +155,12 @@ const SettingsApplication = () => { ) => { const [group, key] = configKey.split('.'); const updatedConfig = { [group]: { [key]: configValue } } as Partial; - await updateAppsettingsConfig(updatedConfig); + const response = await updateAppsettingsConfig(updatedConfig); + if (response?.error?.error) { + setAudioMultistreamWarning(response.error.error); + return; + } + setAudioMultistreamWarning(null); setRefresh(true); }; @@ -514,6 +525,25 @@ const SettingsApplication = () => { updateCallback={handleUpdateConfig} />
+
+
+

Download container

+
+
+ +
+

Embed metadata

@@ -531,9 +561,33 @@ const SettingsApplication = () => { + {audioMultistreamWarning && ( +

{audioMultistreamWarning}

+ )}
+ {audioMultistream && downloadContainer === 'mkv' && ( +
+
+

Audio languages

+
+ +
+ )}

Subtitles

diff --git a/frontend/src/stores/AppSettingsStore.ts b/frontend/src/stores/AppSettingsStore.ts index 41bc4277..3345a0c7 100644 --- a/frontend/src/stores/AppSettingsStore.ts +++ b/frontend/src/stores/AppSettingsStore.ts @@ -24,6 +24,7 @@ export const useAppSettingsStore = create(set => ({ format_sort: null, add_metadata: false, audio_multistream: false, + audio_languages: null, subtitle: null, subtitle_source: null, subtitle_index: false, @@ -35,6 +36,7 @@ export const useAppSettingsStore = create(set => ({ extractor_lang: null, integrate_ryd: false, integrate_sponsorblock: false, + container: 'mp4', }, application: { enable_snapshot: false, diff --git a/frontend/src/style.css b/frontend/src/style.css index 7db5d0d2..b1a979a8 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -247,6 +247,18 @@ button:disabled:hover { text-align: center; } +.settings-help-text { + color: var(--accent-font-light); + font-size: 0.85em; + margin-bottom: 5px; +} + +.settings-error { + color: var(--highlight-error-light); + font-size: 0.9em; + margin-top: 5px; +} + .top-banner { background-image: var(--banner); background-repeat: no-repeat; @@ -279,6 +291,11 @@ button:disabled:hover { align-items: center; } +.toggle input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .toggleBox > input[type='checkbox'] { position: relative; width: 70px;