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": [
|
"runtimeArgs": [
|
||||||
"run",
|
"run",
|
||||||
"dev",
|
"dev",
|
||||||
"--",
|
|
||||||
"--port",
|
"--port",
|
||||||
"8082"
|
"8082"
|
||||||
],
|
],
|
||||||
|
|
@ -21,7 +20,8 @@
|
||||||
"NUXT_API_URL": "http://localhost:8081/api/",
|
"NUXT_API_URL": "http://localhost:8081/api/",
|
||||||
"NUXT_PUBLIC_WSS": ":8081/",
|
"NUXT_PUBLIC_WSS": ":8081/",
|
||||||
},
|
},
|
||||||
"console": "internalConsole"
|
"console": "internalConsole",
|
||||||
|
"outputCapture": "std",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Python: main.py",
|
"name": "Python: main.py",
|
||||||
|
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -23,13 +23,16 @@
|
||||||
"httpx",
|
"httpx",
|
||||||
"libcurl",
|
"libcurl",
|
||||||
"libx",
|
"libx",
|
||||||
|
"matroska",
|
||||||
"mpegts",
|
"mpegts",
|
||||||
|
"msvideo",
|
||||||
"muxdelay",
|
"muxdelay",
|
||||||
"nodesc",
|
"nodesc",
|
||||||
"noprogress",
|
"noprogress",
|
||||||
"postprocessor",
|
"postprocessor",
|
||||||
"preferredcodec",
|
"preferredcodec",
|
||||||
"preferredquality",
|
"preferredquality",
|
||||||
|
"quicktime",
|
||||||
"tmpfilename",
|
"tmpfilename",
|
||||||
"vcodec",
|
"vcodec",
|
||||||
"vconvert",
|
"vconvert",
|
||||||
|
|
|
||||||
|
|
@ -395,7 +395,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
"func": lambda _, msg: logs.append(msg),
|
"func": lambda _, msg: logs.append(msg),
|
||||||
"level": logging.WARNING,
|
"level": logging.WARNING,
|
||||||
},
|
},
|
||||||
**merge_config(self.config.ytdl_options, config),
|
**get_opts(preset, merge_config(self.config.ytdl_options, config)),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cookies:
|
if cookies:
|
||||||
|
|
@ -408,7 +408,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
|
|
||||||
entry = await asyncio.wait_for(
|
entry = await asyncio.wait_for(
|
||||||
fut=asyncio.get_running_loop().run_in_executor(
|
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,
|
timeout=self.config.extract_info_timeout,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ class Events:
|
||||||
TASK_FINISHED = "task_finished"
|
TASK_FINISHED = "task_finished"
|
||||||
TASK_ERROR = "task_error"
|
TASK_ERROR = "task_error"
|
||||||
|
|
||||||
|
PRESETS_ADD = "preset_add"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
class Event:
|
class Event:
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,21 @@ from .ffprobe import ffprobe
|
||||||
from .M3u8 import M3u8
|
from .M3u8 import M3u8
|
||||||
from .Notifications import Notification, NotificationEvents
|
from .Notifications import Notification, NotificationEvents
|
||||||
from .Playlist import Playlist
|
from .Playlist import Playlist
|
||||||
|
from .Presets import Presets
|
||||||
from .Segments import Segments
|
from .Segments import Segments
|
||||||
from .Subtitle import Subtitle
|
from .Subtitle import Subtitle
|
||||||
from .Tasks import Task, Tasks
|
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")
|
LOG = logging.getLogger("http_api")
|
||||||
MIME = magic.Magic(mime=True)
|
MIME = magic.Magic(mime=True)
|
||||||
|
|
@ -108,6 +119,7 @@ class HttpAPI(Common):
|
||||||
HttpAPI: The instance of the HttpAPI.
|
HttpAPI: The instance of the HttpAPI.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
app.middlewares.append(HttpAPI.middle_wares())
|
||||||
if self.config.auth_username and self.config.auth_password:
|
if self.config.auth_username and self.config.auth_password:
|
||||||
app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, 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
|
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:.*}")
|
@route("OPTIONS", "/{path:.*}")
|
||||||
async def add_coors(self, _: Request) -> Response:
|
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)
|
return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
err = str(e).strip()
|
err = str(e).strip()
|
||||||
err = err.split("\n")[-1] if "\n" in err else err
|
err = err.split("\n")[-1] if "\n" in err else err
|
||||||
LOG.error(f"Failed to convert args. '{err}'.")
|
LOG.error(f"Failed to convert args. '{err}'.")
|
||||||
|
LOG.exception(e)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
data={"error": f"Failed to convert args. '{err}'."}, status=web.HTTPBadRequest.status_code
|
data={"error": f"Failed to convert args. '{err}'."}, status=web.HTTPBadRequest.status_code
|
||||||
)
|
)
|
||||||
|
|
@ -553,6 +600,22 @@ class HttpAPI(Common):
|
||||||
dumps=self.encoder.encode,
|
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")
|
@route("GET", "api/tasks")
|
||||||
async def tasks(self, _: Request) -> Response:
|
async def tasks(self, _: Request) -> Response:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1136,6 +1199,49 @@ class HttpAPI(Common):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
|
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")
|
@route("GET", "api/youtube/auth")
|
||||||
async def is_authenticated(self, request: Request) -> Response:
|
async def is_authenticated(self, request: Request) -> Response:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import glob
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from aiohttp.web import HTTPFound, Response
|
from aiohttp.web import HTTPFound, Response
|
||||||
|
|
||||||
from .ffprobe import ffprobe
|
from .ffprobe import ffprobe
|
||||||
from .Subtitle import Subtitle
|
from .Utils import StreamingError, calc_download_path, check_id, get_sidecar_subtitles
|
||||||
from .Utils import StreamingError, calc_download_path, check_id
|
|
||||||
|
|
||||||
|
|
||||||
class Playlist:
|
class Playlist:
|
||||||
|
|
@ -41,28 +38,19 @@ class Playlist:
|
||||||
msg = f"Unable to get '{rFile}' duration."
|
msg = f"Unable to get '{rFile}' duration."
|
||||||
raise StreamingError(msg)
|
raise StreamingError(msg)
|
||||||
|
|
||||||
duration: float = float(ff.metadata.get("duration"))
|
|
||||||
|
|
||||||
playlist = []
|
playlist = []
|
||||||
playlist.append("#EXTM3U")
|
playlist.append("#EXTM3U")
|
||||||
|
|
||||||
subs = ""
|
subs = ""
|
||||||
|
|
||||||
index = 0
|
duration: float = float(ff.metadata.get("duration"))
|
||||||
for item in self.get_sidecar_files(rFile):
|
for sub_file in get_sidecar_subtitles(rFile):
|
||||||
if item.suffix not in Subtitle.allowed_extensions:
|
lang = sub_file["lang"]
|
||||||
continue
|
item = sub_file["file"]
|
||||||
|
name = sub_file["name"]
|
||||||
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")
|
|
||||||
|
|
||||||
subs = ',SUBTITLES="subs"'
|
subs = ',SUBTITLES="subs"'
|
||||||
|
|
||||||
url = f"{self.url}api/player/m3u8/subtitle/{quote(str(Path(file).with_name(item.name)))}.m3u8?duration={duration}"
|
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(
|
playlist.append(
|
||||||
f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{url}"'
|
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")
|
playlist.append(f"{self.url}api/player/m3u8/video/{quote(file)}.m3u8")
|
||||||
|
|
||||||
return "\n".join(playlist)
|
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 copy
|
||||||
|
import glob
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -23,11 +24,12 @@ IGNORED_KEYS: tuple[str] = (
|
||||||
"outtmpl",
|
"outtmpl",
|
||||||
"progress_hooks",
|
"progress_hooks",
|
||||||
"postprocessor_hooks",
|
"postprocessor_hooks",
|
||||||
"format",
|
|
||||||
"download_archive",
|
"download_archive",
|
||||||
)
|
)
|
||||||
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
|
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
|
||||||
|
|
||||||
|
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
|
||||||
|
|
||||||
|
|
||||||
class StreamingError(Exception):
|
class StreamingError(Exception):
|
||||||
"""Raised when an error occurs during streaming."""
|
"""Raised when an error occurs during streaming."""
|
||||||
|
|
@ -45,6 +47,11 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict:
|
||||||
ytdl extra options
|
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)
|
opts = copy.deepcopy(ytdl_opts)
|
||||||
|
|
||||||
if "default" == preset:
|
if "default" == preset:
|
||||||
|
|
@ -520,3 +527,75 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool:
|
||||||
return True
|
return True
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
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
|
file_logging: bool = False
|
||||||
"Enable file logging."
|
"Enable file logging."
|
||||||
|
|
||||||
|
sentry_dsn: str | None = None
|
||||||
|
"The Sentry DSN to use for error reporting."
|
||||||
|
|
||||||
_manual_vars: tuple = (
|
_manual_vars: tuple = (
|
||||||
"temp_path",
|
"temp_path",
|
||||||
"config_path",
|
"config_path",
|
||||||
|
|
@ -200,6 +203,7 @@ class Config:
|
||||||
"basic_mode",
|
"basic_mode",
|
||||||
"default_preset",
|
"default_preset",
|
||||||
"instance_title",
|
"instance_title",
|
||||||
|
"sentry_dsn",
|
||||||
)
|
)
|
||||||
"The variables that are relevant to the frontend."
|
"The variables that are relevant to the frontend."
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
class Encoder(json.JSONEncoder):
|
class Encoder(json.JSONEncoder):
|
||||||
|
|
@ -9,10 +10,14 @@ class Encoder(json.JSONEncoder):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def default(self, o):
|
def default(self, o):
|
||||||
if isinstance(o, object) and hasattr(o, "serialize"):
|
if isinstance(o, Path):
|
||||||
return o.serialize()
|
return str(o)
|
||||||
|
|
||||||
if isinstance(o, object) and hasattr(o, "__dict__"):
|
if isinstance(o, object):
|
||||||
return o.__dict__
|
if hasattr(o, "serialize"):
|
||||||
|
return o.serialize()
|
||||||
|
|
||||||
|
if hasattr(o, "__dict__"):
|
||||||
|
return o.__dict__
|
||||||
|
|
||||||
return json.JSONEncoder.default(self, o)
|
return json.JSONEncoder.default(self, o)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from library.HttpAPI import HttpAPI
|
||||||
from library.HttpSocket import HttpSocket
|
from library.HttpSocket import HttpSocket
|
||||||
from library.Notifications import Notification
|
from library.Notifications import Notification
|
||||||
from library.PackageInstaller import PackageInstaller
|
from library.PackageInstaller import PackageInstaller
|
||||||
|
from library.Presets import Presets
|
||||||
from library.Tasks import Tasks
|
from library.Tasks import Tasks
|
||||||
|
|
||||||
LOG = logging.getLogger("app")
|
LOG = logging.getLogger("app")
|
||||||
|
|
@ -96,6 +97,7 @@ class Main:
|
||||||
self._http.attach(self._app)
|
self._http.attach(self._app)
|
||||||
self._queue.attach(self._app)
|
self._queue.attach(self._app)
|
||||||
Tasks.get_instance().attach(self._app)
|
Tasks.get_instance().attach(self._app)
|
||||||
|
Presets.get_instance().attach(self._app)
|
||||||
|
|
||||||
def started(_):
|
def started(_):
|
||||||
LOG.info("=" * 40)
|
LOG.info("=" * 40)
|
||||||
|
|
|
||||||
|
|
@ -249,3 +249,8 @@ hr {
|
||||||
.play-active {
|
.play-active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-portrait {
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,13 @@
|
||||||
<figure class="image is-3by1">
|
<figure class="image is-3by1">
|
||||||
<span v-if="'finished' === item.status" @click="playVideo(item)" class="play-overlay">
|
<span v-if="'finished' === item.status" @click="playVideo(item)" class="play-overlay">
|
||||||
<div class="play-icon"></div>
|
<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" />
|
<img v-else src="/images/placeholder.png" />
|
||||||
</span>
|
</span>
|
||||||
<template v-else>
|
<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" />
|
<img v-else src="/images/placeholder.png" />
|
||||||
</template>
|
</template>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
@ -233,12 +235,11 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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-background" @click="closeVideo"></div>
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<VideoPlayer type="default" :link="video_link" :isMuted="false" autoplay="true" :isControls="true"
|
<VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="video_item"
|
||||||
:title="video_title" :thumbnail="video_thumbnail" :artist="video_artist" class="is-fullwidth"
|
class="is-fullwidth" @closeModel="closeVideo" />
|
||||||
@closeModel="closeVideo" />
|
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close is-large" aria-label="close" @click="closeVideo"></button>
|
<button class="modal-close is-large" aria-label="close" @click="closeVideo"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -262,36 +263,10 @@ const showCompleted = useStorage('showCompleted', true)
|
||||||
const hideThumbnail = useStorage('hideThumbnailHistory', false)
|
const hideThumbnail = useStorage('hideThumbnailHistory', false)
|
||||||
const direction = useStorage('sortCompleted', 'desc')
|
const direction = useStorage('sortCompleted', 'desc')
|
||||||
|
|
||||||
const video_link = ref('')
|
const video_item = ref(null)
|
||||||
const video_title = ref('')
|
|
||||||
const video_thumbnail = ref('')
|
|
||||||
const video_artist = ref('')
|
|
||||||
|
|
||||||
const playVideo = item => {
|
const playVideo = item => video_item.value = item
|
||||||
video_thumbnail.value = '';
|
const closeVideo = () => video_item.value = null
|
||||||
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 = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(masterSelectAll, (value) => {
|
watch(masterSelectAll, (value) => {
|
||||||
for (const key in stateStore.history) {
|
for (const key in stateStore.history) {
|
||||||
|
|
@ -473,4 +448,6 @@ const reQueueItem = item => {
|
||||||
template: item.template,
|
template: item.template,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="preset" class="is-fullwidth" :disabled="!socket.isConnected || addInProgress"
|
<select id="preset" class="is-fullwidth"
|
||||||
v-model="selectedPreset">
|
: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">
|
<option v-for="item in config.presets" :key="item.name" :value="item.name">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -81,7 +82,7 @@
|
||||||
<label class="label is-inline" for="ytdlpConfig"
|
<label class="label is-inline" for="ytdlpConfig"
|
||||||
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
|
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
|
||||||
JSON yt-dlp config or CLI options.
|
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
|
Convert to JSON
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -93,8 +94,10 @@
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<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
|
<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.
|
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with caution
|
||||||
Warning: Use with caution some of those options can break yt-dlp or the frontend.</span>
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -242,7 +245,15 @@ const convertOptions = async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
convertInProgress.value = true
|
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) {
|
} catch (e) {
|
||||||
toast.error(e.message)
|
toast.error(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -262,4 +273,16 @@ onUnmounted(() => {
|
||||||
socket.off('status', statusHandler)
|
socket.off('status', statusHandler)
|
||||||
socket.off('error', unlockDownload)
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,7 @@
|
||||||
</header>
|
</header>
|
||||||
<div v-if="false === hideThumbnail" class="card-image">
|
<div v-if="false === hideThumbnail" class="card-image">
|
||||||
<figure class="image is-3by1" v-if="item.extras?.thumbnail">
|
<figure class="image is-3by1" v-if="item.extras?.thumbnail">
|
||||||
<img :alt="item.title"
|
<img :alt="item.title" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
|
||||||
:src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
|
|
||||||
</figure>
|
</figure>
|
||||||
<figure class="image is-3by1" v-else>
|
<figure class="image is-3by1" v-else>
|
||||||
<img :src="'/images/placeholder.png'" />
|
<img :src="'/images/placeholder.png'" />
|
||||||
|
|
@ -274,4 +273,6 @@ const cancelItems = item => {
|
||||||
|
|
||||||
items.forEach(id => socket.emit('item_cancel', id));
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="url">
|
<label class="label is-inline" for="url">
|
||||||
Channel or Playlist URL
|
URL
|
||||||
</label>
|
</label>
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="url" class="input" id="url" v-model="form.url" :disabled="addInProgress">
|
<input type="url" class="input" id="url" v-model="form.url" :disabled="addInProgress">
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -69,7 +69,9 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<div class="select is-fullwidth">
|
<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">
|
<option v-for="item in config.presets" :key="item.name" :value="item.name">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -79,7 +81,8 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,8 +141,8 @@
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<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
|
<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.
|
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with caution
|
||||||
Warning: Use with caution some of those options can break yt-dlp or the frontend.</span>
|
some of those options can break yt-dlp or the frontend.</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -278,7 +281,14 @@ const convertOptions = async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
convertInProgress.value = true
|
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) {
|
} catch (e) {
|
||||||
toast.error(e.message)
|
toast.error(e.message)
|
||||||
} finally {
|
} 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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -18,44 +18,41 @@
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<video ref="video" :poster="thumbnail" :controls="isControls" :title="title" playsinline>
|
<video id="player" ref="video" :poster="thumbnail" :title="title" playsinline>
|
||||||
<source :src="link" type="application/x-mpegURL" />
|
<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>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUpdated, ref, defineProps, defineEmits, onUnmounted } from 'vue'
|
import { onMounted, onUpdated, ref, onUnmounted } from 'vue'
|
||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
import Plyr from 'plyr'
|
import Plyr from 'plyr'
|
||||||
import 'plyr/dist/plyr.css'
|
import 'plyr/dist/plyr.css'
|
||||||
|
import { makeDownload } from '~/utils/index'
|
||||||
|
const config = useConfigStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
link: {
|
item: {
|
||||||
type: String,
|
type: Object,
|
||||||
default: ''
|
default: () => ({})
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
thumbnail: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
artist: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
isControls: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitter = defineEmits(['closeModel'])
|
const emitter = defineEmits(['closeModel'])
|
||||||
|
|
||||||
const video = ref(null)
|
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 player = null;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
|
|
||||||
|
|
@ -65,8 +62,57 @@ const eventFunc = e => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
if (/(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)) {
|
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');
|
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,23 +132,23 @@ onUnmounted(() => {
|
||||||
|
|
||||||
window.removeEventListener('keydown', eventFunc)
|
window.removeEventListener('keydown', eventFunc)
|
||||||
|
|
||||||
if (props.title) {
|
if (title.value) {
|
||||||
window.document.title = 'YTPTube'
|
window.document.title = 'YTPTube'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const prepareVideoPlayer = () => {
|
const prepareVideoPlayer = () => {
|
||||||
let mediaMetadata = {
|
let mediaMetadata = {
|
||||||
title: props.title,
|
title: title.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (props.thumbnail) {
|
if (thumbnail.value) {
|
||||||
mediaMetadata['artwork'] = [
|
mediaMetadata['artwork'] = [
|
||||||
{ src: props.thumbnail, sizes: '1920x1080', type: 'image/jpeg' },
|
{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (props.artist) {
|
if (artist.value) {
|
||||||
mediaMetadata['artist'] = props.artist
|
mediaMetadata['artist'] = artist.value
|
||||||
}
|
}
|
||||||
|
|
||||||
let opts = {
|
let opts = {
|
||||||
|
|
@ -121,28 +167,47 @@ const prepareVideoPlayer = () => {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
key: 'plyr'
|
key: 'plyr'
|
||||||
},
|
},
|
||||||
poster: props.thumbnail,
|
artist: artist.value,
|
||||||
artist: props.artist,
|
|
||||||
title: props.title,
|
|
||||||
mediaMetadata: mediaMetadata,
|
mediaMetadata: mediaMetadata,
|
||||||
captions: {
|
captions: {
|
||||||
update: true,
|
update: true,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (props.artist) {
|
if (artist.value) {
|
||||||
opts.artist = props.artist
|
opts.artist = artist.value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.title) {
|
if (title.value) {
|
||||||
opts.title = props.title
|
opts.title = title.value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.thumbnail) {
|
if (thumbnail.value) {
|
||||||
opts.poster = props.thumbnail
|
opts.poster = thumbnail.value
|
||||||
}
|
}
|
||||||
|
|
||||||
player = new Plyr(video.value, opts);
|
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({
|
hls = new Hls({
|
||||||
debug: false,
|
debug: false,
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
|
|
@ -151,14 +216,7 @@ const prepareVideoPlayer = () => {
|
||||||
fragLoadingTimeOut: 200000,
|
fragLoadingTimeOut: 200000,
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.loadSource(props.link)
|
hls.loadSource(link)
|
||||||
|
hls.attachMedia(video.value)
|
||||||
if (video.value) {
|
|
||||||
hls.attachMedia(video.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.title) {
|
|
||||||
window.document.title = `YTPTube - Playing: ${props.title}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ import 'assets/css/style.css'
|
||||||
import 'assets/css/all.css'
|
import 'assets/css/all.css'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
import * as Sentry from "@sentry/nuxt";
|
||||||
|
|
||||||
const Year = new Date().getFullYear()
|
const Year = new Date().getFullYear()
|
||||||
const selectedTheme = useStorage('theme', 'auto')
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
applyPreferredColorScheme(selectedTheme.value)
|
applyPreferredColorScheme(selectedTheme.value)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export default defineNuxtConfig({
|
||||||
domain: '/',
|
domain: '/',
|
||||||
wss: process.env.VUE_APP_BASE_URL ?? '',
|
wss: process.env.VUE_APP_BASE_URL ?? '',
|
||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
|
sentry: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|
@ -60,6 +61,7 @@ export default defineNuxtConfig({
|
||||||
'@pinia/nuxt',
|
'@pinia/nuxt',
|
||||||
'@vueuse/nuxt',
|
'@vueuse/nuxt',
|
||||||
'floating-vue/nuxt',
|
'floating-vue/nuxt',
|
||||||
|
'@sentry/nuxt/module',
|
||||||
],
|
],
|
||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"web-types": "./web-types.json",
|
"web-types": "./web-types.json",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinia/nuxt": "^0.5.1",
|
"@pinia/nuxt": "^0.5.1",
|
||||||
|
"@sentry/nuxt": "^9.2.0",
|
||||||
"@vueuse/core": "^10.9.0",
|
"@vueuse/core": "^10.9.0",
|
||||||
"@vueuse/nuxt": "^10.9.0",
|
"@vueuse/nuxt": "^10.9.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const CONFIG_KEYS = {
|
||||||
basic_mode: true,
|
basic_mode: true,
|
||||||
default_preset: 'default',
|
default_preset: 'default',
|
||||||
instance_title: null,
|
instance_title: null,
|
||||||
|
sentry_dsn: null,
|
||||||
},
|
},
|
||||||
presets: [
|
presets: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -350,7 +350,10 @@ const getQueryParams = (url = window.location.search) => Object.fromEntries(new
|
||||||
* @returns {string} The download URL
|
* @returns {string} The download URL
|
||||||
*/
|
*/
|
||||||
const makeDownload = (config, item, base = 'api/download') => {
|
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) {
|
if (item.folder) {
|
||||||
item.folder = item.folder.replace(/#/g, '%23');
|
item.folder = item.folder.replace(/#/g, '%23');
|
||||||
|
|
@ -400,7 +403,7 @@ const convertCliOptions = async opts => {
|
||||||
throw new Error(`Error: (${response.status}): ${data.error}`)
|
throw new Error(`Error: (${response.status}): ${data.error}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify(data, null, 2)
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
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