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:
parent
80d32461d8
commit
e49f11c67b
4 changed files with 175 additions and 1 deletions
57
.github/workflows/publish-branch-image.yml
vendored
Normal file
57
.github/workflows/publish-branch-image.yml
vendored
Normal 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 }}
|
||||
|
||||
28
README.md
28
README.md
|
|
@ -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`.
|
||||
* __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).
|
||||
* __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
|
||||
|
||||
|
|
@ -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)
|
||||
* [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
|
||||
|
||||
In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos:
|
||||
|
|
|
|||
38
app/main.py
38
app/main.py
|
|
@ -64,9 +64,13 @@ 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',
|
||||
}
|
||||
|
||||
_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):
|
||||
for k, v in self._DEFAULTS.items():
|
||||
|
|
@ -114,6 +118,10 @@ class Config:
|
|||
'PUBLIC_HOST_URL',
|
||||
'PUBLIC_HOST_AUDIO_URL',
|
||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
|
||||
'HASHARR_ENABLED',
|
||||
'HASHARR_URL',
|
||||
'HASHARR_SERVICE_ID',
|
||||
'HASHARR_TIMEOUT_SEC',
|
||||
)
|
||||
|
||||
def frontend_safe(self) -> dict:
|
||||
|
|
@ -550,6 +558,34 @@ 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):
|
||||
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
|
||||
async def connect(sid, environ):
|
||||
log.info(f"Client connected: {sid}")
|
||||
|
|
|
|||
53
app/ytdl.py
53
app/ytdl.py
|
|
@ -14,6 +14,8 @@ import re
|
|||
import types
|
||||
import dbm
|
||||
import subprocess
|
||||
import json
|
||||
from urllib import request as urlrequest
|
||||
from typing import Any
|
||||
from functools import lru_cache
|
||||
|
||||
|
|
@ -694,6 +696,7 @@ class DownloadQueue:
|
|||
else:
|
||||
self.done.put(download)
|
||||
asyncio.create_task(self.notifier.completed(download.info))
|
||||
asyncio.create_task(self._notify_hasharr(download.info))
|
||||
try:
|
||||
clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
|
||||
except ValueError:
|
||||
|
|
@ -703,6 +706,56 @@ 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):
|
||||
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):
|
||||
await asyncio.sleep(delay_seconds)
|
||||
if self.done.exists(url):
|
||||
|
|
|
|||
Loading…
Reference in a new issue