Merge pull request #2 from Stash-KennyG/generic-webservice
Refactor integration settings to generic webhook model
This commit is contained in:
commit
54651fb07e
7 changed files with 934 additions and 792 deletions
33
README.md
33
README.md
|
|
@ -40,10 +40,9 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||||
* __SUBSCRIPTION_SCAN_PLAYLIST_END__: Maximum playlist/channel entries to fetch per subscription check (newest-first). Defaults to `50`.
|
* __SUBSCRIPTION_SCAN_PLAYLIST_END__: Maximum playlist/channel entries to fetch per subscription check (newest-first). Defaults to `50`.
|
||||||
* __SUBSCRIPTION_MAX_SEEN_IDS__: Cap on stored video IDs per subscription to limit state file growth. Defaults to `50000`.
|
* __SUBSCRIPTION_MAX_SEEN_IDS__: Cap on stored video IDs per subscription to limit state file growth. Defaults to `50000`.
|
||||||
* __CLEAR_COMPLETED_AFTER__: Number of seconds after which completed (and failed) downloads are automatically removed from the "Completed" list. Defaults to `0` (disabled).
|
* __CLEAR_COMPLETED_AFTER__: Number of seconds after which completed (and failed) downloads are automatically removed from the "Completed" list. Defaults to `0` (disabled).
|
||||||
* __HASHARR_ENABLED__: If `true`, call hasharr when a download completes. Defaults to `false`.
|
* __WEBSERVICE_ENABLED__: If `true`, call a webhook endpoint when a download completes. Defaults to `false`.
|
||||||
* __HASHARR_URL__: Base URL for hasharr service (for example `http://hasharr:9995`). Defaults to `http://hasharr:9995`.
|
* __WEBSERVICE_ENDPOINT__: Full webhook URL to call (for example `"http://localhost:9876/api/web-service/"`). Defaults to `"http://localhost:9876/api/web-service/"`.
|
||||||
* __HASHARR_SERVICE_ID__: Hash service profile ID to call at `POST /api/hash-service/{id}`. Defaults to `1`.
|
* __WEBSERVICE_TIMEOUT_SEC__: Timeout (seconds) for webhook requests. Defaults to `20`.
|
||||||
* __HASHARR_TIMEOUT_SEC__: Timeout (seconds) for hasharr callback requests. Defaults to `20`.
|
|
||||||
|
|
||||||
### 📁 Storage & Directories
|
### 📁 Storage & Directories
|
||||||
|
|
||||||
|
|
@ -93,29 +92,39 @@ The project's Wiki contains examples of useful configurations contributed by use
|
||||||
* [YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)
|
* [YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)
|
||||||
* [OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)
|
* [OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)
|
||||||
|
|
||||||
## hasharr integration
|
## Webhook integration
|
||||||
|
|
||||||
MeTube can call hasharr after download completion to perform pHash matching and action policy.
|
MeTube can call a generic webhook endpoint after download completion. The payload is fixed JSON so any consumer can implement it.
|
||||||
|
|
||||||
### Runtime API endpoints
|
### Runtime API endpoints
|
||||||
|
|
||||||
- `GET /hasharr-settings` -> current effective integration settings
|
- `GET /webhook-settings` -> current effective integration settings
|
||||||
- `POST /hasharr-settings` -> update runtime settings
|
- `POST /webhook-settings` -> update runtime settings
|
||||||
|
- `POST /webhook-settings/test` -> send test payload and return endpoint response
|
||||||
|
|
||||||
|
Settings saved via `POST /webhook-settings` are written to `{STATE_DIR}/webhook_settings.json` and reloaded on startup, so they survive container restarts. If this file is present, it overrides the `WEBSERVICE_*` environment defaults for the running process.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"url": "http://hasharr:9995",
|
"endpoint": "http://localhost:9876/api/web-service/",
|
||||||
"service_id": 1,
|
|
||||||
"timeout_sec": 20
|
"timeout_sec": 20
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
When enabled, MeTube posts each completed output file to:
|
When enabled, MeTube posts each completed output file to `POST {WEBSERVICE_ENDPOINT}` with:
|
||||||
|
|
||||||
`POST {HASHARR_URL}/api/hash-service/{HASHARR_SERVICE_ID}`
|
```json
|
||||||
|
{
|
||||||
|
"filePath": "/downloads/example.mp4",
|
||||||
|
"source": "metube",
|
||||||
|
"jobId": "optional-job-id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The test route posts the same shape with an empty `filePath`.
|
||||||
|
|
||||||
## 🍪 Using browser cookies
|
## 🍪 Using browser cookies
|
||||||
|
|
||||||
|
|
|
||||||
175
app/main.py
175
app/main.py
|
|
@ -20,6 +20,7 @@ from watchfiles import DefaultFilter, Change, awatch
|
||||||
|
|
||||||
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
||||||
from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo
|
from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo
|
||||||
|
from state_store import AtomicJsonStore
|
||||||
from yt_dlp.version import __version__ as yt_dlp_version
|
from yt_dlp.version import __version__ as yt_dlp_version
|
||||||
|
|
||||||
log = logging.getLogger('main')
|
log = logging.getLogger('main')
|
||||||
|
|
@ -70,13 +71,12 @@ class Config:
|
||||||
'MAX_CONCURRENT_DOWNLOADS': '3',
|
'MAX_CONCURRENT_DOWNLOADS': '3',
|
||||||
'LOGLEVEL': 'INFO',
|
'LOGLEVEL': 'INFO',
|
||||||
'ENABLE_ACCESSLOG': 'false',
|
'ENABLE_ACCESSLOG': 'false',
|
||||||
'HASHARR_ENABLED': 'false',
|
'WEBSERVICE_ENABLED': 'false',
|
||||||
'HASHARR_URL': 'http://hasharr:9995',
|
'WEBSERVICE_ENDPOINT': 'http://localhost:9876/api/web-service/',
|
||||||
'HASHARR_SERVICE_ID': '1',
|
'WEBSERVICE_TIMEOUT_SEC': '20',
|
||||||
'HASHARR_TIMEOUT_SEC': '20',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'HASHARR_ENABLED')
|
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'WEBSERVICE_ENABLED')
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
for k, v in self._DEFAULTS.items():
|
for k, v in self._DEFAULTS.items():
|
||||||
|
|
@ -124,10 +124,9 @@ class Config:
|
||||||
'PUBLIC_HOST_URL',
|
'PUBLIC_HOST_URL',
|
||||||
'PUBLIC_HOST_AUDIO_URL',
|
'PUBLIC_HOST_AUDIO_URL',
|
||||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
|
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
|
||||||
'HASHARR_ENABLED',
|
'WEBSERVICE_ENABLED',
|
||||||
'HASHARR_URL',
|
'WEBSERVICE_ENDPOINT',
|
||||||
'HASHARR_SERVICE_ID',
|
'WEBSERVICE_TIMEOUT_SEC',
|
||||||
'HASHARR_TIMEOUT_SEC',
|
|
||||||
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL',
|
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -171,6 +170,43 @@ class Config:
|
||||||
return (True, '')
|
return (True, '')
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
|
_webhook_settings_store = AtomicJsonStore(
|
||||||
|
os.path.join(config.STATE_DIR, 'webhook_settings.json'),
|
||||||
|
kind='webhook_settings',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_persisted_webhook_settings() -> None:
|
||||||
|
"""Apply webhook endpoint settings saved under STATE_DIR (survives container restarts)."""
|
||||||
|
payload = _webhook_settings_store.load()
|
||||||
|
if not payload:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if 'endpoint' in payload:
|
||||||
|
ep = str(payload['endpoint'] or '').strip()
|
||||||
|
if ep:
|
||||||
|
config.WEBSERVICE_ENDPOINT = ep
|
||||||
|
if 'timeout_sec' in payload:
|
||||||
|
ts = int(payload['timeout_sec'])
|
||||||
|
if ts > 0:
|
||||||
|
config.WEBSERVICE_TIMEOUT_SEC = ts
|
||||||
|
if 'enabled' in payload:
|
||||||
|
config.WEBSERVICE_ENABLED = bool(payload['enabled'])
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
log.warning('Ignoring invalid persisted webhook settings: %s', exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_persisted_webhook_settings() -> None:
|
||||||
|
_webhook_settings_store.save({
|
||||||
|
'enabled': bool(config.WEBSERVICE_ENABLED),
|
||||||
|
'endpoint': str(config.WEBSERVICE_ENDPOINT),
|
||||||
|
'timeout_sec': int(config.WEBSERVICE_TIMEOUT_SEC),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
_load_persisted_webhook_settings()
|
||||||
|
|
||||||
# Align root logger level with Config (keeps a single source of truth).
|
# Align root logger level with Config (keeps a single source of truth).
|
||||||
# This re-applies the log level after Config loads, in case LOGLEVEL was
|
# This re-applies the log level after Config loads, in case LOGLEVEL was
|
||||||
# overridden by config file settings or differs from the environment variable.
|
# overridden by config file settings or differs from the environment variable.
|
||||||
|
|
@ -693,92 +729,91 @@ async def history(request):
|
||||||
log.info("Sending download history")
|
log.info("Sending download history")
|
||||||
return web.Response(text=serializer.encode(history))
|
return web.Response(text=serializer.encode(history))
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX + 'hasharr-settings')
|
@routes.get(config.URL_PREFIX + 'webhook-settings')
|
||||||
async def get_hasharr_settings(request):
|
async def get_webhook_settings(request):
|
||||||
return web.Response(text=serializer.encode({
|
return web.Response(text=serializer.encode({
|
||||||
'enabled': bool(config.HASHARR_ENABLED),
|
'enabled': bool(config.WEBSERVICE_ENABLED),
|
||||||
'url': str(config.HASHARR_URL),
|
'endpoint': str(config.WEBSERVICE_ENDPOINT),
|
||||||
'service_id': int(config.HASHARR_SERVICE_ID),
|
'timeout_sec': int(config.WEBSERVICE_TIMEOUT_SEC),
|
||||||
'timeout_sec': int(config.HASHARR_TIMEOUT_SEC),
|
|
||||||
}), content_type='application/json')
|
}), content_type='application/json')
|
||||||
|
|
||||||
@routes.post(config.URL_PREFIX + 'hasharr-settings')
|
@routes.post(config.URL_PREFIX + 'webhook-settings')
|
||||||
async def set_hasharr_settings(request):
|
async def set_webhook_settings(request):
|
||||||
post = await _read_json_request(request)
|
post = await _read_json_request(request)
|
||||||
enabled = bool(post.get('enabled', config.HASHARR_ENABLED))
|
enabled = bool(post.get('enabled', config.WEBSERVICE_ENABLED))
|
||||||
url = str(post.get('url', config.HASHARR_URL)).strip()
|
endpoint = str(post.get('endpoint', config.WEBSERVICE_ENDPOINT)).strip()
|
||||||
service_id = int(post.get('service_id', config.HASHARR_SERVICE_ID))
|
timeout_sec = int(post.get('timeout_sec', config.WEBSERVICE_TIMEOUT_SEC))
|
||||||
timeout_sec = int(post.get('timeout_sec', config.HASHARR_TIMEOUT_SEC))
|
if not endpoint:
|
||||||
if not url:
|
raise web.HTTPBadRequest(reason='endpoint is required')
|
||||||
raise web.HTTPBadRequest(reason='url is required')
|
|
||||||
if service_id <= 0:
|
|
||||||
raise web.HTTPBadRequest(reason='service_id must be > 0')
|
|
||||||
if timeout_sec <= 0:
|
if timeout_sec <= 0:
|
||||||
raise web.HTTPBadRequest(reason='timeout_sec must be > 0')
|
raise web.HTTPBadRequest(reason='timeout_sec must be > 0')
|
||||||
config.HASHARR_ENABLED = enabled
|
config.WEBSERVICE_ENABLED = enabled
|
||||||
config.HASHARR_URL = url
|
config.WEBSERVICE_ENDPOINT = endpoint
|
||||||
config.HASHARR_SERVICE_ID = service_id
|
config.WEBSERVICE_TIMEOUT_SEC = timeout_sec
|
||||||
config.HASHARR_TIMEOUT_SEC = timeout_sec
|
try:
|
||||||
|
_save_persisted_webhook_settings()
|
||||||
|
except OSError as exc:
|
||||||
|
log.exception('Failed to persist webhook settings to %s', _webhook_settings_store.path)
|
||||||
|
raise web.HTTPInternalServerError(reason=f'could not persist webhook settings: {exc}') from exc
|
||||||
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
|
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
|
||||||
|
|
||||||
@routes.post(config.URL_PREFIX + 'hasharr-settings/test')
|
@routes.post(config.URL_PREFIX + 'webhook-settings/test')
|
||||||
async def test_hasharr_settings(request):
|
async def test_webhook_settings(request):
|
||||||
post = await _read_json_request(request)
|
post = await _read_json_request(request)
|
||||||
url = str(post.get('url', config.HASHARR_URL)).strip()
|
endpoint = str(post.get('endpoint', config.WEBSERVICE_ENDPOINT)).strip()
|
||||||
service_id = int(post.get('service_id', config.HASHARR_SERVICE_ID))
|
timeout_sec = int(post.get('timeout_sec', config.WEBSERVICE_TIMEOUT_SEC))
|
||||||
timeout_sec = int(post.get('timeout_sec', config.HASHARR_TIMEOUT_SEC))
|
if not endpoint:
|
||||||
if not url:
|
raise web.HTTPBadRequest(reason='endpoint is required')
|
||||||
raise web.HTTPBadRequest(reason='url is required')
|
|
||||||
if service_id <= 0:
|
|
||||||
raise web.HTTPBadRequest(reason='service_id must be > 0')
|
|
||||||
if timeout_sec <= 0:
|
if timeout_sec <= 0:
|
||||||
raise web.HTTPBadRequest(reason='timeout_sec must be > 0')
|
raise web.HTTPBadRequest(reason='timeout_sec must be > 0')
|
||||||
|
|
||||||
base_url = url.rstrip('/')
|
payload = {
|
||||||
profile_url = f"{base_url}/v1/hash-service-profiles/{service_id}"
|
'filePath': '',
|
||||||
|
'source': 'metube',
|
||||||
|
'jobId': '',
|
||||||
|
}
|
||||||
|
body = json.dumps(payload).encode('utf-8')
|
||||||
|
|
||||||
def _fetch_profile():
|
def _call_endpoint():
|
||||||
req = urlrequest.Request(profile_url, method='GET')
|
req = urlrequest.Request(endpoint, data=body, headers={'Content-Type': 'application/json'}, method='POST')
|
||||||
with urlrequest.urlopen(req, timeout=timeout_sec) as resp:
|
with urlrequest.urlopen(req, timeout=timeout_sec) as resp:
|
||||||
body = resp.read().decode('utf-8', errors='replace')
|
raw = resp.read().decode('utf-8', errors='replace')
|
||||||
return resp.status, body
|
return resp.status, raw
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status, body = await asyncio.get_running_loop().run_in_executor(None, _fetch_profile)
|
status, raw = await asyncio.get_running_loop().run_in_executor(None, _call_endpoint)
|
||||||
if status != 200:
|
if not raw:
|
||||||
return web.json_response({
|
response_obj = {}
|
||||||
'status': 'error',
|
else:
|
||||||
'reachable': True,
|
try:
|
||||||
'valid_service_id': False,
|
response_obj = json.loads(raw)
|
||||||
'message': f'hasharr responded with status {status}',
|
except json.JSONDecodeError:
|
||||||
}, status=502)
|
response_obj = {'raw': raw}
|
||||||
profile = json.loads(body)
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'reachable': True,
|
'message': f'Endpoint returned HTTP {status}.',
|
||||||
'valid_service_id': True,
|
'status_code': status,
|
||||||
'profile': profile,
|
'response': response_obj,
|
||||||
})
|
})
|
||||||
except urlerror.HTTPError as exc:
|
except urlerror.HTTPError as exc:
|
||||||
if exc.code == 404:
|
raw = exc.read().decode('utf-8', errors='replace')
|
||||||
return web.json_response({
|
if not raw:
|
||||||
'status': 'ok',
|
response_obj = {}
|
||||||
'reachable': True,
|
else:
|
||||||
'valid_service_id': False,
|
try:
|
||||||
'message': f'Service ID {service_id} was not found in hasharr.',
|
response_obj = json.loads(raw)
|
||||||
})
|
except json.JSONDecodeError:
|
||||||
|
response_obj = {'raw': raw}
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'reachable': True,
|
'message': f'Endpoint returned HTTP {exc.code}.',
|
||||||
'valid_service_id': False,
|
'status_code': int(exc.code),
|
||||||
'message': f'hasharr returned HTTP {exc.code}',
|
'response': response_obj,
|
||||||
}, status=502)
|
}, status=502)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'reachable': False,
|
'message': f'Could not reach endpoint: {exc}',
|
||||||
'valid_service_id': False,
|
|
||||||
'message': f'Could not reach hasharr: {exc}',
|
|
||||||
}, status=502)
|
}, status=502)
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
|
|
@ -911,6 +946,8 @@ app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/delete', add_
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/check', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/check', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
|
||||||
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'webhook-settings', add_cors)
|
||||||
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'webhook-settings/test', add_cors)
|
||||||
|
|
||||||
async def on_prepare(request, response):
|
async def on_prepare(request, response):
|
||||||
if 'Origin' in request.headers:
|
if 'Origin' in request.headers:
|
||||||
|
|
|
||||||
19
app/ytdl.py
19
app/ytdl.py
|
|
@ -766,7 +766,7 @@ class DownloadQueue:
|
||||||
else:
|
else:
|
||||||
self.done.put(download)
|
self.done.put(download)
|
||||||
asyncio.create_task(self.notifier.completed(download.info))
|
asyncio.create_task(self.notifier.completed(download.info))
|
||||||
asyncio.create_task(self._notify_hasharr(download.info))
|
asyncio.create_task(self._notify_webhook(download.info))
|
||||||
try:
|
try:
|
||||||
clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
|
clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
@ -776,12 +776,13 @@ class DownloadQueue:
|
||||||
task = asyncio.create_task(self.__auto_clear_after_delay(download.info.url, clear_after))
|
task = asyncio.create_task(self.__auto_clear_after_delay(download.info.url, clear_after))
|
||||||
task.add_done_callback(lambda t: log.error(f'Auto-clear task failed: {t.exception()}') if not t.cancelled() and t.exception() else None)
|
task.add_done_callback(lambda t: log.error(f'Auto-clear task failed: {t.exception()}') if not t.cancelled() and t.exception() else None)
|
||||||
|
|
||||||
async def _notify_hasharr(self, info):
|
async def _notify_webhook(self, info):
|
||||||
if not getattr(self.config, 'HASHARR_ENABLED', False):
|
if not getattr(self.config, 'WEBSERVICE_ENABLED', False):
|
||||||
|
return
|
||||||
|
endpoint = str(getattr(self.config, 'WEBSERVICE_ENDPOINT', '')).strip()
|
||||||
|
timeout_sec = int(getattr(self.config, 'WEBSERVICE_TIMEOUT_SEC', 20))
|
||||||
|
if not endpoint:
|
||||||
return
|
return
|
||||||
base_url = str(getattr(self.config, 'HASHARR_URL', 'http://hasharr:9995')).rstrip('/')
|
|
||||||
service_id = int(getattr(self.config, 'HASHARR_SERVICE_ID', 1))
|
|
||||||
timeout_sec = int(getattr(self.config, 'HASHARR_TIMEOUT_SEC', 20))
|
|
||||||
files = []
|
files = []
|
||||||
if getattr(info, 'filename', None):
|
if getattr(info, 'filename', None):
|
||||||
files.append(info.filename)
|
files.append(info.filename)
|
||||||
|
|
@ -811,7 +812,7 @@ class DownloadQueue:
|
||||||
}
|
}
|
||||||
data = json.dumps(payload).encode('utf-8')
|
data = json.dumps(payload).encode('utf-8')
|
||||||
req = urlrequest.Request(
|
req = urlrequest.Request(
|
||||||
f"{base_url}/api/hash-service/{service_id}",
|
endpoint,
|
||||||
data=data,
|
data=data,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
method="POST",
|
method="POST",
|
||||||
|
|
@ -822,9 +823,9 @@ class DownloadQueue:
|
||||||
for rel_name in dedup:
|
for rel_name in dedup:
|
||||||
try:
|
try:
|
||||||
status = await asyncio.get_running_loop().run_in_executor(None, _post_one, rel_name)
|
status = await asyncio.get_running_loop().run_in_executor(None, _post_one, rel_name)
|
||||||
log.info(f"hasharr callback status={status} file={rel_name}")
|
log.info(f"webhook callback status={status} file={rel_name}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning(f"hasharr callback failed for {rel_name}: {exc}")
|
log.warning(f"webhook callback failed for {rel_name}: {exc}")
|
||||||
|
|
||||||
async def __auto_clear_after_delay(self, url, delay_seconds):
|
async def __auto_clear_after_delay(self, url, delay_seconds):
|
||||||
await asyncio.sleep(delay_seconds)
|
await asyncio.sleep(delay_seconds)
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,13 @@
|
||||||
</div>
|
</div>
|
||||||
-->
|
-->
|
||||||
<div class="navbar-nav ms-auto">
|
<div class="navbar-nav ms-auto">
|
||||||
|
<button type="button" class="btn btn-link nav-link py-2 px-0 px-sm-2" aria-label="Open settings"
|
||||||
|
title="UI settings" (click)="openSettingsModal()">
|
||||||
|
<fa-icon [icon]="faGear" />
|
||||||
|
</button>
|
||||||
<div class="nav-item dropdown" ngbDropdown placement="bottom-end">
|
<div class="nav-item dropdown" ngbDropdown placement="bottom-end">
|
||||||
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
|
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
|
||||||
id="theme-select"
|
id="theme-select" type="button" aria-expanded="false" ngbDropdownToggle>
|
||||||
type="button"
|
|
||||||
aria-expanded="false"
|
|
||||||
ngbDropdownToggle>
|
|
||||||
@if(activeTheme){
|
@if(activeTheme){
|
||||||
<fa-icon [icon]="activeTheme.icon" />
|
<fa-icon [icon]="activeTheme.icon" />
|
||||||
}
|
}
|
||||||
|
|
@ -62,16 +63,13 @@
|
||||||
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select" ngbDropdownMenu>
|
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select" ngbDropdownMenu>
|
||||||
@for (theme of themes; track theme) {
|
@for (theme of themes; track theme) {
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
<button type="button" class="dropdown-item d-flex align-items-center" [class.active]="activeTheme === theme"
|
||||||
[class.active]="activeTheme === theme"
|
ngbDropdownItem (click)="themeChanged(theme)">
|
||||||
ngbDropdownItem
|
|
||||||
(click)="themeChanged(theme)">
|
|
||||||
<span class="me-2 opacity-50">
|
<span class="me-2 opacity-50">
|
||||||
<fa-icon [icon]="theme.icon" />
|
<fa-icon [icon]="theme.icon" />
|
||||||
</span>
|
</span>
|
||||||
{{ theme.displayName }}
|
{{ theme.displayName }}
|
||||||
<span class="ms-auto"
|
<span class="ms-auto" [class.d-none]="activeTheme !== theme">
|
||||||
[class.d-none]="activeTheme !== theme">
|
|
||||||
<fa-icon [icon]="faCheck" />
|
<fa-icon [icon]="faCheck" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -100,11 +98,8 @@
|
||||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
Adding...
|
Adding...
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-danger btn-lg px-3 add-cancel-btn"
|
<button class="btn btn-outline-danger btn-lg px-3 add-cancel-btn" type="button" (click)="cancelAdding()"
|
||||||
type="button"
|
aria-label="Cancel adding URL" title="Cancel adding URL">
|
||||||
(click)="cancelAdding()"
|
|
||||||
aria-label="Cancel adding URL"
|
|
||||||
title="Cancel adding URL">
|
|
||||||
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
|
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
|
||||||
</button>
|
</button>
|
||||||
} @else if (subscribeInProgress) {
|
} @else if (subscribeInProgress) {
|
||||||
|
|
@ -116,13 +111,11 @@
|
||||||
Subscribing...
|
Subscribing...
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<button class="btn btn-primary btn-lg px-4" type="submit"
|
<button class="btn btn-primary btn-lg px-4" type="submit" (click)="addDownload()"
|
||||||
(click)="addDownload()"
|
|
||||||
[disabled]="downloads.loading">
|
[disabled]="downloads.loading">
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-secondary btn-lg px-3" type="button"
|
<button class="btn btn-outline-secondary btn-lg px-3" type="button" (click)="addSubscription()"
|
||||||
(click)="addSubscription()"
|
|
||||||
[disabled]="downloads.loading">
|
[disabled]="downloads.loading">
|
||||||
Subscribe
|
Subscribe
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -131,12 +124,8 @@
|
||||||
|
|
||||||
<!-- Narrow viewports: full-width field, then Bootstrap btn-group (no faux input-group strip) -->
|
<!-- Narrow viewports: full-width field, then Bootstrap btn-group (no faux input-group strip) -->
|
||||||
<div class="vstack gap-2 d-md-none">
|
<div class="vstack gap-2 d-md-none">
|
||||||
<input type="text"
|
<input type="text" autocomplete="off" spellcheck="false" class="form-control form-control-lg"
|
||||||
autocomplete="off"
|
placeholder="Enter video, channel, or playlist URL" [(ngModel)]="addUrl"
|
||||||
spellcheck="false"
|
|
||||||
class="form-control form-control-lg"
|
|
||||||
placeholder="Enter video, channel, or playlist URL"
|
|
||||||
[(ngModel)]="addUrl"
|
|
||||||
[ngModelOptions]="{standalone: true}"
|
[ngModelOptions]="{standalone: true}"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
<div class="btn-group w-100" role="group" aria-label="Download or subscribe">
|
<div class="btn-group w-100" role="group" aria-label="Download or subscribe">
|
||||||
|
|
@ -146,12 +135,8 @@
|
||||||
|
|
||||||
<!-- md and up: standard input-group so Bootstrap handles fused borders -->
|
<!-- md and up: standard input-group so Bootstrap handles fused borders -->
|
||||||
<div class="input-group input-group-lg shadow-sm d-none d-md-flex">
|
<div class="input-group input-group-lg shadow-sm d-none d-md-flex">
|
||||||
<input type="text"
|
<input type="text" autocomplete="off" spellcheck="false" class="form-control form-control-lg"
|
||||||
autocomplete="off"
|
placeholder="Enter video, channel, or playlist URL" [(ngModel)]="addUrl"
|
||||||
spellcheck="false"
|
|
||||||
class="form-control form-control-lg"
|
|
||||||
placeholder="Enter video, channel, or playlist URL"
|
|
||||||
[(ngModel)]="addUrl"
|
|
||||||
[ngModelOptions]="{standalone: true}"
|
[ngModelOptions]="{standalone: true}"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
<ng-container [ngTemplateOutlet]="urlBarActions" />
|
<ng-container [ngTemplateOutlet]="urlBarActions" />
|
||||||
|
|
@ -165,10 +150,7 @@
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Type</span>
|
<span class="input-group-text">Type</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="downloadType" [(ngModel)]="downloadType" (change)="downloadTypeChanged()"
|
||||||
name="downloadType"
|
|
||||||
[(ngModel)]="downloadType"
|
|
||||||
(change)="downloadTypeChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
@for (type of downloadTypes; track type.id) {
|
@for (type of downloadTypes; track type.id) {
|
||||||
<option [ngValue]="type.id">{{ type.text }}</option>
|
<option [ngValue]="type.id">{{ type.text }}</option>
|
||||||
|
|
@ -179,10 +161,7 @@
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Codec</span>
|
<span class="input-group-text">Codec</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="codec" [(ngModel)]="codec" (change)="codecChanged()"
|
||||||
name="codec"
|
|
||||||
[(ngModel)]="codec"
|
|
||||||
(change)="codecChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
@for (vc of videoCodecs; track vc.id) {
|
@for (vc of videoCodecs; track vc.id) {
|
||||||
<option [ngValue]="vc.id">{{ vc.text }}</option>
|
<option [ngValue]="vc.id">{{ vc.text }}</option>
|
||||||
|
|
@ -193,10 +172,7 @@
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Format</span>
|
<span class="input-group-text">Format</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="format" [(ngModel)]="format" (change)="formatChanged()"
|
||||||
name="format"
|
|
||||||
[(ngModel)]="format"
|
|
||||||
(change)="formatChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
@for (f of formatOptions; track f.id) {
|
@for (f of formatOptions; track f.id) {
|
||||||
<option [ngValue]="f.id">{{ f.text }}</option>
|
<option [ngValue]="f.id">{{ f.text }}</option>
|
||||||
|
|
@ -207,10 +183,7 @@
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Quality</span>
|
<span class="input-group-text">Quality</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="quality" [(ngModel)]="quality" (change)="qualityChanged()"
|
||||||
name="quality"
|
|
||||||
[(ngModel)]="quality"
|
|
||||||
(change)="qualityChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading || !showQualitySelector()">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading || !showQualitySelector()">
|
||||||
@for (q of qualities; track q.id) {
|
@for (q of qualities; track q.id) {
|
||||||
<option [ngValue]="q.id">{{ q.text }}</option>
|
<option [ngValue]="q.id">{{ q.text }}</option>
|
||||||
|
|
@ -222,10 +195,7 @@
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Type</span>
|
<span class="input-group-text">Type</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="downloadType" [(ngModel)]="downloadType" (change)="downloadTypeChanged()"
|
||||||
name="downloadType"
|
|
||||||
[(ngModel)]="downloadType"
|
|
||||||
(change)="downloadTypeChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
@for (type of downloadTypes; track type.id) {
|
@for (type of downloadTypes; track type.id) {
|
||||||
<option [ngValue]="type.id">{{ type.text }}</option>
|
<option [ngValue]="type.id">{{ type.text }}</option>
|
||||||
|
|
@ -236,10 +206,7 @@
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Format</span>
|
<span class="input-group-text">Format</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="format" [(ngModel)]="format" (change)="formatChanged()"
|
||||||
name="format"
|
|
||||||
[(ngModel)]="format"
|
|
||||||
(change)="formatChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
@for (f of formatOptions; track f.id) {
|
@for (f of formatOptions; track f.id) {
|
||||||
<option [ngValue]="f.id">{{ f.text }}</option>
|
<option [ngValue]="f.id">{{ f.text }}</option>
|
||||||
|
|
@ -250,10 +217,7 @@
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Quality</span>
|
<span class="input-group-text">Quality</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="quality" [(ngModel)]="quality" (change)="qualityChanged()"
|
||||||
name="quality"
|
|
||||||
[(ngModel)]="quality"
|
|
||||||
(change)="qualityChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
@for (q of qualities; track q.id) {
|
@for (q of qualities; track q.id) {
|
||||||
<option [ngValue]="q.id">{{ q.text }}</option>
|
<option [ngValue]="q.id">{{ q.text }}</option>
|
||||||
|
|
@ -266,10 +230,7 @@
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Type</span>
|
<span class="input-group-text">Type</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="downloadType" [(ngModel)]="downloadType" (change)="downloadTypeChanged()"
|
||||||
name="downloadType"
|
|
||||||
[(ngModel)]="downloadType"
|
|
||||||
(change)="downloadTypeChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
@for (type of downloadTypes; track type.id) {
|
@for (type of downloadTypes; track type.id) {
|
||||||
<option [ngValue]="type.id">{{ type.text }}</option>
|
<option [ngValue]="type.id">{{ type.text }}</option>
|
||||||
|
|
@ -280,10 +241,7 @@
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Format</span>
|
<span class="input-group-text">Format</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="format" [(ngModel)]="format" (change)="formatChanged()"
|
||||||
name="format"
|
|
||||||
[(ngModel)]="format"
|
|
||||||
(change)="formatChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||||
ngbTooltip="Subtitle output format for captions mode">
|
ngbTooltip="Subtitle output format for captions mode">
|
||||||
@for (f of formatOptions; track f.id) {
|
@for (f of formatOptions; track f.id) {
|
||||||
|
|
@ -295,14 +253,9 @@
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Language</span>
|
<span class="input-group-text">Language</span>
|
||||||
<input class="form-control"
|
<input class="form-control" type="text" list="subtitleLanguageOptions" name="subtitleLanguage"
|
||||||
type="text"
|
[(ngModel)]="subtitleLanguage" (change)="subtitleLanguageChanged()"
|
||||||
list="subtitleLanguageOptions"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading" placeholder="e.g. en, es, zh-Hans"
|
||||||
name="subtitleLanguage"
|
|
||||||
[(ngModel)]="subtitleLanguage"
|
|
||||||
(change)="subtitleLanguageChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
|
||||||
placeholder="e.g. en, es, zh-Hans"
|
|
||||||
ngbTooltip="Subtitle language (you can type any language code)">
|
ngbTooltip="Subtitle language (you can type any language code)">
|
||||||
<datalist id="subtitleLanguageOptions">
|
<datalist id="subtitleLanguageOptions">
|
||||||
@for (lang of subtitleLanguages; track lang.id) {
|
@for (lang of subtitleLanguages; track lang.id) {
|
||||||
|
|
@ -314,10 +267,7 @@
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Subtitle Source</span>
|
<span class="input-group-text">Subtitle Source</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="subtitleMode" [(ngModel)]="subtitleMode" (change)="subtitleModeChanged()"
|
||||||
name="subtitleMode"
|
|
||||||
[(ngModel)]="subtitleMode"
|
|
||||||
(change)="subtitleModeChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||||
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
|
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
|
||||||
@for (mode of subtitleModes; track mode.id) {
|
@for (mode of subtitleModes; track mode.id) {
|
||||||
|
|
@ -330,10 +280,7 @@
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Type</span>
|
<span class="input-group-text">Type</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="downloadType" [(ngModel)]="downloadType" (change)="downloadTypeChanged()"
|
||||||
name="downloadType"
|
|
||||||
[(ngModel)]="downloadType"
|
|
||||||
(change)="downloadTypeChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
@for (type of downloadTypes; track type.id) {
|
@for (type of downloadTypes; track type.id) {
|
||||||
<option [ngValue]="type.id">{{ type.text }}</option>
|
<option [ngValue]="type.id">{{ type.text }}</option>
|
||||||
|
|
@ -352,15 +299,10 @@
|
||||||
|
|
||||||
<div class="row mb-3 g-3">
|
<div class="row mb-3 g-3">
|
||||||
<div class="col-12 text-start">
|
<div class="col-12 text-start">
|
||||||
<button type="button"
|
<button type="button" class="btn btn-link p-0 text-decoration-none" (click)="toggleAdvanced()"
|
||||||
class="btn btn-link p-0 text-decoration-none"
|
[attr.aria-expanded]="isAdvancedOpen" aria-controls="advancedOptions">
|
||||||
(click)="toggleAdvanced()"
|
|
||||||
[attr.aria-expanded]="isAdvancedOpen"
|
|
||||||
aria-controls="advancedOptions">
|
|
||||||
Advanced Options
|
Advanced Options
|
||||||
<fa-icon
|
<fa-icon [icon]="isAdvancedOpen ? faChevronDown : faChevronRight" class="ms-1" />
|
||||||
[icon]="isAdvancedOpen ? faChevronDown : faChevronRight"
|
|
||||||
class="ms-1" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -375,10 +317,7 @@
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Auto Start</span>
|
<span class="input-group-text">Auto Start</span>
|
||||||
<select class="form-select"
|
<select class="form-select" name="autoStart" [(ngModel)]="autoStart" (change)="autoStartChanged()"
|
||||||
name="autoStart"
|
|
||||||
[(ngModel)]="autoStart"
|
|
||||||
(change)="autoStartChanged()"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||||
ngbTooltip="Automatically start downloads when added">
|
ngbTooltip="Automatically start downloads when added">
|
||||||
<option [ngValue]="true">Yes</option>
|
<option [ngValue]="true">Yes</option>
|
||||||
|
|
@ -390,18 +329,10 @@
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Download Folder</span>
|
<span class="input-group-text">Download Folder</span>
|
||||||
@if (customDirs$ | async; as customDirs) {
|
@if (customDirs$ | async; as customDirs) {
|
||||||
<ng-select [items]="customDirs"
|
<ng-select [items]="customDirs" placeholder="Default" [addTag]="allowCustomDir.bind(this)"
|
||||||
placeholder="Default"
|
addTagText="Create directory" bindLabel="folder" [(ngModel)]="folder"
|
||||||
[addTag]="allowCustomDir.bind(this)"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading" [virtualScroll]="true"
|
||||||
addTagText="Create directory"
|
[clearable]="true" [loading]="downloads.loading" [searchable]="true" [closeOnSelect]="true"
|
||||||
bindLabel="folder"
|
|
||||||
[(ngModel)]="folder"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
|
||||||
[virtualScroll]="true"
|
|
||||||
[clearable]="true"
|
|
||||||
[loading]="downloads.loading"
|
|
||||||
[searchable]="true"
|
|
||||||
[closeOnSelect]="true"
|
|
||||||
ngbTooltip="Choose where to save downloads. Type to create a new folder." />
|
ngbTooltip="Choose where to save downloads. Type to create a new folder." />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -410,10 +341,7 @@
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Custom Name Prefix</span>
|
<span class="input-group-text">Custom Name Prefix</span>
|
||||||
<input type="text"
|
<input type="text" class="form-control" placeholder="Default" name="customNamePrefix"
|
||||||
class="form-control"
|
|
||||||
placeholder="Default"
|
|
||||||
name="customNamePrefix"
|
|
||||||
[(ngModel)]="customNamePrefix"
|
[(ngModel)]="customNamePrefix"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||||
ngbTooltip="Add a prefix to downloaded filenames">
|
ngbTooltip="Add a prefix to downloaded filenames">
|
||||||
|
|
@ -422,13 +350,8 @@
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Items Limit</span>
|
<span class="input-group-text">Items Limit</span>
|
||||||
<input type="number"
|
<input type="number" min="0" class="form-control" placeholder="Default" name="playlistItemLimit"
|
||||||
min="0"
|
(keydown)="isNumber($event)" [(ngModel)]="playlistItemLimit"
|
||||||
class="form-control"
|
|
||||||
placeholder="Default"
|
|
||||||
name="playlistItemLimit"
|
|
||||||
(keydown)="isNumber($event)"
|
|
||||||
[(ngModel)]="playlistItemLimit"
|
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||||
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -436,12 +359,8 @@
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Subscription Check (min)</span>
|
<span class="input-group-text">Subscription Check (min)</span>
|
||||||
<input type="number"
|
<input type="number" min="1" class="form-control" name="checkIntervalMinutes"
|
||||||
min="1"
|
(keydown)="isNumber($event)" [(ngModel)]="checkIntervalMinutes"
|
||||||
class="form-control"
|
|
||||||
name="checkIntervalMinutes"
|
|
||||||
(keydown)="isNumber($event)"
|
|
||||||
[(ngModel)]="checkIntervalMinutes"
|
|
||||||
(ngModelChange)="checkIntervalChanged()"
|
(ngModelChange)="checkIntervalChanged()"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||||
ngbTooltip="How often to poll subscriptions for new videos">
|
ngbTooltip="How often to poll subscriptions for new videos">
|
||||||
|
|
@ -463,7 +382,8 @@
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Template</span>
|
<span class="input-group-text">Template</span>
|
||||||
<input type="text" class="form-control" name="chapterTemplate" [(ngModel)]="chapterTemplate"
|
<input type="text" class="form-control" name="chapterTemplate" [(ngModel)]="chapterTemplate"
|
||||||
(change)="chapterTemplateChanged()" [disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
(change)="chapterTemplateChanged()"
|
||||||
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||||
ngbTooltip="Output template for chapter files">
|
ngbTooltip="Output template for chapter files">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -476,81 +396,95 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
<div class="row g-3 mb-3 hasharr-integration">
|
<div class="row g-3 mb-3 webhook-integration">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="action-group-label">Hasharr Integration</div>
|
<div class="action-group-label">Webhook Integration</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-3 d-flex align-items-center">
|
<div class="col-12">
|
||||||
|
<div class="webhook-control-row">
|
||||||
|
<div class="webhook-control-enabled d-flex align-items-center">
|
||||||
<div class="form-check form-switch mb-0">
|
<div class="form-check form-switch mb-0">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="hasharrEnabled"
|
<input class="form-check-input" type="checkbox" role="switch" id="webhookEnabled"
|
||||||
name="hasharrEnabled" [(ngModel)]="hasharrEnabled"
|
name="webhookEnabled" [(ngModel)]="webhookEnabled"
|
||||||
[disabled]="addInProgress || downloads.loading">
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
<label class="form-check-label" for="hasharrEnabled">Enabled</label>
|
<label class="form-check-label" for="webhookEnabled">Enabled</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-5">
|
<div class="webhook-control-endpoint">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Hasharr URL</span>
|
<span class="input-group-text">Endpoint</span>
|
||||||
<input type="text"
|
<input type="text" class="form-control" placeholder="http://localhost:9995/api/hash-service/1"
|
||||||
class="form-control"
|
name="webhookEndpoint" [(ngModel)]="webhookEndpoint"
|
||||||
placeholder="http://hasharr:9995"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
name="hasharrUrl"
|
|
||||||
[(ngModel)]="hasharrUrl"
|
|
||||||
[disabled]="addInProgress || downloads.loading">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-2">
|
<div class="webhook-control-timeout">
|
||||||
<div class="input-group">
|
<div class="input-group webhook-timeout-group">
|
||||||
<span class="input-group-text">Service ID</span>
|
|
||||||
<input type="number"
|
|
||||||
min="1"
|
|
||||||
class="form-control"
|
|
||||||
name="hasharrServiceID"
|
|
||||||
[(ngModel)]="hasharrServiceID"
|
|
||||||
[disabled]="addInProgress || downloads.loading">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-2">
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">Timeout</span>
|
<span class="input-group-text">Timeout</span>
|
||||||
<input type="number"
|
<input type="number" min="1" class="form-control webhook-timeout-input" name="webhookTimeoutSec"
|
||||||
min="1"
|
[(ngModel)]="webhookTimeoutSec"
|
||||||
class="form-control"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
name="hasharrTimeoutSec"
|
</div>
|
||||||
[(ngModel)]="hasharrTimeoutSec"
|
</div>
|
||||||
[disabled]="addInProgress || downloads.loading">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-7">
|
<div class="col-12 col-md-7">
|
||||||
<div class="hasharr-action-row">
|
<div class="webhook-action-row">
|
||||||
<button type="button" class="btn btn-secondary" (click)="saveHasharrSettings()">
|
<button type="button" class="btn btn-secondary" (click)="saveWebhookSettings()">
|
||||||
Save Hasharr Settings
|
Save Webhook
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="testHasharrService()">
|
<button type="button" class="btn btn-outline-secondary" (click)="testWebhookService()">
|
||||||
Test Service
|
Test Service
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-5">
|
<div class="col-12 col-md-5">
|
||||||
<div class="hasharr-status-block">
|
<div class="webhook-status-block">
|
||||||
@if (hasharrSettingsStatus) {
|
@if (webhookSettingsStatus) {
|
||||||
<small class="text-muted d-block">{{ hasharrSettingsStatus }}</small>
|
<small class="text-muted d-block">{{ webhookSettingsStatus }}</small>
|
||||||
}
|
}
|
||||||
@if (hasharrTestStatus) {
|
@if (webhookTestStatus) {
|
||||||
<small class="text-muted d-block">{{ hasharrTestStatus }}</small>
|
<small class="text-muted d-block">{{ webhookTestStatus }}</small>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (hasharrTestProfile) {
|
@if (webhookTestResponsePretty) {
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="small text-muted hasharr-profile-result">
|
<div class="webhook-response-view-toggle">
|
||||||
<div><strong>Profile Name:</strong> {{ hasharrTestProfile['name'] }}</div>
|
<button
|
||||||
<div><strong>Enabled:</strong> {{ hasharrTestProfile['enabled'] }}</div>
|
type="button"
|
||||||
<div><strong>Apply Actions:</strong> {{ hasharrTestProfile['applyActions'] }}</div>
|
class="btn btn-sm"
|
||||||
<div><strong>Stash Index:</strong> {{ hasharrTestProfile['stashIndex'] }}</div>
|
[class.btn-secondary]="webhookTestViewMode === 'pretty'"
|
||||||
<div><strong>Max Time Delta:</strong> {{ hasharrTestProfile['maxTimeDelta'] }}</div>
|
[class.btn-outline-secondary]="webhookTestViewMode !== 'pretty'"
|
||||||
<div><strong>Max Distance:</strong> {{ hasharrTestProfile['maxDistance'] }}</div>
|
(click)="setWebhookTestViewMode('pretty')">
|
||||||
|
Pretty
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
[class.btn-secondary]="webhookTestViewMode === 'raw'"
|
||||||
|
[class.btn-outline-secondary]="webhookTestViewMode !== 'raw'"
|
||||||
|
(click)="setWebhookTestViewMode('raw')">
|
||||||
|
Raw
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (webhookTestViewMode === 'pretty') {
|
||||||
|
<div class="webhook-response-block webhook-response-pretty">
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<tbody>
|
||||||
|
@for (entry of webhookTestResponseEntries; track entry.key) {
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{{ entry.key }}:</th>
|
||||||
|
<td>{{ entry.value }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<pre class="webhook-response-block">{{ webhookTestResponseRaw }}</pre>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -558,13 +492,11 @@
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="action-group-label">Cookies</div>
|
<div class="action-group-label">Cookies</div>
|
||||||
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
|
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
|
||||||
(change)="onCookieFileSelect($event)"
|
(change)="onCookieFileSelect($event)" [disabled]="cookieUploadInProgress || addInProgress">
|
||||||
[disabled]="cookieUploadInProgress || addInProgress">
|
|
||||||
<div class="btn-group w-100" role="group">
|
<div class="btn-group w-100" role="group">
|
||||||
<label class="btn mb-0"
|
<label class="btn mb-0"
|
||||||
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
|
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
|
||||||
[class.disabled]="cookieUploadInProgress || addInProgress"
|
[class.disabled]="cookieUploadInProgress || addInProgress" for="cookie-upload"
|
||||||
for="cookie-upload"
|
|
||||||
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
|
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
|
||||||
@if (cookieUploadInProgress) {
|
@if (cookieUploadInProgress) {
|
||||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
|
|
@ -574,10 +506,8 @@
|
||||||
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
|
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
|
||||||
</label>
|
</label>
|
||||||
@if (hasCookies) {
|
@if (hasCookies) {
|
||||||
<button type="button" class="btn btn-outline-danger"
|
<button type="button" class="btn btn-outline-danger" (click)="deleteCookies()"
|
||||||
(click)="deleteCookies()"
|
[disabled]="cookieUploadInProgress || addInProgress" ngbTooltip="Remove uploaded cookies">
|
||||||
[disabled]="cookieUploadInProgress || addInProgress"
|
|
||||||
ngbTooltip="Remove uploaded cookies">
|
|
||||||
<fa-icon [icon]="faTrashAlt" />
|
<fa-icon [icon]="faTrashAlt" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
@ -595,25 +525,19 @@
|
||||||
<div class="action-group-label">Bulk Actions</div>
|
<div class="action-group-label">Bulk Actions</div>
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<button type="button"
|
<button type="button" class="btn btn-secondary w-100" (click)="openBatchImportModal()">
|
||||||
class="btn btn-secondary w-100"
|
|
||||||
(click)="openBatchImportModal()">
|
|
||||||
<fa-icon [icon]="faFileImport" class="me-2" />
|
<fa-icon [icon]="faFileImport" class="me-2" />
|
||||||
Import URLs
|
Import URLs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<button type="button"
|
<button type="button" class="btn btn-secondary w-100" (click)="exportBatchUrls('all')">
|
||||||
class="btn btn-secondary w-100"
|
|
||||||
(click)="exportBatchUrls('all')">
|
|
||||||
<fa-icon [icon]="faFileExport" class="me-2" />
|
<fa-icon [icon]="faFileExport" class="me-2" />
|
||||||
Export URLs
|
Export URLs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<button type="button"
|
<button type="button" class="btn btn-secondary w-100" (click)="copyBatchUrls('all')">
|
||||||
class="btn btn-secondary w-100"
|
|
||||||
(click)="copyBatchUrls('all')">
|
|
||||||
<fa-icon [icon]="faCopy" class="me-2" />
|
<fa-icon [icon]="faCopy" class="me-2" />
|
||||||
Copy URLs
|
Copy URLs
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -631,11 +555,8 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Batch Import Modal -->
|
<!-- Batch Import Modal -->
|
||||||
<div class="modal fade" tabindex="-1" role="dialog"
|
<div class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="batch-import-modal-title"
|
||||||
aria-modal="true"
|
[class.show]="batchImportModalOpen" [style.display]="batchImportModalOpen ? 'block' : 'none'">
|
||||||
aria-labelledby="batch-import-modal-title"
|
|
||||||
[class.show]="batchImportModalOpen"
|
|
||||||
[style.display]="batchImportModalOpen ? 'block' : 'none'">
|
|
||||||
<div class="modal-dialog" role="document">
|
<div class="modal-dialog" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|
@ -666,6 +587,40 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="ui-settings-modal-title"
|
||||||
|
[class.show]="settingsModalOpen" [style.display]="settingsModalOpen ? 'block' : 'none'">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 id="ui-settings-modal-title" class="modal-title">UI Refresh Settings</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="closeSettingsModal()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">Focused Refresh (ms)</span>
|
||||||
|
<input type="number" min="500" max="10000" class="form-control" [(ngModel)]="focusedRefreshMs"
|
||||||
|
[ngModelOptions]="{standalone: true}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">Background Refresh (ms)</span>
|
||||||
|
<input type="number" min="2000" max="120000" class="form-control" [(ngModel)]="backgroundRefreshMs"
|
||||||
|
[ngModelOptions]="{standalone: true}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" (click)="closeSettingsModal()">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" (click)="saveRefreshSettings()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@if (downloads.loading) {
|
@if (downloads.loading) {
|
||||||
<div class="alert alert-info" role="alert">
|
<div class="alert alert-info" role="alert">
|
||||||
|
|
@ -674,15 +629,18 @@
|
||||||
}
|
}
|
||||||
<div class="metube-section-header">Downloading</div>
|
<div class="metube-section-header">Downloading</div>
|
||||||
<div class="px-2 py-3 border-bottom">
|
<div class="px-2 py-3 border-bottom">
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" /> Cancel selected</button>
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" /> Download selected</button>
|
(click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" /> Cancel selected</button>
|
||||||
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected
|
||||||
|
(click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" /> Download selected</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-auto">
|
<div class="overflow-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" style="width: 1rem;">
|
<th scope="col" style="width: 1rem;">
|
||||||
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
|
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue"
|
||||||
|
(changed)="queueSelectionChanged($event)" />
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Video</th>
|
<th scope="col">Video</th>
|
||||||
<th scope="col" style="width: 8rem;">Speed</th>
|
<th scope="col" style="width: 8rem;">Speed</th>
|
||||||
|
|
@ -699,8 +657,10 @@
|
||||||
<td title="{{ download.value.filename }}">
|
<td title="{{ download.value.filename }}">
|
||||||
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
||||||
<div>{{ download.value.title }} </div>
|
<div>{{ download.value.title }} </div>
|
||||||
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
|
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'"
|
||||||
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
|
[striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'"
|
||||||
|
type="success" [value]="download.value.status === 'preparing' ? 100 : download.value.percent"
|
||||||
|
class="download-progressbar" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ download.value.speed | speed }}</td>
|
<td>{{ download.value.speed | speed }}</td>
|
||||||
|
|
@ -708,10 +668,16 @@
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
@if (download.value.status === 'pending') {
|
@if (download.value.status === 'pending') {
|
||||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
<button type="button" class="btn btn-link"
|
||||||
|
[attr.aria-label]="'Start download for ' + download.value.title"
|
||||||
|
(click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
<button type="button" class="btn btn-link"
|
||||||
<a href="{{download.value.url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
[attr.aria-label]="'Remove ' + download.value.title + ' from queue'"
|
||||||
|
(click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
||||||
|
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"
|
||||||
|
[attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon
|
||||||
|
[icon]="faExternalLinkAlt" /></a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -722,19 +688,28 @@
|
||||||
|
|
||||||
<div class="metube-section-header">Completed</div>
|
<div class="metube-section-header">Completed</div>
|
||||||
<div class="px-2 py-3 border-bottom">
|
<div class="px-2 py-3 border-bottom">
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()"
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone" (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
[icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
first' }}</button>
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" /> Retry failed</button>
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" /> Download Selected</button>
|
(click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
||||||
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone"
|
||||||
|
(click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
||||||
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone"
|
||||||
|
(click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
||||||
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone"
|
||||||
|
(click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" /> Retry failed</button>
|
||||||
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected
|
||||||
|
(click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" /> Download Selected</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-auto">
|
<div class="overflow-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" style="width: 1rem;">
|
<th scope="col" style="width: 1rem;">
|
||||||
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" />
|
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done"
|
||||||
|
(changed)="doneSelectionChanged($event)" />
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Video</th>
|
<th scope="col">Video</th>
|
||||||
<th scope="col">Type</th>
|
<th scope="col">Type</th>
|
||||||
|
|
@ -757,8 +732,7 @@
|
||||||
<fa-icon [icon]="faCheckCircle" class="text-success" />
|
<fa-icon [icon]="faCheckCircle" class="text-success" />
|
||||||
}
|
}
|
||||||
@if (entry[1].status === 'error') {
|
@if (entry[1].status === 'error') {
|
||||||
<button type="button" class="btn btn-link p-0"
|
<button type="button" class="btn btn-link p-0" (click)="toggleErrorDetail(entry[0])"
|
||||||
(click)="toggleErrorDetail(entry[0])"
|
|
||||||
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
|
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
|
||||||
[attr.aria-expanded]="isErrorExpanded(entry[0])">
|
[attr.aria-expanded]="isErrorExpanded(entry[0])">
|
||||||
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
||||||
|
|
@ -769,7 +743,8 @@
|
||||||
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
|
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
|
||||||
} @else {
|
} @else {
|
||||||
@if (entry[1].status === 'error') {
|
@if (entry[1].status === 'error') {
|
||||||
<button type="button" class="btn btn-link p-0 text-start align-baseline" (click)="toggleErrorDetail(entry[0])">
|
<button type="button" class="btn btn-link p-0 text-start align-baseline"
|
||||||
|
(click)="toggleErrorDetail(entry[0])">
|
||||||
{{entry[1].title}}
|
{{entry[1].title}}
|
||||||
@if (!isErrorExpanded(entry[0])) {
|
@if (!isErrorExpanded(entry[0])) {
|
||||||
<small class="text-danger ms-2">
|
<small class="text-danger ms-2">
|
||||||
|
|
@ -828,13 +803,17 @@
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
@if (entry[1].status === 'error') {
|
@if (entry[1].status === 'error') {
|
||||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title"
|
||||||
|
(click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
||||||
}
|
}
|
||||||
@if (entry[1].filename) {
|
@if (entry[1].filename) {
|
||||||
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
|
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link"
|
||||||
|
[attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
|
||||||
}
|
}
|
||||||
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link"
|
||||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
[attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||||
|
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title"
|
||||||
|
(click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -845,7 +824,8 @@
|
||||||
<td>
|
<td>
|
||||||
<div style="padding-left: 2rem;">
|
<div style="padding-left: 2rem;">
|
||||||
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
|
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
|
||||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank" [attr.aria-label]="'Open chapter file ' + getChapterFileName(chapterFile.filename)">{{
|
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank"
|
||||||
|
[attr.aria-label]="'Open chapter file ' + getChapterFileName(chapterFile.filename)">{{
|
||||||
getChapterFileName(chapterFile.filename) }}</a>
|
getChapterFileName(chapterFile.filename) }}</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -860,7 +840,8 @@
|
||||||
<td></td>
|
<td></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download [attr.aria-label]="'Download chapter file ' + getChapterFileName(chapterFile.filename)"
|
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download
|
||||||
|
[attr.aria-label]="'Download chapter file ' + getChapterFileName(chapterFile.filename)"
|
||||||
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -879,8 +860,7 @@
|
||||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
|
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="checkAllSubscriptions()"
|
||||||
(click)="checkAllSubscriptions()"
|
|
||||||
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
|
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
|
||||||
<fa-icon [icon]="faRedoAlt" /> Check all now
|
<fa-icon [icon]="faRedoAlt" /> Check all now
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -890,14 +870,12 @@
|
||||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
|
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="checkSelectedSubscriptions()"
|
||||||
(click)="checkSelectedSubscriptions()"
|
|
||||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
|
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
|
||||||
<fa-icon [icon]="faRedoAlt" /> Check selected
|
<fa-icon [icon]="faRedoAlt" /> Check selected
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="deleteSelectedSubscriptions()"
|
||||||
(click)="deleteSelectedSubscriptions()"
|
|
||||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
|
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
|
||||||
<fa-icon [icon]="faTrashAlt" /> Delete selected
|
<fa-icon [icon]="faTrashAlt" /> Delete selected
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -907,10 +885,8 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" style="width: 1rem;">
|
<th scope="col" style="width: 1rem;">
|
||||||
<input type="checkbox" class="form-check-input"
|
<input type="checkbox" class="form-check-input" [checked]="allSubsSelected()"
|
||||||
[checked]="allSubsSelected()"
|
(change)="toggleSubMaster($event)" [disabled]="downloads.loading || cachedSubs.length === 0"
|
||||||
(change)="toggleSubMaster($event)"
|
|
||||||
[disabled]="downloads.loading || cachedSubs.length === 0"
|
|
||||||
aria-label="Select all subscriptions" />
|
aria-label="Select all subscriptions" />
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Name</th>
|
<th scope="col">Name</th>
|
||||||
|
|
@ -925,10 +901,8 @@
|
||||||
@for (entry of cachedSubs; track entry[0]) {
|
@for (entry of cachedSubs; track entry[0]) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" class="form-check-input"
|
<input type="checkbox" class="form-check-input" [checked]="isSubSelected(entry[0])"
|
||||||
[checked]="isSubSelected(entry[0])"
|
(change)="toggleSubSelected(entry[0])" [disabled]="downloads.loading"
|
||||||
(change)="toggleSubSelected(entry[0])"
|
|
||||||
[disabled]="downloads.loading"
|
|
||||||
[attr.aria-label]="'Select subscription ' + entry[1].name" />
|
[attr.aria-label]="'Select subscription ' + entry[1].name" />
|
||||||
</td>
|
</td>
|
||||||
<td>{{ entry[1].name }}</td>
|
<td>{{ entry[1].name }}</td>
|
||||||
|
|
@ -953,23 +927,17 @@
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="d-flex flex-wrap gap-1">
|
||||||
@if (isSubscriptionChecking(entry[0])) {
|
@if (isSubscriptionChecking(entry[0])) {
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
<button type="button" class="btn btn-link btn-sm p-0 me-2" disabled
|
||||||
disabled
|
[attr.aria-label]="'Checking ' + entry[1].name" ngbTooltip="Checking now">
|
||||||
[attr.aria-label]="'Checking ' + entry[1].name"
|
|
||||||
ngbTooltip="Checking now">
|
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
<button type="button" class="btn btn-link btn-sm p-0 me-2" (click)="checkSubscriptionNow(entry[0])"
|
||||||
(click)="checkSubscriptionNow(entry[0])"
|
[disabled]="downloads.loading" [attr.aria-label]="'Check now ' + entry[1].name" ngbTooltip="Check now">
|
||||||
[disabled]="downloads.loading"
|
|
||||||
[attr.aria-label]="'Check now ' + entry[1].name"
|
|
||||||
ngbTooltip="Check now">
|
|
||||||
<fa-icon [icon]="faRedoAlt" />
|
<fa-icon [icon]="faRedoAlt" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
<button type="button" class="btn btn-link btn-sm p-0 me-2" (click)="toggleSubscriptionEnabled(entry[1])"
|
||||||
(click)="toggleSubscriptionEnabled(entry[1])"
|
|
||||||
[disabled]="downloads.loading"
|
[disabled]="downloads.loading"
|
||||||
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
|
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
|
||||||
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
|
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
|
||||||
|
|
@ -979,10 +947,8 @@
|
||||||
<fa-icon [icon]="faPlay" />
|
<fa-icon [icon]="faPlay" />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
|
<button type="button" class="btn btn-link btn-sm p-0 text-danger" (click)="deleteSubscription(entry[0])"
|
||||||
(click)="deleteSubscription(entry[0])"
|
[disabled]="downloads.loading" [attr.aria-label]="'Delete subscription ' + entry[1].name">
|
||||||
[disabled]="downloads.loading"
|
|
||||||
[attr.aria-label]="'Delete subscription ' + entry[1].name">
|
|
||||||
<fa-icon [icon]="faTrashAlt" />
|
<fa-icon [icon]="faTrashAlt" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -197,25 +197,86 @@ main
|
||||||
&.active
|
&.active
|
||||||
color: var(--bs-success-text-emphasis)
|
color: var(--bs-success-text-emphasis)
|
||||||
|
|
||||||
.hasharr-integration
|
.webhook-integration
|
||||||
.input-group
|
.input-group
|
||||||
margin-bottom: 0
|
margin-bottom: 0
|
||||||
|
|
||||||
.hasharr-action-row
|
.webhook-control-row
|
||||||
|
display: grid
|
||||||
|
row-gap: 0.75rem
|
||||||
|
|
||||||
|
.webhook-control-endpoint
|
||||||
|
min-width: 0
|
||||||
|
|
||||||
|
.webhook-timeout-group
|
||||||
|
flex-wrap: nowrap
|
||||||
|
|
||||||
|
.webhook-timeout-input
|
||||||
|
width: 3.4em
|
||||||
|
min-width: 3.4em
|
||||||
|
max-width: 3.4em
|
||||||
|
flex: 0 0 3.4em
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
.webhook-integration
|
||||||
|
.webhook-control-row
|
||||||
|
grid-template-columns: 150px minmax(0, 1fr) 200px
|
||||||
|
column-gap: 0.125rem
|
||||||
|
|
||||||
|
.webhook-control-endpoint
|
||||||
|
margin-left: -10px
|
||||||
|
|
||||||
|
.webhook-timeout-group
|
||||||
|
justify-content: flex-start
|
||||||
|
|
||||||
|
.webhook-timeout-input
|
||||||
|
width: 65px
|
||||||
|
min-width: 65px
|
||||||
|
max-width: 65px
|
||||||
|
flex: 0 0 65px
|
||||||
|
|
||||||
|
.webhook-action-row
|
||||||
display: flex
|
display: flex
|
||||||
flex-wrap: wrap
|
flex-wrap: wrap
|
||||||
gap: 0.5rem
|
gap: 0.5rem
|
||||||
|
|
||||||
.hasharr-status-block
|
.webhook-status-block
|
||||||
min-height: 2.5rem
|
min-height: 2.5rem
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
justify-content: center
|
justify-content: center
|
||||||
text-align: left
|
text-align: left
|
||||||
|
|
||||||
.hasharr-profile-result
|
.webhook-response-block
|
||||||
margin-top: 0.25rem
|
margin-top: 0.25rem
|
||||||
padding: 0.5rem 0.75rem
|
padding: 0.5rem 0.75rem
|
||||||
border: 1px solid var(--bs-border-color)
|
border: 1px solid var(--bs-border-color)
|
||||||
border-radius: 0.375rem
|
border-radius: 0.375rem
|
||||||
background: var(--bs-tertiary-bg)
|
background: var(--bs-tertiary-bg)
|
||||||
|
white-space: pre-wrap
|
||||||
|
word-break: break-word
|
||||||
|
|
||||||
|
.webhook-response-view-toggle
|
||||||
|
display: inline-flex
|
||||||
|
gap: 0.35rem
|
||||||
|
margin-top: 0.25rem
|
||||||
|
|
||||||
|
.webhook-response-pretty
|
||||||
|
table
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
th,
|
||||||
|
td
|
||||||
|
padding: 0
|
||||||
|
border: 0
|
||||||
|
vertical-align: top
|
||||||
|
line-height: 1.5
|
||||||
|
|
||||||
|
th
|
||||||
|
width: 170px
|
||||||
|
font-weight: 700
|
||||||
|
color: var(--bs-emphasis-color)
|
||||||
|
|
||||||
|
td
|
||||||
|
font-weight: 400
|
||||||
|
color: var(--bs-emphasis-color)
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
import { NgSelectModule } from '@ng-select/ng-select';
|
||||||
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay } from '@fortawesome/free-solid-svg-icons';
|
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay, faGear } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
import { AddDownloadPayload, DownloadsService, HasharrSettings, HasharrServiceTestResponse } from './services/downloads.service';
|
import { AddDownloadPayload, DownloadsService, WebhookSettings, WebhookTestResponse } from './services/downloads.service';
|
||||||
import { SubscriptionsService } from './services/subscriptions.service';
|
import { SubscriptionsService } from './services/subscriptions.service';
|
||||||
import { SubscriptionRow } from './interfaces/subscription';
|
import { SubscriptionRow } from './interfaces/subscription';
|
||||||
import { Themes } from './theme';
|
import { Themes } from './theme';
|
||||||
|
|
@ -107,13 +107,18 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
ytDlpVersion: string | null = null;
|
ytDlpVersion: string | null = null;
|
||||||
metubeVersion: string | null = null;
|
metubeVersion: string | null = null;
|
||||||
isAdvancedOpen = false;
|
isAdvancedOpen = false;
|
||||||
hasharrEnabled = false;
|
webhookEnabled = false;
|
||||||
hasharrUrl = 'http://hasharr:9995';
|
webhookEndpoint = 'http://localhost:9876/api/web-service/';
|
||||||
hasharrServiceID = 1;
|
webhookTimeoutSec = 20;
|
||||||
hasharrTimeoutSec = 20;
|
webhookSettingsStatus = '';
|
||||||
hasharrSettingsStatus = '';
|
webhookTestStatus = '';
|
||||||
hasharrTestStatus = '';
|
webhookTestViewMode: 'pretty' | 'raw' = 'pretty';
|
||||||
hasharrTestProfile: Record<string, unknown> | null = null;
|
webhookTestResponseEntries: Array<{ key: string; value: string }> = [];
|
||||||
|
webhookTestResponsePretty = '';
|
||||||
|
webhookTestResponseRaw = '';
|
||||||
|
settingsModalOpen = false;
|
||||||
|
focusedRefreshMs = 3000;
|
||||||
|
backgroundRefreshMs = 30000;
|
||||||
sortAscending = false;
|
sortAscending = false;
|
||||||
expandedErrors: Set<string> = new Set<string>();
|
expandedErrors: Set<string> = new Set<string>();
|
||||||
cachedSortedDone: [string, Download][] = [];
|
cachedSortedDone: [string, Download][] = [];
|
||||||
|
|
@ -129,6 +134,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
}> = {};
|
}> = {};
|
||||||
private readonly selectionCookiePrefix = 'metube_selection_';
|
private readonly selectionCookiePrefix = 'metube_selection_';
|
||||||
private readonly settingsCookieExpiryDays = 3650;
|
private readonly settingsCookieExpiryDays = 3650;
|
||||||
|
private readonly focusedRefreshCookie = 'metube_focused_refresh_ms';
|
||||||
|
private readonly backgroundRefreshCookie = 'metube_background_refresh_ms';
|
||||||
private lastFocusedElement: HTMLElement | null = null;
|
private lastFocusedElement: HTMLElement | null = null;
|
||||||
private colorSchemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
private colorSchemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
private onColorSchemeChanged = () => {
|
private onColorSchemeChanged = () => {
|
||||||
|
|
@ -176,6 +183,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
faUpload = faUpload;
|
faUpload = faUpload;
|
||||||
faPause = faPause;
|
faPause = faPause;
|
||||||
faPlay = faPlay;
|
faPlay = faPlay;
|
||||||
|
faGear = faGear;
|
||||||
subtitleLanguages = [
|
subtitleLanguages = [
|
||||||
{ id: 'en', text: 'English' },
|
{ id: 'en', text: 'English' },
|
||||||
{ id: 'ar', text: 'Arabic' },
|
{ id: 'ar', text: 'Arabic' },
|
||||||
|
|
@ -263,6 +271,15 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
if (!Number.isNaN(ci) && ci >= 1) {
|
if (!Number.isNaN(ci) && ci >= 1) {
|
||||||
this.checkIntervalMinutes = ci;
|
this.checkIntervalMinutes = ci;
|
||||||
}
|
}
|
||||||
|
const focusedCookie = parseInt(this.cookieService.get(this.focusedRefreshCookie) || '', 10);
|
||||||
|
if (!Number.isNaN(focusedCookie)) {
|
||||||
|
this.focusedRefreshMs = this.clampRefreshMs(focusedCookie, 500, 10000);
|
||||||
|
}
|
||||||
|
const backgroundCookie = parseInt(this.cookieService.get(this.backgroundRefreshCookie) || '', 10);
|
||||||
|
if (!Number.isNaN(backgroundCookie)) {
|
||||||
|
this.backgroundRefreshMs = this.clampRefreshMs(backgroundCookie, 2000, 120000);
|
||||||
|
}
|
||||||
|
this.downloads.setRefreshCadence(this.focusedRefreshMs, this.backgroundRefreshMs);
|
||||||
this.activeTheme = this.getPreferredTheme(this.cookieService);
|
this.activeTheme = this.getPreferredTheme(this.cookieService);
|
||||||
|
|
||||||
// Subscribe to download updates
|
// Subscribe to download updates
|
||||||
|
|
@ -298,7 +315,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
this.getConfiguration();
|
this.getConfiguration();
|
||||||
this.loadHasharrSettings();
|
this.loadWebhookSettings();
|
||||||
this.getYtdlOptionsUpdateTime();
|
this.getYtdlOptionsUpdateTime();
|
||||||
this.customDirs$ = this.getMatchingCustomDir();
|
this.customDirs$ = this.getMatchingCustomDir();
|
||||||
this.setTheme(this.activeTheme!);
|
this.setTheme(this.activeTheme!);
|
||||||
|
|
@ -306,94 +323,117 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged);
|
this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadHasharrSettings() {
|
loadWebhookSettings() {
|
||||||
this.downloads.getHasharrSettings().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
this.downloads.getWebhookSettings().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||||
next: (data) => {
|
next: (data) => {
|
||||||
if (!data || typeof data !== 'object' || ('status' in data && data.status === 'error')) {
|
if (!data || typeof data !== 'object' || ('status' in data && data.status === 'error')) {
|
||||||
this.hasharrSettingsStatus = 'Unable to load hasharr settings.';
|
this.webhookSettingsStatus = 'Unable to load webhook settings.';
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const settings = data as HasharrSettings;
|
const settings = data as WebhookSettings;
|
||||||
this.hasharrEnabled = !!settings.enabled;
|
this.webhookEnabled = !!settings.enabled;
|
||||||
this.hasharrUrl = String(settings.url || 'http://hasharr:9995');
|
this.webhookEndpoint = String(settings.endpoint || 'http://localhost:9876/api/web-service/');
|
||||||
this.hasharrServiceID = Math.max(1, Number(settings.service_id || 1));
|
this.webhookTimeoutSec = Math.max(1, Number(settings.timeout_sec || 20));
|
||||||
this.hasharrTimeoutSec = Math.max(1, Number(settings.timeout_sec || 20));
|
this.webhookSettingsStatus = '';
|
||||||
this.hasharrSettingsStatus = '';
|
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.hasharrSettingsStatus = 'Unable to load hasharr settings.';
|
this.webhookSettingsStatus = 'Unable to load webhook settings.';
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
saveHasharrSettings() {
|
saveWebhookSettings() {
|
||||||
const payload: HasharrSettings = {
|
const payload: WebhookSettings = {
|
||||||
enabled: !!this.hasharrEnabled,
|
enabled: !!this.webhookEnabled,
|
||||||
url: String(this.hasharrUrl || '').trim(),
|
endpoint: String(this.webhookEndpoint || '').trim(),
|
||||||
service_id: Math.max(1, Number(this.hasharrServiceID || 1)),
|
timeout_sec: Math.max(1, Number(this.webhookTimeoutSec || 20)),
|
||||||
timeout_sec: Math.max(1, Number(this.hasharrTimeoutSec || 20)),
|
|
||||||
};
|
};
|
||||||
if (!payload.url) {
|
if (!payload.endpoint) {
|
||||||
this.hasharrSettingsStatus = 'Hasharr URL is required.';
|
this.webhookSettingsStatus = 'Endpoint is required.';
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.downloads.saveHasharrSettings(payload).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
this.downloads.saveWebhookSettings(payload).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||||
next: (out) => {
|
next: (out) => {
|
||||||
if (out && typeof out === 'object' && 'status' in out && out.status === 'ok') {
|
if (out && typeof out === 'object' && 'status' in out && out.status === 'ok') {
|
||||||
this.hasharrSettingsStatus = 'Hasharr settings saved.';
|
this.webhookSettingsStatus = 'Webhook settings saved.';
|
||||||
} else {
|
} else {
|
||||||
this.hasharrSettingsStatus = 'Failed to save hasharr settings.';
|
this.webhookSettingsStatus = 'Failed to save webhook settings.';
|
||||||
}
|
}
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.hasharrSettingsStatus = 'Failed to save hasharr settings.';
|
this.webhookSettingsStatus = 'Failed to save webhook settings.';
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
testHasharrService() {
|
testWebhookService() {
|
||||||
this.hasharrTestStatus = 'Testing hasharr service...';
|
this.webhookTestStatus = 'Testing webhook endpoint...';
|
||||||
this.hasharrTestProfile = null;
|
this.webhookTestViewMode = 'pretty';
|
||||||
|
this.webhookTestResponseEntries = [];
|
||||||
|
this.webhookTestResponseRaw = '';
|
||||||
|
this.webhookTestResponsePretty = '';
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
this.downloads.testHasharrSettings({
|
this.downloads.testWebhookSettings({
|
||||||
url: String(this.hasharrUrl || '').trim(),
|
endpoint: String(this.webhookEndpoint || '').trim(),
|
||||||
service_id: Math.max(1, Number(this.hasharrServiceID || 1)),
|
timeout_sec: Math.max(1, Number(this.webhookTimeoutSec || 20)),
|
||||||
timeout_sec: Math.max(1, Number(this.hasharrTimeoutSec || 20)),
|
|
||||||
}).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
}).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||||
next: (resp) => {
|
next: (resp) => {
|
||||||
if (!resp || typeof resp !== 'object' || ('status' in resp && resp.status === 'error')) {
|
if (!resp || typeof resp !== 'object') {
|
||||||
this.hasharrTestStatus = 'Hasharr test failed.';
|
this.webhookTestStatus = 'Webhook test failed.';
|
||||||
this.hasharrTestProfile = null;
|
this.webhookTestResponsePretty = '';
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = resp as HasharrServiceTestResponse;
|
const result = resp as WebhookTestResponse;
|
||||||
if (result.valid_service_id) {
|
const display = result.response ?? result;
|
||||||
this.hasharrTestStatus = 'Hasharr reachable. Service ID is valid.';
|
this.webhookTestStatus = result.message || (result.status === 'ok' ? 'Webhook test succeeded.' : 'Webhook test completed with errors.');
|
||||||
this.hasharrTestProfile = result.profile || null;
|
this.webhookTestResponseRaw = JSON.stringify(display, null, 2);
|
||||||
} else if (result.reachable) {
|
this.webhookTestResponsePretty = this.webhookTestResponseRaw;
|
||||||
this.hasharrTestStatus = result.message || 'Hasharr reachable, but service ID is not valid.';
|
this.webhookTestResponseEntries = this.toWebhookResponseEntries(display);
|
||||||
this.hasharrTestProfile = null;
|
|
||||||
} else {
|
|
||||||
this.hasharrTestStatus = result.message || 'Could not reach hasharr.';
|
|
||||||
this.hasharrTestProfile = null;
|
|
||||||
}
|
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.hasharrTestStatus = 'Hasharr test failed.';
|
this.webhookTestStatus = 'Webhook test failed.';
|
||||||
this.hasharrTestProfile = null;
|
this.webhookTestResponseEntries = [];
|
||||||
|
this.webhookTestResponseRaw = '';
|
||||||
|
this.webhookTestResponsePretty = '';
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setWebhookTestViewMode(mode: 'pretty' | 'raw') {
|
||||||
|
this.webhookTestViewMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toWebhookResponseEntries(value: unknown): Array<{ key: string; value: string }> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return [{ key: 'value', value: this.stringifyWebhookValue(value) }];
|
||||||
|
}
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
return Object.entries(obj).map(([key, entryValue]) => ({
|
||||||
|
key,
|
||||||
|
value: this.stringifyWebhookValue(entryValue),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringifyWebhookValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
this.queueMasterCheckbox()?.selectionChanged();
|
this.queueMasterCheckbox()?.selectionChanged();
|
||||||
|
|
@ -414,6 +454,31 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openSettingsModal(): void {
|
||||||
|
this.lastFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
this.settingsModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSettingsModal(): void {
|
||||||
|
this.settingsModalOpen = false;
|
||||||
|
this.lastFocusedElement?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRefreshSettings(): void {
|
||||||
|
this.focusedRefreshMs = this.clampRefreshMs(this.focusedRefreshMs, 500, 10000);
|
||||||
|
this.backgroundRefreshMs = this.clampRefreshMs(this.backgroundRefreshMs, 2000, 120000);
|
||||||
|
this.downloads.setRefreshCadence(this.focusedRefreshMs, this.backgroundRefreshMs);
|
||||||
|
this.cookieService.set(this.focusedRefreshCookie, String(this.focusedRefreshMs), { expires: this.settingsCookieExpiryDays });
|
||||||
|
this.cookieService.set(this.backgroundRefreshCookie, String(this.backgroundRefreshMs), { expires: this.settingsCookieExpiryDays });
|
||||||
|
this.closeSettingsModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampRefreshMs(value: number, min: number, max: number): number {
|
||||||
|
const normalized = Number(value);
|
||||||
|
if (Number.isNaN(normalized)) return min;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(normalized)));
|
||||||
|
}
|
||||||
|
|
||||||
// workaround to allow fetching of Map values in the order they were inserted
|
// workaround to allow fetching of Map values in the order they were inserted
|
||||||
// https://github.com/angular/angular/issues/31420
|
// https://github.com/angular/angular/issues/31420
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,17 @@ import { MeTubeSocket } from './metube-socket.service';
|
||||||
import { Download, Status, State } from '../interfaces';
|
import { Download, Status, State } from '../interfaces';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
export interface HasharrSettings {
|
export interface WebhookSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url: string;
|
endpoint: string;
|
||||||
service_id: number;
|
|
||||||
timeout_sec: number;
|
timeout_sec: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HasharrServiceTestResponse {
|
export interface WebhookTestResponse {
|
||||||
status: string;
|
status: string;
|
||||||
reachable: boolean;
|
|
||||||
valid_service_id: boolean;
|
|
||||||
message?: string;
|
message?: string;
|
||||||
profile?: Record<string, unknown>;
|
response?: unknown;
|
||||||
|
status_code?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddDownloadPayload {
|
export interface AddDownloadPayload {
|
||||||
|
|
@ -52,8 +50,8 @@ export class DownloadsService {
|
||||||
configurationChanged = new Subject<Record<string, unknown>>();
|
configurationChanged = new Subject<Record<string, unknown>>();
|
||||||
updated = new Subject<void>();
|
updated = new Subject<void>();
|
||||||
private updateRefreshScheduled = false;
|
private updateRefreshScheduled = false;
|
||||||
private readonly foregroundRefreshMs = 3000;
|
private foregroundRefreshMs = 3000;
|
||||||
private readonly backgroundRefreshMs = 30000;
|
private backgroundRefreshMs = 30000;
|
||||||
|
|
||||||
configuration: Record<string, unknown> = {};
|
configuration: Record<string, unknown> = {};
|
||||||
customDirs: Record<string, string[]> = {};
|
customDirs: Record<string, string[]> = {};
|
||||||
|
|
@ -157,6 +155,11 @@ export class DownloadsService {
|
||||||
setTimeout(() => flush(), delay);
|
setTimeout(() => flush(), delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRefreshCadence(focusedMs: number, backgroundMs: number) {
|
||||||
|
this.foregroundRefreshMs = focusedMs;
|
||||||
|
this.backgroundRefreshMs = backgroundMs;
|
||||||
|
}
|
||||||
|
|
||||||
handleHTTPError(error: HttpErrorResponse) {
|
handleHTTPError(error: HttpErrorResponse) {
|
||||||
const msg = error.error instanceof ErrorEvent
|
const msg = error.error instanceof ErrorEvent
|
||||||
? error.error.message
|
? error.error.message
|
||||||
|
|
@ -240,20 +243,20 @@ export class DownloadsService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getHasharrSettings() {
|
getWebhookSettings() {
|
||||||
return this.http.get<HasharrSettings>('hasharr-settings').pipe(
|
return this.http.get<WebhookSettings>('webhook-settings').pipe(
|
||||||
catchError(this.handleHTTPError)
|
catchError(this.handleHTTPError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveHasharrSettings(settings: HasharrSettings) {
|
saveWebhookSettings(settings: WebhookSettings) {
|
||||||
return this.http.post<{ status: string; msg?: string }>('hasharr-settings', settings).pipe(
|
return this.http.post<{ status: string; msg?: string }>('webhook-settings', settings).pipe(
|
||||||
catchError(this.handleHTTPError)
|
catchError(this.handleHTTPError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
testHasharrSettings(settings: Pick<HasharrSettings, 'url' | 'service_id' | 'timeout_sec'>) {
|
testWebhookSettings(settings: Pick<WebhookSettings, 'endpoint' | 'timeout_sec'>) {
|
||||||
return this.http.post<HasharrServiceTestResponse>('hasharr-settings/test', settings).pipe(
|
return this.http.post<WebhookTestResponse>('webhook-settings/test', settings).pipe(
|
||||||
catchError(this.handleHTTPError)
|
catchError(this.handleHTTPError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue