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/tasks](#get-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/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
|
||||
- [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
|
||||
**Purpose**: Generate a playlist for a given local media file.
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ class HttpAPI(Common):
|
|||
self.routes = web.RouteTableDef()
|
||||
self.cache = Cache()
|
||||
self.app: web.Application | None = None
|
||||
self.isRequestingBackground = False
|
||||
|
||||
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)
|
||||
|
||||
@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")
|
||||
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.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={
|
||||
"Content-Type": "application/x-mpegURL",
|
||||
"Cache-Control": "no-cache",
|
||||
|
|
@ -1444,7 +1344,7 @@ class HttpAPI(Common):
|
|||
base_path: str = self.config.base_path.rstrip("/")
|
||||
|
||||
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)
|
||||
if web.HTTPFound.status_code == status:
|
||||
|
|
@ -1708,7 +1608,11 @@ class HttpAPI(Common):
|
|||
"""
|
||||
backend = None
|
||||
|
||||
if self.isRequestingBackground:
|
||||
return web.Response(status=web.HTTPTooManyRequests.status_code)
|
||||
|
||||
try:
|
||||
self.isRequestingBackground = True
|
||||
backend = random.choice(self.config.pictures_backends) # noqa: S311
|
||||
CACHE_KEY_BING = "random_background_bing"
|
||||
CACHE_KEY = "random_background"
|
||||
|
|
@ -1776,6 +1680,8 @@ class HttpAPI(Common):
|
|||
|
||||
await self.cache.aset(key=CACHE_KEY, value=data, ttl=3600)
|
||||
|
||||
LOG.debug(f"Random background image from '{backend!s}' cached.")
|
||||
|
||||
return web.Response(
|
||||
body=data.get("content"),
|
||||
headers={
|
||||
|
|
@ -1791,6 +1697,8 @@ class HttpAPI(Common):
|
|||
data={"error": "failed to retrieve the random background image."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
finally:
|
||||
self.isRequestingBackground = False
|
||||
|
||||
@route("GET", "api/file/ffprobe/{file:.*}", "ffprobe")
|
||||
async def get_ffprobe(self, request: Request) -> Response:
|
||||
|
|
@ -2017,3 +1925,159 @@ class HttpAPI(Common):
|
|||
except OSError as e:
|
||||
LOG.exception(e)
|
||||
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()
|
||||
|
||||
# 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()
|
||||
|
||||
def emit(e: Event, _, **kwargs):
|
||||
|
|
@ -77,6 +86,12 @@ class HttpSocket(Common):
|
|||
async def on_shutdown(self, _: web.Application):
|
||||
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):
|
||||
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_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.download_path = download_path
|
||||
self.download_path: Path = download_path
|
||||
self.duration = float(segment_duration) if segment_duration is not None else self.duration
|
||||
|
||||
async def make_stream(self, file: Path) -> str:
|
||||
|
|
@ -23,7 +23,7 @@ class M3u8:
|
|||
except UnicodeDecodeError:
|
||||
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:
|
||||
error = f"Unable to get '{file}' play duration."
|
||||
|
|
@ -31,7 +31,7 @@ class M3u8:
|
|||
|
||||
duration: float = float(ff.metadata.get("duration"))
|
||||
|
||||
m3u8 = []
|
||||
m3u8: list = []
|
||||
|
||||
m3u8.append("#EXTM3U")
|
||||
m3u8.append("#EXT-X-VERSION:3")
|
||||
|
|
@ -57,7 +57,7 @@ class M3u8:
|
|||
|
||||
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:
|
||||
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:
|
||||
m3u8 = []
|
||||
|
||||
urlPath: str = str(file.relative_to(self.download_path).as_posix()).strip("/")
|
||||
|
||||
m3u8.append("#EXTM3U")
|
||||
m3u8.append("#EXT-X-VERSION:3")
|
||||
m3u8.append(f"#EXT-X-TARGETDURATION:{int(self.duration)}")
|
||||
m3u8.append("#EXT-X-MEDIA-SEQUENCE:0")
|
||||
m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD")
|
||||
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")
|
||||
|
||||
return "\n".join(m3u8)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import logging
|
|||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
LOG = logging.getLogger("package_installer")
|
||||
|
||||
|
|
@ -12,11 +13,15 @@ class Packages:
|
|||
from_env = env.split() if env else []
|
||||
from_file = []
|
||||
|
||||
if os.path.exists(file) and os.access(file, os.R_OK):
|
||||
with open(file) as f:
|
||||
from_file = [pkg.strip() for pkg in f if pkg.strip()]
|
||||
if file:
|
||||
file = Path(file)
|
||||
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)
|
||||
|
||||
def has_packages(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ from .Utils import StreamingError, get_file_sidecar
|
|||
class Playlist:
|
||||
_url: str = None
|
||||
|
||||
def __init__(self, download_path: str, url: str):
|
||||
self.url = url
|
||||
self.download_path = download_path
|
||||
def __init__(self, download_path: Path, url: str):
|
||||
self.url: str = url
|
||||
self.download_path: Path = download_path
|
||||
|
||||
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:
|
||||
ff = await ffprobe(file)
|
||||
|
|
@ -24,19 +24,19 @@ class Playlist:
|
|||
msg = f"Unable to get '{ref}' duration."
|
||||
raise StreamingError(msg)
|
||||
|
||||
playlist = []
|
||||
playlist: list[str] = []
|
||||
playlist.append("#EXTM3U")
|
||||
|
||||
subs = ""
|
||||
subs: str = ""
|
||||
|
||||
duration: float = float(ff.metadata.get("duration"))
|
||||
for sub_file in get_file_sidecar(file).get("subtitle", []):
|
||||
lang = sub_file["lang"]
|
||||
item = Path(sub_file["file"])
|
||||
name = sub_file["name"]
|
||||
lang: str = sub_file["lang"]
|
||||
item: Path = sub_file["file"]
|
||||
name: str = sub_file["name"]
|
||||
|
||||
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(
|
||||
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 logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
|
@ -11,10 +11,51 @@ from .config import Config
|
|||
from .encoder import Encoder
|
||||
from .Events import EventBus, Events
|
||||
from .Singleton import Singleton
|
||||
from .Utils import arg_converter, clean_item
|
||||
from .Utils import arg_converter, init_class
|
||||
|
||||
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)
|
||||
class Preset:
|
||||
|
|
@ -65,28 +106,26 @@ class Presets(metaclass=Singleton):
|
|||
|
||||
_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
|
||||
|
||||
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:
|
||||
os.chmod(self._file, 0o600)
|
||||
self._file.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
default_file = os.path.join(os.path.dirname(__file__), "presets.json")
|
||||
with open(default_file) as f:
|
||||
for i, preset in enumerate(json.load(f)):
|
||||
try:
|
||||
self.validate(preset)
|
||||
self._default.append(Preset(**preset))
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse '{default_file}:{i}'. '{e!s}'.")
|
||||
continue
|
||||
for i, preset in enumerate(DEFAULT_PRESETS):
|
||||
try:
|
||||
self.validate(preset)
|
||||
self._default.append(init_class(Preset, preset))
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse default preset ':{i}'. '{e!s}'.")
|
||||
continue
|
||||
|
||||
def event_handler(_, __):
|
||||
msg = "Not implemented"
|
||||
|
|
@ -138,13 +177,12 @@ class Presets(metaclass=Singleton):
|
|||
"""
|
||||
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
|
||||
|
||||
LOG.info(f"Loading '{self._file}'.")
|
||||
try:
|
||||
with open(self._file) as f:
|
||||
presets = json.load(f)
|
||||
LOG.info(f"Loading '{self._file}'.")
|
||||
presets: dict = json.loads(self._file.read_text())
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
|
||||
return self
|
||||
|
|
@ -160,7 +198,6 @@ class Presets(metaclass=Singleton):
|
|||
preset["id"] = str(uuid.uuid4())
|
||||
need_save = True
|
||||
|
||||
preset, preset_status = clean_item(preset, keys=("args", "postprocessors"))
|
||||
if preset.get("format"):
|
||||
if not preset.get("cli"):
|
||||
preset.update({"cli": f"--format {preset['format']}"})
|
||||
|
|
@ -172,10 +209,7 @@ class Presets(metaclass=Singleton):
|
|||
preset.pop("format")
|
||||
need_save = True
|
||||
|
||||
preset = Preset(**preset)
|
||||
|
||||
if preset_status:
|
||||
need_save = True
|
||||
preset: Preset = init_class(Preset, preset)
|
||||
|
||||
self._items.append(preset)
|
||||
except Exception as e:
|
||||
|
|
@ -183,7 +217,7 @@ class Presets(metaclass=Singleton):
|
|||
continue
|
||||
|
||||
if need_save:
|
||||
LOG.info(f"Saving '{self._file}' due to changes.")
|
||||
LOG.info(f"Saving '{self._file}'.")
|
||||
self.save(self._items)
|
||||
|
||||
return self
|
||||
|
|
@ -255,7 +289,7 @@ class Presets(metaclass=Singleton):
|
|||
for i, preset in enumerate(items):
|
||||
try:
|
||||
if not isinstance(preset, Preset):
|
||||
preset = Preset(**preset)
|
||||
preset: Preset = init_class(Preset, preset)
|
||||
items[i] = preset
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.")
|
||||
|
|
@ -268,8 +302,9 @@ class Presets(metaclass=Singleton):
|
|||
continue
|
||||
|
||||
try:
|
||||
with open(self._file, "w") as f:
|
||||
json.dump(obj=[preset.serialize() for preset in items if preset.default is False], fp=f, indent=4)
|
||||
self._file.write_text(
|
||||
json.dumps(obj=[preset.serialize() for preset in items if preset.default is False], indent=4)
|
||||
)
|
||||
|
||||
LOG.info(f"Saved '{self._file}'.")
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from datetime import UTC, datetime, timedelta
|
|||
from functools import lru_cache
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
|
||||
import yt_dlp
|
||||
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?")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class StreamingError(Exception):
|
||||
"""Raised when an error occurs during streaming."""
|
||||
|
|
@ -1180,7 +1183,7 @@ def delete_dir(dir: Path) -> bool:
|
|||
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.
|
||||
|
||||
|
|
@ -1189,7 +1192,7 @@ def init_class(cls: type, data: dict):
|
|||
data (dict): The data to use for initialization.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ from .version import APP_VERSION
|
|||
|
||||
|
||||
class Config:
|
||||
app_env: str = "production"
|
||||
"""The application environment, can be 'production' or 'development'."""
|
||||
|
||||
config_path: str = "."
|
||||
"""The path to the configuration directory."""
|
||||
|
||||
|
|
@ -228,6 +231,7 @@ class Config:
|
|||
"file_logging",
|
||||
"base_path",
|
||||
"is_native",
|
||||
"app_env",
|
||||
)
|
||||
"The variables that are relevant to the frontend."
|
||||
|
||||
|
|
@ -416,6 +420,13 @@ class Config:
|
|||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
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":
|
||||
self._version_via_git()
|
||||
|
||||
|
|
@ -433,6 +444,26 @@ class Config:
|
|||
|
||||
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:
|
||||
try:
|
||||
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._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()
|
||||
|
||||
caribou.upgrade(self._config.db_file, ROOT_PATH / "migrations")
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
const isConnected = ref(false)
|
||||
|
||||
const connect = () => {
|
||||
let opts = { withCredentials: true }
|
||||
let opts = {
|
||||
transports: ['websocket'],
|
||||
withCredentials: true,
|
||||
}
|
||||
|
||||
let url = runtimeConfig.public.wss
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue