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",
"console": "internalConsole",
"outputCapture": "std",
"env": {
"APP_ENV": "production"
}
},
{
"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.
### Nginx 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
## Caddy http server
The following example Caddyfile gets a reverse proxy going behind [caddy](https://caddyserver.com).
```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/* {
uri strip_prefix ytptube
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
`YTP_YTDLP_AUTO_UPDATE` environment variable to `false`.
We will no longer release new versions of YTPTube for every new version of yt-dlp.
# 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_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| 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 datetime import UTC, datetime, timedelta
from pathlib import Path
from urllib.parse import quote, unquote_plus, urlparse
from urllib.parse import unquote_plus, urlparse
import anyio
import httpx
@ -70,7 +70,7 @@ class HttpAPI(Common):
}
"""Map ext to mimetype"""
_frontend_routes = [
_frontend_routes: list[str] = [
"/console",
"/presets",
"/tasks",
@ -97,17 +97,19 @@ class HttpAPI(Common):
self.rootPath = str(Path(__file__).parent.parent.parent)
self.routes = web.RouteTableDef()
self.cache = Cache()
self.app: web.Application | None = None
super().__init__(queue=self.queue, encoder=self.encoder, config=self.config)
@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.
Args:
method (str): The HTTP method.
path (str): The path to the route.
name (str | None): The name of the route (optional).
Returns:
Awaitable: The decorated function.
@ -121,6 +123,7 @@ class HttpAPI(Common):
wrapper._http_method = method.upper()
wrapper._http_path = path
wrapper._http_name = name
return wrapper
return decorator
@ -139,7 +142,16 @@ class HttpAPI(Common):
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:
app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, self.config.auth_password))
@ -176,6 +188,9 @@ class HttpAPI(Common):
"""
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:
for k in self._static_holder:
if path.startswith(k):
@ -217,13 +232,15 @@ class HttpAPI(Common):
preloaded = 0
base_path: str = self.config.base_path.rstrip("/")
for root, _, files in os.walk(staticDir):
for file in files:
if file.endswith(".map"):
continue
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:
content = f.read()
@ -237,6 +254,7 @@ class HttpAPI(Common):
if urlPath.endswith("/index.html"):
for path in self._frontend_routes:
path: str = f"{base_path}/{path.lstrip('/')}"
self._static_holder[path] = {"content": content, "content_type": contentType}
app.router.add_get(path, self._static_file)
if "{" not in path:
@ -255,6 +273,13 @@ class HttpAPI(Common):
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
def add_routes(self, app: web.Application) -> "HttpAPI":
@ -268,7 +293,9 @@ class HttpAPI(Common):
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):
method = getattr(self, attr_name)
@ -277,7 +304,12 @@ class HttpAPI(Common):
if http_path.startswith("/"):
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:
continue
@ -285,10 +317,13 @@ class HttpAPI(Common):
async def options_handler(_: Request) -> Response:
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)
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)
try:
@ -386,19 +421,24 @@ class HttpAPI(Common):
return middleware_handler
@staticmethod
def middle_wares(download_path: str) -> Awaitable:
def middle_wares(app: web.Application, base_path: str, download_path: str) -> Awaitable:
@web.middleware
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(
download_path=download_path,
file=request.path.replace("/api/download/", ""),
file=request.path.replace(static_path, ""),
)
if web.HTTPFound.status_code == status:
return Response(
status=status,
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,
)
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):
try:
ff_info = await ffprobe(response._path)
@ -425,7 +476,7 @@ class HttpAPI(Common):
return middleware_handler
@route("GET", "api/ping")
@route("GET", "api/ping", "ping")
async def ping(self, _: Request) -> Response:
"""
Ping the server.
@ -440,7 +491,7 @@ class HttpAPI(Common):
await self.queue.test()
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:
"""
Convert the yt-dlp args to a dict.
@ -495,7 +546,7 @@ class HttpAPI(Common):
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:
"""
Get the video info.
@ -588,7 +639,7 @@ class HttpAPI(Common):
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:
"""
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)
@route("GET", "api/history/add")
@route("GET", "api/history/add", "quick_add")
async def quick_add(self, request: Request) -> Response:
"""
Add a URL to the download queue.
@ -710,7 +761,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode,
)
@route("POST", "api/history")
@route("POST", "api/history", "history_item_add")
async def history_item_add(self, request: Request) -> Response:
"""
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)
@route("GET", "api/logs")
@route("GET", "api/logs", "logs")
async def logs(self, request: Request) -> Response:
"""
Get recent logs
@ -783,7 +834,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode,
)
@route("GET", "api/conditions")
@route("GET", "api/conditions", "conditions")
async def conditions(self, _: Request) -> Response:
"""
Get the conditions
@ -801,7 +852,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode,
)
@route("PUT", "api/conditions")
@route("PUT", "api/conditions", "conditions_add")
async def conditions_add(self, request: Request) -> Response:
"""
Save Conditions.
@ -872,7 +923,7 @@ class HttpAPI(Common):
await self._notify.emit(Events.CONDITIONS_UPDATE, data=items)
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:
"""
Test condition against URL.
@ -949,7 +1000,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode,
)
@route("GET", "api/presets")
@route("GET", "api/presets", "presets")
async def presets(self, request: Request) -> Response:
"""
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)
@route("PUT", "api/presets")
@route("PUT", "api/presets", "presets_add")
async def presets_add(self, request: Request) -> Response:
"""
Add presets.
@ -1030,7 +1081,7 @@ class HttpAPI(Common):
await self._notify.emit(Events.PRESETS_UPDATE, data=presets)
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:
"""
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
)
@route("PUT", "api/tasks")
@route("PUT", "api/tasks", "tasks_add")
async def tasks_add(self, request: Request) -> Response:
"""
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)
@route("DELETE", "api/history")
@route("DELETE", "api/history", "history_delete")
async def history_delete(self, request: Request) -> Response:
"""
Delete an item from the queue.
@ -1140,7 +1191,7 @@ class HttpAPI(Common):
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:
"""
Update an item in the history.
@ -1187,7 +1238,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode,
)
@route("GET", "api/history")
@route("GET", "api/history", "history")
async def history(self, _: Request) -> Response:
"""
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)
@route("GET", "api/workers")
@route("GET", "api/workers", "pool_list")
async def pool_list(self, _) -> Response:
"""
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__}>>"),
)
@route("POST", "api/workers")
@route("POST", "api/workers", "pool_start")
async def pool_restart(self, _) -> Response:
"""
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)
@route("PATCH", "api/workers/{id}")
@route("PATCH", "api/workers/{id}", "worker_restart")
async def worker_restart(self, request: Request) -> Response:
"""
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)
@route("DELETE", "api/workers/{id}")
@route("DELETE", "api/workers/{id}", "worker_stop")
async def worker_stop(self, request: Request) -> Response:
"""
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)
@route("GET", "api/player/playlist/{file:.*}.m3u8")
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist")
async def playlist(self, request: Request) -> Response:
"""
Get the playlist.
@ -1327,13 +1378,19 @@ class HttpAPI(Common):
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
base_path: str = self.config.base_path.rstrip("/")
try:
realFile, status = get_file(download_path=self.config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=web.HTTPFound.status_code,
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.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={
"Content-Type": "application/x-mpegURL",
"Cache-Control": "no-cache",
@ -1352,7 +1409,7 @@ class HttpAPI(Common):
except StreamingError as e:
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:
"""
Get the m3u8 file.
@ -1383,15 +1440,22 @@ class HttpAPI(Common):
duration = float(duration)
base_path: str = self.config.base_path.rstrip("/")
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)
if web.HTTPFound.status_code == status:
return Response(
status=status,
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,
)
@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:
"""
Get the segments.
@ -1445,7 +1509,12 @@ class HttpAPI(Common):
return Response(
status=status,
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
@route("GET", "api/player/subtitle/{file:.*}.vtt")
@route("GET", "api/player/subtitle/{file:.*}.vtt", "subtitles")
async def subtitles(self, request: Request) -> Response:
"""
Get the subtitles.
@ -1509,7 +1578,11 @@ class HttpAPI(Common):
return Response(
status=status,
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,
)
@route("GET", "/")
@route("GET", "/", "index")
async def index(self, _: Request) -> Response:
"""
Get the index file.
@ -1566,7 +1639,7 @@ class HttpAPI(Common):
status=web.HTTPOk.status_code,
)
@route("GET", "api/thumbnail")
@route("GET", "api/thumbnail", "get_thumbnail")
async def get_thumbnail(self, request: Request) -> Response:
"""
Get the thumbnail.
@ -1620,7 +1693,7 @@ class HttpAPI(Common):
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:
"""
Get random background.
@ -1694,7 +1767,7 @@ class HttpAPI(Common):
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:
"""
Get the ffprobe data.
@ -1716,7 +1789,11 @@ class HttpAPI(Common):
return Response(
status=web.HTTPFound.status_code,
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:
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:
"""
Get file info
@ -1751,7 +1828,11 @@ class HttpAPI(Common):
return Response(
status=web.HTTPFound.status_code,
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)
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:
"""
Get the notification targets.
@ -1801,7 +1882,7 @@ class HttpAPI(Common):
dumps=self.encoder.encode,
)
@route("PUT", "api/notifications")
@route("PUT", "api/notifications", "notification_add")
async def notification_add(self, request: Request) -> Response:
"""
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)
@route("POST", "api/notifications/test")
@route("POST", "api/notifications/test", "notification_test")
async def notification_test(self, _: Request) -> Response:
"""
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)
@route("GET", "api/file/browser/{path:.*}")
@route("GET", "api/file/browser/{path:.*}", "file_browser")
async def file_browser(self, request: Request) -> Response:
"""
Get the file browser.

View file

@ -50,6 +50,7 @@ class HttpSocket(Common):
self.queue = queue or DownloadQueue.get_instance()
self._notify = EventBus.get_instance()
#logger=True, engineio_logger=True,
self.sio = sio or socketio.AsyncServer(cors_allowed_origins="*")
encoder = encoder or Encoder()
@ -77,7 +78,7 @@ class HttpSocket(Common):
LOG.debug("Shutting down socket server.")
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):
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():
return
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
while True:
line = await f.readline()
if not line:
await asyncio_sleep(sleep_time)
continue
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
while True:
line = await f.readline()
if not line:
await asyncio_sleep(sleep_time)
continue
msg = line.decode(errors="replace")
dt_match = DATETIME_PATTERN.match(msg)
msg = line.decode(errors="replace")
dt_match = DATETIME_PATTERN.match(msg)
await emitter(
{
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
await emitter(
{
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"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]:

View file

@ -28,9 +28,6 @@ class Config:
temp_keep: bool = False
"""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"
"""The output template to use for the downloaded files."""
@ -55,11 +52,15 @@ class Config:
log_level_file: str = "info"
"""The log level to use for the file logging."""
base_path: str = "/"
"""The base path to use for the application."""
max_workers: int = 1
"""The maximum number of workers to use for downloading."""
streamer_vcodec: str = "libx264"
"""The video codec to use for streaming."""
streamer_acodec: str = "aac"
"""The audio codec to use for streaming."""
@ -221,6 +222,7 @@ class Config:
"browser_enabled",
"ytdlp_cli",
"file_logging",
"base_path",
)
"The variables that are relevant to the frontend."

View file

@ -23,6 +23,7 @@ from library.Tasks import Tasks
LOG = logging.getLogger("app")
MIME = magic.Magic(mime=True)
class Main:
def __init__(self):
self._config = Config.get_instance()
@ -99,14 +100,16 @@ class Main:
def started(_):
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)
HTTP_LOGGER = None
if self._config.access_log:
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(
self._app,

View file

@ -107,8 +107,8 @@
</label>
</td>
<td class="is-vcentered">
<div v-if="item.extras.thumbnail">
<FloatingImage :image="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)"
<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>
@ -198,53 +198,52 @@
</div>
<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">
<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 }">
<header class="card-header has-tooltip">
<header class="card-header ">
<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 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)"
v-tooltip="'Play video.'">
<span class="icon"><i class="fa-solid fa-play" /></span>
</a>
<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>
</a>
</span>
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)">
<span class="icon"><i class="fa-solid fa-copy" /></span>
</a>
<button @click="hideThumbnail = !hideThumbnail">
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span>
</button>
</div>
</header>
<div v-if="false === hideThumbnail" class="card-image">
<div v-if="showThumbnails" class="card-image">
<figure class="image is-3by1">
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
<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" />
<img v-else src="/images/placeholder.png" />
</span>
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay">
<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" />
<img v-else src="/images/placeholder.png" />
</span>
<template v-else>
<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" />
</template>
</figure>
@ -378,10 +377,18 @@
<script setup>
import moment from 'moment'
import { useStorage } from '@vueuse/core'
import { makeDownload, formatBytes } from '~/utils/index'
import { makeDownload, formatBytes, uri } from '~/utils/index'
import { isEmbedable, getEmbedable } from '~/utils/embedable'
const emitter = defineEmits(['getInfo', 'add_new'])
const props = defineProps({
thumbnails: {
type: Boolean,
default: true
}
})
const config = useConfigStore()
const stateStore = useStateStore()
const socket = useSocketStore()
@ -401,6 +408,8 @@ const video_item = ref(null)
const playVideo = item => video_item.value = item
const closeVideo = () => video_item.value = null
const showThumbnails = computed(() => props.thumbnails && !hideThumbnail.value)
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85)

View file

@ -65,8 +65,20 @@
:id="'checkbox-' + item._id" :value="item._id">
</label>
</td>
<td class="is-text-overflow is-vcentered" v-tooltip="item.title">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
<td class="is-text-overflow is-vcentered">
<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 class="has-text-centered is-text-overflow is-unselectable">
<span class="icon-text">
@ -116,7 +128,7 @@
</div>
<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">
<div class="card is-flex is-full-height is-flex-direction-column">
<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)">
<span class="icon"><i class="fa-solid fa-copy" /></span>
</a>
<button @click="hideThumbnail = !hideThumbnail">
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span>
</button>
</div>
</header>
<div v-if="false === hideThumbnail" class="card-image">
<div v-if="showThumbnails" class="card-image">
<figure class="image is-3by1">
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay">
<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" />
<img v-else src="/images/placeholder.png" />
</span>
<template v-else>
<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" />
</template>
</figure>
@ -235,6 +247,12 @@ import { ucFirst } from '~/utils/index'
import { isEmbedable, getEmbedable } from '~/utils/embedable'
const emitter = defineEmits(['getInfo'])
const props = defineProps({
thumbnails: {
type: Boolean,
default: true
}
})
const config = useConfigStore()
const stateStore = useStateStore()
const socket = useSocketStore()
@ -245,6 +263,7 @@ const masterSelectAll = ref(false)
const showQueue = useStorage('showQueue', true)
const hideThumbnail = useStorage('hideThumbnailQueue', false)
const display_style = useStorage('display_style', 'cards')
const showThumbnails = computed(() => props.thumbnails && !hideThumbnail.value)
const embed_url = ref('')

View file

@ -11,92 +11,87 @@
<div class="card-content">
<div class="columns is-multiline">
<div class="column is-6">
<div class="field">
<label class="label">Color scheme</label>
<label class="label is-unselectable">Color scheme</label>
<div class="control">
<label for="auto" class="radio">
<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 for="light" class="radio">
<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 for="dark" class="radio">
<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>
</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 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">
<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>
<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 class="field">
<label class="label" for="random_bg_opacity">
Background Visibility: (<code>{{ bg_opacity }}</code>)
<label class="label is-unselectable" for="random_bg_opacity">
Background visibility <code>{{ parseFloat(1.0 - bg_opacity).toFixed(2) }}</code>
</label>
<div class="control">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50"
max="1.00" step="0.05">
</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 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 class="column is-6">
<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">
<input id="reduce_confirm" type="checkbox" class="switch is-success" v-model="reduce_confirm">
<label for="reduce_confirm" class="is-unselectable">
&nbsp;{{ reduce_confirm ? 'Enabled' : 'Disabled' }}
</label>
</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 class="field">
<label class="label" for="allow_toasts">Show toasts</label>
<label class="label" for="allow_toasts">Show notifications toasts</label>
<div class="control">
<input id="allow_toasts" type="checkbox" class="switch is-success" v-model="allow_toasts">
<label for="allow_toasts" class="is-unselectable">
&nbsp;{{ allow_toasts ? 'Enabled' : 'Disabled' }}
</label>
</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 class="field">
<div class="field" v-if="allow_toasts">
<label class="label">Toasts position</label>
<div class="control">
<div class="select is-fullwidth">
@ -112,7 +107,7 @@
</div>
</div>
<div class="field">
<div class="field" v-if="allow_toasts">
<label class="label" for="dismiss_on_click">Dismiss toasts on click</label>
<div class="control">
<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 toast_position = useStorage('toast_position', POSITION.TOP_RIGHT)
const toast_dismiss_on_click = useStorage('toast_dismiss_on_click', true)
const show_thumbnail = useStorage('show_thumbnail', true)
</script>

View file

@ -10,7 +10,7 @@
</style>
<template>
<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>
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
:type="source.type" />

View file

@ -30,9 +30,8 @@ export default defineNuxtConfig({
],
runtimeConfig: {
public: {
domain: '/',
APP_ENV: process.env.APP_ENV || "production",
wss: process.env.VUE_APP_BASE_URL ?? '',
version: '2.0.0',
sentry: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
}
},
@ -40,6 +39,7 @@ export default defineNuxtConfig({
transpile: ['vue-toastification'],
},
app: {
baseURL: 'dev' == process.env.APP_ENV ? '/' : '',
buildAssetsDir: "assets",
head: {
"meta": [
@ -47,7 +47,8 @@ export default defineNuxtConfig({
{ "name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0" },
{ "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' }
},
@ -66,7 +67,7 @@ export default defineNuxtConfig({
nitro: {
output: {
publicDir: path.join(__dirname, 'exported')
publicDir: path.join(__dirname, 'dev' == process.env.APP_ENV ? 'dist' : 'exported')
},
...extraNitro,
},

View file

@ -94,7 +94,7 @@
<td class="is-text-overflow is-vcentered">
<div class="field is-grouped">
<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 }}
</a>
<a :href="makeDownload({}, { filename: item.path, folder: '' })"
@ -321,7 +321,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
}
const title = `File Browser: /${dir}`
const stateUrl = `/browser/${dir}`
const stateUrl = uri(`/browser/${dir}`)
if (false === fromMounted) {
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"
@clear_form="item_form = {}" />
<Queue @getInfo="url => get_info = url" />
<History @getInfo="url => get_info = url" @add_new="item => toNewDownload(item)" />
<Queue @getInfo="url => get_info = url" :thumbnails="show_thumbnail" />
<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 = ''" />
</div>
</template>
@ -68,6 +68,8 @@ const get_info = ref('')
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85)
const display_style = useStorage('display_style', 'cards')
const show_thumbnail = useStorage('show_thumbnail', true)
const item_form = ref({})
onMounted(() => {

View file

@ -7,11 +7,20 @@ export const useSocketStore = defineStore('socket', () => {
const stateStore = useStateStore()
const toast = useNotification()
const socket = ref(null);
const isConnected = ref(false);
const socket = ref(null)
const isConnected = ref(false)
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('disconnect', () => isConnected.value = false);

View file

@ -1,4 +1,5 @@
const toast = useNotification()
const runtimeConfig = useRuntimeConfig()
const AG_SEPARATOR = '.'
/**
@ -237,7 +238,6 @@ const encodePath = item => {
* @returns {Promise<Response>} - The response from the API
*/
const request = (url, options = {}) => {
const runtimeConfig = useRuntimeConfig()
options = options || {}
options.method = options.method || 'GET'
options.headers = options.headers || {}
@ -257,7 +257,7 @@ const request = (url, options = {}) => {
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)}`;
return ('m3u8' === base) ? url + '.m3u8' : url;
return uri(('m3u8' === base) ? url + '.m3u8' : url);
}
/**
@ -492,6 +493,18 @@ const cleanObject = (item, fields = []) => {
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 {
ag_set,
ag,
@ -519,4 +532,5 @@ export {
has_data,
toggleClass,
cleanObject,
uri,
}