updated some type checks

This commit is contained in:
ArabCoders 2024-12-19 22:58:33 +03:00
parent 252970f66f
commit df86cc0001
6 changed files with 333 additions and 232 deletions

View file

@ -15,3 +15,4 @@ trim_trailing_whitespace = false
[*.py] [*.py]
indent_size = 4 indent_size = 4
max_line_length = 120

View file

@ -10,13 +10,13 @@ from .config import Config
from .Download import Download from .Download import Download
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .DataStore import DataStore from .DataStore import DataStore
from .Utils import ag, calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig from .Utils import calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig
from .AsyncPool import AsyncPool from .AsyncPool import AsyncPool
from .Emitter import Emitter from .Emitter import Emitter
LOG = logging.getLogger('DownloadQueue') LOG = logging.getLogger("DownloadQueue")
TYPE_DONE: str = 'done' TYPE_DONE: str = "done"
TYPE_QUEUE: str = 'queue' TYPE_QUEUE: str = "queue"
class DownloadQueue: class DownloadQueue:
@ -41,11 +41,11 @@ class DownloadQueue:
async def initialize(self): async def initialize(self):
self.event = asyncio.Event() self.event = asyncio.Event()
LOG.info(f'Using {self.config.max_workers} workers for downloading.') LOG.info(f"Using {self.config.max_workers} workers for downloading.")
asyncio.create_task( asyncio.create_task(
self.__download_pool() if self.config.max_workers > 1 else self.__download(), self.__download_pool() if self.config.max_workers > 1 else self.__download(),
name='download_pool' if self.config.max_workers > 1 else 'download_worker' name="download_pool" if self.config.max_workers > 1 else "download_worker",
) )
async def __add_entry( async def __add_entry(
@ -54,29 +54,29 @@ class DownloadQueue:
preset: str, preset: str,
folder: str, folder: str,
ytdlp_config: dict = {}, ytdlp_config: dict = {},
ytdlp_cookies: str = '', ytdlp_cookies: str = "",
output_template: str = '', output_template: str = "",
already=None already=None,
): ):
if not entry: if not entry:
return {'status': 'error', 'msg': 'Invalid/empty data was given.'} return {"status": "error", "msg": "Invalid/empty data was given."}
options: dict = {} options: dict = {}
error: str = None error: str = None
live_in: str = None live_in: str = None
eventType = entry.get('_type') or 'video' eventType = entry.get("_type") or "video"
if eventType == 'playlist': if eventType == "playlist":
entries = entry['entries'] entries = entry["entries"]
playlist_index_digits = len(str(len(entries))) playlist_index_digits = len(str(len(entries)))
results = [] results = []
for i, etr in enumerate(entries, start=1): for i, etr in enumerate(entries, start=1):
etr['playlist'] = entry.get('id') etr["playlist"] = entry.get("id")
etr['playlist_index'] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(i) etr["playlist_index"] = "{{0:0{0:d}d}}".format(playlist_index_digits).format(i)
for property in ('id', 'title', 'uploader', 'uploader_id'): for property in ("id", "title", "uploader", "uploader_id"):
if property in entry: if property in entry:
etr[f'playlist_{property}'] = entry.get(property) etr[f"playlist_{property}"] = entry.get(property)
results.append( results.append(
await self.__add_entry( await self.__add_entry(
@ -86,60 +86,63 @@ class DownloadQueue:
ytdlp_config=ytdlp_config, ytdlp_config=ytdlp_config,
ytdlp_cookies=ytdlp_cookies, ytdlp_cookies=ytdlp_cookies,
output_template=output_template, output_template=output_template,
already=already already=already,
) )
) )
if any(res['status'] == 'error' for res in results): if any(res["status"] == "error" for res in results):
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)} return {
"status": "error",
"msg": ", ".join(res["msg"] for res in results if res["status"] == "error" and "msg" in res),
}
return {'status': 'ok'} return {"status": "ok"}
elif (eventType == 'video' or eventType.startswith('url')) and 'id' in entry and 'title' in entry: elif (eventType == "video" or eventType.startswith("url")) and "id" in entry and "title" in entry:
# check if the video is live stream. # check if the video is live stream.
if 'live_status' in entry and entry.get('live_status') == 'is_upcoming': if "live_status" in entry and entry.get("live_status") == "is_upcoming":
if 'release_timestamp' in entry and entry.get('release_timestamp'): if "release_timestamp" in entry and entry.get("release_timestamp"):
live_in = formatdate(entry.get('release_timestamp')) live_in = formatdate(entry.get("release_timestamp"))
else: else:
error = 'Live stream not yet started. And no date is set.' error = "Live stream not yet started. And no date is set."
else: else:
error = entry.get('msg', None) error = entry.get("msg", None)
LOG.debug( LOG.debug(
f"Entry id '{entry.get('id', None)}' url '{entry.get('webpage_url', None)} - {entry.get('url', None)}'." f"Entry id '{entry.get('id', None)}' url '{entry.get('webpage_url', None)} - {entry.get('url', None)}'."
) )
if self.done.exists(key=entry['id'], url=entry.get('webpage_url') or entry.get('url')): if self.done.exists(key=entry["id"], url=entry.get("webpage_url") or entry.get("url")):
item = self.done.get(key=entry['id'], url=entry.get('webpage_url') or entry['url']) item = self.done.get(key=entry["id"], url=entry.get("webpage_url") or entry["url"])
LOG.warning(f"Item '{item.info.id}' - '{item.info.title}' already downloaded. Removing from history.") LOG.warning(f"Item '{item.info.id}' - '{item.info.title}' already downloaded. Removing from history.")
await self.clear([item.info._id]) await self.clear([item.info._id])
try: try:
item = self.queue.get(key=entry.get('id'), url=entry.get('webpage_url') or entry.get('url')) item = self.queue.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url"))
if item is not None: if item is not None:
err_message = f"Item ID '{item.info.id}' - '{item.info.title}' already in download queue." err_message = f"Item ID '{item.info.id}' - '{item.info.title}' already in download queue."
LOG.info(err_message) LOG.info(err_message)
return {'status': 'error', 'msg': err_message} return {"status": "error", "msg": err_message}
except KeyError: except KeyError:
pass pass
is_manifestless = entry.get('is_manifestless', False) is_manifestless = entry.get("is_manifestless", False)
options.update({'is_manifestless': is_manifestless}) options.update({"is_manifestless": is_manifestless})
live_status: list = ['is_live', 'is_upcoming'] live_status: list = ["is_live", "is_upcoming"]
is_live = entry.get('is_live', None) or live_in or entry.get('live_status', None) in live_status is_live = entry.get("is_live", None) or live_in or entry.get("live_status", None) in live_status
try: try:
download_dir = calcDownloadPath(basePath=self.config.download_path, folder=folder) download_dir = calcDownloadPath(basePath=self.config.download_path, folder=folder)
except Exception as e: except Exception as e:
LOG.exception(e) LOG.exception(e)
return {'status': 'error', 'msg': str(e)} return {"status": "error", "msg": str(e)}
dl = ItemDTO( dl = ItemDTO(
id=entry.get('id'), id=entry.get("id"),
title=entry.get('title'), title=entry.get("title"),
url=entry.get('webpage_url') or entry.get('url'), url=entry.get("webpage_url") or entry.get("url"),
preset=preset, preset=preset,
thumbnail=entry.get('thumbnail', None), thumbnail=entry.get("thumbnail", None),
folder=folder, folder=folder,
download_dir=download_dir, download_dir=download_dir,
temp_dir=self.config.temp_path, temp_dir=self.config.temp_path,
@ -160,42 +163,37 @@ class DownloadQueue:
dlInfo: Download = Download(info=dl, info_dict=entry, debug=bool(self.config.ytdl_debug)) dlInfo: Download = Download(info=dl, info_dict=entry, debug=bool(self.config.ytdl_debug))
if dlInfo.info.live_in or 'is_upcoming' == entry.get('live_status', None): if dlInfo.info.live_in or "is_upcoming" == entry.get("live_status", None):
dlInfo.info.status = 'not_live' dlInfo.info.status = "not_live"
itemDownload = self.done.put(dlInfo) itemDownload = self.done.put(dlInfo)
NotifyEvent = 'completed' NotifyEvent = "completed"
elif self.config.allow_manifestless is False and is_manifestless is True: elif self.config.allow_manifestless is False and is_manifestless is True:
dlInfo.info.status = 'error' dlInfo.info.status = "error"
dlInfo.info.error = 'Video is in post-live manifestless mode.' dlInfo.info.error = "Video is in post-live manifestless mode."
itemDownload = self.done.put(dlInfo) itemDownload = self.done.put(dlInfo)
NotifyEvent = 'completed' NotifyEvent = "completed"
else: else:
NotifyEvent = 'added' NotifyEvent = "added"
itemDownload = self.queue.put(dlInfo) itemDownload = self.queue.put(dlInfo)
self.event.set() self.event.set()
asyncio.create_task( asyncio.create_task(
self.emitter.emit(NotifyEvent, itemDownload.info), self.emitter.emit(NotifyEvent, itemDownload.info), name=f"notifier-{NotifyEvent}-{itemDownload.info.id}"
name=f'notifier-{NotifyEvent}-{itemDownload.info.id}') )
return { return {"status": "ok"}
'status': 'ok' elif eventType.startswith("url"):
}
elif eventType.startswith('url'):
return await self.add( return await self.add(
url=entry.get('url'), url=entry.get("url"),
preset=preset, preset=preset,
folder=folder, folder=folder,
ytdlp_config=ytdlp_config, ytdlp_config=ytdlp_config,
ytdlp_cookies=ytdlp_cookies, ytdlp_cookies=ytdlp_cookies,
output_template=output_template, output_template=output_template,
already=already already=already,
) )
return { return {"status": "error", "msg": f'Unsupported resource "{eventType}"'}
'status': 'error',
'msg': f'Unsupported resource "{eventType}"'
}
async def add( async def add(
self, self,
@ -203,26 +201,28 @@ class DownloadQueue:
preset: str, preset: str,
folder: str, folder: str,
ytdlp_config: dict = {}, ytdlp_config: dict = {},
ytdlp_cookies: str = '', ytdlp_cookies: str = "",
output_template: str = '', output_template: str = "",
already=None already=None,
): ):
ytdlp_config = ytdlp_config if ytdlp_config else {} ytdlp_config = ytdlp_config if ytdlp_config else {}
LOG.info(f"Adding url '{url}' to folder '{folder}' with the following options 'Preset: {preset}' 'Naming: {output_template}', 'Cookies: {ytdlp_cookies}' 'YTConfig: {ytdlp_config}'.") LOG.info(
f"Adding url '{url}' to folder '{folder}' with the following options 'Preset: {preset}' 'Naming: {output_template}', 'Cookies: {ytdlp_cookies}' 'YTConfig: {ytdlp_config}'."
)
if isinstance(ytdlp_config, str): if isinstance(ytdlp_config, str):
try: try:
ytdlp_config = json.loads(ytdlp_config) ytdlp_config = json.loads(ytdlp_config)
except Exception as e: except Exception as e:
LOG.error(f"Unable to load '{ytdlp_config=}'. {str(e)}") LOG.error(f"Unable to load '{ytdlp_config=}'. {str(e)}")
return {'status': 'error', 'msg': f"Failed to parse json yt-dlp config. {str(e)}"} return {"status": "error", "msg": f"Failed to parse json yt-dlp config. {str(e)}"}
already = set() if already is None else already already = set() if already is None else already
if url in already: if url in already:
LOG.warning(f"Recursion detected with url '{url}' skipping.") LOG.warning(f"Recursion detected with url '{url}' skipping.")
return {'status': 'ok'} return {"status": "ok"}
already.add(url) already.add(url)
@ -231,10 +231,10 @@ class DownloadQueue:
if downloaded is True: if downloaded is True:
message = f"This url with ID '{id_dict.get('id')}' has been downloaded already and recorded in archive." message = f"This url with ID '{id_dict.get('id')}' has been downloaded already and recorded in archive."
LOG.info(message) LOG.info(message)
return {'status': 'error', 'msg': message} return {"status": "error", "msg": message}
started = time.perf_counter() started = time.perf_counter()
LOG.debug(f'extract_info: checking {url=}') LOG.debug(f"extract_info: checking {url=}")
entry = await asyncio.wait_for( entry = await asyncio.wait_for(
fut=asyncio.get_running_loop().run_in_executor( fut=asyncio.get_running_loop().run_in_executor(
@ -242,24 +242,29 @@ class DownloadQueue:
ExtractInfo, ExtractInfo,
mergeConfig(self.config.ytdl_options, ytdlp_config), mergeConfig(self.config.ytdl_options, ytdlp_config),
url, url,
bool(self.config.ytdl_debug) bool(self.config.ytdl_debug),
), ),
timeout=self.config.extract_info_timeout) timeout=self.config.extract_info_timeout,
)
if not entry: if not entry:
return {'status': 'error', 'msg': 'Unable to extract info check logs.'} return {"status": "error", "msg": "Unable to extract info check logs."}
LOG.debug( LOG.debug(
f"extract_info: for 'URL: {url}' is done in '{time.perf_counter() - started}'. Length: '{len(entry)}'.") f"extract_info: for 'URL: {url}' is done in '{time.perf_counter() - started}'. Length: '{len(entry)}'."
)
except yt_dlp.utils.ExistingVideoReached as exc: except yt_dlp.utils.ExistingVideoReached as exc:
LOG.error(f'Video has been downloaded already and recorded in archive.log file. {str(exc)}') LOG.error(f"Video has been downloaded already and recorded in archive.log file. {str(exc)}")
return {'status': 'error', 'msg': 'Video has been downloaded already and recorded in archive.log file.'} 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:
LOG.error(f'YoutubeDLError: Unable to extract info. {str(exc)}') LOG.error(f"YoutubeDLError: Unable to extract info. {str(exc)}")
return {'status': 'error', 'msg': str(exc)} return {"status": "error", "msg": str(exc)}
except asyncio.exceptions.TimeoutError as exc: except asyncio.exceptions.TimeoutError as exc:
LOG.error(f'TimeoutError: Unable to extract info. {str(exc)}') LOG.error(f"TimeoutError: Unable to extract info. {str(exc)}")
return {'status': 'error', 'msg': f'TimeoutError: {self.config.extract_info_timeout}s reached Unable to extract info.'} return {
"status": "error",
"msg": f"TimeoutError: {self.config.extract_info_timeout}s reached Unable to extract info.",
}
return await self.__add_entry( return await self.__add_entry(
entry=entry, entry=entry,
@ -279,27 +284,27 @@ class DownloadQueue:
item = self.queue.get(key=id) item = self.queue.get(key=id)
except KeyError as e: except KeyError as e:
status[id] = str(e) status[id] = str(e)
status['status'] = 'error' status["status"] = "error"
LOG.warning(f'Requested cancel for non-existent download {id=}. {str(e)}') LOG.warning(f"Requested cancel for non-existent download {id=}. {str(e)}")
continue continue
itemMessage = f"{id=} {item.info.id=} {item.info.title=}" itemMessage = f"{id=} {item.info.id=} {item.info.title=}"
if item.running(): if item.running():
LOG.debug(f'Canceling {itemMessage}') LOG.debug(f"Canceling {itemMessage}")
item.cancel() item.cancel()
LOG.info(f'Cancelled {itemMessage}') LOG.info(f"Cancelled {itemMessage}")
await item.close() await item.close()
else: else:
await item.close() await item.close()
LOG.debug(f'Deleting from queue {itemMessage}') LOG.debug(f"Deleting from queue {itemMessage}")
self.queue.delete(id) self.queue.delete(id)
asyncio.create_task(self.emitter.canceled(id=id, dl=item), name=f'notifier-c-{id}') asyncio.create_task(self.emitter.canceled(id=id, dl=item), name=f"notifier-c-{id}")
self.done.put(item) self.done.put(item)
asyncio.create_task(self.emitter.completed(dl=item), name=f'notifier-d-{id}') asyncio.create_task(self.emitter.completed(dl=item), name=f"notifier-d-{id}")
LOG.info(f'Deleted from queue {itemMessage}') LOG.info(f"Deleted from queue {itemMessage}")
status[id] = 'ok' status[id] = "ok"
return status return status
@ -311,35 +316,35 @@ class DownloadQueue:
item = self.done.get(key=id) item = self.done.get(key=id)
except KeyError as e: except KeyError as e:
status[id] = str(e) status[id] = str(e)
status['status'] = 'error' status["status"] = "error"
LOG.warning(f'Requested delete for non-existent download {id=}. {str(e)}') LOG.warning(f"Requested delete for non-existent download {id=}. {str(e)}")
continue continue
itemMessage = f"{id=} {item.info.id=} {item.info.title=}" itemMessage = f"{id=} {item.info.id=} {item.info.title=}"
LOG.debug(f'Deleting completed download {itemMessage}') LOG.debug(f"Deleting completed download {itemMessage}")
self.done.delete(id) self.done.delete(id)
asyncio.create_task(self.emitter.cleared(id, dl=item), name=f'notifier-c-{id}') asyncio.create_task(self.emitter.cleared(id, dl=item), name=f"notifier-c-{id}")
LOG.info(f'Deleted completed download {itemMessage}') LOG.info(f"Deleted completed download {itemMessage}")
status[id] = 'ok' status[id] = "ok"
return status return status
def get(self) -> dict[str, list[dict[str, ItemDTO]]]: def get(self) -> dict[str, list[dict[str, ItemDTO]]]:
items = {'queue': {}, 'done': {}} items = {"queue": {}, "done": {}}
for k, v in self.queue.saved_items(): for k, v in self.queue.saved_items():
items['queue'][k] = v items["queue"][k] = v
for k, v in self.done.saved_items(): for k, v in self.done.saved_items():
items['done'][k] = v items["done"][k] = v
for k, v in self.queue.items(): for k, v in self.queue.items():
if not k in items['queue']: if k not in items["queue"]:
items['queue'][k] = v.info items["queue"][k] = v.info
for k, v in self.done.items(): for k, v in self.done.items():
if not k in items['done']: if k not in items["done"]:
items['done'][k] = v.info items["done"][k] = v.info
return items return items
@ -348,8 +353,8 @@ 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='download_pool', name="download_pool",
logger=logging.getLogger('WorkerPool'), logger=logging.getLogger("WorkerPool"),
) )
self.pool.start() self.pool.start()
@ -362,14 +367,14 @@ class DownloadQueue:
break break
if time.time() - lastLog > 120: if time.time() - lastLog > 120:
lastLog = time.time() lastLog = time.time()
LOG.info(f'Waiting for worker to be free. {self.pool.get_workers_status()}') LOG.info(f"Waiting for worker to be free. {self.pool.get_workers_status()}")
await asyncio.sleep(1) await asyncio.sleep(1)
while not self.queue.hasDownloads(): while not self.queue.hasDownloads():
LOG.info(f"Waiting for item to download. '{self.pool.get_available_workers()}' free workers.") LOG.info(f"Waiting for item to download. '{self.pool.get_available_workers()}' free workers.")
await self.event.wait() await self.event.wait()
self.event.clear() self.event.clear()
LOG.debug(f"Cleared wait event.") LOG.debug("Cleared wait event.")
entry = self.queue.getNextDownload() entry = self.queue.getNextDownload()
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
@ -387,7 +392,7 @@ class DownloadQueue:
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()
@ -396,34 +401,34 @@ class DownloadQueue:
async def __downloadFile(self, id: str, entry: Download): async def __downloadFile(self, id: str, entry: Download):
LOG.info( LOG.info(
f"Downloading 'id: {id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' to 'folder: {entry.info.folder}'.") f"Downloading 'id: {id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' to 'folder: {entry.info.folder}'."
)
try: try:
await entry.start(self.emitter) await entry.start(self.emitter)
if entry.info.status != 'finished': if entry.info.status != "finished":
if entry.tmpfilename and os.path.isfile(entry.tmpfilename): if entry.tmpfilename and os.path.isfile(entry.tmpfilename):
try: try:
os.remove(entry.tmpfilename) os.remove(entry.tmpfilename)
entry.tmpfilename = None entry.tmpfilename = None
except: except Exception:
pass pass
entry.info.status = 'error' entry.info.status = "error"
finally: finally:
await entry.close() await entry.close()
if self.queue.exists(key=id): if self.queue.exists(key=id):
self.queue.delete(key=id) self.queue.delete(key=id)
if entry.is_canceled() is True: if entry.is_canceled() is True:
asyncio.create_task(self.emitter.canceled(id, dl=entry.info), name=f'notifier-c-{id}') asyncio.create_task(self.emitter.canceled(id, dl=entry.info), name=f"notifier-c-{id}")
entry.info.status = 'canceled' entry.info.status = "canceled"
entry.info.error = 'Canceled by user.' entry.info.error = "Canceled by user."
self.done.put(value=entry) self.done.put(value=entry)
asyncio.create_task(self.emitter.completed(dl=entry.info), name=f'notifier-d-{id}') asyncio.create_task(self.emitter.completed(dl=entry.info), name=f"notifier-d-{id}")
self.event.set() self.event.set()
@ -431,4 +436,4 @@ class DownloadQueue:
if not url or not self.config.keep_archive: if not url or not self.config.keep_archive:
return False, None return False, None
return isDownloaded(self.config.ytdl_options.get('download_archive', None), url) return isDownloaded(self.config.ytdl_options.get("download_archive", None), url)

View file

@ -448,7 +448,7 @@ class HttpAPI(common):
'Access-Control-Max-Age': "300", 'Access-Control-Max-Age': "300",
}) })
@route('GET', 'player/segments/{segment:\d+}/{file:.*}.ts') @route('GET', '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')

View file

@ -13,31 +13,31 @@ from pathlib import Path
class Config: class Config:
__instance = None __instance = None
config_path: str = '.' config_path: str = "."
download_path: str = '.' download_path: str = "."
temp_path: str = '/tmp' temp_path: str = "/tmp"
temp_keep: bool = False temp_keep: bool = False
db_file: str = '{config_path}/ytptube.db' db_file: str = "{config_path}/ytptube.db"
manual_archive: str = '{config_path}/archive.manual.log' manual_archive: str = "{config_path}/archive.manual.log"
url_host: str = '' url_host: str = ""
url_prefix: str = '' url_prefix: str = ""
url_socketio: str = '{url_prefix}socket.io' url_socketio: str = "{url_prefix}socket.io"
output_template: str = '%(title)s.%(ext)s' output_template: str = "%(title)s.%(ext)s"
output_template_chapter: str = '%(title)s - %(section_number)s %(section_title)s.%(ext)s' output_template_chapter: str = "%(title)s - %(section_number)s %(section_title)s.%(ext)s"
ytdl_options: dict | str = {} ytdl_options: dict | str = {}
ytdl_debug: bool = False ytdl_debug: bool = False
host: str = '0.0.0.0' host: str = "0.0.0.0"
port: int = 8081 port: int = 8081
keep_archive: bool = True keep_archive: bool = True
base_path: str = '' base_path: str = ""
log_level: str = 'info' log_level: str = "info"
allow_manifestless: bool = False allow_manifestless: bool = False
@ -57,8 +57,8 @@ class Config:
started: int = 0 started: int = 0
streamer_vcodec = 'libx264' streamer_vcodec = "libx264"
streamer_acodec = 'aac' streamer_acodec = "aac"
auth_username: str = None auth_username: str = None
auth_password: str = None auth_password: str = None
@ -66,64 +66,87 @@ class Config:
ytdlp_version: str = YTDLP_VERSION ytdlp_version: str = YTDLP_VERSION
tasks: list = [] tasks: list = []
presets: list = [ presets: list = [
{'name': 'default', 'format': 'default', 'postprocessors': [], {"name": "default", "format": "default", "postprocessors": [], "args": {}},
'args': {}},
{'name': 'Video - 1080p h264/acc', 'format': 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]',
'args': {'format_sort': ['vcodec:h264'], }, },
{'name': 'Video - 720p h264/acc', 'format': 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]',
'args': {'format_sort': ['vcodec:h264'], }, },
{ {
'name': 'Audio - Best audio [MP3]', "name": "Video - 1080p h264/acc",
'format': 'bestaudio/best', "format": "bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
'postprocessors': [ "args": {
"format_sort": ["vcodec:h264"],
},
},
{
"name": "Video - 720p h264/acc",
"format": "bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
"args": {
"format_sort": ["vcodec:h264"],
},
},
{
"name": "Audio - Best audio [MP3]",
"format": "bestaudio/best",
"postprocessors": [
{ {
"key": "FFmpegExtractAudio", "key": "FFmpegExtractAudio",
"preferredcodec": "mp3", "preferredcodec": "mp3",
"preferredquality": "5", "preferredquality": "5",
"nopostoverwrites": False "nopostoverwrites": False,
}, },
{ {"key": "FFmpegMetadata", "add_chapters": True, "add_metadata": True, "add_infojson": "if_exists"},
"key": "FFmpegMetadata", {"key": "EmbedThumbnail", "already_have_thumbnail": False},
"add_chapters": True, {"key": "FFmpegConcat", "only_multi_video": True, "when": "playlist"},
"add_metadata": True,
"add_infojson": "if_exists"
},
{
"key": "EmbedThumbnail",
"already_have_thumbnail": False
},
{
"key": "FFmpegConcat",
"only_multi_video": True,
"when": "playlist"
}
], ],
'args': { "args": {
"outtmpl": { "outtmpl": {"pl_thumbnail": ""},
"pl_thumbnail": ""
},
"ignoreerrors": "only_download", "ignoreerrors": "only_download",
"retries": 10, "retries": 10,
"fragment_retries": 10, "fragment_retries": 10,
"writethumbnail": True, "writethumbnail": True,
"extract_flat": "discard_in_playlist", "extract_flat": "discard_in_playlist",
"final_ext": "mp3", "final_ext": "mp3",
} },
} },
] ]
_manual_vars: tuple = ('temp_path', 'config_path', 'download_path',) _manual_vars: tuple = (
"temp_path",
"config_path",
"download_path",
)
_immutable: tuple = ( _immutable: tuple = (
'version', '__instance', 'ytdl_options', 'tasks', "version",
'new_version_available', 'ytdlp_version', 'started', 'presets', "__instance",
"ytdl_options",
"tasks",
"new_version_available",
"ytdlp_version",
"started",
"presets",
) )
_int_vars: tuple = ('port', 'max_workers', 'socket_timeout', 'extract_info_timeout', 'debugpy_port',) _int_vars: tuple = (
_boolean_vars: tuple = ('keep_archive', 'ytdl_debug', 'debug', 'temp_keep', 'allow_manifestless',) "port",
"max_workers",
"socket_timeout",
"extract_info_timeout",
"debugpy_port",
)
_boolean_vars: tuple = (
"keep_archive",
"ytdl_debug",
"debug",
"temp_keep",
"allow_manifestless",
)
_frontend_vars: tuple = ( _frontend_vars: tuple = (
'download_path', 'keep_archive', 'output_template', "download_path",
'ytdlp_version', 'version', 'url_host', 'started', 'url_prefix', "keep_archive",
"output_template",
"ytdlp_version",
"version",
"url_host",
"started",
"url_prefix",
) )
@staticmethod @staticmethod
@ -140,20 +163,20 @@ class Config:
baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute()) baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute())
self.temp_path = os.environ.get('YTP_TEMP_PATH', None) or os.path.join(baseDefaultPath, 'var', 'tmp') self.temp_path = os.environ.get("YTP_TEMP_PATH", None) or os.path.join(baseDefaultPath, "var", "tmp")
self.config_path = os.environ.get('YTP_CONFIG_PATH', None) or os.path.join(baseDefaultPath, 'var', 'config') self.config_path = os.environ.get("YTP_CONFIG_PATH", None) or os.path.join(baseDefaultPath, "var", "config")
self.download_path = os.environ.get( self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or os.path.join(
'YTP_DOWNLOAD_PATH', None) or os.path.join( baseDefaultPath, "var", "downloads"
baseDefaultPath, 'var', 'downloads') )
envFile: str = os.path.join(self.config_path, '.env') envFile: str = os.path.join(self.config_path, ".env")
if os.path.exists(envFile): if os.path.exists(envFile):
logging.info(f"Loading environment variables from '{envFile}'.") logging.info(f"Loading environment variables from '{envFile}'.")
load_dotenv(envFile) load_dotenv(envFile)
for k, v in self._getAttributes().items(): for k, v in self._getAttributes().items():
if k.startswith('_') or k in self._manual_vars: if k.startswith("_") or k in self._manual_vars:
continue continue
# If the variable declared as immutable, set it to the default value. # If the variable declared as immutable, set it to the default value.
@ -161,15 +184,15 @@ class Config:
setattr(self, k, v) setattr(self, k, v)
continue continue
lookUpKey: str = f'YTP_{k}'.upper() lookUpKey: str = f"YTP_{k}".upper()
setattr(self, k, os.environ[lookUpKey] if lookUpKey in os.environ else v) setattr(self, k, os.environ[lookUpKey] if lookUpKey in os.environ else v)
for k, v in self.__dict__.items(): for k, v in self.__dict__.items():
if k.startswith('_') or k in self._immutable or k in self._manual_vars: if k.startswith("_") or k in self._immutable or k in self._manual_vars:
continue continue
if isinstance(v, str) and '{' in v and '}' in v: if isinstance(v, str) and "{" in v and "}" in v:
for key in re.findall(r'\{.*?\}', v): for key in re.findall(r"\{.*?\}", v):
localKey: str = key[1:-1] localKey: str = key[1:-1]
if localKey not in self.__dict__: if localKey not in self.__dict__:
logging.error(f"Config variable '{k}' had non-existing config reference '{key}'.") logging.error(f"Config variable '{k}' had non-existing config reference '{key}'.")
@ -180,32 +203,31 @@ class Config:
setattr(self, k, v) setattr(self, k, v)
if k in self._boolean_vars: if k in self._boolean_vars:
if str(v).lower() not in (True, False, 'true', 'false', 'on', 'off', '1', '0'): if str(v).lower() not in (True, False, "true", "false", "on", "off", "1", "0"):
raise ValueError(f"Config variable '{k}' is set to a non-boolean value '{v}'.") raise ValueError(f"Config variable '{k}' is set to a non-boolean value '{v}'.")
setattr(self, k, str(v).lower() in (True, 'true', 'on', '1')) setattr(self, k, str(v).lower() in (True, "true", "on", "1"))
if k in self._int_vars: if k in self._int_vars:
setattr(self, k, int(v)) setattr(self, k, int(v))
if not self.url_prefix.endswith('/'): if not self.url_prefix.endswith("/"):
self.url_prefix += '/' self.url_prefix += "/"
numeric_level = getattr(logging, self.log_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.log_level}' specified.") raise ValueError(f"Invalid log level '{self.log_level}' specified.")
coloredlogs.install( coloredlogs.install(
level=numeric_level, level=numeric_level, fmt="%(asctime)s [%(name)s] [%(levelname)-5.5s] %(message)s", datefmt="%H:%M:%S"
fmt="%(asctime)s [%(name)s] [%(levelname)-5.5s] %(message)s",
datefmt='%H:%M:%S'
) )
LOG = logging.getLogger('config') LOG = logging.getLogger("config")
if self.debug: if self.debug:
try: try:
import debugpy import debugpy
debugpy.listen(("0.0.0.0", self.debugpy_port)) debugpy.listen(("0.0.0.0", self.debugpy_port))
LOG.info(f"starting debugpy server on '0.0.0.0:{self.debugpy_port}'.") LOG.info(f"starting debugpy server on '0.0.0.0:{self.debugpy_port}'.")
except ImportError: except ImportError:
@ -213,7 +235,7 @@ class Config:
except Exception as e: except Exception as e:
LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}") LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}")
optsFile: str = os.path.join(self.config_path, 'ytdlp.json') optsFile: str = os.path.join(self.config_path, "ytdlp.json")
if os.path.exists(optsFile) and os.path.getsize(optsFile) > 4: if os.path.exists(optsFile) and os.path.getsize(optsFile) > 4:
LOG.info(f"Loading yt-dlp custom options from '{optsFile}'.") LOG.info(f"Loading yt-dlp custom options from '{optsFile}'.")
@ -226,7 +248,7 @@ class Config:
else: else:
LOG.info(f"No yt-dlp custom options found at '{optsFile}'.") LOG.info(f"No yt-dlp custom options found at '{optsFile}'.")
tasksFile = os.path.join(self.config_path, 'tasks.json') tasksFile = os.path.join(self.config_path, "tasks.json")
if os.path.exists(tasksFile) and os.path.getsize(tasksFile) > 0: if os.path.exists(tasksFile) and os.path.getsize(tasksFile) > 0:
LOG.info(f"Loading tasks from '{tasksFile}'.") LOG.info(f"Loading tasks from '{tasksFile}'.")
try: try:
@ -235,10 +257,10 @@ class Config:
LOG.error(f"Could not load tasks file from '{tasksFile}'. '{error}'.") LOG.error(f"Could not load tasks file from '{tasksFile}'. '{error}'.")
sys.exit(1) sys.exit(1)
self.tasks.extend(tasks) self.tasks.extend(tasks)
except Exception as e: except Exception:
pass pass
presetsFile = os.path.join(self.config_path, 'presets.json') presetsFile = os.path.join(self.config_path, "presets.json")
if os.path.exists(presetsFile) and os.path.getsize(presetsFile) > 0: if os.path.exists(presetsFile) and os.path.getsize(presetsFile) > 0:
LOG.info(f"Loading extra presets from '{presetsFile}'.") LOG.info(f"Loading extra presets from '{presetsFile}'.")
try: try:
@ -248,32 +270,32 @@ class Config:
sys.exit(1) sys.exit(1)
for preset in presets: for preset in presets:
if 'name' not in preset: if "name" not in preset:
LOG.error(f"Missing 'name' key in preset '{preset}'.") LOG.error(f"Missing 'name' key in preset '{preset}'.")
continue continue
if 'format' not in preset: if "format" not in preset:
LOG.error(f"Missing 'format' key in preset '{preset}'.") LOG.error(f"Missing 'format' key in preset '{preset}'.")
continue continue
if 'args' not in preset: if "args" not in preset:
preset['args'] = {} preset["args"] = {}
if 'postprocessors' not in preset: if "postprocessors" not in preset:
preset['postprocessors'] = [] preset["postprocessors"] = []
self.presets.append(preset) self.presets.append(preset)
except Exception as e: except Exception:
pass pass
self.ytdl_options['socket_timeout'] = self.socket_timeout self.ytdl_options["socket_timeout"] = self.socket_timeout
if self.keep_archive: if self.keep_archive:
LOG.info(f"keep archive option is enabled.") LOG.info("keep archive option is enabled.")
self.ytdl_options['download_archive'] = os.path.join(self.config_path, 'archive.log') self.ytdl_options["download_archive"] = os.path.join(self.config_path, "archive.log")
if self.temp_keep: if self.temp_keep:
LOG.info(f'Keep temp files option is enabled.') LOG.info("Keep temp files option is enabled.")
if self.auth_password and self.auth_username: if self.auth_password and self.auth_username:
LOG.warning(f"Basic authentication enabled with username '{self.auth_username}'.") LOG.warning(f"Basic authentication enabled with username '{self.auth_username}'.")
@ -285,7 +307,7 @@ class Config:
vClass: str = self.__class__ vClass: str = self.__class__
for attribute in vClass.__dict__.keys(): for attribute in vClass.__dict__.keys():
if attribute.startswith('_'): if attribute.startswith("_"):
continue continue
value = getattr(vClass, attribute) value = getattr(vClass, attribute)

View file

@ -1,33 +1,34 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio import asyncio
import logging
import os import os
import random import random
import logging
import caribou
import sqlite3 import sqlite3
import magic
from datetime import datetime from datetime import datetime
from aiohttp import web
from pathlib import Path from pathlib import Path
from library.Emitter import Emitter
from library.config import Config import caribou
from library.encoder import Encoder import magic
from library.DownloadQueue import DownloadQueue
from library.Webhooks import Webhooks
from library.HttpSocket import HttpSocket
from library.HttpAPI import HttpAPI
from aiocron import crontab from aiocron import crontab
from aiohttp import web
from library.config import Config
from library.DownloadQueue import DownloadQueue
from library.Emitter import Emitter
from library.encoder import Encoder
from library.HttpAPI import HttpAPI
from library.HttpSocket import HttpSocket
from library.Webhooks import Webhooks
LOG = logging.getLogger("app") LOG = logging.getLogger("app")
MIME = magic.Magic(mime=True) MIME = magic.Magic(mime=True)
class main: class Main:
config: Config = None config: Config
app: web.Application = None app: web.Application
http: HttpAPI = None http: HttpAPI
socket: HttpSocket = None socket: HttpSocket
def __init__(self): def __init__(self):
self.config = Config.get_instance() self.config = Config.get_instance()
@ -61,9 +62,7 @@ class main:
LOG.info(f"Creating download folder at '{self.config.download_path}'.") LOG.info(f"Creating download folder at '{self.config.download_path}'.")
os.makedirs(self.config.download_path, exist_ok=True) os.makedirs(self.config.download_path, exist_ok=True)
except OSError as e: except OSError as e:
LOG.error( LOG.error(f"Could not create download folder at '{self.config.download_path}'.")
f"Could not create download folder at '{self.config.download_path}'."
)
raise e raise e
try: try:
@ -111,9 +110,7 @@ class main:
LOG.info(f"Completed 'Task: {taskName}' at '{timeNow}'.") LOG.info(f"Completed 'Task: {taskName}' at '{timeNow}'.")
except Exception as e: except Exception as e:
timeNow = datetime.now().strftime("%Y-%m-%d %H:%M:%S") timeNow = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
LOG.error( LOG.error(f"Failed 'Task: {taskName}' at '{timeNow}'. Error message '{str(e)}'.")
f"Failed 'Task: {taskName}' at '{timeNow}'. Error message '{str(e)}'."
)
def load_tasks(self): def load_tasks(self):
for task in self.config.tasks: for task in self.config.tasks:
@ -131,9 +128,7 @@ class main:
loop=asyncio.get_event_loop(), loop=asyncio.get_event_loop(),
) )
LOG.info( LOG.info(f"Added 'Task: {task.get('name', task.get('url'))}' to be executed every '{cron_timer}'.")
f"Added 'Task: {task.get('name', task.get('url'))}' to be executed every '{cron_timer}'."
)
def start(self): def start(self):
self.socket.attach(self.app) self.socket.attach(self.app)
@ -155,4 +150,4 @@ class main:
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
main().start() Main().start()

View file

@ -1,2 +1,80 @@
[tool.ruff]
# Exclude a variety of commonly ignored directories.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
]
# Same as Black.
line-length = 120
indent-width = 4
# Assume Python 3.11
target-version = "py311"
[tool.ruff.lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
# McCabe complexity (`C901`) by default.
select = ["E4", "E7", "E9", "F", "G", "W"]
ignore = ["PTH", "G0", "G1", "G201"]
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.format]
# Like Black, use double quotes for strings.
quote-style = "double"
# Like Black, indent with spaces, rather than tabs.
indent-style = "space"
# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"
# Enable auto-formatting of code examples in docstrings. Markdown,
# reStructuredText code/literal blocks and doctests are all supported.
#
# This is currently disabled by default, but it is planned for this
# to be opt-out in the future.
docstring-code-format = false
# Set the line length limit used when formatting code snippets in
# docstrings.
#
# This only has an effect when the `docstring-code-format` setting is
# enabled.
docstring-code-line-length = "dynamic"
[tool.autopep8] [tool.autopep8]
--max-line-length = 120 --max-line-length = 120