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:
commit
65dbfc775e
23 changed files with 1562 additions and 157 deletions
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -23,13 +23,16 @@
|
|||
"httpx",
|
||||
"libcurl",
|
||||
"libx",
|
||||
"matroska",
|
||||
"mpegts",
|
||||
"msvideo",
|
||||
"muxdelay",
|
||||
"nodesc",
|
||||
"noprogress",
|
||||
"postprocessor",
|
||||
"preferredcodec",
|
||||
"preferredquality",
|
||||
"quicktime",
|
||||
"tmpfilename",
|
||||
"vcodec",
|
||||
"vconvert",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ class Events:
|
|||
TASK_FINISHED = "task_finished"
|
||||
TASK_ERROR = "task_error"
|
||||
|
||||
PRESETS_ADD = "preset_add"
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Event:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
234
app/library/Presets.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -249,3 +249,8 @@ hr {
|
|||
.play-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-portrait {
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const CONFIG_KEYS = {
|
|||
basic_mode: true,
|
||||
default_preset: 'default',
|
||||
instance_title: null,
|
||||
sentry_dsn: null,
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
918
ui/yarn.lock
918
ui/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue