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
This commit is contained in:
Dakota Gravitt 2026-02-24 09:33:43 -06:00
parent c7962486f3
commit 6fc2bf36b6
21 changed files with 551 additions and 19 deletions

View File

@ -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(

View File

@ -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,

View File

@ -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"))
}
)

View File

@ -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)

View File

@ -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
)

View File

@ -266,6 +266,7 @@ class YoutubeChannel(YouTubeItem):
"""set per channel overwrites"""
valid_keys = [
"download_format",
"download_container",
"audio_multistream",
"autodelete_days",
"index_playlists",

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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]:

View File

@ -25,6 +25,11 @@ urlpatterns = [
views.VideoCommentView.as_view(),
name="api-video-comment",
),
path(
"<slug:video_id>/stream/",
views.VideoStreamView.as_view(),
name="api-video-stream",
),
path(
"<slug:video_id>/similar/",
views.VideoSimilarView.as_view(),

View File

@ -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/<video_id>/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

View File

@ -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;

View File

@ -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 (
<div className="toggle">
{text && <p>{text}</p>}
{helperText && <p className="settings-help-text">{helperText}</p>}
<div className="toggleBox">
<input
name={name}
type="checkbox"
checked={value}
disabled={disabled}
onChange={event => {
if (disabled) {
return;
}
updateCallback(name, event.target.checked);
}}
/>

View File

@ -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;

View File

@ -47,7 +47,9 @@ const ChannelAbout = () => {
const [channelResponse, setChannelResponse] = useState<ApiResponseType<ChannelResponseType>>();
const [downloadFormat, setDownloadFormat] = useState<string | null>(null);
const [downloadContainer, setDownloadContainer] = useState<'mp4' | 'mkv' | null>(null);
const [audioMultistream, setAudioMultistream] = useState<boolean | null>(null);
const [audioMultistreamWarning, setAudioMultistreamWarning] = useState<string | null>(null);
const [autoDeleteAfter, setAutoDeleteAfter] = useState<number | null>(null);
const [indexPlaylists, setIndexPlaylists] = useState(false);
const [enableSponsorblock, setEnableSponsorblock] = useState<boolean | null>(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}
/>
</div>
<div className="settings-box-wrapper">
<div>
<p>Download Container</p>
</div>
<div>
<select
name="download_container"
value={downloadContainer ?? ''}
onChange={event => {
const value = event.target.value as 'mp4' | 'mkv' | '';
if (value === '') {
handleUpdateConfig('download_container', null);
setDownloadContainer(null);
return;
}
handleUpdateConfig('download_container', value);
setDownloadContainer(value);
}}
>
<option value="">(use global)</option>
<option value="mp4">mp4</option>
<option value="mkv">mkv</option>
</select>
</div>
</div>
<div className="settings-box-wrapper">
<div>
<p>Enable multistream audio</p>
@ -306,12 +340,21 @@ const ChannelAbout = () => {
<ToggleConfig
name="audio_multistream"
value={audioMultistream ?? false}
disabled={(downloadContainer ?? 'mp4') !== 'mkv'}
helperText={
(downloadContainer ?? 'mp4') !== 'mkv'
? 'Choose mkv above (or use the global mkv setting) to enable multiple audio tracks.'
: 'Downloads every available audio language for this channel.'
}
updateCallback={handleUpdateConfig}
resetCallback={() => {
handleUpdateConfig('audio_multistream', null);
setAudioMultistream(null);
}}
/>
{audioMultistreamWarning && (
<p className="settings-error">{audioMultistreamWarning}</p>
)}
</div>
<div className="settings-box-wrapper">
<div>

View File

@ -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;

View File

@ -57,6 +57,9 @@ const SettingsApplication = () => {
const [downloadsExtractorLang, setDownloadsExtractorLang] = useState<string | null>(null);
const [embedMetadata, setEmbedMetadata] = useState(false);
const [audioMultistream, setAudioMultistream] = useState(false);
const [downloadContainer, setDownloadContainer] = useState<'mp4' | 'mkv'>('mp4');
const [audioLanguages, setAudioLanguages] = useState<string | null>(null);
const [audioMultistreamWarning, setAudioMultistreamWarning] = useState<string | null>(null);
// Subtitles
const [subtitleLang, setSubtitleLang] = useState<string | null>(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<AppSettingsConfigType>;
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}
/>
</div>
<div className="settings-box-wrapper">
<div>
<p>Download container</p>
</div>
<div>
<select
name="downloads.container"
value={downloadContainer}
onChange={event => {
const value = event.target.value as 'mp4' | 'mkv';
setDownloadContainer(value);
handleUpdateConfig('downloads.container', value);
}}
>
<option value="mp4">mp4 (browser friendly)</option>
<option value="mkv">mkv (multi-audio)</option>
</select>
</div>
</div>
<div className="settings-box-wrapper">
<div>
<p>Embed metadata</p>
@ -531,9 +561,33 @@ const SettingsApplication = () => {
<ToggleConfig
name="downloads.audio_multistream"
value={audioMultistream}
disabled={downloadContainer !== 'mkv'}
helperText={
downloadContainer !== 'mkv'
? 'Select the mkv container to enable multiple audio tracks.'
: 'Downloads all available audio languages when supported.'
}
updateCallback={handleUpdateConfig}
/>
{audioMultistreamWarning && (
<p className="settings-error">{audioMultistreamWarning}</p>
)}
</div>
{audioMultistream && downloadContainer === 'mkv' && (
<div className="settings-box-wrapper">
<div>
<p>Audio languages</p>
</div>
<InputConfig
type="text"
name="downloads.audio_languages"
value={audioLanguages}
setValue={setAudioLanguages}
oldValue={appSettingsConfig.downloads.audio_languages}
updateCallback={handleUpdateConfig}
/>
</div>
)}
</div>
<div className="info-box-item">
<h2 id="subtitles">Subtitles</h2>

View File

@ -24,6 +24,7 @@ export const useAppSettingsStore = create<AppSettingsState>(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<AppSettingsState>(set => ({
extractor_lang: null,
integrate_ryd: false,
integrate_sponsorblock: false,
container: 'mp4',
},
application: {
enable_snapshot: false,

View File

@ -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;