some minor app changes =)
This commit is contained in:
parent
f7f5e72aeb
commit
51024c2a9b
45 changed files with 1043 additions and 38187 deletions
|
|
@ -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:
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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
58
app/library/Emitter.py
Normal 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
527
app/library/HttpAPI.py
Normal 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
259
app/library/HttpSocket.py
Normal 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)
|
||||
|
|
@ -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',)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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')
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
46
app/library/common.py
Normal 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
|
||||
|
|
@ -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
15
app/library/encoder.py
Normal 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)
|
||||
826
app/main.py
826
app/main.py
|
|
@ -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
23
frontend/.gitignore
vendored
|
|
@ -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?
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
|
|
@ -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
12442
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
22437
frontend/src/assets/css/bulma.css
vendored
22437
frontend/src/assets/css/bulma.css
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"> ({{ app_version }})</span>
|
||||
- <a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a>
|
||||
<span class="is-hidden-mobile" v-if="ytdlp_version"> ({{ 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' }
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue