Merge pull request #2 from Stash-KennyG/generic-webservice
Refactor integration settings to generic webhook model
This commit is contained in:
commit
54651fb07e
7 changed files with 934 additions and 792 deletions
33
README.md
33
README.md
|
|
@ -40,10 +40,9 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||
* __SUBSCRIPTION_SCAN_PLAYLIST_END__: Maximum playlist/channel entries to fetch per subscription check (newest-first). Defaults to `50`.
|
||||
* __SUBSCRIPTION_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).
|
||||
* __HASHARR_ENABLED__: If `true`, call hasharr when a download completes. Defaults to `false`.
|
||||
* __HASHARR_URL__: Base URL for hasharr service (for example `http://hasharr:9995`). Defaults to `http://hasharr:9995`.
|
||||
* __HASHARR_SERVICE_ID__: Hash service profile ID to call at `POST /api/hash-service/{id}`. Defaults to `1`.
|
||||
* __HASHARR_TIMEOUT_SEC__: Timeout (seconds) for hasharr callback requests. Defaults to `20`.
|
||||
* __WEBSERVICE_ENABLED__: If `true`, call a webhook endpoint when a download completes. Defaults to `false`.
|
||||
* __WEBSERVICE_ENDPOINT__: Full webhook URL to call (for example `"http://localhost:9876/api/web-service/"`). Defaults to `"http://localhost:9876/api/web-service/"`.
|
||||
* __WEBSERVICE_TIMEOUT_SEC__: Timeout (seconds) for webhook requests. Defaults to `20`.
|
||||
|
||||
### 📁 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)
|
||||
* [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
|
||||
|
||||
- `GET /hasharr-settings` -> current effective integration settings
|
||||
- `POST /hasharr-settings` -> update runtime settings
|
||||
- `GET /webhook-settings` -> current effective integration 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:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"url": "http://hasharr:9995",
|
||||
"service_id": 1,
|
||||
"endpoint": "http://localhost:9876/api/web-service/",
|
||||
"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
|
||||
|
||||
|
|
|
|||
175
app/main.py
175
app/main.py
|
|
@ -20,6 +20,7 @@ from watchfiles import DefaultFilter, Change, awatch
|
|||
|
||||
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
||||
from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo
|
||||
from state_store import AtomicJsonStore
|
||||
from yt_dlp.version import __version__ as yt_dlp_version
|
||||
|
||||
log = logging.getLogger('main')
|
||||
|
|
@ -70,13 +71,12 @@ class Config:
|
|||
'MAX_CONCURRENT_DOWNLOADS': '3',
|
||||
'LOGLEVEL': 'INFO',
|
||||
'ENABLE_ACCESSLOG': 'false',
|
||||
'HASHARR_ENABLED': 'false',
|
||||
'HASHARR_URL': 'http://hasharr:9995',
|
||||
'HASHARR_SERVICE_ID': '1',
|
||||
'HASHARR_TIMEOUT_SEC': '20',
|
||||
'WEBSERVICE_ENABLED': 'false',
|
||||
'WEBSERVICE_ENDPOINT': 'http://localhost:9876/api/web-service/',
|
||||
'WEBSERVICE_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):
|
||||
for k, v in self._DEFAULTS.items():
|
||||
|
|
@ -124,10 +124,9 @@ class Config:
|
|||
'PUBLIC_HOST_URL',
|
||||
'PUBLIC_HOST_AUDIO_URL',
|
||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
|
||||
'HASHARR_ENABLED',
|
||||
'HASHARR_URL',
|
||||
'HASHARR_SERVICE_ID',
|
||||
'HASHARR_TIMEOUT_SEC',
|
||||
'WEBSERVICE_ENABLED',
|
||||
'WEBSERVICE_ENDPOINT',
|
||||
'WEBSERVICE_TIMEOUT_SEC',
|
||||
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL',
|
||||
)
|
||||
|
||||
|
|
@ -171,6 +170,43 @@ class Config:
|
|||
return (True, '')
|
||||
|
||||
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).
|
||||
# This re-applies the log level after Config loads, in case LOGLEVEL was
|
||||
# overridden by config file settings or differs from the environment variable.
|
||||
|
|
@ -693,92 +729,91 @@ async def history(request):
|
|||
log.info("Sending download history")
|
||||
return web.Response(text=serializer.encode(history))
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'hasharr-settings')
|
||||
async def get_hasharr_settings(request):
|
||||
@routes.get(config.URL_PREFIX + 'webhook-settings')
|
||||
async def get_webhook_settings(request):
|
||||
return web.Response(text=serializer.encode({
|
||||
'enabled': bool(config.HASHARR_ENABLED),
|
||||
'url': str(config.HASHARR_URL),
|
||||
'service_id': int(config.HASHARR_SERVICE_ID),
|
||||
'timeout_sec': int(config.HASHARR_TIMEOUT_SEC),
|
||||
'enabled': bool(config.WEBSERVICE_ENABLED),
|
||||
'endpoint': str(config.WEBSERVICE_ENDPOINT),
|
||||
'timeout_sec': int(config.WEBSERVICE_TIMEOUT_SEC),
|
||||
}), content_type='application/json')
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'hasharr-settings')
|
||||
async def set_hasharr_settings(request):
|
||||
@routes.post(config.URL_PREFIX + 'webhook-settings')
|
||||
async def set_webhook_settings(request):
|
||||
post = await _read_json_request(request)
|
||||
enabled = bool(post.get('enabled', config.HASHARR_ENABLED))
|
||||
url = str(post.get('url', config.HASHARR_URL)).strip()
|
||||
service_id = int(post.get('service_id', config.HASHARR_SERVICE_ID))
|
||||
timeout_sec = int(post.get('timeout_sec', config.HASHARR_TIMEOUT_SEC))
|
||||
if not url:
|
||||
raise web.HTTPBadRequest(reason='url is required')
|
||||
if service_id <= 0:
|
||||
raise web.HTTPBadRequest(reason='service_id must be > 0')
|
||||
enabled = bool(post.get('enabled', config.WEBSERVICE_ENABLED))
|
||||
endpoint = str(post.get('endpoint', config.WEBSERVICE_ENDPOINT)).strip()
|
||||
timeout_sec = int(post.get('timeout_sec', config.WEBSERVICE_TIMEOUT_SEC))
|
||||
if not endpoint:
|
||||
raise web.HTTPBadRequest(reason='endpoint is required')
|
||||
if timeout_sec <= 0:
|
||||
raise web.HTTPBadRequest(reason='timeout_sec must be > 0')
|
||||
config.HASHARR_ENABLED = enabled
|
||||
config.HASHARR_URL = url
|
||||
config.HASHARR_SERVICE_ID = service_id
|
||||
config.HASHARR_TIMEOUT_SEC = timeout_sec
|
||||
config.WEBSERVICE_ENABLED = enabled
|
||||
config.WEBSERVICE_ENDPOINT = endpoint
|
||||
config.WEBSERVICE_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')
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'hasharr-settings/test')
|
||||
async def test_hasharr_settings(request):
|
||||
@routes.post(config.URL_PREFIX + 'webhook-settings/test')
|
||||
async def test_webhook_settings(request):
|
||||
post = await _read_json_request(request)
|
||||
url = str(post.get('url', config.HASHARR_URL)).strip()
|
||||
service_id = int(post.get('service_id', config.HASHARR_SERVICE_ID))
|
||||
timeout_sec = int(post.get('timeout_sec', config.HASHARR_TIMEOUT_SEC))
|
||||
if not url:
|
||||
raise web.HTTPBadRequest(reason='url is required')
|
||||
if service_id <= 0:
|
||||
raise web.HTTPBadRequest(reason='service_id must be > 0')
|
||||
endpoint = str(post.get('endpoint', config.WEBSERVICE_ENDPOINT)).strip()
|
||||
timeout_sec = int(post.get('timeout_sec', config.WEBSERVICE_TIMEOUT_SEC))
|
||||
if not endpoint:
|
||||
raise web.HTTPBadRequest(reason='endpoint is required')
|
||||
if timeout_sec <= 0:
|
||||
raise web.HTTPBadRequest(reason='timeout_sec must be > 0')
|
||||
|
||||
base_url = url.rstrip('/')
|
||||
profile_url = f"{base_url}/v1/hash-service-profiles/{service_id}"
|
||||
payload = {
|
||||
'filePath': '',
|
||||
'source': 'metube',
|
||||
'jobId': '',
|
||||
}
|
||||
body = json.dumps(payload).encode('utf-8')
|
||||
|
||||
def _fetch_profile():
|
||||
req = urlrequest.Request(profile_url, method='GET')
|
||||
def _call_endpoint():
|
||||
req = urlrequest.Request(endpoint, data=body, headers={'Content-Type': 'application/json'}, method='POST')
|
||||
with urlrequest.urlopen(req, timeout=timeout_sec) as resp:
|
||||
body = resp.read().decode('utf-8', errors='replace')
|
||||
return resp.status, body
|
||||
raw = resp.read().decode('utf-8', errors='replace')
|
||||
return resp.status, raw
|
||||
|
||||
try:
|
||||
status, body = await asyncio.get_running_loop().run_in_executor(None, _fetch_profile)
|
||||
if status != 200:
|
||||
return web.json_response({
|
||||
'status': 'error',
|
||||
'reachable': True,
|
||||
'valid_service_id': False,
|
||||
'message': f'hasharr responded with status {status}',
|
||||
}, status=502)
|
||||
profile = json.loads(body)
|
||||
status, raw = await asyncio.get_running_loop().run_in_executor(None, _call_endpoint)
|
||||
if not raw:
|
||||
response_obj = {}
|
||||
else:
|
||||
try:
|
||||
response_obj = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
response_obj = {'raw': raw}
|
||||
return web.json_response({
|
||||
'status': 'ok',
|
||||
'reachable': True,
|
||||
'valid_service_id': True,
|
||||
'profile': profile,
|
||||
'message': f'Endpoint returned HTTP {status}.',
|
||||
'status_code': status,
|
||||
'response': response_obj,
|
||||
})
|
||||
except urlerror.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return web.json_response({
|
||||
'status': 'ok',
|
||||
'reachable': True,
|
||||
'valid_service_id': False,
|
||||
'message': f'Service ID {service_id} was not found in hasharr.',
|
||||
})
|
||||
raw = exc.read().decode('utf-8', errors='replace')
|
||||
if not raw:
|
||||
response_obj = {}
|
||||
else:
|
||||
try:
|
||||
response_obj = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
response_obj = {'raw': raw}
|
||||
return web.json_response({
|
||||
'status': 'error',
|
||||
'reachable': True,
|
||||
'valid_service_id': False,
|
||||
'message': f'hasharr returned HTTP {exc.code}',
|
||||
'message': f'Endpoint returned HTTP {exc.code}.',
|
||||
'status_code': int(exc.code),
|
||||
'response': response_obj,
|
||||
}, status=502)
|
||||
except Exception as exc:
|
||||
return web.json_response({
|
||||
'status': 'error',
|
||||
'reachable': False,
|
||||
'valid_service_id': False,
|
||||
'message': f'Could not reach hasharr: {exc}',
|
||||
'message': f'Could not reach endpoint: {exc}',
|
||||
}, status=502)
|
||||
|
||||
@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 + 'upload-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):
|
||||
if 'Origin' in request.headers:
|
||||
|
|
|
|||
19
app/ytdl.py
19
app/ytdl.py
|
|
@ -766,7 +766,7 @@ class DownloadQueue:
|
|||
else:
|
||||
self.done.put(download)
|
||||
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:
|
||||
clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
|
||||
except ValueError:
|
||||
|
|
@ -776,12 +776,13 @@ class DownloadQueue:
|
|||
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)
|
||||
|
||||
async def _notify_hasharr(self, info):
|
||||
if not getattr(self.config, 'HASHARR_ENABLED', False):
|
||||
async def _notify_webhook(self, info):
|
||||
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
|
||||
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 = []
|
||||
if getattr(info, 'filename', None):
|
||||
files.append(info.filename)
|
||||
|
|
@ -811,7 +812,7 @@ class DownloadQueue:
|
|||
}
|
||||
data = json.dumps(payload).encode('utf-8')
|
||||
req = urlrequest.Request(
|
||||
f"{base_url}/api/hash-service/{service_id}",
|
||||
endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
|
|
@ -822,9 +823,9 @@ class DownloadQueue:
|
|||
for rel_name in dedup:
|
||||
try:
|
||||
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:
|
||||
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):
|
||||
await asyncio.sleep(delay_seconds)
|
||||
|
|
|
|||
1222
ui/src/app/app.html
1222
ui/src/app/app.html
File diff suppressed because it is too large
Load diff
|
|
@ -197,25 +197,86 @@ main
|
|||
&.active
|
||||
color: var(--bs-success-text-emphasis)
|
||||
|
||||
.hasharr-integration
|
||||
.webhook-integration
|
||||
.input-group
|
||||
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
|
||||
flex-wrap: wrap
|
||||
gap: 0.5rem
|
||||
|
||||
.hasharr-status-block
|
||||
.webhook-status-block
|
||||
min-height: 2.5rem
|
||||
display: flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
text-align: left
|
||||
|
||||
.hasharr-profile-result
|
||||
.webhook-response-block
|
||||
margin-top: 0.25rem
|
||||
padding: 0.5rem 0.75rem
|
||||
border: 1px solid var(--bs-border-color)
|
||||
border-radius: 0.375rem
|
||||
background: var(--bs-tertiary-bg)
|
||||
white-space: pre-wrap
|
||||
word-break: break-word
|
||||
|
||||
.webhook-response-view-toggle
|
||||
display: inline-flex
|
||||
gap: 0.35rem
|
||||
margin-top: 0.25rem
|
||||
|
||||
.webhook-response-pretty
|
||||
table
|
||||
margin: 0
|
||||
|
||||
th,
|
||||
td
|
||||
padding: 0
|
||||
border: 0
|
||||
vertical-align: top
|
||||
line-height: 1.5
|
||||
|
||||
th
|
||||
width: 170px
|
||||
font-weight: 700
|
||||
color: var(--bs-emphasis-color)
|
||||
|
||||
td
|
||||
font-weight: 400
|
||||
color: var(--bs-emphasis-color)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
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 { 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 { SubscriptionRow } from './interfaces/subscription';
|
||||
import { Themes } from './theme';
|
||||
|
|
@ -107,13 +107,18 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
ytDlpVersion: string | null = null;
|
||||
metubeVersion: string | null = null;
|
||||
isAdvancedOpen = false;
|
||||
hasharrEnabled = false;
|
||||
hasharrUrl = 'http://hasharr:9995';
|
||||
hasharrServiceID = 1;
|
||||
hasharrTimeoutSec = 20;
|
||||
hasharrSettingsStatus = '';
|
||||
hasharrTestStatus = '';
|
||||
hasharrTestProfile: Record<string, unknown> | null = null;
|
||||
webhookEnabled = false;
|
||||
webhookEndpoint = 'http://localhost:9876/api/web-service/';
|
||||
webhookTimeoutSec = 20;
|
||||
webhookSettingsStatus = '';
|
||||
webhookTestStatus = '';
|
||||
webhookTestViewMode: 'pretty' | 'raw' = 'pretty';
|
||||
webhookTestResponseEntries: Array<{ key: string; value: string }> = [];
|
||||
webhookTestResponsePretty = '';
|
||||
webhookTestResponseRaw = '';
|
||||
settingsModalOpen = false;
|
||||
focusedRefreshMs = 3000;
|
||||
backgroundRefreshMs = 30000;
|
||||
sortAscending = false;
|
||||
expandedErrors: Set<string> = new Set<string>();
|
||||
cachedSortedDone: [string, Download][] = [];
|
||||
|
|
@ -129,6 +134,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
}> = {};
|
||||
private readonly selectionCookiePrefix = 'metube_selection_';
|
||||
private readonly settingsCookieExpiryDays = 3650;
|
||||
private readonly focusedRefreshCookie = 'metube_focused_refresh_ms';
|
||||
private readonly backgroundRefreshCookie = 'metube_background_refresh_ms';
|
||||
private lastFocusedElement: HTMLElement | null = null;
|
||||
private colorSchemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
private onColorSchemeChanged = () => {
|
||||
|
|
@ -176,6 +183,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
faUpload = faUpload;
|
||||
faPause = faPause;
|
||||
faPlay = faPlay;
|
||||
faGear = faGear;
|
||||
subtitleLanguages = [
|
||||
{ id: 'en', text: 'English' },
|
||||
{ id: 'ar', text: 'Arabic' },
|
||||
|
|
@ -263,6 +271,15 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
if (!Number.isNaN(ci) && ci >= 1) {
|
||||
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);
|
||||
|
||||
// Subscribe to download updates
|
||||
|
|
@ -298,7 +315,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
this.cdr.markForCheck();
|
||||
});
|
||||
this.getConfiguration();
|
||||
this.loadHasharrSettings();
|
||||
this.loadWebhookSettings();
|
||||
this.getYtdlOptionsUpdateTime();
|
||||
this.customDirs$ = this.getMatchingCustomDir();
|
||||
this.setTheme(this.activeTheme!);
|
||||
|
|
@ -306,94 +323,117 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged);
|
||||
}
|
||||
|
||||
loadHasharrSettings() {
|
||||
this.downloads.getHasharrSettings().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
loadWebhookSettings() {
|
||||
this.downloads.getWebhookSettings().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (data) => {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
const settings = data as HasharrSettings;
|
||||
this.hasharrEnabled = !!settings.enabled;
|
||||
this.hasharrUrl = String(settings.url || 'http://hasharr:9995');
|
||||
this.hasharrServiceID = Math.max(1, Number(settings.service_id || 1));
|
||||
this.hasharrTimeoutSec = Math.max(1, Number(settings.timeout_sec || 20));
|
||||
this.hasharrSettingsStatus = '';
|
||||
const settings = data as WebhookSettings;
|
||||
this.webhookEnabled = !!settings.enabled;
|
||||
this.webhookEndpoint = String(settings.endpoint || 'http://localhost:9876/api/web-service/');
|
||||
this.webhookTimeoutSec = Math.max(1, Number(settings.timeout_sec || 20));
|
||||
this.webhookSettingsStatus = '';
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.hasharrSettingsStatus = 'Unable to load hasharr settings.';
|
||||
this.webhookSettingsStatus = 'Unable to load webhook settings.';
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
saveHasharrSettings() {
|
||||
const payload: HasharrSettings = {
|
||||
enabled: !!this.hasharrEnabled,
|
||||
url: String(this.hasharrUrl || '').trim(),
|
||||
service_id: Math.max(1, Number(this.hasharrServiceID || 1)),
|
||||
timeout_sec: Math.max(1, Number(this.hasharrTimeoutSec || 20)),
|
||||
saveWebhookSettings() {
|
||||
const payload: WebhookSettings = {
|
||||
enabled: !!this.webhookEnabled,
|
||||
endpoint: String(this.webhookEndpoint || '').trim(),
|
||||
timeout_sec: Math.max(1, Number(this.webhookTimeoutSec || 20)),
|
||||
};
|
||||
if (!payload.url) {
|
||||
this.hasharrSettingsStatus = 'Hasharr URL is required.';
|
||||
if (!payload.endpoint) {
|
||||
this.webhookSettingsStatus = 'Endpoint is required.';
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
this.downloads.saveHasharrSettings(payload).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
this.downloads.saveWebhookSettings(payload).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (out) => {
|
||||
if (out && typeof out === 'object' && 'status' in out && out.status === 'ok') {
|
||||
this.hasharrSettingsStatus = 'Hasharr settings saved.';
|
||||
this.webhookSettingsStatus = 'Webhook settings saved.';
|
||||
} else {
|
||||
this.hasharrSettingsStatus = 'Failed to save hasharr settings.';
|
||||
this.webhookSettingsStatus = 'Failed to save webhook settings.';
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.hasharrSettingsStatus = 'Failed to save hasharr settings.';
|
||||
this.webhookSettingsStatus = 'Failed to save webhook settings.';
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
testHasharrService() {
|
||||
this.hasharrTestStatus = 'Testing hasharr service...';
|
||||
this.hasharrTestProfile = null;
|
||||
testWebhookService() {
|
||||
this.webhookTestStatus = 'Testing webhook endpoint...';
|
||||
this.webhookTestViewMode = 'pretty';
|
||||
this.webhookTestResponseEntries = [];
|
||||
this.webhookTestResponseRaw = '';
|
||||
this.webhookTestResponsePretty = '';
|
||||
this.cdr.markForCheck();
|
||||
this.downloads.testHasharrSettings({
|
||||
url: String(this.hasharrUrl || '').trim(),
|
||||
service_id: Math.max(1, Number(this.hasharrServiceID || 1)),
|
||||
timeout_sec: Math.max(1, Number(this.hasharrTimeoutSec || 20)),
|
||||
this.downloads.testWebhookSettings({
|
||||
endpoint: String(this.webhookEndpoint || '').trim(),
|
||||
timeout_sec: Math.max(1, Number(this.webhookTimeoutSec || 20)),
|
||||
}).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (resp) => {
|
||||
if (!resp || typeof resp !== 'object' || ('status' in resp && resp.status === 'error')) {
|
||||
this.hasharrTestStatus = 'Hasharr test failed.';
|
||||
this.hasharrTestProfile = null;
|
||||
if (!resp || typeof resp !== 'object') {
|
||||
this.webhookTestStatus = 'Webhook test failed.';
|
||||
this.webhookTestResponsePretty = '';
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
const result = resp as HasharrServiceTestResponse;
|
||||
if (result.valid_service_id) {
|
||||
this.hasharrTestStatus = 'Hasharr reachable. Service ID is valid.';
|
||||
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;
|
||||
}
|
||||
const result = resp as WebhookTestResponse;
|
||||
const display = result.response ?? result;
|
||||
this.webhookTestStatus = result.message || (result.status === 'ok' ? 'Webhook test succeeded.' : 'Webhook test completed with errors.');
|
||||
this.webhookTestResponseRaw = JSON.stringify(display, null, 2);
|
||||
this.webhookTestResponsePretty = this.webhookTestResponseRaw;
|
||||
this.webhookTestResponseEntries = this.toWebhookResponseEntries(display);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.hasharrTestStatus = 'Hasharr test failed.';
|
||||
this.hasharrTestProfile = null;
|
||||
this.webhookTestStatus = 'Webhook test failed.';
|
||||
this.webhookTestResponseEntries = [];
|
||||
this.webhookTestResponseRaw = '';
|
||||
this.webhookTestResponsePretty = '';
|
||||
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() {
|
||||
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.queueMasterCheckbox()?.selectionChanged();
|
||||
|
|
@ -414,6 +454,31 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
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
|
||||
// https://github.com/angular/angular/issues/31420
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,17 @@ import { MeTubeSocket } from './metube-socket.service';
|
|||
import { Download, Status, State } from '../interfaces';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
export interface HasharrSettings {
|
||||
export interface WebhookSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
service_id: number;
|
||||
endpoint: string;
|
||||
timeout_sec: number;
|
||||
}
|
||||
|
||||
export interface HasharrServiceTestResponse {
|
||||
export interface WebhookTestResponse {
|
||||
status: string;
|
||||
reachable: boolean;
|
||||
valid_service_id: boolean;
|
||||
message?: string;
|
||||
profile?: Record<string, unknown>;
|
||||
response?: unknown;
|
||||
status_code?: number;
|
||||
}
|
||||
|
||||
export interface AddDownloadPayload {
|
||||
|
|
@ -52,8 +50,8 @@ export class DownloadsService {
|
|||
configurationChanged = new Subject<Record<string, unknown>>();
|
||||
updated = new Subject<void>();
|
||||
private updateRefreshScheduled = false;
|
||||
private readonly foregroundRefreshMs = 3000;
|
||||
private readonly backgroundRefreshMs = 30000;
|
||||
private foregroundRefreshMs = 3000;
|
||||
private backgroundRefreshMs = 30000;
|
||||
|
||||
configuration: Record<string, unknown> = {};
|
||||
customDirs: Record<string, string[]> = {};
|
||||
|
|
@ -157,6 +155,11 @@ export class DownloadsService {
|
|||
setTimeout(() => flush(), delay);
|
||||
}
|
||||
|
||||
setRefreshCadence(focusedMs: number, backgroundMs: number) {
|
||||
this.foregroundRefreshMs = focusedMs;
|
||||
this.backgroundRefreshMs = backgroundMs;
|
||||
}
|
||||
|
||||
handleHTTPError(error: HttpErrorResponse) {
|
||||
const msg = error.error instanceof ErrorEvent
|
||||
? error.error.message
|
||||
|
|
@ -240,20 +243,20 @@ export class DownloadsService {
|
|||
);
|
||||
}
|
||||
|
||||
getHasharrSettings() {
|
||||
return this.http.get<HasharrSettings>('hasharr-settings').pipe(
|
||||
getWebhookSettings() {
|
||||
return this.http.get<WebhookSettings>('webhook-settings').pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
saveHasharrSettings(settings: HasharrSettings) {
|
||||
return this.http.post<{ status: string; msg?: string }>('hasharr-settings', settings).pipe(
|
||||
saveWebhookSettings(settings: WebhookSettings) {
|
||||
return this.http.post<{ status: string; msg?: string }>('webhook-settings', settings).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
testHasharrSettings(settings: Pick<HasharrSettings, 'url' | 'service_id' | 'timeout_sec'>) {
|
||||
return this.http.post<HasharrServiceTestResponse>('hasharr-settings/test', settings).pipe(
|
||||
testWebhookSettings(settings: Pick<WebhookSettings, 'endpoint' | 'timeout_sec'>) {
|
||||
return this.http.post<WebhookTestResponse>('webhook-settings/test', settings).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue