Merge pull request #2 from Stash-KennyG/generic-webservice

Refactor integration settings to generic webhook model
This commit is contained in:
Stash-KennyG 2026-04-02 15:59:50 -04:00 committed by GitHub
commit 54651fb07e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 934 additions and 792 deletions

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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" />&nbsp; 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" />&nbsp; Download selected</button> (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" />&nbsp; 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" />&nbsp; 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" />&nbsp; {{ 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" />&nbsp; 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" />&nbsp; Clear completed</button> [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" />&nbsp; {{ 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" />&nbsp; 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" />&nbsp; 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" />&nbsp; Download Selected</button> (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; 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" />&nbsp; 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" />&nbsp; 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" />&nbsp; 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" />&nbsp; 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" />&nbsp; Check all now <fa-icon [icon]="faRedoAlt" />&nbsp; 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" />&nbsp; Check selected <fa-icon [icon]="faRedoAlt" />&nbsp; 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" />&nbsp; Delete selected <fa-icon [icon]="faTrashAlt" />&nbsp; 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>

View file

@ -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)

View file

@ -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

View file

@ -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)
); );
} }