Add native hasharr callback integration for completed downloads.

Introduce configurable hasharr integration settings, invoke hasharr service endpoint on completion, and add branch image publish workflow for integration validation.

Made-with: Cursor
This commit is contained in:
KennyG 2026-04-01 12:06:43 -04:00
parent 80d32461d8
commit e49f11c67b
4 changed files with 175 additions and 1 deletions

View file

@ -0,0 +1,57 @@
name: publish-branch-image
on:
workflow_dispatch:
inputs:
image_tag:
description: "Container tag to publish"
required: true
default: "hash-mt-integration"
permissions:
contents: read
packages: write
jobs:
publish-ghcr-branch:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Prepare image name
id: image
run: echo "name=$(echo "ghcr.io/${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Compute publish tag
id: tag
run: |
TAG="${{ github.event.inputs.image_tag }}"
TAG="$(echo "$TAG" | tr '[:upper:]' '[:lower:]')"
echo "value=$TAG" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish image
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
build-args: |
VERSION=${{ github.run_number }}
tags: |
${{ steps.image.outputs.name }}:${{ steps.tag.outputs.value }}
${{ steps.image.outputs.name }}:${{ github.sha }}

View file

@ -37,6 +37,10 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`. * __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit). * __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
* __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`.
* __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`.
### 📁 Storage & Directories ### 📁 Storage & Directories
@ -86,6 +90,30 @@ 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
MeTube can call hasharr after download completion to perform pHash matching and action policy.
### Runtime API endpoints
- `GET /hasharr-settings` -> current effective integration settings
- `POST /hasharr-settings` -> update runtime settings
Example:
```json
{
"enabled": true,
"url": "http://hasharr:9995",
"service_id": 1,
"timeout_sec": 20
}
```
When enabled, MeTube posts each completed output file to:
`POST {HASHARR_URL}/api/hash-service/{HASHARR_SERVICE_ID}`
## 🍪 Using browser cookies ## 🍪 Using browser cookies
In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos: In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos:

View file

@ -64,9 +64,13 @@ class Config:
'MAX_CONCURRENT_DOWNLOADS': '3', 'MAX_CONCURRENT_DOWNLOADS': '3',
'LOGLEVEL': 'INFO', 'LOGLEVEL': 'INFO',
'ENABLE_ACCESSLOG': 'false', 'ENABLE_ACCESSLOG': 'false',
'HASHARR_ENABLED': 'false',
'HASHARR_URL': 'http://hasharr:9995',
'HASHARR_SERVICE_ID': '1',
'HASHARR_TIMEOUT_SEC': '20',
} }
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG') _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'HASHARR_ENABLED')
def __init__(self): def __init__(self):
for k, v in self._DEFAULTS.items(): for k, v in self._DEFAULTS.items():
@ -114,6 +118,10 @@ 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',
'HASHARR_URL',
'HASHARR_SERVICE_ID',
'HASHARR_TIMEOUT_SEC',
) )
def frontend_safe(self) -> dict: def frontend_safe(self) -> dict:
@ -550,6 +558,34 @@ 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')
async def get_hasharr_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),
}), content_type='application/json')
@routes.post(config.URL_PREFIX + 'hasharr-settings')
async def set_hasharr_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')
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
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
@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

@ -14,6 +14,8 @@ import re
import types import types
import dbm import dbm
import subprocess import subprocess
import json
from urllib import request as urlrequest
from typing import Any from typing import Any
from functools import lru_cache from functools import lru_cache
@ -694,6 +696,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))
try: try:
clear_after = int(self.config.CLEAR_COMPLETED_AFTER) clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
except ValueError: except ValueError:
@ -703,6 +706,56 @@ 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):
if not getattr(self.config, 'HASHARR_ENABLED', False):
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)
for cf in getattr(info, 'chapter_files', []) or []:
if isinstance(cf, dict) and cf.get('filename'):
files.append(cf['filename'])
for sf in getattr(info, 'subtitle_files', []) or []:
if isinstance(sf, dict) and sf.get('filename'):
files.append(sf['filename'])
dedup = []
seen = set()
for f in files:
if f and f not in seen:
seen.add(f)
dedup.append(f)
if not dedup:
return
def _post_one(rel_name):
download_type = getattr(info, 'download_type', 'video')
base_dir = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR
full_path = os.path.join(base_dir, rel_name)
payload = {
"filePath": full_path,
"source": "metube",
"jobId": str(getattr(info, 'id', '')),
}
data = json.dumps(payload).encode('utf-8')
req = urlrequest.Request(
f"{base_url}/api/hash-service/{service_id}",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urlrequest.urlopen(req, timeout=timeout_sec) as resp:
return resp.status
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}")
except Exception as exc:
log.warning(f"hasharr 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)
if self.done.exists(url): if self.done.exists(url):