Gracefully handle already downloaded files and add not_live status.

This commit is contained in:
ArabCoders 2024-01-05 23:50:10 +03:00
parent a8ef590a36
commit 9605aa0090
6 changed files with 190 additions and 11 deletions

160
app/AsyncPool.py Normal file
View file

@ -0,0 +1,160 @@
import asyncio
from datetime import datetime, timezone
class Terminator:
pass
class AsyncPool:
def __init__(self, loop, num_workers: int, name: str, logger, worker_co, load_factor: int = 1,
job_accept_duration: int = None, max_task_time: int = None, return_futures: bool = False,
raise_on_join: bool = False, log_every_n: int = None, expected_total=None):
"""
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
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
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 name: name of the worker pool (used for logging)
@param logger: logger to use
@param worker_co: async coroutine to call when an item is retrieved from the queue
@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.
@param max_task_time: maximum time allowed for each task before a CancelledError is raised in the task.
Set to None for no limit.
@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 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)
@return: instance of AsyncWorkerPool
"""
loop = loop or asyncio.get_event_loop()
self._loop = loop
self._num_workers = num_workers
self._logger = logger
self._queue = asyncio.Queue(num_workers * load_factor)
self._workers = None
self._exceptions = False
self._job_accept_duration = job_accept_duration
self._first_push_dt = None
self._max_task_time = max_task_time
self._return_futures = return_futures
self._raise_on_join = raise_on_join
self._name = name
self._worker_co = worker_co
self._total_queued = 0
self._log_every_n = log_every_n
self._expected_total = expected_total
async def _worker_loop(self):
while True:
got_obj = False
future = None
try:
item = await self._queue.get()
got_obj = True
if item.__class__ is Terminator:
break
future, args, kwargs = item
# the wait_for will cancel the task (task sees CancelledError) and raises a TimeoutError from here
# so be wary of catching TimeoutErrors in this loop
result = await asyncio.wait_for(self._worker_co(*args, **kwargs), self._max_task_time, loop=self._loop)
if future:
future.set_result(result)
except (KeyboardInterrupt, MemoryError, SystemExit) as e:
if future:
future.set_exception(e)
self._exceptions = True
raise
except BaseException as e:
self._exceptions = True
if future:
# don't log the failure when the client is receiving the future
future.set_exception(e)
else:
self._logger.exception('Worker call failed')
finally:
if got_obj:
self._queue.task_done()
@property
def exceptions(self):
return self._exceptions
@property
def total_queued(self):
return self._total_queued
async def __aenter__(self):
self.start()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.join()
async def push(self, *args, **kwargs) -> asyncio.Future:
""" Method to push work to `worker_co` passed to `__init__`.
:param args: position arguments to be passed to `worker_co`
:param kwargs: keyword arguments to be passed to `worker_co`
:return: future of result """
if self._first_push_dt is None:
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:
raise TimeoutError("Maximum lifetime of {} seconds of AsyncWorkerPool: {} exceeded".format(
self._job_accept_duration, self._name))
future = asyncio.futures.Future(
loop=self._loop) if self._return_futures else None
await self._queue.put((future, args, kwargs))
self._total_queued += 1
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._total_queued, self._expected_total, self._name))
return future
def start(self):
""" Will start up worker pool and reset exception state """
assert self._workers is None
self._exceptions = False
self._workers = [asyncio.ensure_future(
self._worker_loop(), loop=self._loop) for _ in range(self._num_workers)]
async def join(self):
# no-op if workers aren't running
if not self._workers:
return
self._logger.debug('Joining {}'.format(self._name))
# The Terminators will kick each worker from being blocked against the _queue.get() and allow
# each one to exit
for _ in range(self._num_workers):
await self._queue.put(Terminator())
try:
await asyncio.gather(*self._workers, loop=self._loop)
self._workers = None
except:
self._logger.exception('Exception joining {}'.format(self._name))
raise
finally:
self._logger.debug('Completed {}'.format(self._name))
if self._exceptions and self._raise_on_join:
raise Exception("Exception occurred in pool {}".format(self._name))
def _time(self):
# utcnow returns a naive datetime, so we have to set the timezone manually <sigh>
return datetime.utcnow().replace(tzinfo=timezone.utc)

View file

@ -103,7 +103,9 @@ class Download:
'default': self.output_template, 'default': self.output_template,
'chapter': self.output_template_chapter 'chapter': self.output_template_chapter
}, },
'socket_timeout': 30, 'socket_timeout': 60,
'break_per_url': True,
'ignoreerrors': False,
'progress_hooks': [put_status], 'progress_hooks': [put_status],
'postprocessor_hooks': [put_status_postprocessor], 'postprocessor_hooks': [put_status_postprocessor],
**mergeConfig(self.default_ytdl_opts, self.ytdl_opts), **mergeConfig(self.default_ytdl_opts, self.ytdl_opts),

View file

@ -10,7 +10,6 @@ from Download import Download
from ItemDTO import ItemDTO from ItemDTO import ItemDTO
from DataStore import DataStore from DataStore import DataStore
from Utils import Notifier, ObjectSerializer, calcDownloadPath, ExtractInfo, mergeConfig from Utils import Notifier, ObjectSerializer, calcDownloadPath, ExtractInfo, mergeConfig
from datetime import datetime, timezone
log = logging.getLogger('DownloadQueue') log = logging.getLogger('DownloadQueue')
@ -143,17 +142,23 @@ class DownloadQueue:
dl.output_template = dl.output_template.replace( dl.output_template = dl.output_template.replace(
f"%({property})s", str(value)) f"%({property})s", str(value))
itemDownload = self.queue.put(Download( dlInfo: Download = Download(
info=dl, info=dl,
download_dir=dldirectory, download_dir=dldirectory,
temp_dir=self.config.temp_path, temp_dir=self.config.temp_path,
output_template_chapter=output_chapter, output_template_chapter=output_chapter,
default_ytdl_opts=self.config.ytdl_options, default_ytdl_opts=self.config.ytdl_options,
debug=bool(self.config.ytdl_debug) debug=bool(self.config.ytdl_debug)
)) );
self.event.set() if dlInfo.info.live_in:
await self.notifier.added(itemDownload.info) dlInfo.info.status = 'not_live'
itemDownload = self.done.put(dlInfo)
else:
itemDownload = self.queue.put(dlInfo)
self.event.set()
await self.notifier.emit('completed' if itemDownload.info.live_in else 'added', itemDownload.info)
return { return {
'status': 'ok' 'status': 'ok'
@ -211,6 +216,8 @@ class DownloadQueue:
'msg': 'No metadata, most likely video has been downloaded before.' if self.config.keep_archive else 'Unable to extract info check logs.' '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'entry: extract info says: {entry}') log.debug(f'entry: extract info says: {entry}')
except yt_dlp.utils.ExistingVideoReached:
return {'status': 'error', 'msg': 'Video has been downloaded already and recorded in archive.log file.'}
except yt_dlp.utils.YoutubeDLError as exc: except yt_dlp.utils.YoutubeDLError as exc:
return {'status': 'error', 'msg': str(exc)} return {'status': 'error', 'msg': str(exc)}

View file

@ -169,9 +169,11 @@ def ExtractInfo(config: dict, url: str, debug: bool = False) -> dict:
'skip_download': True, 'skip_download': True,
'ignoreerrors': True, 'ignoreerrors': True,
'ignore_no_formats_error': True, 'ignore_no_formats_error': True,
'break_on_existing': True,
**config, **config,
} }
logging.info(params)
# 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:

View file

@ -18,6 +18,10 @@
border: 1px solid red; border: 1px solid red;
} }
.is-bordered-info {
border: 1px solid #3e8ed0;
}
html, html,
body { body {
background-color: #eaeaea; background-color: #eaeaea;
@ -63,6 +67,10 @@
border: 1px solid red; border: 1px solid red;
} }
.is-bordered-info {
border: 1px solid #3e8ed0;
}
hr { hr {
background-color: #fff; background-color: #fff;
} }

View file

@ -64,11 +64,11 @@
</button> </button>
</div> </div>
<div class="column is-1"> <div class="column is-1">
<button type="button" class="button is-fullwidth" <button type="button" class="button is-fullwidth" @click="direction = direction === 'desc' ? 'asc' : 'desc'">
@click="direction = direction === 'desc' ? 'asc' : 'desc'">
<span class="icon-text"> <span class="icon-text">
<span class="icon"> <span class="icon">
<font-awesome-icon :icon="direction === 'desc' ? 'fa-solid fa-arrow-down-a-z' :'fa-solid fa-arrow-up-a-z'"/> <font-awesome-icon
:icon="direction === 'desc' ? 'fa-solid fa-arrow-down-a-z' : 'fa-solid fa-arrow-up-a-z'" />
</span> </span>
</span> </span>
</button> </button>
@ -77,7 +77,7 @@
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-6" v-for="item in sortCompleted" :key="item._id"> <div class="column is-6" v-for="item in sortCompleted" :key="item._id">
<div class="card" :class="{ 'is-bordered-danger': hasError(item) }"> <div class="card" :class="{ 'is-bordered-danger': hasError(item), 'is-bordered-info': item.live_in }">
<header class="card-header has-tooltip" :data-tooltip="item.title"> <header class="card-header has-tooltip" :data-tooltip="item.title">
<div class="card-header-title has-text-centered is-text-overflow is-block"> <div class="card-header-title has-text-centered is-text-overflow is-block">
<a v-if="item.filename" referrerpolicy="no-referrer" :href="makeDownload(config, item, 'm3u8')" <a v-if="item.filename" referrerpolicy="no-referrer" :href="makeDownload(config, item, 'm3u8')"
@ -89,7 +89,7 @@
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-12" v-if="item.status === 'error' && item.live_in"> <div class="column is-12" v-if="item.live_in">
<span class="has-text-info"> <span class="has-text-info">
LIVE stream is scheduled to start at {{ moment(item.datetime).format() }} LIVE stream is scheduled to start at {{ moment(item.datetime).format() }}
</span> </span>