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
This commit is contained in:
KennyG 2026-04-01 14:40:12 -04:00
parent c6b86690ae
commit 36740b00d9
4 changed files with 136 additions and 1 deletions

View file

@ -14,6 +14,8 @@ import logging
import json import json
import pathlib import pathlib
import re import re
from urllib import request as urlrequest
from urllib import error as urlerror
from watchfiles import DefaultFilter, Change, awatch from watchfiles import DefaultFilter, Change, awatch
from ytdl import DownloadQueueNotifier, DownloadQueue, Download from ytdl import DownloadQueueNotifier, DownloadQueue, Download
@ -586,6 +588,66 @@ async def set_hasharr_settings(request):
config.HASHARR_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')
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 @sio.event
async def connect(sid, environ): async def connect(sid, environ):
log.info(f"Client connected: {sid}") log.info(f"Client connected: {sid}")

View file

@ -477,10 +477,30 @@
<button type="button" class="btn btn-secondary" (click)="saveHasharrSettings()"> <button type="button" class="btn btn-secondary" (click)="saveHasharrSettings()">
Save Hasharr Settings Save Hasharr Settings
</button> </button>
<button type="button" class="btn btn-outline-secondary ms-2" (click)="testHasharrService()">
Test Service
</button>
@if (hasharrSettingsStatus) { @if (hasharrSettingsStatus) {
<span class="ms-3 text-muted">{{ hasharrSettingsStatus }}</span> <span class="ms-3 text-muted">{{ hasharrSettingsStatus }}</span>
} }
</div> </div>
@if (hasharrTestStatus) {
<div class="col-12">
<small class="text-muted">{{ hasharrTestStatus }}</small>
</div>
}
@if (hasharrTestProfile) {
<div class="col-12">
<div class="small text-muted">
<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 class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">

View file

@ -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 { 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 { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service'; 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 { Themes } from './theme';
import { import {
Download, Download,
@ -101,6 +101,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
hasharrServiceID = 1; hasharrServiceID = 1;
hasharrTimeoutSec = 20; hasharrTimeoutSec = 20;
hasharrSettingsStatus = ''; hasharrSettingsStatus = '';
hasharrTestStatus = '';
hasharrTestProfile: Record<string, unknown> | null = null;
sortAscending = false; sortAscending = false;
expandedErrors: Set<string> = new Set<string>(); expandedErrors: Set<string> = new Set<string>();
cachedSortedDone: [string, Download][] = []; 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() { ngAfterViewInit() {
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.queueMasterCheckbox()?.selectionChanged(); this.queueMasterCheckbox()?.selectionChanged();

View file

@ -13,6 +13,14 @@ export interface HasharrSettings {
timeout_sec: number; timeout_sec: number;
} }
export interface HasharrServiceTestResponse {
status: string;
reachable: boolean;
valid_service_id: boolean;
message?: string;
profile?: Record<string, unknown>;
}
export interface AddDownloadPayload { export interface AddDownloadPayload {
url: string; url: string;
downloadType: string; downloadType: string;
@ -243,4 +251,10 @@ export class DownloadsService {
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
} }
testHasharrSettings(settings: Pick<HasharrSettings, 'url' | 'service_id' | 'timeout_sec'>) {
return this.http.post<HasharrServiceTestResponse>('hasharr-settings/test', settings).pipe(
catchError(this.handleHTTPError)
);
}
} }