From 36740b00d9d733822fa2ebd605381813529d16c3 Mon Sep 17 00:00:00 2001 From: KennyG Date: Wed, 1 Apr 2026 14:40:12 -0400 Subject: [PATCH] Add hasharr service validation from MeTube settings UI. Expose a test endpoint and UI button that verifies hasharr reachability and service ID validity, then returns and displays profile details from hasharr for quick pairing validation. Made-with: Cursor --- app/main.py | 62 ++++++++++++++++++++++++ ui/src/app/app.html | 20 ++++++++ ui/src/app/app.ts | 41 +++++++++++++++- ui/src/app/services/downloads.service.ts | 14 ++++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 1684301..d235cb3 100644 --- a/app/main.py +++ b/app/main.py @@ -14,6 +14,8 @@ import logging import json import pathlib import re +from urllib import request as urlrequest +from urllib import error as urlerror from watchfiles import DefaultFilter, Change, awatch from ytdl import DownloadQueueNotifier, DownloadQueue, Download @@ -586,6 +588,66 @@ async def set_hasharr_settings(request): config.HASHARR_TIMEOUT_SEC = timeout_sec 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): + 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') + 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}" + + def _fetch_profile(): + req = urlrequest.Request(profile_url, method='GET') + with urlrequest.urlopen(req, timeout=timeout_sec) as resp: + body = resp.read().decode('utf-8', errors='replace') + return resp.status, body + + 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) + return web.json_response({ + 'status': 'ok', + 'reachable': True, + 'valid_service_id': True, + 'profile': profile, + }) + 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.', + }) + return web.json_response({ + 'status': 'error', + 'reachable': True, + 'valid_service_id': False, + 'message': f'hasharr returned HTTP {exc.code}', + }, 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}', + }, status=502) + @sio.event async def connect(sid, environ): log.info(f"Client connected: {sid}") diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 22be60d..87c110e 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -477,10 +477,30 @@ + @if (hasharrSettingsStatus) { {{ hasharrSettingsStatus }} } + @if (hasharrTestStatus) { +
+ {{ hasharrTestStatus }} +
+ } + @if (hasharrTestProfile) { +
+
+
Profile Name: {{ hasharrTestProfile['name'] }}
+
Enabled: {{ hasharrTestProfile['enabled'] }}
+
Apply Actions: {{ hasharrTestProfile['applyActions'] }}
+
Stash Index: {{ hasharrTestProfile['stashIndex'] }}
+
Max Time Delta: {{ hasharrTestProfile['maxTimeDelta'] }}
+
Max Distance: {{ hasharrTestProfile['maxDistance'] }}
+
+
+ }
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index f909c4e..aac0ea6 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -10,7 +10,7 @@ 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 } from '@fortawesome/free-solid-svg-icons'; import { faGithub } from '@fortawesome/free-brands-svg-icons'; import { CookieService } from 'ngx-cookie-service'; -import { AddDownloadPayload, DownloadsService, HasharrSettings } from './services/downloads.service'; +import { AddDownloadPayload, DownloadsService, HasharrSettings, HasharrServiceTestResponse } from './services/downloads.service'; import { Themes } from './theme'; import { Download, @@ -101,6 +101,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy { hasharrServiceID = 1; hasharrTimeoutSec = 20; hasharrSettingsStatus = ''; + hasharrTestStatus = ''; + hasharrTestProfile: Record | null = null; sortAscending = false; expandedErrors: Set = new Set(); cachedSortedDone: [string, Download][] = []; @@ -332,6 +334,43 @@ export class App implements AfterViewInit, OnInit, OnDestroy { }); } + testHasharrService() { + this.hasharrTestStatus = 'Testing hasharr service...'; + this.hasharrTestProfile = null; + 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)), + }).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; + 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; + } + this.cdr.markForCheck(); + }, + error: () => { + this.hasharrTestStatus = 'Hasharr test failed.'; + this.hasharrTestProfile = null; + this.cdr.markForCheck(); + }, + }); + } + ngAfterViewInit() { this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { this.queueMasterCheckbox()?.selectionChanged(); diff --git a/ui/src/app/services/downloads.service.ts b/ui/src/app/services/downloads.service.ts index a2ff8e9..e414921 100644 --- a/ui/src/app/services/downloads.service.ts +++ b/ui/src/app/services/downloads.service.ts @@ -13,6 +13,14 @@ export interface HasharrSettings { timeout_sec: number; } +export interface HasharrServiceTestResponse { + status: string; + reachable: boolean; + valid_service_id: boolean; + message?: string; + profile?: Record; +} + export interface AddDownloadPayload { url: string; downloadType: string; @@ -243,4 +251,10 @@ export class DownloadsService { catchError(this.handleHTTPError) ); } + + testHasharrSettings(settings: Pick) { + return this.http.post('hasharr-settings/test', settings).pipe( + catchError(this.handleHTTPError) + ); + } }