Merge branch 'master' into master
This commit is contained in:
commit
f30bd109f7
|
|
@ -0,0 +1,4 @@
|
||||||
|
.git
|
||||||
|
.venv
|
||||||
|
ui/.angular
|
||||||
|
ui/node_modules
|
||||||
File diff suppressed because it is too large
Load Diff
19
README.md
19
README.md
|
|
@ -36,11 +36,13 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||||
* __UMASK__: umask value used by MeTube. Defaults to `022`.
|
* __UMASK__: umask value used by MeTube. Defaults to `022`.
|
||||||
* __DOWNLOAD_DIR__: path to where the downloads will be saved. Defaults to `/downloads` in the docker image, and `.` otherwise.
|
* __DOWNLOAD_DIR__: path to where the downloads will be saved. Defaults to `/downloads` in the docker image, and `.` otherwise.
|
||||||
* __AUDIO_DOWNLOAD_DIR__: path to where audio-only downloads will be saved, if you wish to separate them from the video downloads. Defaults to the value of `DOWNLOAD_DIR`.
|
* __AUDIO_DOWNLOAD_DIR__: path to where audio-only downloads will be saved, if you wish to separate them from the video downloads. Defaults to the value of `DOWNLOAD_DIR`.
|
||||||
|
* __CUSTOM_DIRS__: whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a drop-down appears next to the Add button to specify the download directory. Defaults to `true`.
|
||||||
|
* __CREATE_CUSTOM_DIRS__: whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector becomes supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
|
||||||
* __STATE_DIR__: path to where the queue persistence files will be saved. Defaults to `/downloads/.metube` in the docker image, and `.` otherwise.
|
* __STATE_DIR__: path to where the queue persistence files will be saved. Defaults to `/downloads/.metube` in the docker image, and `.` otherwise.
|
||||||
* __URL_PREFIX__: base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
|
* __URL_PREFIX__: base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
|
||||||
* __OUTPUT_TEMPLATE__: the template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`.
|
* __OUTPUT_TEMPLATE__: the template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`.
|
||||||
* __OUTPUT_TEMPLATE_CHAPTER__: the template for the filenames of the downloaded videos, when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`.
|
* __OUTPUT_TEMPLATE_CHAPTER__: the template for the filenames of the downloaded videos, when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`.
|
||||||
* __YTDL_OPTIONS__: Additional options to pass to youtube-dl, in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L176). They roughly correspond to command-line options, though some do not have exact equivalents here, for example `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores.
|
* __YTDL_OPTIONS__: Additional options to pass to youtube-dl, in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L183). They roughly correspond to command-line options, though some do not have exact equivalents here, for example `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores.
|
||||||
|
|
||||||
The following example value for `YTDL_OPTIONS` embeds English subtitles and chapter markers (for videos that have them), and also changes the permissions on the downloaded video:
|
The following example value for `YTDL_OPTIONS` embeds English subtitles and chapter markers (for videos that have them), and also changes the permissions on the downloaded video:
|
||||||
```
|
```
|
||||||
|
|
@ -166,7 +168,20 @@ There's an automatic nightly build of MeTube which looks for a new version of yt
|
||||||
|
|
||||||
I recommend installing and setting up [watchtower](https://github.com/containrrr/watchtower) for this purpose.
|
I recommend installing and setting up [watchtower](https://github.com/containrrr/watchtower) for this purpose.
|
||||||
|
|
||||||
## Build and run locally
|
## Troubleshooting and submitting issues
|
||||||
|
|
||||||
|
Before asking a question or submitting an issue for MeTube, please remember that MeTube is only a UI for [yt-dlp](https://github.com/yt-dlp/yt-dlp). Any issues you might be experiencing with authentication to video websites, postprocessing, permissions, other `YTDL_OPTIONS` configurations which seem not to work, or anything else that concerns the workings of the underlying yt-dlp library, need not be opened on the MeTube project. In order to debug and troubleshoot them, it's advised to try using the yt-dlp binary directly first, bypassing the UI, and once that is working, importing the options that worked for you into `YTDL_OPTIONS`.
|
||||||
|
|
||||||
|
In order to test with the yt-dlp command directly, you can either download it and run it locally, or for a better simulation of its actual conditions, you can run it within the MeTube container itself. Assuming your MeTube container is called `metube`, run the following on your Docker host to get a shell inside the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -ti metube sh
|
||||||
|
cd /downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
Once there, you can use the yt-dlp command freely.
|
||||||
|
|
||||||
|
## Building and running locally
|
||||||
|
|
||||||
Make sure you have node.js and Python 3.8 installed.
|
Make sure you have node.js and Python 3.8 installed.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ def get_format(format: str, quality: str) -> str:
|
||||||
if format.startswith("custom:"):
|
if format.startswith("custom:"):
|
||||||
return format[7:]
|
return format[7:]
|
||||||
|
|
||||||
|
if format == "thumbnail":
|
||||||
|
# Quality is irrelevant in this case since we skip the download
|
||||||
|
return "bestaudio/best"
|
||||||
|
|
||||||
if format == "mp3":
|
if format == "mp3":
|
||||||
# Audio quality needs to be set post-download, set in opts
|
# Audio quality needs to be set post-download, set in opts
|
||||||
return "bestaudio/best"
|
return "bestaudio/best"
|
||||||
|
|
@ -63,7 +67,13 @@ def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
|
||||||
"preferredquality": 0 if quality == "best" else quality,
|
"preferredquality": 0 if quality == "best" else quality,
|
||||||
})
|
})
|
||||||
opts["writethumbnail"] = True
|
opts["writethumbnail"] = True
|
||||||
|
opts["postprocessors"].append({"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"})
|
||||||
opts["postprocessors"].append({"key": "FFmpegMetadata"})
|
opts["postprocessors"].append({"key": "FFmpegMetadata"})
|
||||||
opts["postprocessors"].append({"key": "EmbedThumbnail"})
|
opts["postprocessors"].append({"key": "EmbedThumbnail"})
|
||||||
|
|
||||||
|
if format == "thumbnail":
|
||||||
|
opts["skip_download"] = True
|
||||||
|
opts["writethumbnail"] = True
|
||||||
|
opts["postprocessors"].append({"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"})
|
||||||
|
|
||||||
return opts
|
return opts
|
||||||
|
|
|
||||||
56
app/main.py
56
app/main.py
|
|
@ -7,6 +7,7 @@ from aiohttp import web
|
||||||
import socketio
|
import socketio
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
from ytdl import DownloadQueueNotifier, DownloadQueue
|
from ytdl import DownloadQueueNotifier, DownloadQueue
|
||||||
|
|
||||||
|
|
@ -16,6 +17,8 @@ class Config:
|
||||||
_DEFAULTS = {
|
_DEFAULTS = {
|
||||||
'DOWNLOAD_DIR': '.',
|
'DOWNLOAD_DIR': '.',
|
||||||
'AUDIO_DOWNLOAD_DIR': '%%DOWNLOAD_DIR',
|
'AUDIO_DOWNLOAD_DIR': '%%DOWNLOAD_DIR',
|
||||||
|
'CUSTOM_DIRS': 'true',
|
||||||
|
'CREATE_CUSTOM_DIRS': 'true',
|
||||||
'STATE_DIR': '.',
|
'STATE_DIR': '.',
|
||||||
'URL_PREFIX': '',
|
'URL_PREFIX': '',
|
||||||
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
|
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
|
||||||
|
|
@ -26,12 +29,19 @@ class Config:
|
||||||
'BASE_DIR': ''
|
'BASE_DIR': ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_BOOLEAN = ('CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS')
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
for k, v in self._DEFAULTS.items():
|
for k, v in self._DEFAULTS.items():
|
||||||
setattr(self, k, os.environ[k] if k in os.environ else v)
|
setattr(self, k, os.environ[k] if k in os.environ else v)
|
||||||
for k, v in self.__dict__.items():
|
for k, v in self.__dict__.items():
|
||||||
if v.startswith('%%'):
|
if v.startswith('%%'):
|
||||||
setattr(self, k, getattr(self, v[2:]))
|
setattr(self, k, getattr(self, v[2:]))
|
||||||
|
if k in self._BOOLEAN:
|
||||||
|
if v not in ('true', 'false', 'True', 'False', 'on', 'off', '1', '0'):
|
||||||
|
log.error(f'Environment variable "{k}" is set to a non-boolean value "{v}"')
|
||||||
|
sys.exit(1)
|
||||||
|
setattr(self, k, v in ('true', 'True', 'on', '1'))
|
||||||
if not self.URL_PREFIX.endswith('/'):
|
if not self.URL_PREFIX.endswith('/'):
|
||||||
self.URL_PREFIX += '/'
|
self.URL_PREFIX += '/'
|
||||||
try:
|
try:
|
||||||
|
|
@ -83,7 +93,8 @@ async def add(request):
|
||||||
if not url or not quality:
|
if not url or not quality:
|
||||||
raise web.HTTPBadRequest()
|
raise web.HTTPBadRequest()
|
||||||
format = post.get('format')
|
format = post.get('format')
|
||||||
status = await dqueue.add(url, quality, format)
|
folder = post.get('folder')
|
||||||
|
status = await dqueue.add(url, quality, format, folder)
|
||||||
return web.Response(text=serializer.encode(status))
|
return web.Response(text=serializer.encode(status))
|
||||||
|
|
||||||
@routes.post(config.URL_PREFIX + 'delete')
|
@routes.post(config.URL_PREFIX + 'delete')
|
||||||
|
|
@ -99,6 +110,40 @@ async def delete(request):
|
||||||
@sio.event
|
@sio.event
|
||||||
async def connect(sid, environ):
|
async def connect(sid, environ):
|
||||||
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
||||||
|
await sio.emit('configuration', serializer.encode(config), to=sid)
|
||||||
|
if config.CUSTOM_DIRS:
|
||||||
|
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
||||||
|
|
||||||
|
def get_custom_dirs():
|
||||||
|
def recursive_dirs(base):
|
||||||
|
path = pathlib.Path(base)
|
||||||
|
|
||||||
|
# Converts PosixPath object to string, and remove base/ prefix
|
||||||
|
def convert(p):
|
||||||
|
s = str(p)
|
||||||
|
if s.startswith(base):
|
||||||
|
s = s[len(base):]
|
||||||
|
|
||||||
|
if s.startswith('/'):
|
||||||
|
s = s[1:]
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
# Recursively lists all subdirectories of DOWNLOAD_DIR
|
||||||
|
dirs = list(filter(None, map(convert, path.glob('**'))))
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
|
||||||
|
download_dir = recursive_dirs(config.DOWNLOAD_DIR)
|
||||||
|
|
||||||
|
audio_download_dir = download_dir
|
||||||
|
if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR:
|
||||||
|
audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"download_dir": download_dir,
|
||||||
|
"audio_download_dir": audio_download_dir
|
||||||
|
}
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX)
|
@routes.get(config.URL_PREFIX)
|
||||||
def index(request):
|
def index(request):
|
||||||
|
|
@ -115,9 +160,14 @@ if config.URL_PREFIX != '/':
|
||||||
|
|
||||||
routes.static(config.URL_PREFIX + 'favicon/', os.path.join(config.BASE_DIR, 'favicon'))
|
routes.static(config.URL_PREFIX + 'favicon/', os.path.join(config.BASE_DIR, 'favicon'))
|
||||||
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR)
|
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR)
|
||||||
|
routes.static(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR)
|
||||||
routes.static(config.URL_PREFIX, os.path.join(config.BASE_DIR, 'ui/dist/metube'))
|
routes.static(config.URL_PREFIX, os.path.join(config.BASE_DIR, 'ui/dist/metube'))
|
||||||
app.add_routes(routes)
|
try:
|
||||||
|
app.add_routes(routes)
|
||||||
|
except ValueError as e:
|
||||||
|
if 'ui/dist/metube' in str(e):
|
||||||
|
raise RuntimeError('Could not find the frontend UI static assets. Please run `node_modules/.bin/ng build` inside the ui folder') from e
|
||||||
|
raise e
|
||||||
|
|
||||||
# https://github.com/aio-libs/aiohttp/pull/4615 waiting for release
|
# https://github.com/aio-libs/aiohttp/pull/4615 waiting for release
|
||||||
# @routes.options(config.URL_PREFIX + 'add')
|
# @routes.options(config.URL_PREFIX + 'add')
|
||||||
|
|
|
||||||
42
app/ytdl.py
42
app/ytdl.py
|
|
@ -6,6 +6,7 @@ import time
|
||||||
import asyncio
|
import asyncio
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from dl_formats import get_format, get_opts
|
from dl_formats import get_format, get_opts
|
||||||
|
|
||||||
log = logging.getLogger('ytdl')
|
log = logging.getLogger('ytdl')
|
||||||
|
|
@ -27,10 +28,11 @@ class DownloadQueueNotifier:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
class DownloadInfo:
|
class DownloadInfo:
|
||||||
def __init__(self, id, title, url, quality, format):
|
def __init__(self, id, title, url, quality, format, folder):
|
||||||
self.id, self.title, self.url = id, title, url
|
self.id, self.title, self.url = id, title, url
|
||||||
self.quality = quality
|
self.quality = quality
|
||||||
self.format = format
|
self.format = format
|
||||||
|
self.folder = folder
|
||||||
self.status = self.msg = self.percent = self.speed = self.eta = None
|
self.status = self.msg = self.percent = self.speed = self.eta = None
|
||||||
self.filename = None
|
self.filename = None
|
||||||
self.timestamp = time.time_ns()
|
self.timestamp = time.time_ns()
|
||||||
|
|
@ -126,6 +128,10 @@ class Download:
|
||||||
self.tmpfilename = status.get('tmpfilename')
|
self.tmpfilename = status.get('tmpfilename')
|
||||||
if 'filename' in status:
|
if 'filename' in status:
|
||||||
self.info.filename = os.path.relpath(status.get('filename'), self.download_dir)
|
self.info.filename = os.path.relpath(status.get('filename'), self.download_dir)
|
||||||
|
|
||||||
|
# Set correct file extension for thumbnails
|
||||||
|
if(self.info.format == 'thumbnail'):
|
||||||
|
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
|
||||||
self.info.status = status['status']
|
self.info.status = status['status']
|
||||||
self.info.msg = status.get('msg')
|
self.info.msg = status.get('msg')
|
||||||
if 'downloaded_bytes' in status:
|
if 'downloaded_bytes' in status:
|
||||||
|
|
@ -164,7 +170,7 @@ class PersistentQueue:
|
||||||
return sorted(shelf.items(), key=lambda item: item[1].timestamp)
|
return sorted(shelf.items(), key=lambda item: item[1].timestamp)
|
||||||
|
|
||||||
def put(self, value):
|
def put(self, value):
|
||||||
key = value.info.id
|
key = value.info.url
|
||||||
self.dict[key] = value
|
self.dict[key] = value
|
||||||
with shelve.open(self.path, 'w') as shelf:
|
with shelve.open(self.path, 'w') as shelf:
|
||||||
shelf[key] = value.info
|
shelf[key] = value.info
|
||||||
|
|
@ -192,7 +198,7 @@ class DownloadQueue:
|
||||||
|
|
||||||
async def __import_queue(self):
|
async def __import_queue(self):
|
||||||
for k, v in self.queue.saved_items():
|
for k, v in self.queue.saved_items():
|
||||||
await self.add(v.url, v.quality, v.format)
|
await self.add(v.url, v.quality, v.format, folder=v.folder)
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
self.event = asyncio.Event()
|
self.event = asyncio.Event()
|
||||||
|
|
@ -207,7 +213,7 @@ class DownloadQueue:
|
||||||
**self.config.YTDL_OPTIONS,
|
**self.config.YTDL_OPTIONS,
|
||||||
}).extract_info(url, download=False)
|
}).extract_info(url, download=False)
|
||||||
|
|
||||||
async def __add_entry(self, entry, quality, format, already):
|
async def __add_entry(self, entry, quality, format, folder, already):
|
||||||
etype = entry.get('_type') or 'video'
|
etype = entry.get('_type') or 'video'
|
||||||
if etype == 'playlist':
|
if etype == 'playlist':
|
||||||
entries = entry['entries']
|
entries = entry['entries']
|
||||||
|
|
@ -220,14 +226,28 @@ class DownloadQueue:
|
||||||
for property in ("id", "title", "uploader", "uploader_id"):
|
for property in ("id", "title", "uploader", "uploader_id"):
|
||||||
if property in entry:
|
if property in entry:
|
||||||
etr[f"playlist_{property}"] = entry[property]
|
etr[f"playlist_{property}"] = entry[property]
|
||||||
results.append(await self.__add_entry(etr, quality, format, already))
|
results.append(await self.__add_entry(etr, quality, format, folder, already))
|
||||||
if any(res['status'] == 'error' for res in results):
|
if any(res['status'] == 'error' for res in results):
|
||||||
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
elif etype == 'video' or etype.startswith('url') and 'id' in entry and 'title' in entry:
|
elif etype == 'video' or etype.startswith('url') and 'id' in entry and 'title' in entry:
|
||||||
if not self.queue.exists(entry['id']):
|
if not self.queue.exists(entry['id']):
|
||||||
dl = DownloadInfo(entry['id'], entry['title'], entry.get('webpage_url') or entry['url'], quality, format)
|
dl = DownloadInfo(entry['id'], entry['title'], entry.get('webpage_url') or entry['url'], quality, format, folder)
|
||||||
dldirectory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format != 'mp3') else self.config.AUDIO_DOWNLOAD_DIR
|
# Keep consistent with frontend
|
||||||
|
base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format != 'mp3') else self.config.AUDIO_DOWNLOAD_DIR
|
||||||
|
if folder:
|
||||||
|
if not self.config.CUSTOM_DIRS:
|
||||||
|
return {'status': 'error', 'msg': f'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
|
||||||
|
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
||||||
|
real_base_directory = os.path.realpath(base_directory)
|
||||||
|
if not dldirectory.startswith(real_base_directory):
|
||||||
|
return {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the base download directory "{real_base_directory}"'}
|
||||||
|
if not os.path.isdir(dldirectory):
|
||||||
|
if not self.config.CREATE_CUSTOM_DIRS:
|
||||||
|
return {'status': 'error', 'msg': f'Folder "{folder}" for download does not exist inside base directory "{real_base_directory}", and CREATE_CUSTOM_DIRS is not true in the configuration.'}
|
||||||
|
os.makedirs(dldirectory, exist_ok=True)
|
||||||
|
else:
|
||||||
|
dldirectory = base_directory
|
||||||
output = self.config.OUTPUT_TEMPLATE
|
output = self.config.OUTPUT_TEMPLATE
|
||||||
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
|
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
|
|
@ -238,11 +258,11 @@ class DownloadQueue:
|
||||||
await self.notifier.added(dl)
|
await self.notifier.added(dl)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
elif etype.startswith('url'):
|
elif etype.startswith('url'):
|
||||||
return await self.add(entry['url'], quality, format, already)
|
return await self.add(entry['url'], quality, format, folder, already)
|
||||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||||
|
|
||||||
async def add(self, url, quality, format, already=None):
|
async def add(self, url, quality, format, folder, already=None):
|
||||||
log.info(f'adding {url}')
|
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=}')
|
||||||
already = set() if already is None else already
|
already = set() if already is None else already
|
||||||
if url in already:
|
if url in already:
|
||||||
log.info('recursion detected, skipping')
|
log.info('recursion detected, skipping')
|
||||||
|
|
@ -253,7 +273,7 @@ class DownloadQueue:
|
||||||
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
|
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
|
||||||
except yt_dlp.utils.YoutubeDLError as exc:
|
except yt_dlp.utils.YoutubeDLError as exc:
|
||||||
return {'status': 'error', 'msg': str(exc)}
|
return {'status': 'error', 'msg': str(exc)}
|
||||||
return await self.__add_entry(entry, quality, format, already)
|
return await self.__add_entry(entry, quality, format, folder, already)
|
||||||
|
|
||||||
async def cancel(self, ids):
|
async def cancel(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,9 @@
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.sass"
|
"src/styles.sass"
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": [
|
||||||
|
"node_modules/bootstrap/dist/js/bootstrap.min.js",
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -25,9 +25,10 @@
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
|
||||||
|
"@ng-select/ng-select": "^8.3.0",
|
||||||
"bootstrap": "^5.0.0",
|
"bootstrap": "^5.0.0",
|
||||||
"ngx-cookie-service": "^13.0.0",
|
"ngx-cookie-service": "^13.0.0",
|
||||||
"ngx-socket-io": "^4.2.0",
|
"ngx-socket-io": "~4.2.0",
|
||||||
"rxjs": "~7.5.5",
|
"rxjs": "~7.5.5",
|
||||||
"tslib": "^2.4.0",
|
"tslib": "^2.4.0",
|
||||||
"zone.js": "~0.11.6"
|
"zone.js": "~0.11.6"
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
<div class="container add-url-box">
|
<div class="container add-url-box">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col add-url-component input-group">
|
<div class="col add-url-component input-group">
|
||||||
<input type="text" class="form-control" placeholder="Video or playlist URL" name="addUrl" [(ngModel)]="addUrl" [disabled]="addInProgress || downloads.loading">
|
<input type="text" autocomplete="off" spellcheck="false" class="form-control" placeholder="Video or playlist URL" name="addUrl" [(ngModel)]="addUrl" [disabled]="addInProgress || downloads.loading">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -47,10 +47,21 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 add-url-component">
|
<div class="col-md-3 add-url-component">
|
||||||
<button class="btn btn-primary add-url" type="submit" (click)="addDownload()" [disabled]="addInProgress || downloads.loading">
|
<div [attr.class]="showAdvanced() ? 'btn-group add-url-group' : 'add-url-group'" ngbDropdown #advancedDropdown="ngbDropdown" display="dynamic" placement="bottom-end">
|
||||||
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
|
<button class="btn btn-primary add-url" type="submit" (click)="addDownload()" [disabled]="addInProgress || downloads.loading">
|
||||||
{{ addInProgress ? "Adding..." : "Add" }}
|
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
|
||||||
</button>
|
{{ addInProgress ? "Adding..." : "Add" }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary dropdown-toggle dropdown-toggle-split" id="advancedButton" type="button" title="Advanced options" [disabled]="addInProgress || downloads.loading" ngbDropdownAnchor (click)="advancedDropdown.open()" *ngIf="showAdvanced()">
|
||||||
|
<span class="sr-only">Advanced options</span>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="advancedButton" class="dropdown-menu dropdown-menu-end folder-dropdown-menu">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">Download Folder</span>
|
||||||
|
<ng-select [items]="customDirs$ | async" placeholder="Default" [addTag]="allowCustomDir.bind(this)" addTagText="Create directory" [ngStyle]="{'flex-grow':'1'}" bindLabel="folder" [(ngModel)]="folder" [disabled]="addInProgress || downloads.loading"></ng-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,6 +117,7 @@
|
||||||
<th scope="col" style="width: 2rem;"></th>
|
<th scope="col" style="width: 2rem;"></th>
|
||||||
<th scope="col" style="width: 2rem;"></th>
|
<th scope="col" style="width: 2rem;"></th>
|
||||||
<th scope="col" style="width: 2rem;"></th>
|
<th scope="col" style="width: 2rem;"></th>
|
||||||
|
<th scope="col" style="width: 2rem;"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -118,11 +130,14 @@
|
||||||
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" style="color: green;"></fa-icon>
|
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" style="color: green;"></fa-icon>
|
||||||
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" style="color: red;"></fa-icon>
|
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" style="color: red;"></fa-icon>
|
||||||
</div>
|
</div>
|
||||||
<span ngbTooltip="{{download.value.msg}}"><a *ngIf="!!download.value.filename; else noDownloadLink" href="download/{{download.value.filename | encodeURIComponent}}" target="_blank">{{ download.value.title }}</a></span>
|
<span ngbTooltip="{{download.value.msg}}"><a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a></span>
|
||||||
<ng-template #noDownloadLink>{{ download.value.title }}</ng-template>
|
<ng-template #noDownloadLink>{{ download.value.title }}</ng-template>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button *ngIf="download.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value.url, download.value.quality)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
|
<button *ngIf="download.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value.url, download.value.quality, download.value.folder)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" download><fa-icon [icon]="faDownload"></fa-icon></a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{download.value.url}}" target="_blank"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
|
<a href="{{download.value.url}}" target="_blank"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,20 @@
|
||||||
.add-url-component
|
.add-url-component
|
||||||
margin: 0.5rem auto
|
margin: 0.5rem auto
|
||||||
|
|
||||||
|
.add-url-group
|
||||||
|
width: 100%
|
||||||
|
|
||||||
button.add-url
|
button.add-url
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
|
.folder-dropdown-menu
|
||||||
|
width: 500px
|
||||||
|
|
||||||
|
.folder-dropdown-menu .input-group
|
||||||
|
display: flex
|
||||||
|
padding-left: 5px
|
||||||
|
padding-right: 5px
|
||||||
|
|
||||||
$metube-section-color-bg: rgba(0,0,0,.07)
|
$metube-section-color-bg: rgba(0,0,0,.07)
|
||||||
|
|
||||||
.metube-section-header
|
.metube-section-header
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
||||||
import { faTrashAlt, faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
import { faTrashAlt, faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { faRedoAlt, faSun, faMoon, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faRedoAlt, faSun, faMoon, faExternalLinkAlt, faDownload } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
|
import { map, Observable, of } from 'rxjs';
|
||||||
|
|
||||||
import { DownloadsService, Status } from './downloads.service';
|
import { Download, DownloadsService, Status } from './downloads.service';
|
||||||
import { MasterCheckboxComponent } from './master-checkbox.component';
|
import { MasterCheckboxComponent } from './master-checkbox.component';
|
||||||
import { Formats, Format, Quality } from './formats';
|
import { Formats, Format, Quality } from './formats';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrls: ['./app.component.sass']
|
styleUrls: ['./app.component.sass'],
|
||||||
})
|
})
|
||||||
export class AppComponent implements AfterViewInit {
|
export class AppComponent implements AfterViewInit {
|
||||||
addUrl: string;
|
addUrl: string;
|
||||||
|
|
@ -18,8 +19,10 @@ export class AppComponent implements AfterViewInit {
|
||||||
qualities: Quality[];
|
qualities: Quality[];
|
||||||
quality: string;
|
quality: string;
|
||||||
format: string;
|
format: string;
|
||||||
|
folder: string;
|
||||||
addInProgress = false;
|
addInProgress = false;
|
||||||
darkMode: boolean;
|
darkMode: boolean;
|
||||||
|
customDirs$: Observable<string[]>;
|
||||||
|
|
||||||
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
|
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
|
||||||
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
|
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
|
||||||
|
|
@ -34,6 +37,7 @@ export class AppComponent implements AfterViewInit {
|
||||||
faRedoAlt = faRedoAlt;
|
faRedoAlt = faRedoAlt;
|
||||||
faSun = faSun;
|
faSun = faSun;
|
||||||
faMoon = faMoon;
|
faMoon = faMoon;
|
||||||
|
faDownload = faDownload;
|
||||||
faExternalLinkAlt = faExternalLinkAlt;
|
faExternalLinkAlt = faExternalLinkAlt;
|
||||||
|
|
||||||
constructor(public downloads: DownloadsService, private cookieService: CookieService) {
|
constructor(public downloads: DownloadsService, private cookieService: CookieService) {
|
||||||
|
|
@ -44,6 +48,10 @@ export class AppComponent implements AfterViewInit {
|
||||||
this.setupTheme(cookieService)
|
this.setupTheme(cookieService)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.customDirs$ = this.getMatchingCustomDir();
|
||||||
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
this.downloads.queueChanged.subscribe(() => {
|
this.downloads.queueChanged.subscribe(() => {
|
||||||
this.queueMasterCheckbox.selectionChanged();
|
this.queueMasterCheckbox.selectionChanged();
|
||||||
|
|
@ -70,6 +78,36 @@ export class AppComponent implements AfterViewInit {
|
||||||
|
|
||||||
qualityChanged() {
|
qualityChanged() {
|
||||||
this.cookieService.set('metube_quality', this.quality, { expires: 3650 });
|
this.cookieService.set('metube_quality', this.quality, { expires: 3650 });
|
||||||
|
// Re-trigger custom directory change
|
||||||
|
this.downloads.customDirsChanged.next(this.downloads.customDirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
showAdvanced() {
|
||||||
|
return this.downloads.configuration['CUSTOM_DIRS'];
|
||||||
|
}
|
||||||
|
|
||||||
|
allowCustomDir(tag: string) {
|
||||||
|
if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAudioType() {
|
||||||
|
return this.quality == 'audio' || this.format == 'mp3';
|
||||||
|
}
|
||||||
|
|
||||||
|
getMatchingCustomDir() : Observable<string[]> {
|
||||||
|
return this.downloads.customDirsChanged.asObservable().pipe(map((output) => {
|
||||||
|
// Keep logic consistent with app/ytdl.py
|
||||||
|
if (this.isAudioType()) {
|
||||||
|
console.debug("Showing audio-specific download directories");
|
||||||
|
return output["audio_download_dir"];
|
||||||
|
} else {
|
||||||
|
console.debug("Showing default download directories");
|
||||||
|
return output["download_dir"];
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
setupTheme(cookieService) {
|
setupTheme(cookieService) {
|
||||||
|
|
@ -97,6 +135,8 @@ export class AppComponent implements AfterViewInit {
|
||||||
this.cookieService.set('metube_format', this.format, { expires: 3650 });
|
this.cookieService.set('metube_format', this.format, { expires: 3650 });
|
||||||
// Updates to use qualities available
|
// Updates to use qualities available
|
||||||
this.setQualities()
|
this.setQualities()
|
||||||
|
// Re-trigger custom directory change
|
||||||
|
this.downloads.customDirsChanged.next(this.downloads.customDirs);
|
||||||
}
|
}
|
||||||
|
|
||||||
queueSelectionChanged(checked: number) {
|
queueSelectionChanged(checked: number) {
|
||||||
|
|
@ -114,13 +154,15 @@ export class AppComponent implements AfterViewInit {
|
||||||
this.quality = exists ? this.quality : 'best'
|
this.quality = exists ? this.quality : 'best'
|
||||||
}
|
}
|
||||||
|
|
||||||
addDownload(url?: string, quality?: string, format?: string) {
|
addDownload(url?: string, quality?: string, format?: string, folder?: string) {
|
||||||
url = url ?? this.addUrl
|
url = url ?? this.addUrl
|
||||||
quality = quality ?? this.quality
|
quality = quality ?? this.quality
|
||||||
format = format ?? this.format
|
format = format ?? this.format
|
||||||
|
folder = folder ?? this.folder
|
||||||
|
|
||||||
|
console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder);
|
||||||
this.addInProgress = true;
|
this.addInProgress = true;
|
||||||
this.downloads.add(url, quality, format).subscribe((status: Status) => {
|
this.downloads.add(url, quality, format, folder).subscribe((status: Status) => {
|
||||||
if (status.status === 'error') {
|
if (status.status === 'error') {
|
||||||
alert(`Error adding URL: ${status.msg}`);
|
alert(`Error adding URL: ${status.msg}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -130,8 +172,8 @@ export class AppComponent implements AfterViewInit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
retryDownload(key: string, url: string, quality: string, format: string) {
|
retryDownload(key: string, url: string, quality: string, format: string, folder: string) {
|
||||||
this.addDownload(url, quality, format);
|
this.addDownload(url, quality, format, folder);
|
||||||
this.downloads.delById('done', [key]).subscribe();
|
this.downloads.delById('done', [key]).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,4 +192,17 @@ export class AppComponent implements AfterViewInit {
|
||||||
clearFailedDownloads() {
|
clearFailedDownloads() {
|
||||||
this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe();
|
this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildDownloadLink(download: Download) {
|
||||||
|
let baseDir = 'download/';
|
||||||
|
if (download.quality == 'audio' || download.filename.endsWith('.mp3')) {
|
||||||
|
baseDir = 'audio_download/';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download.folder) {
|
||||||
|
baseDir += download.folder + '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseDir + encodeURIComponent(download.filename);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { AppComponent } from './app.component';
|
||||||
import { EtaPipe, SpeedPipe, EncodeURIComponent } from './downloads.pipe';
|
import { EtaPipe, SpeedPipe, EncodeURIComponent } from './downloads.pipe';
|
||||||
import { MasterCheckboxComponent, SlaveCheckboxComponent } from './master-checkbox.component';
|
import { MasterCheckboxComponent, SlaveCheckboxComponent } from './master-checkbox.component';
|
||||||
import { MeTubeSocket } from './metube-socket';
|
import { MeTubeSocket } from './metube-socket';
|
||||||
|
import { NgSelectModule } from '@ng-select/ng-select';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
|
@ -25,7 +26,8 @@ import { MeTubeSocket } from './metube-socket';
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgbModule,
|
NgbModule,
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
FontAwesomeModule
|
FontAwesomeModule,
|
||||||
|
NgSelectModule
|
||||||
],
|
],
|
||||||
providers: [CookieService, MeTubeSocket],
|
providers: [CookieService, MeTubeSocket],
|
||||||
bootstrap: [AppComponent]
|
bootstrap: [AppComponent]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
import { of, Subject } from 'rxjs';
|
import { Observable, of, Subject } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { MeTubeSocket } from './metube-socket';
|
import { MeTubeSocket } from './metube-socket';
|
||||||
|
|
||||||
|
|
@ -9,13 +9,14 @@ export interface Status {
|
||||||
msg?: string;
|
msg?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Download {
|
export interface Download {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
url: string,
|
url: string,
|
||||||
status: string;
|
status: string;
|
||||||
msg: string;
|
msg: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
folder: string;
|
||||||
quality: string;
|
quality: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
|
|
@ -33,6 +34,10 @@ export class DownloadsService {
|
||||||
done = new Map<string, Download>();
|
done = new Map<string, Download>();
|
||||||
queueChanged = new Subject();
|
queueChanged = new Subject();
|
||||||
doneChanged = new Subject();
|
doneChanged = new Subject();
|
||||||
|
customDirsChanged = new Subject();
|
||||||
|
|
||||||
|
configuration = {};
|
||||||
|
customDirs = {};
|
||||||
|
|
||||||
constructor(private http: HttpClient, private socket: MeTubeSocket) {
|
constructor(private http: HttpClient, private socket: MeTubeSocket) {
|
||||||
socket.fromEvent('all').subscribe((strdata: string) => {
|
socket.fromEvent('all').subscribe((strdata: string) => {
|
||||||
|
|
@ -47,20 +52,20 @@ export class DownloadsService {
|
||||||
});
|
});
|
||||||
socket.fromEvent('added').subscribe((strdata: string) => {
|
socket.fromEvent('added').subscribe((strdata: string) => {
|
||||||
let data: Download = JSON.parse(strdata);
|
let data: Download = JSON.parse(strdata);
|
||||||
this.queue.set(data.id, data);
|
this.queue.set(data.url, data);
|
||||||
this.queueChanged.next(null);
|
this.queueChanged.next(null);
|
||||||
});
|
});
|
||||||
socket.fromEvent('updated').subscribe((strdata: string) => {
|
socket.fromEvent('updated').subscribe((strdata: string) => {
|
||||||
let data: Download = JSON.parse(strdata);
|
let data: Download = JSON.parse(strdata);
|
||||||
let dl: Download = this.queue.get(data.id);
|
let dl: Download = this.queue.get(data.url);
|
||||||
data.checked = dl.checked;
|
data.checked = dl.checked;
|
||||||
data.deleting = dl.deleting;
|
data.deleting = dl.deleting;
|
||||||
this.queue.set(data.id, data);
|
this.queue.set(data.url, data);
|
||||||
});
|
});
|
||||||
socket.fromEvent('completed').subscribe((strdata: string) => {
|
socket.fromEvent('completed').subscribe((strdata: string) => {
|
||||||
let data: Download = JSON.parse(strdata);
|
let data: Download = JSON.parse(strdata);
|
||||||
this.queue.delete(data.id);
|
this.queue.delete(data.url);
|
||||||
this.done.set(data.id, data);
|
this.done.set(data.url, data);
|
||||||
this.queueChanged.next(null);
|
this.queueChanged.next(null);
|
||||||
this.doneChanged.next(null);
|
this.doneChanged.next(null);
|
||||||
});
|
});
|
||||||
|
|
@ -74,6 +79,17 @@ export class DownloadsService {
|
||||||
this.done.delete(data);
|
this.done.delete(data);
|
||||||
this.doneChanged.next(null);
|
this.doneChanged.next(null);
|
||||||
});
|
});
|
||||||
|
socket.fromEvent('configuration').subscribe((strdata: string) => {
|
||||||
|
let data = JSON.parse(strdata);
|
||||||
|
console.debug("got configuration:", data);
|
||||||
|
this.configuration = data;
|
||||||
|
});
|
||||||
|
socket.fromEvent('custom_dirs').subscribe((strdata: string) => {
|
||||||
|
let data = JSON.parse(strdata);
|
||||||
|
console.debug("got custom_dirs:", data);
|
||||||
|
this.customDirs = data;
|
||||||
|
this.customDirsChanged.next(data);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHTTPError(error: HttpErrorResponse) {
|
handleHTTPError(error: HttpErrorResponse) {
|
||||||
|
|
@ -81,8 +97,8 @@ export class DownloadsService {
|
||||||
return of({status: 'error', msg: msg})
|
return of({status: 'error', msg: msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
public add(url: string, quality: string, format: string) {
|
public add(url: string, quality: string, format: string, folder: string) {
|
||||||
return this.http.post<Status>('add', {url: url, quality: quality, format: format}).pipe(
|
return this.http.post<Status>('add', {url: url, quality: quality, format: format, folder: folder}).pipe(
|
||||||
catchError(this.handleHTTPError)
|
catchError(this.handleHTTPError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +110,7 @@ export class DownloadsService {
|
||||||
|
|
||||||
public delByFilter(where: string, filter: (dl: Download) => boolean) {
|
public delByFilter(where: string, filter: (dl: Download) => boolean) {
|
||||||
let ids: string[] = [];
|
let ids: string[] = [];
|
||||||
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.id) });
|
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
||||||
return this.delById(where, ids);
|
return this.delById(where, ids);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,11 @@ export const Formats: Format[] = [
|
||||||
{ id: '128', text: '128 kbps' },
|
{ id: '128', text: '128 kbps' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'thumbnail',
|
||||||
|
text: 'Thumbnail',
|
||||||
|
qualities: [
|
||||||
|
{ id: 'best', text: 'Best' }
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@
|
||||||
|
|
||||||
/* Importing Bootstrap SCSS file. */
|
/* Importing Bootstrap SCSS file. */
|
||||||
@import '~bootstrap/scss/bootstrap'
|
@import '~bootstrap/scss/bootstrap'
|
||||||
|
@import '~@ng-select/ng-select/themes/default.theme.css'
|
||||||
Loading…
Reference in New Issue