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)

File diff suppressed because it is too large Load diff

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