Add support for running YTPTube from sub-directory. Fix #295

This commit is contained in:
arabcoders 2025-06-05 21:04:00 +03:00
parent 18dea30719
commit 0f0dd51a41
14 changed files with 240 additions and 116 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,22 @@ 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
# If you are using sub-domain.
youtube.example.com {
reverse_proxy ytptube:8081
}
# If you are using sub-folder, for example: https://example.com/ytptube/
# Make sure to change the `ytptube` to the name of your container.
# Also make sure to set the `YTP_BASE_PATH` environment variable to `/ytptube/`
example.com { example.com {
redir /ytptube /ytptube/
route /ytptube/* { route /ytptube/* {
uri strip_prefix ytptube
reverse_proxy ytptube:8081 reverse_proxy ytptube:8081
} }
} }
@ -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

@ -108,7 +108,7 @@
</td> </td>
<td class="is-vcentered"> <td class="is-vcentered">
<div v-if="item.extras.thumbnail"> <div v-if="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>
@ -232,19 +232,19 @@
<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,7 +378,7 @@
<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'])

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="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">
@ -137,13 +149,13 @@
<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>

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

@ -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,
} }