Refactor MeTube integration to generic webhook settings.

Replace hasharr-specific settings/routes with webhook endpoint+timeout configuration, post fixed JSON payloads directly to endpoint, add generic JSON test response rendering, and add a gear modal for cookie-backed focused/background refresh cadence.

Made-with: Cursor
This commit is contained in:
KennyG 2026-04-01 23:33:33 -04:00
parent 4ddc47884e
commit 7f7d9909f6
7 changed files with 270 additions and 208 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:9995/api/hash-service/1`). Defaults to `http://localhost:9995/api/hash-service/1`.
* __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,37 @@ 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
Example: Example:
```json ```json
{ {
"enabled": true, "enabled": true,
"url": "http://hasharr:9995", "endpoint": "http://localhost:9995/api/hash-service/1",
"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

@ -70,13 +70,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:9995/api/hash-service/1',
'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 +123,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',
) )
@ -693,92 +691,86 @@ 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
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 +903,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,6 +49,12 @@
</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"
@ -476,38 +482,27 @@
<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 col-md-3 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="col-12 col-md-7">
<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" class="form-control"
placeholder="http://hasharr:9995" placeholder="http://localhost:9995/api/hash-service/1"
name="hasharrUrl" name="webhookEndpoint"
[(ngModel)]="hasharrUrl" [(ngModel)]="webhookEndpoint"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || subscribeInProgress || downloads.loading">
</div>
</div>
<div class="col-6 col-md-2">
<div class="input-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> </div>
<div class="col-6 col-md-2"> <div class="col-6 col-md-2">
@ -516,41 +511,34 @@
<input type="number" <input type="number"
min="1" min="1"
class="form-control" class="form-control"
name="hasharrTimeoutSec" name="webhookTimeoutSec"
[(ngModel)]="hasharrTimeoutSec" [(ngModel)]="webhookTimeoutSec"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || subscribeInProgress || 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 Settings
</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"> <pre class="webhook-response-block">{{ webhookTestResponsePretty }}</pre>
<div><strong>Profile Name:</strong> {{ hasharrTestProfile['name'] }}</div>
<div><strong>Enabled:</strong> {{ hasharrTestProfile['enabled'] }}</div>
<div><strong>Apply Actions:</strong> {{ hasharrTestProfile['applyActions'] }}</div>
<div><strong>Stash Index:</strong> {{ hasharrTestProfile['stashIndex'] }}</div>
<div><strong>Max Time Delta:</strong> {{ hasharrTestProfile['maxTimeDelta'] }}</div>
<div><strong>Max Distance:</strong> {{ hasharrTestProfile['maxDistance'] }}</div>
</div>
</div> </div>
} }
</div> </div>
@ -666,6 +654,45 @@
</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">

View file

@ -197,25 +197,27 @@ 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-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

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,15 @@ 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:9995/api/hash-service/1';
hasharrServiceID = 1; webhookTimeoutSec = 20;
hasharrTimeoutSec = 20; webhookSettingsStatus = '';
hasharrSettingsStatus = ''; webhookTestStatus = '';
hasharrTestStatus = ''; webhookTestResponsePretty = '';
hasharrTestProfile: Record<string, unknown> | null = null; 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 +131,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 +180,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 +268,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 +312,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,89 +320,78 @@ 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:9995/api/hash-service/1');
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.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) { this.webhookTestStatus = result.message || (result.status === 'ok' ? 'Webhook test succeeded.' : 'Webhook test completed with errors.');
this.hasharrTestStatus = 'Hasharr reachable. Service ID is valid.'; this.webhookTestResponsePretty = JSON.stringify(result.response ?? result, null, 2);
this.hasharrTestProfile = result.profile || null;
} else if (result.reachable) {
this.hasharrTestStatus = result.message || 'Hasharr reachable, but service ID is not valid.';
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.webhookTestResponsePretty = '';
this.cdr.markForCheck(); this.cdr.markForCheck();
}, },
}); });
@ -414,6 +417,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)
); );
} }