WiP: More migration to Path

This commit is contained in:
arabcoders 2025-06-10 01:08:44 +03:00
parent 93ff749dee
commit 6c8fd05886
12 changed files with 320 additions and 303 deletions

84
API.md
View file

@ -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.

View 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)

View file

@ -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")

View file

@ -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)

View file

@ -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:

View file

@ -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}"'
)

View file

@ -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:

View file

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

View file

@ -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)

View file

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

View file

@ -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")

View file

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