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:
parent
c7962486f3
commit
6fc2bf36b6
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -266,6 +266,7 @@ class YoutubeChannel(YouTubeItem):
|
|||
"""set per channel overwrites"""
|
||||
valid_keys = [
|
||||
"download_format",
|
||||
"download_container",
|
||||
"audio_multistream",
|
||||
"autodelete_days",
|
||||
"index_playlists",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue