Merge pull request #296 from arabcoders/dev

Add support for running YTPTube from sub-directory
This commit is contained in:
Abdulmohsen 2025-06-05 22:52:13 +03:00 committed by GitHub
commit f1f79afea1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 311 additions and 172 deletions

3
.vscode/launch.json vendored
View file

@ -50,6 +50,9 @@
"cwd": "${workspaceFolder}/ui", "cwd": "${workspaceFolder}/ui",
"console": "internalConsole", "console": "internalConsole",
"outputCapture": "std", "outputCapture": "std",
"env": {
"APP_ENV": "production"
}
}, },
{ {
"name": "Python: main.py (Deep)", "name": "Python: main.py (Deep)",

View file

@ -124,29 +124,23 @@ other than the shortcut itself. this shortcut missing support for parsing the ht
It's advisable to run YTPTube behind a reverse proxy, if better authentication and/or HTTPS support are required. It's advisable to run YTPTube behind a reverse proxy, if better authentication and/or HTTPS support are required.
### Nginx http server ## Caddy http server
```nginx
location /ytptube/ {
proxy_pass http://ytptube:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
```
> [!NOTE]
> The extra `proxy_set_header` directives are there to make web socket connection work.
### Caddy http server
The following example Caddyfile gets a reverse proxy going behind [caddy](https://caddyserver.com). The following example Caddyfile gets a reverse proxy going behind [caddy](https://caddyserver.com).
```caddyfile ```caddyfile
example.com { # If you are using sub-domain.
# make sure to change "ytptube:8081" to the actual name of your YTPTube container/port.
ytp.example.org {
reverse_proxy ytptube:8081
}
# If you are using sub-folder, for example: https://example.org/ytptube/
# Also make sure to set the `YTP_BASE_PATH` environment variable to `/ytptube/`
# make sure to change "ytptube:8081" to the actual name of your YTPTube container/port.
example.org {
redir /ytptube /ytptube/
route /ytptube/* { route /ytptube/* {
uri strip_prefix ytptube
reverse_proxy ytptube:8081 reverse_proxy ytptube:8081
} }
} }
@ -162,7 +156,6 @@ container to automatically update `yt-dlp` to the latest version whenever the co
available, it will be downloaded and applied automatically. To disable this automatic update, set the available, it will be downloaded and applied automatically. To disable this automatic update, set the
`YTP_YTDLP_AUTO_UPDATE` environment variable to `false`. `YTP_YTDLP_AUTO_UPDATE` environment variable to `false`.
We will no longer release new versions of YTPTube for every new version of yt-dlp. We will no longer release new versions of YTPTube for every new version of yt-dlp.
# Troubleshooting and submitting issues # Troubleshooting and submitting issues
@ -317,4 +310,5 @@ Certain configuration values can be set via environment variables, using the `-e
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` | | YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` |
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` | | YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` | | YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |

View file

@ -11,7 +11,7 @@ import uuid
from collections.abc import Awaitable from collections.abc import Awaitable
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from urllib.parse import quote, unquote_plus, urlparse from urllib.parse import unquote_plus, urlparse
import anyio import anyio
import httpx import httpx
@ -70,7 +70,7 @@ class HttpAPI(Common):
} }
"""Map ext to mimetype""" """Map ext to mimetype"""
_frontend_routes = [ _frontend_routes: list[str] = [
"/console", "/console",
"/presets", "/presets",
"/tasks", "/tasks",
@ -97,17 +97,19 @@ class HttpAPI(Common):
self.rootPath = str(Path(__file__).parent.parent.parent) self.rootPath = str(Path(__file__).parent.parent.parent)
self.routes = web.RouteTableDef() self.routes = web.RouteTableDef()
self.cache = Cache() self.cache = Cache()
self.app: web.Application | None = None
super().__init__(queue=self.queue, encoder=self.encoder, config=self.config) super().__init__(queue=self.queue, encoder=self.encoder, config=self.config)
@staticmethod @staticmethod
def route(method: str, path: str) -> Awaitable: def route(method: str, path: str, name: str | None = None) -> Awaitable:
""" """
Decorator to mark a method as an HTTP route handler. Decorator to mark a method as an HTTP route handler.
Args: Args:
method (str): The HTTP method. method (str): The HTTP method.
path (str): The path to the route. path (str): The path to the route.
name (str | None): The name of the route (optional).
Returns: Returns:
Awaitable: The decorated function. Awaitable: The decorated function.
@ -121,6 +123,7 @@ class HttpAPI(Common):
wrapper._http_method = method.upper() wrapper._http_method = method.upper()
wrapper._http_path = path wrapper._http_path = path
wrapper._http_name = name
return wrapper return wrapper
return decorator return decorator
@ -139,7 +142,16 @@ class HttpAPI(Common):
HttpAPI: The instance of the HttpAPI. HttpAPI: The instance of the HttpAPI.
""" """
app.middlewares.append(HttpAPI.middle_wares(download_path=self.config.download_path)) self.app = app
app.middlewares.append(
HttpAPI.middle_wares(
app=app,
base_path=self.config.base_path.rstrip("/"),
download_path=self.config.download_path,
)
)
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))
@ -176,6 +188,9 @@ class HttpAPI(Common):
""" """
path = req.path path = req.path
if "/" != self.config.base_path and path.rstrip("/") == self.config.base_path.rstrip("/"):
path: str = f"{self.config.base_path.rstrip('/')}/index.html"
if path not in self._static_holder: if path not in self._static_holder:
for k in self._static_holder: for k in self._static_holder:
if path.startswith(k): if path.startswith(k):
@ -217,13 +232,15 @@ class HttpAPI(Common):
preloaded = 0 preloaded = 0
base_path: str = self.config.base_path.rstrip("/")
for root, _, files in os.walk(staticDir): for root, _, files in os.walk(staticDir):
for file in files: for file in files:
if file.endswith(".map"): if file.endswith(".map"):
continue continue
file = os.path.join(root, file) file = os.path.join(root, file)
urlPath = f"/{file.replace(f'{staticDir}/', '')}" urlPath: str = f"{base_path}/{file.replace(f'{staticDir}/', '')}"
with open(file, "rb") as f: with open(file, "rb") as f:
content = f.read() content = f.read()
@ -237,6 +254,7 @@ class HttpAPI(Common):
if urlPath.endswith("/index.html"): if urlPath.endswith("/index.html"):
for path in self._frontend_routes: for path in self._frontend_routes:
path: str = f"{base_path}/{path.lstrip('/')}"
self._static_holder[path] = {"content": content, "content_type": contentType} self._static_holder[path] = {"content": content, "content_type": contentType}
app.router.add_get(path, self._static_file) app.router.add_get(path, self._static_file)
if "{" not in path: if "{" not in path:
@ -255,6 +273,13 @@ class HttpAPI(Common):
LOG.info(f"Preloaded '{preloaded}' static files.") LOG.info(f"Preloaded '{preloaded}' static files.")
if "/" != self.config.base_path:
LOG.debug(f"adding base_path folder '{self.config.base_path}' to routes.")
app.router.add_get(self.config.base_path.rstrip("/"), self._static_file, name="base_path_static")
app.router.add_get(
f"{self.config.base_path.rstrip('/')}/", self._static_file, name="base_path_static_slash"
)
return self return self
def add_routes(self, app: web.Application) -> "HttpAPI": def add_routes(self, app: web.Application) -> "HttpAPI":
@ -268,7 +293,9 @@ class HttpAPI(Common):
HttpAPI: The instance of the HttpAPI. HttpAPI: The instance of the HttpAPI.
""" """
registered_options = [] registered_options: list = []
base_path: str = self.config.base_path.rstrip("/")
for attr_name in dir(self): for attr_name in dir(self):
method = getattr(self, attr_name) method = getattr(self, attr_name)
@ -277,7 +304,12 @@ class HttpAPI(Common):
if http_path.startswith("/"): if http_path.startswith("/"):
http_path = method._http_path[1:] http_path = method._http_path[1:]
self.routes.route(method._http_method, f"/{http_path}")(method) opts = {}
if hasattr(method, "_http_name") and method._http_name:
opts["name"] = method._http_name
LOG.debug(f"Registering route {method._http_method} {base_path}/{http_path}' {opts}.")
self.routes.route(method._http_method, f"{base_path}/{http_path}", **opts)(method)
if http_path in registered_options: if http_path in registered_options:
continue continue
@ -285,10 +317,13 @@ class HttpAPI(Common):
async def options_handler(_: Request) -> Response: async def options_handler(_: Request) -> Response:
return web.Response(status=204) return web.Response(status=204)
self.routes.route("OPTIONS", f"/{http_path}")(options_handler) if "name" in opts:
opts["name"] = f"{opts['name']}_options"
self.routes.route("OPTIONS", f"{base_path}/{http_path}", **opts)(options_handler)
registered_options.append(http_path) registered_options.append(http_path)
self.routes.static("/api/download/", self.config.download_path) self.routes.static(f"{base_path}/api/download/", self.config.download_path, name="download_static")
self._preload_static(app) self._preload_static(app)
try: try:
@ -386,19 +421,24 @@ class HttpAPI(Common):
return middleware_handler return middleware_handler
@staticmethod @staticmethod
def middle_wares(download_path: str) -> Awaitable: def middle_wares(app: web.Application, base_path: str, download_path: str) -> Awaitable:
@web.middleware @web.middleware
async def middleware_handler(request: Request, handler: RequestHandler) -> Response: async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
if request.path.startswith("/api/download"): static_path = str(app.router["download_static"].url_for(filename=""))
if request.path.startswith(static_path):
realFile, status = get_file( realFile, status = get_file(
download_path=download_path, download_path=download_path,
file=request.path.replace("/api/download/", ""), file=request.path.replace(static_path, ""),
) )
if web.HTTPFound.status_code == status: if web.HTTPFound.status_code == status:
return Response( return Response(
status=status, status=status,
headers={ headers={
"Location": f"/api/download/{quote(str(realFile).replace(download_path, '').strip('/'))}" "Location": str(
app.router["download_static"].url_for(
filename=str(realFile).replace(download_path, "").strip("/")
)
)
}, },
) )
@ -413,6 +453,17 @@ class HttpAPI(Common):
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,
) )
contentType: str | None = response.headers.get("content-type", None)
if contentType and "/" != base_path and contentType.startswith("text/html"):
rewrite_path: str = base_path.rstrip("/")
content: str = (
response.body.decode("utf-8")
.replace('<base href="/">', f'<base href="{rewrite_path}/">')
.replace('baseURL:""', f'baseURL:"{rewrite_path}/"')
)
response.body = content.encode("utf-8")
if isinstance(response, web.FileResponse): if isinstance(response, web.FileResponse):
try: try:
ff_info = await ffprobe(response._path) ff_info = await ffprobe(response._path)
@ -425,7 +476,7 @@ class HttpAPI(Common):
return middleware_handler return middleware_handler
@route("GET", "api/ping") @route("GET", "api/ping", "ping")
async def ping(self, _: Request) -> Response: async def ping(self, _: Request) -> Response:
""" """
Ping the server. Ping the server.
@ -440,7 +491,7 @@ class HttpAPI(Common):
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", "api/yt-dlp/convert") @route("POST", "api/yt-dlp/convert", "yt_dlp_convert")
async def yt_dlp_convert(self, request: Request) -> Response: async def yt_dlp_convert(self, request: Request) -> Response:
""" """
Convert the yt-dlp args to a dict. Convert the yt-dlp args to a dict.
@ -495,7 +546,7 @@ class HttpAPI(Common):
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,
) )
@route("GET", "api/yt-dlp/url/info") @route("GET", "api/yt-dlp/url/info", "get_info")
async def get_info(self, request: Request) -> Response: async def get_info(self, request: Request) -> Response:
""" """
Get the video info. Get the video info.
@ -588,7 +639,7 @@ class HttpAPI(Common):
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,
) )
@route("GET", "api/yt-dlp/archive/recheck") @route("GET", "api/yt-dlp/archive/recheck", "archive_recheck")
async def archive_recheck(self, _) -> Response: async def archive_recheck(self, _) -> Response:
""" """
Recheck the manual archive entries. Recheck the manual archive entries.
@ -669,7 +720,7 @@ class HttpAPI(Common):
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=self.encoder.encode) return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("GET", "api/history/add") @route("GET", "api/history/add", "quick_add")
async def quick_add(self, request: Request) -> Response: async def quick_add(self, request: Request) -> Response:
""" """
Add a URL to the download queue. Add a URL to the download queue.
@ -710,7 +761,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode, dumps=self.encoder.encode,
) )
@route("POST", "api/history") @route("POST", "api/history", "history_item_add")
async def history_item_add(self, request: Request) -> Response: async def history_item_add(self, request: Request) -> Response:
""" """
Add a URL to the download queue. Add a URL to the download queue.
@ -744,7 +795,7 @@ class HttpAPI(Common):
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=self.encoder.encode) return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("GET", "api/logs") @route("GET", "api/logs", "logs")
async def logs(self, request: Request) -> Response: async def logs(self, request: Request) -> Response:
""" """
Get recent logs Get recent logs
@ -783,7 +834,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode, dumps=self.encoder.encode,
) )
@route("GET", "api/conditions") @route("GET", "api/conditions", "conditions")
async def conditions(self, _: Request) -> Response: async def conditions(self, _: Request) -> Response:
""" """
Get the conditions Get the conditions
@ -801,7 +852,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode, dumps=self.encoder.encode,
) )
@route("PUT", "api/conditions") @route("PUT", "api/conditions", "conditions_add")
async def conditions_add(self, request: Request) -> Response: async def conditions_add(self, request: Request) -> Response:
""" """
Save Conditions. Save Conditions.
@ -872,7 +923,7 @@ class HttpAPI(Common):
await self._notify.emit(Events.CONDITIONS_UPDATE, data=items) await self._notify.emit(Events.CONDITIONS_UPDATE, data=items)
return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=self.encoder.encode) return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("POST", "api/conditions/test") @route("POST", "api/conditions/test", "conditions_test")
async def conditions_test(self, request: Request) -> Response: async def conditions_test(self, request: Request) -> Response:
""" """
Test condition against URL. Test condition against URL.
@ -949,7 +1000,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode, dumps=self.encoder.encode,
) )
@route("GET", "api/presets") @route("GET", "api/presets", "presets")
async def presets(self, request: Request) -> Response: async def presets(self, request: Request) -> Response:
""" """
Get the presets. Get the presets.
@ -970,7 +1021,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("PUT", "api/presets") @route("PUT", "api/presets", "presets_add")
async def presets_add(self, request: Request) -> Response: async def presets_add(self, request: Request) -> Response:
""" """
Add presets. Add presets.
@ -1030,7 +1081,7 @@ class HttpAPI(Common):
await self._notify.emit(Events.PRESETS_UPDATE, data=presets) await self._notify.emit(Events.PRESETS_UPDATE, data=presets)
return web.json_response(data=presets, status=web.HTTPOk.status_code, dumps=self.encoder.encode) return web.json_response(data=presets, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
@route("GET", "api/tasks") @route("GET", "api/tasks", "tasks")
async def tasks(self, _: Request) -> Response: async def tasks(self, _: Request) -> Response:
""" """
Get the tasks. Get the tasks.
@ -1046,7 +1097,7 @@ class HttpAPI(Common):
data=Tasks.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=self.encoder.encode data=Tasks.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=self.encoder.encode
) )
@route("PUT", "api/tasks") @route("PUT", "api/tasks", "tasks_add")
async def tasks_add(self, request: Request) -> Response: async def tasks_add(self, request: Request) -> Response:
""" """
Add tasks to the queue. Add tasks to the queue.
@ -1112,7 +1163,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("DELETE", "api/history") @route("DELETE", "api/history", "history_delete")
async def history_delete(self, request: Request) -> Response: async def history_delete(self, request: Request) -> Response:
""" """
Delete an item from the queue. Delete an item from the queue.
@ -1140,7 +1191,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode, dumps=self.encoder.encode,
) )
@route("POST", "api/history/{id}") @route("POST", "api/history/{id}", "history_item_update")
async def history_item_update(self, request: Request) -> Response: async def history_item_update(self, request: Request) -> Response:
""" """
Update an item in the history. Update an item in the history.
@ -1187,7 +1238,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode, dumps=self.encoder.encode,
) )
@route("GET", "api/history") @route("GET", "api/history", "history")
async def history(self, _: Request) -> Response: async def history(self, _: Request) -> Response:
""" """
Get the history. Get the history.
@ -1207,7 +1258,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", "api/workers") @route("GET", "api/workers", "pool_list")
async def pool_list(self, _) -> Response: async def pool_list(self, _) -> Response:
""" """
Get the workers status. Get the workers status.
@ -1245,7 +1296,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", "api/workers") @route("POST", "api/workers", "pool_start")
async def pool_restart(self, _) -> Response: async def pool_restart(self, _) -> Response:
""" """
Restart the workers pool. Restart the workers pool.
@ -1264,7 +1315,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", "api/workers/{id}") @route("PATCH", "api/workers/{id}", "worker_restart")
async def worker_restart(self, request: Request) -> Response: async def worker_restart(self, request: Request) -> Response:
""" """
Restart a worker. Restart a worker.
@ -1287,7 +1338,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", "api/workers/{id}") @route("DELETE", "api/workers/{id}", "worker_stop")
async def worker_stop(self, request: Request) -> Response: async def worker_stop(self, request: Request) -> Response:
""" """
Stop a worker. Stop a worker.
@ -1310,7 +1361,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", "api/player/playlist/{file:.*}.m3u8") @route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist")
async def playlist(self, request: Request) -> Response: async def playlist(self, request: Request) -> Response:
""" """
Get the playlist. Get the playlist.
@ -1327,13 +1378,19 @@ class HttpAPI(Common):
if not file: if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
base_path: str = self.config.base_path.rstrip("/")
try: try:
realFile, status = get_file(download_path=self.config.download_path, file=file) realFile, status = get_file(download_path=self.config.download_path, file=file)
if web.HTTPFound.status_code == status: if web.HTTPFound.status_code == status:
return Response( return Response(
status=web.HTTPFound.status_code, status=web.HTTPFound.status_code,
headers={ headers={
"Location": f"/api/player/playlist/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.m3u8" "Location": str(
self.app.router["playlist"].url_for(
file=str(realFile).replace(self.config.download_path, "").strip("/")
)
),
}, },
) )
@ -1341,7 +1398,7 @@ class HttpAPI(Common):
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status) return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
return web.Response( return web.Response(
text=await Playlist(download_path=self.config.download_path, url="/").make(file=realFile), text=await Playlist(download_path=self.config.download_path, url=f"{base_path}/").make(file=realFile),
headers={ headers={
"Content-Type": "application/x-mpegURL", "Content-Type": "application/x-mpegURL",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
@ -1352,7 +1409,7 @@ class HttpAPI(Common):
except StreamingError as e: except StreamingError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code) return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
@route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8") @route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8", "m3u8")
async def m3u8(self, request: Request) -> Response: async def m3u8(self, request: Request) -> Response:
""" """
Get the m3u8 file. Get the m3u8 file.
@ -1383,15 +1440,22 @@ class HttpAPI(Common):
duration = float(duration) duration = float(duration)
base_path: str = self.config.base_path.rstrip("/")
try: try:
cls = M3u8(download_path=self.config.download_path, url="/") cls = M3u8(download_path=self.config.download_path, url=f"{base_path}/")
realFile, status = get_file(download_path=self.config.download_path, file=file) realFile, status = get_file(download_path=self.config.download_path, file=file)
if web.HTTPFound.status_code == status: if web.HTTPFound.status_code == status:
return Response( return Response(
status=status, status=status,
headers={ headers={
"Location": f"/api/player/m3u8/{mode}/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.m3u8" "Location": str(
self.app.router["m3u8"].url_for(
mode=mode,
file=str(realFile).replace(self.config.download_path, "").strip("/"),
)
),
}, },
) )
@ -1416,7 +1480,7 @@ class HttpAPI(Common):
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
) )
@route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts") @route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments")
async def segments(self, request: Request) -> Response: async def segments(self, request: Request) -> Response:
""" """
Get the segments. Get the segments.
@ -1445,7 +1509,12 @@ class HttpAPI(Common):
return Response( return Response(
status=status, status=status,
headers={ headers={
"Location": f"/api/player/segments/{segment}/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.ts" "Location": str(
self.app.router["segments"].url_for(
segment=segment,
file=str(realFile).replace(self.config.download_path, "").strip("/"),
)
),
}, },
) )
@ -1487,7 +1556,7 @@ class HttpAPI(Common):
return resp return resp
@route("GET", "api/player/subtitle/{file:.*}.vtt") @route("GET", "api/player/subtitle/{file:.*}.vtt", "subtitles")
async def subtitles(self, request: Request) -> Response: async def subtitles(self, request: Request) -> Response:
""" """
Get the subtitles. Get the subtitles.
@ -1509,7 +1578,11 @@ class HttpAPI(Common):
return Response( return Response(
status=status, status=status,
headers={ headers={
"Location": f"/api/player/subtitle/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.vtt" "Location": str(
self.app.router["subtitles"].url_for(
file=str(realFile).replace(self.config.download_path, "").strip("/")
)
),
}, },
) )
@ -1540,7 +1613,7 @@ class HttpAPI(Common):
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
) )
@route("GET", "/") @route("GET", "/", "index")
async def index(self, _: Request) -> Response: async def index(self, _: Request) -> Response:
""" """
Get the index file. Get the index file.
@ -1566,7 +1639,7 @@ class HttpAPI(Common):
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
) )
@route("GET", "api/thumbnail") @route("GET", "api/thumbnail", "get_thumbnail")
async def get_thumbnail(self, request: Request) -> Response: async def get_thumbnail(self, request: Request) -> Response:
""" """
Get the thumbnail. Get the thumbnail.
@ -1620,7 +1693,7 @@ class HttpAPI(Common):
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/random/background") @route("GET", "api/random/background", "get_background")
async def get_background(self, request: Request) -> Response: async def get_background(self, request: Request) -> Response:
""" """
Get random background. Get random background.
@ -1694,7 +1767,7 @@ class HttpAPI(Common):
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,
) )
@route("GET", "api/file/ffprobe/{file:.*}") @route("GET", "api/file/ffprobe/{file:.*}", "ffprobe")
async def get_ffprobe(self, request: Request) -> Response: async def get_ffprobe(self, request: Request) -> Response:
""" """
Get the ffprobe data. Get the ffprobe data.
@ -1716,7 +1789,11 @@ class HttpAPI(Common):
return Response( return Response(
status=web.HTTPFound.status_code, status=web.HTTPFound.status_code,
headers={ headers={
"Location": f"/api/file/ffprobe/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}" "Location": str(
self.app.router["ffprobe"].url_for(
file=str(realFile).replace(self.config.download_path, "").strip("/")
)
),
}, },
) )
@ -1729,7 +1806,7 @@ 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:.*}") @route("GET", "api/file/info/{file:.*}", "file_info")
async def get_file_info(self, request: Request) -> Response: async def get_file_info(self, request: Request) -> Response:
""" """
Get file info Get file info
@ -1751,7 +1828,11 @@ class HttpAPI(Common):
return Response( return Response(
status=web.HTTPFound.status_code, status=web.HTTPFound.status_code,
headers={ headers={
"Location": f"/api/file/info/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}" "Location": str(
self.app.router["file_info"].url_for(
file=str(realFile).replace(self.config.download_path, "").strip("/")
)
),
}, },
) )
@ -1780,7 +1861,7 @@ class HttpAPI(Common):
LOG.exception(e) LOG.exception(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/notifications") @route("GET", "api/notifications", "notifications")
async def notifications(self, _: Request) -> Response: async def notifications(self, _: Request) -> Response:
""" """
Get the notification targets. Get the notification targets.
@ -1801,7 +1882,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode, dumps=self.encoder.encode,
) )
@route("PUT", "api/notifications") @route("PUT", "api/notifications", "notification_add")
async def notification_add(self, request: Request) -> Response: async def notification_add(self, request: Request) -> Response:
""" """
Add notification targets. Add notification targets.
@ -1857,7 +1938,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("POST", "api/notifications/test") @route("POST", "api/notifications/test", "notification_test")
async def notification_test(self, _: Request) -> Response: async def notification_test(self, _: Request) -> Response:
""" """
Test the notification. Test the notification.
@ -1875,7 +1956,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", "api/file/browser/{path:.*}") @route("GET", "api/file/browser/{path:.*}", "file_browser")
async def file_browser(self, request: Request) -> Response: async def file_browser(self, request: Request) -> Response:
""" """
Get the file browser. Get the file browser.

View file

@ -50,6 +50,7 @@ class HttpSocket(Common):
self.queue = queue or DownloadQueue.get_instance() self.queue = queue or DownloadQueue.get_instance()
self._notify = EventBus.get_instance() self._notify = EventBus.get_instance()
#logger=True, engineio_logger=True,
self.sio = sio or socketio.AsyncServer(cors_allowed_origins="*") self.sio = sio or socketio.AsyncServer(cors_allowed_origins="*")
encoder = encoder or Encoder() encoder = encoder or Encoder()
@ -77,7 +78,7 @@ class HttpSocket(Common):
LOG.debug("Shutting down socket server.") LOG.debug("Shutting down socket server.")
def attach(self, app: web.Application): def attach(self, app: web.Application):
self.sio.attach(app, socketio_path=self.config.url_socketio) self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
for attr_name in dir(self): for attr_name in dir(self):
method = getattr(self, attr_name) method = getattr(self, attr_name)

View file

@ -969,24 +969,28 @@ async def tail_log(file: str, emitter: callable, sleep_time: float = 0.5):
if not pathlib.Path(file).exists(): if not pathlib.Path(file).exists():
return return
async with await open_file(file, "rb") as f: try:
await f.seek(0, os.SEEK_END) async with await open_file(file, "rb") as f:
while True: await f.seek(0, os.SEEK_END)
line = await f.readline() while True:
if not line: line = await f.readline()
await asyncio_sleep(sleep_time) if not line:
continue await asyncio_sleep(sleep_time)
continue
msg = line.decode(errors="replace") msg = line.decode(errors="replace")
dt_match = DATETIME_PATTERN.match(msg) dt_match = DATETIME_PATTERN.match(msg)
await emitter( await emitter(
{ {
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(), "id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg, "line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None, "datetime": dt_match.group(1) if dt_match else None,
} }
) )
except Exception as e:
LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
return
def load_cookies(file: str) -> tuple[bool, MozillaCookieJar]: def load_cookies(file: str) -> tuple[bool, MozillaCookieJar]:

View file

@ -28,9 +28,6 @@ class Config:
temp_keep: bool = False temp_keep: bool = False
"""Keep temporary files after the download is complete.""" """Keep temporary files after the download is complete."""
url_socketio: str = "/socket.io"
"""The URL to use for the socket.io server."""
output_template: str = "%(title)s.%(ext)s" output_template: str = "%(title)s.%(ext)s"
"""The output template to use for the downloaded files.""" """The output template to use for the downloaded files."""
@ -55,11 +52,15 @@ class Config:
log_level_file: str = "info" log_level_file: str = "info"
"""The log level to use for the file logging.""" """The log level to use for the file logging."""
base_path: str = "/"
"""The base path to use for the application."""
max_workers: int = 1 max_workers: int = 1
"""The maximum number of workers to use for downloading.""" """The maximum number of workers to use for downloading."""
streamer_vcodec: str = "libx264" streamer_vcodec: str = "libx264"
"""The video codec to use for streaming.""" """The video codec to use for streaming."""
streamer_acodec: str = "aac" streamer_acodec: str = "aac"
"""The audio codec to use for streaming.""" """The audio codec to use for streaming."""
@ -221,6 +222,7 @@ class Config:
"browser_enabled", "browser_enabled",
"ytdlp_cli", "ytdlp_cli",
"file_logging", "file_logging",
"base_path",
) )
"The variables that are relevant to the frontend." "The variables that are relevant to the frontend."

View file

@ -23,6 +23,7 @@ from library.Tasks import Tasks
LOG = logging.getLogger("app") LOG = logging.getLogger("app")
MIME = magic.Magic(mime=True) MIME = magic.Magic(mime=True)
class Main: class Main:
def __init__(self): def __init__(self):
self._config = Config.get_instance() self._config = Config.get_instance()
@ -99,14 +100,16 @@ class Main:
def started(_): def started(_):
LOG.info("=" * 40) LOG.info("=" * 40)
LOG.info(f"YTPTube v{self._config.version} - started on http://{self._config.host}:{self._config.port}") LOG.info(
f"YTPTube v{self._config.version} - started on http://{self._config.host}:{self._config.port}{self._config.base_path}"
)
LOG.info("=" * 40) LOG.info("=" * 40)
HTTP_LOGGER = None HTTP_LOGGER = None
if self._config.access_log: if self._config.access_log:
from library.HttpAPI import LOG as HTTP_LOGGER from library.HttpAPI import LOG as HTTP_LOGGER
HTTP_LOGGER.addFilter(lambda record: "GET /api/ping" not in record.getMessage()) HTTP_LOGGER.addFilter(lambda record: f"GET {self._app.router['ping'].url_for()}" not in record.getMessage())
web.run_app( web.run_app(
self._app, self._app,

View file

@ -107,8 +107,8 @@
</label> </label>
</td> </td>
<td class="is-vcentered"> <td class="is-vcentered">
<div v-if="item.extras.thumbnail"> <div v-if="showThumbnails && item.extras.thumbnail">
<FloatingImage :image="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" <FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
:title="item.title"> :title="item.title">
<div class="is-text-overflow"> <div class="is-text-overflow">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink> <NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
@ -198,53 +198,52 @@
</div> </div>
<div class="columns is-multiline" v-else> <div class="columns is-multiline" v-else>
<LateLoader :unrender="true" :min-height="hideThumbnail ? 210 : 410" class="column is-6" <LateLoader :unrender="true" :min-height="showThumbnails ? 410 : 210" class="column is-6"
v-for="item in sortCompleted" :key="item._id"> v-for="item in sortCompleted" :key="item._id">
<div class="card is-flex is-full-height is-flex-direction-column" <div class="card is-flex is-full-height is-flex-direction-column"
:class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }"> :class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }">
<header class="card-header has-tooltip"> <header class="card-header ">
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title"> <div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink> <NuxtLink target="_blank" :href="item.url" class="has-tooltip">{{ item.title }}</NuxtLink>
</div> </div>
<div class="card-header-icon"> <div class="card-header-icon">
<span v-if="hideThumbnail"> <span v-if="!showThumbnails">
<a v-if="'finished' === item.status && item.filename" href="#" @click.prevent="playVideo(item)" <a v-if="'finished' === item.status && item.filename" href="#" @click.prevent="playVideo(item)"
v-tooltip="'Play video.'"> v-tooltip="'Play video.'">
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
</a> </a>
<a v-else-if="isEmbedable(item.url)" href="#" @click.prevent="embed_url = getEmbedable(item.url)" <a v-else-if="isEmbedable(item.url)" href="#" @click.prevent="embed_url = getEmbedable(item.url)"
v-tooltip="'Play video.'"> v-tooltip="'Play video.'" class="has-text-danger">
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
</a> </a>
</span> </span>
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)"> <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> <span class="icon"><i class="fa-solid fa-copy" /></span>
</a> </a>
<button @click="hideThumbnail = !hideThumbnail"> <button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<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>
</button> </button>
</div> </div>
</header> </header>
<div v-if="false === hideThumbnail" class="card-image"> <div v-if="showThumbnails" class="card-image">
<figure class="image is-3by1"> <figure class="image is-3by1">
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay"> <span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
<div class="play-icon"></div> <div class="play-icon"></div>
<img @load="e => pImg(e)" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" <img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</span> </span>
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay"> <span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay">
<div class="play-icon embed-icon"></div> <div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" <img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
v-if="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 @load="e => pImg(e)" v-if="item.extras?.thumbnail" <img @load="e => pImg(e)" v-if="item.extras?.thumbnail"
:src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" /> :src="uri('/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>
@ -378,10 +377,18 @@
<script setup> <script setup>
import moment from 'moment' import moment from 'moment'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { makeDownload, formatBytes } from '~/utils/index' import { makeDownload, formatBytes, uri } from '~/utils/index'
import { isEmbedable, getEmbedable } from '~/utils/embedable' import { isEmbedable, getEmbedable } from '~/utils/embedable'
const emitter = defineEmits(['getInfo', 'add_new']) const emitter = defineEmits(['getInfo', 'add_new'])
const props = defineProps({
thumbnails: {
type: Boolean,
default: true
}
})
const config = useConfigStore() const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()
const socket = useSocketStore() const socket = useSocketStore()
@ -401,6 +408,8 @@ const video_item = ref(null)
const playVideo = item => video_item.value = item const playVideo = item => video_item.value = item
const closeVideo = () => video_item.value = null const closeVideo = () => video_item.value = null
const showThumbnails = computed(() => props.thumbnails && !hideThumbnail.value)
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage('random_bg_opacity', 0.85)

View file

@ -65,8 +65,20 @@
:id="'checkbox-' + item._id" :value="item._id"> :id="'checkbox-' + item._id" :value="item._id">
</label> </label>
</td> </td>
<td class="is-text-overflow is-vcentered" v-tooltip="item.title"> <td class="is-text-overflow is-vcentered">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink> <div v-if="showThumbnails && item.extras?.thumbnail">
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
:title="item.title">
<div class="is-text-overflow">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
</FloatingImage>
</div>
<template v-else>
<div class="is-text-overflow" v-tooltip="item.title">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
</template>
</td> </td>
<td class="has-text-centered is-text-overflow is-unselectable"> <td class="has-text-centered is-text-overflow is-unselectable">
<span class="icon-text"> <span class="icon-text">
@ -116,7 +128,7 @@
</div> </div>
<div class="columns is-multiline" v-else> <div class="columns is-multiline" v-else>
<LateLoader :unrender="true" :min-height="hideThumbnail ? 265 : 475" class="column is-6" <LateLoader :unrender="true" :min-height="showThumbnails ? 475 : 265" class="column is-6"
v-for="item in stateStore.queue" :key="item._id"> v-for="item in stateStore.queue" :key="item._id">
<div class="card is-flex is-full-height is-flex-direction-column"> <div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header"> <header class="card-header">
@ -127,23 +139,23 @@
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)"> <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> <span class="icon"><i class="fa-solid fa-copy" /></span>
</a> </a>
<button @click="hideThumbnail = !hideThumbnail"> <button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<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>
</button> </button>
</div> </div>
</header> </header>
<div v-if="false === hideThumbnail" class="card-image"> <div v-if="showThumbnails" class="card-image">
<figure class="image is-3by1"> <figure class="image is-3by1">
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay"> <span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay">
<div class="play-icon embed-icon"></div> <div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" <img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
v-if="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 @load="e => pImg(e)" v-if="item.extras?.thumbnail" <img @load="e => pImg(e)" v-if="item.extras?.thumbnail"
:src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" /> :src="uri('/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>
@ -235,6 +247,12 @@ import { ucFirst } from '~/utils/index'
import { isEmbedable, getEmbedable } from '~/utils/embedable' import { isEmbedable, getEmbedable } from '~/utils/embedable'
const emitter = defineEmits(['getInfo']) const emitter = defineEmits(['getInfo'])
const props = defineProps({
thumbnails: {
type: Boolean,
default: true
}
})
const config = useConfigStore() const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()
const socket = useSocketStore() const socket = useSocketStore()
@ -245,6 +263,7 @@ const masterSelectAll = ref(false)
const showQueue = useStorage('showQueue', true) const showQueue = useStorage('showQueue', true)
const hideThumbnail = useStorage('hideThumbnailQueue', false) const hideThumbnail = useStorage('hideThumbnailQueue', false)
const display_style = useStorage('display_style', 'cards') const display_style = useStorage('display_style', 'cards')
const showThumbnails = computed(() => props.thumbnails && !hideThumbnail.value)
const embed_url = ref('') const embed_url = ref('')

View file

@ -11,92 +11,87 @@
<div class="card-content"> <div class="card-content">
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-6"> <div class="column is-6">
<div class="field"> <div class="field">
<label class="label">Color scheme</label> <label class="label is-unselectable">Color scheme</label>
<div class="control"> <div class="control">
<label for="auto" class="radio"> <label for="auto" class="radio">
<input id="auto" type="radio" v-model="selectedTheme" value="auto"> <input id="auto" type="radio" v-model="selectedTheme" value="auto">
System Default <span class="icon"><i class="fa-solid fa-circle-half-stroke" /></span>
<span>Auto</span>
</label> </label>
<label for="light" class="radio"> <label for="light" class="radio">
<input id="light" type="radio" v-model="selectedTheme" value="light"> <input id="light" type="radio" v-model="selectedTheme" value="light">
Light <span class="icon has-text-warning"><i class="fa-solid fa-sun" /></span>
<span>Light</span>
</label> </label>
<label for="dark" class="radio"> <label for="dark" class="radio">
<input id="dark" type="radio" v-model="selectedTheme" value="dark"> <input id="dark" type="radio" v-model="selectedTheme" value="dark">
Dark <span class="icon"><i class="fa-solid fa-moon" /></span>
<span>Dark</span>
</label> </label>
</div> </div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Select the color scheme for the WebUI.</span>
</p>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="random_bg">Backgrounds</label> <label class="label is-unselectable">
Backgrounds
<template v-if="bg_enable">
<NuxtLink @click="$emit('reload_bg')" class="is-bold">Reload</NuxtLink>
<span class="icon" v-if="isLoading"><i class="fa fa-spin fa-spinner" /></span>
</template>
</label>
<div class="control"> <div class="control">
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable"> <input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable">
<label for="random_bg" class="is-unselectable">&nbsp;Enable</label> <label for="random_bg" class="is-unselectable">
&nbsp;{{ bg_enable ? 'Enabled' : 'Disabled' }}
</label>
</div> </div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
Use random background image.
<NuxtLink @click="$emit('reload_bg')" class="is-bold" v-if="bg_enable">
Reload
</NuxtLink>
<span class="icon" v-if="isLoading"><i class="fa fa-spin fa-spinner" /></span>
</span>
</p>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="random_bg_opacity"> <label class="label is-unselectable" for="random_bg_opacity">
Background Visibility: (<code>{{ bg_opacity }}</code>) Background visibility <code>{{ parseFloat(1.0 - bg_opacity).toFixed(2) }}</code>
</label> </label>
<div class="control"> <div class="control">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50" <input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50"
max="1.00" step="0.05"> max="1.00" step="0.05">
</div> </div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>How visible the background image should be.</span>
</p>
</div> </div>
<div class="field">
<label class="label is-unselectable" for="show_thumbnail">Show Videos Thumbnail when possible</label>
<div class="control">
<input id="show_thumbnail" type="checkbox" class="switch is-success" v-model="show_thumbnail">
<label for="show_thumbnail" class="is-unselectable">
&nbsp;{{ show_thumbnail ? 'Enabled' : 'Disabled' }}
</label>
</div>
</div>
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="field"> <div class="field">
<label class="label" for="reduce_confirm">Reduce confirm box usage</label> <label class="label is-unselectable" for="reduce_confirm">Reduce confirm box usage</label>
<div class="control"> <div class="control">
<input id="reduce_confirm" type="checkbox" class="switch is-success" v-model="reduce_confirm"> <input id="reduce_confirm" type="checkbox" class="switch is-success" v-model="reduce_confirm">
<label for="reduce_confirm" class="is-unselectable"> <label for="reduce_confirm" class="is-unselectable">
&nbsp;{{ reduce_confirm ? 'Enabled' : 'Disabled' }} &nbsp;{{ reduce_confirm ? 'Enabled' : 'Disabled' }}
</label> </label>
</div> </div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Reduce the usage of confirm boxes in the WebUI.</span>
</p>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="allow_toasts">Show toasts</label> <label class="label" for="allow_toasts">Show notifications toasts</label>
<div class="control"> <div class="control">
<input id="allow_toasts" type="checkbox" class="switch is-success" v-model="allow_toasts"> <input id="allow_toasts" type="checkbox" class="switch is-success" v-model="allow_toasts">
<label for="allow_toasts" class="is-unselectable"> <label for="allow_toasts" class="is-unselectable">
&nbsp;{{ allow_toasts ? 'Enabled' : 'Disabled' }} &nbsp;{{ allow_toasts ? 'Enabled' : 'Disabled' }}
</label> </label>
</div> </div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="has-text-danger is-bold">
Show notification toasts. If disabled, you will not see errors reported or anything else.
</span>
</p>
</div> </div>
<div class="field"> <div class="field" v-if="allow_toasts">
<label class="label">Toasts position</label> <label class="label">Toasts position</label>
<div class="control"> <div class="control">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
@ -112,7 +107,7 @@
</div> </div>
</div> </div>
<div class="field"> <div class="field" v-if="allow_toasts">
<label class="label" for="dismiss_on_click">Dismiss toasts on click</label> <label class="label" for="dismiss_on_click">Dismiss toasts on click</label>
<div class="control"> <div class="control">
<input id="dismiss_on_click" type="checkbox" class="switch is-success" <input id="dismiss_on_click" type="checkbox" class="switch is-success"
@ -149,4 +144,6 @@ const allow_toasts = useStorage('allow_toasts', true)
const reduce_confirm = useStorage('reduce_confirm', false) const reduce_confirm = useStorage('reduce_confirm', false)
const toast_position = useStorage('toast_position', POSITION.TOP_RIGHT) const toast_position = useStorage('toast_position', POSITION.TOP_RIGHT)
const toast_dismiss_on_click = useStorage('toast_dismiss_on_click', true) const toast_dismiss_on_click = useStorage('toast_dismiss_on_click', true)
const show_thumbnail = useStorage('show_thumbnail', true)
</script> </script>

View file

@ -10,7 +10,7 @@
</style> </style>
<template> <template>
<div v-if="infoLoaded"> <div v-if="infoLoaded">
<video class="player" ref="video" :poster="thumbnail" :title="title" playsinline controls crossorigin="anonymous" <video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls crossorigin="anonymous"
preload="auto" autoplay> preload="auto" autoplay>
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror" <source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
:type="source.type" /> :type="source.type" />

View file

@ -30,9 +30,8 @@ export default defineNuxtConfig({
], ],
runtimeConfig: { runtimeConfig: {
public: { public: {
domain: '/', APP_ENV: process.env.APP_ENV || "production",
wss: process.env.VUE_APP_BASE_URL ?? '', wss: process.env.VUE_APP_BASE_URL ?? '',
version: '2.0.0',
sentry: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '', sentry: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
} }
}, },
@ -40,6 +39,7 @@ export default defineNuxtConfig({
transpile: ['vue-toastification'], transpile: ['vue-toastification'],
}, },
app: { app: {
baseURL: 'dev' == process.env.APP_ENV ? '/' : '',
buildAssetsDir: "assets", buildAssetsDir: "assets",
head: { head: {
"meta": [ "meta": [
@ -47,7 +47,8 @@ export default defineNuxtConfig({
{ "name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0" }, { "name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0" },
{ "name": "theme-color", "content": "#000000" }, { "name": "theme-color", "content": "#000000" },
], ],
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico?v=100' }] base: { "href": "/" },
link: [{ rel: 'icon', type: 'image/x-icon', href: 'favicon.ico?v=100' }]
}, },
pageTransition: { name: 'page', mode: 'out-in' } pageTransition: { name: 'page', mode: 'out-in' }
}, },
@ -66,7 +67,7 @@ export default defineNuxtConfig({
nitro: { nitro: {
output: { output: {
publicDir: path.join(__dirname, 'exported') publicDir: path.join(__dirname, 'dev' == process.env.APP_ENV ? 'dist' : 'exported')
}, },
...extraNitro, ...extraNitro,
}, },

View file

@ -94,7 +94,7 @@
<td class="is-text-overflow is-vcentered"> <td class="is-text-overflow is-vcentered">
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control is-text-overflow is-expanded"> <div class="control is-text-overflow is-expanded">
<a :href="`/browser/${item.path}`" v-if="'dir' === item.type" @click.prevent="handleClick(item)"> <a :href="uri(`/browser/${item.path}`)" v-if="'dir' === item.type" @click.prevent="handleClick(item)">
{{ item.name }} {{ item.name }}
</a> </a>
<a :href="makeDownload({}, { filename: item.path, folder: '' })" <a :href="makeDownload({}, { filename: item.path, folder: '' })"
@ -321,7 +321,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
} }
const title = `File Browser: /${dir}` const title = `File Browser: /${dir}`
const stateUrl = `/browser/${dir}` const stateUrl = uri(`/browser/${dir}`)
if (false === fromMounted) { if (false === fromMounted) {
history.pushState({ path: dir, title: title }, title, stateUrl) history.pushState({ path: dir, title: title }, title, stateUrl)

View file

@ -49,8 +49,8 @@
<NewDownload v-if="config.showForm || config.app.basic_mode" @getInfo="url => get_info = url" :item="item_form" <NewDownload v-if="config.showForm || config.app.basic_mode" @getInfo="url => get_info = url" :item="item_form"
@clear_form="item_form = {}" /> @clear_form="item_form = {}" />
<Queue @getInfo="url => get_info = url" /> <Queue @getInfo="url => get_info = url" :thumbnails="show_thumbnail" />
<History @getInfo="url => get_info = url" @add_new="item => toNewDownload(item)" /> <History @getInfo="url => get_info = url" @add_new="item => toNewDownload(item)" :thumbnails="show_thumbnail" />
<GetInfo v-if="get_info" :link="get_info" @closeModel="get_info = ''" /> <GetInfo v-if="get_info" :link="get_info" @closeModel="get_info = ''" />
</div> </div>
</template> </template>
@ -68,6 +68,8 @@ const get_info = ref('')
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage('random_bg_opacity', 0.85)
const display_style = useStorage('display_style', 'cards') const display_style = useStorage('display_style', 'cards')
const show_thumbnail = useStorage('show_thumbnail', true)
const item_form = ref({}) const item_form = ref({})
onMounted(() => { onMounted(() => {

View file

@ -7,11 +7,20 @@ export const useSocketStore = defineStore('socket', () => {
const stateStore = useStateStore() const stateStore = useStateStore()
const toast = useNotification() const toast = useNotification()
const socket = ref(null); const socket = ref(null)
const isConnected = ref(false); const isConnected = ref(false)
const connect = () => { const connect = () => {
socket.value = io(runtimeConfig.public.wss, { withCredentials: true }) let opts = { withCredentials: true }
let url = runtimeConfig.public.wss
if ('dev' !== runtimeConfig.public.APP_ENV) {
url = window.origin;
opts.path = `${runtimeConfig.app.baseURL.replace(/\/$/, '')}/socket.io`;
}
socket.value = io(url, opts)
socket.value.on('connect', () => isConnected.value = true); socket.value.on('connect', () => isConnected.value = true);
socket.value.on('disconnect', () => isConnected.value = false); socket.value.on('disconnect', () => isConnected.value = false);

View file

@ -1,4 +1,5 @@
const toast = useNotification() const toast = useNotification()
const runtimeConfig = useRuntimeConfig()
const AG_SEPARATOR = '.' const AG_SEPARATOR = '.'
/** /**
@ -237,7 +238,6 @@ const encodePath = item => {
* @returns {Promise<Response>} - The response from the API * @returns {Promise<Response>} - The response from the API
*/ */
const request = (url, options = {}) => { const request = (url, options = {}) => {
const runtimeConfig = useRuntimeConfig()
options = options || {} options = options || {}
options.method = options.method || 'GET' options.method = options.method || 'GET'
options.headers = options.headers || {} options.headers = options.headers || {}
@ -257,7 +257,7 @@ const request = (url, options = {}) => {
options.credentials = 'same-origin' options.credentials = 'same-origin'
} }
return fetch(url.startsWith('/') ? eTrim(runtimeConfig.public.domain, '/') + '/' + sTrim(url, '/') : url, options) return fetch(url.startsWith('/') ? uri(url) : url, options)
} }
/** /**
@ -360,7 +360,8 @@ const makeDownload = (config, item, base = 'api/download') => {
} }
let url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`; let url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`;
return ('m3u8' === base) ? url + '.m3u8' : url;
return uri(('m3u8' === base) ? url + '.m3u8' : url);
} }
/** /**
@ -492,6 +493,18 @@ const cleanObject = (item, fields = []) => {
return cleaned return cleaned
} }
const uri = u => {
if (!u || '/' === runtimeConfig.app.baseURL || !u.startsWith('/')) {
return u
}
if (true === u.startsWith(runtimeConfig.app.baseURL)) {
return u
}
return eTrim(runtimeConfig.app.baseURL, '/') + '/' + sTrim(u, '/');
}
export { export {
ag_set, ag_set,
ag, ag,
@ -519,4 +532,5 @@ export {
has_data, has_data,
toggleClass, toggleClass,
cleanObject, cleanObject,
uri,
} }