Merge pull request #72 from arabcoders/dev

Dev
This commit is contained in:
Abdulmohsen 2024-02-27 17:05:36 +03:00 committed by GitHub
commit 0dbbd6a2a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 83 additions and 57 deletions

5
.vscode/launch.json vendored
View file

@ -23,7 +23,7 @@
}, },
{ {
"name": "Python: main.py", "name": "Python: main.py",
"type": "python", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "app/main.py", "program": "app/main.py",
"console": "internalConsole", "console": "internalConsole",
@ -31,7 +31,8 @@
"YTP_CONFIG_PATH": "${workspaceFolder}/var/config", "YTP_CONFIG_PATH": "${workspaceFolder}/var/config",
"YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads", "YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads",
"YTP_TEMP_PATH": "${workspaceFolder}/var/tmp", "YTP_TEMP_PATH": "${workspaceFolder}/var/tmp",
"YTP_URL_HOST": "http://localhost:8081" "YTP_URL_HOST": "http://localhost:8081",
"YTP_LOG_LEVEL": "DEBUG"
} }
} }
] ]

View file

@ -71,9 +71,9 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __YTP_KEEP_ARCHIVE__: Whether to keep history of downloaded videos to prevent downloading same file multiple times. Defaults to `true`. * __YTP_KEEP_ARCHIVE__: Whether to keep history of downloaded videos to prevent downloading same file multiple times. Defaults to `true`.
* __YTP_YTDL_DEBUG__: Whether to turn debug logging for the internal `yt-dlp` package. Defaults to `false`. * __YTP_YTDL_DEBUG__: Whether to turn debug logging for the internal `yt-dlp` package. Defaults to `false`.
* __YTP_ALLOW_MANIFESTLESS__: Allow `yt-dlp` to download live streams videos which are yet to be processed by YouTube. Defaults to `false` * __YTP_ALLOW_MANIFESTLESS__: Allow `yt-dlp` to download live streams videos which are yet to be processed by YouTube. Defaults to `false`
* __YTP_HOST__: Which ip address to bind to. Defaults to `0.0.0.0`. * __YTP_HOST__: Which IP address to bind to. Defaults to `0.0.0.0`.
* __YTP_PORT__: Which port to bind to. Defaults to `8081`. * __YTP_PORT__: Which port to bind to. Defaults to `8081`.
* __YTP_LOGGING_LEVEL__: Logging level. Defaults to `info`. * __YTP_LOG_LEVEL__: Log level. Defaults to `info`.
* __YTP_MAX_WORKERS__: How many works to use for downloads. Defaults to `1`. * __YTP_MAX_WORKERS__: How many works to use for downloads. Defaults to `1`.
## Running behind a reverse proxy ## Running behind a reverse proxy
@ -130,7 +130,7 @@ Once there, you can use the yt-dlp command freely.
## Building and running locally ## Building and running locally
Make sure you have node.js and Python 3.11 installed. Make sure you have `node.js` and Python `3.11+` installed.
```bash ```bash
cd ytptube/frontend cd ytptube/frontend

View file

@ -16,21 +16,23 @@ class AsyncPool:
number of items of work is in the queue). `worker_co` will be called number of items of work is in the queue). `worker_co` will be called
against each item retrieved from the queue. If any exceptions are raised out of against each item retrieved from the queue. If any exceptions are raised out of
worker_co, self.exceptions will be set to True. worker_co, self.exceptions will be set to True.
@param loop: asyncio loop to use
@param num_workers: number of async tasks which will pull from the internal queue :param loop: asyncio loop to use
@param name: name of the worker pool (used for logging) :param num_workers: number of async tasks which will pull from the internal queue
@param logger: logger to use :param name: name of the worker pool (used for logging)
@param worker_co: async coroutine to call when an item is retrieved from the queue :param logger: logger to use
@param load_factor: multiplier used for number of items in queue :param worker_co: async coroutine to call when an item is retrieved from the queue
@param job_accept_duration: maximum number of seconds from first push to last push before a TimeoutError will be thrown. :param load_factor: multiplier used for number of items in queue
:param job_accept_duration: maximum number of seconds from first push to last push before a TimeoutError will be thrown.
Set to None for no limit. Note this does not get reset on aenter/aexit. Set to None for no limit. Note this does not get reset on aenter/aexit.
@param max_task_time: maximum time allowed for each task before a CancelledError is raised in the task. :param max_task_time: maximum time allowed for each task before a CancelledError is raised in the task.
Set to None for no limit. Set to None for no limit.
@param return_futures: set to reture to return a future for each `push` (imposes CPU overhead) :param return_futures: set to reture to return a future for each `push` (imposes CPU overhead)
@param raise_on_join: raise on join if any exceptions have occurred, default is False :param raise_on_join: raise on join if any exceptions have occurred, default is False
@param log_every_n: (optional) set to number of `push`s each time a log statement should be printed (default does not print every-n pushes) :param log_every_n: (optional) set to number of `push`s each time a log statement should be printed (default does not print every-n pushes)
@param expected_total: (optional) expected total number of jobs (used for `log_event_n` logging) :param expected_total: (optional) expected total number of jobs (used for `log_event_n` logging)
@return: instance of AsyncWorkerPool
:return: instance of AsyncWorkerPool
""" """
loop = loop if loop else asyncio.get_event_loop() loop = loop if loop else asyncio.get_event_loop()
self._loop = loop self._loop = loop
@ -117,25 +119,28 @@ class AsyncPool:
await self.join() await self.join()
async def push(self, *args, **kwargs) -> asyncio.Future: async def push(self, *args, **kwargs) -> asyncio.Future:
""" Method to push work to `worker_co` passed to `__init__`. """
Method to push work to `worker_co` passed initially to `__init__`.
:param args: position arguments to be passed to `worker_co` :param args: position arguments to be passed to `worker_co`
:param kwargs: keyword arguments to be passed to `worker_co` :param kwargs: keyword arguments to be passed to `worker_co`
:return: future of result """
:return: future of result.
"""
if self._first_push_dt is None: if self._first_push_dt is None:
self._first_push_dt = self._time() self._first_push_dt = self._time()
if self._job_accept_duration is not None and (self._time() - self._first_push_dt) > self._job_accept_duration: if self._job_accept_duration is not None and (self._time() - self._first_push_dt) > self._job_accept_duration:
raise TimeoutError("Maximum lifetime of {} seconds of AsyncWorkerPool: {} exceeded".format( raise TimeoutError(f"Max life time of {self._job_accept_duration}s exceeded for {self._name} pool.")
self._job_accept_duration, self._name))
future = asyncio.futures.Future( future = asyncio.futures.Future(loop=self._loop) if self._return_futures else None
loop=self._loop) if self._return_futures else None
await self._queue.put((future, args, kwargs)) await self._queue.put((future, args, kwargs))
self._total_queued += 1 self._total_queued += 1
if self._log_every_n is not None and (self._total_queued % self._log_every_n) == 0: if self._log_every_n is not None and (self._total_queued % self._log_every_n) == 0:
self._logger.info("pushed {}/{} items to {} AsyncWorkerPool".format( self._logger.info(f"pushed {self._total_queued}/{self._expected_total} items to {self._name} pool.")
self._total_queued, self._expected_total, self._name))
self._logger.debug(f"'{self._name}' pool has received a new job. {args} {kwargs}")
return future return future
@ -144,15 +149,22 @@ class AsyncPool:
assert self._workers is None assert self._workers is None
self._exceptions = False self._exceptions = False
self._workers = [asyncio.ensure_future( self._workers = []
self._worker_loop(), loop=self._loop) for _ in range(self._num_workers)] for _ in range(self._num_workers):
self._workers.append(
asyncio.ensure_future(
coro_or_future=self._worker_loop(),
loop=self._loop
)
)
async def join(self): async def join(self):
# no-op if workers aren't running # no-op if workers aren't running
if not self._workers: if not self._workers:
return return
self._logger.info('Joining {}'.format(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
for _ in range(self._num_workers): for _ in range(self._num_workers):
@ -162,13 +174,13 @@ class AsyncPool:
await asyncio.gather(*self._workers) await asyncio.gather(*self._workers)
self._workers = None self._workers = None
except: except:
self._logger.exception('Exception joining {}'.format(self._name)) self._logger.exception(f'Exception joining {self._name}')
raise raise
finally: finally:
self._logger.info('Completed {}'.format(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("Exception occurred in pool {}".format(self._name)) raise Exception(f"Exception occurred in {self._name} pool")
def _time(self): def _time(self):
# utcnow returns a naive datetime, so we have to set the timezone manually <sigh> # utcnow returns a naive datetime, so we have to set the timezone manually <sigh>

View file

@ -33,7 +33,7 @@ class Config:
base_path: str = '' base_path: str = ''
logging_level: str = 'info' log_level: str = 'info'
allow_manifestless: bool = False allow_manifestless: bool = False
@ -110,9 +110,9 @@ class Config:
if not self.url_prefix.endswith('/'): if not self.url_prefix.endswith('/'):
self.url_prefix += '/' self.url_prefix += '/'
numeric_level = getattr(logging, self.logging_level.upper(), None) numeric_level = getattr(logging, self.log_level.upper(), None)
if not isinstance(numeric_level, int): if not isinstance(numeric_level, int):
raise ValueError(f"Invalid log level: {self.logging_level}") raise ValueError(f"Invalid log level: {self.log_level}")
coloredlogs.install( coloredlogs.install(
level=numeric_level, level=numeric_level,

View file

@ -11,7 +11,7 @@ from ItemDTO import ItemDTO
from DataStore import DataStore from DataStore import DataStore
from Utils import Notifier, ObjectSerializer, calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig from Utils import Notifier, ObjectSerializer, calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig
from AsyncPool import AsyncPool from AsyncPool import AsyncPool
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
LOG = logging.getLogger('DownloadQueue') LOG = logging.getLogger('DownloadQueue')
TYPE_DONE: str = 'done' TYPE_DONE: str = 'done'
TYPE_QUEUE: str = 'queue' TYPE_QUEUE: str = 'queue'
@ -217,18 +217,26 @@ class DownloadQueue:
else: else:
already.add(url) already.add(url)
try: try:
entry = await asyncio.get_running_loop().run_in_executor( with ThreadPoolExecutor(thread_name_prefix='extract_info') as pool:
None, LOG.debug(f'extracting info from {url=}')
ExtractInfo, entry = await asyncio.get_running_loop().run_in_executor(
mergeConfig(self.config.ytdl_options, ytdlp_config), pool,
url, ExtractInfo,
bool(self.config.ytdl_debug) mergeConfig(self.config.ytdl_options, ytdlp_config),
) url,
bool(self.config.ytdl_debug)
)
if not entry: if not entry:
if self.config.keep_archive: if not self.config.keep_archive:
return {
'status': 'error',
'msg': 'No metadata, most likely video has been downloaded before.' if self.config.keep_archive else 'Unable to extract info check logs.'
}
LOG.debug(f'No metadata, Rechecking with archive disabled. {url=}')
entry = await asyncio.get_running_loop().run_in_executor( entry = await asyncio.get_running_loop().run_in_executor(
None, pool,
ExtractInfo, ExtractInfo,
mergeConfig(self.config.ytdl_options, ytdlp_config), mergeConfig(self.config.ytdl_options, ytdlp_config),
url, url,
@ -246,11 +254,6 @@ class DownloadQueue:
'msg': f'[{entry.get("id")}: {entry.get("title")}]: has been downloaded already.' 'msg': f'[{entry.get("id")}: {entry.get("title")}]: has been downloaded already.'
} }
return {
'status': 'error',
'msg': 'No metadata, most likely video has been downloaded before.' if self.config.keep_archive else 'Unable to extract info check logs.'
}
if self.isDownloaded(entry): if self.isDownloaded(entry):
raise yt_dlp.utils.ExistingVideoReached() raise yt_dlp.utils.ExistingVideoReached()
@ -331,32 +334,42 @@ class DownloadQueue:
loop=asyncio.get_running_loop(), loop=asyncio.get_running_loop(),
num_workers=self.config.max_workers, num_workers=self.config.max_workers,
worker_co=self.__downloadFile, worker_co=self.__downloadFile,
name='WorkerPool', name='download_pool',
logger=logging.getLogger('WorkerPool'), logger=logging.getLogger('WorkerPool'),
) as executor: ) as executor:
lastLog = time.time()
while True: while True:
while True: while True:
if executor.has_open_workers() is False: if executor.has_open_workers() is True:
await asyncio.sleep(1)
else:
break break
if time.time() - lastLog > 120:
lastLog = time.time()
LOG.info(f'Waiting for workers to be free.')
await asyncio.sleep(1)
LOG.debug(f"Has '{executor.get_available_workers()}' free workers.")
while not self.queue.hasDownloads(): while not self.queue.hasDownloads():
LOG.info(f'Waiting for item to download. [{executor.get_available_workers()}] workers available.') LOG.info(f'Waiting for item to download.')
await self.event.wait() await self.event.wait()
self.event.clear() self.event.clear()
LOG.debug(f"Cleared wait event.")
entry = self.queue.getNextDownload() entry = self.queue.getNextDownload()
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
LOG.debug(f"Pushing {entry=} to executor.")
if entry.started() is False and entry.is_canceled() is False: if entry.started() is False and entry.is_canceled() is False:
await executor.push(id=entry.info._id, entry=entry) await executor.push(id=entry.info._id, entry=entry)
LOG.debug(f"Pushed {entry=} to executor.")
await asyncio.sleep(1) await asyncio.sleep(1)
async def __download(self): async def __download(self):
while True: while True:
while self.queue.empty(): while self.queue.empty():
LOG.info('waiting for item to download.') LOG.info('Waiting for item to download.')
await self.event.wait() await self.event.wait()
self.event.clear() self.event.clear()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 34 KiB