some minor app changes =)

This commit is contained in:
ArabCoders 2024-12-15 21:28:37 +03:00
parent f7f5e72aeb
commit 51024c2a9b
45 changed files with 1043 additions and 38187 deletions

View file

@ -4,9 +4,9 @@ from datetime import datetime, timezone
from email.utils import formatdate
import json
from sqlite3 import Connection
from Config import Config
from Download import Download
from ItemDTO import ItemDTO
from .config import Config
from .Download import Download
from .ItemDTO import ItemDTO
class DataStore:

View file

@ -7,10 +7,11 @@ import re
import shutil
import yt_dlp
import hashlib
from AsyncPool import Terminator
from Utils import Notifier, get_format, get_opts, jsonCookie, mergeConfig
from ItemDTO import ItemDTO
from Config import Config
from .AsyncPool import Terminator
from .Utils import get_format, get_opts, jsonCookie, mergeConfig
from .ItemDTO import ItemDTO
from .config import Config
from .Emitter import Emitter
from multiprocessing.managers import SyncManager
LOG = logging.getLogger('download')
@ -31,7 +32,7 @@ class Download:
default_ytdl_opts: dict = None
debug: bool = False
tempPath: str = None
notifier: Notifier = None
emitter: Emitter = None
manager: SyncManager = None
canceled: bool = False
is_live: bool = False
@ -79,7 +80,7 @@ class Download:
self.tmpfilename = None
self.status_queue = None
self.proc = None
self.notifier = None
self.emitter = None
self.max_workers = int(config.max_workers)
self.tempKeep = bool(config.temp_keep)
self.is_live = bool(info.is_live) or info.live_in is not None
@ -183,9 +184,9 @@ class Download:
LOG.info(f'Finished {os.getpid()=} id="{self.info.id}" title="{self.info.title}".')
async def start(self, notifier: Notifier):
async def start(self, emitter: Emitter):
self.manager = multiprocessing.Manager()
self.notifier = notifier
self.emitter = emitter
self.status_queue = self.manager.Queue()
# Create temp dir for each download.
@ -201,7 +202,7 @@ class Download:
self.proc.start()
self.info.status = 'preparing'
asyncio.create_task(self.notifier.updated(dl=self.info), name=f"notifier-{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}")
return await asyncio.get_running_loop().run_in_executor(None, self.proc.join)
@ -329,7 +330,7 @@ class Download:
LOG.debug(f'Status Update: {self.info._id=} {status=}')
if isinstance(status, str):
asyncio.create_task(self.notifier.updated(dl=self.info), name=f"notifier-u-{self.id}")
asyncio.create_task(self.emitter.updated(dl=self.info), name=f"emitter-u-{self.id}")
continue
self.tmpfilename = status.get('tmpfilename')
@ -350,8 +351,8 @@ class Download:
if self.info.status == 'error' and 'error' in status:
self.info.error = status.get('error')
asyncio.create_task(
self.notifier.error(dl=self.info, message=self.info.error),
name=f"notifier-e-{self.id}")
self.emitter.error(dl=self.info, message=self.info.error),
name=f"emitter-e-{self.id}")
if 'downloaded_bytes' in status:
total = status.get('total_bytes') or status.get('total_bytes_estimate')
@ -370,4 +371,4 @@ class Download:
self.info.file_size = None
pass
asyncio.create_task(self.notifier.updated(dl=self.info), name=f"notifier-u-{self.id}")
asyncio.create_task(self.emitter.updated(dl=self.info), name=f"emitter-u-{self.id}")

View file

@ -6,12 +6,13 @@ import os
import time
import yt_dlp
from sqlite3 import Connection
from Config import Config
from Download import Download
from ItemDTO import ItemDTO
from DataStore import DataStore
from Utils import Notifier, ObjectSerializer, calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig
from AsyncPool import AsyncPool
from .config import Config
from .Download import Download
from .ItemDTO import ItemDTO
from .DataStore import DataStore
from .Utils import calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig
from .AsyncPool import AsyncPool
from .Emitter import Emitter
LOG = logging.getLogger('DownloadQueue')
TYPE_DONE: str = 'done'
@ -20,22 +21,26 @@ TYPE_QUEUE: str = 'queue'
class DownloadQueue:
config: Config = None
notifier: Notifier = None
serializer: ObjectSerializer = None
emitter: Emitter = None
queue: DataStore = None
done: DataStore = None
event: asyncio.Event = None
pool: AsyncPool = None
connection: Connection = None
def __init__(self, notifier: Notifier, connection: Connection, serializer: ObjectSerializer):
def __init__(self, emitter: Emitter, connection: Connection):
self.config = Config.get_instance()
self.notifier = notifier
self.serializer = serializer
self.done = DataStore(type=TYPE_DONE, connection=connection)
self.queue = DataStore(type=TYPE_QUEUE, connection=connection)
self.emitter = emitter
self.connection = connection
self.done = DataStore(type=TYPE_DONE, connection=self.connection)
self.queue = DataStore(type=TYPE_QUEUE, connection=self.connection)
self.done.load()
self.queue.load()
async def test(self) -> bool:
self.connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone()
return True
async def initialize(self):
self.event = asyncio.Event()
LOG.info(f'Using {self.config.max_workers} workers for downloading.')
@ -174,7 +179,7 @@ class DownloadQueue:
self.event.set()
asyncio.create_task(
self.notifier.emit(NotifyEvent, itemDownload.info),
self.emitter.emit(NotifyEvent, itemDownload.info),
name=f'notifier-{NotifyEvent}-{itemDownload.info.id}')
return {
@ -294,9 +299,9 @@ class DownloadQueue:
await item.close()
LOG.debug(f'Deleting from queue {itemMessage}')
self.queue.delete(id)
asyncio.create_task(self.notifier.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)
asyncio.create_task(self.notifier.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}')
status[id] = 'ok'
@ -318,7 +323,7 @@ class DownloadQueue:
itemMessage = f"{id=} {item.info.id=} {item.info.title=}"
LOG.debug(f'Deleting completed download {itemMessage}')
self.done.delete(id)
asyncio.create_task(self.notifier.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}')
status[id] = 'ok'
@ -398,7 +403,7 @@ class DownloadQueue:
LOG.info(f'Queuing {id=} - {entry.info.title=} - {entry.info.url=} - {entry.info.folder=}.')
try:
await entry.start(self.notifier)
await entry.start(self.emitter)
if entry.info.status != 'finished':
if entry.tmpfilename and os.path.isfile(entry.tmpfilename):
@ -417,12 +422,12 @@ class DownloadQueue:
self.queue.delete(key=id)
if entry.is_canceled() is True:
asyncio.create_task(self.notifier.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.error = 'Canceled by user.'
self.done.put(value=entry)
asyncio.create_task(self.notifier.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()

58
app/library/Emitter.py Normal file
View file

@ -0,0 +1,58 @@
import asyncio
import logging
LOG = logging.getLogger('Emitter')
class Emitter:
"""
This class is used to emit events to the registered emitters.
"""
emitters: list[callable] = []
def add_emitter(self, emitter: callable):
"""
Add an emitter to the list of emitters.
Args:
emitter (function): The emitter function. The function must return a coroutine or None.
"""
self.emitters.append(emitter)
async def added(self, dl: dict, **kwargs):
await self.emit('added', dl, **kwargs)
async def updated(self, dl: dict, **kwargs):
await self.emit('updated', dl, **kwargs)
async def completed(self, dl: dict, **kwargs):
await self.emit('completed', dl, **kwargs)
async def canceled(self, id: str, dl: dict = None, **kwargs):
await self.emit('canceled', id, **kwargs)
async def cleared(self, id: str, dl: dict = None, **kwargs):
await self.emit('cleared', id, **kwargs)
async def error(self, dl: dict, message: str, **kwargs):
await self.emit('error', (dl, message), **kwargs)
async def warning(self, message: str, **kwargs):
await self.emit('error', message, **kwargs)
async def emit(self, event: str, data, **kwargs):
tasks = []
for emitter in self.emitters:
_ret = emitter(event, data, **kwargs)
if _ret:
if isinstance(_ret, list):
tasks.extend(_ret)
else:
tasks.append(_ret)
try:
await asyncio.wait_for(asyncio.gather(*tasks), timeout=60)
except asyncio.TimeoutError:
LOG.error(f"Timed out sending event {event}.")

527
app/library/HttpAPI.py Normal file
View file

@ -0,0 +1,527 @@
import asyncio
import base64
from datetime import datetime
import functools
import json
import os
import random
import time
from .config import Config
from .DownloadQueue import DownloadQueue
from aiohttp import web
from aiohttp.web import Request, Response, RequestHandler
from .Playlist import Playlist
from .M3u8 import M3u8
from .Segments import Segments
from .Subtitle import Subtitle
import socketio
import logging
import sqlite3
import magic
from .common import common
from pathlib import Path
from .encoder import Encoder
from .Emitter import Emitter
LOG = logging.getLogger('app')
MIME = magic.Magic(mime=True)
class HttpAPI(common):
config: Config = None
encoder: Encoder = None
app: web.Application = None
sio: socketio.AsyncServer = None
routes: web.RouteTableDef = None
connection: sqlite3.Connection = None
queue: DownloadQueue = None
loop: asyncio.AbstractEventLoop = None
rootPath: str = None
staticHolder: dict = {}
extToMime: dict = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.ico': 'image/x-icon',
}
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder):
super().__init__(queue=queue, encoder=encoder)
self.rootPath = str(Path(__file__).parent.parent.parent)
self.config = Config.get_instance()
self.loop = asyncio.get_event_loop()
self.encoder = encoder
self.routes = web.RouteTableDef()
self.emitter = emitter
self.queue = queue
def route(method: str, path: str):
"""
Decorator to mark a method as an HTTP route handler.
"""
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
return await func(*args, **kwargs)
wrapper._http_method = method.upper()
wrapper._http_path = path
return wrapper
return decorator
async def staticFile(self, req: Request) -> Response:
"""
Preload static files from the ui/dist folder.
"""
path = req.path
if req.path not in self.staticHolder:
return web.json_response({"error": "File not found.", "file": path}, status=404)
item: dict = self.staticHolder[req.path]
return web.Response(body=item.get('content'), headers={
'Pragma': 'public',
'Cache-Control': 'public, max-age=31536000',
'Content-Type': item.get('content_type'),
'X-Via': 'memory' if not item.get('file', None) else 'disk',
})
def preloadStatic(self, app: web.Application):
"""
Preload static files from the ui/dist folder.
"""
staticDir = os.path.join(self.rootPath, 'ui', 'dist')
for root, _, files in os.walk(staticDir):
for file in files:
if file.endswith('.map'):
continue
file = os.path.join(root, file)
urlPath = f"{self.config.url_prefix}{file.replace(f'{staticDir}/', '')}"
content = open(file, 'rb').read()
contentType = self.extToMime.get(os.path.splitext(file)[1], MIME.from_file(file))
self.staticHolder[urlPath] = {'content': content, 'content_type': contentType}
LOG.info(f'Preloading: [{urlPath}].')
app.router.add_get(urlPath, self.staticFile)
if urlPath.endswith('/index.html') and urlPath != '/index.html':
parentSlash = urlPath.replace('/index.html', '/')
parentNoSlash = urlPath.replace('/index.html', '')
self.staticHolder[parentSlash] = {'content': content, 'content_type': contentType}
self.staticHolder[parentNoSlash] = {'content': content, 'content_type': contentType}
app.router.add_get(parentSlash, self.staticFile)
app.router.add_get(parentNoSlash, self.staticFile)
def attach(self, app: web.Application):
if self.config.auth_username and self.config.auth_password:
app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, self.config.auth_password))
self.add_routes(app)
pass
def add_routes(self, app: web.Application):
for attr_name in dir(self):
method = getattr(self, attr_name)
if hasattr(method, '_http_method') and hasattr(method, '_http_path'):
http_path = method._http_path
if http_path.startswith('/') and self.config.url_prefix.endswith('/'):
http_path = method._http_path[1:]
self.routes.route(method._http_method, self.config.url_prefix + http_path)(method)
async def on_prepare(request: Request, response: Response):
if 'Origin' in request.headers:
response.headers['Access-Control-Allow-Origin'] = request.headers['Origin']
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
response.headers['Access-Control-Allow-Methods'] = 'PUT, POST, DELETE'
if 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))
# add static files.
self.routes.static(f'{self.config.url_prefix}download/', self.config.download_path)
self.preloadStatic(app)
try:
app.add_routes(self.routes)
app.on_response_prepare.append(on_prepare)
except ValueError as e:
if 'ui/dist' in str(e):
raise RuntimeError(f"Could not find the frontend UI static assets. '{e}'.") from e
raise e
def basic_auth(username: str, password: str):
@web.middleware
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
auth_header = request.headers.get('Authorization')
if auth_header is None:
return web.Response(status=401, headers={
'WWW-Authenticate': 'Basic realm="Authorization Required."',
}, text='Unauthorized.')
auth_type, encoded_credentials = auth_header.split(' ', 1)
if 'basic' != auth_type.lower():
return web.Response(status=401, text="Unsupported authentication method.")
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
user_name, _, user_password = decoded_credentials.partition(':')
if user_name != username or user_password != password:
return web.Response(status=401, text='Unauthorized (Invalid credentials).')
return await handler(request)
return middleware_handler
@route('GET', 'ping')
async def ping(self, _) -> Response:
self.queue.test()
return web.Response(text='pong')
@route('POST', 'add')
async def add(self, request: Request) -> Response:
post = await request.json()
url: str = post.get('url')
quality: str = post.get('quality')
if not url:
raise web.HTTPBadRequest()
format: str = post.get('format')
folder: str = post.get('folder')
ytdlp_cookies: str = post.get('ytdlp_cookies')
ytdlp_config: dict = post.get('ytdlp_config')
output_template: str = post.get('output_template')
if ytdlp_config is None:
ytdlp_config = {}
status = await self.add(
url=url,
quality=quality,
format=format,
folder=folder,
ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config,
output_template=output_template
)
return web.Response(text=self.encoder.encode(status))
@route('GET', 'tasks')
async def tasks(self, _: Request) -> Response:
tasks_file: str = os.path.join(self.config.config_path, 'tasks.json')
if not os.path.exists(tasks_file):
return web.json_response({"error": "No tasks defined."}, status=404)
try:
with open(tasks_file, 'r') as f:
tasks = json.load(f)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
return web.json_response(tasks)
@route('POST', 'add_batch')
async def add_batch(self, request: Request) -> Response:
status = {}
post = await request.json()
if not isinstance(post, list):
raise web.HTTPBadRequest(text='Invalid request body expecting list with dicts.')
for item in post:
if not isinstance(item, dict):
raise web.HTTPBadRequest(text='Invalid request body expecting list with dicts.')
if not item.get('url'):
raise web.HTTPBadRequest(text='url is required.')
status[item.get('url')] = await self.add(
url=item.get('url'),
quality=item.get('quality'),
format=item.get('format'),
folder=item.get('folder'),
ytdlp_cookies=item.get('ytdlp_cookies'),
ytdlp_config=item.get('ytdlp_config'),
output_template=item.get('output_template')
)
return web.Response(text=self.encoder.encode(status))
@route('DELETE', 'delete')
async def delete(self, request: Request) -> Response:
post = await request.json()
ids = post.get('ids')
where = post.get('where')
if not ids or where not in ['queue', 'done']:
raise web.HTTPBadRequest()
status: dict[str, str] = {}
status = await (self.queue.cancel(ids) if where == 'queue' else self.queue.clear(ids))
return web.Response(text=self.encoder.encode(status))
@route('POST', 'item/{id}')
async def update_item(self, request: Request) -> Response:
id: str = request.match_info.get('id')
if not id:
raise web.HTTPBadRequest(text='id is required.')
item = self.queue.done.getById(id)
if not item:
raise web.HTTPNotFound(text='Item not found.')
try:
post = await request.json()
if not post:
raise web.HTTPBadRequest(text='no data provided.')
except Exception as e:
raise web.HTTPBadRequest(text=str(e))
updated = False
for k, v in post.items():
if not hasattr(item.info, k):
continue
if getattr(item.info, k) == v:
continue
updated = True
setattr(item.info, k, v)
LOG.info(f'Updated [{k}] to [{v}] for [{item.info.id}]')
status = 200 if updated else 304
if updated:
self.queue.done.put(item)
await self.notifier.emit('update', item.info)
return web.Response(text=self.encoder.encode(item.info), status=status)
@route('GET', 'history')
async def history(self, _: Request) -> Response:
history = {'done': [], 'queue': []}
for _, v in self.queue.queue.saved_items():
history['queue'].append(v)
for _, v in self.queue.done.saved_items():
history['done'].append(v)
return web.Response(text=self.encoder.encode(history))
@route('GET', 'workers')
async def workers(self, _) -> Response:
if self.queue.pool is None:
return web.json_response({"error": "Worker pool not initialized."}, status=404)
status = self.queue.pool.get_workers_status()
data = []
for worker in status:
worker_status = status.get(worker)
data.append({
'id': worker,
'data': {"status": 'Waiting for download.'} if worker_status is None else worker_status,
})
def safe_serialize(obj):
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:
if self.queue.pool is None:
return web.json_response({"error": "Worker pool not initialized."}, status=404)
self.queue.pool.start()
return web.Response()
@route('PATCH', 'workers/{id}')
async def restart_worker(self, request: Request) -> Response:
id: str = request.match_info.get('id')
if not id:
raise web.HTTPBadRequest(text='worker id is required.')
if self.queue.pool is None:
return web.json_response({"error": "Worker pool not initialized."}, status=404)
status = await self.queue.pool.restart(id, 'requested by user.')
return web.json_response({"status": "restarted" if status else "in_error_state"})
@route('delete', 'workers/{id}')
async def stop_worker(self, request: Request) -> Response:
id: str = request.match_info.get('id')
if not id:
raise web.HTTPBadRequest(text='worker id is required.')
if self.queue.pool is None:
return web.json_response({"error": "Worker pool not initialized."}, status=404)
status = await self.queue.pool.stop(id, 'requested by user.')
return web.json_response({"status": "stopped" if status else "in_error_state"})
@route('GET', 'player/playlist/{file:.*}.m3u8')
async def playlist(self, request: Request) -> Response:
file: str = request.match_info.get('file')
if not file:
raise web.HTTPBadRequest(text='file is required.')
try:
text = await Playlist(url=f"{self.config.url_host}{self.config.url_prefix}").make(
download_path=self.config.download_path,
file=file
)
if isinstance(text, Response):
return text
except Exception as e:
return web.HTTPNotFound(text=str(e))
return web.Response(text=text, headers={
'Content-Type': 'application/x-mpegURL',
'Cache-Control': 'no-cache',
'Access-Control-Max-Age': "300",
})
@route('GET', 'player/m3u8/{mode}/{file:.*}.m3u8')
async def m3u8(self, request: Request) -> Response:
file: str = request.match_info.get('file')
mode: str = request.match_info.get('mode')
if mode not in ['video', 'subtitle']:
raise web.HTTPBadRequest(text='Only video and subtitle modes are supported.')
if not file:
raise web.HTTPBadRequest(text='file is required.')
duration = request.query.get('duration', None)
if 'subtitle' in mode:
if not duration:
raise web.HTTPBadRequest(text='duration is required.')
duration = float(duration)
try:
cls = M3u8(f"{self.config.url_host}{self.config.url_prefix}")
if 'subtitle' in mode:
text = await cls.make_subtitle(self.config.download_path, file, duration)
else:
text = await cls.make_stream(self.config.download_path, file)
except Exception as e:
return web.HTTPNotFound(text=str(e))
return web.Response(text=text, headers={
'Content-Type': 'application/x-mpegURL',
'Cache-Control': 'no-cache',
'Access-Control-Max-Age': "300",
})
@route('GET', 'player/segments/{segment:\d+}/{file:.*}.ts')
async def segments(self, request: Request) -> Response:
file: str = request.match_info.get('file')
segment: int = request.match_info.get('segment')
sd: int = request.query.get('sd')
vc: int = int(request.query.get('vc', 0))
ac: int = int(request.query.get('ac', 0))
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
if not file_path.startswith(self.config.download_path):
raise web.HTTPBadRequest(text='Invalid file path.')
if request.if_modified_since:
if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path):
return web.Response(status=304)
if not file:
raise web.HTTPBadRequest(text='file is required')
if not segment:
raise web.HTTPBadRequest(text='segment is required')
segmenter = Segments(
index=int(segment),
duration=float('{:.6f}'.format(float(sd if sd else M3u8.duration))),
vconvert=True if vc == 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={
'Content-Type': 'video/mpegts',
'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('GET', 'player/subtitle/{file:.*}.vtt')
async def subtitles(self, request: Request) -> Response:
file: str = request.match_info.get('file')
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
if not file_path.startswith(self.config.download_path):
raise web.HTTPBadRequest(text='Invalid file path.')
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):
return web.Response(status=304, headers={'Last-Modified': lastMod})
if not file:
raise web.HTTPBadRequest(text='file is required')
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:
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)

259
app/library/HttpSocket.py Normal file
View file

@ -0,0 +1,259 @@
import asyncio
from datetime import datetime
import errno
import functools
import os
import pty
import shlex
from aiohttp import web
import socketio
import logging
import sqlite3
from .config import Config
from .DownloadQueue import DownloadQueue
from .common import common
from .encoder import Encoder
from .Utils import isDownloaded, load_file
from .Emitter import Emitter
LOG = logging.getLogger('socket')
class HttpSocket(common):
"""
This class is used to handle WebSocket events.
"""
config: Config = None
app: web.Application = None
sio: socketio.AsyncServer = None
connection: sqlite3.Connection = None
def __init__(self, queue: DownloadQueue, emitter: Emitter, encoder: Encoder):
super().__init__(queue=queue, encoder=encoder)
self.sio = socketio.AsyncServer(cors_allowed_origins='*')
emitter.add_emitter(lambda event, data, **kwargs: self.sio.emit(event, encoder.encode(data), **kwargs))
self.config = Config.get_instance()
self.queue = queue
self.emitter = emitter
def ws_event(func):
"""
Decorator to mark a method as a socket event.
"""
@functools.wraps(func)
async def wrapper(*args, **kwargs):
return await func(*args, **kwargs)
wrapper._ws_event = func.__name__
return wrapper
def attach(self, app: web.Application):
self.sio.attach(app, socketio_path=self.config.url_prefix + 'socket.io')
for attr_name in dir(self):
method = getattr(self, attr_name)
if hasattr(method, '_ws_event'):
event = method._ws_event
self.sio.on(event)(method)
@ws_event
async def cli_post(self, sid: str, data):
if not data:
await self.emitter.emit('cli_close', {'exitcode': 0}, to=sid)
return
try:
LOG.info(f'Cli command from {sid=}: {data}')
args = ['yt-dlp'] + shlex.split(data)
_env = os.environ.copy()
_env.update({
"TERM": "xterm-256color",
"LANG": "en_US.UTF-8",
"SHELL": "/bin/bash",
"LC_ALL": "en_US.UTF-8",
"PWD": self.config.download_path,
"FORCE_COLOR": "1",
"PYTHONUNBUFFERED": "1",
})
master_fd, slave_fd = pty.openpty()
proc = await asyncio.create_subprocess_exec(
*args,
cwd=self.config.download_path,
stdin=asyncio.subprocess.DEVNULL,
stdout=slave_fd,
stderr=slave_fd,
env=_env,
)
try:
os.close(slave_fd)
except Exception as e:
LOG.error(f'Error closing PTY: {str(e)}')
async def read_pty():
loop = asyncio.get_running_loop()
buffer = b''
while True:
try:
chunk = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
except OSError as e:
if e.errno == errno.EIO:
break
else:
raise
if not chunk:
# No more output
if buffer:
# Emit any remaining partial line
await self.emitter.emit('cli_output', {
'type': 'stdout',
'line': buffer.decode('utf-8', errors='replace')
})
break
buffer += chunk
*lines, buffer = buffer.split(b'\n')
for line in lines:
await self.emitter.emit('cli_output', {
'type': 'stdout',
'line': line.decode('utf-8', errors='replace')
})
try:
os.close(master_fd)
except Exception as e:
LOG.error(f'Error closing PTY: {str(e)}')
# Start reading output from PTY
read_task = asyncio.create_task(read_pty())
# Wait until process finishes
returncode = await proc.wait()
# Ensure reading is done
await read_task
await self.emitter.emit('cli_close', {'exitcode': returncode}, to=sid)
except Exception as e:
LOG.error(f'CLI Error for {sid=}: {str(e)}')
await self.emitter.emit('cli_out1put', {
'type': 'stderr',
'line': str(e),
}, to=sid)
await self.emitter.emit('cli_close', {'exitcode': -1}, to=sid)
@ws_event
async def add_url(self, sid: str, data: dict):
url: str = data.get('url')
quality: str = data.get('quality')
if not url:
self.emitter.warning('No URL provided.')
return
format: str = data.get('format')
folder: str = data.get('folder')
ytdlp_cookies: str = data.get('ytdlp_cookies')
ytdlp_config: dict = data.get('ytdlp_config')
output_template: str = data.get('output_template')
if ytdlp_config is None:
ytdlp_config = {}
status = await self.add(
url=url,
quality=quality,
format=format,
folder=folder,
ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config,
output_template=output_template
)
await self.emitter.emit('status', status, to=sid)
@ws_event
async def item_cancel(self, sid: str, id: str):
if not id:
await self.emitter.warning('Invalid request.')
return
status: dict[str, str] = {}
status = await self.queue.cancel([id])
status.update({'identifier': id})
await self.emitter.emit('item_cancel', status)
@ws_event
async def item_delete(self, sid: str, id: str):
if not id:
await self.emitter.warning('Invalid request.')
return
status: dict[str, str] = {}
status = await self.queue.clear([id])
status.update({'identifier': id})
await self.emitter.emit('item_delete', status)
@ws_event
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:
return
file: str = self.config.ytdl_options.get('download_archive', None)
if not file:
return
exists, idDict = isDownloaded(file, data['url'])
if exists or 'archive_id' not in idDict or idDict['archive_id'] is None:
return
with open(file, 'a') as f:
f.write(f"{idDict['archive_id']}\n")
manual_archive = self.config.manual_archive
if manual_archive:
previouslyArchived = False
if os.path.exists(manual_archive):
with open(manual_archive, 'r') as f:
for line in f.readlines():
if idDict['archive_id'] in line:
previouslyArchived = True
break
if not previouslyArchived:
with open(manual_archive, 'a') as f:
f.write(f"{idDict['archive_id']} - at: {datetime.now().isoformat()}\n")
LOG.info(f'Archiving item: {data["url"]=}')
@ws_event
async def connect(self, sid: str, _=None):
data: dict = {
**self.queue.get(),
"config": self.config.frontend(),
"tasks": [],
"presets": [],
}
if os.path.exists(os.path.join(self.config.config_path, 'tasks.json')):
try:
(tasks, status, error) = load_file(os.path.join(self.config.config_path, 'tasks.json'), list)
if status is False:
LOG.error(f"Could not load tasks file. Error message '{error}'.")
else:
data['tasks'] = tasks
except Exception as e:
pass
# get directory listing
dlDir: str = self.config.download_path
data['directories'] = [name for name in os.listdir(dlDir) if os.path.isdir(os.path.join(dlDir, name))]
await self.emitter.emit('initial_data', data, to=sid)

View file

@ -1,10 +1,9 @@
import math
import os
from urllib.parse import quote
from Utils import calcDownloadPath
from .Utils import calcDownloadPath
from .ffprobe import FFProbe
class M3u8:
ok_vcodecs: tuple = ('h264', 'x264', 'avc',)
ok_acodecs: tuple = ('aac', 'mp3',)

View file

@ -2,13 +2,12 @@ import glob
import pathlib
import re
from urllib.parse import quote
from Utils import calcDownloadPath, checkId
from library.Utils import calcDownloadPath, checkId
import pathlib
from .ffprobe import FFProbe
from library.ffprobe import FFProbe
from .Subtitle import Subtitle
from aiohttp.web import Response
class Playlist:
_url: str = None

View file

@ -3,8 +3,8 @@ import hashlib
import logging
import os
import tempfile
from Utils import calcDownloadPath
from Config import Config
from .Utils import calcDownloadPath
from .config import Config
LOG = logging.getLogger('player.segments')

View file

@ -1,9 +1,5 @@
import asyncio
import hashlib
import logging
import os
import tempfile
from Utils import calcDownloadPath
from .Utils import calcDownloadPath
import pathlib
import pysubs2
from pysubs2.time import ms_to_times

View file

@ -1,4 +1,3 @@
import asyncio
import copy
from datetime import datetime, timezone
import json
@ -8,8 +7,6 @@ import pathlib
import re
from typing import Any
import yt_dlp
from socketio import AsyncServer
from Webhooks import Webhooks
LOG = logging.getLogger('Utils')
@ -287,77 +284,6 @@ def jsonCookie(cookies: dict[dict[str, any]]) -> str | None:
return netscapeCookies if hasCookies else None
class ObjectSerializer(json.JSONEncoder):
"""
This class is used to serialize objects to JSON.
The only difference between this and the default JSONEncoder is that this one
will call the __dict__ method of an object if it exists.
"""
def default(self, obj):
if isinstance(obj, object) and hasattr(obj, '__dict__'):
return obj.__dict__
return json.JSONEncoder.default(self, obj)
class Notifier:
'''
This class is used to send events to the frontend.
'''
sio: AsyncServer = None
"SocketIO server instance"
serializer: ObjectSerializer = None
"Serializer used to serialize objects to JSON"
webhooks: Webhooks = None
"Send webhooks events."
webhooks_allowed_events: tuple = (
'added', 'completed', 'error', 'not_live'
)
"Events that are allowed to be sent to webhooks."
def __init__(self, sio: AsyncServer, serializer: ObjectSerializer, webhooks: Webhooks = None):
self.sio = sio
self.serializer = serializer
self.webhooks = webhooks
async def added(self, dl: dict):
await self.emit('added', dl)
async def updated(self, dl: dict):
await self.emit('updated', dl)
async def completed(self, dl: dict):
await self.emit('completed', dl)
async def canceled(self, id: str, dl: dict = None):
await self.emit('canceled', id)
async def cleared(self, id: str, dl: dict = None):
await self.emit('cleared', id)
async def error(self, dl: dict, message: str):
await self.emit('error', (dl, message))
async def warning(self, message: str):
await self.emit('error', message)
async def emit(self, event: str, data):
tasks = []
tasks.append(self.sio.emit(event, self.serializer.encode(data)))
if self.webhooks and event in self.webhooks_allowed_events:
tasks.append(self.webhooks.send(event, data))
try:
await asyncio.wait_for(asyncio.gather(*tasks), timeout=60)
except asyncio.TimeoutError:
LOG.error(f"Timed out sending event {event} to webhooks.")
def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
"""
Load a JSON or JSON5 file and return the contents as a dictionary

View file

@ -1,14 +1,15 @@
import asyncio
import logging
import os
from ItemDTO import ItemDTO
from .ItemDTO import ItemDTO
import httpx
from version import APP_VERSION
LOG = logging.getLogger('Webhooks')
from .version import APP_VERSION
LOG = logging.getLogger('Webhooks')
class Webhooks:
targets: list[dict] = []
allowed_events: tuple = ('added', 'completed', 'error', 'not_live',)
def __init__(self, file: str):
if os.path.exists(file):
@ -20,7 +21,7 @@ class Webhooks:
raise Exception(f'file is empty.')
LOG.info(f'Loading webhooks from {file}')
from Utils import load_file
from .Utils import load_file
(target, status, error) = load_file(file, list)
if not status:
raise Exception(f'{error}')
@ -49,7 +50,7 @@ class Webhooks:
return await asyncio.gather(*tasks)
async def __send(self, event: str, target: dict, item: ItemDTO) -> dict:
from Config import Config
from .config import Config
req: dict = target.get('request')
try:
LOG.info(f"Sending {event=} {item.id=} to [{target.get('name')}]")
@ -96,3 +97,9 @@ class Webhooks:
'status': 500,
'text': str(e)
}
def emit(self, event, data, **kwargs):
if len(self.targets) < 1 or event not in self.allowed_events:
return False
return self.send(event, data)

46
app/library/common.py Normal file
View file

@ -0,0 +1,46 @@
import logging
from .DownloadQueue import DownloadQueue
from .encoder import Encoder
LOG = logging.getLogger('common')
class common():
"""
This class is used to share common methods between the socket and the API gateways.
"""
queue: DownloadQueue = None
encoder: Encoder = None
def __init__(self, queue: DownloadQueue, encoder: Encoder):
super().__init__()
self.queue = queue
self.encoder = encoder
async def add(
self,
url: str,
quality: str,
format: str,
folder: str,
ytdlp_cookies: str,
ytdlp_config: dict,
output_template: str
) -> dict[str, str]:
if ytdlp_config is None:
ytdlp_config = {}
if ytdlp_cookies and isinstance(ytdlp_cookies, dict):
ytdlp_cookies = self.encoder.encode(ytdlp_cookies)
status = await self.queue.add(
url=url,
format=format if format else 'any',
quality=quality if quality else 'best',
folder=folder,
ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config,
output_template=output_template
)
return status

View file

@ -4,17 +4,18 @@ import re
import sys
import time
import coloredlogs
from version import APP_VERSION
from .version import APP_VERSION
from dotenv import load_dotenv
from yt_dlp.version import __version__ as YTDLP_VERSION
from Utils import load_file
from .Utils import load_file
from pathlib import Path
class Config:
__instance = None
config_path: str = '.'
download_path: str = '.'
temp_path: str = '{download_path}'
temp_path: str = '/tmp'
temp_keep: bool = False
db_file: str = '{config_path}/ytptube.db'
manual_archive: str = '{config_path}/archive.manual.log'
@ -64,9 +65,12 @@ class Config:
ytdlp_version: str = YTDLP_VERSION
_int_vars: tuple = ('port', 'max_workers', 'socket_timeout', 'extract_info_timeout', 'debugpy_port',)
_manual_vars: tuple = ('temp_path', 'config_path', 'download_path',)
_immutable: tuple = ('version', '__instance', 'ytdl_options', 'new_version_available', 'ytdlp_version', 'started')
_int_vars: tuple = ('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 = (
'download_path', 'keep_archive', 'output_template',
'ytdlp_version', 'url_host', 'url_prefix',
@ -80,11 +84,11 @@ class Config:
def __init__(self):
""" Virtually private constructor. """
if Config.__instance is not None:
raise Exception("This class is a singleton!. Use Config.getInstance() instead.")
raise Exception("This class is a singleton. Use Config.get_instance() instead.")
else:
Config.__instance = self
baseDefaultPath: str = os.path.dirname(__file__)
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.config_path = os.environ.get('YTP_CONFIG_PATH', None) or os.path.join(baseDefaultPath, 'var', 'config')
@ -95,11 +99,11 @@ class Config:
envFile: str = os.path.join(self.config_path, '.env')
if os.path.exists(envFile):
logging.info(f'Loading environment variables from [{envFile}].')
logging.info(f"Loading environment variables from '{envFile}'.")
load_dotenv(envFile)
for k, v in self._getAttributes().items():
if k.startswith('_'):
if k.startswith('_') or k in self._manual_vars:
continue
# If the variable declared as immutable, set it to the default value.
@ -111,7 +115,7 @@ class Config:
setattr(self, k, os.environ[lookUpKey] if lookUpKey in os.environ else v)
for k, v in self.__dict__.items():
if k.startswith('_') or k in self._immutable:
if k.startswith('_') or k in self._immutable or k in self._manual_vars:
continue
if isinstance(v, str) and '{' in v and '}' in v:
@ -182,7 +186,7 @@ class Config:
LOG.info(f'Keep temp: {self.temp_keep}')
if self.auth_password and self.auth_username:
LOG.warn(f"Basic Auth enabled with username: '{self.auth_username}'.")
LOG.warn(f"Basic authentication enabled with username: '{self.auth_username}'.")
self.started = time.time()

15
app/library/encoder.py Normal file
View file

@ -0,0 +1,15 @@
import json
class Encoder(json.JSONEncoder):
"""
This class is used to serialize objects to JSON.
The only difference between this and the default JSONEncoder is that this one
will call the __dict__ method of an object if it exists.
"""
def default(self, obj):
if isinstance(obj, object) and hasattr(obj, '__dict__'):
return obj.__dict__
return json.JSONEncoder.default(self, obj)

View file

@ -1,62 +1,68 @@
#!/usr/bin/env python3
import argparse
import asyncio
import base64
from datetime import datetime
import errno
import json
import os
import pty
import random
import shlex
import time
from Config import Config
from DownloadQueue import DownloadQueue
from Utils import ObjectSerializer, Notifier, isDownloaded, load_file
from aiohttp import web, client
from aiohttp.web import Request, Response, RequestHandler
from Webhooks import Webhooks
from player.Playlist import Playlist
from player.M3u8 import M3u8
from player.Segments import Segments
from player.Subtitle import Subtitle
import socketio
import logging
import caribou
import sqlite3
from aiocron import crontab
import magic
from datetime import datetime
from aiohttp import web
from pathlib import Path
from library.Utils import load_file
from library.Emitter import Emitter
from library.config import Config
from library.encoder import Encoder
from library.DownloadQueue import DownloadQueue
from library.Webhooks import Webhooks
from library.HttpSocket import HttpSocket
from library.HttpAPI import HttpAPI
from aiocron import crontab
LOG = logging.getLogger('app')
MIME = magic.Magic(mime=True)
class Main:
class main:
config: Config = None
serializer: ObjectSerializer = None
app: web.Application = None
sio: socketio.AsyncServer = None
routes: web.RouteTableDef = None
connection: sqlite3.Connection = None
queue: DownloadQueue = None
loop: asyncio.AbstractEventLoop = None
appLoader: str = None
staticHolder: dict = {}
extToMime: dict = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.ico': 'image/x-icon',
}
http: HttpAPI = None
socket: HttpSocket = None
def __init__(self):
self.config = Config.get_instance()
self.rootPath = str(Path(__file__).parent.absolute())
self.app = web.Application()
self.encoder = Encoder()
self.checkDirectories()
caribou.upgrade(self.config.db_file, os.path.join(self.rootPath, 'migrations'))
connection = sqlite3.connect(database=self.config.db_file, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute('PRAGMA journal_mode=wal')
emitter = Emitter()
queue = DownloadQueue(emitter=emitter, connection=connection)
self.app.on_startup.append(lambda _: queue.initialize())
self.http = HttpAPI(queue=queue, emitter=emitter, encoder=self.encoder)
self.socket = HttpSocket(queue=queue, emitter=emitter, encoder=self.encoder)
WebhookFile = os.path.join(self.config.config_path, 'webhooks.json')
if os.path.exists(WebhookFile):
emitter.add_emitter(Webhooks(WebhookFile).emit)
def checkDirectories(self) -> None:
try:
LOG.debug(f'Checking download folder at [{self.config.download_path}]')
if not os.path.exists(self.config.download_path):
LOG.info(f'Creating download folder at [{self.config.download_path}]')
os.makedirs(self.config.download_path, exist_ok=True)
@ -65,6 +71,7 @@ class Main:
raise e
try:
LOG.debug(f'Checking temp folder at [{self.config.temp_path}]')
if not os.path.exists(self.config.temp_path):
LOG.info(f'Creating temp folder at [{self.config.temp_path}]')
os.makedirs(self.config.temp_path, exist_ok=True)
@ -73,6 +80,7 @@ class Main:
raise e
try:
LOG.debug(f'Checking config folder at [{self.config.config_path}]')
if not os.path.exists(self.config.config_path):
LOG.info(f'Creating config folder at [{self.config.config_path}]')
os.makedirs(self.config.config_path, exist_ok=True)
@ -81,6 +89,7 @@ class Main:
raise e
try:
LOG.debug(f'Checking database file at [{self.config.db_file}]')
if not os.path.exists(self.config.db_file):
LOG.info(f'Creating database file at [{self.config.db_file}]')
with open(self.config.db_file, 'w') as _:
@ -89,132 +98,16 @@ class Main:
LOG.error(f'Could not create database file at [{self.config.db_file}]')
raise e
caribou.upgrade(self.config.db_file, os.path.join(os.path.realpath(os.path.dirname(__file__)), 'migrations'))
self.webhooks = Webhooks(os.path.join(self.config.config_path, 'webhooks.json'))
self.loop = asyncio.get_event_loop()
self.serializer = ObjectSerializer()
self.app = web.Application()
if self.config.auth_username and self.config.auth_password:
self.app.middlewares.append(
Main.basic_auth(self.config.auth_username, self.config.auth_password)
)
self.sio = socketio.AsyncServer(cors_allowed_origins='*')
self.sio.attach(self.app, socketio_path=self.config.url_prefix + 'socket.io')
self.routes = web.RouteTableDef()
self.connection = sqlite3.connect(self.config.db_file, isolation_level=None)
self.connection.row_factory = sqlite3.Row
self.connection.execute('PRAGMA journal_mode=wal')
self.notifier = Notifier(sio=self.sio, serializer=self.serializer, webhooks=self.webhooks)
self.queue = DownloadQueue(notifier=self.notifier, connection=self.connection, serializer=self.serializer)
self.app.on_startup.append(lambda _: self.queue.initialize())
self.addRoutes()
self.addTasks()
self.start()
def start(self):
start: str = f'YTPTube v{self.config.version} - listening on http://{self.config.host}:{self.config.port}'
web.run_app(
self.app,
host=self.config.host,
port=self.config.port,
reuse_port=True,
loop=self.loop,
access_log=None,
print=lambda _: LOG.info(start)
)
def basic_auth(username: str, password: str):
@web.middleware
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
auth_header = request.headers.get('Authorization')
if auth_header is None:
return web.Response(status=401, headers={
'WWW-Authenticate': 'Basic realm="Authorization Required."',
}, text='Unauthorized.')
auth_type, encoded_credentials = auth_header.split(' ', 1)
if 'basic' != auth_type.lower():
return web.Response(status=401, text="Unsupported authentication method.")
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
user_name, _, user_password = decoded_credentials.partition(':')
if user_name != username or user_password != password:
return web.Response(status=401, text='Unauthorized (Invalid credentials).')
return await handler(request)
return middleware_handler
async def connect(self, sid, _):
data: dict = {
**self.queue.get(),
"config": self.config.frontend(),
"tasks": [],
"presets": [],
}
if os.path.exists(os.path.join(self.config.config_path, 'tasks.json')):
try:
(tasks, status, error) = load_file(os.path.join(self.config.config_path, 'tasks.json'), list)
if status is False:
LOG.error(f"Could not load tasks file. Error message '{error}'.")
else:
data['tasks'] = tasks
except Exception as e:
pass
# get directory listing
dlDir: str = self.config.download_path
data['directories'] = [name for name in os.listdir(dlDir) if os.path.isdir(os.path.join(dlDir, name))]
await self.sio.emit('initial_data', self.serializer.encode(data), to=sid)
async def version_check(self):
try:
with client.ClientSession() as session:
async with session.get('https://api.github.com/repos/ytptube/ytptube/tags') as r:
if r.status != 200:
return
tags = await r.json()
for tag in tags:
tagName = tag.get('name')
splitTagName = tagName.split('-')
if len(splitTagName) > 1:
tagName = splitTagName[0]
cvDate = self.config.version.split('-')
if tagName > cvDate[0]:
self.config.new_version_available = True
LOG.info(f'New version [{tagName}] is available.')
break
except Exception as e:
pass
def addTasks(self):
def load_tasks(self):
tasks_file: str = os.path.join(self.config.config_path, 'tasks.json')
if not os.path.exists(tasks_file):
LOG.info(
f'No tasks file found at {tasks_file}. Skipping Tasks.')
LOG.info(f'No tasks file found at {tasks_file}. Skipping Tasks.')
return
try:
(tasks, status, error) = load_file(tasks_file, list)
if status is False:
raise Exception(error)
except Exception as e:
LOG.error(f"Could not load tasks file '{tasks_file}'. Error message '{str(e)}'. Skipping Tasks.")
return
@ -254,623 +147,24 @@ class Main:
LOG.info(f'Added [Task: {task.get("name", task.get("url"))}] executed every [{cron_timer}].')
def addRoutes(self):
@self.routes.get(self.config.url_prefix + 'ping')
async def ping(_) -> Response:
self.connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone()
return web.Response(text='pong')
@self.routes.post(self.config.url_prefix + 'add')
async def add(request: Request) -> Response:
post = await request.json()
url: str = post.get('url')
quality: str = post.get('quality')
if not url:
raise web.HTTPBadRequest()
format: str = post.get('format')
folder: str = post.get('folder')
ytdlp_cookies: str = post.get('ytdlp_cookies')
ytdlp_config: dict = post.get('ytdlp_config')
output_template: str = post.get('output_template')
if ytdlp_config is None:
ytdlp_config = {}
status = await self.add(
url=url,
quality=quality,
format=format,
folder=folder,
ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config,
output_template=output_template
)
return web.Response(text=self.serializer.encode(status))
@self.routes.get(self.config.url_prefix + 'tasks')
async def tasks(_: Request) -> Response:
tasks_file: str = os.path.join(self.config.config_path, 'tasks.json')
if not os.path.exists(tasks_file):
return web.json_response({"error": "No tasks defined."}, status=404)
try:
with open(tasks_file, 'r') as f:
tasks = json.load(f)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
return web.json_response(tasks)
@self.routes.post(self.config.url_prefix + 'add_batch')
async def add_batch(request: Request) -> Response:
status = {}
post = await request.json()
if not isinstance(post, list):
raise web.HTTPBadRequest()
for item in post:
if not isinstance(item, dict):
raise web.HTTPBadRequest(reason='Invalid request body expecting list with dicts.')
if not item.get('url'):
raise web.HTTPBadRequest(reason='url is required.')
status[item.get('url')] = await self.add(
url=item.get('url'),
quality=item.get('quality'),
format=item.get('format'),
folder=item.get('folder'),
ytdlp_cookies=item.get('ytdlp_cookies'),
ytdlp_config=item.get('ytdlp_config'),
output_template=item.get('output_template')
)
return web.Response(text=self.serializer.encode(status))
@self.routes.delete(self.config.url_prefix + 'delete')
async def delete(request: Request) -> Response:
post = await request.json()
ids = post.get('ids')
where = post.get('where')
if not ids or where not in ['queue', 'done']:
raise web.HTTPBadRequest()
status: dict[str, str] = {}
status = await (self.queue.cancel(ids) if where == 'queue' else self.queue.clear(ids))
return web.Response(text=self.serializer.encode(status))
@self.routes.post(self.config.url_prefix + 'item/{id}')
async def update_item(request: Request) -> Response:
id: str = request.match_info.get('id')
if not id:
raise web.HTTPBadRequest(reason='id is required.')
item = self.queue.done.getById(id)
if not item:
raise web.HTTPNotFound(reason='Item not found.')
try:
post = await request.json()
if not post:
raise web.HTTPBadRequest(reason='no data provided.')
except Exception as e:
raise web.HTTPBadRequest(reason=str(e))
updated = False
for k, v in post.items():
if not hasattr(item.info, k):
continue
if getattr(item.info, k) == v:
continue
updated = True
setattr(item.info, k, v)
LOG.info(f'Updated [{k}] to [{v}] for [{item.info.id}]')
status = 200 if updated else 304
if updated:
self.queue.done.put(item)
await self.notifier.emit('update', item.info)
return web.Response(text=self.serializer.encode(item.info), status=status)
@self.routes.get(self.config.url_prefix + 'history')
async def history(_) -> Response:
history = {'done': [], 'queue': []}
for _, v in self.queue.queue.saved_items():
history['queue'].append(v)
for _, v in self.queue.done.saved_items():
history['done'].append(v)
return web.Response(text=self.serializer.encode(history))
@self.routes.get(self.config.url_prefix + 'workers')
async def workers(_) -> Response:
if self.queue.pool is None:
return web.json_response({"error": "Worker pool not initialized."}, status=404)
status = self.queue.pool.get_workers_status()
data = []
for worker in status:
worker_status = status.get(worker)
data.append({
'id': worker,
'data': {"status": 'Waiting for download.'} if worker_status is None else worker_status,
})
def safe_serialize(obj):
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',
})
@self.routes.post(self.config.url_prefix + 'workers')
async def restart_pool(_) -> Response:
if self.queue.pool is None:
return web.json_response({"error": "Worker pool not initialized."}, status=404)
status = self.queue.pool.start()
return web.Response()
@self.routes.patch(self.config.url_prefix + 'workers/{id}')
async def restart_worker(request: Request) -> Response:
id: str = request.match_info.get('id')
if not id:
raise web.HTTPBadRequest(reason='worker id is required.')
if self.queue.pool is None:
return web.json_response({"error": "Worker pool not initialized."}, status=404)
status = await self.queue.pool.restart(id, 'requested by user.')
return web.json_response({"status": "restarted" if status else "in_error_state"})
@self.routes.delete(self.config.url_prefix + 'workers/{id}')
async def stop_worker(request: Request) -> Response:
id: str = request.match_info.get('id')
if not id:
raise web.HTTPBadRequest(reason='worker id is required.')
if self.queue.pool is None:
return web.json_response({"error": "Worker pool not initialized."}, status=404)
status = await self.queue.pool.stop(id, 'requested by user.')
return web.json_response({"status": "stopped" if status else "in_error_state"})
@self.routes.get(self.config.url_prefix + 'player/playlist/{file:.*}.m3u8')
async def playlist(request: Request) -> Response:
file: str = request.match_info.get('file')
if not file:
raise web.HTTPBadRequest(reason='file is required.')
try:
text = await Playlist(url=f"{self.config.url_host}{self.config.url_prefix}").make(
download_path=self.config.download_path,
file=file
)
if isinstance(text, Response):
return text
except Exception as e:
return web.HTTPNotFound(reason=str(e))
return web.Response(text=text, headers={
'Content-Type': 'application/x-mpegURL',
'Cache-Control': 'no-cache',
'Access-Control-Max-Age': "300",
})
@self.routes.get(self.config.url_prefix + 'player/m3u8/{mode}/{file:.*}.m3u8')
async def m3u8(request: Request) -> Response:
file: str = request.match_info.get('file')
mode: str = request.match_info.get('mode')
if mode not in ['video', 'subtitle']:
raise web.HTTPBadRequest(reason='Only video and subtitle modes are supported.')
if not file:
raise web.HTTPBadRequest(reason='file is required.')
duration = request.query.get('duration', None)
if 'subtitle' in mode:
if not duration:
raise web.HTTPBadRequest(reason='duration is required.')
duration = float(duration)
try:
cls = M3u8(f"{self.config.url_host}{self.config.url_prefix}")
if 'subtitle' in mode:
text = await cls.make_subtitle(self.config.download_path, file, duration)
else:
text = await cls.make_stream(self.config.download_path, file)
except Exception as e:
return web.HTTPNotFound(reason=str(e))
return web.Response(
text=text,
headers={
'Content-Type': 'application/x-mpegURL',
'Cache-Control': 'no-cache',
'Access-Control-Max-Age': "300",
}
)
@self.routes.get(self.config.url_prefix + 'player/segments/{segment:\d+}/{file:.*}.ts')
async def segments(request: Request) -> Response:
file: str = request.match_info.get('file')
segment: int = request.match_info.get('segment')
sd: int = request.query.get('sd')
vc: int = int(request.query.get('vc', 0))
ac: int = int(request.query.get('ac', 0))
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
if not file_path.startswith(self.config.download_path):
raise web.HTTPBadRequest(reason='Invalid file path.')
if request.if_modified_since:
if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path):
return web.Response(status=304)
if not file:
raise web.HTTPBadRequest(reason='file is required')
if not segment:
raise web.HTTPBadRequest(reason='segment is required')
segmenter = Segments(
index=int(segment),
duration=float('{:.6f}'.format(float(sd if sd else M3u8.duration))),
vconvert=True if vc == 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={
'Content-Type': 'video/mpegts',
'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()),
}
)
@self.routes.get(self.config.url_prefix + 'player/subtitle/{file:.*}.vtt')
async def subtitles(request: Request) -> Response:
file: str = request.match_info.get('file')
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
if not file_path.startswith(self.config.download_path):
raise web.HTTPBadRequest(reason='Invalid file path.')
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):
return web.Response(status=304, headers={'Last-Modified': lastMod})
if not file:
raise web.HTTPBadRequest(reason='file is required')
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()),
}
)
@self.routes.get(self.config.url_prefix)
async def index(_) -> Response:
if not self.appLoader:
with open(os.path.join(
os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
'ui/dist/index.html'
), 'r') as f:
self.appLoader = f.read()
return web.Response(text=self.appLoader, content_type='text/html', charset='utf-8', status=200)
@self.sio.event()
async def cli_post(sid, data):
if not data:
await self.sio.emit('cli_close', {'exitcode': 0})
return
try:
LOG.info(f'CLI: {data}')
args = ['yt-dlp'] + shlex.split(data)
_env = os.environ.copy()
_env.update({
"TERM": "xterm-256color",
"LANG": "en_US.UTF-8",
"SHELL": "/bin/bash",
"LC_ALL": "en_US.UTF-8",
"PWD": self.config.download_path,
"FORCE_COLOR": "1",
"PYTHONUNBUFFERED": "1",
})
master_fd, slave_fd = pty.openpty()
proc = await asyncio.create_subprocess_exec(
*args,
cwd=self.config.download_path,
stdin=asyncio.subprocess.DEVNULL,
stdout=slave_fd,
stderr=slave_fd,
env=_env,
)
try:
os.close(slave_fd)
except Exception as e:
LOG.error(f'Error closing PTY: {str(e)}')
async def read_pty():
loop = asyncio.get_running_loop()
buffer = b''
while True:
try:
chunk = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
except OSError as e:
if e.errno == errno.EIO:
break
else:
raise
if not chunk:
# No more output
if buffer:
# Emit any remaining partial line
await self.sio.emit('cli_output', {
'type': 'stdout',
'line': buffer.decode('utf-8', errors='replace')
})
break
buffer += chunk
*lines, buffer = buffer.split(b'\n')
for line in lines:
await self.sio.emit('cli_output', {
'type': 'stdout',
'line': line.decode('utf-8', errors='replace')
})
try:
os.close(master_fd)
except Exception as e:
LOG.error(f'Error closing PTY: {str(e)}')
# Start reading output from PTY
read_task = asyncio.create_task(read_pty())
# Wait until process finishes
returncode = await proc.wait()
# Ensure reading is done
await read_task
await self.sio.emit('cli_close', {'exitcode': returncode})
except Exception as e:
LOG.error(f'CLI Error: {str(e)}')
await self.sio.emit('cli_output', {
'type': 'stderr',
'line': str(e),
})
await self.sio.emit('cli_close', {'exitcode': -1})
@self.sio.event()
async def add_url(sid, post: dict):
url: str = post.get('url')
quality: str = post.get('quality')
if not url:
self.notifier.warning('No URL provided.')
return
format: str = post.get('format')
folder: str = post.get('folder')
ytdlp_cookies: str = post.get('ytdlp_cookies')
ytdlp_config: dict = post.get('ytdlp_config')
output_template: str = post.get('output_template')
if ytdlp_config is None:
ytdlp_config = {}
status = await self.add(
url=url,
quality=quality,
format=format,
folder=folder,
ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config,
output_template=output_template
)
await self.sio.emit('status', self.serializer.encode(status))
@self.sio.event
async def item_cancel(sid, id: str):
if not id:
await self.notifier.warning('Invalid request.')
return
status: dict[str, str] = {}
status = await self.queue.cancel([id])
status.update({'identifier': id})
await self.sio.emit('item_cancel', self.serializer.encode(status))
@self.sio.event
async def item_delete(sid, id: str):
if not id:
await self.notifier.warning('Invalid request.')
return
status: dict[str, str] = {}
status = await self.queue.clear([id])
status.update({'identifier': id})
await self.sio.emit('item_delete', self.serializer.encode(status))
@self.sio.event()
async def archive_item(sid, data):
if not isinstance(data, dict) or 'url' not in data or not self.config.keep_archive:
return
file: str = self.config.ytdl_options.get('download_archive', None)
if not file:
return
exists, idDict = isDownloaded(file, data['url'])
if exists or 'archive_id' not in idDict or idDict['archive_id'] is None:
return
with open(file, 'a') as f:
f.write(f"{idDict['archive_id']}\n")
manual_archive = self.config.manual_archive
if manual_archive:
previouslyArchived = False
if os.path.exists(manual_archive):
with open(manual_archive, 'r') as f:
for line in f.readlines():
if idDict['archive_id'] in line:
previouslyArchived = True
break
if not previouslyArchived:
with open(manual_archive, 'a') as f:
f.write(f"{idDict['archive_id']} - at: {datetime.now().isoformat()}\n")
LOG.info(f'Archiving item: {data["url"]=}')
@self.sio.event()
async def connect(sid, environ):
await self.connect(sid, environ)
if self.config.url_prefix != '/':
@self.routes.get('/')
async def redirect_d(_) -> Response:
return web.HTTPFound(self.config.url_prefix)
@self.routes.get(self.config.url_prefix[:-1])
async def redirect_w(_) -> Response:
return web.HTTPFound(self.config.url_prefix)
@self.routes.options(self.config.url_prefix + 'add')
async def add_cors(_) -> Response:
return web.Response(text=self.serializer.encode({"status": "ok"}))
@self.routes.options(f'{self.config.url_prefix}delete')
async def delete_cors(_) -> Response:
return web.Response(text=self.serializer.encode({"status": "ok"}))
self.routes.static(f'{self.config.url_prefix}download/', self.config.download_path)
async def staticFile(req: Request) -> Response:
if req.path not in self.staticHolder:
return web.HTTPNotFound()
item: dict = self.staticHolder[req.path]
return web.Response(body=item.get('content'), headers={
'Pragma': 'public',
'Cache-Control': 'public, max-age=31536000',
'Content-Type': item.get('content_type'),
'X-Via': 'memory' if not item.get('file', None) else 'disk',
})
staticDir = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'ui/dist')
for root, _, files in os.walk(staticDir):
for file in files:
if file.endswith('.map'):
continue
file = os.path.join(root, file)
urlPath = f"{self.config.url_prefix}{file.replace(f'{staticDir}/', '')}"
self.staticHolder[urlPath] = {
# 'file': file,
'content': open(file, 'rb').read(),
'content_type': self.extToMime.get(os.path.splitext(file)[1], MIME.from_file(file)),
}
self.app.router.add_get(urlPath, staticFile)
LOG.debug(f'Preloading: [{urlPath}].')
try:
self.app.add_routes(self.routes)
async def on_prepare(request, response):
if 'Origin' in request.headers:
response.headers['Access-Control-Allow-Origin'] = request.headers['Origin']
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
response.headers['Access-Control-Allow-Methods'] = 'PUT, POST, DELETE'
self.app.on_response_prepare.append(on_prepare)
except ValueError as e:
if 'ui/dist' in str(e):
raise RuntimeError('Could not find the frontend UI static assets.') from e
raise e
async def add(
self,
url: str,
quality: str,
format: str,
folder: str,
ytdlp_cookies: str,
ytdlp_config: dict,
output_template: str
) -> dict[str, str]:
if ytdlp_config is None:
ytdlp_config = {}
if ytdlp_cookies and isinstance(ytdlp_cookies, dict):
ytdlp_cookies = self.serializer.encode(ytdlp_cookies)
status = await self.queue.add(
url=url,
format=format if format else 'any',
quality=quality if quality else 'best',
folder=folder,
ytdlp_cookies=ytdlp_cookies,
ytdlp_config=ytdlp_config,
output_template=output_template
def start(self):
self.socket.attach(self.app)
self.http.attach(self.app)
self.load_tasks()
start: str = f'YTPTube v{self.config.version} - listening on http://{self.config.host}:{self.config.port}'
web.run_app(
self.app,
host=self.config.host,
port=self.config.port,
reuse_port=True,
loop=asyncio.get_event_loop(),
access_log=None,
print=lambda _: LOG.info(start)
)
return status
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
main = Main()
main().start()

23
frontend/.gitignore vendored
View file

@ -1,23 +0,0 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View file

@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

12442
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,58 +0,0 @@
{
"name": "YTPTube",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"watch": "vue-cli-service build --watch"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-regular-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/vue-fontawesome": "^3.0.5",
"@vueuse/core": "^10.6.1",
"core-js": "^3.8.3",
"cron-parser": "^4.9.0",
"floating-vue": "^5.0.2",
"hls.js": "^1.4.12",
"moment": "^2.29.4",
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2",
"vue": "^3.4.27",
"vue-toastification": "^2.0.0-rc.5",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "^5.0.8",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta name="theme-color" content="#000000">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>
<%= htmlWebpackPlugin.options.title %>
</title>
</head>
<body>
<noscript>
<strong>
We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please
enable it to continue.
</strong>
</noscript>
<div class="container">
<div id="app"></div>
</div>
<!-- built files will be auto injected -->
</body>
</html>

View file

@ -1,251 +0,0 @@
<template>
<PageHeader :config="config" @toggleForm="addForm = !addForm" @toggleTasks="showTasks = !showTasks"
@toggleConsole="showConsole = !showConsole" @toggleConsole2="showConsole2 = !showConsole2" @reload="reloadWindow" />
<CliConsole v-if="showConsole" @runCommand="runCommand" :cli_output="cli_output" :isLoading="cli_isLoading"
@cli_clear="cli_output = []" />
<template v-else>
<formAdd v-if="addForm" :config="config" @addItem="addItem" />
<pageTasks v-if="showTasks" :tasks="config.tasks" />
<DownloadingList :config="config" :queue="downloading" @deleteItem="deleteItem" />
<PageCompleted :config="config" :completed="completed" @deleteItem="deleteItem" @addItem="addItem"
@playItem="playItem" @archiveItem="archiveItem" />
</template>
<div class="modal is-active" v-if="video_link">
<div class="modal-background"></div>
<div class="modal-content">
<VideoPlayer type="default" :link="video_link" :isMuted="false" autoplay="true" :isControls="true"
:title="video_title" class="is-fullwidth" @closeModel="video_link = ''; video_title = ''" />
</div>
<button class="modal-close is-large" aria-label="close" @click="video_link = ''"></button>
</div>
<PageFooter :app_version="config?.app?.version || 'unknown'" :ytdlp_version="config?.app.ytdlp_version || 'unknown'"
:started="config?.app.started || 0" />
</template>
<script setup>
import { onMounted, reactive, ref, inject } from 'vue'
import PageHeader from './components/Page-Header'
import formAdd from './components/Form-Add'
import pageTasks from './components/Page-Tasks'
import DownloadingList from './components/Page-Downloading'
import PageCompleted from './components/Page-Completed'
import PageFooter from './components/Page-Footer'
import VideoPlayer from './components/Video-Player'
import { io } from "socket.io-client";
import { useToast } from 'vue-toastification'
import { useStorage, useEventBus } from '@vueuse/core'
import CliConsole from './components/CLI-Console'
const toast = useToast()
const bus = useEventBus('item_added', 'show_form', 'task_edit')
const config = reactive({
isConnected: false,
app: {},
tasks: [],
})
const makeDownload = inject('makeDownload')
const socket = ref()
const downloading = reactive({})
const completed = reactive({})
const video_link = ref('')
const video_title = ref('')
const addForm = useStorage('addForm', true)
const showTasks = useStorage('showTasks', false)
const cli_output = ref([])
const cli_isLoading = ref(false)
const showConsole = ref(false)
const showConsole2 = ref(false)
const runCommand = (args) => {
cli_output.value = [];
cli_isLoading.value = true;
socket.value.emit('cli_post', args);
}
const sendData = (type, data, stringify = false) => {
if (!socket.value) {
return;
}
if (true === Boolean(stringify)) {
data = JSON.stringify(data);
}
socket.value.emit(type, data);
}
onMounted(() => {
socket.value = io(process.env.VUE_APP_BASE_URL, {
path: document.location.pathname + 'socket.io',
})
socket.value.on('connect', () => config.isConnected = true);
socket.value.on('disconnect', () => config.isConnected = false);
socket.value.on('initial_data', stream => {
const initialData = JSON.parse(stream);
config.app = initialData['config'];
config.tasks = initialData['tasks'];
config.directories = initialData['directories'];
for (const id in initialData['queue']) {
downloading[id] = initialData['queue'][id];
}
for (const id in initialData['done']) {
completed[id] = initialData['done'][id];
}
})
socket.value.on('added', stream => {
const item = JSON.parse(stream);
downloading[item._id] = item;
toast.success(`Item queued successfully: ${downloading[item._id]?.title}`);
});
socket.value.on('error', stream => {
const [item, error] = JSON.parse(stream);
toast.error(`${item?.id}: Error: ${error}`);
});
socket.value.on('completed', stream => {
const item = JSON.parse(stream);
if (item._id in downloading) {
delete downloading[item._id];
}
completed[item._id] = item;
});
socket.value.on('canceled', stream => {
const id = JSON.parse(stream);
if (false === (id in downloading)) {
return
}
toast.info('Download canceled: ' + downloading[id]?.title);
delete downloading[id];
});
socket.value.on('cleared', stream => {
const id = JSON.parse(stream);
if (false === (id in completed)) {
return;
}
delete completed[id];
});
socket.value.on("updated", stream => {
const data = JSON.parse(stream);
let dl = downloading[data._id] ?? {};
data.deleting = dl?.deleting;
downloading[data._id] = data;
});
socket.value.on("update", stream => {
const data = JSON.parse(stream);
if (false === (data._id in completed)) {
return;
}
completed[data._id] = data;
});
socket.value.on('cli_close', () => cli_isLoading.value = false);
socket.value.on('cli_output', s => cli_output.value.push(s));
});
const archiveItem = (type, item) => {
if (!confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) {
return
}
sendData('archive_item', item);
deleteItem(type, item._id);
}
const deleteItem = (type, item) => {
const items = []
if (typeof item === 'object') {
for (const key in item) {
items.push(item[key]);
}
} else {
items.push(item);
}
if (items.length < 0) {
return;
}
fetch(`${config.app.url_host}${config.app.url_prefix}delete`, {
method: 'DELETE',
body: JSON.stringify({
where: type === 'completed' ? 'done' : 'queue',
ids: items,
}),
headers: {
'Content-Type': 'application/json'
}
}).then(resp => resp.json()).then(json => {
for (const key in items) {
const itemId = items[key];
if (itemId in json && json[itemId] === 'ok') {
if (true === (itemId in completed)) {
delete completed[itemId];
}
if (true === (itemId in downloading)) {
toast.info('Download canceled: ' + downloading[itemId]?.title);
delete downloading[itemId];
}
}
}
}).catch((error) => {
console.log(error);
toast.error('Failed to delete/cancel item/s. ' + error);
});
}
const addItem = (item) => {
fetch(config.app.url_host + config.app.url_prefix + 'add', {
method: 'POST',
body: JSON.stringify(item),
headers: {
'Content-Type': 'application/json'
}
}).then((response) => response.json()).then((data) => {
if (data.status === 'error') {
const message = data.msg?.toString() || 'Failed to add download.';
bus.emit('item_added', { status: "error", msg: message });
toast.error(`${data.status[0].toUpperCase() + data.status.slice(1)}: ${message}`);
return;
}
bus.emit('item_added', { status: "ok" });
}).catch((error) => {
console.log(error);
bus.emit('item_added', { status: "error", msg: error });
toast.error(`Failed to add link. ${error.message}`);
});
};
const playItem = item => {
video_link.value = makeDownload(config, item, 'm3u8');
video_title.value = item.title;
}
const reloadWindow = () => window.location.reload();
bus.on((event, data) => {
if (!['show_form'].includes(event)) {
return true;
}
if ('show_form' === event) {
addForm.value = true;
setTimeout(() => bus.emit('task_edit', data), 500);
}
});
</script>

File diff suppressed because it is too large Load diff

View file

@ -1,190 +0,0 @@
* {
unicode-bidi: plaintext;
}
.container {
padding: 1em;
margin-top: 1em;
}
.container,
.card,
.box,
.navbar {
border-radius: 15px;
}
.is-bordered-danger {
border: 1px solid red;
}
.is-bordered-info {
border: 1px solid #3e8ed0;
}
html,
body {
background-color: #eaeaea;
}
.container {
background-color: #ffffff;
}
hr {
background-color: #000;
}
.is-unselectable {
user-select: none;
}
.is-text-overflow {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (prefers-color-scheme: dark) {
* {
unicode-bidi: plaintext;
}
.container {
padding: 1em;
margin-top: 1em;
}
.container,
.card,
.box,
.navbar {
border-radius: 15px;
}
.is-bordered-danger {
border: 1px solid red;
}
.is-bordered-info {
border: 1px solid #3e8ed0;
}
hr {
background-color: #fff;
}
html,
body {
background-color: #000000;
}
.container {
background-color: #0f1010;
}
.is-unselectable {
user-select: none;
}
.is-text-overflow {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.is-borderless {
border: none;
}
.is-marginless {
margin: 0 !important;
}
.is-paddingless {
padding: 0 !important;
}
.section {
padding: 3rem 3rem;
}
.section.is-medium {
padding: 9rem 4.5rem;
}
.section.is-large {
padding: 18rem 6rem;
}
.footer {
background-color: #fafafa;
padding: 3rem 1.5rem 6rem;
}
.progress-bar {
border-radius: 15px;
position: relative;
width: 100%;
height: 30px;
background-color: #F5F5F5;
}
.progress,
.progress-percentage {
border-radius: 15px;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
.progress-percentage {
text-align: center;
z-index: 2;
line-height: 30px;
}
.progress {
z-index: 1;
background-color: #00d1b2;
}
@media (prefers-color-scheme: dark) {
.footer {
background-color: #121212;
}
.progress-bar {
border-radius: 15px;
position: relative;
width: 100%;
height: 30px;
background-color: #383636;
}
.progress,
.progress-percentage {
border-radius: 15px;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
.progress-percentage {
text-align: center;
z-index: 2;
line-height: 30px;
}
.progress {
z-index: 1;
background-color: #087363;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View file

@ -1,173 +0,0 @@
<template>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix">
<h1 class="title is-4">Console</h1>
<div class="subtitle is-6">
You can execute <strong>non-interactive</strong> commands here. The interface jailed to the <code>yt-dlp</code>
command.
</div>
</div>
<div class="column is-12">
<div class="card">
<header class="card-header">
<p class="card-header-title">
<span class="icon"><font-awesome-icon icon="fa-solid fa-terminal" /></span> Terminal
</p>
<p class="card-header-icon">
<span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput"><font-awesome-icon
icon="fa-solid fa-broom" /></span>
</p>
</header>
<section class="card-content p-0 m-0">
<div ref="terminal_window" style="min-height: 60vh;max-height:70vh;" />
</section>
<section class="card-content p-1 m-1">
<div class="field is-grouped">
<div class="control">
<span class="icon-text input is-unselectable">
<span class="icon"><font-awesome-icon icon="fa-solid fa-terminal" /></span>
<span>yt-dlp</span>
</span>
</div>
<div class="control is-expanded">
<input type="text" class="input" v-model="command" placeholder="ls -la" autocomplete="off"
ref="command_input" @keydown.enter="runCommand" :disabled="props.isLoading" id="command">
</div>
<p class="control">
<button class="button is-primary" type="button" :disabled="props.isLoading || '' === command"
@click="runCommand">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-spinner" spin v-if="props.isLoading" />
<font-awesome-icon icon="fa-solid fa-paper-plane" v-else />
</span>
</button>
</p>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, defineProps, onMounted, defineEmits, onUnmounted, nextTick } from 'vue'
import "@xterm/xterm/css/xterm.css"
import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit"
const emitter = defineEmits(['runCommand', 'cli_clear']);
const terminal = ref()
const terminalFit = ref()
const command = ref('')
const terminal_window = ref()
const command_input = ref()
const props = defineProps({
isLoading: {
type: Boolean,
required: false,
default: false
},
cli_output: {
type: Array,
required: false,
default: () => []
}
})
watch(() => props.isLoading, async value => {
if (value) {
return
}
command.value = ''
await nextTick();
focusInput()
}, { immediate: true })
watch(() => props.cli_output, v => {
if (!terminal.value) {
return
}
if (!v.length) {
return
}
// -- display last output
const data = v.slice(-1)
terminal.value.writeln(data[0].line)
}, {
deep: true
})
const reSizeTerminal = () => {
if (!terminal.value) {
return
}
terminalFit.value.fit()
}
const runCommand = async () => {
if ('' === command.value) {
return
}
if (!terminal.value) {
terminal.value = new Terminal({
fontSize: 14,
fontFamily: "'JetBrains Mono', monospace",
cursorBlink: false,
cursorStyle: 'underline',
cols: 108,
rows: 10,
buffer: 1000,
disableStdin: true,
scrollback: 1000,
})
terminalFit.value = new FitAddon()
terminal.value.loadAddon(terminalFit.value)
terminal.value.open(terminal_window.value)
terminalFit.value.fit();
}
if ('clear' === command.value) {
clearOutput(true)
return
}
emitter('runCommand', command.value)
terminal.value.writeln(`user@YTPTube ~`)
terminal.value.writeln(`$ yt-dlp ${command.value}`)
}
const clearOutput = async (withCommand = false) => {
if (terminal.value) {
terminal.value.clear()
}
emitter('cli_clear', {})
if (true === withCommand) {
command.value = ''
}
focusInput()
}
onMounted(async () => {
window.addEventListener('resize', reSizeTerminal);
focusInput()
})
const focusInput = () => {
if (!command_input.value) {
return
}
command_input.value.focus()
}
onUnmounted(() => window.removeEventListener('resize', reSizeTerminal));
</script>

View file

@ -1,142 +0,0 @@
<template>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix">
<h1 class="title is-4">Console</h1>
<div class="subtitle is-6">
You can execute <strong>non-interactive</strong> commands here. The interface jailed to the <code>yt-dlp</code>
command.
</div>
</div>
<div class="column is-12">
<div class="card">
<header class="card-header">
<p class="card-header-title">
<span class="icon"><font-awesome-icon icon="fa-solid fa-terminal" /></span> Terminal
</p>
<p class="card-header-icon">
<span class="icon" @click="clearOutput"><font-awesome-icon icon="fa-solid fa-broom" /></span>
</p>
</header>
<section class="card-content p-0 m-0">
<div ref="terminal_window" style="min-height: 60vh;max-height:70vh;" />
</section>
<section class="card-content p-1 m-1">
<div class="field is-grouped">
<div class="control is-expanded has-icons-left">
<input type="text" class="input" v-model="command" placeholder="ls -la" autocomplete="off"
ref="command_input" @keydown.enter="runCommand" :disabled="props.isLoading" id="command">
<span class="icon is-left"><font-awesome-icon icon="fa-solid fa-terminal" /></span>
</div>
<p class="control">
<button class="button is-primary" type="button" :disabled="props.isLoading || '' === command"
:class="{ 'is-loading': props.isLoading }" @click="runCommand">
<span class="icon"><font-awesome-icon icon="fa-solid fa-paper-plane" /></span>
</button>
</p>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, defineProps, onMounted, defineEmits, onUnmounted, nextTick } from 'vue'
import "@xterm/xterm/css/xterm.css"
import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit"
const emitter = defineEmits(['runCommand', 'cli_clear']);
const terminal = ref()
const terminalFit = ref()
const command = ref('')
const terminal_window = ref()
const command_input = ref()
const props = defineProps({
isLoading: {
type: Boolean,
required: false,
default: false
},
cli_output: {
type: Array,
required: false,
default: () => []
}
})
watch(() => props.isLoading, async value => {
if (value) {
return
}
command.value = ''
await nextTick();
command_input.value.focus()
}, { immediate: true })
watch(() => props.cli_output, v => {
if (!terminal.value) {
return
}
if (!v.length) {
return
}
// -- display last output
const data = v.slice(-1)
terminal.value.writeln(data[0].line)
}, {
deep: true
})
const reSizeTerminal = () => {
if (!terminal.value) {
return
}
terminalFit.value.fit()
}
const runCommand = async () => {
if (!terminal.value) {
terminal.value = new Terminal({
fontSize: 14,
fontFamily: "'JetBrains Mono', monospace",
cursorBlink: false,
cursorStyle: 'underline',
cols: 108,
rows: 10,
disableStdin: true,
})
terminalFit.value = new FitAddon()
terminal.value.loadAddon(terminalFit.value)
terminal.value.open(terminal_window.value)
terminalFit.value.fit();
}
emitter('runCommand', command.value)
terminal.value.writeln(`~ ${command.value}`)
}
const clearOutput = async () => {
if (terminal.value) {
terminal.value.clear()
}
emitter('cli_clear', {})
command_input.value.focus()
}
onMounted(async () => {
window.addEventListener("resize", reSizeTerminal);
command_input.value.focus()
})
onUnmounted(() => window.removeEventListener("resize", reSizeTerminal));
</script>

View file

@ -1,230 +0,0 @@
<template>
<main class="columns mt-2">
<div class="column">
<div class="box">
<div class="columns is-multiline">
<div class="column is-12">
<div class="control has-icons-left">
<input type="url" class="input" id="url" placeholder="Video or playlist link"
:disabled="!config.isConnected || addInProgress" v-model="url">
<span class="icon is-small is-left">
<font-awesome-icon icon="fa-solid fa-link" />
</span>
</div>
</div>
<div class="column is-3">
<div class="field has-addons">
<div class="control">
<a href="#" class="button is-static">Format</a>
</div>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select id="format" class="is-fullwidth" :disabled="!config.isConnected" v-model="selectedFormat"
@change="updateQualities">
<option v-for="item in downloadFormats" :key="item.id" :value="item.id">
{{ item.text }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-3">
<div class="field has-addons">
<div class="control">
<a href="#" class="button is-static">Quality</a>
</div>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select id="quality" class="is-fullwidth" v-model="selectedQuality" :disabled="!config.isConnected">
<option v-for="item in qualities" :key="item.id" :value="item.id">{{ item.text }}</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-4">
<div class="field has-addons" v-tooltip="'Download path relative to ' + config.app.download_path">
<div class="control">
<a href="#" class="button is-static">Download Path</a>
</div>
<div class="control is-expanded">
<input type="text" class="input is-fullwidth" id="path" v-model="downloadPath" placeholder="Default"
:disabled="!config.isConnected" list="directories">
</div>
</div>
</div>
<div class="column">
<button type="submit" class="button is-primary" @click="addDownload"
:class="{ 'is-loading': !config.isConnected || addInProgress }"
:disabled="!config.isConnected || addInProgress || !url">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-plus" />
</span>
<span>Add Link</span>
</button>
</div>
<div class="column">
<button type="submit" class="button is-info" @click="showAdvanced = !showAdvanced"
v-tooltip="'Show advanced options'" :class="{ 'is-loading': !config.isConnected }"
:disabled="!config.isConnected">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-cog" />
</span>
</button>
</div>
</div>
<div class="columns is-multiline" v-if="showAdvanced">
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="output_format"
v-tooltip="'Default Format: ' + config.app.output_template">
Output Format
</label>
<div class="control">
<input type="text" class="input" v-model="output_template" id="output_format"
placeholder="Uses default format if non is given.">
</div>
<span class="subtitle is-6 has-text-info">
All format options can be found at <a class="has-text-danger" target="_blank"
referrerpolicy="no-referrer" href="https://github.com/yt-dlp/yt-dlp#output-template">this page</a>.
</span>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label is-inline" for="ytdlpConfig"
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config
</label>
<div class="control">
<textarea class="textarea" id="ytdlpConfig" v-model="ytdlpConfig"
:disabled="!config.isConnected"></textarea>
</div>
<span class="subtitle is-6 has-text-info">
Some config fields are ignored like cookiefile, path, and output_format etc.
Available option can be found at <a class="has-text-danger" target="_blank" referrerpolicy="no-referrer"
href="https://github.com/yt-dlp/yt-dlp/blob/a0b19d319a6ce8b7059318fa17a34b144fde1785/yt_dlp/YoutubeDL.py#L194">this
page</a>.
</span>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label is-inline" for="ytdlpCookies" v-tooltip="'JSON exported cookies for downloading.'">
yt-dlp Cookies
</label>
<div class="control">
<textarea class="textarea" id="ytdlpCookies" v-model="ytdlpCookies"
:disabled="!config.isConnected"></textarea>
</div>
<span class="subtitle is-6 has-text-info">
Use something like <a class="has-text-danger" href="https://github.com/jrie/flagCookies">flagCookies</a>
to extract cookies as JSON string.
</span>
</div>
</div>
<div class="column is-12 has-text-right">
<div class="field">
<div class="control">
<button type="submit" class="button is-danger" @click="resetConfig" :disabled="!config.isConnected"
v-tooltip="'This configuration are stored locally in your browser.'">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-trash" />
</span>
<span>Reset Local Configuration</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<datalist id="directories" v-if="config?.directories">
<option v-for="dir in config.directories" :key="dir" :value="dir" />
</datalist>
</main>
</template>
<script setup>
import { defineEmits, defineProps, onMounted, ref } from 'vue'
import { downloadFormats } from '../formats.js'
import { useStorage, useEventBus } from '@vueuse/core'
const bus = useEventBus('item_added', 'task_edit');
const emits = defineEmits(['addItem']);
const selectedFormat = useStorage('selectedFormat', 'any')
const selectedQuality = useStorage('selectedQuality', '')
const ytdlpConfig = useStorage('ytdlp_config', '')
const ytdlpCookies = useStorage('ytdlp_cookies', '')
const output_template = useStorage('output_template', null)
const downloadPath = useStorage('downloadPath', null)
const url = useStorage('downloadUrl', null)
const showAdvanced = useStorage('show_advanced', false)
const qualities = ref([])
const addInProgress = ref(false)
const updateQualities = () => {
for (const key in downloadFormats) {
const item = downloadFormats[key];
if (item.id === selectedFormat.value) {
qualities.value = item.qualities;
break;
}
}
selectedQuality.value = qualities.value[0].id;
}
defineProps({
config: {
type: Object,
required: true
}
});
onMounted(() => {
updateQualities();
})
const addDownload = () => {
addInProgress.value = true;
emits('addItem', {
url: url.value,
format: selectedFormat.value,
quality: selectedQuality.value,
folder: downloadPath.value,
ytdlp_config: ytdlpConfig.value,
ytdlp_cookies: ytdlpCookies.value,
output_template: output_template.value,
});
}
const resetConfig = () => {
if (!confirm('Are you sure you want to reset the local configuration? this will NOT delete any downloads or server configuration.')) {
return;
}
selectedFormat.value = 'any';
selectedQuality.value = '';
ytdlpConfig.value = '';
ytdlpCookies.value = '';
output_template.value = null;
url.value = '';
downloadPath.value = '';
}
bus.on((event, data) => {
const allowedEvents = ['item_added'];
if (!allowedEvents.includes(event)) {
return;
}
if ('item_added' === event) {
if (data?.status === 'ok') {
url.value = '';
}
addInProgress.value = false;
}
});
</script>

View file

@ -1,76 +0,0 @@
<template>
<div ref="targetEl" :style="`min-height:${fixedMinHeight ? fixedMinHeight : (minHeight)}px`">
<slot v-if="shouldRender"/>
</div>
</template>
<script>
import {nextTick, ref} from 'vue'
import {useIntersectionObserver} from '@vueuse/core'
function onIdle(cb = () => {
}) {
if ("requestIdleCallback" in window) {
window.requestIdleCallback(cb);
} else {
setTimeout(() => nextTick(cb), 300);
}
}
export default {
props: {
renderOnIdle: Boolean,
unrender: Boolean,
minHeight: Number,
unrenderDelay: {
type: Number,
default: 6000,
},
},
setup(props) {
const shouldRender = ref(false);
const targetEl = ref();
const fixedMinHeight = ref(0);
let unrenderTimer;
let renderTimer;
const {stop} = useIntersectionObserver(targetEl, ([{isIntersecting}]) => {
if (isIntersecting) {
// perhaps the user re-scrolled to a component that was set to unrender. In that case stop the un-rendering timer
clearTimeout(unrenderTimer);
// if we're dealing under-rendering lets add a waiting period of 200ms before rendering.
// If a component enters the viewport and also leaves it within 200ms it will not render at all.
// This saves work and improves performance when user scrolls very fast
renderTimer = setTimeout(() => (shouldRender.value = true), props.unrender ? 200 : 0);
shouldRender.value = true;
if (!props.unrender) {
stop();
}
} else if (props.unrender) {
// if the component was set to render, cancel that
clearTimeout(renderTimer);
unrenderTimer = setTimeout(() => {
if (targetEl.value?.clientHeight) {
fixedMinHeight.value = targetEl.value.clientHeight;
}
shouldRender.value = false;
}, props.unrenderDelay);
}
}, {
rootMargin: "600px",
}
);
if (props.renderOnIdle) {
onIdle(() => {
shouldRender.value = true;
if (!props.unrender) {
stop();
}
});
}
return {targetEl, shouldRender, fixedMinHeight};
},
};
</script>

View file

@ -1,395 +0,0 @@
<template>
<h1 class="mt-3 is-size-3 is-clickable is-unselectable" @click="showCompleted = !showCompleted">
<span class="icon-text title is-4">
<span class="icon">
<font-awesome-icon :icon="showCompleted ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
</span>
<span>History <span v-if="hasItems">({{ getTotal }})</span></span>
</span>
</h1>
<div v-if="showCompleted">
<div class="columns is-multiline is-mobile has-text-centered" v-if="hasItems">
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-ghost is-inverted"
@click="masterSelectAll = !masterSelectAll">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon :icon="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
</span>
<span v-if="!masterSelectAll">Select All</span>
<span v-else>Unselect All</span>
</span>
</button>
</div>
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-danger" :disabled="!hasSelected"
@click="$emit('deleteItem', 'completed', selectedElms); selectedElms = []">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-trash-can" />
</span>
<span>Remove Selected</span>
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasCompleted">
<button type="button" class="button is-fullwidth is-primary is-inverted" @click="clearCompleted">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-circle-check" />
</span>
<span>Clear Completed</span>
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasIncomplete">
<button type="button" class="button is-fullwidth is-info is-inverted" @click="clearIncomplete">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-circle-xmark" />
</span>
<span>Clear Incomplete</span>
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasIncomplete">
<button type="button" class="button is-fullwidth is-warning is-inverted" @click="requeueIncomplete">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-rotate-right" />
</span>
<span>Re-queue Incomplete</span>
</span>
</button>
</div>
<div class="column is-1-tablet">
<button type="button" class="button is-fullwidth" @click="direction = direction === 'desc' ? 'asc' : 'desc'">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon
:icon="direction === 'desc' ? 'fa-solid fa-arrow-down-a-z' : 'fa-solid fa-arrow-up-a-z'" />
</span>
</span>
</button>
</div>
</div>
<div class="columns is-multiline">
<LazyLoader :unrender="true" :min-height="210" class="column is-6" v-for="item in sortCompleted" :key="item._id">
<div class="card"
:class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }">
<header class="card-header has-tooltip">
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
<a v-if="item.filename" referrerpolicy="no-referrer" :href="makeDownload(config, item, 'm3u8')"
@click.prevent="$emit('playItem', item)">
{{ item.title }}
</a>
<span v-else>{{ item.title }}</span>
</div>
<div class="card-header-icon" v-if="item.filename">
<a :href="makeDownload(config, item)" :download="item.filename?.split('/').reverse()[0]" class="has-text-primary"
v-tooltip="'Download item.'">
<span class="icon"><font-awesome-icon icon="fa-solid fa-download" /></span>
</a>
</div>
</header>
<div class="card-content">
<div class="columns is-mobile is-multiline">
<div class="column is-12" v-if="item.live_in">
<span class="has-text-info">
LIVE stream is scheduled to start at {{ moment(item.live_in).format() }}
</span>
</div>
<div class="column is-12" v-if="item.error">
<span class="has-text-danger">{{ item.error }}</span>
</div>
<div class="column is-12" v-if="showMessage(item)">
<span class="has-text-danger">{{ item.msg }}</span>
</div>
<div class="column is-half-mobile has-text-centered">
<span v-if="!item.live_in && !item.is_live">
<span class="icon-text">
<span class="icon"
:class="{ 'has-text-success': item.status === 'finished', 'has-text-danger': item.status !== 'finished' }">
<font-awesome-icon :icon="setIcon(item)" />
</span>
<span v-if="item.status == 'finished' && item.is_live">Stream Ended</span>
<span v-else>{{ capitalize(item.status) }}</span>
</span>
</span>
<span v-else>
<span class="icon-text">
<span class="icon has-text-info">
<font-awesome-icon icon="fa-solid fa-calendar" />
</span>
<span>Live Stream</span>
</span>
</span>
</div>
<div class="column is-half-mobile has-text-centered">
<span :date-datetime="item.datetime" v-tooltip="moment(item.datetime).format('YYYY-M-DD H:mm Z')">
{{ moment(item.datetime).fromNow() }}
</span>
</div>
<div class="column is-half-mobile has-text-centered" v-if="item.live_in && item.status != 'finished'">
<span :date-datetime="item.datetime"
v-tooltip="'Will start at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')">
{{ moment(item.live_in).fromNow() }}
</span>
</div>
<div class="column is-half-mobile has-text-centered" v-if="item.file_size">
{{ formatBytes(item.file_size) }}
</div>
<div class="column is-half-mobile has-text-centered">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
:value="item._id">
Select
</label>
</div>
</div>
<div class="columns is-mobile is-multiline">
<div class="column is-half-mobile" v-if="item.status != 'finished'">
<a class="button is-warning is-fullwidth" v-tooltip="'Re-queue incomplete download.'"
@click="reQueueItem(item)">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-rotate-right" />
</span>
<span>Re-queue</span>
</span>
</a>
</div>
<div class="column is-half-mobile">
<a class="button is-danger is-fullwidth" @click="$emit('deleteItem', 'completed', item._id)">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-trash-can" />
</span>
<span>Remove</span>
</span>
</a>
</div>
<div class="column is-half-mobile" v-if="config.app?.keep_archive && item.status != 'finished'">
<a class="button is-danger is-light is-fullwidth" v-tooltip="'Add link to archive.'"
@click="$emit('archiveItem', 'completed', item)">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-box-archive" />
</span>
<span>Archive</span>
</span>
</a>
</div>
<div class="column is-half-mobile">
<a referrerpolicy="no-referrer" class="button is-link is-fullwidth" target="_blank" :href="item.url">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-up-right-from-square" />
</span>
<span>Visit Link</span>
</span>
</a>
</div>
</div>
</div>
</div>
</LazyLoader>
</div>
<div class="content has-text-centered" v-if="!hasItems">
<p v-if="config.isConnected">
<span class="icon-text">
<span class="icon has-text-success">
<font-awesome-icon icon="fa-solid fa-circle-check" />
</span>
<span>No records.</span>
</span>
</p>
<p v-else>
<span class="icon-text">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-spinner fa-spin" />
</span>
<span>Connecting...</span>
</span>
</p>
</div>
</div>
</template>
<script setup>
import { defineProps, computed, ref, watch, defineEmits } from 'vue';
import moment from "moment";
import { useStorage } from '@vueuse/core'
import LazyLoader from './Lazy-Loader'
const emits = defineEmits(['deleteItem', 'addItem', 'playItem', 'archiveItem']);
const props = defineProps({
completed: {
type: Object,
required: true
},
config: {
type: Object,
required: true
},
})
const selectedElms = ref([]);
const masterSelectAll = ref(false);
const showCompleted = useStorage('showCompleted', true)
const direction = useStorage('sortCompleted', 'desc')
watch(masterSelectAll, (value) => {
for (const key in props.completed) {
const element = props.completed[key];
if (value) {
selectedElms.value.push(element._id);
} else {
selectedElms.value = [];
}
}
})
const sortCompleted = computed(() => {
const thisDirection = direction.value;
return Object.values(props.completed).sort((a, b) => {
if (thisDirection === 'asc') {
return new Date(a.datetime) - new Date(b.datetime);
}
return new Date(b.datetime) - new Date(a.datetime);
})
})
const hasSelected = computed(() => selectedElms.value.length > 0)
const hasItems = computed(() => Object.keys(props.completed)?.length > 0)
const getTotal = computed(() => Object.keys(props.completed)?.length);
const showMessage = (item) => {
if (!item?.msg || item.msg === item?.error) {
return false
}
return item.msg.length > 0;
}
const hasIncomplete = computed(() => {
if (Object.keys(props.completed)?.length < 0) {
return false;
}
for (const key in props.completed) {
const element = props.completed[key];
if (element.status !== 'finished') {
return true;
}
}
return false;
})
const hasCompleted = computed(() => {
if (Object.keys(props.completed)?.length < 0) {
return false;
}
for (const key in props.completed) {
const element = props.completed[key];
if (element.status === 'finished') {
return true;
}
}
return false;
})
const clearCompleted = () => {
const state = confirm('Are you sure you want to clear all completed downloads?');
if (false === state) {
return;
}
const keys = {};
for (const key in props.completed) {
if (props.completed[key].status === 'finished') {
keys[key] = props.completed[key]._id;
}
}
emits('deleteItem', 'completed', keys);
}
const clearIncomplete = () => {
const state = confirm('Are you sure you want to clear all incomplete downloads?');
if (false === state) {
return;
}
const keys = {};
for (const key in props.completed) {
if (props.completed[key].status !== 'finished') {
keys[key] = props.completed[key]._id;
}
}
emits('deleteItem', 'completed', keys);
}
const setIcon = (item) => {
if (item.status === 'finished' && item.is_live) {
return 'fa-solid fa-globe';
}
if (item.status === 'finished') {
return 'fa-solid fa-circle-check';
}
if (item.status === 'error') {
return 'fa-solid fa-circle-xmark';
}
if (item.status === 'canceled') {
return 'fa-solid fa-eject';
}
return 'fa-solid fa-circle';
}
const requeueIncomplete = () => {
if (false === confirm('Are you sure you want to re-queue all incomplete downloads?')) {
return false;
}
for (const key in props.completed) {
const item = props.completed[key];
if (item.status !== 'finished') {
emits('deleteItem', 'completed', key);
emits('addItem', {
url: item.url,
format: item.format,
quality: item.quality,
folder: item.folder,
ytdlp_config: item.ytdlp_config,
ytdlp_cookies: item.ytdlp_cookies,
output_template: item.output_template,
});
}
}
}
const reQueueItem = (item) => {
emits('deleteItem', 'completed', item._id);
emits('addItem', {
url: item.url,
format: item.format,
quality: item.quality,
folder: item.folder,
ytdlp_config: item.ytdlp_config,
ytdlp_cookies: item.ytdlp_cookies,
output_template: item.output_template,
});
}
</script>

View file

@ -1,236 +0,0 @@
<template>
<h1 class="mt-3 is-size-3 is-clickable is-unselectable" @click="showQueue = !showQueue">
<span class="icon-text title is-4">
<span class="icon">
<font-awesome-icon :icon="showQueue ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
</span>
<span>Queue <span v-if="hasQueuedItems">({{ getTotal }})</span></span>
</span>
</h1>
<div v-if="showQueue">
<div class="columns is-multiline is-mobile has-text-centered" v-if="hasQueuedItems">
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-ghost" @click="masterSelectAll = !masterSelectAll">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon :icon="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
</span>
<span v-if="!masterSelectAll">Select All</span>
<span v-else>Unselect All</span>
</span>
</button>
</div>
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-danger" :disabled="!hasSelected"
@click="$emit('deleteItem', 'queue', selectedElms); selectedElms = []">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-trash-can" />
</span>
<span>Cancel Selected</span>
</span>
</button>
</div>
</div>
<div class="columns is-multiline">
<LazyLoader :unrender="true" :min-height="265" class="column is-6" v-for="item in queue" :key="item._id">
<div class="card">
<header class="card-header has-tooltip" v-tooltip="item.title">
<div class="card-header-title has-text-centered is-text-overflow is-block">
{{ item.title }}
</div>
</header>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<div class="progress-bar is-round">
<div class="progress-percentage">{{ updateProgress(item) }}</div>
<div class="progress" :style="{ width: percentPipe(item.percent) + '%' }"></div>
</div>
</div>
<div class="column is-half-mobile has-text-centered">
<span class="icon-text">
<span class="icon" :class="{ 'has-text-success': item.status == 'downloading' }">
<font-awesome-icon :icon="setIcon(item)" />
</span>
<span v-if="item.status == 'downloading' && item.is_live">Live Streaming</span>
<span v-else>{{ capitalize(item.status) }}</span>
</span>
</div>
<div class="column is-half-mobile has-text-centered">
<span :data-datetime="item.datetime" v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')">
{{ moment(item.datetime).fromNow() }}
</span>
</div>
<div class="column is-half-mobile has-text-centered">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
:value="item._id">
Select
</label>
</div>
</div>
<div class="columns is-multiline is-mobile">
<div class="column is-half-mobile">
<button class="button is-danger is-fullwidth" @click="confirmDelete(item);">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-trash-can" />
</span>
<span>Cancel</span>
</span>
</button>
</div>
<div class="column is-half-mobile">
<a referrerpolicy="no-referrer" class="button is-link is-fullwidth" target="_blank" :href="item.url">
<span class="icon-text is-block">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-up-right-from-square" />
</span>
<span>Visit Link</span>
</span>
</a>
</div>
</div>
</div>
</div>
</LazyLoader>
</div>
<div class="content has-text-centered" v-if="!hasQueuedItems">
<p v-if="config.isConnected">
<span class="icon-text">
<span class="icon has-text-success">
<font-awesome-icon icon="fa-solid fa-circle-check" />
</span>
<span>No queued items.</span>
</span>
</p>
<p v-else>
<span class="icon-text">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-spinner fa-spin" />
</span>
<span>Connecting...</span>
</span>
</p>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, ref, watch, computed } from 'vue';
import moment from "moment";
import { useStorage } from '@vueuse/core'
import LazyLoader from './Lazy-Loader'
const emit = defineEmits(['deleteItem']);
const props = defineProps({
queue: {
type: Object,
required: true
},
config: {
type: Object,
required: true
},
})
const selectedElms = ref([]);
const masterSelectAll = ref(false);
const showQueue = useStorage('showQueue', true)
watch(masterSelectAll, (value) => {
for (const key in props.queue) {
const element = props.queue[key];
if (value) {
selectedElms.value.push(element._id);
} else {
selectedElms.value = [];
}
}
})
const hasSelected = computed(() => selectedElms.value.length > 0)
const hasQueuedItems = computed(() => Object.keys(props.queue)?.length > 0)
const getTotal = computed(() => Object.keys(props.queue)?.length);
const setIcon = (item) => {
if (item.status === 'downloading' && item.is_live) {
return 'fa-solid fa-globe';
}
if (item.status === 'downloading') {
return 'fa-solid fa-circle-check';
}
return 'fa-solid fa-spinner fa-spin';
}
const ETAPipe = value => {
if (value === null || 0 === value) {
return 'Live';
}
if (value < 60) {
return `${Math.round(value)}s`;
}
if (value < 3600) {
return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`;
}
const hours = Math.floor(value / 3600)
const minutes = value % 3600
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`;
}
const speedPipe = value => {
if (value === null || 0 === value) {
return '0KB/s';
}
const k = 1024;
const dm = 2;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(value) / Math.log(k));
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
const percentPipe = value => {
if (value === null || 0 === value) {
return '00.00';
}
return parseFloat(value).toFixed(2);
}
const updateProgress = (item) => {
let string = '';
if (item.status == 'preparing') {
return 'Preparing';
}
if (item.status != null) {
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live';
}
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..';
if (item.status != null && item.eta) {
string += ' - ' + ETAPipe(item.eta);
}
return string;
}
const confirmDelete = (item) => {
if (!confirm(`Are you sure you want to delete (${item.title})?`)) {
return false;
}
emit('deleteItem', 'queue', item._id);
return true;
}
</script>

View file

@ -1,49 +0,0 @@
<style>
.user-hint {
user-select: none;
cursor: help;
border-bottom: 1px dotted;
}
</style>
<template>
<div class="columns mt-3 is-mobile">
<div class="column is-8-mobile">
<div class="has-text-left" v-if="app_version">
© {{ Year }} - <a href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</a>
<span class="is-hidden-mobile" v-if="app_version">&nbsp;({{ app_version }})</span>
- <a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a>
<span class="is-hidden-mobile" v-if="ytdlp_version">&nbsp;({{ ytdlp_version }})</span>
</div>
</div>
<div class="column is-4-mobile" v-if="started > 1">
<div class="has-text-right">
<span class="user-hint" v-tooltip="'App Started: ' + moment.unix(started).format('YYYY-M-DD H:mm Z')">
{{ moment.unix(started).fromNow() }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
import moment from "moment";
const Year = new Date().getFullYear()
defineProps({
app_version: {
type: String,
required: true,
},
ytdlp_version: {
type: String,
required: true,
},
started: {
type: Number,
required: true,
},
})
</script>

View file

@ -1,164 +0,0 @@
<template>
<nav class="navbar is-mobile is-dark">
<div class="navbar-brand pl-5">
<a class="navbar-item has-tooltip-bottom" :class="config.isConnected ? 'has-text-success' : 'has-text-danger'"
v-tooltip="config.isConnected ? 'Connected' : 'Connecting'" href="javascript:void(0);">
<b>YTPTube</b>
</a>
</div>
<div class="navbar-end">
<div class="navbar-item">
<button v-tooltip.bottom="'Toggle Console'" class="button is-dark has-tooltip-bottom"
@click="$emit('toggleConsole')">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-terminal" />
</span>
<span class="is-hidden-mobile">Console</span>
</button>
</div>
<div class="navbar-item">
<button v-tooltip.bottom="'Toggle Add Form'" class="button is-dark has-tooltip-bottom"
@click="$emit('toggleForm')">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-plus" />
</span>
<span class="is-hidden-mobile">Add</span>
</button>
</div>
<div class="navbar-item" v-if="config.tasks.length > 0">
<button v-tooltip.bottom="'Toggle Tasks'" class="button is-dark has-tooltip-bottom"
@click="$emit('toggleTasks')">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-tasks" />
</span>
<span class="is-hidden-mobile">Tasks</span>
</button>
</div>
<div class="navbar-item">
<button v-tooltip.bottom="'Reload window'" class="button is-dark has-tooltip-bottom" @click="$emit('reload')">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-rotate-right" />
</span>
</button>
</div>
<div class="navbar-item">
<button v-tooltip.bottom="'Switch to Light theme'" class="button is-dark has-tooltip-bottom"
@click="selectedTheme = 'light'" v-if="selectedTheme == 'dark'">
<span class="icon has-text-warning">
<font-awesome-icon icon="fa-solid fa-sun" />
</span>
</button>
<button v-tooltip.bottom="'Switch to Dark theme'" class="button is-dark has-tooltip-bottom"
@click="selectedTheme = 'dark'" v-if="selectedTheme == 'light'">
<span class="icon">
<font-awesome-icon icon="fa-solid fa-moon" />
</span>
</button>
</div>
</div>
</nav>
</template>
<script setup>
import { defineProps, defineEmits, watch, onMounted } from 'vue'
import { useStorage } from '@vueuse/core'
const selectedTheme = useStorage('theme', (() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')());
defineEmits(['toggleForm', 'toggleTasks', 'toggleConsole', 'reload'])
defineProps({
config: {
type: Object,
required: true
}
})
const applyPreferredColorScheme = (scheme) => {
for (var s = 0; s < document.styleSheets.length; s++) {
for (var i = 0; i < document.styleSheets[s].cssRules.length; i++) {
try {
const rule = document.styleSheets[s].cssRules[i];
if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) {
switch (scheme) {
case "light":
rule.media.appendMedium("original-prefers-color-scheme");
if (rule.media.mediaText.includes("light")) {
rule.media.deleteMedium("(prefers-color-scheme: light)");
}
if (rule.media.mediaText.includes("dark")) {
rule.media.deleteMedium("(prefers-color-scheme: dark)");
}
break;
case "dark":
rule.media.appendMedium("(prefers-color-scheme: light)");
rule.media.appendMedium("(prefers-color-scheme: dark)");
if (rule.media.mediaText.includes("original")) {
rule.media.deleteMedium("original-prefers-color-scheme");
}
break;
default:
rule.media.appendMedium("(prefers-color-scheme: dark)");
if (rule.media.mediaText.includes("light")) {
rule.media.deleteMedium("(prefers-color-scheme: light)");
}
if (rule.media.mediaText.includes("original")) {
rule.media.deleteMedium("original-prefers-color-scheme");
}
break;
}
}
} catch (e) {
// pass
}
}
}
}
onMounted(() => {
try {
applyPreferredColorScheme(selectedTheme.value);
} catch (e) {
// pass
}
})
watch(selectedTheme, (value) => {
try {
applyPreferredColorScheme(value);
} catch (e) {
// pass
}
})
</script>
<style scoped>
.navbar-item {
display: flex;
}
.navbar,
.navbar-menu,
.navbar-start,
.navbar-end {
align-items: stretch;
display: flex;
padding: 0;
}
.navbar-menu {
flex-grow: 1;
flex-shrink: 0;
}
.navbar-start {
justify-content: flex-start;
margin-right: auto;
}
.navbar-end {
justify-content: flex-end;
margin-left: auto;
}
</style>

View file

@ -1,70 +0,0 @@
<template>
<h1 class="mt-3 is-size-3 is-clickable is-unselectable">
<span class="icon-text title is-4">
<span class="icon"><font-awesome-icon icon="fa-solid fa-tasks" /></span>
<span>Tasks</span>
</span>
</h1>
<div class="columns is-multiline">
<div class="column is-12">
<div class="table-container">
<table class="table is-bordered is-narrow is-hoverable is-striped is-fullwidth is-fixed">
<thead class="has-text-centered">
<tr>
<th class="has-text-centered" width="25%">Name</th>
<th class="has-text-centered" width="50%">URL</th>
<th class="has-text-centered" width="25%">Next run</th>
</tr>
</thead>
<tbody>
<tr v-for="item, key in tasks" :key="key">
<td class="is-text-overflow has-text-centered" v-if="item.name">{{ item.name }}</td>
<td class="has-text-centered" v-else>Not set</td>
<td class="is-text-overflow">
<a :href="item.url" target="_blank">
{{ item.url }}
</a>
</td>
<td class="has-text-centered" v-if="item.timer">
<span v-tooltip="'Timer: ' + item.timer">
{{ moment(parseExpression(item.timer).next().toISOString()).fromNow() }}
</span>
</td>
<td class="has-text-centered" v-else>once an hour</td>
</tr>
<tr v-if="tasks.length < 1">
<td colspan="4" class="has-text-centered has-text-danger">
No tasks are defined.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
import moment from "moment";
import { parseExpression } from 'cron-parser';
defineProps({
tasks: {
type: Array,
required: true
},
})
</script>
<style scoped>
table.is-fixed {
table-layout: fixed;
}
div.table-container {
overflow: hidden;
}
</style>

View file

@ -1,135 +0,0 @@
<style>
:root {
--plyr-captions-background: rgba(0, 0, 0, 0.6);
--plyr-captions-text-color: #f3db4d;
--webkit-text-track-display: none;
}
.plyr__caption {
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
font-size: 140%;
font-weight: bold;
}
.plyr--full-ui ::-webkit-media-text-track-container {
display: var(--webkit-text-track-display);
}
</style>
<template>
<video ref="video" :poster="previewImageLink" :controls="isControls" :title="title" playsinline>
<source :src="link" type="application/x-mpegURL" />
</video>
</template>
<script setup>
import { onMounted, onUpdated, ref, defineProps, defineEmits, onUnmounted } from 'vue'
import Hls from 'hls.js'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
const props = defineProps({
previewImageLink: {
type: String,
default: ''
},
link: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
isControls: {
type: Boolean,
default: true
},
})
const emitter = defineEmits(['closeModel'])
const video = ref(null)
let player = null;
let hls = null;
const eventFunc = e => {
if (e.key === 'Escape') {
emitter('closeModel')
}
}
onMounted(() => {
if (/(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)) {
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
}
prepareVideoPlayer()
window.addEventListener('keydown', eventFunc)
})
onUpdated(() => prepareVideoPlayer())
onUnmounted(() => {
if (player) {
player.destroy()
}
if (hls) {
hls.destroy()
}
window.removeEventListener('keydown', eventFunc)
if (props.title) {
window.document.title = 'YTPTube'
}
})
const prepareVideoPlayer = () => {
player = new Plyr(video.value, {
debug: false,
clickToPlay: true,
keyboard: { focused: true, global: true },
controls: [
'play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'
],
fullscreen: {
enabled: true,
fallback: true,
iosNative: true,
},
storage: {
enabled: true,
key: 'plyr'
},
title: props.title,
mediaMetadata: {
title: props.title
},
captions: {
update: true,
}
});
hls = new Hls({
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
fragLoadingTimeOut: 200000,
});
hls.loadSource(props.link)
if (video.value) {
hls.attachMedia(video.value)
}
if (props.title) {
window.document.title = `YTPTube - Playing: ${props.title}`
}
}
</script>

View file

@ -1,65 +0,0 @@
export const downloadFormats = [
{
id: 'any',
text: 'Any',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '1440', text: '1440p' },
{ id: '1080', text: '1080p' },
{ id: '720', text: '720p' },
{ id: '480', text: '480p' },
{ id: 'audio', text: 'Audio Only' },
],
},
{
id: 'mp4',
text: 'MP4',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '1440', text: '1440p' },
{ id: '1080', text: '1080p' },
{ id: '720', text: '720p' },
{ id: '480', text: '480p' },
],
},
{
id: 'm4a',
text: 'M4A',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '192', text: '192 kbps' },
{ id: '128', text: '128 kbps' },
],
},
{
id: 'mp3',
text: 'MP3',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '320', text: '320 kbps' },
{ id: '192', text: '192 kbps' },
{ id: '128', text: '128 kbps' },
],
},
{
id: 'opus',
text: 'OPUS',
qualities: [
{ id: 'best', text: 'Best' },
],
},
{
id: 'wav',
text: 'WAV',
qualities: [
{ id: 'best', text: 'Best' },
],
},
{
id: 'thumbnail',
text: 'Thumbnail',
qualities: [
{ id: 'best', text: 'Best' }
],
},
];

View file

@ -1,79 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
import Toast from 'vue-toastification'
import FloatingVue from 'floating-vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import {
faCog, faTrash, faLink, faPlus, faTrashCan, faCircleXmark, faCircleCheck, faRotateRight, faDownload, faUpRightFromSquare,
faSpinner, faArrowUp, faArrowDown, faTasks, faCalendar, faArrowUpAZ, faArrowDownAZ, faEject, faGlobe, faMoon, faSun,
faTerminal, faBroom, faServer, faPowerOff, faPaperPlane, faBoxArchive,
} from '@fortawesome/free-solid-svg-icons'
import { faSquare, faSquareCheck } from '@fortawesome/free-regular-svg-icons'
import 'vue-toastification/dist/index.css'
import './assets/css/bulma.css'
import './assets/css/style.css'
import 'floating-vue/dist/style.css'
library.add(faCog, faTrash, faLink, faPlus, faTrashCan, faCircleXmark, faCircleCheck, faRotateRight, faDownload,
faUpRightFromSquare, faSquare, faSquareCheck, faSpinner, faArrowUp, faArrowDown, faTasks, faCalendar, faArrowUpAZ,
faArrowDownAZ, faEject, faGlobe, faMoon, faSun, faTerminal, faBroom, faServer, faPowerOff, faBroom, faServer,
faPowerOff, faPaperPlane, faBoxArchive)
const app = createApp(App);
app.config.globalProperties.encodePath = item => {
if (!item) {
return item
}
return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/').replace(/#/g, '%23')
}
app.config.globalProperties.capitalize = s => s && s[0].toUpperCase() + s.slice(1);
app.config.globalProperties.makeDownload = (config, item, base = 'download') => {
let baseDir = 'download' === base ? `${base}/` : 'player/playlist/';
if (item.folder) {
item.folder = item.folder.replace(/#/g, '%23');
baseDir += item.folder + '/';
}
let url = config.app.url_host + app.config.globalProperties.encodePath(config.app.url_prefix + baseDir + item.filename);
return ('m3u8' === base) ? url + '.m3u8' : url;
}
app.config.globalProperties.formatBytes = (bytes, decimals = 2) => {
if (!+bytes) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
}
app.provide('makeDownload', app.config.globalProperties.makeDownload)
app.use(Toast, {
transition: "Vue-Toastification__bounce",
position: "bottom-right",
maxToasts: 5,
newestOnTop: true
});
app.use(FloatingVue, {
themes: {
tooltip: {
placement: 'top',
triggers: ['hover', 'focus', 'touch', 'click'],
}
}
})
app.component('font-awesome-icon', FontAwesomeIcon)
app.mount('#app')

View file

@ -1,15 +0,0 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
publicPath: './',
transpileDependencies: true,
chainWebpack: (config) => {
config.plugin('define').tap((definitions) => {
Object.assign(definitions[0], {
__VUE_OPTIONS_API__: 'true',
__VUE_PROD_DEVTOOLS__: 'false',
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false'
})
return definitions
})
}
})

View file

@ -135,12 +135,14 @@ const focusInput = () => {
command_input.value.focus()
}
const writer = s => {
if (!terminal.value) {
return
}
terminal.value.writeln(s.line)
const data = JSON.parse(s)
terminal.value.writeln(data.line)
}
const loader = () => isLoading.value = false