Merge pull request #162 from arabcoders/dev

updates API endpoints, fix webhooks not firing due to an error.
This commit is contained in:
Abdulmohsen 2025-01-03 20:41:03 +03:00 committed by GitHub
commit 9adf48e404
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 153 additions and 105 deletions

2
.vscode/launch.json vendored
View file

@ -40,7 +40,7 @@
"YTP_PIP_IGNORE_UPDATES": "true", "YTP_PIP_IGNORE_UPDATES": "true",
"YTP_LOG_LEVEL": "DEBUG", "YTP_LOG_LEVEL": "DEBUG",
"YTP_DEBUG": "true", "YTP_DEBUG": "true",
"YTP_YTDL_DEBUG": "true", "YTP_YTDL_DEBUG": "false",
} }
}, },
{ {

View file

@ -8,7 +8,7 @@ YTPTube started as a fork of [meTube](https://github.com/alexta69/metube), Since
# YTPTube Features. # YTPTube Features.
* A built in video player that can play any video file regardless of the format. **With support for sidecar external subtitles**. * A built in video player that can play any video file regardless of the format. **With support for sidecar external subtitles**.
* New `/add_batch` endpoint that allow multiple links to be sent. * New `/api/add_batch` endpoint that allow multiple links to be sent.
* Completely redesigned the frontend UI. * Completely redesigned the frontend UI.
* Switched out of binary file storage in favor of SQLite. * Switched out of binary file storage in favor of SQLite.
* Handle live streams. * Handle live streams.

View file

@ -14,6 +14,7 @@ from .config import Config
from .Emitter import Emitter from .Emitter import Emitter
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Utils import get_opts, jsonCookie, mergeConfig from .Utils import get_opts, jsonCookie, mergeConfig
from .ffprobe import ffprobe
LOG = logging.getLogger("download") LOG = logging.getLogger("download")
@ -161,10 +162,10 @@ class Download:
) )
LOG.info( LOG.info(
f'Downloading pid: {os.getpid()} id="{self.info.id}" title="{self.info.title}" preset="{self.preset}".' f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" preset="{self.preset}" started.'
) )
LOG.debug(f"Params before passing to yt-dlp: {params}") LOG.debug("Params before passing to yt-dlp.", extra=params)
cls = yt_dlp.YoutubeDL(params=params) cls = yt_dlp.YoutubeDL(params=params)
@ -180,7 +181,7 @@ class Download:
except Exception as exc: except Exception as exc:
self.status_queue.put({"id": self.id, "status": "error", "msg": str(exc), "error": str(exc)}) self.status_queue.put({"id": self.id, "status": "error", "msg": str(exc), "error": str(exc)})
LOG.info(f'Finished {os.getpid()=} id="{self.info.id}" title="{self.info.title}".') LOG.info(f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" completed.')
async def start(self, emitter: Emitter): async def start(self, emitter: Emitter):
self.manager = multiprocessing.Manager() self.manager = multiprocessing.Manager()
@ -364,4 +365,13 @@ class Download:
self.info.file_size = None self.info.file_size = None
pass pass
try:
ff = await ffprobe(status.get("filename"))
self.info.extras['is_video'] = ff.has_video()
self.info.extras['is_audio'] = ff.has_audio()
except Exception as e:
self.info.extras['is_video'] = True
self.info.extras['is_audio'] = True
LOG.exception(f"Failed to ffprobe: {status.get('filename')}. {e}")
asyncio.create_task(self.emitter.updated(dl=self.info), name=f"emitter-u-{self.id}") asyncio.create_task(self.emitter.updated(dl=self.info), name=f"emitter-u-{self.id}")

View file

@ -22,7 +22,8 @@ from .M3u8 import M3u8
from .Playlist import Playlist from .Playlist import Playlist
from .Segments import Segments from .Segments import Segments
from .Subtitle import Subtitle from .Subtitle import Subtitle
from .Utils import validate_url, StreamingError from .Utils import validate_url, calcDownloadPath, StreamingError
from .ffprobe import ffprobe
LOG = logging.getLogger("http_api") LOG = logging.getLogger("http_api")
MIME = magic.Magic(mime=True) MIME = magic.Magic(mime=True)
@ -161,7 +162,7 @@ class HttpAPI(common):
self.routes.route("GET", "/")(lambda _: web.HTTPFound(self.config.url_prefix)) self.routes.route("GET", "/")(lambda _: web.HTTPFound(self.config.url_prefix))
self.routes.get(self.config.url_prefix[:-1])(lambda _: web.HTTPFound(self.config.url_prefix)) self.routes.get(self.config.url_prefix[:-1])(lambda _: web.HTTPFound(self.config.url_prefix))
self.routes.static(f"{self.config.url_prefix}download/", self.config.download_path) self.routes.static(f"{self.config.url_prefix}api/download/", self.config.download_path)
self.preloadStatic(app) self.preloadStatic(app)
try: try:
@ -209,12 +210,12 @@ class HttpAPI(common):
return middleware_handler return middleware_handler
@route("GET", "ping") @route("GET", "api/ping")
async def ping(self, _) -> Response: async def ping(self, _) -> Response:
await self.queue.test() await self.queue.test()
return web.json_response(data={"status": "pong"}, status=web.HTTPOk.status_code) return web.json_response(data={"status": "pong"}, status=web.HTTPOk.status_code)
@route("POST", "add") @route("POST", "api/add")
async def add_url(self, request: Request) -> Response: async def add_url(self, request: Request) -> Response:
post = await request.json() post = await request.json()
@ -242,7 +243,7 @@ class HttpAPI(common):
return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=self.encoder.encode) return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("GET", "tasks") @route("GET", "api/tasks")
async def tasks(self, _: Request) -> Response: async def tasks(self, _: Request) -> Response:
tasks_file: str = os.path.join(self.config.config_path, "tasks.json") tasks_file: str = os.path.join(self.config.config_path, "tasks.json")
@ -258,7 +259,7 @@ class HttpAPI(common):
return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=self.encoder.encode) return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("POST", "add_batch") @route("POST", "api/add_batch")
async def add_batch(self, request: Request) -> Response: async def add_batch(self, request: Request) -> Response:
status = {} status = {}
@ -293,7 +294,7 @@ class HttpAPI(common):
return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=self.encoder.encode) return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("DELETE", "delete") @route("DELETE", "api/delete")
async def delete(self, request: Request) -> Response: async def delete(self, request: Request) -> Response:
post = await request.json() post = await request.json()
ids = post.get("ids") ids = post.get("ids")
@ -311,7 +312,7 @@ class HttpAPI(common):
dumps=self.encoder.encode, dumps=self.encoder.encode,
) )
@route("POST", "item/{id}") @route("POST", "api/history/{id}")
async def update_item(self, request: Request) -> Response: async def update_item(self, request: Request) -> Response:
id: str = request.match_info.get("id") id: str = request.match_info.get("id")
if not id: if not id:
@ -348,7 +349,7 @@ class HttpAPI(common):
dumps=self.encoder.encode, dumps=self.encoder.encode,
) )
@route("GET", "history") @route("GET", "api/history")
async def history(self, _: Request) -> Response: async def history(self, _: Request) -> Response:
data: dict = {"queue": [], "history": []} data: dict = {"queue": [], "history": []}
@ -359,7 +360,7 @@ class HttpAPI(common):
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("GET", "workers") @route("GET", "api/workers")
async def workers(self, _) -> Response: async def workers(self, _) -> Response:
if self.queue.pool is None: if self.queue.pool is None:
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
@ -387,7 +388,7 @@ class HttpAPI(common):
dumps=lambda obj: json.dumps(obj, default=lambda o: f"<<non-serializable: {type(o).__qualname__}>>"), dumps=lambda obj: json.dumps(obj, default=lambda o: f"<<non-serializable: {type(o).__qualname__}>>"),
) )
@route("POST", "workers") @route("POST", "api/workers")
async def restart_pool(self, _) -> Response: async def restart_pool(self, _) -> Response:
if self.queue.pool is None: if self.queue.pool is None:
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
@ -396,7 +397,7 @@ class HttpAPI(common):
return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code) return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code)
@route("PATCH", "workers/{id}") @route("PATCH", "api/workers/{id}")
async def restart_worker(self, request: Request) -> Response: async def restart_worker(self, request: Request) -> Response:
id: str = request.match_info.get("id") id: str = request.match_info.get("id")
if not id: if not id:
@ -409,7 +410,7 @@ class HttpAPI(common):
return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code) return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code)
@route("delete", "workers/{id}") @route("delete", "api/workers/{id}")
async def stop_worker(self, request: Request) -> Response: async def stop_worker(self, request: Request) -> Response:
id: str = request.match_info.get("id") id: str = request.match_info.get("id")
if not id: if not id:
@ -422,7 +423,7 @@ class HttpAPI(common):
return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code) return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code)
@route("GET", "player/playlist/{file:.*}.m3u8") @route("GET", "api/player/playlist/{file:.*}.m3u8")
async def playlist(self, request: Request) -> Response: async def playlist(self, request: Request) -> Response:
file: str = request.match_info.get("file") file: str = request.match_info.get("file")
@ -448,7 +449,7 @@ class HttpAPI(common):
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
) )
@route("GET", "player/m3u8/{mode}/{file:.*}.m3u8") @route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8")
async def m3u8(self, request: Request) -> Response: async def m3u8(self, request: Request) -> Response:
file: str = request.match_info.get("file") file: str = request.match_info.get("file")
mode: str = request.match_info.get("mode") mode: str = request.match_info.get("mode")
@ -489,7 +490,7 @@ class HttpAPI(common):
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
) )
@route("GET", r"player/segments/{segment:\d+}/{file:.*}.ts") @route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts")
async def segments(self, request: Request) -> Response: async def segments(self, request: Request) -> Response:
file: str = request.match_info.get("file") file: str = request.match_info.get("file")
segment: int = request.match_info.get("segment") segment: int = request.match_info.get("segment")
@ -538,7 +539,7 @@ class HttpAPI(common):
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
) )
@route("GET", "player/subtitle/{file:.*}.vtt") @route("GET", "api/player/subtitle/{file:.*}.vtt")
async def subtitles(self, request: Request) -> Response: async def subtitles(self, request: Request) -> Response:
file: str = request.match_info.get("file") file: str = request.match_info.get("file")
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file)) file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
@ -573,11 +574,11 @@ class HttpAPI(common):
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
) )
@route("OPTIONS", "/add") @route("OPTIONS", "api/add")
async def add_cors(self, _: Request) -> Response: async def add_cors(self, _: Request) -> Response:
return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code) return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code)
@route("OPTIONS", "/delete") @route("OPTIONS", "api/delete")
async def delete_cors(self, _: Request) -> Response: async def delete_cors(self, _: Request) -> Response:
return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code) return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code)
@ -597,7 +598,7 @@ class HttpAPI(common):
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
) )
@route("GET", "/thumbnail") @route("GET", "api/thumbnail")
async def get_thumbnail(self, request: Request) -> Response: async def get_thumbnail(self, request: Request) -> Response:
url = request.query.get("url") url = request.query.get("url")
if not url: if not url:
@ -638,3 +639,19 @@ class HttpAPI(common):
return web.json_response( return web.json_response(
data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code
) )
@route("GET", "api/ffprobe/{file:.*}")
async def get_ffprobe(self, request: Request) -> Response:
file: str = request.match_info.get("file")
try:
realFile: str = calcDownloadPath(basePath=self.config.download_path, folder=file, createPath=False)
if not os.path.exists(realFile):
return web.json_response(
data={"error": f"File '{file}' does not exist."}, status=web.HTTPNotFound.status_code
)
return web.json_response(
data=await ffprobe(realFile), status=web.HTTPOk.status_code, dumps=self.encoder.encode
)
except Exception as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)

View file

@ -3,7 +3,7 @@ import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from email.utils import formatdate from email.utils import formatdate
from typing import Any
@dataclass(kw_only=True) @dataclass(kw_only=True)
class ItemDTO: class ItemDTO:
@ -67,3 +67,6 @@ class ItemDTO:
def json(self) -> str: def json(self) -> str:
return json.dumps(self.serialize(), default=lambda o: o.__dict__, sort_keys=True, indent=4) return json.dumps(self.serialize(), default=lambda o: o.__dict__, sort_keys=True, indent=4)
def get(self, key: str, default: Any = None) -> Any:
return self.__dict__.get(key, default)

View file

@ -67,7 +67,7 @@ class M3u8:
m3u8.append(f"#EXTINF:{segmentSize},") m3u8.append(f"#EXTINF:{segmentSize},")
url = f"{self.url}player/segments/{i}/{quote(file)}.ts" url = f"{self.url}api/player/segments/{i}/{quote(file)}.ts"
if len(segmentParams) > 0: if len(segmentParams) > 0:
url += "?" + "&".join([f"{key}={value}" for key, value in segmentParams.items()]) url += "?" + "&".join([f"{key}={value}" for key, value in segmentParams.items()])
@ -91,7 +91,7 @@ class M3u8:
m3u8.append("#EXT-X-MEDIA-SEQUENCE:0") m3u8.append("#EXT-X-MEDIA-SEQUENCE:0")
m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD") m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD")
m3u8.append(f"#EXTINF:{duration},") m3u8.append(f"#EXTINF:{duration},")
m3u8.append(f"{self.url}player/subtitle/{quote(file)}.vtt") m3u8.append(f"{self.url}api/player/subtitle/{quote(file)}.vtt")
m3u8.append("#EXT-X-ENDLIST") m3u8.append("#EXT-X-ENDLIST")
return "\n".join(m3u8) return "\n".join(m3u8)

View file

@ -25,7 +25,7 @@ class Playlist:
return Response( return Response(
status=302, status=302,
headers={ headers={
"Location": f"{self.url}player/playlist/{quote(str(possibleFile).replace(download_path, '').strip('/'))}.m3u8" "Location": f"{self.url}api/player/playlist/{quote(str(possibleFile).replace(download_path, '').strip('/'))}.m3u8"
}, },
) )
@ -57,16 +57,14 @@ class Playlist:
subs = ',SUBTITLES="subs"' subs = ',SUBTITLES="subs"'
url = ( url = f"{self.url}api/player/m3u8/subtitle/{quote(str(Path(file).with_name(item.name)))}.m3u8?duration={duration}"
f"{self.url}player/m3u8/subtitle/{quote(str(Path(file).with_name(item.name)))}.m3u8?duration={duration}"
)
name = f"{item.suffix[1:].upper()} ({index}) - {lang}" 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}"'
) )
playlist.append(f"#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}") playlist.append(f"#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}")
playlist.append(f"{self.url}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)

View file

@ -64,7 +64,12 @@ class Webhooks:
from .config import Config from .config import Config
req: dict = target.get("request") req: dict = target.get("request")
itemId = item.get("id", item.get("_id", "??"))
try:
itemId = item.get("id", item.get("_id", "??"))
except Exception:
itemId = "??"
try: try:
LOG.info(f"Sending event '{event}' id '{itemId}' to '{target.get('name')}'.") LOG.info(f"Sending event '{event}' id '{itemId}' to '{target.get('name')}'.")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:

View file

@ -244,7 +244,7 @@ class Config:
try: try:
import debugpy import debugpy
debugpy.listen(("0.0.0.0", self.debugpy_port)) debugpy.listen(("0.0.0.0", self.debugpy_port), in_process_debug_adapter=True)
LOG.info(f"starting debugpy server on '0.0.0.0:{self.debugpy_port}'.") LOG.info(f"starting debugpy server on '0.0.0.0:{self.debugpy_port}'.")
except ImportError: except ImportError:
LOG.error("debugpy package not found, please install it with 'pip install debugpy'.") LOG.error("debugpy package not found, please install it with 'pip install debugpy'.")

View file

@ -200,6 +200,14 @@ class FFProbeResult:
self.subtitle: list[FFStream] = [] self.subtitle: list[FFStream] = []
self.attachment: list[FFStream] = [] self.attachment: list[FFStream] = []
@property
def is_video(self):
return self.has_video()
@property
def is_audio(self):
return self.has_audio()
def get(self, key: str, default=None): def get(self, key: str, default=None):
return getattr(self, key) if hasattr(self, key) else default return getattr(self, key) if hasattr(self, key) else default
@ -222,6 +230,24 @@ class FFProbeResult:
def __repr__(self): def __repr__(self):
return "<FFprobe: {metadata}, {video}, {audio}, {subtitle}, {attachment}>".format(**vars(self)) return "<FFprobe: {metadata}, {video}, {audio}, {subtitle}, {attachment}>".format(**vars(self))
def unserialize(self, data: dict):
self.metadata = data.get("metadata", {})
self.video = [FFStream(v) for v in data.get("video", [])]
self.audio = [FFStream(a) for a in data.get("audio", [])]
self.subtitle = [FFStream(s) for s in data.get("subtitle", [])]
self.attachment = [FFStream(a) for a in data.get("attachment", [])]
def serialize(self) -> dict:
return {
"metadata": self.metadata,
"video": [v.__dict__ for v in self.video],
"audio": [a.__dict__ for a in self.audio],
"subtitle": [s.__dict__ for s in self.subtitle],
"attachment": [a.__dict__ for a in self.attachment],
"is_video": self.has_video(),
"is_audio": self.has_audio(),
}
@async_lru_cache(maxsize=512) @async_lru_cache(maxsize=512)
async def ffprobe(file: str) -> FFProbeResult: async def ffprobe(file: str) -> FFProbeResult:

View file

@ -4,4 +4,4 @@ set -eu pipefail
LOCAL_PORT="${YTP_PORT:-8081}" LOCAL_PORT="${YTP_PORT:-8081}"
/usr/bin/curl -f "http://localhost:${LOCAL_PORT}/ping" /usr/bin/curl -f "http://localhost:${LOCAL_PORT}/api/ping"

View file

@ -53,7 +53,7 @@ const defaultLoader = async () => {
return return
} }
const response = await fetch(config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(props.image), { const response = await fetch(config.app.url_host + config.app.url_prefix + 'api/thumbnail?url=' + encodePath(props.image), {
signal: cancelRequest.signal signal: cancelRequest.signal
}) })

View file

@ -85,12 +85,9 @@
</div> </div>
<div class="card-header-icon"> <div class="card-header-icon">
<a :href="makeDownload(config, item)" :download="item.filename?.split('/').reverse()[0]" <a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)">
class="has-text-primary" v-tooltip="'Download item.'" <span class="icon"><i class="fa-solid fa-copy" /></span>
v-if="item.filename && item.status === 'finished'">
<span class="icon"><i class="fa-solid fa-download" /></span>
</a> </a>
<button @click="hideThumbnail = !hideThumbnail"> <button @click="hideThumbnail = !hideThumbnail">
<span class="icon"><i class="fa-solid" <span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span> :class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span>
@ -102,15 +99,15 @@
<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 <img
:src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.extras.thumbnail)" :src="config.app.url_host + config.app.url_prefix + 'api/thumbnail?url=' + encodePath(item.extras.thumbnail)"
:alt="item.title" v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" :alt="item.title" /> <img v-else src="/images/placeholder.png" />
</span> </span>
<NuxtLink v-else target="_blank" :href="item.url" v-tooltip="`Open: ${item.title} link`"> <template v-else>
<img :alt="item.title" v-if="item.extras?.thumbnail" <img v-if="item.extras?.thumbnail"
:src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.extras.thumbnail)" /> :src="config.app.url_host + config.app.url_prefix + 'api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
<img v-else src="/images/placeholder.png" :alt="item.title" /> <img v-else src="/images/placeholder.png" />
</NuxtLink> </template>
</figure> </figure>
</div> </div>
<div class="card-content"> <div class="card-content">
@ -196,14 +193,14 @@
</span> </span>
</a> </a>
</div> </div>
<div class="column is-half-mobile"> <div class="column is-half-mobile" v-if="item.filename && item.status === 'finished'">
<button class="button is-link is-fullwidth" target="_blank" @click="copyText(item.url)" <a class="button is-link is-fullwidth" :href="makeDownload(config, item)"
v-tooltip="'Copy url to clipboard.'"> :download="item.filename?.split('/').reverse()[0]">
<span class="icon-text is-block"> <span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-copy" /></span> <span class="icon"><i class="fa-solid fa-download" /></span>
<span>Copy</span> <span>Download</span>
</span> </span>
</button> </a>
</div> </div>
</div> </div>
</div> </div>
@ -231,13 +228,13 @@
</div> </div>
<div class="modal is-active" v-if="video_link"> <div class="modal is-active" v-if="video_link">
<div class="modal-background"></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" :link="video_link" :isMuted="false" autoplay="true" :isControls="true"
:title="video_title" :thumbnail="video_thumbnail" :artist="video_artist" class="is-fullwidth" :title="video_title" :thumbnail="video_thumbnail" :artist="video_artist" class="is-fullwidth"
@closeModel="video_link = ''; video_title = ''; video_thumbnail = ''; video_artist = '';" /> @closeModel="closeVideo" />
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="video_link = ''"></button> <button class="modal-close is-large" aria-label="close" @click="closeVideo"></button>
</div> </div>
</div> </div>
</template> </template>
@ -269,7 +266,7 @@ const playVideo = item => {
video_link.value = makeDownload(config, item, 'm3u8') video_link.value = makeDownload(config, item, 'm3u8')
video_title.value = item.title video_title.value = item.title
if (item.extras?.thumbnail) { if (item.extras?.thumbnail) {
video_thumbnail.value = config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.extras.thumbnail) video_thumbnail.value = config.app.url_host + config.app.url_prefix + 'api/thumbnail?url=' + encodePath(item.extras.thumbnail)
} }
if (item.extras?.channel) { if (item.extras?.channel) {
video_artist.value = item.extras.channel video_artist.value = item.extras.channel
@ -277,6 +274,16 @@ const playVideo = item => {
if (!video_artist.value && item.extras?.uploader) { if (!video_artist.value && item.extras?.uploader) {
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) => {

View file

@ -37,11 +37,14 @@
<LateLoader :unrender="true" :min-height="265" class="column is-6" v-for="item in stateStore.queue" <LateLoader :unrender="true" :min-height="265" class="column is-6" v-for="item in stateStore.queue"
:key="item._id"> :key="item._id">
<div class="card"> <div class="card">
<header class="card-header has-tooltip" v-tooltip="item.title"> <header class="card-header">
<div class="card-header-title has-text-centered is-text-overflow is-block"> <div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
{{ item.title }} <NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div> </div>
<div class="card-header-icon"> <div class="card-header-icon">
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)">
<span class="icon"><i class="fa-solid fa-copy" /></span>
</a>
<button @click="hideThumbnail = !hideThumbnail"> <button @click="hideThumbnail = !hideThumbnail">
<span class="icon"><i class="fa-solid" <span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span> :class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span>
@ -50,16 +53,11 @@
</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">
<NuxtLink v-tooltip="item.title" :href="item.url" target="_blank"> <img :alt="item.title"
<img :src="config.app.url_host + config.app.url_prefix + 'api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
:src="config.app.url_host + config.app.url_prefix + 'thumbnail?url=' + encodePath(item.extras.thumbnail)"
:alt="item.title" />
</NuxtLink>
</figure> </figure>
<figure class="image is-3by1" v-else> <figure class="image is-3by1" v-else>
<NuxtLink target="_blank" :href="item.url" v-tooltip="`Open: ${item.title} link`"> <img :src="config.app.url_host + config.app.url_prefix + 'images/placeholder.png'" />
<img :src="config.app.url_host + config.app.url_prefix + 'images/placeholder.png'" :alt="item.title" />
</NuxtLink>
</figure> </figure>
</div> </div>
<div class="card-content"> <div class="card-content">
@ -104,16 +102,6 @@
</span> </span>
</button> </button>
</div> </div>
<div class="column is-half-mobile">
<a referrerpolicy="no-referrer" class="button is-link is-fullwidth" target="_blank" :href="item.url">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-up-right-from-square" />
</span>
<span>Visit Link</span>
</span>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -202,7 +190,7 @@ const ETAPipe = value => {
} }
const speedPipe = value => { const speedPipe = value => {
if (value === null || 0 === value) { if (null === value || 0 === value) {
return '0KB/s'; return '0KB/s';
} }
@ -231,13 +219,13 @@ const updateProgress = (item) => {
return 'Preparing'; return 'Preparing';
} }
if (item.status != null) { if (null != item.status) {
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live'; string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live';
} }
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..'; string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..';
if (item.status != null && item.eta) { if (null != item.status && item.eta) {
string += ' - ' + ETAPipe(item.eta); string += ' - ' + ETAPipe(item.eta);
} }

View file

@ -18,7 +18,7 @@
<template> <template>
<div> <div>
<video ref="video" :data-poster="thumbnail" :controls="isControls" :title="title" playsinline> <video ref="video" :poster="thumbnail" :controls="isControls" :title="title" playsinline>
<source :src="link" type="application/x-mpegURL" /> <source :src="link" type="application/x-mpegURL" />
</video> </video>
</div> </div>
@ -141,7 +141,6 @@ const prepareVideoPlayer = () => {
if (props.thumbnail) { if (props.thumbnail) {
opts.poster = props.thumbnail opts.poster = props.thumbnail
} }
player = new Plyr(video.value, opts); player = new Plyr(video.value, opts);
hls = new Hls({ hls = new Hls({
@ -161,7 +160,5 @@ const prepareVideoPlayer = () => {
if (props.title) { if (props.title) {
window.document.title = `YTPTube - Playing: ${props.title}` window.document.title = `YTPTube - Playing: ${props.title}`
} }
} }
</script> </script>

View file

@ -67,9 +67,9 @@
<div class="column is-8-mobile"> <div class="column is-8-mobile">
<div class="has-text-left" v-if="config.app?.version"> <div class="has-text-left" v-if="config.app?.version">
© {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink> © {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink>
<span class="is-hidden-mobile">&nbsp;({{ config.app?.version || 'unknown' }})</span> <span class="is-hidden-mobile">&nbsp;({{ config?.app?.version || 'unknown' }})</span>
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink> - <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
<span class="is-hidden-mobile">&nbsp;({{ config?.app.ytdlp_version || 'unknown' }})</span> <span class="is-hidden-mobile">&nbsp;({{ config?.app?.ytdlp_version || 'unknown' }})</span>
</div> </div>
</div> </div>
<div class="column is-4-mobile" v-if="config.app?.started"> <div class="column is-4-mobile" v-if="config.app?.started">

View file

@ -59,11 +59,8 @@ import "@xterm/xterm/css/xterm.css"
import { Terminal } from "@xterm/xterm" import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit" import { FitAddon } from "@xterm/addon-fit"
const emitter = defineEmits(['runCommand', 'cli_clear']);
const terminal = ref() const terminal = ref()
const terminalFit = ref() const terminalFit = ref()
const command = ref('') const command = ref('')
const terminal_window = ref() const terminal_window = ref()
const command_input = ref() const command_input = ref()

View file

@ -1,3 +1,13 @@
<style scoped>
table.is-fixed {
table-layout: fixed;
}
div.table-container {
overflow: hidden;
}
</style>
<template> <template>
<div class="mt-1 columns is-multiline"> <div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix"> <div class="column is-12 is-clearfix">
@ -55,13 +65,3 @@ import moment from 'moment'
import { parseExpression } from 'cron-parser' import { parseExpression } from 'cron-parser'
const config = useConfigStore() const config = useConfigStore()
</script> </script>
<style scoped>
table.is-fixed {
table-layout: fixed;
}
div.table-container {
overflow: hidden;
}
</style>

View file

@ -345,8 +345,8 @@ const getQueryParams = (url = window.location.search) => Object.fromEntries(new
* *
* @returns {string} The download URL * @returns {string} The download URL
*/ */
const makeDownload = (config, item, base = 'download') => { const makeDownload = (config, item, base = 'api/download') => {
let baseDir = 'download' === base ? `${base}/` : 'player/playlist/'; let baseDir = 'api/download' === base ? `${base}/` : 'api/player/playlist/';
if (item.folder) { if (item.folder) {
item.folder = item.folder.replace(/#/g, '%23'); item.folder = item.folder.replace(/#/g, '%23');