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
|
from email.utils import formatdate
|
||||||
import json
|
import json
|
||||||
from sqlite3 import Connection
|
from sqlite3 import Connection
|
||||||
from Config import Config
|
from .config import Config
|
||||||
from Download import Download
|
from .Download import Download
|
||||||
from ItemDTO import ItemDTO
|
from .ItemDTO import ItemDTO
|
||||||
|
|
||||||
|
|
||||||
class DataStore:
|
class DataStore:
|
||||||
|
|
@ -7,10 +7,11 @@ import re
|
||||||
import shutil
|
import shutil
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
import hashlib
|
import hashlib
|
||||||
from AsyncPool import Terminator
|
from .AsyncPool import Terminator
|
||||||
from Utils import Notifier, get_format, get_opts, jsonCookie, mergeConfig
|
from .Utils import get_format, get_opts, jsonCookie, mergeConfig
|
||||||
from ItemDTO import ItemDTO
|
from .ItemDTO import ItemDTO
|
||||||
from Config import Config
|
from .config import Config
|
||||||
|
from .Emitter import Emitter
|
||||||
from multiprocessing.managers import SyncManager
|
from multiprocessing.managers import SyncManager
|
||||||
LOG = logging.getLogger('download')
|
LOG = logging.getLogger('download')
|
||||||
|
|
||||||
|
|
@ -31,7 +32,7 @@ class Download:
|
||||||
default_ytdl_opts: dict = None
|
default_ytdl_opts: dict = None
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
tempPath: str = None
|
tempPath: str = None
|
||||||
notifier: Notifier = None
|
emitter: Emitter = None
|
||||||
manager: SyncManager = None
|
manager: SyncManager = None
|
||||||
canceled: bool = False
|
canceled: bool = False
|
||||||
is_live: bool = False
|
is_live: bool = False
|
||||||
|
|
@ -79,7 +80,7 @@ class Download:
|
||||||
self.tmpfilename = None
|
self.tmpfilename = None
|
||||||
self.status_queue = None
|
self.status_queue = None
|
||||||
self.proc = None
|
self.proc = None
|
||||||
self.notifier = None
|
self.emitter = None
|
||||||
self.max_workers = int(config.max_workers)
|
self.max_workers = int(config.max_workers)
|
||||||
self.tempKeep = bool(config.temp_keep)
|
self.tempKeep = bool(config.temp_keep)
|
||||||
self.is_live = bool(info.is_live) or info.live_in is not None
|
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}".')
|
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.manager = multiprocessing.Manager()
|
||||||
self.notifier = notifier
|
self.emitter = emitter
|
||||||
self.status_queue = self.manager.Queue()
|
self.status_queue = self.manager.Queue()
|
||||||
|
|
||||||
# Create temp dir for each download.
|
# Create temp dir for each download.
|
||||||
|
|
@ -201,7 +202,7 @@ class Download:
|
||||||
self.proc.start()
|
self.proc.start()
|
||||||
self.info.status = 'preparing'
|
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}")
|
asyncio.create_task(self.progress_update(), name=f"update-{self.id}")
|
||||||
|
|
||||||
return await asyncio.get_running_loop().run_in_executor(None, self.proc.join)
|
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=}')
|
LOG.debug(f'Status Update: {self.info._id=} {status=}')
|
||||||
|
|
||||||
if isinstance(status, str):
|
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
|
continue
|
||||||
|
|
||||||
self.tmpfilename = status.get('tmpfilename')
|
self.tmpfilename = status.get('tmpfilename')
|
||||||
|
|
@ -350,8 +351,8 @@ class Download:
|
||||||
if self.info.status == 'error' and 'error' in status:
|
if self.info.status == 'error' and 'error' in status:
|
||||||
self.info.error = status.get('error')
|
self.info.error = status.get('error')
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
self.notifier.error(dl=self.info, message=self.info.error),
|
self.emitter.error(dl=self.info, message=self.info.error),
|
||||||
name=f"notifier-e-{self.id}")
|
name=f"emitter-e-{self.id}")
|
||||||
|
|
||||||
if 'downloaded_bytes' in status:
|
if 'downloaded_bytes' in status:
|
||||||
total = status.get('total_bytes') or status.get('total_bytes_estimate')
|
total = status.get('total_bytes') or status.get('total_bytes_estimate')
|
||||||
|
|
@ -370,4 +371,4 @@ class Download:
|
||||||
self.info.file_size = None
|
self.info.file_size = None
|
||||||
pass
|
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 time
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from sqlite3 import Connection
|
from sqlite3 import Connection
|
||||||
from Config import Config
|
from .config import Config
|
||||||
from Download import Download
|
from .Download import Download
|
||||||
from ItemDTO import ItemDTO
|
from .ItemDTO import ItemDTO
|
||||||
from DataStore import DataStore
|
from .DataStore import DataStore
|
||||||
from Utils import Notifier, ObjectSerializer, calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig
|
from .Utils import calcDownloadPath, ExtractInfo, isDownloaded, mergeConfig
|
||||||
from AsyncPool import AsyncPool
|
from .AsyncPool import AsyncPool
|
||||||
|
from .Emitter import Emitter
|
||||||
|
|
||||||
LOG = logging.getLogger('DownloadQueue')
|
LOG = logging.getLogger('DownloadQueue')
|
||||||
TYPE_DONE: str = 'done'
|
TYPE_DONE: str = 'done'
|
||||||
|
|
@ -20,22 +21,26 @@ TYPE_QUEUE: str = 'queue'
|
||||||
|
|
||||||
class DownloadQueue:
|
class DownloadQueue:
|
||||||
config: Config = None
|
config: Config = None
|
||||||
notifier: Notifier = None
|
emitter: Emitter = None
|
||||||
serializer: ObjectSerializer = None
|
|
||||||
queue: DataStore = None
|
queue: DataStore = None
|
||||||
done: DataStore = None
|
done: DataStore = None
|
||||||
event: asyncio.Event = None
|
event: asyncio.Event = None
|
||||||
pool: AsyncPool = 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.config = Config.get_instance()
|
||||||
self.notifier = notifier
|
self.emitter = emitter
|
||||||
self.serializer = serializer
|
self.connection = connection
|
||||||
self.done = DataStore(type=TYPE_DONE, connection=connection)
|
self.done = DataStore(type=TYPE_DONE, connection=self.connection)
|
||||||
self.queue = DataStore(type=TYPE_QUEUE, connection=connection)
|
self.queue = DataStore(type=TYPE_QUEUE, connection=self.connection)
|
||||||
self.done.load()
|
self.done.load()
|
||||||
self.queue.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):
|
async def initialize(self):
|
||||||
self.event = asyncio.Event()
|
self.event = asyncio.Event()
|
||||||
LOG.info(f'Using {self.config.max_workers} workers for downloading.')
|
LOG.info(f'Using {self.config.max_workers} workers for downloading.')
|
||||||
|
|
@ -174,7 +179,7 @@ class DownloadQueue:
|
||||||
self.event.set()
|
self.event.set()
|
||||||
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
self.notifier.emit(NotifyEvent, itemDownload.info),
|
self.emitter.emit(NotifyEvent, itemDownload.info),
|
||||||
name=f'notifier-{NotifyEvent}-{itemDownload.info.id}')
|
name=f'notifier-{NotifyEvent}-{itemDownload.info.id}')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -294,9 +299,9 @@ class DownloadQueue:
|
||||||
await item.close()
|
await item.close()
|
||||||
LOG.debug(f'Deleting from queue {itemMessage}')
|
LOG.debug(f'Deleting from queue {itemMessage}')
|
||||||
self.queue.delete(id)
|
self.queue.delete(id)
|
||||||
asyncio.create_task(self.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)
|
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}')
|
LOG.info(f'Deleted from queue {itemMessage}')
|
||||||
|
|
||||||
status[id] = 'ok'
|
status[id] = 'ok'
|
||||||
|
|
@ -318,7 +323,7 @@ class DownloadQueue:
|
||||||
itemMessage = f"{id=} {item.info.id=} {item.info.title=}"
|
itemMessage = f"{id=} {item.info.id=} {item.info.title=}"
|
||||||
LOG.debug(f'Deleting completed download {itemMessage}')
|
LOG.debug(f'Deleting completed download {itemMessage}')
|
||||||
self.done.delete(id)
|
self.done.delete(id)
|
||||||
asyncio.create_task(self.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}')
|
LOG.info(f'Deleted completed download {itemMessage}')
|
||||||
status[id] = 'ok'
|
status[id] = 'ok'
|
||||||
|
|
||||||
|
|
@ -398,7 +403,7 @@ class DownloadQueue:
|
||||||
LOG.info(f'Queuing {id=} - {entry.info.title=} - {entry.info.url=} - {entry.info.folder=}.')
|
LOG.info(f'Queuing {id=} - {entry.info.title=} - {entry.info.url=} - {entry.info.folder=}.')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await entry.start(self.notifier)
|
await entry.start(self.emitter)
|
||||||
|
|
||||||
if entry.info.status != 'finished':
|
if entry.info.status != 'finished':
|
||||||
if entry.tmpfilename and os.path.isfile(entry.tmpfilename):
|
if entry.tmpfilename and os.path.isfile(entry.tmpfilename):
|
||||||
|
|
@ -417,12 +422,12 @@ class DownloadQueue:
|
||||||
self.queue.delete(key=id)
|
self.queue.delete(key=id)
|
||||||
|
|
||||||
if entry.is_canceled() is True:
|
if entry.is_canceled() is True:
|
||||||
asyncio.create_task(self.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.status = 'canceled'
|
||||||
entry.info.error = 'Canceled by user.'
|
entry.info.error = 'Canceled by user.'
|
||||||
|
|
||||||
self.done.put(value=entry)
|
self.done.put(value=entry)
|
||||||
asyncio.create_task(self.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()
|
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 math
|
||||||
import os
|
import os
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from Utils import calcDownloadPath
|
from .Utils import calcDownloadPath
|
||||||
from .ffprobe import FFProbe
|
from .ffprobe import FFProbe
|
||||||
|
|
||||||
|
|
||||||
class M3u8:
|
class M3u8:
|
||||||
ok_vcodecs: tuple = ('h264', 'x264', 'avc',)
|
ok_vcodecs: tuple = ('h264', 'x264', 'avc',)
|
||||||
ok_acodecs: tuple = ('aac', 'mp3',)
|
ok_acodecs: tuple = ('aac', 'mp3',)
|
||||||
|
|
@ -2,13 +2,12 @@ import glob
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from Utils import calcDownloadPath, checkId
|
from library.Utils import calcDownloadPath, checkId
|
||||||
import pathlib
|
import pathlib
|
||||||
from .ffprobe import FFProbe
|
from library.ffprobe import FFProbe
|
||||||
from .Subtitle import Subtitle
|
from .Subtitle import Subtitle
|
||||||
from aiohttp.web import Response
|
from aiohttp.web import Response
|
||||||
|
|
||||||
|
|
||||||
class Playlist:
|
class Playlist:
|
||||||
_url: str = None
|
_url: str = None
|
||||||
|
|
||||||
|
|
@ -3,8 +3,8 @@ import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from Utils import calcDownloadPath
|
from .Utils import calcDownloadPath
|
||||||
from Config import Config
|
from .config import Config
|
||||||
|
|
||||||
LOG = logging.getLogger('player.segments')
|
LOG = logging.getLogger('player.segments')
|
||||||
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
from .Utils import calcDownloadPath
|
||||||
import tempfile
|
|
||||||
from Utils import calcDownloadPath
|
|
||||||
import pathlib
|
import pathlib
|
||||||
import pysubs2
|
import pysubs2
|
||||||
from pysubs2.time import ms_to_times
|
from pysubs2.time import ms_to_times
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import asyncio
|
|
||||||
import copy
|
import copy
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import json
|
import json
|
||||||
|
|
@ -8,8 +7,6 @@ import pathlib
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from socketio import AsyncServer
|
|
||||||
from Webhooks import Webhooks
|
|
||||||
|
|
||||||
LOG = logging.getLogger('Utils')
|
LOG = logging.getLogger('Utils')
|
||||||
|
|
||||||
|
|
@ -287,77 +284,6 @@ def jsonCookie(cookies: dict[dict[str, any]]) -> str | None:
|
||||||
return netscapeCookies if hasCookies else 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]:
|
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
|
Load a JSON or JSON5 file and return the contents as a dictionary
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from ItemDTO import ItemDTO
|
from .ItemDTO import ItemDTO
|
||||||
import httpx
|
import httpx
|
||||||
from version import APP_VERSION
|
from .version import APP_VERSION
|
||||||
LOG = logging.getLogger('Webhooks')
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger('Webhooks')
|
||||||
|
|
||||||
class Webhooks:
|
class Webhooks:
|
||||||
targets: list[dict] = []
|
targets: list[dict] = []
|
||||||
|
allowed_events: tuple = ('added', 'completed', 'error', 'not_live',)
|
||||||
|
|
||||||
def __init__(self, file: str):
|
def __init__(self, file: str):
|
||||||
if os.path.exists(file):
|
if os.path.exists(file):
|
||||||
|
|
@ -20,7 +21,7 @@ class Webhooks:
|
||||||
raise Exception(f'file is empty.')
|
raise Exception(f'file is empty.')
|
||||||
|
|
||||||
LOG.info(f'Loading webhooks from {file}')
|
LOG.info(f'Loading webhooks from {file}')
|
||||||
from Utils import load_file
|
from .Utils import load_file
|
||||||
(target, status, error) = load_file(file, list)
|
(target, status, error) = load_file(file, list)
|
||||||
if not status:
|
if not status:
|
||||||
raise Exception(f'{error}')
|
raise Exception(f'{error}')
|
||||||
|
|
@ -49,7 +50,7 @@ class Webhooks:
|
||||||
return await asyncio.gather(*tasks)
|
return await asyncio.gather(*tasks)
|
||||||
|
|
||||||
async def __send(self, event: str, target: dict, item: ItemDTO) -> dict:
|
async def __send(self, event: str, target: dict, item: ItemDTO) -> dict:
|
||||||
from Config import Config
|
from .config import Config
|
||||||
req: dict = target.get('request')
|
req: dict = target.get('request')
|
||||||
try:
|
try:
|
||||||
LOG.info(f"Sending {event=} {item.id=} to [{target.get('name')}]")
|
LOG.info(f"Sending {event=} {item.id=} to [{target.get('name')}]")
|
||||||
|
|
@ -96,3 +97,9 @@ class Webhooks:
|
||||||
'status': 500,
|
'status': 500,
|
||||||
'text': str(e)
|
'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 sys
|
||||||
import time
|
import time
|
||||||
import coloredlogs
|
import coloredlogs
|
||||||
from version import APP_VERSION
|
from .version import APP_VERSION
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from yt_dlp.version import __version__ as YTDLP_VERSION
|
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:
|
class Config:
|
||||||
__instance = None
|
__instance = None
|
||||||
config_path: str = '.'
|
config_path: str = '.'
|
||||||
download_path: str = '.'
|
download_path: str = '.'
|
||||||
temp_path: str = '{download_path}'
|
temp_path: str = '/tmp'
|
||||||
temp_keep: bool = False
|
temp_keep: bool = False
|
||||||
db_file: str = '{config_path}/ytptube.db'
|
db_file: str = '{config_path}/ytptube.db'
|
||||||
manual_archive: str = '{config_path}/archive.manual.log'
|
manual_archive: str = '{config_path}/archive.manual.log'
|
||||||
|
|
@ -64,9 +65,12 @@ class Config:
|
||||||
|
|
||||||
ytdlp_version: str = YTDLP_VERSION
|
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')
|
_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',)
|
_boolean_vars: tuple = ('keep_archive', 'ytdl_debug', 'debug', 'temp_keep', 'allow_manifestless',)
|
||||||
|
|
||||||
_frontend_vars: tuple = (
|
_frontend_vars: tuple = (
|
||||||
'download_path', 'keep_archive', 'output_template',
|
'download_path', 'keep_archive', 'output_template',
|
||||||
'ytdlp_version', 'url_host', 'url_prefix',
|
'ytdlp_version', 'url_host', 'url_prefix',
|
||||||
|
|
@ -80,11 +84,11 @@ class Config:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
""" Virtually private constructor. """
|
""" Virtually private constructor. """
|
||||||
if Config.__instance is not None:
|
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:
|
else:
|
||||||
Config.__instance = self
|
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.temp_path = os.environ.get('YTP_TEMP_PATH', None) or os.path.join(baseDefaultPath, 'var', 'tmp')
|
||||||
self.config_path = os.environ.get('YTP_CONFIG_PATH', None) or os.path.join(baseDefaultPath, 'var', 'config')
|
self.config_path = os.environ.get('YTP_CONFIG_PATH', None) or os.path.join(baseDefaultPath, 'var', 'config')
|
||||||
|
|
@ -95,11 +99,11 @@ class Config:
|
||||||
envFile: str = os.path.join(self.config_path, '.env')
|
envFile: str = os.path.join(self.config_path, '.env')
|
||||||
|
|
||||||
if os.path.exists(envFile):
|
if os.path.exists(envFile):
|
||||||
logging.info(f'Loading environment variables from [{envFile}].')
|
logging.info(f"Loading environment variables from '{envFile}'.")
|
||||||
load_dotenv(envFile)
|
load_dotenv(envFile)
|
||||||
|
|
||||||
for k, v in self._getAttributes().items():
|
for k, v in self._getAttributes().items():
|
||||||
if k.startswith('_'):
|
if k.startswith('_') or k in self._manual_vars:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If the variable declared as immutable, set it to the default value.
|
# If the variable declared as immutable, set it to the default value.
|
||||||
|
|
@ -111,7 +115,7 @@ class Config:
|
||||||
setattr(self, k, os.environ[lookUpKey] if lookUpKey in os.environ else v)
|
setattr(self, k, os.environ[lookUpKey] if lookUpKey in os.environ else v)
|
||||||
|
|
||||||
for k, v in self.__dict__.items():
|
for k, v in self.__dict__.items():
|
||||||
if k.startswith('_') or k in self._immutable:
|
if k.startswith('_') or k in self._immutable or k in self._manual_vars:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(v, str) and '{' in v and '}' in v:
|
if isinstance(v, str) and '{' in v and '}' in v:
|
||||||
|
|
@ -182,7 +186,7 @@ class Config:
|
||||||
LOG.info(f'Keep temp: {self.temp_keep}')
|
LOG.info(f'Keep temp: {self.temp_keep}')
|
||||||
|
|
||||||
if self.auth_password and self.auth_username:
|
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()
|
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
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
|
||||||
from datetime import datetime
|
|
||||||
import errno
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import pty
|
|
||||||
import random
|
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 socketio
|
||||||
import logging
|
import logging
|
||||||
import caribou
|
import caribou
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from aiocron import crontab
|
|
||||||
import magic
|
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')
|
LOG = logging.getLogger('app')
|
||||||
MIME = magic.Magic(mime=True)
|
MIME = magic.Magic(mime=True)
|
||||||
|
|
||||||
|
|
||||||
class Main:
|
class main:
|
||||||
config: Config = None
|
config: Config = None
|
||||||
serializer: ObjectSerializer = None
|
|
||||||
app: web.Application = None
|
app: web.Application = None
|
||||||
sio: socketio.AsyncServer = None
|
sio: socketio.AsyncServer = None
|
||||||
routes: web.RouteTableDef = None
|
routes: web.RouteTableDef = None
|
||||||
connection: sqlite3.Connection = None
|
|
||||||
queue: DownloadQueue = None
|
|
||||||
loop: asyncio.AbstractEventLoop = None
|
loop: asyncio.AbstractEventLoop = None
|
||||||
appLoader: str = None
|
appLoader: str = None
|
||||||
|
http: HttpAPI = None
|
||||||
staticHolder: dict = {}
|
socket: HttpSocket = None
|
||||||
|
|
||||||
extToMime: dict = {
|
|
||||||
'.html': 'text/html',
|
|
||||||
'.css': 'text/css',
|
|
||||||
'.js': 'application/javascript',
|
|
||||||
'.json': 'application/json',
|
|
||||||
'.ico': 'image/x-icon',
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.config = Config.get_instance()
|
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:
|
try:
|
||||||
|
LOG.debug(f'Checking download folder at [{self.config.download_path}]')
|
||||||
if not os.path.exists(self.config.download_path):
|
if not os.path.exists(self.config.download_path):
|
||||||
LOG.info(f'Creating download folder at [{self.config.download_path}]')
|
LOG.info(f'Creating download folder at [{self.config.download_path}]')
|
||||||
os.makedirs(self.config.download_path, exist_ok=True)
|
os.makedirs(self.config.download_path, exist_ok=True)
|
||||||
|
|
@ -65,6 +71,7 @@ class Main:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
LOG.debug(f'Checking temp folder at [{self.config.temp_path}]')
|
||||||
if not os.path.exists(self.config.temp_path):
|
if not os.path.exists(self.config.temp_path):
|
||||||
LOG.info(f'Creating temp folder at [{self.config.temp_path}]')
|
LOG.info(f'Creating temp folder at [{self.config.temp_path}]')
|
||||||
os.makedirs(self.config.temp_path, exist_ok=True)
|
os.makedirs(self.config.temp_path, exist_ok=True)
|
||||||
|
|
@ -73,6 +80,7 @@ class Main:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
LOG.debug(f'Checking config folder at [{self.config.config_path}]')
|
||||||
if not os.path.exists(self.config.config_path):
|
if not os.path.exists(self.config.config_path):
|
||||||
LOG.info(f'Creating config folder at [{self.config.config_path}]')
|
LOG.info(f'Creating config folder at [{self.config.config_path}]')
|
||||||
os.makedirs(self.config.config_path, exist_ok=True)
|
os.makedirs(self.config.config_path, exist_ok=True)
|
||||||
|
|
@ -81,6 +89,7 @@ class Main:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
LOG.debug(f'Checking database file at [{self.config.db_file}]')
|
||||||
if not os.path.exists(self.config.db_file):
|
if not os.path.exists(self.config.db_file):
|
||||||
LOG.info(f'Creating database file at [{self.config.db_file}]')
|
LOG.info(f'Creating database file at [{self.config.db_file}]')
|
||||||
with open(self.config.db_file, 'w') as _:
|
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}]')
|
LOG.error(f'Could not create database file at [{self.config.db_file}]')
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
caribou.upgrade(self.config.db_file, os.path.join(os.path.realpath(os.path.dirname(__file__)), 'migrations'))
|
def load_tasks(self):
|
||||||
|
|
||||||
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):
|
|
||||||
tasks_file: str = os.path.join(self.config.config_path, 'tasks.json')
|
tasks_file: str = os.path.join(self.config.config_path, 'tasks.json')
|
||||||
if not os.path.exists(tasks_file):
|
if not os.path.exists(tasks_file):
|
||||||
LOG.info(
|
LOG.info(f'No tasks file found at {tasks_file}. Skipping Tasks.')
|
||||||
f'No tasks file found at {tasks_file}. Skipping Tasks.')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
(tasks, status, error) = load_file(tasks_file, list)
|
(tasks, status, error) = load_file(tasks_file, list)
|
||||||
if status is False:
|
if status is False:
|
||||||
raise Exception(error)
|
raise Exception(error)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Could not load tasks file '{tasks_file}'. Error message '{str(e)}'. Skipping Tasks.")
|
LOG.error(f"Could not load tasks file '{tasks_file}'. Error message '{str(e)}'. Skipping Tasks.")
|
||||||
return
|
return
|
||||||
|
|
@ -254,623 +147,24 @@ class Main:
|
||||||
|
|
||||||
LOG.info(f'Added [Task: {task.get("name", task.get("url"))}] executed every [{cron_timer}].')
|
LOG.info(f'Added [Task: {task.get("name", task.get("url"))}] executed every [{cron_timer}].')
|
||||||
|
|
||||||
def addRoutes(self):
|
def start(self):
|
||||||
@self.routes.get(self.config.url_prefix + 'ping')
|
self.socket.attach(self.app)
|
||||||
async def ping(_) -> Response:
|
self.http.attach(self.app)
|
||||||
self.connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone()
|
|
||||||
return web.Response(text='pong')
|
self.load_tasks()
|
||||||
|
|
||||||
@self.routes.post(self.config.url_prefix + 'add')
|
start: str = f'YTPTube v{self.config.version} - listening on http://{self.config.host}:{self.config.port}'
|
||||||
async def add(request: Request) -> Response:
|
web.run_app(
|
||||||
post = await request.json()
|
self.app,
|
||||||
|
host=self.config.host,
|
||||||
url: str = post.get('url')
|
port=self.config.port,
|
||||||
quality: str = post.get('quality')
|
reuse_port=True,
|
||||||
|
loop=asyncio.get_event_loop(),
|
||||||
if not url:
|
access_log=None,
|
||||||
raise web.HTTPBadRequest()
|
print=lambda _: LOG.info(start)
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
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()
|
command_input.value.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const writer = s => {
|
const writer = s => {
|
||||||
if (!terminal.value) {
|
if (!terminal.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
terminal.value.writeln(s.line)
|
|
||||||
|
const data = JSON.parse(s)
|
||||||
|
|
||||||
|
terminal.value.writeln(data.line)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loader = () => isLoading.value = false
|
const loader = () => isLoading.value = false
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue