Merge pull request #192 from arabcoders/dev

Try to direct play video first and fallback to HLS if src load fails
This commit is contained in:
Abdulmohsen 2025-02-28 01:53:32 +03:00 committed by GitHub
commit 65dbfc775e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1562 additions and 157 deletions

4
.vscode/launch.json vendored
View file

@ -10,7 +10,6 @@
"runtimeArgs": [
"run",
"dev",
"--",
"--port",
"8082"
],
@ -21,7 +20,8 @@
"NUXT_API_URL": "http://localhost:8081/api/",
"NUXT_PUBLIC_WSS": ":8081/",
},
"console": "internalConsole"
"console": "internalConsole",
"outputCapture": "std",
},
{
"name": "Python: main.py",

View file

@ -23,13 +23,16 @@
"httpx",
"libcurl",
"libx",
"matroska",
"mpegts",
"msvideo",
"muxdelay",
"nodesc",
"noprogress",
"postprocessor",
"preferredcodec",
"preferredquality",
"quicktime",
"tmpfilename",
"vcodec",
"vconvert",

View file

@ -395,7 +395,7 @@ class DownloadQueue(metaclass=Singleton):
"func": lambda _, msg: logs.append(msg),
"level": logging.WARNING,
},
**merge_config(self.config.ytdl_options, config),
**get_opts(preset, merge_config(self.config.ytdl_options, config)),
}
if cookies:
@ -408,7 +408,7 @@ class DownloadQueue(metaclass=Singleton):
entry = await asyncio.wait_for(
fut=asyncio.get_running_loop().run_in_executor(
None, extract_info, get_opts(preset, yt_conf), url, bool(self.config.ytdl_debug)
None, extract_info, yt_conf, url, bool(self.config.ytdl_debug)
),
timeout=self.config.extract_info_timeout,
)

View file

@ -41,6 +41,8 @@ class Events:
TASK_FINISHED = "task_finished"
TASK_ERROR = "task_error"
PRESETS_ADD = "preset_add"
@dataclass(kw_only=True)
class Event:

View file

@ -29,10 +29,21 @@ from .ffprobe import ffprobe
from .M3u8 import M3u8
from .Notifications import Notification, NotificationEvents
from .Playlist import Playlist
from .Presets import Presets
from .Segments import Segments
from .Subtitle import Subtitle
from .Tasks import Task, Tasks
from .Utils import StreamingError, arg_converter, calc_download_path, get_video_info, validate_url, validate_uuid
from .Utils import (
IGNORED_KEYS,
StreamingError,
arg_converter,
calc_download_path,
get_mime_type,
get_sidecar_subtitles,
get_video_info,
validate_url,
validate_uuid,
)
LOG = logging.getLogger("http_api")
MIME = magic.Magic(mime=True)
@ -108,6 +119,7 @@ class HttpAPI(Common):
HttpAPI: The instance of the HttpAPI.
"""
app.middlewares.append(HttpAPI.middle_wares())
if self.config.auth_username and self.config.auth_password:
app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, self.config.auth_password))
@ -299,6 +311,24 @@ class HttpAPI(Common):
return middleware_handler
@staticmethod
def middle_wares() -> Awaitable:
@web.middleware
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
response = await handler(request)
if isinstance(response, web.FileResponse):
try:
ff_info = await ffprobe(response._path)
mime_type = get_mime_type(ff_info.get("metadata", {}), response._path)
response.content_type = mime_type
except Exception:
pass
return response
return middleware_handler
@route("OPTIONS", "/{path:.*}")
async def add_coors(self, _: Request) -> Response:
"""
@ -347,11 +377,28 @@ class HttpAPI(Common):
return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code)
try:
return web.json_response(data=arg_converter(args), status=web.HTTPOk.status_code)
response = {"opts": {}, "output_template": None, "download_path": None}
data = arg_converter(args)
if "outtmpl" in data and "default" in data["outtmpl"]:
response["output_template"] = data["outtmpl"]["default"]
if "paths" in data and "home" in data["paths"]:
response["download_path"] = data["paths"]["home"]
for key in data:
if key in IGNORED_KEYS:
continue
if not key.startswith("_"):
response["opts"][key] = data[key]
return web.json_response(data=response, status=web.HTTPOk.status_code)
except Exception as e:
err = str(e).strip()
err = err.split("\n")[-1] if "\n" in err else err
LOG.error(f"Failed to convert args. '{err}'.")
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to convert args. '{err}'."}, status=web.HTTPBadRequest.status_code
)
@ -553,6 +600,22 @@ class HttpAPI(Common):
dumps=self.encoder.encode,
)
@route("GET", "api/presets")
async def presets(self, _: Request) -> Response:
"""
Get the presets.
Args:
_: The request object.
Returns:
Response: The response object.
"""
return web.json_response(
data=Presets.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=self.encoder.encode
)
@route("GET", "api/tasks")
async def tasks(self, _: Request) -> Response:
"""
@ -1136,6 +1199,49 @@ class HttpAPI(Common):
except Exception as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
@route("GET", "api/file/info/{file:.*}")
async def get_file_info(self, request: Request) -> Response:
"""
Get file info
Args:
request (Request): The request object.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
if not file:
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
try:
realFile: str = calc_download_path(base_path=self.config.download_path, folder=file, create_path=False)
if not os.path.exists(realFile) or not os.path.isfile(realFile):
return web.json_response(
data={"error": f"File '{file}' does not exist."}, status=web.HTTPNotFound.status_code
)
realFile = Path(realFile)
ff_info = await ffprobe(realFile)
response = {
"ffprobe": ff_info,
"mimetype": get_mime_type(ff_info.get("metadata", {}), realFile),
"sidecar": get_sidecar_subtitles(realFile),
}
for i, f in enumerate(response["sidecar"]):
response["sidecar"][i]["file"] = (
str(Path(realFile).with_name(f["file"].name)).replace(self.config.download_path, "").strip("/")
)
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
except Exception as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
@route("GET", "api/youtube/auth")
async def is_authenticated(self, request: Request) -> Response:
"""

View file

@ -1,13 +1,10 @@
import glob
import re
from pathlib import Path
from urllib.parse import quote
from aiohttp.web import HTTPFound, Response
from .ffprobe import ffprobe
from .Subtitle import Subtitle
from .Utils import StreamingError, calc_download_path, check_id
from .Utils import StreamingError, calc_download_path, check_id, get_sidecar_subtitles
class Playlist:
@ -41,28 +38,19 @@ class Playlist:
msg = f"Unable to get '{rFile}' duration."
raise StreamingError(msg)
duration: float = float(ff.metadata.get("duration"))
playlist = []
playlist.append("#EXTM3U")
subs = ""
index = 0
for item in self.get_sidecar_files(rFile):
if item.suffix not in Subtitle.allowed_extensions:
continue
index += 1
lang: str = "und"
lg = re.search(r"\.(?P<lang>\w{2,3})\.\w{3}$", item.name)
if lg:
lang = lg.groupdict().get("lang")
duration: float = float(ff.metadata.get("duration"))
for sub_file in get_sidecar_subtitles(rFile):
lang = sub_file["lang"]
item = sub_file["file"]
name = sub_file["name"]
subs = ',SUBTITLES="subs"'
url = f"{self.url}api/player/m3u8/subtitle/{quote(str(Path(file).with_name(item.name)))}.m3u8?duration={duration}"
name = f"{item.suffix[1:].upper()} ({index}) - {lang}"
playlist.append(
f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{url}"'
)
@ -71,20 +59,3 @@ class Playlist:
playlist.append(f"{self.url}api/player/m3u8/video/{quote(file)}.m3u8")
return "\n".join(playlist)
def get_sidecar_files(self, file: Path) -> list[Path]:
"""
Get sidecar files for the given file.
:param file: File to get sidecar files for.
:return: List of sidecar files.
"""
files = []
for sub_file in file.parent.glob(f"{glob.escape(file.stem)}.*"):
if sub_file == file or sub_file.is_file() is False or sub_file.stem.startswith("."):
continue
files.append(sub_file)
return files

234
app/library/Presets.py Normal file
View file

@ -0,0 +1,234 @@
import asyncio
import json
import logging
import os
from dataclasses import dataclass, field
from typing import Any
from aiohttp import web
from .config import Config
from .Emitter import Emitter
from .encoder import Encoder
from .EventsSubscriber import Event, Events, EventsSubscriber
from .Singleton import Singleton
LOG = logging.getLogger("presets")
@dataclass(kw_only=True)
class Preset:
name: str
"""The name of the preset."""
format: str
"""The format of the preset."""
args: dict[str, list[str] | bool] | None = field(default_factory=dict)
"""The arguments of the preset."""
postprocessors: list | None = field(default_factory=list)
"""The postprocessors of the preset."""
def serialize(self) -> dict:
return self.__dict__
def json(self) -> str:
return Encoder().encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any:
return self.serialize().get(key, default)
class Presets(metaclass=Singleton):
"""
This class is used to manage the presets.
"""
_presets: list[Preset] = []
"""The list of presets."""
_instance = None
"""The instance of the class."""
def __init__(
self,
file: str | None = None,
emitter: Emitter | None = None,
loop: asyncio.AbstractEventLoop | None = None,
config: Config | None = None,
):
Presets._instance = self
config = config or Config.get_instance()
self._file: str = file or os.path.join(config.config_path, "presets.json")
self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
self._emitter: Emitter = emitter or Emitter.get_instance()
if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]:
try:
os.chmod(self._file, 0o600)
except Exception:
pass
def handle_event(_, e: Event):
self.save(**e.data)
EventsSubscriber.get_instance().subscribe(Events.PRESETS_ADD, f"{__class__}.save", handle_event)
@staticmethod
def get_instance() -> "Presets":
"""
Get the instance of the class.
Returns:
Presets: The instance of the class
"""
if not Presets._instance:
Presets._instance = Presets()
return Presets._instance
async def on_shutdown(self, _: web.Application):
pass
def attach(self, _: web.Application):
"""
Attach the work to the aiohttp application.
Args:
_ (web.Application): The aiohttp application.
Returns:
None
"""
self.load()
def get_all(self) -> list[Preset]:
"""Return the presets."""
return self._presets
def load(self) -> "Presets":
"""
Load the Presets.
Returns:
Presets: The current instance.
"""
self.clear()
if not os.path.exists(self._file) or os.path.getsize(self._file) < 10:
return self
LOG.info(f"Loading presets from '{self._file}'.")
try:
with open(self._file) as f:
presets = json.load(f)
except Exception as e:
LOG.error(f"Failed to parse presets from '{self._file}'. '{e}'.")
return self
if not presets or len(presets) < 1:
LOG.info(f"No presets were defined in '{self._file}'.")
return self
for i, preset in enumerate(presets):
try:
preset = Preset(**preset)
self._presets.append(preset)
except Exception as e:
LOG.error(f"Failed to parse preset at list position '{i}'. '{e!s}'.")
continue
return self
def clear(self) -> "Presets":
"""
Clear all presets
Returns:
Presets: The current instance.
"""
if len(self._presets) < 1:
return self
self._presets.clear()
return self
def validate(self, preset: Preset | dict) -> bool:
"""
Validate the preset.
Args:
preset (Preset|dict): The preset to validate.
Returns:
bool: True if the preset is valid, False otherwise.
"""
if not isinstance(preset, dict):
if not isinstance(preset, Preset):
msg = "Invalid preset type."
raise ValueError(msg) # noqa: TRY004
preset = preset.serialize()
if not preset.get("name"):
msg = "No name found."
raise ValueError(msg)
if not preset.get("format"):
msg = "No format found."
raise ValueError(msg)
if preset.get("args") and not isinstance(preset.get("args"), dict):
msg = "Invalid args type. expected dict."
raise ValueError(msg)
if preset.get("postprocessors") and not isinstance(preset.get("postprocessors"), list):
msg = "Invalid postprocessors type. expected list."
raise ValueError(msg)
return True
def save(self, presets: list[Preset | dict]) -> "Presets":
"""
Save the presets.
Args:
presets (list[Preset]): The presets to save.
Returns:
Presets: The current instance.
"""
for i, preset in enumerate(presets):
try:
if not isinstance(preset, Preset):
preset = Preset(**preset)
presets[i] = preset
except Exception as e:
LOG.error(f"Failed to save preset '{i}' due to parsing error. '{e!s}'.")
continue
try:
self.validate(preset)
except ValueError as e:
LOG.error(f"Failed to validate preset '{i}: {preset.name}'. '{e}'.")
continue
try:
with open(self._file, "w") as f:
json.dump(obj=[preset.serialize() for preset in presets], fp=f, indent=4)
LOG.info(f"Presets saved to '{self._file}'.")
except Exception as e:
LOG.error(f"Failed to save presets to '{self._file}'. '{e!s}'.")
return self

View file

@ -1,4 +1,5 @@
import copy
import glob
import ipaddress
import json
import logging
@ -23,11 +24,12 @@ IGNORED_KEYS: tuple[str] = (
"outtmpl",
"progress_hooks",
"postprocessor_hooks",
"format",
"download_archive",
)
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
class StreamingError(Exception):
"""Raised when an error occurs during streaming."""
@ -45,6 +47,11 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict:
ytdl extra options
"""
if "format" in ytdl_opts and len(ytdl_opts["format"]) > 2:
format = ytdl_opts["format"]
LOG.info(f"Format '{format}' was given via yt-dlp options. Therefore, the preset will be ignored.")
return ytdl_opts
opts = copy.deepcopy(ytdl_opts)
if "default" == preset:
@ -520,3 +527,75 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool:
return True
except ValueError:
return False
def get_sidecar_subtitles(file: pathlib.Path) -> list[dict]:
"""
Get sidecar files for the given file.
:param file: File to get sidecar files for.
:return: List of sidecar files.
"""
files = []
for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")):
if f == file or f.is_file() is False or f.stem.startswith("."):
continue
if f.suffix not in ALLOWED_SUBS_EXTENSIONS:
continue
if f.stat().st_size < 1:
continue
lg = re.search(r"\.(?P<lang>\w{2,3})\.\w{3}$", f.name)
lang = lg.groupdict().get("lang") if lg else "und"
files.append({"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"})
return files
def get_mime_type(metadata: dict, file_path: pathlib.Path) -> str:
"""
Determine the correct MIME type for a video file based on ffprobe metadata.
Args:
metadata (dict): Parsed JSON output from ffprobe.
file_path (str): The path to the video file for fallback detection.
Returns:
str: MIME type compatible with HTML5 <video> tag.
"""
# Extract format name from ffprobe
format_name = metadata.get("format_name", "")
# Define mappings for HTML5-compatible video types
format_to_mime = {
"matroska": "video/x-matroska", # Default for MKV
"webm": "video/webm", # MKV can also be WebM
"mp4": "video/mp4",
"mpegts": "video/mp2t",
}
# Check format_name against known formats
if format_name:
selected = None
for fmt in format_name.split(","):
fmt = fmt.strip().lower()
if fmt in format_to_mime:
selected = format_to_mime[fmt]
if selected:
return selected
# Fallback: Use Python's mimetypes module
import mimetypes
mime_type, _ = mimetypes.guess_type(str(file_path))
if mime_type:
return mime_type
# Final fallback: Return generic binary type
return "application/octet-stream"

View file

@ -143,6 +143,9 @@ class Config:
file_logging: bool = False
"Enable file logging."
sentry_dsn: str | None = None
"The Sentry DSN to use for error reporting."
_manual_vars: tuple = (
"temp_path",
"config_path",
@ -200,6 +203,7 @@ class Config:
"basic_mode",
"default_preset",
"instance_title",
"sentry_dsn",
)
"The variables that are relevant to the frontend."

View file

@ -1,4 +1,5 @@
import json
from pathlib import Path
class Encoder(json.JSONEncoder):
@ -9,10 +10,14 @@ class Encoder(json.JSONEncoder):
"""
def default(self, o):
if isinstance(o, object) and hasattr(o, "serialize"):
return o.serialize()
if isinstance(o, Path):
return str(o)
if isinstance(o, object) and hasattr(o, "__dict__"):
return o.__dict__
if isinstance(o, object):
if hasattr(o, "serialize"):
return o.serialize()
if hasattr(o, "__dict__"):
return o.__dict__
return json.JSONEncoder.default(self, o)

View file

@ -17,6 +17,7 @@ from library.HttpAPI import HttpAPI
from library.HttpSocket import HttpSocket
from library.Notifications import Notification
from library.PackageInstaller import PackageInstaller
from library.Presets import Presets
from library.Tasks import Tasks
LOG = logging.getLogger("app")
@ -96,6 +97,7 @@ class Main:
self._http.attach(self._app)
self._queue.attach(self._app)
Tasks.get_instance().attach(self._app)
Presets.get_instance().attach(self._app)
def started(_):
LOG.info("=" * 40)

View file

@ -249,3 +249,8 @@ hr {
.play-active {
opacity: 1;
}
.image-portrait {
object-fit: cover;
object-position: top;
}

View file

@ -99,11 +99,13 @@
<figure class="image is-3by1">
<span v-if="'finished' === item.status" @click="playVideo(item)" class="play-overlay">
<div class="play-icon"></div>
<img :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" v-if="item.extras?.thumbnail" />
<img @load="e => pImg(e)" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)"
v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" />
</span>
<template v-else>
<img v-if="item.extras?.thumbnail" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
<img @load="e => pImg(e)" v-if="item.extras?.thumbnail"
:src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
<img v-else src="/images/placeholder.png" />
</template>
</figure>
@ -233,12 +235,11 @@
</p>
</div>
<div class="modal is-active" v-if="video_link">
<div class="modal is-active" v-if="video_item">
<div class="modal-background" @click="closeVideo"></div>
<div class="modal-content">
<VideoPlayer type="default" :link="video_link" :isMuted="false" autoplay="true" :isControls="true"
:title="video_title" :thumbnail="video_thumbnail" :artist="video_artist" class="is-fullwidth"
@closeModel="closeVideo" />
<VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="video_item"
class="is-fullwidth" @closeModel="closeVideo" />
</div>
<button class="modal-close is-large" aria-label="close" @click="closeVideo"></button>
</div>
@ -262,36 +263,10 @@ const showCompleted = useStorage('showCompleted', true)
const hideThumbnail = useStorage('hideThumbnailHistory', false)
const direction = useStorage('sortCompleted', 'desc')
const video_link = ref('')
const video_title = ref('')
const video_thumbnail = ref('')
const video_artist = ref('')
const video_item = ref(null)
const playVideo = item => {
video_thumbnail.value = '';
video_artist.value = '';
video_link.value = makeDownload(config, item, 'm3u8')
video_title.value = item.title
if (item.extras?.thumbnail) {
video_thumbnail.value = '/api/thumbnail?url=' + encodePath(item.extras.thumbnail)
}
if (item.extras?.channel) {
video_artist.value = item.extras.channel
}
if (!video_artist.value && item.extras?.uploader) {
video_artist.value = item.extras.uploader
}
if (!item.extras?.is_video && item.extras?.is_audio) {
video_audio.value = true
}
}
const closeVideo = () => {
video_link.value = ''
video_title.value = ''
video_thumbnail.value = ''
video_artist.value = ''
}
const playVideo = item => video_item.value = item
const closeVideo = () => video_item.value = null
watch(masterSelectAll, (value) => {
for (const key in stateStore.history) {
@ -473,4 +448,6 @@ const reQueueItem = item => {
template: item.template,
})
}
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
</script>

View file

@ -20,8 +20,9 @@
</div>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select id="preset" class="is-fullwidth" :disabled="!socket.isConnected || addInProgress"
v-model="selectedPreset">
<select id="preset" class="is-fullwidth"
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="selectedPreset"
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the config.' : ''">
<option v-for="item in config.presets" :key="item.name" :value="item.name">
{{ item.name }}
</option>
@ -81,7 +82,7 @@
<label class="label is-inline" for="ytdlpConfig"
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config or CLI options.
<NuxtLink v-if="ytdlpConfig && !ytdlpConfig.trim().startsWith('{')" @click="convertOptions()">
<NuxtLink v-if="ytdlpConfig && ytdlpConfig.trim() && !ytdlpConfig.trim().startsWith('{')" @click="convertOptions()">
Convert to JSON
</NuxtLink>
</label>
@ -93,8 +94,10 @@
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Extends current global yt-dlp config with given options. Some fields are ignored like
<code>format</code> <code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc.
Warning: Use with caution some of those options can break yt-dlp or the frontend.</span>
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with caution
some of those options can break yt-dlp or the frontend. If <code>Format</code> key is present
in the config, <span class="has-text-danger">the preset and all it's options will be
ignored</span>.</span>
</span>
</div>
</div>
@ -242,7 +245,15 @@ const convertOptions = async () => {
try {
convertInProgress.value = true
ytdlpConfig.value = await convertCliOptions(ytdlpConfig.value)
const response = await convertCliOptions(ytdlpConfig.value)
ytdlpConfig.value = JSON.stringify(response.opts, null, 2)
if (response.output_template) {
output_template.value = response.output_template
}
if (response.download_path) {
downloadPath.value = response.download_path
}
} catch (e) {
toast.error(e.message)
} finally {
@ -262,4 +273,16 @@ onUnmounted(() => {
socket.off('status', statusHandler)
socket.off('error', unlockDownload)
})
const hasFormatInConfig = computed(() => {
if (!ytdlpConfig.value) {
return false
}
try {
const config = JSON.parse(ytdlpConfig.value)
return "format" in config
} catch (e) {
return false
}
})
</script>

View file

@ -53,8 +53,7 @@
</header>
<div v-if="false === hideThumbnail" class="card-image">
<figure class="image is-3by1" v-if="item.extras?.thumbnail">
<img :alt="item.title"
:src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
<img :alt="item.title" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
</figure>
<figure class="image is-3by1" v-else>
<img :src="'/images/placeholder.png'" />
@ -274,4 +273,6 @@ const cancelItems = item => {
items.forEach(id => socket.emit('item_cancel', id));
}
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
</script>

View file

@ -32,7 +32,7 @@
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="url">
Channel or Playlist URL
URL
</label>
<div class="control has-icons-left">
<input type="url" class="input" id="url" v-model="form.url" :disabled="addInProgress">
@ -40,7 +40,7 @@
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The YouTube channel or playlist URL</span>
<span>The channel or playlist URL.</span>
</span>
</div>
</div>
@ -69,7 +69,9 @@
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="preset" class="is-fullwidth" v-model="form.preset" :disabled="addInProgress">
<select id="preset" class="is-fullwidth" v-model="form.preset"
:disabled="addInProgress || hasFormatInConfig"
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the config.' : ''">
<option v-for="item in config.presets" :key="item.name" :value="item.name">
{{ item.name }}
</option>
@ -79,7 +81,8 @@
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Select the preset to use for this URL.</span>
<span>Select the preset to use for this URL. The preset will be ignored if format key is present in
config.</span>
</span>
</div>
</div>
@ -138,8 +141,8 @@
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span> Extends current global yt-dlp config with given options. Some fields are ignored like
<code>format</code> <code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc.
Warning: Use with caution some of those options can break yt-dlp or the frontend.</span>
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with caution
some of those options can break yt-dlp or the frontend.</span>
</span>
</div>
</div>
@ -278,7 +281,14 @@ const convertOptions = async () => {
try {
convertInProgress.value = true
form.config = await convertCliOptions(form.config)
const response = await convertCliOptions(form.config)
form.config = JSON.stringify(response.opts, null, 2)
if (response.output_template) {
form.template = response.output_template
}
if (response.download_path) {
form.folder = response.download_path
}
} catch (e) {
toast.error(e.message)
} finally {
@ -286,4 +296,15 @@ const convertOptions = async () => {
}
}
const hasFormatInConfig = computed(() => {
if (!form.config) {
return false
}
try {
const config = JSON.parse(form.config)
return "format" in config
} catch (e) {
return false
}
})
</script>

View file

@ -18,44 +18,41 @@
<template>
<div>
<video ref="video" :poster="thumbnail" :controls="isControls" :title="title" playsinline>
<source :src="link" type="application/x-mpegURL" />
<video id="player" ref="video" :poster="thumbnail" :title="title" playsinline>
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
:type="source.type" />
<track v-for="track in tracks" :key="track.file" :kind="track.kind" :label="track.label" :srclang="track.lang"
:src="track.file" default />
</video>
</div>
</template>
<script setup>
import { onMounted, onUpdated, ref, defineProps, defineEmits, onUnmounted } from 'vue'
import { onMounted, onUpdated, ref, onUnmounted } from 'vue'
import Hls from 'hls.js'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
import { makeDownload } from '~/utils/index'
const config = useConfigStore()
const props = defineProps({
link: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
thumbnail: {
type: String,
default: ''
},
artist: {
type: String,
default: ''
},
isControls: {
type: Boolean,
default: true
item: {
type: Object,
default: () => ({})
},
})
const emitter = defineEmits(['closeModel'])
const video = ref(null)
const tracks = ref([])
const sources = ref([])
const thumbnail = ref('')
const artist = ref('')
const title = ref('')
const isAudio = ref(false)
let player = null;
let hls = null;
@ -65,8 +62,57 @@ const eventFunc = e => {
}
}
onMounted(() => {
if (/(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)) {
onMounted(async () => {
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
const response = await (await request(makeDownload(config, props.item, 'api/file/info'))).json()
if (props.item.extras?.thumbnail) {
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
}
// -- check if mimetype is video/mp4 and device is apple
// -- as always apple, apple like to be snowflakes.
if (isApple) {
const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4')
sources.value.push({
src: makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8'),
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
onerror: e => src_error(e),
})
} else {
sources.value.push({
src: makeDownload(config, props.item, 'api/download'),
type: response.mimetype,
onerror: e => src_error(e),
})
}
if (props.item.extras?.channel) {
artist.value = props.item.extras.channel
}
if (!artist.value && props.item.extras?.uploader) {
artist.value = props.item.extras.uploader
}
if (props.item?.title) {
title.value = props.item.title
}
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
isAudio.value = true
}
response.sidecar.forEach((cap, id) => {
tracks.value.push({
kind: "captions",
label: cap.name,
lang: cap.lang,
file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt`
})
})
if (isApple) {
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
}
@ -86,23 +132,23 @@ onUnmounted(() => {
window.removeEventListener('keydown', eventFunc)
if (props.title) {
if (title.value) {
window.document.title = 'YTPTube'
}
})
const prepareVideoPlayer = () => {
let mediaMetadata = {
title: props.title,
title: title.value,
};
if (props.thumbnail) {
if (thumbnail.value) {
mediaMetadata['artwork'] = [
{ src: props.thumbnail, sizes: '1920x1080', type: 'image/jpeg' },
{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' },
]
}
if (props.artist) {
mediaMetadata['artist'] = props.artist
if (artist.value) {
mediaMetadata['artist'] = artist.value
}
let opts = {
@ -121,28 +167,47 @@ const prepareVideoPlayer = () => {
enabled: true,
key: 'plyr'
},
poster: props.thumbnail,
artist: props.artist,
title: props.title,
artist: artist.value,
mediaMetadata: mediaMetadata,
captions: {
update: true,
}
},
};
if (props.artist) {
opts.artist = props.artist
if (artist.value) {
opts.artist = artist.value
}
if (props.title) {
opts.title = props.title
if (title.value) {
opts.title = title.value
}
if (props.thumbnail) {
opts.poster = props.thumbnail
if (thumbnail.value) {
opts.poster = thumbnail.value
}
player = new Plyr(video.value, opts);
player.source = {
type: isAudio.value ? 'audio' : 'video',
title: title.value,
poster: thumbnail.value,
};
if (title.value) {
window.document.title = `YTPTube - Playing: ${title.value}`
}
}
const src_error = () => {
if (hls) {
return
}
console.warn('Direct play failed, trying HLS.');
attach_hls(makeDownload(config, props.item, 'm3u8'));
}
const attach_hls = link => {
hls = new Hls({
debug: false,
enableWorker: true,
@ -151,14 +216,7 @@ const prepareVideoPlayer = () => {
fragLoadingTimeOut: 200000,
});
hls.loadSource(props.link)
if (video.value) {
hls.attachMedia(video.value)
}
if (props.title) {
window.document.title = `YTPTube - Playing: ${props.title}`
}
hls.loadSource(link)
hls.attachMedia(video.value)
}
</script>

View file

@ -93,6 +93,7 @@ import 'assets/css/style.css'
import 'assets/css/all.css'
import { useStorage } from '@vueuse/core'
import moment from 'moment'
import * as Sentry from "@sentry/nuxt";
const Year = new Date().getFullYear()
const selectedTheme = useStorage('theme', 'auto')
@ -144,6 +145,14 @@ const applyPreferredColorScheme = scheme => {
}
}
watch(() => config.app.sentry_dsn, dsn => {
if (!dsn) {
return
}
console.warn('Loading sentry module.')
Sentry.init({ dsn: dsn })
})
onMounted(async () => {
try {
applyPreferredColorScheme(selectedTheme.value)

View file

@ -33,6 +33,7 @@ export default defineNuxtConfig({
domain: '/',
wss: process.env.VUE_APP_BASE_URL ?? '',
version: '2.0.0',
sentry: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
}
},
build: {
@ -60,6 +61,7 @@ export default defineNuxtConfig({
'@pinia/nuxt',
'@vueuse/nuxt',
'floating-vue/nuxt',
'@sentry/nuxt/module',
],
nitro: {

View file

@ -12,6 +12,7 @@
"web-types": "./web-types.json",
"dependencies": {
"@pinia/nuxt": "^0.5.1",
"@sentry/nuxt": "^9.2.0",
"@vueuse/core": "^10.9.0",
"@vueuse/nuxt": "^10.9.0",
"@xterm/addon-fit": "^0.10.0",

View file

@ -15,6 +15,7 @@ const CONFIG_KEYS = {
basic_mode: true,
default_preset: 'default',
instance_title: null,
sentry_dsn: null,
},
presets: [
{

View file

@ -350,7 +350,10 @@ const getQueryParams = (url = window.location.search) => Object.fromEntries(new
* @returns {string} The download URL
*/
const makeDownload = (config, item, base = 'api/download') => {
let baseDir = 'api/download' === base ? `${base}/` : 'api/player/playlist/';
let baseDir = 'api/player/m3u8/video/';
if ('m3u8' !== base) {
baseDir = `${base}/`;
}
if (item.folder) {
item.folder = item.folder.replace(/#/g, '%23');
@ -400,7 +403,7 @@ const convertCliOptions = async opts => {
throw new Error(`Error: (${response.status}): ${data.error}`)
}
return JSON.stringify(data, null, 2)
return data
}
export {

File diff suppressed because it is too large Load diff