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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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._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")

View file

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