updating python code to be little more clean and implements more type checks.
This commit is contained in:
parent
1b9a4e528b
commit
2d70de2032
23 changed files with 970 additions and 783 deletions
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
|
|
@ -37,6 +37,7 @@
|
||||||
"YTP_LOG_LEVEL": "DEBUG",
|
"YTP_LOG_LEVEL": "DEBUG",
|
||||||
"YTP_DEBUG": "false",
|
"YTP_DEBUG": "false",
|
||||||
"YTP_YTDL_DEBUG": "false",
|
"YTP_YTDL_DEBUG": "false",
|
||||||
|
"YTP_MAX_WORKERS": "2",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
class Terminator:
|
class Terminator:
|
||||||
|
|
@ -8,13 +8,18 @@ class Terminator:
|
||||||
|
|
||||||
|
|
||||||
class AsyncPool:
|
class AsyncPool:
|
||||||
def __init__(self,
|
def __init__(
|
||||||
num_workers: int, worker_co,
|
self,
|
||||||
name: str, logger: logging.Logger,
|
num_workers: int,
|
||||||
loop: asyncio.AbstractEventLoop = None,
|
worker_co,
|
||||||
load_factor: int = 1, max_task_time: int = None,
|
name: str,
|
||||||
return_futures: bool = False, raise_on_join: bool = False
|
logger: logging.Logger,
|
||||||
):
|
loop: asyncio.AbstractEventLoop = None,
|
||||||
|
load_factor: int = 1,
|
||||||
|
max_task_time: int = None,
|
||||||
|
return_futures: bool = False,
|
||||||
|
raise_on_join: bool = False,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
This class will create `num_workers` asyncio tasks to work against a queue of
|
This class will create `num_workers` asyncio tasks to work against a queue of
|
||||||
`num_workers * load_factor` items of back-pressure (IOW we will block after such
|
`num_workers * load_factor` items of back-pressure (IOW we will block after such
|
||||||
|
|
@ -62,8 +67,8 @@ class AsyncPool:
|
||||||
future, args, kwargs = item
|
future, args, kwargs = item
|
||||||
|
|
||||||
self._status[worker_id] = {
|
self._status[worker_id] = {
|
||||||
'started': self._time().isoformat(),
|
"started": self._time().isoformat(),
|
||||||
'data': kwargs.get('entry', {'info': {}}).info.__dict__,
|
"data": kwargs.get("entry", {"info": {}}).info.__dict__,
|
||||||
}
|
}
|
||||||
|
|
||||||
# the wait_for will cancel the task (task sees CancelledError) and raises a TimeoutError from here
|
# the wait_for will cancel the task (task sees CancelledError) and raises a TimeoutError from here
|
||||||
|
|
@ -87,7 +92,7 @@ class AsyncPool:
|
||||||
# don't log the failure when the client is receiving the future
|
# don't log the failure when the client is receiving the future
|
||||||
future.set_exception(e)
|
future.set_exception(e)
|
||||||
else:
|
else:
|
||||||
self._logger.exception(f'Worker call failed. {str(e)}')
|
self._logger.exception(f"Worker call failed. {str(e)}")
|
||||||
finally:
|
finally:
|
||||||
self._status[worker_id] = None
|
self._status[worker_id] = None
|
||||||
|
|
||||||
|
|
@ -140,7 +145,7 @@ class AsyncPool:
|
||||||
return future
|
return future
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
""" Will start up worker pool and reset exception state """
|
"""Will start up worker pool and reset exception state"""
|
||||||
self._exceptions = False
|
self._exceptions = False
|
||||||
|
|
||||||
for worker_number in range(self._num_workers):
|
for worker_number in range(self._num_workers):
|
||||||
|
|
@ -148,16 +153,16 @@ class AsyncPool:
|
||||||
self._createWorker(worker_id)
|
self._createWorker(worker_id)
|
||||||
|
|
||||||
async def restart(self, worker_id: str, msg: str = None) -> bool:
|
async def restart(self, worker_id: str, msg: str = None) -> bool:
|
||||||
""" Will restart the worker pool """
|
"""Will restart the worker pool"""
|
||||||
if worker_id not in self._workers:
|
if worker_id not in self._workers:
|
||||||
self._logger.warning(f'Worker {worker_id} does not exist.')
|
self._logger.warning(f"Worker {worker_id} does not exist.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._workers[worker_id].cancel(msg)
|
self._workers[worker_id].cancel(msg)
|
||||||
await self._workers[worker_id]
|
await self._workers[worker_id]
|
||||||
except asyncio.exceptions.CancelledError as e:
|
except asyncio.exceptions.CancelledError as e:
|
||||||
self._logger.warning(f'Worker {worker_id} restarted. {str(e)}')
|
self._logger.warning(f"Worker {worker_id} restarted. {str(e)}")
|
||||||
if worker_id in self._status:
|
if worker_id in self._status:
|
||||||
self._status.pop(worker_id)
|
self._status.pop(worker_id)
|
||||||
|
|
||||||
|
|
@ -169,16 +174,16 @@ class AsyncPool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def stop(self, worker_id: str, msg: str = None) -> bool:
|
async def stop(self, worker_id: str, msg: str = None) -> bool:
|
||||||
""" Will stop the worker """
|
"""Will stop the worker"""
|
||||||
if worker_id not in self._workers:
|
if worker_id not in self._workers:
|
||||||
self._logger.warning(f'Worker {worker_id} does not exist.')
|
self._logger.warning(f"Worker {worker_id} does not exist.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self._workers[worker_id].cancel(msg):
|
if self._workers[worker_id].cancel(msg):
|
||||||
try:
|
try:
|
||||||
await self._workers[worker_id]
|
await self._workers[worker_id]
|
||||||
except asyncio.exceptions.CancelledError as e:
|
except asyncio.exceptions.CancelledError as e:
|
||||||
self._logger.warning(f'Worker {worker_id} stopped. {str(e)}')
|
self._logger.warning(f"Worker {worker_id} stopped. {str(e)}")
|
||||||
if worker_id in self._status:
|
if worker_id in self._status:
|
||||||
self._status.pop(worker_id)
|
self._status.pop(worker_id)
|
||||||
|
|
||||||
|
|
@ -192,7 +197,7 @@ class AsyncPool:
|
||||||
if len(self._workers) < 1:
|
if len(self._workers) < 1:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._logger.info(f'Joining {self._name}')
|
self._logger.info(f"Joining {self._name}")
|
||||||
|
|
||||||
# The Terminators will kick each worker from being blocked against the _queue.get() and allow
|
# The Terminators will kick each worker from being blocked against the _queue.get() and allow
|
||||||
# each one to exit.
|
# each one to exit.
|
||||||
|
|
@ -203,26 +208,25 @@ class AsyncPool:
|
||||||
await asyncio.gather(*list(self._workers))
|
await asyncio.gather(*list(self._workers))
|
||||||
self._workers = {}
|
self._workers = {}
|
||||||
except:
|
except:
|
||||||
self._logger.exception(f'Exception joining {self._name}')
|
self._logger.exception(f"Exception joining {self._name}")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
self._logger.info(f'Completed {self._name}')
|
self._logger.info(f"Completed {self._name}")
|
||||||
|
|
||||||
if self._exceptions and self._raise_on_join:
|
if self._exceptions and self._raise_on_join:
|
||||||
raise Exception(f"Exception occurred in {self._name} pool")
|
raise Exception(f"Exception occurred in {self._name} pool")
|
||||||
|
|
||||||
def _createWorker(self, worker_id: str) -> asyncio.Future:
|
def _createWorker(self, worker_id: str) -> asyncio.Future:
|
||||||
if worker_id in self._workers:
|
if worker_id in self._workers:
|
||||||
self._logger.debug(f'Worker {worker_id} already exists.')
|
self._logger.debug(f"Worker {worker_id} already exists.")
|
||||||
return self._workers[worker_id]
|
return self._workers[worker_id]
|
||||||
|
|
||||||
self._status[worker_id] = None
|
self._status[worker_id] = None
|
||||||
self._workers[worker_id] = asyncio.ensure_future(
|
self._workers[worker_id] = asyncio.ensure_future(
|
||||||
coro_or_future=self._worker_loop(worker_id=worker_id),
|
coro_or_future=self._worker_loop(worker_id=worker_id), loop=self._loop
|
||||||
loop=self._loop
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._logger.debug(f'Created {worker_id}')
|
self._logger.debug(f"Created {worker_id}")
|
||||||
|
|
||||||
return self._workers[worker_id]
|
return self._workers[worker_id]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from collections import OrderedDict
|
|
||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from email.utils import formatdate
|
from email.utils import formatdate
|
||||||
import json
|
|
||||||
from sqlite3 import Connection
|
from sqlite3 import Connection
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .Download import Download
|
from .Download import Download
|
||||||
from .ItemDTO import ItemDTO
|
from .ItemDTO import ItemDTO
|
||||||
|
|
@ -13,6 +14,7 @@ class DataStore:
|
||||||
"""
|
"""
|
||||||
Persistent queue.
|
Persistent queue.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
type: str = None
|
type: str = None
|
||||||
dict: OrderedDict[str, Download] = None
|
dict: OrderedDict[str, Download] = None
|
||||||
config: Config = None
|
config: Config = None
|
||||||
|
|
@ -31,7 +33,7 @@ class DataStore:
|
||||||
|
|
||||||
def exists(self, key: str = None, url: str = None) -> bool:
|
def exists(self, key: str = None, url: str = None) -> bool:
|
||||||
if not key and not url:
|
if not key and not url:
|
||||||
raise KeyError('key or url must be provided.')
|
raise KeyError("key or url must be provided.")
|
||||||
|
|
||||||
if key and key in self.dict:
|
if key and key in self.dict:
|
||||||
return True
|
return True
|
||||||
|
|
@ -44,13 +46,13 @@ class DataStore:
|
||||||
|
|
||||||
def get(self, key: str, url: str = None) -> Download:
|
def get(self, key: str, url: str = None) -> Download:
|
||||||
if not key and not url:
|
if not key and not url:
|
||||||
raise KeyError('key or url must be provided.')
|
raise KeyError("key or url must be provided.")
|
||||||
|
|
||||||
for i in self.dict:
|
for i in self.dict:
|
||||||
if (key and self.dict[i].info._id == key) or (url and self.dict[i].info.url == url):
|
if (key and self.dict[i].info._id == key) or (url and self.dict[i].info.url == url):
|
||||||
return self.dict[i]
|
return self.dict[i]
|
||||||
|
|
||||||
raise KeyError(f'{key=} or {url=} not found.')
|
raise KeyError(f"{key=} or {url=} not found.")
|
||||||
|
|
||||||
def getById(self, id: str) -> Download | None:
|
def getById(self, id: str) -> Download | None:
|
||||||
return self.dict[id] if id in self.dict else None
|
return self.dict[id] if id in self.dict else None
|
||||||
|
|
@ -62,18 +64,17 @@ class DataStore:
|
||||||
items: list[tuple[str, ItemDTO]] = []
|
items: list[tuple[str, ItemDTO]] = []
|
||||||
|
|
||||||
cursor = self.connection.execute(
|
cursor = self.connection.execute(
|
||||||
f'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" ASC',
|
'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" ASC', (self.type,)
|
||||||
(self.type,)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for row in cursor:
|
for row in cursor:
|
||||||
rowDate = datetime.strptime(row['created_at'], '%Y-%m-%d %H:%M:%S')
|
rowDate = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S")
|
||||||
data: dict = json.loads(row['data'])
|
data: dict = json.loads(row["data"])
|
||||||
key: str = data.pop('_id')
|
key: str = data.pop("_id")
|
||||||
item: ItemDTO = ItemDTO(**data)
|
item: ItemDTO = ItemDTO(**data)
|
||||||
item._id = key
|
item._id = key
|
||||||
item.datetime = formatdate(rowDate.replace(tzinfo=timezone.utc).timestamp())
|
item.datetime = formatdate(rowDate.replace(tzinfo=timezone.utc).timestamp())
|
||||||
items.append((row['id'], item))
|
items.append((row["id"], item))
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
@ -123,22 +124,37 @@ class DataStore:
|
||||||
|
|
||||||
stored = copy.deepcopy(item)
|
stored = copy.deepcopy(item)
|
||||||
|
|
||||||
if hasattr(stored, 'datetime'):
|
if hasattr(stored, "datetime"):
|
||||||
try:
|
try:
|
||||||
delattr(stored, 'datetime')
|
delattr(stored, "datetime")
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if hasattr(stored, 'live_in') and stored.status == 'finished':
|
if hasattr(stored, "live_in") and stored.status == "finished":
|
||||||
try:
|
try:
|
||||||
delattr(stored, 'live_in')
|
delattr(stored, "live_in")
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.connection.execute(sqlStatement.strip(), (
|
self.connection.execute(
|
||||||
stored._id, type, stored.url, stored.json(), type, stored.url,
|
sqlStatement.strip(),
|
||||||
stored.json(), datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
(
|
||||||
))
|
stored._id,
|
||||||
|
type,
|
||||||
|
stored.url,
|
||||||
|
stored.json(),
|
||||||
|
type,
|
||||||
|
stored.url,
|
||||||
|
stored.json(),
|
||||||
|
datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def _deleteStoreItem(self, key: str) -> None:
|
def _deleteStoreItem(self, key: str) -> None:
|
||||||
self.connection.execute('DELETE FROM "history" WHERE "type" = ? AND "id" = ?', (self.type, key,))
|
self.connection.execute(
|
||||||
|
'DELETE FROM "history" WHERE "type" = ? AND "id" = ?',
|
||||||
|
(
|
||||||
|
self.type,
|
||||||
|
key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,28 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
|
from multiprocessing.managers import SyncManager
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
import hashlib
|
|
||||||
from .AsyncPool import Terminator
|
from .AsyncPool import Terminator
|
||||||
from .Utils import get_opts, jsonCookie, mergeConfig
|
|
||||||
from .ItemDTO import ItemDTO
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .Emitter import Emitter
|
from .Emitter import Emitter
|
||||||
from multiprocessing.managers import SyncManager
|
from .ItemDTO import ItemDTO
|
||||||
LOG = logging.getLogger('download')
|
from .Utils import get_opts, jsonCookie, mergeConfig
|
||||||
|
|
||||||
|
LOG = logging.getLogger("download")
|
||||||
|
|
||||||
|
|
||||||
class Download:
|
class Download:
|
||||||
"""
|
"""
|
||||||
Download task.
|
Download task.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str = None
|
id: str = None
|
||||||
manager = None
|
manager = None
|
||||||
download_dir: str = None
|
download_dir: str = None
|
||||||
|
|
@ -47,15 +50,15 @@ class Download:
|
||||||
"Bad yt-dlp options which are known to cause issues with live stream and post manifestless mode."
|
"Bad yt-dlp options which are known to cause issues with live stream and post manifestless mode."
|
||||||
|
|
||||||
_ytdlp_fields: tuple = (
|
_ytdlp_fields: tuple = (
|
||||||
'tmpfilename',
|
"tmpfilename",
|
||||||
'filename',
|
"filename",
|
||||||
'status',
|
"status",
|
||||||
'msg',
|
"msg",
|
||||||
'total_bytes',
|
"total_bytes",
|
||||||
'total_bytes_estimate',
|
"total_bytes_estimate",
|
||||||
'downloaded_bytes',
|
"downloaded_bytes",
|
||||||
'speed',
|
"speed",
|
||||||
'eta',
|
"eta",
|
||||||
)
|
)
|
||||||
"Fields to be extracted from yt-dlp progress hook."
|
"Fields to be extracted from yt-dlp progress hook."
|
||||||
|
|
||||||
|
|
@ -83,69 +86,62 @@ class Download:
|
||||||
self.max_workers = int(config.max_workers)
|
self.max_workers = int(config.max_workers)
|
||||||
self.tempKeep = bool(config.temp_keep)
|
self.tempKeep = bool(config.temp_keep)
|
||||||
self.is_live = bool(info.is_live) or info.live_in is not None
|
self.is_live = bool(info.is_live) or info.live_in is not None
|
||||||
self.is_manifestless = 'is_manifestless' in self.info.options and self.info.options['is_manifestless'] is True
|
self.is_manifestless = "is_manifestless" in self.info.options and self.info.options["is_manifestless"] is True
|
||||||
self.info_dict = info_dict
|
self.info_dict = info_dict
|
||||||
|
|
||||||
def _progress_hook(self, data: dict):
|
def _progress_hook(self, data: dict):
|
||||||
dataDict = {k: v for k, v in data.items() if k in self._ytdlp_fields}
|
dataDict = {k: v for k, v in data.items() if k in self._ytdlp_fields}
|
||||||
self.status_queue.put({'id': self.id, **dataDict})
|
self.status_queue.put({"id": self.id, **dataDict})
|
||||||
|
|
||||||
def _postprocessor_hook(self, data: dict):
|
def _postprocessor_hook(self, data: dict):
|
||||||
if data.get('postprocessor') != 'MoveFiles' or data.get('status') != 'finished':
|
if data.get("postprocessor") != "MoveFiles" or data.get("status") != "finished":
|
||||||
return
|
return
|
||||||
|
|
||||||
if '__finaldir' in data['info_dict']:
|
if "__finaldir" in data["info_dict"]:
|
||||||
filename = os.path.join(data['info_dict']['__finaldir'], os.path.basename(data['info_dict']['filepath']))
|
filename = os.path.join(data["info_dict"]["__finaldir"], os.path.basename(data["info_dict"]["filepath"]))
|
||||||
else:
|
else:
|
||||||
filename = data['info_dict']['filepath']
|
filename = data["info_dict"]["filepath"]
|
||||||
|
|
||||||
self.status_queue.put({
|
self.status_queue.put({"id": self.id, "status": "finished", "filename": filename})
|
||||||
'id': self.id,
|
|
||||||
'status': 'finished',
|
|
||||||
'filename': filename
|
|
||||||
})
|
|
||||||
|
|
||||||
def _download(self):
|
def _download(self):
|
||||||
try:
|
try:
|
||||||
params: dict = {
|
params: dict = {
|
||||||
'color': 'no_color',
|
"color": "no_color",
|
||||||
'paths': {
|
"paths": {
|
||||||
'home': self.download_dir,
|
"home": self.download_dir,
|
||||||
'temp': self.tempPath,
|
"temp": self.tempPath,
|
||||||
},
|
},
|
||||||
'outtmpl': {
|
"outtmpl": {"default": self.output_template, "chapter": self.output_template_chapter},
|
||||||
'default': self.output_template,
|
"noprogress": True,
|
||||||
'chapter': self.output_template_chapter
|
"break_on_existing": True,
|
||||||
},
|
"progress_hooks": [self._progress_hook],
|
||||||
'noprogress': True,
|
"postprocessor_hooks": [self._postprocessor_hook],
|
||||||
'break_on_existing': True,
|
|
||||||
'progress_hooks': [self._progress_hook],
|
|
||||||
'postprocessor_hooks': [self._postprocessor_hook],
|
|
||||||
**get_opts(self.preset, mergeConfig(self.default_ytdl_opts, self.ytdl_opts)),
|
**get_opts(self.preset, mergeConfig(self.default_ytdl_opts, self.ytdl_opts)),
|
||||||
}
|
}
|
||||||
|
|
||||||
if 'format' not in params and self.default_ytdl_opts.get('format', None):
|
if "format" not in params and self.default_ytdl_opts.get("format", None):
|
||||||
params['format'] = self.default_ytdl_opts['format']
|
params["format"] = self.default_ytdl_opts["format"]
|
||||||
|
|
||||||
params['ignoreerrors'] = False
|
params["ignoreerrors"] = False
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
params['verbose'] = True
|
params["verbose"] = True
|
||||||
params['noprogress'] = False
|
params["noprogress"] = False
|
||||||
|
|
||||||
if self.info.ytdlp_cookies:
|
if self.info.ytdlp_cookies:
|
||||||
try:
|
try:
|
||||||
data = jsonCookie(json.loads(self.info.ytdlp_cookies))
|
data = jsonCookie(json.loads(self.info.ytdlp_cookies))
|
||||||
if not data:
|
if not data:
|
||||||
LOG.warning(
|
LOG.warning(
|
||||||
f'The cookie string that was provided for {self.info.title} is empty or not in expected spec.')
|
f"The cookie string that was provided for {self.info.title} is empty or not in expected spec."
|
||||||
with open(os.path.join(self.tempPath, f'cookie_{self.info._id}.txt'), 'w') as f:
|
)
|
||||||
|
with open(os.path.join(self.tempPath, f"cookie_{self.info._id}.txt"), "w") as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
|
|
||||||
params['cookiefile'] = f.name
|
params["cookiefile"] = f.name
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
LOG.error(
|
LOG.error(f"Invalid cookies: was provided for {self.info.title} - {str(e)}")
|
||||||
f'Invalid cookies: was provided for {self.info.title} - {str(e)}')
|
|
||||||
|
|
||||||
if self.is_live or self.is_manifestless:
|
if self.is_live or self.is_manifestless:
|
||||||
hasDeletedOptions = False
|
hasDeletedOptions = False
|
||||||
|
|
@ -158,29 +154,26 @@ class Download:
|
||||||
|
|
||||||
if hasDeletedOptions:
|
if hasDeletedOptions:
|
||||||
LOG.warning(
|
LOG.warning(
|
||||||
f"Live stream detected for '{self.info.title}', The following opts '{deletedOpts=}' have been deleted which are known to cause issues with live stream and post stream manifestless mode.")
|
f"Live stream detected for '{self.info.title}', The following opts '{deletedOpts=}' have been deleted which are known to cause issues with live stream and post stream manifestless mode."
|
||||||
|
)
|
||||||
|
|
||||||
LOG.info(
|
LOG.info(
|
||||||
f'Downloading pid: {os.getpid()} id="{self.info.id}" title="{self.info.title}" preset="{self.preset}".')
|
f'Downloading pid: {os.getpid()} id="{self.info.id}" title="{self.info.title}" preset="{self.preset}".'
|
||||||
|
)
|
||||||
|
|
||||||
cls = yt_dlp.YoutubeDL(params=params)
|
cls = yt_dlp.YoutubeDL(params=params)
|
||||||
|
|
||||||
if isinstance(self.info_dict, dict) and len(self.info_dict) > 1:
|
if isinstance(self.info_dict, dict) and len(self.info_dict) > 1:
|
||||||
LOG.debug(f'Downloading using pre-info.')
|
LOG.debug("Downloading using pre-info.")
|
||||||
cls.process_ie_result(self.info_dict, download=True)
|
cls.process_ie_result(self.info_dict, download=True)
|
||||||
ret = cls._download_retcode
|
ret = cls._download_retcode
|
||||||
else:
|
else:
|
||||||
LOG.debug(f'Downloading using url: {self.info.url}')
|
LOG.debug(f"Downloading using url: {self.info.url}")
|
||||||
ret = cls.download([self.info.url])
|
ret = cls.download([self.info.url])
|
||||||
|
|
||||||
self.status_queue.put({'id': self.id, 'status': 'finished' if ret == 0 else 'error'})
|
self.status_queue.put({"id": self.id, "status": "finished" if ret == 0 else "error"})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.status_queue.put({
|
self.status_queue.put({"id": self.id, "status": "error", "msg": str(exc), "error": str(exc)})
|
||||||
'id': self.id,
|
|
||||||
'status': 'error',
|
|
||||||
'msg': str(exc),
|
|
||||||
'error': str(exc)
|
|
||||||
})
|
|
||||||
|
|
||||||
LOG.info(f'Finished {os.getpid()=} id="{self.info.id}" title="{self.info.title}".')
|
LOG.info(f'Finished {os.getpid()=} id="{self.info.id}" title="{self.info.title}".')
|
||||||
|
|
||||||
|
|
@ -190,17 +183,14 @@ class Download:
|
||||||
self.status_queue = self.manager.Queue()
|
self.status_queue = self.manager.Queue()
|
||||||
|
|
||||||
# Create temp dir for each download.
|
# Create temp dir for each download.
|
||||||
self.tempPath = os.path.join(
|
self.tempPath = os.path.join(self.temp_dir, hashlib.shake_256(f"D-{self.info.id}".encode("utf-8")).hexdigest(5))
|
||||||
self.temp_dir,
|
|
||||||
hashlib.shake_256(f"D-{self.info.id}".encode('utf-8')).hexdigest(5)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not os.path.exists(self.tempPath):
|
if not os.path.exists(self.tempPath):
|
||||||
os.makedirs(self.tempPath, exist_ok=True)
|
os.makedirs(self.tempPath, exist_ok=True)
|
||||||
|
|
||||||
self.proc = multiprocessing.Process(name=f"download-{self.id}", target=self._download)
|
self.proc = multiprocessing.Process(name=f"download-{self.id}", target=self._download)
|
||||||
self.proc.start()
|
self.proc.start()
|
||||||
self.info.status = 'preparing'
|
self.info.status = "preparing"
|
||||||
|
|
||||||
asyncio.create_task(self.emitter.updated(dl=self.info), name=f"emitter-{self.id}")
|
asyncio.create_task(self.emitter.updated(dl=self.info), name=f"emitter-{self.id}")
|
||||||
asyncio.create_task(self.progress_update(), name=f"update-{self.id}")
|
asyncio.create_task(self.progress_update(), name=f"update-{self.id}")
|
||||||
|
|
@ -230,7 +220,7 @@ class Download:
|
||||||
try:
|
try:
|
||||||
LOG.info(f"Closing download process: '{procId}'.")
|
LOG.info(f"Closing download process: '{procId}'.")
|
||||||
try:
|
try:
|
||||||
if 'update_task' in self.__dict__ and self.update_task.done() is False:
|
if "update_task" in self.__dict__ and self.update_task.done() is False:
|
||||||
self.update_task.cancel()
|
self.update_task.cancel()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Failed to close status queue: '{procId}'. {e}")
|
LOG.error(f"Failed to close status queue: '{procId}'. {e}")
|
||||||
|
|
@ -290,9 +280,10 @@ class Download:
|
||||||
if self.tempKeep is True or not self.tempPath:
|
if self.tempKeep is True or not self.tempPath:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.info.status != 'finished' and self.is_live:
|
if self.info.status != "finished" and self.is_live:
|
||||||
LOG.warning(
|
LOG.warning(
|
||||||
f'Keeping live temp folder [{self.tempPath}], as the reported status is not finished [{self.info.status}].')
|
f"Keeping live temp folder [{self.tempPath}], as the reported status is not finished [{self.info.status}]."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not os.path.exists(self.tempPath):
|
if not os.path.exists(self.tempPath):
|
||||||
|
|
@ -300,10 +291,11 @@ class Download:
|
||||||
|
|
||||||
if self.tempPath == self.temp_dir:
|
if self.tempPath == self.temp_dir:
|
||||||
LOG.warning(
|
LOG.warning(
|
||||||
f'Attempted to delete video temp folder: {self.tempPath}, but it is the same as main temp folder.')
|
f"Attempted to delete video temp folder: {self.tempPath}, but it is the same as main temp folder."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
LOG.info(f'Deleting Temp folder: {self.tempPath}')
|
LOG.info(f"Deleting Temp folder: {self.tempPath}")
|
||||||
shutil.rmtree(self.tempPath, ignore_errors=True)
|
shutil.rmtree(self.tempPath, ignore_errors=True)
|
||||||
|
|
||||||
async def progress_update(self):
|
async def progress_update(self):
|
||||||
|
|
@ -314,54 +306,54 @@ class Download:
|
||||||
try:
|
try:
|
||||||
self.update_task = asyncio.get_running_loop().run_in_executor(None, self.status_queue.get)
|
self.update_task = asyncio.get_running_loop().run_in_executor(None, self.status_queue.get)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
LOG.debug(f'Closing progress update for: {self.info._id=}.')
|
LOG.debug(f"Closing progress update for: {self.info._id=}.")
|
||||||
return
|
return
|
||||||
|
|
||||||
status = await self.update_task
|
status = await self.update_task
|
||||||
|
|
||||||
if status is None or status.__class__ is Terminator:
|
if status is None or status.__class__ is Terminator:
|
||||||
LOG.debug(f'Closing progress update for: {self.info._id=}.')
|
LOG.debug(f"Closing progress update for: {self.info._id=}.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if status.get('id') != self.id or len(status) < 2:
|
if status.get("id") != self.id or len(status) < 2:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
LOG.debug(f'Status Update: {self.info._id=} {status=}')
|
LOG.debug(f"Status Update: {self.info._id=} {status=}")
|
||||||
|
|
||||||
if isinstance(status, str):
|
if isinstance(status, str):
|
||||||
asyncio.create_task(self.emitter.updated(dl=self.info), name=f"emitter-u-{self.id}")
|
asyncio.create_task(self.emitter.updated(dl=self.info), name=f"emitter-u-{self.id}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.tmpfilename = status.get('tmpfilename')
|
self.tmpfilename = status.get("tmpfilename")
|
||||||
|
|
||||||
if 'filename' in status:
|
if "filename" in status:
|
||||||
self.info.filename = os.path.relpath(status.get('filename'), self.download_dir)
|
self.info.filename = os.path.relpath(status.get("filename"), self.download_dir)
|
||||||
|
|
||||||
if os.path.exists(status.get('filename')):
|
if os.path.exists(status.get("filename")):
|
||||||
self.info.file_size = os.path.getsize(status.get('filename'))
|
self.info.file_size = os.path.getsize(status.get("filename"))
|
||||||
|
|
||||||
self.info.status = status.get('status', self.info.status)
|
self.info.status = status.get("status", self.info.status)
|
||||||
self.info.msg = status.get('msg')
|
self.info.msg = status.get("msg")
|
||||||
|
|
||||||
if self.info.status == 'error' and 'error' in status:
|
if self.info.status == "error" and "error" in status:
|
||||||
self.info.error = status.get('error')
|
self.info.error = status.get("error")
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
self.emitter.error(message=self.info.error, data=self.info),
|
self.emitter.error(message=self.info.error, data=self.info), name=f"emitter-e-{self.id}"
|
||||||
name=f"emitter-e-{self.id}")
|
)
|
||||||
|
|
||||||
if 'downloaded_bytes' in status:
|
if "downloaded_bytes" in status:
|
||||||
total = status.get('total_bytes') or status.get('total_bytes_estimate')
|
total = status.get("total_bytes") or status.get("total_bytes_estimate")
|
||||||
if total:
|
if total:
|
||||||
self.info.percent = status['downloaded_bytes'] / total * 100
|
self.info.percent = status["downloaded_bytes"] / total * 100
|
||||||
self.info.total_bytes = total
|
self.info.total_bytes = total
|
||||||
|
|
||||||
self.info.speed = status.get('speed')
|
self.info.speed = status.get("speed")
|
||||||
self.info.eta = status.get('eta')
|
self.info.eta = status.get("eta")
|
||||||
|
|
||||||
if self.info.status == 'finished' and 'filename' in status:
|
if self.info.status == "finished" and "filename" in status:
|
||||||
try:
|
try:
|
||||||
self.info.file_size = os.path.getsize(status.get('filename'))
|
self.info.file_size = os.path.getsize(status.get("filename"))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
LOG.warning(f'File not found: {status.get("filename")}')
|
LOG.warning(f'File not found: {status.get("filename")}')
|
||||||
self.info.file_size = None
|
self.info.file_size = None
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from email.utils import formatdate
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import yt_dlp
|
from email.utils import formatdate
|
||||||
from sqlite3 import Connection
|
from sqlite3 import Connection
|
||||||
from .config import Config
|
|
||||||
from .Download import Download
|
import yt_dlp
|
||||||
from .ItemDTO import ItemDTO
|
|
||||||
from .DataStore import DataStore
|
|
||||||
from .Utils import calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig
|
|
||||||
from .AsyncPool import AsyncPool
|
from .AsyncPool import AsyncPool
|
||||||
|
from .config import Config
|
||||||
|
from .DataStore import DataStore
|
||||||
|
from .Download import Download
|
||||||
from .Emitter import Emitter
|
from .Emitter import Emitter
|
||||||
|
from .ItemDTO import ItemDTO
|
||||||
|
from .Utils import ExtractInfo, calcDownloadPath, isDownloaded, mergeConfig
|
||||||
|
|
||||||
LOG = logging.getLogger("DownloadQueue")
|
LOG = logging.getLogger("DownloadQueue")
|
||||||
TYPE_DONE: str = "done"
|
TYPE_DONE: str = "done"
|
||||||
|
|
@ -20,12 +22,8 @@ TYPE_QUEUE: str = "queue"
|
||||||
|
|
||||||
|
|
||||||
class DownloadQueue:
|
class DownloadQueue:
|
||||||
config: Config = None
|
event: asyncio.Event | None = None
|
||||||
emitter: Emitter = None
|
pool: AsyncPool | None = None
|
||||||
queue: DataStore = None
|
|
||||||
done: DataStore = None
|
|
||||||
event: asyncio.Event = None
|
|
||||||
pool: AsyncPool = None
|
|
||||||
|
|
||||||
def __init__(self, emitter: Emitter, connection: Connection):
|
def __init__(self, emitter: Emitter, connection: Connection):
|
||||||
self.config = Config.get_instance()
|
self.config = Config.get_instance()
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,48 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
LOG = logging.getLogger('Emitter')
|
LOG = logging.getLogger("Emitter")
|
||||||
|
|
||||||
|
|
||||||
class Emitter:
|
class Emitter:
|
||||||
"""
|
"""
|
||||||
This class is used to emit events to the registered emitters.
|
This class is used to emit events to the registered emitters.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
emitters: list[callable] = []
|
emitters: list[callable] = []
|
||||||
|
|
||||||
def add_emitter(self, emitter: callable):
|
def add_emitter(self, emitter: callable):
|
||||||
"""
|
"""
|
||||||
Add an emitter to the list of emitters.
|
Add an emitter to the list of emitters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
emitter (function): The emitter function. The function must return a coroutine or None.
|
emitter (function): The emitter function. The function must return a coroutine or None.
|
||||||
"""
|
"""
|
||||||
self.emitters.append(emitter)
|
self.emitters.append(emitter)
|
||||||
|
|
||||||
async def added(self, dl: dict, **kwargs):
|
async def added(self, dl: dict, **kwargs):
|
||||||
await self.emit('added', dl, **kwargs)
|
await self.emit("added", dl, **kwargs)
|
||||||
|
|
||||||
async def updated(self, dl: dict, **kwargs):
|
async def updated(self, dl: dict, **kwargs):
|
||||||
await self.emit('updated', dl, **kwargs)
|
await self.emit("updated", dl, **kwargs)
|
||||||
|
|
||||||
async def completed(self, dl: dict, **kwargs):
|
async def completed(self, dl: dict, **kwargs):
|
||||||
await self.emit('completed', dl, **kwargs)
|
await self.emit("completed", dl, **kwargs)
|
||||||
|
|
||||||
async def canceled(self, id: str, dl: dict = None, **kwargs):
|
async def canceled(self, id: str, dl: dict = None, **kwargs):
|
||||||
await self.emit('canceled', id, **kwargs)
|
await self.emit("canceled", id, **kwargs)
|
||||||
|
|
||||||
async def cleared(self, id: str, dl: dict = None, **kwargs):
|
async def cleared(self, id: str, dl: dict = None, **kwargs):
|
||||||
await self.emit('cleared', id, **kwargs)
|
await self.emit("cleared", id, **kwargs)
|
||||||
|
|
||||||
async def error(self, message: str, data: dict = {}, **kwargs):
|
async def error(self, message: str, data: dict = {}, **kwargs):
|
||||||
msg = {'status': 'error', 'message': message, 'data': {}}
|
msg = {"status": "error", "message": message, "data": {}}
|
||||||
if data:
|
if data:
|
||||||
msg.update({'data': data})
|
msg.update({"data": data})
|
||||||
await self.emit('error', msg, **kwargs)
|
await self.emit("error", msg, **kwargs)
|
||||||
|
|
||||||
async def warning(self, message: str, **kwargs):
|
async def warning(self, message: str, **kwargs):
|
||||||
await self.emit('error', message, **kwargs)
|
await self.emit("error", message, **kwargs)
|
||||||
|
|
||||||
async def emit(self, event: str, data, **kwargs):
|
async def emit(self, event: str, data, **kwargs):
|
||||||
tasks = []
|
tasks = []
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,41 @@
|
||||||
import base64
|
import base64
|
||||||
from datetime import datetime
|
|
||||||
import functools
|
import functools
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import magic
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.web import Request, RequestHandler, Response
|
||||||
|
|
||||||
|
from .common import common
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .DownloadQueue import DownloadQueue
|
from .DownloadQueue import DownloadQueue
|
||||||
from aiohttp import web
|
from .Emitter import Emitter
|
||||||
from aiohttp.web import Request, Response, RequestHandler
|
from .encoder import Encoder
|
||||||
from .Playlist import Playlist
|
|
||||||
from .M3u8 import M3u8
|
from .M3u8 import M3u8
|
||||||
|
from .Playlist import Playlist
|
||||||
from .Segments import Segments
|
from .Segments import Segments
|
||||||
from .Subtitle import Subtitle
|
from .Subtitle import Subtitle
|
||||||
import logging
|
from .Utils import validate_url, StreamingError
|
||||||
import magic
|
|
||||||
from .common import common
|
|
||||||
from pathlib import Path
|
|
||||||
from .encoder import Encoder
|
|
||||||
from .Emitter import Emitter
|
|
||||||
from .Utils import validate_url
|
|
||||||
|
|
||||||
LOG = logging.getLogger('app')
|
LOG = logging.getLogger("http_api")
|
||||||
MIME = magic.Magic(mime=True)
|
MIME = magic.Magic(mime=True)
|
||||||
|
|
||||||
|
|
||||||
class HttpAPI(common):
|
class HttpAPI(common):
|
||||||
config: Config = None
|
|
||||||
encoder: Encoder = None
|
|
||||||
routes: web.RouteTableDef = None
|
|
||||||
queue: DownloadQueue = None
|
|
||||||
rootPath: str = None
|
|
||||||
|
|
||||||
staticHolder: dict = {}
|
staticHolder: dict = {}
|
||||||
extToMime: dict = {
|
extToMime: dict = {
|
||||||
'.html': 'text/html',
|
".html": "text/html",
|
||||||
'.css': 'text/css',
|
".css": "text/css",
|
||||||
'.js': 'application/javascript',
|
".js": "application/javascript",
|
||||||
'.json': 'application/json',
|
".json": "application/json",
|
||||||
'.ico': 'image/x-icon',
|
".ico": "image/x-icon",
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder):
|
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder):
|
||||||
|
|
@ -57,13 +54,16 @@ class HttpAPI(common):
|
||||||
"""
|
"""
|
||||||
Decorator to mark a method as an HTTP route handler.
|
Decorator to mark a method as an HTTP route handler.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
async def wrapper(*args, **kwargs):
|
async def wrapper(*args, **kwargs):
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
wrapper._http_method = method.upper()
|
wrapper._http_method = method.upper()
|
||||||
wrapper._http_path = path
|
wrapper._http_path = path
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
async def staticFile(self, req: Request) -> Response:
|
async def staticFile(self, req: Request) -> Response:
|
||||||
|
|
@ -72,22 +72,26 @@ class HttpAPI(common):
|
||||||
"""
|
"""
|
||||||
path = req.path
|
path = req.path
|
||||||
if req.path not in self.staticHolder:
|
if req.path not in self.staticHolder:
|
||||||
return web.json_response({"error": "File not found.", "file": path}, status=404)
|
return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
item: dict = self.staticHolder[req.path]
|
item: dict = self.staticHolder[req.path]
|
||||||
|
|
||||||
return web.Response(body=item.get('content'), headers={
|
return web.Response(
|
||||||
'Pragma': 'public',
|
body=item.get("content"),
|
||||||
'Cache-Control': 'public, max-age=31536000',
|
headers={
|
||||||
'Content-Type': item.get('content_type'),
|
"Pragma": "public",
|
||||||
'X-Via': 'memory' if not item.get('file', None) else 'disk',
|
"Cache-Control": "public, max-age=31536000",
|
||||||
})
|
"Content-Type": item.get("content_type"),
|
||||||
|
"X-Via": "memory" if not item.get("file", None) else "disk",
|
||||||
|
},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
def preloadStatic(self, app: web.Application):
|
def preloadStatic(self, app: web.Application):
|
||||||
"""
|
"""
|
||||||
Preload static files from the ui/exported folder.
|
Preload static files from the ui/exported folder.
|
||||||
"""
|
"""
|
||||||
staticDir = os.path.join(self.rootPath, 'ui', 'exported')
|
staticDir = os.path.join(self.rootPath, "ui", "exported")
|
||||||
if not os.path.exists(staticDir):
|
if not os.path.exists(staticDir):
|
||||||
raise ValueError(f"Could not find the frontend UI static assets. '{staticDir}'.")
|
raise ValueError(f"Could not find the frontend UI static assets. '{staticDir}'.")
|
||||||
|
|
||||||
|
|
@ -95,25 +99,25 @@ class HttpAPI(common):
|
||||||
|
|
||||||
for root, _, files in os.walk(staticDir):
|
for root, _, files in os.walk(staticDir):
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.endswith('.map'):
|
if file.endswith(".map"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
file = os.path.join(root, file)
|
file = os.path.join(root, file)
|
||||||
urlPath = f"{self.config.url_prefix}{file.replace(f'{staticDir}/', '')}"
|
urlPath = f"{self.config.url_prefix}{file.replace(f'{staticDir}/', '')}"
|
||||||
|
|
||||||
content = open(file, 'rb').read()
|
content = open(file, "rb").read()
|
||||||
contentType = self.extToMime.get(os.path.splitext(file)[1], MIME.from_file(file))
|
contentType = self.extToMime.get(os.path.splitext(file)[1], MIME.from_file(file))
|
||||||
|
|
||||||
self.staticHolder[urlPath] = {'content': content, 'content_type': contentType}
|
self.staticHolder[urlPath] = {"content": content, "content_type": contentType}
|
||||||
LOG.debug(f"Preloading '{urlPath}'.")
|
LOG.debug(f"Preloading '{urlPath}'.")
|
||||||
app.router.add_get(urlPath, self.staticFile)
|
app.router.add_get(urlPath, self.staticFile)
|
||||||
preloaded += 1
|
preloaded += 1
|
||||||
|
|
||||||
if urlPath.endswith('/index.html') and urlPath != '/index.html':
|
if urlPath.endswith("/index.html") and urlPath != "/index.html":
|
||||||
parentSlash = urlPath.replace('/index.html', '/')
|
parentSlash = urlPath.replace("/index.html", "/")
|
||||||
parentNoSlash = urlPath.replace('/index.html', '')
|
parentNoSlash = urlPath.replace("/index.html", "")
|
||||||
self.staticHolder[parentSlash] = {'content': content, 'content_type': contentType}
|
self.staticHolder[parentSlash] = {"content": content, "content_type": contentType}
|
||||||
self.staticHolder[parentNoSlash] = {'content': content, 'content_type': contentType}
|
self.staticHolder[parentNoSlash] = {"content": content, "content_type": contentType}
|
||||||
app.router.add_get(parentSlash, self.staticFile)
|
app.router.add_get(parentSlash, self.staticFile)
|
||||||
app.router.add_get(parentNoSlash, self.staticFile)
|
app.router.add_get(parentNoSlash, self.staticFile)
|
||||||
preloaded += 2
|
preloaded += 2
|
||||||
|
|
@ -121,39 +125,37 @@ class HttpAPI(common):
|
||||||
if preloaded < 1:
|
if preloaded < 1:
|
||||||
raise ValueError(f"Could not find the frontend UI static assets. '{staticDir}'.")
|
raise ValueError(f"Could not find the frontend UI static assets. '{staticDir}'.")
|
||||||
|
|
||||||
LOG.info(f"Preloaded {preloaded} static files.")
|
LOG.info(f"Preloaded '{preloaded}' static files.")
|
||||||
|
|
||||||
def attach(self, app: web.Application):
|
def attach(self, app: web.Application):
|
||||||
if self.config.auth_username and self.config.auth_password:
|
if self.config.auth_username and self.config.auth_password:
|
||||||
app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, self.config.auth_password))
|
app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, self.config.auth_password))
|
||||||
|
|
||||||
self.add_routes(app)
|
self.add_routes(app)
|
||||||
pass
|
|
||||||
|
|
||||||
def add_routes(self, app: web.Application):
|
def add_routes(self, app: web.Application):
|
||||||
for attr_name in dir(self):
|
for attr_name in dir(self):
|
||||||
method = getattr(self, attr_name)
|
method = getattr(self, attr_name)
|
||||||
if hasattr(method, '_http_method') and hasattr(method, '_http_path'):
|
if hasattr(method, "_http_method") and hasattr(method, "_http_path"):
|
||||||
http_path = method._http_path
|
http_path = method._http_path
|
||||||
if http_path.startswith('/') and self.config.url_prefix.endswith('/'):
|
if http_path.startswith("/") and self.config.url_prefix.endswith("/"):
|
||||||
http_path = method._http_path[1:]
|
http_path = method._http_path[1:]
|
||||||
|
|
||||||
self.routes.route(method._http_method, self.config.url_prefix + http_path)(method)
|
self.routes.route(method._http_method, self.config.url_prefix + http_path)(method)
|
||||||
|
|
||||||
async def on_prepare(request: Request, response: Response):
|
async def on_prepare(request: Request, response: Response):
|
||||||
if 'Server' in response.headers:
|
if "Server" in response.headers:
|
||||||
del response.headers['Server']
|
del response.headers["Server"]
|
||||||
|
|
||||||
if 'Origin' in request.headers:
|
if "Origin" in request.headers:
|
||||||
response.headers['Access-Control-Allow-Origin'] = request.headers['Origin']
|
response.headers["Access-Control-Allow-Origin"] = request.headers["Origin"]
|
||||||
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||||
response.headers['Access-Control-Allow-Methods'] = 'PATCH, PUT, POST, DELETE'
|
response.headers["Access-Control-Allow-Methods"] = "PATCH, PUT, POST, DELETE"
|
||||||
|
|
||||||
if self.config.url_prefix != '/':
|
if self.config.url_prefix != "/":
|
||||||
self.routes.route('GET', '/')(lambda _: web.HTTPFound(self.config.url_prefix))
|
self.routes.route("GET", "/")(lambda _: web.HTTPFound(self.config.url_prefix))
|
||||||
self.routes.get(self.config.url_prefix[:-1])(lambda _: web.HTTPFound(self.config.url_prefix))
|
self.routes.get(self.config.url_prefix[:-1])(lambda _: web.HTTPFound(self.config.url_prefix))
|
||||||
|
|
||||||
# add static files.
|
|
||||||
self.routes.static(f"{self.config.url_prefix}download/", self.config.download_path)
|
self.routes.static(f"{self.config.url_prefix}download/", self.config.download_path)
|
||||||
self.preloadStatic(app)
|
self.preloadStatic(app)
|
||||||
|
|
||||||
|
|
@ -161,54 +163,66 @@ class HttpAPI(common):
|
||||||
app.add_routes(self.routes)
|
app.add_routes(self.routes)
|
||||||
app.on_response_prepare.append(on_prepare)
|
app.on_response_prepare.append(on_prepare)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
if 'ui/exported' in str(e):
|
if "ui/exported" in str(e):
|
||||||
raise RuntimeError(f"Could not find the frontend UI static assets. '{e}'.") from e
|
raise RuntimeError(f"Could not find the frontend UI static assets. '{e}'.") from e
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def basic_auth(username: str, password: str):
|
def basic_auth(username: str, password: str):
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
|
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
|
||||||
auth_header = request.headers.get('Authorization')
|
auth_header = request.headers.get("Authorization")
|
||||||
|
|
||||||
if auth_header is None:
|
if auth_header is None:
|
||||||
return web.Response(status=401, headers={
|
return web.json_response(
|
||||||
'WWW-Authenticate': 'Basic realm="Authorization Required."',
|
status=web.HTTPUnauthorized.status_code,
|
||||||
}, text='Unauthorized.')
|
headers={
|
||||||
|
"WWW-Authenticate": 'Basic realm="Authorization Required."',
|
||||||
|
},
|
||||||
|
data={"error": "Authorization Required."},
|
||||||
|
)
|
||||||
|
|
||||||
auth_type, encoded_credentials = auth_header.split(' ', 1)
|
auth_type, encoded_credentials = auth_header.split(" ", 1)
|
||||||
|
|
||||||
if 'basic' != auth_type.lower():
|
if "basic" != auth_type.lower():
|
||||||
return web.Response(status=401, text="Unsupported authentication method.")
|
return web.json_response(
|
||||||
|
data={"error": "Unsupported authentication method.", "method": auth_type},
|
||||||
|
status=web.HTTPUnauthorized.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
|
decoded_credentials = base64.b64decode(encoded_credentials).decode("utf-8")
|
||||||
user_name, _, user_password = decoded_credentials.partition(':')
|
user_name, _, user_password = decoded_credentials.partition(":")
|
||||||
|
|
||||||
if user_name != username or user_password != password:
|
user_match = hmac.compare_digest(user_name, username)
|
||||||
return web.Response(status=401, text='Unauthorized (Invalid credentials).')
|
pass_match = hmac.compare_digest(user_password, password)
|
||||||
|
|
||||||
|
if not (user_match and pass_match):
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "Unauthorized (Invalid credentials)."}, status=web.HTTPUnauthorized.status_code
|
||||||
|
)
|
||||||
|
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
|
|
||||||
return middleware_handler
|
return middleware_handler
|
||||||
|
|
||||||
@route('GET', 'ping')
|
@route("GET", "ping")
|
||||||
async def ping(self, _) -> Response:
|
async def ping(self, _) -> Response:
|
||||||
await self.queue.test()
|
await self.queue.test()
|
||||||
return web.Response(text='pong')
|
return web.json_response(data={"status": "pong"}, status=web.HTTPOk.status_code)
|
||||||
|
|
||||||
@route('POST', 'add')
|
@route("POST", "add")
|
||||||
async def add_url(self, request: Request) -> Response:
|
async def add_url(self, request: Request) -> Response:
|
||||||
post = await request.json()
|
post = await request.json()
|
||||||
|
|
||||||
url: str = post.get('url')
|
url: str = post.get("url")
|
||||||
preset: str = post.get('preset', 'default')
|
preset: str = post.get("preset", "default")
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
raise web.HTTPBadRequest()
|
return web.json_response(data={"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
folder: str = post.get('folder')
|
folder: str = post.get("folder")
|
||||||
ytdlp_cookies: str = post.get('ytdlp_cookies')
|
ytdlp_cookies: str = post.get("ytdlp_cookies")
|
||||||
ytdlp_config: dict = post.get('ytdlp_config')
|
ytdlp_config: dict | None = post.get("ytdlp_config")
|
||||||
output_template: str = post.get('output_template')
|
output_template: str = post.get("output_template")
|
||||||
if ytdlp_config is None:
|
if ytdlp_config is None:
|
||||||
ytdlp_config = {}
|
ytdlp_config = {}
|
||||||
|
|
||||||
|
|
@ -218,83 +232,92 @@ class HttpAPI(common):
|
||||||
folder=folder,
|
folder=folder,
|
||||||
ytdlp_cookies=ytdlp_cookies,
|
ytdlp_cookies=ytdlp_cookies,
|
||||||
ytdlp_config=ytdlp_config,
|
ytdlp_config=ytdlp_config,
|
||||||
output_template=output_template
|
output_template=output_template,
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.Response(text=self.encoder.encode(status))
|
return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
|
||||||
|
|
||||||
@route('GET', 'tasks')
|
@route("GET", "tasks")
|
||||||
async def tasks(self, _: Request) -> Response:
|
async def tasks(self, _: Request) -> Response:
|
||||||
tasks_file: str = os.path.join(self.config.config_path, 'tasks.json')
|
tasks_file: str = os.path.join(self.config.config_path, "tasks.json")
|
||||||
|
|
||||||
if not os.path.exists(tasks_file):
|
if not os.path.exists(tasks_file):
|
||||||
return web.json_response({"error": "No tasks defined."}, status=404)
|
return web.json_response({"error": "No tasks defined."}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(tasks_file, 'r') as f:
|
with open(tasks_file, "r") as f:
|
||||||
tasks = json.load(f)
|
tasks = json.load(f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
LOG.exception(e)
|
||||||
|
return web.json_response({"error": "Failed to load tasks."}, status=web.HTTPInternalServerError.status_code)
|
||||||
|
|
||||||
return web.json_response(tasks)
|
return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
|
||||||
|
|
||||||
@route('POST', 'add_batch')
|
@route("POST", "add_batch")
|
||||||
async def add_batch(self, request: Request) -> Response:
|
async def add_batch(self, request: Request) -> Response:
|
||||||
status = {}
|
status = {}
|
||||||
|
|
||||||
post = await request.json()
|
post = await request.json()
|
||||||
if not isinstance(post, list):
|
if not isinstance(post, list):
|
||||||
raise web.HTTPBadRequest(text='Invalid request body expecting list with dicts.')
|
return web.json_response(
|
||||||
|
data={"error": "Invalid request body expecting list with dicts."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
for item in post:
|
for item in post:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
raise web.HTTPBadRequest(text='Invalid request body expecting list with dicts.')
|
return web.json_response(
|
||||||
|
data={"error": "Invalid request body expecting list with dicts."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
if not item.get('url'):
|
if not item.get("url"):
|
||||||
raise web.HTTPBadRequest(text='url is required.')
|
return web.json_response(
|
||||||
|
data={"error": "url is required.", "data": post}, status=web.HTTPBadRequest.status_code
|
||||||
|
)
|
||||||
|
|
||||||
status[item.get('url')] = await self.add(
|
for item in post:
|
||||||
url=item.get('url'),
|
status[item.get("url")] = await self.add(
|
||||||
preset=item.get('preset', 'default'),
|
url=item.get("url"),
|
||||||
folder=item.get('folder'),
|
preset=item.get("preset", "default"),
|
||||||
ytdlp_cookies=item.get('ytdlp_cookies'),
|
folder=item.get("folder"),
|
||||||
ytdlp_config=item.get('ytdlp_config'),
|
ytdlp_cookies=item.get("ytdlp_cookies"),
|
||||||
output_template=item.get('output_template')
|
ytdlp_config=item.get("ytdlp_config"),
|
||||||
|
output_template=item.get("output_template"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.Response(text=self.encoder.encode(status))
|
return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
|
||||||
|
|
||||||
@route('DELETE', 'delete')
|
@route("DELETE", "delete")
|
||||||
async def delete(self, request: Request) -> Response:
|
async def delete(self, request: Request) -> Response:
|
||||||
post = await request.json()
|
post = await request.json()
|
||||||
ids = post.get('ids')
|
ids = post.get("ids")
|
||||||
where = post.get('where')
|
where = post.get("where")
|
||||||
|
|
||||||
if not ids or where not in ['queue', 'done']:
|
if not ids or where not in ["queue", "done"]:
|
||||||
raise web.HTTPBadRequest()
|
return web.json_response(
|
||||||
|
data={"error": "ids and where are required."}, status=web.HTTPBadRequest.status_code
|
||||||
|
)
|
||||||
|
|
||||||
status: dict[str, str] = {}
|
return web.json_response(
|
||||||
|
data=await (self.queue.cancel(ids) if where == "queue" else self.queue.clear(ids)),
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
dumps=self.encoder.encode,
|
||||||
|
)
|
||||||
|
|
||||||
status = await (self.queue.cancel(ids) if where == 'queue' else self.queue.clear(ids))
|
@route("POST", "item/{id}")
|
||||||
|
|
||||||
return web.Response(text=self.encoder.encode(status))
|
|
||||||
|
|
||||||
@route('POST', 'item/{id}')
|
|
||||||
async def update_item(self, request: Request) -> Response:
|
async def update_item(self, request: Request) -> Response:
|
||||||
id: str = request.match_info.get('id')
|
id: str = request.match_info.get("id")
|
||||||
if not id:
|
if not id:
|
||||||
raise web.HTTPBadRequest(text='id is required.')
|
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
item = self.queue.done.getById(id)
|
item = self.queue.done.getById(id)
|
||||||
if not item:
|
if not item:
|
||||||
raise web.HTTPNotFound(text='Item not found.')
|
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
try:
|
post = await request.json()
|
||||||
post = await request.json()
|
if not post:
|
||||||
if not post:
|
return web.json_response(data={"error": "no data provided."}, status=web.HTTPBadRequest.status_code)
|
||||||
raise web.HTTPBadRequest(text='no data provided.')
|
|
||||||
except Exception as e:
|
|
||||||
raise web.HTTPBadRequest(text=str(e))
|
|
||||||
|
|
||||||
updated = False
|
updated = False
|
||||||
|
|
||||||
|
|
@ -307,30 +330,33 @@ class HttpAPI(common):
|
||||||
|
|
||||||
updated = True
|
updated = True
|
||||||
setattr(item.info, k, v)
|
setattr(item.info, k, v)
|
||||||
LOG.info(f"Updated '{k}' to '{v}' for '{item.info.id}'")
|
LOG.debug(f"Updated '{k}' to '{v}' for '{item.info.id}'")
|
||||||
|
|
||||||
status = 200 if updated else 304
|
|
||||||
if updated:
|
if updated:
|
||||||
self.queue.done.put(item)
|
self.queue.done.put(item)
|
||||||
await self.notifier.emit('update', item.info)
|
await self.emitter.emit("update", item.info)
|
||||||
|
|
||||||
return web.Response(text=self.encoder.encode(item.info), status=status)
|
return web.json_response(
|
||||||
|
data=item.info,
|
||||||
|
status=web.HTTPOk.status_code if updated else web.HTTPNotModified.status_code,
|
||||||
|
dumps=self.encoder.encode,
|
||||||
|
)
|
||||||
|
|
||||||
@route('GET', 'history')
|
@route("GET", "history")
|
||||||
async def history(self, _: Request) -> Response:
|
async def history(self, _: Request) -> Response:
|
||||||
history = {'done': [], 'queue': []}
|
data: dict = {"queue": [], "history": []}
|
||||||
|
|
||||||
for _, v in self.queue.queue.saved_items():
|
for _, v in self.queue.queue.saved_items():
|
||||||
history['queue'].append(v)
|
data["queue"].append(v)
|
||||||
for _, v in self.queue.done.saved_items():
|
for _, v in self.queue.done.saved_items():
|
||||||
history['done'].append(v)
|
data["history"].append(v)
|
||||||
|
|
||||||
return web.Response(text=self.encoder.encode(history))
|
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
|
||||||
|
|
||||||
@route('GET', 'workers')
|
@route("GET", "workers")
|
||||||
async def workers(self, _) -> Response:
|
async def workers(self, _) -> Response:
|
||||||
if self.queue.pool is None:
|
if self.queue.pool is None:
|
||||||
return web.json_response({"error": "Worker pool not initialized."}, status=404)
|
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
status = self.queue.pool.get_workers_status()
|
status = self.queue.pool.get_workers_status()
|
||||||
|
|
||||||
|
|
@ -338,231 +364,271 @@ class HttpAPI(common):
|
||||||
|
|
||||||
for worker in status:
|
for worker in status:
|
||||||
worker_status = status.get(worker)
|
worker_status = status.get(worker)
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"id": worker,
|
||||||
|
"data": {"status": "Waiting for download."} if worker_status is None else worker_status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
data.append({
|
return web.json_response(
|
||||||
'id': worker,
|
data={
|
||||||
'data': {"status": 'Waiting for download.'} if worker_status is None else worker_status,
|
"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__}>>"),
|
||||||
|
)
|
||||||
|
|
||||||
def safe_serialize(obj):
|
@route("POST", "workers")
|
||||||
def default(o): return f"<<non-serializable: {type(o).__qualname__}>>"
|
|
||||||
return json.dumps(obj, default=default)
|
|
||||||
|
|
||||||
return web.Response(text=safe_serialize({
|
|
||||||
'open': self.queue.pool.has_open_workers(),
|
|
||||||
'count': self.queue.pool.get_available_workers(),
|
|
||||||
'workers': data,
|
|
||||||
}), headers={
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
})
|
|
||||||
|
|
||||||
@route('POST', 'workers')
|
|
||||||
async def restart_pool(self, _) -> Response:
|
async def restart_pool(self, _) -> Response:
|
||||||
if self.queue.pool is None:
|
if self.queue.pool is None:
|
||||||
return web.json_response({"error": "Worker pool not initialized."}, status=404)
|
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
self.queue.pool.start()
|
self.queue.pool.start()
|
||||||
|
|
||||||
return web.Response()
|
return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code)
|
||||||
|
|
||||||
@route('PATCH', 'workers/{id}')
|
@route("PATCH", "workers/{id}")
|
||||||
async def restart_worker(self, request: Request) -> Response:
|
async def restart_worker(self, request: Request) -> Response:
|
||||||
id: str = request.match_info.get('id')
|
id: str = request.match_info.get("id")
|
||||||
if not id:
|
if not id:
|
||||||
raise web.HTTPBadRequest(text='worker id is required.')
|
return web.json_response({"error": "worker id is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
if self.queue.pool is None:
|
if self.queue.pool is None:
|
||||||
return web.json_response({"error": "Worker pool not initialized."}, status=404)
|
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
status = await self.queue.pool.restart(id, 'requested by user.')
|
status = await self.queue.pool.restart(id, "requested by user.")
|
||||||
|
|
||||||
return web.json_response({"status": "restarted" if status else "in_error_state"})
|
return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code)
|
||||||
|
|
||||||
@route('delete', 'workers/{id}')
|
@route("delete", "workers/{id}")
|
||||||
async def stop_worker(self, request: Request) -> Response:
|
async def stop_worker(self, request: Request) -> Response:
|
||||||
id: str = request.match_info.get('id')
|
id: str = request.match_info.get("id")
|
||||||
if not id:
|
if not id:
|
||||||
raise web.HTTPBadRequest(text='worker id is required.')
|
raise web.HTTPBadRequest(text="worker id is required.")
|
||||||
|
|
||||||
if self.queue.pool is None:
|
if self.queue.pool is None:
|
||||||
return web.json_response({"error": "Worker pool not initialized."}, status=404)
|
return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
status = await self.queue.pool.stop(id, 'requested by user.')
|
status = await self.queue.pool.stop(id, "requested by user.")
|
||||||
|
|
||||||
return web.json_response({"status": "stopped" if status else "in_error_state"})
|
return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code)
|
||||||
|
|
||||||
@route('GET', 'player/playlist/{file:.*}.m3u8')
|
@route("GET", "player/playlist/{file:.*}.m3u8")
|
||||||
async def playlist(self, request: Request) -> Response:
|
async def playlist(self, request: Request) -> Response:
|
||||||
file: str = request.match_info.get('file')
|
file: str = request.match_info.get("file")
|
||||||
|
|
||||||
if not file:
|
if not file:
|
||||||
raise web.HTTPBadRequest(text='file is required.')
|
raise web.HTTPBadRequest(text="file is required.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text = await Playlist(url=f"{self.config.url_host}{self.config.url_prefix}").make(
|
text = await Playlist(url=f"{self.config.url_host}{self.config.url_prefix}").make(
|
||||||
download_path=self.config.download_path,
|
download_path=self.config.download_path, file=file
|
||||||
file=file
|
|
||||||
)
|
)
|
||||||
if isinstance(text, Response):
|
if isinstance(text, Response):
|
||||||
return text
|
return text
|
||||||
except Exception as e:
|
except StreamingError as e:
|
||||||
return web.HTTPNotFound(text=str(e))
|
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
return web.Response(text=text, headers={
|
return web.Response(
|
||||||
'Content-Type': 'application/x-mpegURL',
|
text=text,
|
||||||
'Cache-Control': 'no-cache',
|
headers={
|
||||||
'Access-Control-Max-Age': "300",
|
"Content-Type": "application/x-mpegURL",
|
||||||
})
|
"Cache-Control": "no-cache",
|
||||||
|
"Access-Control-Max-Age": "300",
|
||||||
|
},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
@route('GET', 'player/m3u8/{mode}/{file:.*}.m3u8')
|
@route("GET", "player/m3u8/{mode}/{file:.*}.m3u8")
|
||||||
async def m3u8(self, request: Request) -> Response:
|
async def m3u8(self, request: Request) -> Response:
|
||||||
file: str = request.match_info.get('file')
|
file: str = request.match_info.get("file")
|
||||||
mode: str = request.match_info.get('mode')
|
mode: str = request.match_info.get("mode")
|
||||||
|
|
||||||
if mode not in ['video', 'subtitle']:
|
if mode not in ["video", "subtitle"]:
|
||||||
raise web.HTTPBadRequest(text='Only video and subtitle modes are supported.')
|
return web.json_response(
|
||||||
|
data={"error": "Only video and subtitle modes are supported."}, status=web.HTTPBadRequest.status_code
|
||||||
|
)
|
||||||
|
|
||||||
if not file:
|
if not file:
|
||||||
raise web.HTTPBadRequest(text='file is required.')
|
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
duration = request.query.get('duration', None)
|
duration = request.query.get("duration", None)
|
||||||
|
|
||||||
if 'subtitle' in mode:
|
if "subtitle" in mode:
|
||||||
if not duration:
|
if not duration:
|
||||||
raise web.HTTPBadRequest(text='duration is required.')
|
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
duration = float(duration)
|
duration = float(duration)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cls = M3u8(f"{self.config.url_host}{self.config.url_prefix}")
|
cls = M3u8(f"{self.config.url_host}{self.config.url_prefix}")
|
||||||
if 'subtitle' in mode:
|
if "subtitle" in mode:
|
||||||
text = await cls.make_subtitle(self.config.download_path, file, duration)
|
text = await cls.make_subtitle(self.config.download_path, file, duration)
|
||||||
else:
|
else:
|
||||||
text = await cls.make_stream(self.config.download_path, file)
|
text = await cls.make_stream(self.config.download_path, file)
|
||||||
except Exception as e:
|
except StreamingError as e:
|
||||||
return web.HTTPNotFound(text=str(e))
|
LOG.exception(e)
|
||||||
|
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
return web.Response(text=text, headers={
|
return web.Response(
|
||||||
'Content-Type': 'application/x-mpegURL',
|
text=text,
|
||||||
'Cache-Control': 'no-cache',
|
headers={
|
||||||
'Access-Control-Max-Age': "300",
|
"Content-Type": "application/x-mpegURL",
|
||||||
})
|
"Cache-Control": "no-cache",
|
||||||
|
"Access-Control-Max-Age": "300",
|
||||||
|
},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
@route('GET', r'player/segments/{segment:\d+}/{file:.*}.ts')
|
@route("GET", r"player/segments/{segment:\d+}/{file:.*}.ts")
|
||||||
async def segments(self, request: Request) -> Response:
|
async def segments(self, request: Request) -> Response:
|
||||||
file: str = request.match_info.get('file')
|
file: str = request.match_info.get("file")
|
||||||
segment: int = request.match_info.get('segment')
|
segment: int = request.match_info.get("segment")
|
||||||
sd: int = request.query.get('sd')
|
sd: int = request.query.get("sd")
|
||||||
vc: int = int(request.query.get('vc', 0))
|
vc: int = int(request.query.get("vc", 0))
|
||||||
ac: int = int(request.query.get('ac', 0))
|
ac: int = int(request.query.get("ac", 0))
|
||||||
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
|
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
|
||||||
if not file_path.startswith(self.config.download_path):
|
if not file_path.startswith(self.config.download_path):
|
||||||
raise web.HTTPBadRequest(text='Invalid file path.')
|
return web.json_response(data={"error": "Invalid file path."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
if request.if_modified_since:
|
if request.if_modified_since:
|
||||||
|
lastMod = time.strftime(
|
||||||
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(os.path.getmtime(file_path)).timetuple()
|
||||||
|
)
|
||||||
if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path):
|
if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path):
|
||||||
return web.Response(status=304)
|
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||||
|
|
||||||
if not file:
|
if not file:
|
||||||
raise web.HTTPBadRequest(text='file is required')
|
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
if not segment:
|
if not segment:
|
||||||
raise web.HTTPBadRequest(text='segment is required')
|
return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
segmenter = Segments(
|
segmenter = Segments(
|
||||||
index=int(segment),
|
index=int(segment),
|
||||||
duration=float('{:.6f}'.format(float(sd if sd else M3u8.duration))),
|
duration=float("{:.6f}".format(float(sd if sd else M3u8.duration))),
|
||||||
vconvert=True if vc == 1 else False,
|
vconvert=True if vc == 1 else False,
|
||||||
aconvert=True if ac == 1 else False
|
aconvert=True if ac == 1 else False,
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.Response(body=await segmenter.stream(path=self.config.download_path, file=file), headers={
|
return web.Response(
|
||||||
'Content-Type': 'video/mpegts',
|
body=await segmenter.stream(path=self.config.download_path, file=file),
|
||||||
'X-Accel-Buffering': 'no',
|
headers={
|
||||||
'Access-Control-Allow-Origin': '*',
|
"Content-Type": "video/mpegts",
|
||||||
'Pragma': 'public',
|
"X-Accel-Buffering": "no",
|
||||||
'Cache-Control': f"public, max-age={time.time() + 31536000}",
|
"Access-Control-Allow-Origin": "*",
|
||||||
'Last-Modified': time.strftime('%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp(os.path.getmtime(file_path)).timetuple()),
|
"Pragma": "public",
|
||||||
'Expires': time.strftime('%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp(time.time() + 31536000).timetuple()),
|
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||||
})
|
"Last-Modified": time.strftime(
|
||||||
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(os.path.getmtime(file_path)).timetuple()
|
||||||
|
),
|
||||||
|
"Expires": time.strftime(
|
||||||
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000).timetuple()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
@route('GET', 'player/subtitle/{file:.*}.vtt')
|
@route("GET", "player/subtitle/{file:.*}.vtt")
|
||||||
async def subtitles(self, request: Request) -> Response:
|
async def subtitles(self, request: Request) -> Response:
|
||||||
file: str = request.match_info.get('file')
|
file: str = request.match_info.get("file")
|
||||||
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
|
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
|
||||||
if not file_path.startswith(self.config.download_path):
|
if not file_path.startswith(self.config.download_path):
|
||||||
raise web.HTTPBadRequest(text='Invalid file path.')
|
return web.json_response(data={"error": "Invalid file path."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
if request.if_modified_since:
|
if request.if_modified_since:
|
||||||
lastMod = time.strftime('%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp(
|
lastMod = time.strftime(
|
||||||
os.path.getmtime(file_path)).timetuple())
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(os.path.getmtime(file_path)).timetuple()
|
||||||
|
)
|
||||||
if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path):
|
if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path):
|
||||||
return web.Response(status=304, headers={'Last-Modified': lastMod})
|
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||||
|
|
||||||
if not file:
|
if not file:
|
||||||
raise web.HTTPBadRequest(text='file is required')
|
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
return web.Response(body=await Subtitle().make(path=self.config.download_path, file=file), headers={
|
|
||||||
'Content-Type': 'text/vtt; charset=UTF-8',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Pragma': 'public',
|
|
||||||
'Cache-Control': f"public, max-age={time.time() + 31536000}",
|
|
||||||
'Last-Modified': time.strftime('%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp(os.path.getmtime(file_path)).timetuple()),
|
|
||||||
'Expires': time.strftime('%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp(time.time() + 31536000).timetuple()),
|
|
||||||
})
|
|
||||||
|
|
||||||
@route('OPTIONS', '/add')
|
|
||||||
async def add_cors(self, _: Request) -> Response:
|
|
||||||
return web.Response(text=self.encoder.encode({"status": "ok"}))
|
|
||||||
|
|
||||||
@route('OPTIONS', '/delete')
|
|
||||||
async def delete_cors(self, _: Request) -> Response:
|
|
||||||
return web.Response(text=self.encoder.encode({"status": "ok"}))
|
|
||||||
|
|
||||||
@route('GET', '/')
|
|
||||||
async def index(self, _) -> Response:
|
|
||||||
if '/index.html' not in self.staticHolder:
|
|
||||||
LOG.error("Static frontend files not found.")
|
|
||||||
return web.json_response({"error": "File not found.", "file": '/index.html'}, status=404)
|
|
||||||
|
|
||||||
data = self.staticHolder['/index.html']
|
|
||||||
return web.Response(
|
return web.Response(
|
||||||
body=data.get('content'),
|
body=await Subtitle().make(path=self.config.download_path, file=file),
|
||||||
content_type=data.get('content_type'),
|
headers={
|
||||||
charset='utf-8',
|
"Content-Type": "text/vtt; charset=UTF-8",
|
||||||
status=web.HTTPOk.status_code)
|
"X-Accel-Buffering": "no",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Pragma": "public",
|
||||||
|
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||||
|
"Last-Modified": time.strftime(
|
||||||
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(os.path.getmtime(file_path)).timetuple()
|
||||||
|
),
|
||||||
|
"Expires": time.strftime(
|
||||||
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000).timetuple()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
@route('GET', '/thumbnail')
|
@route("OPTIONS", "/add")
|
||||||
async def get_thumbnail(self, request: Request) -> dict:
|
async def add_cors(self, _: Request) -> Response:
|
||||||
url = request.query.get('url')
|
return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code)
|
||||||
|
|
||||||
|
@route("OPTIONS", "/delete")
|
||||||
|
async def delete_cors(self, _: Request) -> Response:
|
||||||
|
return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code)
|
||||||
|
|
||||||
|
@route("GET", "/")
|
||||||
|
async def index(self, _) -> Response:
|
||||||
|
if "/index.html" not in self.staticHolder:
|
||||||
|
LOG.error("Static frontend files not found.")
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "File not found.", "file": "/index.html"}, status=web.HTTPNotFound.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
data = self.staticHolder["/index.html"]
|
||||||
|
return web.Response(
|
||||||
|
body=data.get("content"),
|
||||||
|
content_type=data.get("content_type"),
|
||||||
|
charset="utf-8",
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
@route("GET", "/thumbnail")
|
||||||
|
async def get_thumbnail(self, request: Request) -> Response:
|
||||||
|
url = request.query.get("url")
|
||||||
if not url:
|
if not url:
|
||||||
return web.json_response({"error": "URL is required."}, status=400)
|
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validate_url(url)
|
validate_url(url)
|
||||||
except Exception as e:
|
except ValueError as e:
|
||||||
return web.json_response({"error": str(e)}, status=400)
|
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
opts = {
|
opts = {
|
||||||
'proxy': self.config.ytdl_options.get('proxy', None),
|
"proxy": self.config.ytdl_options.get("proxy", None),
|
||||||
'headers': {
|
"headers": {
|
||||||
'User-Agent': self.config.ytdl_options.get(
|
"User-Agent": self.config.ytdl_options.get(
|
||||||
'user_agent',
|
"user_agent", request.headers.get("User-Agent", f"YTPTube/{self.config.version}")
|
||||||
request.headers.get('User-Agent', f"YTPTube/{self.config.version}")),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(**opts) as client:
|
async with httpx.AsyncClient(**opts) as client:
|
||||||
LOG.info(f"Fetching thumbnail from '{url}'.")
|
LOG.debug(f"Fetching thumbnail from '{url}'.")
|
||||||
response = await client.request(method='GET', url=url)
|
response = await client.request(method="GET", url=url)
|
||||||
return web.Response(body=response.content,
|
return web.Response(
|
||||||
headers={'Content-Type': response.headers.get('Content-Type'),
|
body=response.content,
|
||||||
'Pragma': 'public', 'Access-Control-Allow-Origin': '*',
|
headers={
|
||||||
'Cache-Control': f"public, max-age={time.time() + 31536000}",
|
"Content-Type": response.headers.get("Content-Type"),
|
||||||
'Expires': time.strftime(
|
"Pragma": "public",
|
||||||
'%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp(
|
"Access-Control-Allow-Origin": "*",
|
||||||
time.time() + 31536000).timetuple()), })
|
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||||
|
"Expires": time.strftime(
|
||||||
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000).timetuple()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'")
|
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'.")
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
LOG.exception(e)
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,37 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
|
||||||
import errno
|
import errno
|
||||||
import functools
|
import functools
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import pty
|
import pty
|
||||||
import shlex
|
import shlex
|
||||||
from aiohttp import web
|
from datetime import datetime
|
||||||
|
|
||||||
import socketio
|
import socketio
|
||||||
import logging
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .common import common
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .DownloadQueue import DownloadQueue
|
from .DownloadQueue import DownloadQueue
|
||||||
from .common import common
|
from .Emitter import Emitter
|
||||||
from .encoder import Encoder
|
from .encoder import Encoder
|
||||||
from .Utils import isDownloaded
|
from .Utils import isDownloaded
|
||||||
from .Emitter import Emitter
|
|
||||||
|
|
||||||
LOG = logging.getLogger('socket')
|
LOG = logging.getLogger("socket_api")
|
||||||
|
|
||||||
|
|
||||||
class HttpSocket(common):
|
class HttpSocket(common):
|
||||||
"""
|
"""
|
||||||
This class is used to handle WebSocket events.
|
This class is used to handle WebSocket events.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
config: Config = None
|
config: Config = None
|
||||||
sio: socketio.AsyncServer = None
|
sio: socketio.AsyncServer = None
|
||||||
|
|
||||||
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder):
|
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder):
|
||||||
super().__init__(queue=queue, encoder=encoder)
|
super().__init__(queue=queue, encoder=encoder)
|
||||||
|
|
||||||
self.sio = socketio.AsyncServer(cors_allowed_origins='*')
|
self.sio = socketio.AsyncServer(cors_allowed_origins="*")
|
||||||
emitter.add_emitter(lambda event, data, **kwargs: self.sio.emit(event, encoder.encode(data), **kwargs))
|
emitter.add_emitter(lambda event, data, **kwargs: self.sio.emit(event, encoder.encode(data), **kwargs))
|
||||||
|
|
||||||
self.config = Config.get_instance()
|
self.config = Config.get_instance()
|
||||||
|
|
@ -40,41 +43,45 @@ class HttpSocket(common):
|
||||||
"""
|
"""
|
||||||
Decorator to mark a method as a socket event.
|
Decorator to mark a method as a socket event.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
async def wrapper(*args, **kwargs):
|
async def wrapper(*args, **kwargs):
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
wrapper._ws_event = func.__name__
|
wrapper._ws_event = func.__name__
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def attach(self, app: web.Application):
|
def attach(self, app: web.Application):
|
||||||
self.sio.attach(app, socketio_path=self.config.url_prefix + 'socket.io')
|
self.sio.attach(app, socketio_path=self.config.url_prefix + "socket.io")
|
||||||
|
|
||||||
for attr_name in dir(self):
|
for attr_name in dir(self):
|
||||||
method = getattr(self, attr_name)
|
method = getattr(self, attr_name)
|
||||||
if hasattr(method, '_ws_event'):
|
if hasattr(method, "_ws_event"):
|
||||||
event = method._ws_event
|
event = method._ws_event
|
||||||
self.sio.on(event)(method)
|
self.sio.on(event)(method)
|
||||||
|
|
||||||
@ws_event
|
@ws_event
|
||||||
async def cli_post(self, sid: str, data):
|
async def cli_post(self, sid: str, data):
|
||||||
if not data:
|
if not data:
|
||||||
await self.emitter.emit('cli_close', {'exitcode': 0}, to=sid)
|
await self.emitter.emit("cli_close", {"exitcode": 0}, to=sid)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
LOG.info(f"Cli command from client '{sid}'. '{data}'")
|
LOG.info(f"Cli command from client '{sid}'. '{data}'")
|
||||||
|
|
||||||
args = ['yt-dlp'] + shlex.split(data)
|
args = ["yt-dlp"] + shlex.split(data)
|
||||||
_env = os.environ.copy()
|
_env = os.environ.copy()
|
||||||
_env.update({
|
_env.update(
|
||||||
"TERM": "xterm-256color",
|
{
|
||||||
"LANG": "en_US.UTF-8",
|
"TERM": "xterm-256color",
|
||||||
"SHELL": "/bin/bash",
|
"LANG": "en_US.UTF-8",
|
||||||
"LC_ALL": "en_US.UTF-8",
|
"SHELL": "/bin/bash",
|
||||||
"PWD": self.config.download_path,
|
"LC_ALL": "en_US.UTF-8",
|
||||||
"FORCE_COLOR": "1",
|
"PWD": self.config.download_path,
|
||||||
"PYTHONUNBUFFERED": "1",
|
"FORCE_COLOR": "1",
|
||||||
})
|
"PYTHONUNBUFFERED": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
master_fd, slave_fd = pty.openpty()
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
|
||||||
|
|
@ -94,7 +101,7 @@ class HttpSocket(common):
|
||||||
|
|
||||||
async def read_pty():
|
async def read_pty():
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
buffer = b''
|
buffer = b""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
chunk = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
|
chunk = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
|
||||||
|
|
@ -108,18 +115,16 @@ class HttpSocket(common):
|
||||||
# No more output
|
# No more output
|
||||||
if buffer:
|
if buffer:
|
||||||
# Emit any remaining partial line
|
# Emit any remaining partial line
|
||||||
await self.emitter.emit('cli_output', {
|
await self.emitter.emit(
|
||||||
'type': 'stdout',
|
"cli_output", {"type": "stdout", "line": buffer.decode("utf-8", errors="replace")}
|
||||||
'line': buffer.decode('utf-8', errors='replace')
|
)
|
||||||
})
|
|
||||||
break
|
break
|
||||||
buffer += chunk
|
buffer += chunk
|
||||||
*lines, buffer = buffer.split(b'\n')
|
*lines, buffer = buffer.split(b"\n")
|
||||||
for line in lines:
|
for line in lines:
|
||||||
await self.emitter.emit('cli_output', {
|
await self.emitter.emit(
|
||||||
'type': 'stdout',
|
"cli_output", {"type": "stdout", "line": line.decode("utf-8", errors="replace")}
|
||||||
'line': line.decode('utf-8', errors='replace')
|
)
|
||||||
})
|
|
||||||
try:
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -134,28 +139,33 @@ class HttpSocket(common):
|
||||||
# Ensure reading is done
|
# Ensure reading is done
|
||||||
await read_task
|
await read_task
|
||||||
|
|
||||||
await self.emitter.emit('cli_close', {'exitcode': returncode}, to=sid)
|
await self.emitter.emit("cli_close", {"exitcode": returncode}, to=sid)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"CLI execute exception was thrown for client '{sid}'. {str(e)}")
|
LOG.error(f"CLI execute exception was thrown for client '{sid}'.")
|
||||||
await self.emitter.emit('cli_out1put', {
|
LOG.exception(e)
|
||||||
'type': 'stderr',
|
await self.emitter.emit(
|
||||||
'line': str(e),
|
"cli_out1put",
|
||||||
}, to=sid)
|
{
|
||||||
await self.emitter.emit('cli_close', {'exitcode': -1}, to=sid)
|
"type": "stderr",
|
||||||
|
"line": str(e),
|
||||||
|
},
|
||||||
|
to=sid,
|
||||||
|
)
|
||||||
|
await self.emitter.emit("cli_close", {"exitcode": -1}, to=sid)
|
||||||
|
|
||||||
@ws_event
|
@ws_event
|
||||||
async def add_url(self, sid: str, data: dict):
|
async def add_url(self, sid: str, data: dict):
|
||||||
url: str = data.get('url')
|
url: str = data.get("url")
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
self.emitter.warning('No URL provided.', to=sid)
|
self.emitter.warning("No URL provided.", to=sid)
|
||||||
return
|
return
|
||||||
|
|
||||||
preset: str = data.get('preset', 'default')
|
preset: str = data.get("preset", "default")
|
||||||
folder: str = data.get('folder')
|
folder: str = data.get("folder")
|
||||||
ytdlp_cookies: str = data.get('ytdlp_cookies')
|
ytdlp_cookies: str = data.get("ytdlp_cookies")
|
||||||
ytdlp_config: dict = data.get('ytdlp_config')
|
ytdlp_config: dict | None = data.get("ytdlp_config")
|
||||||
output_template: str = data.get('output_template')
|
output_template: str = data.get("output_template")
|
||||||
if ytdlp_config is None:
|
if ytdlp_config is None:
|
||||||
ytdlp_config = {}
|
ytdlp_config = {}
|
||||||
|
|
||||||
|
|
@ -165,64 +175,64 @@ class HttpSocket(common):
|
||||||
folder=folder,
|
folder=folder,
|
||||||
ytdlp_cookies=ytdlp_cookies,
|
ytdlp_cookies=ytdlp_cookies,
|
||||||
ytdlp_config=ytdlp_config,
|
ytdlp_config=ytdlp_config,
|
||||||
output_template=output_template
|
output_template=output_template,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.emitter.emit('status', status, to=sid)
|
await self.emitter.emit("status", status, to=sid)
|
||||||
|
|
||||||
@ws_event
|
@ws_event
|
||||||
async def item_cancel(self, sid: str, id: str):
|
async def item_cancel(self, sid: str, id: str):
|
||||||
if not id:
|
if not id:
|
||||||
await self.emitter.warning('Invalid request.', to=sid)
|
await self.emitter.warning("Invalid request.", to=sid)
|
||||||
return
|
return
|
||||||
|
|
||||||
status: dict[str, str] = {}
|
status: dict[str, str] = {}
|
||||||
status = await self.queue.cancel([id])
|
status = await self.queue.cancel([id])
|
||||||
status.update({'identifier': id})
|
status.update({"identifier": id})
|
||||||
|
|
||||||
await self.emitter.emit('item_cancel', status)
|
await self.emitter.emit("item_cancel", status)
|
||||||
|
|
||||||
@ws_event
|
@ws_event
|
||||||
async def item_delete(self, sid: str, id: str):
|
async def item_delete(self, sid: str, id: str):
|
||||||
if not id:
|
if not id:
|
||||||
await self.emitter.warning('Invalid request.', to=sid)
|
await self.emitter.warning("Invalid request.", to=sid)
|
||||||
return
|
return
|
||||||
|
|
||||||
status: dict[str, str] = {}
|
status: dict[str, str] = {}
|
||||||
status = await self.queue.clear([id])
|
status = await self.queue.clear([id])
|
||||||
status.update({'identifier': id})
|
status.update({"identifier": id})
|
||||||
|
|
||||||
await self.emitter.emit('item_delete', status)
|
await self.emitter.emit("item_delete", status)
|
||||||
|
|
||||||
@ws_event
|
@ws_event
|
||||||
async def archive_item(self, sid: str, data: dict):
|
async def archive_item(self, sid: str, data: dict):
|
||||||
if not isinstance(data, dict) or 'url' not in data or not self.config.keep_archive:
|
if not isinstance(data, dict) or "url" not in data or not self.config.keep_archive:
|
||||||
return
|
return
|
||||||
|
|
||||||
file: str = self.config.ytdl_options.get('download_archive', None)
|
file: str = self.config.ytdl_options.get("download_archive", None)
|
||||||
|
|
||||||
if not file:
|
if not file:
|
||||||
return
|
return
|
||||||
|
|
||||||
exists, idDict = isDownloaded(file, data['url'])
|
exists, idDict = isDownloaded(file, data["url"])
|
||||||
if exists or 'archive_id' not in idDict or idDict['archive_id'] is None:
|
if exists or "archive_id" not in idDict or idDict["archive_id"] is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
with open(file, 'a') as f:
|
with open(file, "a") as f:
|
||||||
f.write(f"{idDict['archive_id']}\n")
|
f.write(f"{idDict['archive_id']}\n")
|
||||||
|
|
||||||
manual_archive = self.config.manual_archive
|
manual_archive = self.config.manual_archive
|
||||||
if manual_archive:
|
if manual_archive:
|
||||||
previouslyArchived = False
|
previouslyArchived = False
|
||||||
if os.path.exists(manual_archive):
|
if os.path.exists(manual_archive):
|
||||||
with open(manual_archive, 'r') as f:
|
with open(manual_archive, "r") as f:
|
||||||
for line in f.readlines():
|
for line in f.readlines():
|
||||||
if idDict['archive_id'] in line:
|
if idDict["archive_id"] in line:
|
||||||
previouslyArchived = True
|
previouslyArchived = True
|
||||||
break
|
break
|
||||||
|
|
||||||
if not previouslyArchived:
|
if not previouslyArchived:
|
||||||
with open(manual_archive, 'a') as f:
|
with open(manual_archive, "a") as f:
|
||||||
f.write(f"{idDict['archive_id']} - at: {datetime.now().isoformat()}\n")
|
f.write(f"{idDict['archive_id']} - at: {datetime.now().isoformat()}\n")
|
||||||
|
|
||||||
LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.")
|
LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.")
|
||||||
|
|
@ -238,8 +248,6 @@ class HttpSocket(common):
|
||||||
|
|
||||||
# get download folder listing
|
# get download folder listing
|
||||||
downloadPath: str = self.config.download_path
|
downloadPath: str = self.config.download_path
|
||||||
data['folders'] = [
|
data["folders"] = [name for name in os.listdir(downloadPath) if os.path.isdir(os.path.join(downloadPath, name))]
|
||||||
name for name in os.listdir(downloadPath) if os.path.isdir(os.path.join(downloadPath, name))
|
|
||||||
]
|
|
||||||
|
|
||||||
await self.emitter.emit('initial_data', data, to=sid)
|
await self.emitter.emit("initial_data", data, to=sid)
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,52 @@
|
||||||
from email.utils import formatdate
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from email.utils import formatdate
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
class ItemDTO:
|
class ItemDTO:
|
||||||
_id: int = field(default_factory=lambda: str(uuid.uuid4()), init=False)
|
"""
|
||||||
|
ItemDTO is a data transfer object that represents a single item in the queue.
|
||||||
|
It contains all the required information for both the frontend and the backend to process the item.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False)
|
||||||
|
|
||||||
error: str = None
|
error: str = None
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
url: str
|
url: str
|
||||||
quality: str = None
|
quality: str | None = None
|
||||||
format: str = None
|
format: str | None = None
|
||||||
thumbnail: str = None
|
thumbnail: str | None = None
|
||||||
preset: str = "default"
|
preset: str = "default"
|
||||||
folder: str
|
folder: str
|
||||||
download_dir: str = None
|
download_dir: str | None = None
|
||||||
temp_dir: str = None
|
temp_dir: str | None = None
|
||||||
status: str = None
|
status: str | None = None
|
||||||
ytdlp_cookies: str = None
|
ytdlp_cookies: str | None = None
|
||||||
ytdlp_config: dict = field(default_factory=dict)
|
ytdlp_config: dict = field(default_factory=dict)
|
||||||
output_template: str = None
|
output_template: str | None = None
|
||||||
output_template_chapter: str = None
|
output_template_chapter: str | None = None
|
||||||
timestamp: float = time.time_ns()
|
timestamp: float = time.time_ns()
|
||||||
is_live: bool = None
|
is_live: bool | None = None
|
||||||
datetime: str = field(default_factory=lambda: str(formatdate(time.time())))
|
datetime: str = field(default_factory=lambda: str(formatdate(time.time())))
|
||||||
live_in: str = None
|
live_in: str | None = None
|
||||||
file_size: int = None
|
file_size: int | None = None
|
||||||
options: dict = field(default_factory=dict)
|
options: dict = field(default_factory=dict)
|
||||||
|
|
||||||
# yt-dlp injected fields.
|
# yt-dlp injected fields.
|
||||||
tmpfilename: str = None
|
tmpfilename: str | None = None
|
||||||
filename: str = None
|
filename: str | None = None
|
||||||
total_bytes: int = None
|
total_bytes: int | None = None
|
||||||
total_bytes_estimate: int = None
|
total_bytes_estimate: int | None = None
|
||||||
downloaded_bytes: int = None
|
downloaded_bytes: int | None = None
|
||||||
msg: str = None
|
msg: str | None = None
|
||||||
percent: int = None
|
percent: int | None = None
|
||||||
speed: str = None
|
speed: str | None = None
|
||||||
eta: str = None
|
eta: str | None = None
|
||||||
|
|
||||||
def json(self) -> str:
|
def json(self) -> str:
|
||||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)
|
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,44 @@
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from .Utils import calcDownloadPath
|
from .Utils import calcDownloadPath, StreamingError
|
||||||
from .ffprobe import FFProbe
|
from .ffprobe import FFProbe
|
||||||
|
|
||||||
|
|
||||||
class M3u8:
|
class M3u8:
|
||||||
ok_vcodecs: tuple = ('h264', 'x264', 'avc',)
|
|
||||||
ok_acodecs: tuple = ('aac', 'm4a', 'mp3',)
|
|
||||||
|
|
||||||
url: str = None
|
|
||||||
duration: float = 6.000000
|
duration: float = 6.000000
|
||||||
|
|
||||||
|
ok_vcodecs: tuple = (
|
||||||
|
"h264",
|
||||||
|
"x264",
|
||||||
|
"avc",
|
||||||
|
)
|
||||||
|
ok_acodecs: tuple = (
|
||||||
|
"aac",
|
||||||
|
"m4a",
|
||||||
|
"mp3",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, url: str, segment_duration: float = None):
|
def __init__(self, url: str, segment_duration: float = None):
|
||||||
self.url = url
|
self.url = url
|
||||||
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, download_path: str, file: str):
|
async def make_stream(self, download_path: str, file: str) -> str:
|
||||||
realFile: str = calcDownloadPath(basePath=download_path, folder=file, createPath=False)
|
realFile: str = calcDownloadPath(basePath=download_path, folder=file, createPath=False)
|
||||||
|
|
||||||
if not os.path.exists(realFile):
|
if not os.path.exists(realFile):
|
||||||
raise Exception(f"File '{realFile}' does not exist.")
|
raise StreamingError(f"File '{realFile}' does not exist.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ffprobe = FFProbe(realFile)
|
ffprobe = FFProbe(realFile)
|
||||||
await ffprobe.run()
|
await ffprobe.run()
|
||||||
except UnicodeDecodeError as e:
|
except UnicodeDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not 'duration' in ffprobe.metadata:
|
if "duration" not in ffprobe.metadata:
|
||||||
raise Exception(f"Unable to get '{realFile}' duration.")
|
raise StreamingError(f"Unable to get '{realFile}' play duration.")
|
||||||
|
|
||||||
duration: float = float(ffprobe.metadata.get('duration'))
|
duration: float = float(ffprobe.metadata.get("duration"))
|
||||||
|
|
||||||
m3u8 = []
|
m3u8 = []
|
||||||
|
|
||||||
|
|
@ -41,7 +48,7 @@ class M3u8:
|
||||||
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")
|
||||||
|
|
||||||
segmentSize: float = '{:.6f}'.format(self.duration)
|
segmentSize: float = "{:.6f}".format(self.duration)
|
||||||
splits: int = math.ceil(duration / self.duration)
|
splits: int = math.ceil(duration / self.duration)
|
||||||
|
|
||||||
segmentParams: dict = {}
|
segmentParams: dict = {}
|
||||||
|
|
@ -49,33 +56,33 @@ class M3u8:
|
||||||
for stream in ffprobe.streams:
|
for stream in ffprobe.streams:
|
||||||
if stream.is_video():
|
if stream.is_video():
|
||||||
if stream.codec_name not in self.ok_vcodecs:
|
if stream.codec_name not in self.ok_vcodecs:
|
||||||
segmentParams['vc'] = 1
|
segmentParams["vc"] = 1
|
||||||
if stream.is_audio():
|
if stream.is_audio():
|
||||||
if stream.codec_name not in self.ok_acodecs:
|
if stream.codec_name not in self.ok_acodecs:
|
||||||
segmentParams['ac'] = 1
|
segmentParams["ac"] = 1
|
||||||
|
|
||||||
for i in range(splits):
|
for i in range(splits):
|
||||||
if (i + 1) == splits:
|
if (i + 1) == splits:
|
||||||
segmentSize = '{:.6f}'.format(duration - (i * self.duration))
|
segmentSize = "{:.6f}".format(duration - (i * self.duration))
|
||||||
segmentParams.update({'sd': segmentSize})
|
segmentParams.update({"sd": segmentSize})
|
||||||
|
|
||||||
m3u8.append(f"#EXTINF:{segmentSize},")
|
m3u8.append(f"#EXTINF:{segmentSize},")
|
||||||
|
|
||||||
url = f"{self.url}player/segments/{i}/{quote(file)}.ts"
|
url = f"{self.url}player/segments/{i}/{quote(file)}.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()])
|
||||||
|
|
||||||
m3u8.append(url)
|
m3u8.append(url)
|
||||||
|
|
||||||
m3u8.append("#EXT-X-ENDLIST")
|
m3u8.append("#EXT-X-ENDLIST")
|
||||||
|
|
||||||
return '\n'.join(m3u8)
|
return "\n".join(m3u8)
|
||||||
|
|
||||||
async def make_subtitle(self, download_path: str, file: str, duration: float):
|
async def make_subtitle(self, download_path: str, file: str, duration: float) -> str:
|
||||||
realFile: str = calcDownloadPath(basePath=download_path, folder=file, createPath=False)
|
realFile: str = calcDownloadPath(basePath=download_path, folder=file, createPath=False)
|
||||||
|
|
||||||
if not os.path.exists(realFile):
|
if not os.path.exists(realFile):
|
||||||
raise Exception(f"File '{realFile}' does not exist.")
|
raise StreamingError(f"File '{realFile}' does not exist.")
|
||||||
|
|
||||||
m3u8 = []
|
m3u8 = []
|
||||||
|
|
||||||
|
|
@ -88,4 +95,4 @@ class M3u8:
|
||||||
m3u8.append(f"{self.url}player/subtitle/{quote(file)}.vtt")
|
m3u8.append(f"{self.url}player/subtitle/{quote(file)}.vtt")
|
||||||
m3u8.append("#EXT-X-ENDLIST")
|
m3u8.append("#EXT-X-ENDLIST")
|
||||||
|
|
||||||
return '\n'.join(m3u8)
|
return "\n".join(m3u8)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import glob
|
import glob
|
||||||
import pathlib
|
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from library.Utils import calcDownloadPath, checkId
|
|
||||||
import pathlib
|
|
||||||
from library.ffprobe import FFProbe
|
|
||||||
from .Subtitle import Subtitle
|
|
||||||
from aiohttp.web import Response
|
from aiohttp.web import Response
|
||||||
|
|
||||||
|
from .ffprobe import FFProbe
|
||||||
|
from .Subtitle import Subtitle
|
||||||
|
from .Utils import calcDownloadPath, checkId, StreamingError
|
||||||
|
|
||||||
|
|
||||||
class Playlist:
|
class Playlist:
|
||||||
_url: str = None
|
_url: str = None
|
||||||
|
|
||||||
|
|
@ -15,27 +17,30 @@ class Playlist:
|
||||||
self.url = url
|
self.url = url
|
||||||
|
|
||||||
async def make(self, download_path: str, file: str) -> str | Response:
|
async def make(self, download_path: str, file: str) -> str | Response:
|
||||||
rFile = pathlib.Path(calcDownloadPath(basePath=download_path, folder=file, createPath=False))
|
rFile = Path(calcDownloadPath(basePath=download_path, folder=file, createPath=False))
|
||||||
|
|
||||||
if not rFile.exists():
|
if not rFile.exists():
|
||||||
possibleFile = checkId(download_path, rFile)
|
possibleFile = checkId(download_path, rFile)
|
||||||
if not possibleFile:
|
if not possibleFile:
|
||||||
raise Exception(f"File '{rFile}' does not exist.")
|
raise StreamingError(f"File '{rFile}' does not exist.")
|
||||||
|
|
||||||
return Response(status=302, headers={
|
return Response(
|
||||||
'Location': f"{self.url}player/playlist/{quote(str(possibleFile).replace(download_path, '').strip('/'))}.m3u8"
|
status=302,
|
||||||
})
|
headers={
|
||||||
|
"Location": f"{self.url}player/playlist/{quote(str(possibleFile).replace(download_path, '').strip('/'))}.m3u8"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ffprobe = FFProbe(rFile)
|
ffprobe = FFProbe(rFile)
|
||||||
await ffprobe.run()
|
await ffprobe.run()
|
||||||
except UnicodeDecodeError as e:
|
except UnicodeDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not 'duration' in ffprobe.metadata:
|
if "duration" not in ffprobe.metadata:
|
||||||
raise Exception(f"Unable to get '{rFile}' duration.")
|
raise StreamingError(f"Unable to get '{rFile}' duration.")
|
||||||
|
|
||||||
duration: float = float(ffprobe.metadata.get('duration'))
|
duration: float = float(ffprobe.metadata.get("duration"))
|
||||||
|
|
||||||
playlist = []
|
playlist = []
|
||||||
playlist.append("#EXTM3U")
|
playlist.append("#EXTM3U")
|
||||||
|
|
@ -44,28 +49,31 @@ class Playlist:
|
||||||
|
|
||||||
index = 0
|
index = 0
|
||||||
for item in self.getSideCarFiles(rFile):
|
for item in self.getSideCarFiles(rFile):
|
||||||
if not item.suffix in Subtitle.allowedExtensions:
|
if item.suffix not in Subtitle.allowedExtensions:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
index += 1
|
index += 1
|
||||||
lang: str = "und"
|
lang: str = "und"
|
||||||
lg = re.search(r'\.(?P<lang>\w{2,3})\.\w{3}$', item.name)
|
lg = re.search(r"\.(?P<lang>\w{2,3})\.\w{3}$", item.name)
|
||||||
if lg:
|
if lg:
|
||||||
lang = lg.groupdict().get('lang')
|
lang = lg.groupdict().get("lang")
|
||||||
|
|
||||||
subs = ',SUBTITLES="subs"'
|
subs = ',SUBTITLES="subs"'
|
||||||
|
|
||||||
url = f"{self.url}player/m3u8/subtitle/{quote(str(pathlib.Path(file).with_name(item.name)))}.m3u8?duration={duration}"
|
url = (
|
||||||
|
f"{self.url}player/m3u8/subtitle/{quote(str(Path(file).with_name(item.name)))}.m3u8?duration={duration}"
|
||||||
|
)
|
||||||
name = f"{item.suffix[1:].upper()} ({index}) - {lang}"
|
name = f"{item.suffix[1:].upper()} ({index}) - {lang}"
|
||||||
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}"'
|
||||||
|
)
|
||||||
|
|
||||||
playlist.append(f'#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}')
|
playlist.append(f"#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}")
|
||||||
playlist.append(f"{self.url}player/m3u8/video/{quote(file)}.m3u8")
|
playlist.append(f"{self.url}player/m3u8/video/{quote(file)}.m3u8")
|
||||||
|
|
||||||
return '\n'.join(playlist)
|
return "\n".join(playlist)
|
||||||
|
|
||||||
def getSideCarFiles(self, file: pathlib.Path) -> list[pathlib.Path]:
|
def getSideCarFiles(self, file: Path) -> list[Path]:
|
||||||
"""
|
"""
|
||||||
Get sidecar files for the given file.
|
Get sidecar files for the given file.
|
||||||
|
|
||||||
|
|
@ -75,7 +83,7 @@ class Playlist:
|
||||||
files = []
|
files = []
|
||||||
|
|
||||||
for sub_file in file.parent.glob(f"{glob.escape(file.stem)}.*"):
|
for sub_file in file.parent.glob(f"{glob.escape(file.stem)}.*"):
|
||||||
if sub_file == file or sub_file.is_file() is False or sub_file.stem.startswith('.'):
|
if sub_file == file or sub_file.is_file() is False or sub_file.stem.startswith("."):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
files.append(sub_file)
|
files.append(sub_file)
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,14 @@ import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from .Utils import calcDownloadPath
|
|
||||||
from .config import Config
|
|
||||||
|
|
||||||
LOG = logging.getLogger('player.segments')
|
from .config import Config
|
||||||
|
from .Utils import calcDownloadPath, StreamingError
|
||||||
|
|
||||||
|
LOG = logging.getLogger("player.segments")
|
||||||
|
|
||||||
|
|
||||||
class Segments:
|
class Segments:
|
||||||
duration: int
|
|
||||||
index: int
|
|
||||||
vconvert: bool
|
|
||||||
aconvert: bool
|
|
||||||
vcodec: str
|
|
||||||
acodec: str
|
|
||||||
|
|
||||||
def __init__(self, index: int, duration: float, vconvert: bool, aconvert: bool):
|
def __init__(self, index: int, duration: float, vconvert: bool, aconvert: bool):
|
||||||
config = Config.get_instance()
|
config = Config.get_instance()
|
||||||
self.index = int(index)
|
self.index = int(index)
|
||||||
|
|
@ -25,13 +19,14 @@ class Segments:
|
||||||
self.aconvert = bool(aconvert)
|
self.aconvert = bool(aconvert)
|
||||||
self.vcodec = config.streamer_vcodec
|
self.vcodec = config.streamer_vcodec
|
||||||
self.acodec = config.streamer_acodec
|
self.acodec = config.streamer_acodec
|
||||||
|
# sadly due to unforeseen circumstances, we have to convert the video for now.
|
||||||
self.vconvert = True
|
self.vconvert = True
|
||||||
|
|
||||||
async def stream(self, path: str, file: str) -> bytes:
|
async def stream(self, path: str, file: str) -> bytes:
|
||||||
realFile: str = calcDownloadPath(basePath=path, folder=file, createPath=False)
|
realFile: str = calcDownloadPath(basePath=path, folder=file, createPath=False)
|
||||||
|
|
||||||
if not os.path.exists(realFile):
|
if not os.path.exists(realFile):
|
||||||
raise Exception(f"File {realFile} does not exist.")
|
raise StreamingError(f"File {realFile} does not exist.")
|
||||||
|
|
||||||
tmpDir: str = tempfile.gettempdir()
|
tmpDir: str = tempfile.gettempdir()
|
||||||
tmpFile = os.path.join(tmpDir, f'ytptube_stream.{hashlib.md5(realFile.encode("utf-8")).hexdigest()}')
|
tmpFile = os.path.join(tmpDir, f'ytptube_stream.{hashlib.md5(realFile.encode("utf-8")).hexdigest()}')
|
||||||
|
|
@ -40,68 +35,69 @@ class Segments:
|
||||||
os.symlink(realFile, tmpFile)
|
os.symlink(realFile, tmpFile)
|
||||||
|
|
||||||
if self.index == 0:
|
if self.index == 0:
|
||||||
startTime: float = '{:.6f}'.format(0)
|
startTime: float = "{:.6f}".format(0)
|
||||||
else:
|
else:
|
||||||
startTime: float = '{:.6f}'.format((self.duration * self.index))
|
startTime: float = "{:.6f}".format((self.duration * self.index))
|
||||||
|
|
||||||
fargs = []
|
fargs = []
|
||||||
fargs.append('-xerror')
|
fargs.append("-xerror")
|
||||||
fargs.append('-hide_banner')
|
fargs.append("-hide_banner")
|
||||||
fargs.append('-loglevel')
|
fargs.append("-loglevel")
|
||||||
fargs.append('error')
|
fargs.append("error")
|
||||||
|
|
||||||
fargs.append('-ss')
|
fargs.append("-ss")
|
||||||
fargs.append(str(startTime if startTime else '0.00000'))
|
fargs.append(str(startTime if startTime else "0.00000"))
|
||||||
fargs.append('-t')
|
fargs.append("-t")
|
||||||
fargs.append(str('{:.6f}'.format(self.duration)))
|
fargs.append(str("{:.6f}".format(self.duration)))
|
||||||
|
|
||||||
fargs.append('-copyts')
|
fargs.append("-copyts")
|
||||||
|
|
||||||
fargs.append('-i')
|
fargs.append("-i")
|
||||||
fargs.append(f'file:{tmpFile}')
|
fargs.append(f"file:{tmpFile}")
|
||||||
fargs.append('-map_metadata')
|
fargs.append("-map_metadata")
|
||||||
fargs.append('-1')
|
fargs.append("-1")
|
||||||
|
|
||||||
fargs.append('-pix_fmt')
|
fargs.append("-pix_fmt")
|
||||||
fargs.append('yuv420p')
|
fargs.append("yuv420p")
|
||||||
fargs.append('-g')
|
fargs.append("-g")
|
||||||
fargs.append('52')
|
fargs.append("52")
|
||||||
|
|
||||||
fargs.append('-map')
|
fargs.append("-map")
|
||||||
fargs.append('0:v:0')
|
fargs.append("0:v:0")
|
||||||
fargs.append('-strict')
|
fargs.append("-strict")
|
||||||
fargs.append('-2')
|
fargs.append("-2")
|
||||||
|
|
||||||
fargs.append('-codec:v')
|
fargs.append("-codec:v")
|
||||||
fargs.append(self.vcodec if self.vconvert else 'copy')
|
fargs.append(self.vcodec if self.vconvert else "copy")
|
||||||
|
|
||||||
# audio section.
|
# audio section.
|
||||||
fargs.append('-map')
|
fargs.append("-map")
|
||||||
fargs.append('0:a:0')
|
fargs.append("0:a:0")
|
||||||
fargs.append('-codec:a')
|
fargs.append("-codec:a")
|
||||||
fargs.append(self.acodec if self.aconvert else 'copy')
|
fargs.append(self.acodec if self.aconvert else "copy")
|
||||||
|
|
||||||
fargs.append('-sn')
|
fargs.append("-sn")
|
||||||
|
|
||||||
fargs.append('-muxdelay')
|
fargs.append("-muxdelay")
|
||||||
fargs.append('0')
|
fargs.append("0")
|
||||||
fargs.append('-f')
|
fargs.append("-f")
|
||||||
fargs.append('mpegts')
|
fargs.append("mpegts")
|
||||||
fargs.append('pipe:1')
|
fargs.append("pipe:1")
|
||||||
|
|
||||||
LOG.debug(f"Streaming '{realFile}' segment '{self.index}'. " + " ".join(fargs))
|
LOG.debug(f"Streaming '{realFile}' segment '{self.index}'. ffmpeg: {' '.join(fargs)}")
|
||||||
|
|
||||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||||
'ffmpeg', *fargs,
|
"ffmpeg",
|
||||||
|
*fargs,
|
||||||
stdin=asyncio.subprocess.DEVNULL,
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
data, err = await proc.communicate()
|
data, err = await proc.communicate()
|
||||||
|
|
||||||
if 0 != proc.returncode:
|
if 0 != proc.returncode:
|
||||||
LOG.error(f'Failed to stream {realFile} segment {self.index}. {err.decode("utf-8")}.')
|
LOG.error(f'Failed to stream {realFile} segment {self.index}. {err.decode("utf-8")}.')
|
||||||
raise Exception(f'Failed to stream {realFile} segment {self.index}.')
|
raise StreamingError(f"Failed to stream {realFile} segment {self.index}.")
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import pysubs2
|
||||||
from pysubs2.time import ms_to_times
|
from pysubs2.time import ms_to_times
|
||||||
from pysubs2.formats.substation import SubstationFormat
|
from pysubs2.formats.substation import SubstationFormat
|
||||||
|
|
||||||
LOG = logging.getLogger('player.subtitle')
|
LOG = logging.getLogger("player.subtitle")
|
||||||
|
|
||||||
|
|
||||||
def ms_to_timestamp(ms: int) -> str:
|
def ms_to_timestamp(ms: int) -> str:
|
||||||
|
|
@ -19,9 +19,13 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp
|
||||||
|
|
||||||
|
|
||||||
class Subtitle:
|
class Subtitle:
|
||||||
allowedExtensions: tuple[str] = (".srt", ".vtt", ".ass",)
|
allowedExtensions: tuple[str] = (
|
||||||
|
".srt",
|
||||||
|
".vtt",
|
||||||
|
".ass",
|
||||||
|
)
|
||||||
|
|
||||||
async def make(self, path: str, file: str) -> bytes:
|
async def make(self, path: str, file: str) -> str:
|
||||||
realFile: str = calcDownloadPath(basePath=path, folder=file, createPath=False)
|
realFile: str = calcDownloadPath(basePath=path, folder=file, createPath=False)
|
||||||
|
|
||||||
rFile = pathlib.Path(realFile)
|
rFile = pathlib.Path(realFile)
|
||||||
|
|
@ -29,12 +33,12 @@ class Subtitle:
|
||||||
if not rFile.exists():
|
if not rFile.exists():
|
||||||
raise Exception(f"File '{file}' does not exist.")
|
raise Exception(f"File '{file}' does not exist.")
|
||||||
|
|
||||||
if not rFile.suffix in self.allowedExtensions:
|
if rFile.suffix not in self.allowedExtensions:
|
||||||
raise Exception(f"File '{file}' subtitle type is not supported.")
|
raise Exception(f"File '{file}' subtitle type is not supported.")
|
||||||
|
|
||||||
if rFile.suffix == ".vtt":
|
if rFile.suffix == ".vtt":
|
||||||
subData = ''
|
subData = ""
|
||||||
with open(realFile, 'r') as f:
|
with open(realFile, "r") as f:
|
||||||
subData = f.read()
|
subData = f.read()
|
||||||
|
|
||||||
return subData
|
return subData
|
||||||
|
|
@ -45,9 +49,9 @@ class Subtitle:
|
||||||
raise Exception(f"No subtitle events were found in '{rFile}'.")
|
raise Exception(f"No subtitle events were found in '{rFile}'.")
|
||||||
|
|
||||||
if len(subs.events) < 2:
|
if len(subs.events) < 2:
|
||||||
return subs.to_string('vtt')
|
return subs.to_string("vtt")
|
||||||
|
|
||||||
if subs.events[0].end == subs.events[len(subs.events)-1].end:
|
if subs.events[0].end == subs.events[len(subs.events) - 1].end:
|
||||||
subs.events.pop(0)
|
subs.events.pop(0)
|
||||||
|
|
||||||
return subs.to_string('vtt')
|
return subs.to_string("vtt")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import copy
|
import copy
|
||||||
from datetime import datetime, timezone
|
|
||||||
from functools import lru_cache
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -8,16 +6,31 @@ import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
from typing import Any
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
|
||||||
LOG = logging.getLogger('Utils')
|
LOG = logging.getLogger("Utils")
|
||||||
|
|
||||||
IGNORED_KEYS: tuple[str] = ('cookiefile', 'paths', 'outtmpl', 'progress_hooks', 'postprocessor_hooks',)
|
IGNORED_KEYS: tuple[str] = (
|
||||||
|
"cookiefile",
|
||||||
|
"paths",
|
||||||
|
"outtmpl",
|
||||||
|
"progress_hooks",
|
||||||
|
"postprocessor_hooks",
|
||||||
|
)
|
||||||
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
|
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingError(Exception):
|
||||||
|
"""Raised when an error occurs during streaming."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_opts(preset: str, ytdl_opts: dict) -> dict:
|
def get_opts(preset: str, ytdl_opts: dict) -> dict:
|
||||||
"""
|
"""
|
||||||
Returns ytdlp options download options
|
Returns ytdlp options download options
|
||||||
|
|
@ -31,16 +44,17 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict:
|
||||||
"""
|
"""
|
||||||
opts = copy.deepcopy(ytdl_opts)
|
opts = copy.deepcopy(ytdl_opts)
|
||||||
|
|
||||||
if 'default' == preset:
|
if "default" == preset:
|
||||||
LOG.debug("Using default preset.")
|
LOG.debug("Using default preset.")
|
||||||
return opts
|
return opts
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
|
||||||
presets = Config.get_instance().presets
|
presets = Config.get_instance().presets
|
||||||
|
|
||||||
found = False
|
found = False
|
||||||
for _preset in presets:
|
for _preset in presets:
|
||||||
if _preset['name'] == preset:
|
if _preset["name"] == preset:
|
||||||
found = True
|
found = True
|
||||||
preset_opts = _preset
|
preset_opts = _preset
|
||||||
break
|
break
|
||||||
|
|
@ -49,24 +63,24 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict:
|
||||||
LOG.error(f"Preset '{preset}' is not defined in the presets.")
|
LOG.error(f"Preset '{preset}' is not defined in the presets.")
|
||||||
return opts
|
return opts
|
||||||
|
|
||||||
opts['format'] = preset_opts.get('format')
|
opts["format"] = preset_opts.get("format")
|
||||||
|
|
||||||
if 'postprocessors' in preset_opts:
|
if "postprocessors" in preset_opts:
|
||||||
opts['postprocessors'] = preset_opts['postprocessors']
|
opts["postprocessors"] = preset_opts["postprocessors"]
|
||||||
|
|
||||||
if 'args' in preset_opts:
|
if "args" in preset_opts:
|
||||||
for key, value in preset_opts['args'].items():
|
for key, value in preset_opts["args"].items():
|
||||||
opts[key] = value
|
opts[key] = value
|
||||||
|
|
||||||
LOG.debug(f"Using preset '{preset}', altered options: {opts}")
|
LOG.debug(f"Using preset '{preset}', altered options: {opts}")
|
||||||
return opts
|
return opts
|
||||||
|
|
||||||
|
|
||||||
def getVideoInfo(url: str, ytdlp_opts: dict = None) -> (Any | dict[str, Any] | None):
|
def getVideoInfo(url: str, ytdlp_opts: dict = None) -> Any | dict[str, Any] | None:
|
||||||
params: dict = {
|
params: dict = {
|
||||||
'quiet': True,
|
"quiet": True,
|
||||||
'color': 'no_color',
|
"color": "no_color",
|
||||||
'extract_flat': True,
|
"extract_flat": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ytdlp_opts:
|
if ytdlp_opts:
|
||||||
|
|
@ -98,30 +112,35 @@ def calcDownloadPath(basePath: str, folder: str = None, createPath: bool = True)
|
||||||
|
|
||||||
def ExtractInfo(config: dict, url: str, debug: bool = False) -> dict:
|
def ExtractInfo(config: dict, url: str, debug: bool = False) -> dict:
|
||||||
params: dict = {
|
params: dict = {
|
||||||
'color': 'no_color',
|
"color": "no_color",
|
||||||
'extract_flat': True,
|
"extract_flat": True,
|
||||||
'skip_download': True,
|
"skip_download": True,
|
||||||
'ignoreerrors': True,
|
"ignoreerrors": True,
|
||||||
'ignore_no_formats_error': True,
|
"ignore_no_formats_error": True,
|
||||||
**config,
|
**config,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Remove keys that are not needed for info extraction as those keys generate files when used with extract_info.
|
# Remove keys that are not needed for info extraction as those keys generate files when used with extract_info.
|
||||||
for key in ('writeinfojson', 'writethumbnail', 'writedescription', 'writeautomaticsub',):
|
for key in (
|
||||||
|
"writeinfojson",
|
||||||
|
"writethumbnail",
|
||||||
|
"writedescription",
|
||||||
|
"writeautomaticsub",
|
||||||
|
):
|
||||||
if key in params:
|
if key in params:
|
||||||
params.pop(key)
|
params.pop(key)
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
params['verbose'] = True
|
params["verbose"] = True
|
||||||
params['logger'] = logging.getLogger('YTPTube-ytdl')
|
params["logger"] = logging.getLogger("YTPTube-ytdl")
|
||||||
else:
|
else:
|
||||||
params['quiet'] = True
|
params["quiet"] = True
|
||||||
|
|
||||||
return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False)
|
return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False)
|
||||||
|
|
||||||
|
|
||||||
def mergeDict(source: dict, destination: dict) -> dict:
|
def mergeDict(source: dict, destination: dict) -> dict:
|
||||||
""" Merge data from source into destination """
|
"""Merge data from source into destination"""
|
||||||
destination_copy = copy.deepcopy(destination)
|
destination_copy = copy.deepcopy(destination)
|
||||||
|
|
||||||
for key, value in source.items():
|
for key, value in source.items():
|
||||||
|
|
@ -137,15 +156,14 @@ def mergeDict(source: dict, destination: dict) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def mergeConfig(config: dict, new_config: dict) -> dict:
|
def mergeConfig(config: dict, new_config: dict) -> dict:
|
||||||
""" Merge user provided config into default config """
|
"""Merge user provided config into default config"""
|
||||||
|
|
||||||
ignored_keys: tuple = (
|
ignored_keys: tuple = (
|
||||||
'cookiefile',
|
"cookiefile",
|
||||||
'download_archive'
|
"download_archive" "paths",
|
||||||
'paths',
|
"outtmpl",
|
||||||
'outtmpl',
|
"progress_hooks",
|
||||||
'progress_hooks',
|
"postprocessor_hooks",
|
||||||
'postprocessor_hooks',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for key in ignored_keys:
|
for key in ignored_keys:
|
||||||
|
|
@ -159,23 +177,28 @@ def isDownloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, st
|
||||||
global YTDLP_INFO_CLS
|
global YTDLP_INFO_CLS
|
||||||
|
|
||||||
idDict = {
|
idDict = {
|
||||||
'id': None,
|
"id": None,
|
||||||
'ie_key': None,
|
"ie_key": None,
|
||||||
'archive_id': None,
|
"archive_id": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not url or not archive_file or not os.path.exists(archive_file):
|
if not url or not archive_file or not os.path.exists(archive_file):
|
||||||
return False, idDict,
|
return (
|
||||||
|
False,
|
||||||
|
idDict,
|
||||||
|
)
|
||||||
|
|
||||||
if not YTDLP_INFO_CLS:
|
if not YTDLP_INFO_CLS:
|
||||||
YTDLP_INFO_CLS = yt_dlp.YoutubeDL(params={
|
YTDLP_INFO_CLS = yt_dlp.YoutubeDL(
|
||||||
'color': 'no_color',
|
params={
|
||||||
'extract_flat': True,
|
"color": "no_color",
|
||||||
'skip_download': True,
|
"extract_flat": True,
|
||||||
'ignoreerrors': True,
|
"skip_download": True,
|
||||||
'ignore_no_formats_error': True,
|
"ignoreerrors": True,
|
||||||
'quiet': True,
|
"ignore_no_formats_error": True,
|
||||||
})
|
"quiet": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
for key, ie in YTDLP_INFO_CLS._ies.items():
|
for key, ie in YTDLP_INFO_CLS._ies.items():
|
||||||
if not ie.suitable(url):
|
if not ie.suitable(url):
|
||||||
|
|
@ -188,26 +211,35 @@ def isDownloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, st
|
||||||
if not temp_id:
|
if not temp_id:
|
||||||
break
|
break
|
||||||
|
|
||||||
idDict['id'] = temp_id
|
idDict["id"] = temp_id
|
||||||
idDict['ie_key'] = key
|
idDict["ie_key"] = key
|
||||||
idDict['archive_id'] = YTDLP_INFO_CLS._make_archive_id(idDict)
|
idDict["archive_id"] = YTDLP_INFO_CLS._make_archive_id(idDict)
|
||||||
break
|
break
|
||||||
|
|
||||||
if not idDict['archive_id']:
|
if not idDict["archive_id"]:
|
||||||
return False, idDict,
|
return (
|
||||||
|
False,
|
||||||
|
idDict,
|
||||||
|
)
|
||||||
|
|
||||||
with open(archive_file, 'r') as f:
|
with open(archive_file, "r") as f:
|
||||||
for line in f.readlines():
|
for line in f.readlines():
|
||||||
if idDict['archive_id'] in line:
|
if idDict["archive_id"] in line:
|
||||||
return True, idDict,
|
return (
|
||||||
|
True,
|
||||||
|
idDict,
|
||||||
|
)
|
||||||
|
|
||||||
return False, idDict,
|
return (
|
||||||
|
False,
|
||||||
|
idDict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def jsonCookie(cookies: dict[dict[str, any]]) -> str | None:
|
def jsonCookie(cookies: dict[dict[str, any]]) -> str | None:
|
||||||
"""Converts JSON cookies to Netscape cookies
|
"""Converts JSON cookies to Netscape cookies
|
||||||
|
|
||||||
Returns None if no cookies are found, otherwise returns a string of cookies in Netscape format.
|
Returns None if no cookies are found, otherwise returns a string of cookies in Netscape format.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
netscapeCookies = "# Netscape HTTP Cookie File\n# https://curl.haxx.se/docs/http-cookies.html\n# This file was generated by libcurl! Edit at your own risk.\n\n"
|
netscapeCookies = "# Netscape HTTP Cookie File\n# https://curl.haxx.se/docs/http-cookies.html\n# This file was generated by libcurl! Edit at your own risk.\n\n"
|
||||||
|
|
@ -227,19 +259,26 @@ def jsonCookie(cookies: dict[dict[str, any]]) -> str | None:
|
||||||
|
|
||||||
cookieDict = cookies[domain][subDomain][cookie]
|
cookieDict = cookies[domain][subDomain][cookie]
|
||||||
|
|
||||||
if 0 == int(cookieDict['expirationDate']):
|
if 0 == int(cookieDict["expirationDate"]):
|
||||||
cookieDict['expirationDate'] = datetime.now(timezone.utc).timestamp() + (86400 * 1000)
|
cookieDict["expirationDate"] = datetime.now(timezone.utc).timestamp() + (86400 * 1000)
|
||||||
|
|
||||||
hasCookies = True
|
hasCookies = True
|
||||||
netscapeCookies += "\t".join([
|
netscapeCookies += (
|
||||||
cookieDict['domain'] if str(cookieDict['domain']).startswith('.') else '.' + cookieDict['domain'],
|
"\t".join(
|
||||||
'TRUE',
|
[
|
||||||
cookieDict['path'],
|
cookieDict["domain"]
|
||||||
'TRUE' if cookieDict['secure'] else 'FALSE',
|
if str(cookieDict["domain"]).startswith(".")
|
||||||
str(int(cookieDict['expirationDate'])),
|
else "." + cookieDict["domain"],
|
||||||
cookieDict['name'],
|
"TRUE",
|
||||||
cookieDict['value']
|
cookieDict["path"],
|
||||||
])+"\n"
|
"TRUE" if cookieDict["secure"] else "FALSE",
|
||||||
|
str(int(cookieDict["expirationDate"])),
|
||||||
|
cookieDict["name"],
|
||||||
|
cookieDict["value"],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
return netscapeCookies if hasCookies else None
|
return netscapeCookies if hasCookies else None
|
||||||
|
|
||||||
|
|
@ -264,21 +303,38 @@ def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
|
||||||
if check_type:
|
if check_type:
|
||||||
assert isinstance(opts, check_type)
|
assert isinstance(opts, check_type)
|
||||||
|
|
||||||
return (opts, True, '',)
|
return (
|
||||||
|
opts,
|
||||||
|
True,
|
||||||
|
"",
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
with open(file) as json_data:
|
with open(file) as json_data:
|
||||||
from pyjson5 import load as json5_load
|
from pyjson5 import load as json5_load
|
||||||
|
|
||||||
try:
|
try:
|
||||||
opts = json5_load(json_data)
|
opts = json5_load(json_data)
|
||||||
|
|
||||||
if check_type:
|
if check_type:
|
||||||
assert isinstance(opts, check_type)
|
assert isinstance(opts, check_type)
|
||||||
|
|
||||||
return (opts, True, '',)
|
return (
|
||||||
|
opts,
|
||||||
|
True,
|
||||||
|
"",
|
||||||
|
)
|
||||||
except AssertionError:
|
except AssertionError:
|
||||||
return ({}, False, f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.",)
|
return (
|
||||||
|
{},
|
||||||
|
False,
|
||||||
|
f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.",
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ({}, False, f'{e}',)
|
return (
|
||||||
|
{},
|
||||||
|
False,
|
||||||
|
f"{e}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def checkId(basePath: str, file: pathlib.Path) -> bool | str:
|
def checkId(basePath: str, file: pathlib.Path) -> bool | str:
|
||||||
|
|
@ -292,17 +348,26 @@ def checkId(basePath: str, file: pathlib.Path) -> bool | str:
|
||||||
:return: False if no id found, otherwise the id.
|
:return: False if no id found, otherwise the id.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
match = re.search(r'(?<=\[)(?:youtube-)?(?P<id>[a-zA-Z0-9\-_]{11})(?=\])', file.stem, re.IGNORECASE)
|
match = re.search(r"(?<=\[)(?:youtube-)?(?P<id>[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE)
|
||||||
if not match:
|
if not match:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
id = match.groupdict().get('id')
|
id = match.groupdict().get("id")
|
||||||
|
|
||||||
for f in file.parent.iterdir():
|
for f in file.parent.iterdir():
|
||||||
if id not in f.stem:
|
if id not in f.stem:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if f.suffix not in ('.mp4', '.mkv', '.webm', '.m4v', '.m4a', '.mp3', '.aac', '.ogg',):
|
if f.suffix not in (
|
||||||
|
".mp4",
|
||||||
|
".mkv",
|
||||||
|
".webm",
|
||||||
|
".m4v",
|
||||||
|
".m4a",
|
||||||
|
".mp3",
|
||||||
|
".aac",
|
||||||
|
".ogg",
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return f.absolute()
|
return f.absolute()
|
||||||
|
|
@ -314,7 +379,7 @@ def get_value(value):
|
||||||
return value() if callable(value) else value
|
return value() if callable(value) else value
|
||||||
|
|
||||||
|
|
||||||
def ag(array: dict | list, path: list[str | int] | str | int, default: Any = None, separator: str = '.') -> Any:
|
def ag(array: dict | list, path: list[str | int] | str | int, default: Any = None, separator: str = ".") -> Any:
|
||||||
"""
|
"""
|
||||||
dict/array getter: Retrieve a value from a nested dict or object using a path.
|
dict/array getter: Retrieve a value from a nested dict or object using a path.
|
||||||
|
|
||||||
|
|
@ -328,7 +393,7 @@ def ag(array: dict | list, path: list[str | int] | str | int, default: Any = Non
|
||||||
:return: The found value or the default if not found.
|
:return: The found value or the default if not found.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if path is None or path == '':
|
if path is None or path == "":
|
||||||
return array
|
return array
|
||||||
|
|
||||||
if not isinstance(array, dict):
|
if not isinstance(array, dict):
|
||||||
|
|
@ -369,7 +434,7 @@ def is_private_address(hostname: str) -> bool:
|
||||||
try:
|
try:
|
||||||
ip = socket.gethostbyname(hostname)
|
ip = socket.gethostbyname(hostname)
|
||||||
ip_obj = ipaddress.ip_address(ip)
|
ip_obj = ipaddress.ip_address(ip)
|
||||||
return (ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local)
|
return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local
|
||||||
except socket.gaierror:
|
except socket.gaierror:
|
||||||
# Could not resolve - treat as invalid or restricted
|
# Could not resolve - treat as invalid or restricted
|
||||||
return True
|
return True
|
||||||
|
|
@ -393,6 +458,7 @@ def validate_url(url: str) -> bool:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
|
|
||||||
parsed_url = URL(url)
|
parsed_url = URL(url)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError("Invalid URL.")
|
raise ValueError("Invalid URL.")
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,17 @@ from .ItemDTO import ItemDTO
|
||||||
import httpx
|
import httpx
|
||||||
from .version import APP_VERSION
|
from .version import APP_VERSION
|
||||||
|
|
||||||
LOG = logging.getLogger('Webhooks')
|
LOG = logging.getLogger("Webhooks")
|
||||||
|
|
||||||
|
|
||||||
class Webhooks:
|
class Webhooks:
|
||||||
targets: list[dict] = []
|
targets: list[dict] = []
|
||||||
allowed_events: tuple = ('added', 'completed', 'error', 'not_live',)
|
allowed_events: tuple = (
|
||||||
|
"added",
|
||||||
|
"completed",
|
||||||
|
"error",
|
||||||
|
"not_live",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, file: str):
|
def __init__(self, file: str):
|
||||||
if os.path.exists(file):
|
if os.path.exists(file):
|
||||||
|
|
@ -19,13 +24,14 @@ class Webhooks:
|
||||||
def load(self, file: str):
|
def load(self, file: str):
|
||||||
try:
|
try:
|
||||||
if os.path.getsize(file) < 3:
|
if os.path.getsize(file) < 3:
|
||||||
raise Exception(f'file is empty.')
|
raise Exception("file is empty.")
|
||||||
|
|
||||||
LOG.info(f"Loading webhooks from '{file}'.")
|
LOG.info(f"Loading webhooks from '{file}'.")
|
||||||
from .Utils import load_file
|
from .Utils import load_file
|
||||||
|
|
||||||
(target, status, error) = load_file(file, list)
|
(target, status, error) = load_file(file, list)
|
||||||
if not status:
|
if not status:
|
||||||
raise Exception(f'{error}')
|
raise Exception(f"{error}")
|
||||||
|
|
||||||
self.targets = target
|
self.targets = target
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -42,7 +48,7 @@ class Webhooks:
|
||||||
tasks = []
|
tasks = []
|
||||||
|
|
||||||
for target in self.targets:
|
for target in self.targets:
|
||||||
allowed_events = target.get('on') if 'on' in target else []
|
allowed_events = target.get("on") if "on" in target else []
|
||||||
if len(allowed_events) > 0 and event not in allowed_events:
|
if len(allowed_events) > 0 and event not in allowed_events:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -52,41 +58,36 @@ class Webhooks:
|
||||||
|
|
||||||
async def __send(self, event: str, target: dict, item: ItemDTO) -> dict:
|
async def __send(self, event: str, target: dict, item: ItemDTO) -> dict:
|
||||||
from .config import Config
|
from .config import Config
|
||||||
req: dict = target.get('request')
|
|
||||||
|
req: dict = target.get("request")
|
||||||
try:
|
try:
|
||||||
LOG.info(f"Sending event '{event}' id '{item.id}' to '{target.get('name')}'.")
|
LOG.info(f"Sending event '{event}' id '{item.id}' to '{target.get('name')}'.")
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
request_type = req.get('type', 'json')
|
request_type = req.get("type", "json")
|
||||||
|
|
||||||
reqBody = {
|
reqBody = {
|
||||||
'method': req.get('method', 'POST'),
|
"method": req.get("method", "POST"),
|
||||||
'url': req.get('url'),
|
"url": req.get("url"),
|
||||||
'headers': {
|
"headers": {"User-Agent": f"YTPTube/{APP_VERSION}"},
|
||||||
'User-Agent': f"YTPTube/{APP_VERSION}"
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.get('headers', None):
|
if req.get("headers", None):
|
||||||
reqBody['headers'].update(req.get('headers'))
|
reqBody["headers"].update(req.get("headers"))
|
||||||
|
|
||||||
match(request_type):
|
match request_type:
|
||||||
case 'json':
|
case "json":
|
||||||
reqBody['json'] = item.__dict__
|
reqBody["json"] = item.__dict__
|
||||||
reqBody['headers']['Content-Type'] = 'application/json'
|
reqBody["headers"]["Content-Type"] = "application/json"
|
||||||
case _:
|
case _:
|
||||||
reqBody['data'] = item.__dict__
|
reqBody["data"] = item.__dict__
|
||||||
reqBody['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
|
reqBody["headers"]["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
response = await client.request(**reqBody)
|
response = await client.request(**reqBody)
|
||||||
|
|
||||||
respData = {
|
respData = {"url": req.get("url"), "status": response.status_code, "text": response.text}
|
||||||
'url': req.get('url'),
|
|
||||||
'status': response.status_code,
|
|
||||||
'text': response.text
|
|
||||||
}
|
|
||||||
|
|
||||||
msg = f"Webhook target '{target.get('name')}' Responded to event '{event}' id '{item.id}' with status '{response.status_code}'."
|
msg = f"Webhook target '{target.get('name')}' Responded to event '{event}' id '{item.id}' with status '{response.status_code}'."
|
||||||
if Config.get_instance().debug and respData.get('text'):
|
if Config.get_instance().debug and respData.get("text"):
|
||||||
msg += f" body '{respData.get('text','??')}'."
|
msg += f" body '{respData.get('text','??')}'."
|
||||||
|
|
||||||
LOG.info(msg)
|
LOG.info(msg)
|
||||||
|
|
@ -94,11 +95,7 @@ class Webhooks:
|
||||||
return respData
|
return respData
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Error sending event '{event}' id '{item.id}' to '{target.get('name')}'. '{e}'")
|
LOG.error(f"Error sending event '{event}' id '{item.id}' to '{target.get('name')}'. '{e}'")
|
||||||
return {
|
return {"url": req.get("url"), "status": 500, "text": str(e)}
|
||||||
'url': req.get('url'),
|
|
||||||
'status': 500,
|
|
||||||
'text': str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def emit(self, event, data, **kwargs):
|
def emit(self, event, data, **kwargs):
|
||||||
if len(self.targets) < 1 or event not in self.allowed_events:
|
if len(self.targets) < 1 or event not in self.allowed_events:
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .DownloadQueue import DownloadQueue
|
from .DownloadQueue import DownloadQueue
|
||||||
from .encoder import Encoder
|
from .encoder import Encoder
|
||||||
|
|
||||||
LOG = logging.getLogger('common')
|
LOG = logging.getLogger("common")
|
||||||
|
|
||||||
|
|
||||||
class common():
|
class common:
|
||||||
"""
|
"""
|
||||||
This class is used to share common methods between the socket and the API gateways.
|
This class is used to share common methods between the socket and the API gateways.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queue: DownloadQueue = None
|
queue: DownloadQueue = None
|
||||||
encoder: Encoder = None
|
encoder: Encoder = None
|
||||||
|
|
||||||
|
|
@ -17,8 +19,9 @@ class common():
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
self.encoder = encoder
|
self.encoder = encoder
|
||||||
|
|
||||||
async def add(self, url: str, preset: str, folder: str, ytdlp_cookies: str, ytdlp_config: dict,
|
async def add(
|
||||||
output_template: str) -> dict[str, str]:
|
self, url: str, preset: str, folder: str, ytdlp_cookies: str, ytdlp_config: dict, output_template: str
|
||||||
|
) -> dict[str, str]:
|
||||||
if ytdlp_config is None:
|
if ytdlp_config is None:
|
||||||
ytdlp_config = {}
|
ytdlp_config = {}
|
||||||
|
|
||||||
|
|
@ -27,11 +30,11 @@ class common():
|
||||||
|
|
||||||
status = await self.queue.add(
|
status = await self.queue.add(
|
||||||
url=url,
|
url=url,
|
||||||
preset=preset if preset else 'default',
|
preset=preset if preset else "default",
|
||||||
folder=folder,
|
folder=folder,
|
||||||
ytdlp_cookies=ytdlp_cookies,
|
ytdlp_cookies=ytdlp_cookies,
|
||||||
ytdlp_config=ytdlp_config,
|
ytdlp_config=ytdlp_config,
|
||||||
output_template=output_template
|
output_template=output_template,
|
||||||
)
|
)
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import coloredlogs
|
import coloredlogs
|
||||||
from .version import APP_VERSION
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from yt_dlp.version import __version__ as YTDLP_VERSION
|
from yt_dlp.version import __version__ as YTDLP_VERSION
|
||||||
|
|
||||||
from .Utils import load_file
|
from .Utils import load_file
|
||||||
from pathlib import Path
|
from .version import APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|
@ -39,6 +41,8 @@ class Config:
|
||||||
|
|
||||||
log_level: str = "info"
|
log_level: str = "info"
|
||||||
|
|
||||||
|
access_log: bool = True
|
||||||
|
|
||||||
allow_manifestless: bool = False
|
allow_manifestless: bool = False
|
||||||
|
|
||||||
max_workers: int = 1
|
max_workers: int = 1
|
||||||
|
|
@ -136,6 +140,7 @@ class Config:
|
||||||
"debug",
|
"debug",
|
||||||
"temp_keep",
|
"temp_keep",
|
||||||
"allow_manifestless",
|
"allow_manifestless",
|
||||||
|
"access_log",
|
||||||
)
|
)
|
||||||
|
|
||||||
_frontend_vars: tuple = (
|
_frontend_vars: tuple = (
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class Encoder(json.JSONEncoder):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def default(self, o):
|
def default(self, o):
|
||||||
if isinstance(o, object) and hasattr(o, '__dict__'):
|
if isinstance(o, object) and hasattr(o, "__dict__"):
|
||||||
return o.__dict__
|
return o.__dict__
|
||||||
|
|
||||||
return json.JSONEncoder.default(self, o)
|
return json.JSONEncoder.default(self, o)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Python wrapper for ffprobe command line tool. ffprobe must exist in the path.
|
Python wrapper for ffprobe command line tool. ffprobe must exist in the path.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
|
|
@ -22,25 +23,26 @@ class FFStream:
|
||||||
setattr(self, key, val)
|
setattr(self, key, val)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.__dict__['framerate'] = round(
|
self.__dict__["framerate"] = round(
|
||||||
functools.reduce(
|
functools.reduce(operator.truediv, map(int, self.__dict__.get("avg_frame_rate", "").split("/")))
|
||||||
operator.truediv, map(int, self.__dict__.get('avg_frame_rate', '').split('/'))
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.__dict__['framerate'] = None
|
self.__dict__["framerate"] = None
|
||||||
except ZeroDivisionError:
|
except ZeroDivisionError:
|
||||||
self.__dict__['framerate'] = 0
|
self.__dict__["framerate"] = 0
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
if not 'codec_long_name' in self.__dict__:
|
if "codec_long_name" not in self.__dict__:
|
||||||
self.codec_long_name = self.__dict__.get('codec_name', '')
|
self.codec_long_name = self.__dict__.get("codec_name", "")
|
||||||
|
|
||||||
if self.is_video():
|
if self.is_video():
|
||||||
return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, {self.framerate}, ({self.width}x{self.height})>"
|
return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, {self.framerate}, ({self.width}x{self.height})>"
|
||||||
|
|
||||||
if self.is_audio():
|
if self.is_audio():
|
||||||
return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, channels: {self.channels} ({self.channel_layout}), " "{sample_rate}Hz> "
|
return (
|
||||||
|
f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, channels: {self.channels} ({self.channel_layout}), "
|
||||||
|
"{sample_rate}Hz> "
|
||||||
|
)
|
||||||
|
|
||||||
if self.is_subtitle() or self.is_attachment():
|
if self.is_subtitle() or self.is_attachment():
|
||||||
return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}>"
|
return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}>"
|
||||||
|
|
@ -51,25 +53,25 @@ class FFStream:
|
||||||
"""
|
"""
|
||||||
Is this stream labelled as an audio stream?
|
Is this stream labelled as an audio stream?
|
||||||
"""
|
"""
|
||||||
return self.__dict__.get('codec_type', None) == 'audio'
|
return self.__dict__.get("codec_type", None) == "audio"
|
||||||
|
|
||||||
def is_video(self):
|
def is_video(self):
|
||||||
"""
|
"""
|
||||||
Is the stream labelled as a video stream.
|
Is the stream labelled as a video stream.
|
||||||
"""
|
"""
|
||||||
return self.__dict__.get('codec_type', None) == 'video'
|
return self.__dict__.get("codec_type", None) == "video"
|
||||||
|
|
||||||
def is_subtitle(self):
|
def is_subtitle(self):
|
||||||
"""
|
"""
|
||||||
Is the stream labelled as a subtitle stream.
|
Is the stream labelled as a subtitle stream.
|
||||||
"""
|
"""
|
||||||
return self.__dict__.get('codec_type', None) == 'subtitle'
|
return self.__dict__.get("codec_type", None) == "subtitle"
|
||||||
|
|
||||||
def is_attachment(self):
|
def is_attachment(self):
|
||||||
"""
|
"""
|
||||||
Is the stream labelled as a attachment stream.
|
Is the stream labelled as a attachment stream.
|
||||||
"""
|
"""
|
||||||
return self.__dict__.get('codec_type', None) == 'attachment'
|
return self.__dict__.get("codec_type", None) == "attachment"
|
||||||
|
|
||||||
def frame_size(self):
|
def frame_size(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -78,8 +80,8 @@ class FFStream:
|
||||||
"""
|
"""
|
||||||
size = None
|
size = None
|
||||||
if self.is_video():
|
if self.is_video():
|
||||||
width = self.__dict__['width']
|
width = self.__dict__["width"]
|
||||||
height = self.__dict__['height']
|
height = self.__dict__["height"]
|
||||||
|
|
||||||
if width and height:
|
if width and height:
|
||||||
try:
|
try:
|
||||||
|
|
@ -96,7 +98,7 @@ class FFStream:
|
||||||
Returns a string representing the pixel format of the video stream. e.g. yuv420p.
|
Returns a string representing the pixel format of the video stream. e.g. yuv420p.
|
||||||
Returns none is it is not a video stream.
|
Returns none is it is not a video stream.
|
||||||
"""
|
"""
|
||||||
return self.__dict__.get('pix_fmt', None)
|
return self.__dict__.get("pix_fmt", None)
|
||||||
|
|
||||||
def frames(self):
|
def frames(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -104,9 +106,9 @@ class FFStream:
|
||||||
"""
|
"""
|
||||||
if self.is_video() or self.is_audio():
|
if self.is_video() or self.is_audio():
|
||||||
try:
|
try:
|
||||||
frame_count = int(self.__dict__.get('nb_frames', ''))
|
frame_count = int(self.__dict__.get("nb_frames", ""))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise FFProbeError('None integer frame count')
|
raise FFProbeError("None integer frame count")
|
||||||
else:
|
else:
|
||||||
frame_count = 0
|
frame_count = 0
|
||||||
|
|
||||||
|
|
@ -119,9 +121,9 @@ class FFStream:
|
||||||
"""
|
"""
|
||||||
if self.is_video() or self.is_audio():
|
if self.is_video() or self.is_audio():
|
||||||
try:
|
try:
|
||||||
duration = float(self.__dict__.get('duration', ''))
|
duration = float(self.__dict__.get("duration", ""))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise FFProbeError('None numeric duration')
|
raise FFProbeError("None numeric duration")
|
||||||
else:
|
else:
|
||||||
duration = 0.0
|
duration = 0.0
|
||||||
|
|
||||||
|
|
@ -131,34 +133,34 @@ class FFStream:
|
||||||
"""
|
"""
|
||||||
Returns language tag of stream. e.g. eng
|
Returns language tag of stream. e.g. eng
|
||||||
"""
|
"""
|
||||||
return self.__dict__.get('TAG:language', None)
|
return self.__dict__.get("TAG:language", None)
|
||||||
|
|
||||||
def codec(self):
|
def codec(self):
|
||||||
"""
|
"""
|
||||||
Returns a string representation of the stream codec.
|
Returns a string representation of the stream codec.
|
||||||
"""
|
"""
|
||||||
return self.__dict__.get('codec_name', None)
|
return self.__dict__.get("codec_name", None)
|
||||||
|
|
||||||
def codec_description(self):
|
def codec_description(self):
|
||||||
"""
|
"""
|
||||||
Returns a long representation of the stream codec.
|
Returns a long representation of the stream codec.
|
||||||
"""
|
"""
|
||||||
return self.__dict__.get('codec_long_name', None)
|
return self.__dict__.get("codec_long_name", None)
|
||||||
|
|
||||||
def codec_tag(self):
|
def codec_tag(self):
|
||||||
"""
|
"""
|
||||||
Returns a short representative tag of the stream codec.
|
Returns a short representative tag of the stream codec.
|
||||||
"""
|
"""
|
||||||
return self.__dict__.get('codec_tag_string', None)
|
return self.__dict__.get("codec_tag_string", None)
|
||||||
|
|
||||||
def bit_rate(self):
|
def bit_rate(self):
|
||||||
"""
|
"""
|
||||||
Returns bit_rate as an integer in bps
|
Returns bit_rate as an integer in bps
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return int(self.__dict__.get('bit_rate', ''))
|
return int(self.__dict__.get("bit_rate", ""))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise FFProbeError('None integer bit_rate')
|
raise FFProbeError("None integer bit_rate")
|
||||||
|
|
||||||
|
|
||||||
class FFProbe:
|
class FFProbe:
|
||||||
|
|
@ -166,38 +168,40 @@ class FFProbe:
|
||||||
FFProbe wraps the ffprobe command and pulls the data into an object form::
|
FFProbe wraps the ffprobe command and pulls the data into an object form::
|
||||||
metadata=FFProbe('multimedia-file.mov')
|
metadata=FFProbe('multimedia-file.mov')
|
||||||
"""
|
"""
|
||||||
|
|
||||||
audio: list[FFStream] = []
|
audio: list[FFStream] = []
|
||||||
attachment: list[FFStream] = []
|
attachment: list[FFStream] = []
|
||||||
streams: list[FFStream] = []
|
streams: list[FFStream] = []
|
||||||
subtitle: list[FFStream] = []
|
subtitle: list[FFStream] = []
|
||||||
video: list[FFStream] = []
|
video: list[FFStream] = []
|
||||||
metadata: dict = {}
|
metadata: dict = {}
|
||||||
path_to_video: str = ''
|
path_to_video: str = ""
|
||||||
|
|
||||||
def __init__(self, path_to_video):
|
def __init__(self, path_to_video):
|
||||||
self.path_to_video = path_to_video
|
self.path_to_video = path_to_video
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
try:
|
try:
|
||||||
with open(os.devnull, 'w') as tempf:
|
with open(os.devnull, "w") as tempf:
|
||||||
await asyncio.create_subprocess_exec(
|
await asyncio.create_subprocess_exec("ffprobe", "-h", stdout=tempf, stderr=tempf)
|
||||||
"ffprobe", "-h", stdout=tempf, stderr=tempf
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise IOError('ffprobe not found.')
|
raise IOError("ffprobe not found.")
|
||||||
|
|
||||||
if not os.path.isfile(self.path_to_video):
|
if not os.path.isfile(self.path_to_video):
|
||||||
raise IOError(f"No such media file '{self.path_to_video}'.")
|
raise IOError(f"No such media file '{self.path_to_video}'.")
|
||||||
|
|
||||||
args = [
|
args = [
|
||||||
'-v', 'quiet',
|
"-v",
|
||||||
'-of', 'json',
|
"quiet",
|
||||||
'-show_streams',
|
"-of",
|
||||||
'-show_format',
|
"json",
|
||||||
|
"-show_streams",
|
||||||
|
"-show_format",
|
||||||
self.path_to_video,
|
self.path_to_video,
|
||||||
]
|
]
|
||||||
p = await asyncio.create_subprocess_exec(
|
p = await asyncio.create_subprocess_exec(
|
||||||
'ffprobe', *args,
|
"ffprobe",
|
||||||
|
*args,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
@ -206,7 +210,7 @@ class FFProbe:
|
||||||
|
|
||||||
data, err = await p.communicate()
|
data, err = await p.communicate()
|
||||||
if 0 == exitCode:
|
if 0 == exitCode:
|
||||||
parsed: dict = json.loads(data.decode('utf-8'))
|
parsed: dict = json.loads(data.decode("utf-8"))
|
||||||
else:
|
else:
|
||||||
raise FFProbeError(f"FFProbe return with non-0 exit code. '{err.decode('utf-8')}'")
|
raise FFProbeError(f"FFProbe return with non-0 exit code. '{err.decode('utf-8')}'")
|
||||||
|
|
||||||
|
|
@ -217,10 +221,10 @@ class FFProbe:
|
||||||
self.subtitle = []
|
self.subtitle = []
|
||||||
self.attachment = []
|
self.attachment = []
|
||||||
|
|
||||||
for stream in parsed.get('streams', []):
|
for stream in parsed.get("streams", []):
|
||||||
self.streams.append(FFStream(stream))
|
self.streams.append(FFStream(stream))
|
||||||
|
|
||||||
self.metadata = parsed.get('format', {})
|
self.metadata = parsed.get("format", {})
|
||||||
|
|
||||||
for stream in self.streams:
|
for stream in self.streams:
|
||||||
if stream.is_audio():
|
if stream.is_audio():
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
Version of the application,
|
Version of the application,
|
||||||
This file is updated by the CI/CD pipeline, do not edit it manually.
|
This file is updated by the CI/CD pipeline, do not edit it manually.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APP_VERSION = "dev-master"
|
APP_VERSION = "dev-master"
|
||||||
"Application version"
|
"Application version"
|
||||||
|
|
|
||||||
12
app/main.py
12
app/main.py
|
|
@ -16,7 +16,7 @@ from library.config import Config
|
||||||
from library.DownloadQueue import DownloadQueue
|
from library.DownloadQueue import DownloadQueue
|
||||||
from library.Emitter import Emitter
|
from library.Emitter import Emitter
|
||||||
from library.encoder import Encoder
|
from library.encoder import Encoder
|
||||||
from library.HttpAPI import HttpAPI
|
from library.HttpAPI import HttpAPI, LOG as http_logger
|
||||||
from library.HttpSocket import HttpSocket
|
from library.HttpSocket import HttpSocket
|
||||||
from library.Webhooks import Webhooks
|
from library.Webhooks import Webhooks
|
||||||
|
|
||||||
|
|
@ -136,15 +136,19 @@ class Main:
|
||||||
|
|
||||||
self.load_tasks()
|
self.load_tasks()
|
||||||
|
|
||||||
start: str = f"YTPTube v{self.config.version} - started on http://{self.config.host}:{self.config.port}"
|
def started(_):
|
||||||
|
LOG.info("=" * 40)
|
||||||
|
LOG.info(f"YTPTube v{self.config.version} - started on http://{self.config.host}:{self.config.port}")
|
||||||
|
LOG.info("=" * 40)
|
||||||
|
|
||||||
web.run_app(
|
web.run_app(
|
||||||
self.app,
|
self.app,
|
||||||
host=self.config.host,
|
host=self.config.host,
|
||||||
port=self.config.port,
|
port=self.config.port,
|
||||||
reuse_port=True,
|
reuse_port=True,
|
||||||
loop=asyncio.get_event_loop(),
|
loop=asyncio.get_event_loop(),
|
||||||
access_log=None,
|
access_log=http_logger if self.config.access_log else None,
|
||||||
print=lambda _: LOG.info(start),
|
print=started,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "Setting umask to ${UMASK}, to change it set the UMASK environment variable"
|
echo "Setting umask to '${UMASK}', to change it set the UMASK environment variable"
|
||||||
umask ${UMASK}
|
umask ${UMASK}
|
||||||
|
|
||||||
echo_err() { cat <<< "$@" 1>&2; }
|
echo_err() { cat <<<"$@" 1>&2; }
|
||||||
|
|
||||||
if [ ! -w "${YTP_CONFIG_PATH}" ]; then
|
if [ ! -w "${YTP_CONFIG_PATH}" ]; then
|
||||||
CH_USER=$(stat -c "%u" "${YTP_CONFIG_PATH}")
|
CH_USER=$(stat -c "%u" "${YTP_CONFIG_PATH}")
|
||||||
CH_GRP=$(stat -c "%g" "${YTP_CONFIG_PATH}")
|
CH_GRP=$(stat -c "%g" "${YTP_CONFIG_PATH}")
|
||||||
echo_err "ERROR: Unable to write to [${YTP_CONFIG_PATH}] data directory. Current user id [${UID}] while directory owner is [${CH_USER}]"
|
echo_err "ERROR: Unable to write to '${YTP_CONFIG_PATH}' data directory. Current user id '${UID}' while directory owner is '${CH_USER}'."
|
||||||
echo_err "[Running under docker]"
|
echo_err "[Running under docker]"
|
||||||
echo_err "change docker-compose.yaml user: to user:\"${CH_USER}:${CH_GRP}\""
|
echo_err "change docker-compose.yaml user: to user:\"${CH_USER}:${CH_GRP}\""
|
||||||
echo_err "Run the following command to change the directory ownership"
|
echo_err "Run the following command to change the directory ownership"
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@
|
||||||
|
|
||||||
set -eu pipefail
|
set -eu pipefail
|
||||||
|
|
||||||
/usr/bin/curl -f http://localhost:${YTP_PORT:-8081}/ping
|
LOCAL_PORT="${YTP_PORT:-8081}"
|
||||||
|
|
||||||
|
/usr/bin/curl -f "http://localhost:${LOCAL_PORT}/ping"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue