WiP: More migration to Path
This commit is contained in:
parent
93ff749dee
commit
6c8fd05886
12 changed files with 320 additions and 303 deletions
84
API.md
84
API.md
|
|
@ -26,10 +26,6 @@ This document describes the available endpoints and their usage. All endpoints r
|
||||||
- [GET /api/history](#get-apihistory)
|
- [GET /api/history](#get-apihistory)
|
||||||
- [GET /api/tasks](#get-apitasks)
|
- [GET /api/tasks](#get-apitasks)
|
||||||
- [PUT /api/tasks](#put-apitasks)
|
- [PUT /api/tasks](#put-apitasks)
|
||||||
- [GET /api/workers](#get-apiworkers)
|
|
||||||
- [POST /api/workers](#post-apiworkers)
|
|
||||||
- [PATCH /api/workers/{id}](#patch-apiworkersid)
|
|
||||||
- [DELETE /api/workers/{id}](#delete-apiworkersid)
|
|
||||||
- [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8)
|
- [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8)
|
||||||
- [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
|
- [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
|
||||||
- [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets)
|
- [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets)
|
||||||
|
|
@ -391,86 +387,6 @@ or on error
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### GET /api/workers
|
|
||||||
**Purpose**: Returns the status of the worker pool and all workers.
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"open": true|false,
|
|
||||||
"count": 4,
|
|
||||||
"workers": [
|
|
||||||
{
|
|
||||||
"id": "worker-1",
|
|
||||||
"data": { "status": "downloading", ... }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "worker-2",
|
|
||||||
"data": { "status": "Waiting for download." }
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `open`: Indicates if there are any available workers.
|
|
||||||
- `count`: Total number of available workers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### POST /api/workers
|
|
||||||
**Purpose**: Restart the entire worker pool.
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Workers pool being restarted."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### PATCH /api/workers/{id}
|
|
||||||
**Purpose**: Restart a single worker by ID.
|
|
||||||
|
|
||||||
**Path Parameter**:
|
|
||||||
- `id` = The worker ID.
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "restarted"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
or
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "in_error_state"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### DELETE /api/workers/{id}
|
|
||||||
**Purpose**: Stop a single worker by ID.
|
|
||||||
|
|
||||||
**Path Parameter**:
|
|
||||||
- `id` = The worker ID.
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "stopped"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
or
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "in_error_state"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /api/player/playlist/{file:.*}.m3u8
|
### GET /api/player/playlist/{file:.*}.m3u8
|
||||||
**Purpose**: Generate a playlist for a given local media file.
|
**Purpose**: Generate a playlist for a given local media file.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ class HttpAPI(Common):
|
||||||
self.routes = web.RouteTableDef()
|
self.routes = web.RouteTableDef()
|
||||||
self.cache = Cache()
|
self.cache = Cache()
|
||||||
self.app: web.Application | None = None
|
self.app: web.Application | None = None
|
||||||
|
self.isRequestingBackground = False
|
||||||
|
|
||||||
super().__init__(queue=self.queue, encoder=self.encoder, config=self.config)
|
super().__init__(queue=self.queue, encoder=self.encoder, config=self.config)
|
||||||
|
|
||||||
|
|
@ -1259,109 +1260,6 @@ 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", "pool_list")
|
|
||||||
async def pool_list(self, _) -> Response:
|
|
||||||
"""
|
|
||||||
Get the workers status.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
_: The request object.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if self.queue.pool is None:
|
|
||||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
|
||||||
|
|
||||||
status = self.queue.pool.get_workers_status()
|
|
||||||
|
|
||||||
data = []
|
|
||||||
|
|
||||||
for worker in status:
|
|
||||||
worker_status = status.get(worker)
|
|
||||||
data.append(
|
|
||||||
{
|
|
||||||
"id": worker,
|
|
||||||
"data": {"status": "Waiting for download."} if worker_status is None else worker_status,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return web.json_response(
|
|
||||||
data={
|
|
||||||
"open": self.queue.pool.has_open_workers(),
|
|
||||||
"count": self.queue.pool.get_available_workers(),
|
|
||||||
"workers": data,
|
|
||||||
},
|
|
||||||
status=web.HTTPOk.status_code,
|
|
||||||
dumps=lambda obj: json.dumps(obj, default=lambda o: f"<<non-serializable: {type(o).__qualname__}>>"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@route("POST", "api/workers", "pool_start")
|
|
||||||
async def pool_restart(self, _) -> Response:
|
|
||||||
"""
|
|
||||||
Restart the workers pool.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
_: The request object.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if self.queue.pool is None:
|
|
||||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
|
||||||
|
|
||||||
self.queue.pool.start()
|
|
||||||
|
|
||||||
return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code)
|
|
||||||
|
|
||||||
@route("PATCH", "api/workers/{id}", "worker_restart")
|
|
||||||
async def worker_restart(self, request: Request) -> Response:
|
|
||||||
"""
|
|
||||||
Restart a worker.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request (Request): The request object.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object
|
|
||||||
|
|
||||||
"""
|
|
||||||
id: str = request.match_info.get("id")
|
|
||||||
if not id:
|
|
||||||
return web.json_response({"error": "worker id is required."}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
if self.queue.pool is None:
|
|
||||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
|
||||||
|
|
||||||
status = await self.queue.pool.restart(id, "requested by user.")
|
|
||||||
|
|
||||||
return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code)
|
|
||||||
|
|
||||||
@route("DELETE", "api/workers/{id}", "worker_stop")
|
|
||||||
async def worker_stop(self, request: Request) -> Response:
|
|
||||||
"""
|
|
||||||
Stop a worker.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request (Request): The request object.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object.
|
|
||||||
|
|
||||||
"""
|
|
||||||
id: str = request.match_info.get("id")
|
|
||||||
if not id:
|
|
||||||
raise web.HTTPBadRequest(text="worker id is required.")
|
|
||||||
|
|
||||||
if self.queue.pool is None:
|
|
||||||
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
|
||||||
|
|
||||||
status = await self.queue.pool.stop(id, "requested by user.")
|
|
||||||
|
|
||||||
return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code)
|
|
||||||
|
|
||||||
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist")
|
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist")
|
||||||
async def playlist(self, request: Request) -> Response:
|
async def playlist(self, request: Request) -> Response:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1399,7 +1297,9 @@ 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=f"{base_path}/").make(file=realFile),
|
text=await Playlist(download_path=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",
|
||||||
|
|
@ -1444,7 +1344,7 @@ class HttpAPI(Common):
|
||||||
base_path: str = self.config.base_path.rstrip("/")
|
base_path: str = self.config.base_path.rstrip("/")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cls = M3u8(download_path=self.config.download_path, url=f"{base_path}/")
|
cls = M3u8(download_path=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:
|
||||||
|
|
@ -1708,7 +1608,11 @@ class HttpAPI(Common):
|
||||||
"""
|
"""
|
||||||
backend = None
|
backend = None
|
||||||
|
|
||||||
|
if self.isRequestingBackground:
|
||||||
|
return web.Response(status=web.HTTPTooManyRequests.status_code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
self.isRequestingBackground = True
|
||||||
backend = random.choice(self.config.pictures_backends) # noqa: S311
|
backend = random.choice(self.config.pictures_backends) # noqa: S311
|
||||||
CACHE_KEY_BING = "random_background_bing"
|
CACHE_KEY_BING = "random_background_bing"
|
||||||
CACHE_KEY = "random_background"
|
CACHE_KEY = "random_background"
|
||||||
|
|
@ -1776,6 +1680,8 @@ class HttpAPI(Common):
|
||||||
|
|
||||||
await self.cache.aset(key=CACHE_KEY, value=data, ttl=3600)
|
await self.cache.aset(key=CACHE_KEY, value=data, ttl=3600)
|
||||||
|
|
||||||
|
LOG.debug(f"Random background image from '{backend!s}' cached.")
|
||||||
|
|
||||||
return web.Response(
|
return web.Response(
|
||||||
body=data.get("content"),
|
body=data.get("content"),
|
||||||
headers={
|
headers={
|
||||||
|
|
@ -1791,6 +1697,8 @@ class HttpAPI(Common):
|
||||||
data={"error": "failed to retrieve the random background image."},
|
data={"error": "failed to retrieve the random background image."},
|
||||||
status=web.HTTPInternalServerError.status_code,
|
status=web.HTTPInternalServerError.status_code,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
self.isRequestingBackground = False
|
||||||
|
|
||||||
@route("GET", "api/file/ffprobe/{file:.*}", "ffprobe")
|
@route("GET", "api/file/ffprobe/{file:.*}", "ffprobe")
|
||||||
async def get_ffprobe(self, request: Request) -> Response:
|
async def get_ffprobe(self, request: Request) -> Response:
|
||||||
|
|
@ -2017,3 +1925,159 @@ class HttpAPI(Common):
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
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/dev/loop")
|
||||||
|
async def debug_asyncio(self, _: Request) -> Response:
|
||||||
|
if not self.config.is_dev():
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "This endpoint is only available in development mode."},
|
||||||
|
status=web.HTTPForbidden.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for task in asyncio.all_tasks():
|
||||||
|
task_info = {"task": str(task), "stack": []}
|
||||||
|
for frame in task.get_stack():
|
||||||
|
formatted = traceback.format_stack(f=frame)
|
||||||
|
task_info["stack"].extend(formatted)
|
||||||
|
|
||||||
|
tasks.append(task_info)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
data={
|
||||||
|
"total_tasks": len(tasks),
|
||||||
|
"loop": str(asyncio.get_event_loop()),
|
||||||
|
"tasks": tasks,
|
||||||
|
},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
dumps=self.encoder.encode,
|
||||||
|
)
|
||||||
|
|
||||||
|
@route("GET", "api/dev/workers", "pool_list")
|
||||||
|
async def pool_list(self, _) -> Response:
|
||||||
|
"""
|
||||||
|
Get the workers status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
_: The request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: The response object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not self.config.is_dev():
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "This endpoint is only available in development mode."},
|
||||||
|
status=web.HTTPNotFound.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.queue.pool is None:
|
||||||
|
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
|
status = self.queue.pool.get_workers_status()
|
||||||
|
|
||||||
|
data = []
|
||||||
|
|
||||||
|
for worker in status:
|
||||||
|
worker_status = status.get(worker)
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"id": worker,
|
||||||
|
"data": {"status": "Waiting for download."} if worker_status is None else worker_status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
data={
|
||||||
|
"open": self.queue.pool.has_open_workers(),
|
||||||
|
"count": self.queue.pool.get_available_workers(),
|
||||||
|
"workers": data,
|
||||||
|
},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
dumps=lambda obj: json.dumps(obj, default=lambda o: f"<<non-serializable: {type(o).__qualname__}>>"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@route("POST", "api/dev/workers", "pool_start")
|
||||||
|
async def pool_restart(self, _) -> Response:
|
||||||
|
"""
|
||||||
|
Restart the workers pool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
_: The request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: The response object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not self.config.is_dev():
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "This endpoint is only available in development mode."},
|
||||||
|
status=web.HTTPNotFound.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.queue.pool is None:
|
||||||
|
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
|
self.queue.pool.start()
|
||||||
|
|
||||||
|
return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code)
|
||||||
|
|
||||||
|
@route("PATCH", "api/dev/workers/{id}", "worker_restart")
|
||||||
|
async def worker_restart(self, request: Request) -> Response:
|
||||||
|
"""
|
||||||
|
Restart a worker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (Request): The request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: The response object
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not self.config.is_dev():
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "This endpoint is only available in development mode."},
|
||||||
|
status=web.HTTPNotFound.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
worker_id: str = request.match_info.get("id")
|
||||||
|
if not worker_id:
|
||||||
|
return web.json_response({"error": "worker id is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
if self.queue.pool is None:
|
||||||
|
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
|
status = await self.queue.pool.restart(worker_id, "requested by user.")
|
||||||
|
|
||||||
|
return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code)
|
||||||
|
|
||||||
|
@route("DELETE", "api/dev/workers/{id}", "worker_stop")
|
||||||
|
async def worker_stop(self, request: Request) -> Response:
|
||||||
|
"""
|
||||||
|
Stop a worker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (Request): The request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: The response object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not self.config.is_dev():
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "This endpoint is only available in development mode."},
|
||||||
|
status=web.HTTPNotFound.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
worker_id: str = request.match_info.get("id")
|
||||||
|
if not worker_id:
|
||||||
|
raise web.HTTPBadRequest(text="worker id is required.")
|
||||||
|
|
||||||
|
if self.queue.pool is None:
|
||||||
|
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
|
status = await self.queue.pool.stop(worker_id, "requested by user.")
|
||||||
|
|
||||||
|
return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,16 @@ class HttpSocket(Common):
|
||||||
self._notify = EventBus.get_instance()
|
self._notify = EventBus.get_instance()
|
||||||
|
|
||||||
# logger=True, engineio_logger=True,
|
# logger=True, engineio_logger=True,
|
||||||
self.sio = sio or socketio.AsyncServer(async_mode="aiohttp", cors_allowed_origins="*")
|
self.sio = sio or socketio.AsyncServer(
|
||||||
|
async_handlers=True,
|
||||||
|
async_mode="aiohttp",
|
||||||
|
cors_allowed_origins=[],
|
||||||
|
transports=["websocket"],
|
||||||
|
logger=self.config.debug,
|
||||||
|
engineio_logger=self.config.debug,
|
||||||
|
ping_interval=10,
|
||||||
|
ping_timeout=5,
|
||||||
|
)
|
||||||
encoder = encoder or Encoder()
|
encoder = encoder or Encoder()
|
||||||
|
|
||||||
def emit(e: Event, _, **kwargs):
|
def emit(e: Event, _, **kwargs):
|
||||||
|
|
@ -77,6 +86,12 @@ class HttpSocket(Common):
|
||||||
async def on_shutdown(self, _: web.Application):
|
async def on_shutdown(self, _: web.Application):
|
||||||
LOG.debug("Shutting down socket server.")
|
LOG.debug("Shutting down socket server.")
|
||||||
|
|
||||||
|
for sid in self.sio.manager.get_participants("/", None):
|
||||||
|
LOG.debug(f"Disconnecting client '{sid}'.")
|
||||||
|
await self.sio.disconnect(sid[0], namespace="/")
|
||||||
|
|
||||||
|
LOG.debug("Socket server shutdown complete.")
|
||||||
|
|
||||||
def attach(self, app: web.Application):
|
def attach(self, app: web.Application):
|
||||||
self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
|
self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ class M3u8:
|
||||||
ok_vcodecs: tuple = ("h264", "x264", "avc")
|
ok_vcodecs: tuple = ("h264", "x264", "avc")
|
||||||
ok_acodecs: tuple = ("aac", "m4a", "mp3")
|
ok_acodecs: tuple = ("aac", "m4a", "mp3")
|
||||||
|
|
||||||
def __init__(self, download_path: str, url: str, segment_duration: float | None = None):
|
def __init__(self, download_path: Path, url: str, segment_duration: float | None = None):
|
||||||
self.url = url
|
self.url = url
|
||||||
self.download_path = download_path
|
self.download_path: Path = download_path
|
||||||
self.duration = float(segment_duration) if segment_duration is not None else self.duration
|
self.duration = float(segment_duration) if segment_duration is not None else self.duration
|
||||||
|
|
||||||
async def make_stream(self, file: Path) -> str:
|
async def make_stream(self, file: Path) -> str:
|
||||||
|
|
@ -23,7 +23,7 @@ class M3u8:
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
file = str(file).replace(self.download_path, "").strip("/")
|
urlPath: str = str(file.relative_to(self.download_path).as_posix()).strip("/")
|
||||||
|
|
||||||
if "duration" not in ff.metadata:
|
if "duration" not in ff.metadata:
|
||||||
error = f"Unable to get '{file}' play duration."
|
error = f"Unable to get '{file}' play duration."
|
||||||
|
|
@ -31,7 +31,7 @@ class M3u8:
|
||||||
|
|
||||||
duration: float = float(ff.metadata.get("duration"))
|
duration: float = float(ff.metadata.get("duration"))
|
||||||
|
|
||||||
m3u8 = []
|
m3u8: list = []
|
||||||
|
|
||||||
m3u8.append("#EXTM3U")
|
m3u8.append("#EXTM3U")
|
||||||
m3u8.append("#EXT-X-VERSION:3")
|
m3u8.append("#EXT-X-VERSION:3")
|
||||||
|
|
@ -57,7 +57,7 @@ class M3u8:
|
||||||
|
|
||||||
m3u8.append(f"#EXTINF:{segmentSize},")
|
m3u8.append(f"#EXTINF:{segmentSize},")
|
||||||
|
|
||||||
url = f"{self.url}api/player/segments/{i}/{quote(file)}.ts"
|
url = f"{self.url}api/player/segments/{i}/{quote(urlPath)}.ts"
|
||||||
if len(segmentParams) > 0:
|
if len(segmentParams) > 0:
|
||||||
url += "?" + "&".join([f"{key}={value}" for key, value in segmentParams.items()])
|
url += "?" + "&".join([f"{key}={value}" for key, value in segmentParams.items()])
|
||||||
|
|
||||||
|
|
@ -70,13 +70,15 @@ class M3u8:
|
||||||
async def make_subtitle(self, file: Path, duration: float) -> str:
|
async def make_subtitle(self, file: Path, duration: float) -> str:
|
||||||
m3u8 = []
|
m3u8 = []
|
||||||
|
|
||||||
|
urlPath: str = str(file.relative_to(self.download_path).as_posix()).strip("/")
|
||||||
|
|
||||||
m3u8.append("#EXTM3U")
|
m3u8.append("#EXTM3U")
|
||||||
m3u8.append("#EXT-X-VERSION:3")
|
m3u8.append("#EXT-X-VERSION:3")
|
||||||
m3u8.append(f"#EXT-X-TARGETDURATION:{int(self.duration)}")
|
m3u8.append(f"#EXT-X-TARGETDURATION:{int(self.duration)}")
|
||||||
m3u8.append("#EXT-X-MEDIA-SEQUENCE:0")
|
m3u8.append("#EXT-X-MEDIA-SEQUENCE:0")
|
||||||
m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD")
|
m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD")
|
||||||
m3u8.append(f"#EXTINF:{duration},")
|
m3u8.append(f"#EXTINF:{duration},")
|
||||||
m3u8.append(f"{self.url}api/player/subtitle/{quote(str(file).replace(self.download_path, '').strip('/'))}.vtt")
|
m3u8.append(f"{self.url}api/player/subtitle/{quote(urlPath)}.vtt")
|
||||||
m3u8.append("#EXT-X-ENDLIST")
|
m3u8.append("#EXT-X-ENDLIST")
|
||||||
|
|
||||||
return "\n".join(m3u8)
|
return "\n".join(m3u8)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
LOG = logging.getLogger("package_installer")
|
LOG = logging.getLogger("package_installer")
|
||||||
|
|
||||||
|
|
@ -12,11 +13,15 @@ class Packages:
|
||||||
from_env = env.split() if env else []
|
from_env = env.split() if env else []
|
||||||
from_file = []
|
from_file = []
|
||||||
|
|
||||||
if os.path.exists(file) and os.access(file, os.R_OK):
|
if file:
|
||||||
with open(file) as f:
|
file = Path(file)
|
||||||
from_file = [pkg.strip() for pkg in f if pkg.strip()]
|
if file.exists() and os.access(str(file), os.R_OK):
|
||||||
|
with open(file) as f:
|
||||||
|
from_file: list[str] = [pkg.strip() for pkg in f if pkg.strip()]
|
||||||
|
else:
|
||||||
|
LOG.error(f"pip packages file '{file}' doesn't exist or is not readable.")
|
||||||
|
|
||||||
self.packages: list = list(set(from_env + from_file))
|
self.packages: list[str] = list(set(from_env + from_file))
|
||||||
self.upgrade = bool(upgrade)
|
self.upgrade = bool(upgrade)
|
||||||
|
|
||||||
def has_packages(self) -> bool:
|
def has_packages(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ from .Utils import StreamingError, get_file_sidecar
|
||||||
class Playlist:
|
class Playlist:
|
||||||
_url: str = None
|
_url: str = None
|
||||||
|
|
||||||
def __init__(self, download_path: str, url: str):
|
def __init__(self, download_path: Path, url: str):
|
||||||
self.url = url
|
self.url: str = url
|
||||||
self.download_path = download_path
|
self.download_path: Path = download_path
|
||||||
|
|
||||||
async def make(self, file: Path) -> str:
|
async def make(self, file: Path) -> str:
|
||||||
ref = str(file).replace(self.download_path, "").strip("/")
|
ref: str = Path(str(file.relative_to(self.download_path)).strip("/"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ff = await ffprobe(file)
|
ff = await ffprobe(file)
|
||||||
|
|
@ -24,19 +24,19 @@ class Playlist:
|
||||||
msg = f"Unable to get '{ref}' duration."
|
msg = f"Unable to get '{ref}' duration."
|
||||||
raise StreamingError(msg)
|
raise StreamingError(msg)
|
||||||
|
|
||||||
playlist = []
|
playlist: list[str] = []
|
||||||
playlist.append("#EXTM3U")
|
playlist.append("#EXTM3U")
|
||||||
|
|
||||||
subs = ""
|
subs: str = ""
|
||||||
|
|
||||||
duration: float = float(ff.metadata.get("duration"))
|
duration: float = float(ff.metadata.get("duration"))
|
||||||
for sub_file in get_file_sidecar(file).get("subtitle", []):
|
for sub_file in get_file_sidecar(file).get("subtitle", []):
|
||||||
lang = sub_file["lang"]
|
lang: str = sub_file["lang"]
|
||||||
item = Path(sub_file["file"])
|
item: Path = sub_file["file"]
|
||||||
name = sub_file["name"]
|
name: str = sub_file["name"]
|
||||||
|
|
||||||
subs = ',SUBTITLES="subs"'
|
subs = ',SUBTITLES="subs"'
|
||||||
url = f"{self.url}api/player/m3u8/subtitle/{quote(str(Path(ref).with_name(item.name)))}.m3u8?duration={duration}"
|
url = f"{self.url}api/player/m3u8/subtitle/{quote(str(ref.with_name(item.name)))}.m3u8?duration={duration}"
|
||||||
playlist.append(
|
playlist.append(
|
||||||
f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{url}"'
|
f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{url}"'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
@ -11,10 +11,51 @@ from .config import Config
|
||||||
from .encoder import Encoder
|
from .encoder import Encoder
|
||||||
from .Events import EventBus, Events
|
from .Events import EventBus, Events
|
||||||
from .Singleton import Singleton
|
from .Singleton import Singleton
|
||||||
from .Utils import arg_converter, clean_item
|
from .Utils import arg_converter, init_class
|
||||||
|
|
||||||
LOG = logging.getLogger("presets")
|
LOG = logging.getLogger("presets")
|
||||||
|
|
||||||
|
DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [
|
||||||
|
{
|
||||||
|
"id": "3e163c6c-64eb-4448-924f-814b629b3810",
|
||||||
|
"name": "default",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5bf9c42b-8852-468a-99f5-915622dfba25",
|
||||||
|
"name": "Best video and audio",
|
||||||
|
"cli": "--format 'bv+ba/b'",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b",
|
||||||
|
"name": "1080p H264/m4a or best available",
|
||||||
|
"cli": "-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e",
|
||||||
|
"name": "720p h264/m4a or best available",
|
||||||
|
"cli": "-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
|
||||||
|
"name": "Audio only",
|
||||||
|
"cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60",
|
||||||
|
"name": "yt-dlp info reader plugin",
|
||||||
|
"description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary',
|
||||||
|
"folder": "youtube",
|
||||||
|
"template": "%(channel)s %(channel_id|Unknown_id)s/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s",
|
||||||
|
"cli": "--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
class Preset:
|
class Preset:
|
||||||
|
|
@ -65,28 +106,26 @@ class Presets(metaclass=Singleton):
|
||||||
|
|
||||||
_default: list[Preset] = []
|
_default: list[Preset] = []
|
||||||
|
|
||||||
def __init__(self, file: str | None = None, config: Config | None = None):
|
def __init__(self, file: str | Path | None = None, config: Config | None = None):
|
||||||
Presets._instance = self
|
Presets._instance = self
|
||||||
|
|
||||||
config = config or Config.get_instance()
|
config = config or Config.get_instance()
|
||||||
|
|
||||||
self._file: str = file or os.path.join(config.config_path, "presets.json")
|
self._file: Path = Path(file) if file else Path(config.config_path).joinpath("presets.json")
|
||||||
|
|
||||||
if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]:
|
if self._file.exists() and "600" != self._file.stat().st_mode:
|
||||||
try:
|
try:
|
||||||
os.chmod(self._file, 0o600)
|
self._file.chmod(0o600)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
default_file = os.path.join(os.path.dirname(__file__), "presets.json")
|
for i, preset in enumerate(DEFAULT_PRESETS):
|
||||||
with open(default_file) as f:
|
try:
|
||||||
for i, preset in enumerate(json.load(f)):
|
self.validate(preset)
|
||||||
try:
|
self._default.append(init_class(Preset, preset))
|
||||||
self.validate(preset)
|
except Exception as e:
|
||||||
self._default.append(Preset(**preset))
|
LOG.error(f"Failed to parse default preset ':{i}'. '{e!s}'.")
|
||||||
except Exception as e:
|
continue
|
||||||
LOG.error(f"Failed to parse '{default_file}:{i}'. '{e!s}'.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
def event_handler(_, __):
|
def event_handler(_, __):
|
||||||
msg = "Not implemented"
|
msg = "Not implemented"
|
||||||
|
|
@ -138,13 +177,12 @@ class Presets(metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
if not os.path.exists(self._file) or os.path.getsize(self._file) < 10:
|
if not self._file.exists() or self._file.stat().st_size < 10:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
LOG.info(f"Loading '{self._file}'.")
|
|
||||||
try:
|
try:
|
||||||
with open(self._file) as f:
|
LOG.info(f"Loading '{self._file}'.")
|
||||||
presets = json.load(f)
|
presets: dict = json.loads(self._file.read_text())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
|
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
|
||||||
return self
|
return self
|
||||||
|
|
@ -160,7 +198,6 @@ class Presets(metaclass=Singleton):
|
||||||
preset["id"] = str(uuid.uuid4())
|
preset["id"] = str(uuid.uuid4())
|
||||||
need_save = True
|
need_save = True
|
||||||
|
|
||||||
preset, preset_status = clean_item(preset, keys=("args", "postprocessors"))
|
|
||||||
if preset.get("format"):
|
if preset.get("format"):
|
||||||
if not preset.get("cli"):
|
if not preset.get("cli"):
|
||||||
preset.update({"cli": f"--format {preset['format']}"})
|
preset.update({"cli": f"--format {preset['format']}"})
|
||||||
|
|
@ -172,10 +209,7 @@ class Presets(metaclass=Singleton):
|
||||||
preset.pop("format")
|
preset.pop("format")
|
||||||
need_save = True
|
need_save = True
|
||||||
|
|
||||||
preset = Preset(**preset)
|
preset: Preset = init_class(Preset, preset)
|
||||||
|
|
||||||
if preset_status:
|
|
||||||
need_save = True
|
|
||||||
|
|
||||||
self._items.append(preset)
|
self._items.append(preset)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -183,7 +217,7 @@ class Presets(metaclass=Singleton):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if need_save:
|
if need_save:
|
||||||
LOG.info(f"Saving '{self._file}' due to changes.")
|
LOG.info(f"Saving '{self._file}'.")
|
||||||
self.save(self._items)
|
self.save(self._items)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
@ -255,7 +289,7 @@ class Presets(metaclass=Singleton):
|
||||||
for i, preset in enumerate(items):
|
for i, preset in enumerate(items):
|
||||||
try:
|
try:
|
||||||
if not isinstance(preset, Preset):
|
if not isinstance(preset, Preset):
|
||||||
preset = Preset(**preset)
|
preset: Preset = init_class(Preset, preset)
|
||||||
items[i] = preset
|
items[i] = preset
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.")
|
LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.")
|
||||||
|
|
@ -268,8 +302,9 @@ class Presets(metaclass=Singleton):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self._file, "w") as f:
|
self._file.write_text(
|
||||||
json.dump(obj=[preset.serialize() for preset in items if preset.default is False], fp=f, indent=4)
|
json.dumps(obj=[preset.serialize() for preset in items if preset.default is False], indent=4)
|
||||||
|
)
|
||||||
|
|
||||||
LOG.info(f"Saved '{self._file}'.")
|
LOG.info(f"Saved '{self._file}'.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from datetime import UTC, datetime, timedelta
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from http.cookiejar import MozillaCookieJar
|
from http.cookiejar import MozillaCookieJar
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
|
|
@ -60,6 +61,8 @@ FILES_TYPE: list = [
|
||||||
|
|
||||||
DATETIME_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
|
DATETIME_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class StreamingError(Exception):
|
class StreamingError(Exception):
|
||||||
"""Raised when an error occurs during streaming."""
|
"""Raised when an error occurs during streaming."""
|
||||||
|
|
@ -1180,7 +1183,7 @@ def delete_dir(dir: Path) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def init_class(cls: type, data: dict):
|
def init_class(cls: type[T], data: dict) -> T:
|
||||||
"""
|
"""
|
||||||
Initialize a class instance with data from a dictionary, filtering out keys not present in the class fields.
|
Initialize a class instance with data from a dictionary, filtering out keys not present in the class fields.
|
||||||
|
|
||||||
|
|
@ -1189,7 +1192,7 @@ def init_class(cls: type, data: dict):
|
||||||
data (dict): The data to use for initialization.
|
data (dict): The data to use for initialization.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
object: An instance of the class initialized with the provided data.
|
T: An instance of the class initialized with the provided data.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from dataclasses import fields
|
from dataclasses import fields
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ from .version import APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
app_env: str = "production"
|
||||||
|
"""The application environment, can be 'production' or 'development'."""
|
||||||
|
|
||||||
config_path: str = "."
|
config_path: str = "."
|
||||||
"""The path to the configuration directory."""
|
"""The path to the configuration directory."""
|
||||||
|
|
||||||
|
|
@ -228,6 +231,7 @@ class Config:
|
||||||
"file_logging",
|
"file_logging",
|
||||||
"base_path",
|
"base_path",
|
||||||
"is_native",
|
"is_native",
|
||||||
|
"app_env",
|
||||||
)
|
)
|
||||||
"The variables that are relevant to the frontend."
|
"The variables that are relevant to the frontend."
|
||||||
|
|
||||||
|
|
@ -416,6 +420,13 @@ class Config:
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
logging.getLogger("httpcore").setLevel(logging.INFO)
|
logging.getLogger("httpcore").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# check env
|
||||||
|
if self.app_env not in ("production", "development"):
|
||||||
|
msg: str = (
|
||||||
|
f"Invalid application environment '{self.app_env}' specified. Must be 'production' or 'development'."
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
if self.version == "dev-master":
|
if self.version == "dev-master":
|
||||||
self._version_via_git()
|
self._version_via_git()
|
||||||
|
|
||||||
|
|
@ -433,6 +444,26 @@ class Config:
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
def is_dev(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the application is running in development mode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the application is in development mode, False otherwise.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return "development" == self.app_env
|
||||||
|
|
||||||
|
def is_prod(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the application is running in production mode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the application is in production mode, False otherwise.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return "production" == self.app_env
|
||||||
|
|
||||||
def get_ytdlp_args(self) -> dict:
|
def get_ytdlp_args(self) -> dict:
|
||||||
try:
|
try:
|
||||||
return arg_converter(args=self._ytdlp_cli_mutable, level=True)
|
return arg_converter(args=self._ytdlp_cli_mutable, level=True)
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "3e163c6c-64eb-4448-924f-814b629b3810",
|
|
||||||
"name": "default",
|
|
||||||
"description": "",
|
|
||||||
"folder": "",
|
|
||||||
"template": "",
|
|
||||||
"cookies": "",
|
|
||||||
"cli": "",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "5bf9c42b-8852-468a-99f5-915622dfba25",
|
|
||||||
"name": "Best video and audio",
|
|
||||||
"description": "",
|
|
||||||
"folder": "",
|
|
||||||
"template": "",
|
|
||||||
"cookies": "",
|
|
||||||
"cli": "--format 'bv+ba/b'",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b",
|
|
||||||
"name": "1080p H264/m4a or best available",
|
|
||||||
"description": "",
|
|
||||||
"folder": "",
|
|
||||||
"template": "",
|
|
||||||
"cookies": "",
|
|
||||||
"cli": "-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e",
|
|
||||||
"name": "720p h264/m4a or best available",
|
|
||||||
"description": "",
|
|
||||||
"folder": "",
|
|
||||||
"template": "",
|
|
||||||
"cookies": "",
|
|
||||||
"cli": "-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
|
|
||||||
"name": "Audio only",
|
|
||||||
"description": "",
|
|
||||||
"folder": "",
|
|
||||||
"template": "",
|
|
||||||
"cookies": "",
|
|
||||||
"cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60",
|
|
||||||
"name": "ytdlp-info-reader",
|
|
||||||
"description": "This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o \"%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s\" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary",
|
|
||||||
"folder": "youtube",
|
|
||||||
"template": "%(channel)s %(channel_id|Unknown_id)s/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s",
|
|
||||||
"cookies": "",
|
|
||||||
"cli": "--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata",
|
|
||||||
"default": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -31,6 +31,11 @@ class Main:
|
||||||
self._config = Config.get_instance(is_native=is_native)
|
self._config = Config.get_instance(is_native=is_native)
|
||||||
self._app = web.Application()
|
self._app = web.Application()
|
||||||
|
|
||||||
|
if self._config.debug:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.set_debug(True)
|
||||||
|
loop.slow_callback_duration = 0.05
|
||||||
|
|
||||||
self._check_folders()
|
self._check_folders()
|
||||||
|
|
||||||
caribou.upgrade(self._config.db_file, ROOT_PATH / "migrations")
|
caribou.upgrade(self._config.db_file, ROOT_PATH / "migrations")
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ export const useSocketStore = defineStore('socket', () => {
|
||||||
const isConnected = ref(false)
|
const isConnected = ref(false)
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
let opts = { withCredentials: true }
|
let opts = {
|
||||||
|
transports: ['websocket'],
|
||||||
|
withCredentials: true,
|
||||||
|
}
|
||||||
|
|
||||||
let url = runtimeConfig.public.wss
|
let url = runtimeConfig.public.wss
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue