This commit is contained in:
nose
2025-12-01 01:10:16 -08:00
parent 2b93edac10
commit 6b9ed7d4ab
17 changed files with 1644 additions and 470 deletions

View File

@@ -372,6 +372,18 @@ def _handle_search_result(result: Any, args: Sequence[str], config: Dict[str, An
log("Error: No magnet ID in debrid result", file=sys.stderr)
return 1
return _handle_debrid_file(magnet_id, file_title, config, args)
elif storage_name.lower() in {'bandcamp', 'youtube'}:
# Handle Bandcamp/YouTube via yt-dlp
url = get_field(result, 'target', None)
if not url:
# Try to find URL in other fields
url = get_field(result, 'url', None)
if not url:
log(f"Error: No URL found for {storage_name} result", file=sys.stderr)
return 1
return _handle_ytdlp_download(url, file_title, config, args)
else:
log(f"Unknown storage backend: {storage_name}", file=sys.stderr)
return 1
@@ -507,8 +519,28 @@ def _handle_local_file(file_path: Optional[str], file_title: str, config: Dict[s
try:
source = Path(file_path)
if not source.exists():
log(f"Error: File not found: {file_path}", file=sys.stderr)
return 1
# Try to resolve by hash if the path looks like a hash
resolved_local = False
if looks_like_hash(str(file_path)):
try:
from config import get_local_storage_path
from helper.local_library import LocalLibraryDB
storage_path = get_local_storage_path(config)
if storage_path:
with LocalLibraryDB(storage_path) as db:
resolved_path = db.search_by_hash(str(file_path))
if resolved_path and resolved_path.exists():
source = resolved_path
file_path = str(resolved_path)
resolved_local = True
# Also set file_hash since we know it
file_hash = str(file_path)
except Exception:
pass
if not resolved_local:
log(f"Error: File not found: {file_path}", file=sys.stderr)
return 1
# Check for explicit user flags
force_mpv = any(str(a).lower() in {'-mpv', '--mpv', 'mpv'} for a in args)
@@ -741,7 +773,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Also check for 'source' field (from add-file and other cmdlets)
if not origin:
origin = get_field(actual_result, 'source', None)
if origin and origin.lower() in {'hydrus', 'local', 'debrid', 'alldebrid'}:
if origin and origin.lower() in {'hydrus', 'local', 'debrid', 'alldebrid', 'bandcamp', 'youtube'}:
# This is a search result with explicit origin - handle it via _handle_search_result
return _handle_search_result(actual_result, args, config)
@@ -1023,8 +1055,28 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if isinstance(local_target, str) and not is_url and not (hash_spec and file_hash):
p = Path(local_target)
if not p.exists():
log(f"File missing: {p}")
return 1
# Check if it's a hash and try to resolve locally
resolved_local = False
if looks_like_hash(local_target):
try:
from config import get_local_storage_path
from helper.local_library import LocalLibraryDB
storage_path = get_local_storage_path(config)
if storage_path:
with LocalLibraryDB(storage_path) as db:
resolved_path = db.search_by_hash(local_target)
if resolved_path and resolved_path.exists():
p = resolved_path
resolved_local = True
# Also set file_hash since we know it
file_hash = local_target
except Exception:
pass
if not resolved_local:
log(f"File missing: {p}")
return 1
source_path = p
try:
source_size = p.stat().st_size
@@ -1046,127 +1098,158 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
except OSError:
pass
elif file_hash:
# Try local resolution first if origin is local or just in case
resolved_local = False
try:
client = hydrus_wrapper.get_client(config)
except Exception as exc:
log(f"Hydrus client unavailable: {exc}")
return 1
if client is None:
log("Hydrus client unavailable")
return 1
# Fetch metadata and tags (needed for both -metadata flag and audio tagging)
# Fetch tags
try:
tags_payload = client.fetch_file_metadata(hashes=[file_hash], include_service_keys_to_tags=True)
from config import get_local_storage_path
from helper.local_library import LocalLibraryDB
storage_path = get_local_storage_path(config)
if storage_path:
with LocalLibraryDB(storage_path) as db:
resolved_path = db.search_by_hash(file_hash)
if resolved_path and resolved_path.exists():
source_path = resolved_path
resolved_local = True
try:
source_size = source_path.stat().st_size
except OSError:
source_size = None
duration_sec = _ffprobe_duration_seconds(source_path)
except Exception:
tags_payload = {}
# Fetch URLs
try:
urls_payload = client.fetch_file_metadata(hashes=[file_hash], include_file_urls=True)
except Exception:
urls_payload = {}
# Extract title from metadata if base_name is still 'export'
if base_name == 'export' and tags_payload:
pass
if not resolved_local:
try:
file_metadata = tags_payload.get('file_metadata', [])
if file_metadata and isinstance(file_metadata, list) and len(file_metadata) > 0:
meta = file_metadata[0]
if isinstance(meta, dict):
tags_dict = meta.get('tags', {})
if isinstance(tags_dict, dict):
# Look for title in storage tags
for service in tags_dict.values():
if isinstance(service, dict):
storage = service.get('storage_tags', {})
if isinstance(storage, dict):
for tag_list in storage.values():
if isinstance(tag_list, list):
for tag in tag_list:
if isinstance(tag, str) and tag.lower().startswith('title:'):
title_val = tag.split(':', 1)[1].strip()
if title_val:
base_name = _sanitize_name(title_val)
break
if base_name != 'export':
break
if base_name != 'export':
break
client = hydrus_wrapper.get_client(config)
except Exception as exc:
log(f"Hydrus client unavailable: {exc}")
return 1
if client is None:
log("Hydrus client unavailable")
return 1
# Fetch metadata and tags (needed for both -metadata flag and audio tagging)
# Fetch tags
try:
tags_payload = client.fetch_file_metadata(hashes=[file_hash], include_service_keys_to_tags=True)
except Exception:
pass
# Normal file export (happens regardless of -metadata flag)
try:
from helper.hydrus import hydrus_export as _hydrus_export
except Exception:
_hydrus_export = None # type: ignore
if _hydrus_export is None:
log("Hydrus export helper unavailable")
return 1
download_dir = out_override if (out_override and out_override.is_dir()) else default_dir
try:
download_dir.mkdir(parents=True, exist_ok=True)
except Exception:
# If mkdir fails, fall back to default_dir
download_dir = default_dir
# Verify the directory is writable; if not, fall back to default
try:
test_file = download_dir / f".downlow_write_test_{_uuid.uuid4().hex[:8]}"
test_file.touch()
test_file.unlink()
except (OSError, PermissionError):
# Directory is not writable, use default_dir instead
download_dir = default_dir
tags_payload = {}
# Fetch URLs
try:
urls_payload = client.fetch_file_metadata(hashes=[file_hash], include_file_urls=True)
except Exception:
urls_payload = {}
# Extract title from metadata if base_name is still 'export'
if base_name == 'export' and tags_payload:
try:
file_metadata = tags_payload.get('file_metadata', [])
if file_metadata and isinstance(file_metadata, list) and len(file_metadata) > 0:
meta = file_metadata[0]
if isinstance(meta, dict):
tags_dict = meta.get('tags', {})
if isinstance(tags_dict, dict):
# Look for title in storage tags
for service in tags_dict.values():
if isinstance(service, dict):
storage = service.get('storage_tags', {})
if isinstance(storage, dict):
for tag_list in storage.values():
if isinstance(tag_list, list):
for tag in tag_list:
if isinstance(tag, str) and tag.lower().startswith('title:'):
title_val = tag.split(':', 1)[1].strip()
if title_val:
base_name = _sanitize_name(title_val)
break
if base_name != 'export':
break
if base_name != 'export':
break
except Exception:
pass
# Normal file export (happens regardless of -metadata flag)
try:
from helper.hydrus import hydrus_export as _hydrus_export
except Exception:
_hydrus_export = None # type: ignore
if _hydrus_export is None:
log("Hydrus export helper unavailable")
return 1
download_dir = out_override if (out_override and out_override.is_dir()) else default_dir
try:
download_dir.mkdir(parents=True, exist_ok=True)
except Exception:
# If mkdir fails, fall back to default_dir
download_dir = default_dir
# Verify the directory is writable; if not, fall back to default
try:
test_file = download_dir / f".downlow_write_test_{_uuid.uuid4().hex[:8]}"
test_file.touch()
test_file.unlink()
except (OSError, PermissionError):
# Directory is not writable, use default_dir instead
download_dir = default_dir
try:
download_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
token = (_uuid.uuid4().hex[:8])
provisional_stem = f"{base_name}.dlhx_{token}"
provisional = download_dir / f"{provisional_stem}.bin"
class _Args:
pass
token = (_uuid.uuid4().hex[:8])
provisional_stem = f"{base_name}.dlhx_{token}"
provisional = download_dir / f"{provisional_stem}.bin"
class _Args:
pass
args_obj = _Args()
setattr(args_obj, 'output', provisional)
setattr(args_obj, 'format', 'copy')
setattr(args_obj, 'tmp_dir', str(download_dir))
setattr(args_obj, 'metadata_json', None)
setattr(args_obj, 'hydrus_url', get_hydrus_url(config, "home") or "http://localhost:45869")
setattr(args_obj, 'access_key', get_hydrus_access_key(config, "home") or "")
setattr(args_obj, 'timeout', float(config.get('HydrusNetwork_Request_Timeout') or 60.0))
try:
file_url = client.file_url(file_hash)
except Exception:
file_url = None
setattr(args_obj, 'file_url', file_url)
setattr(args_obj, 'file_hash', file_hash)
import io as _io, contextlib as _contextlib
_buf = _io.StringIO()
status = 1
with _contextlib.redirect_stdout(_buf):
status = _hydrus_export(args_obj, None)
if status != 0:
stderr_text = _buf.getvalue().strip()
if stderr_text:
log(stderr_text)
return status
json_text = _buf.getvalue().strip().splitlines()[-1] if _buf.getvalue() else ''
final_from_json: Optional[Path] = None
try:
payload = json.loads(json_text) if json_text else None
if isinstance(payload, dict):
outp = payload.get('output')
if isinstance(outp, str) and outp:
final_from_json = Path(outp)
except Exception:
final_from_json = None
if final_from_json and final_from_json.exists():
source_path = final_from_json
else:
args_obj = _Args()
setattr(args_obj, 'output', provisional)
setattr(args_obj, 'format', 'copy')
setattr(args_obj, 'tmp_dir', str(download_dir))
setattr(args_obj, 'metadata_json', None)
setattr(args_obj, 'hydrus_url', get_hydrus_url(config, "home") or "http://localhost:45869")
setattr(args_obj, 'access_key', get_hydrus_access_key(config, "home") or "")
setattr(args_obj, 'timeout', float(config.get('HydrusNetwork_Request_Timeout') or 60.0))
try:
file_url = client.file_url(file_hash)
except Exception:
file_url = None
setattr(args_obj, 'file_url', file_url)
setattr(args_obj, 'file_hash', file_hash)
import io as _io, contextlib as _contextlib
_buf = _io.StringIO()
status = 1
with _contextlib.redirect_stdout(_buf):
status = _hydrus_export(args_obj, None)
if status != 0:
stderr_text = _buf.getvalue().strip()
if stderr_text:
log(stderr_text)
return status
json_text = _buf.getvalue().strip().splitlines()[-1] if _buf.getvalue() else ''
final_from_json: Optional[Path] = None
try:
payload = json.loads(json_text) if json_text else None
if isinstance(payload, dict):
outp = payload.get('output')
if isinstance(outp, str) and outp:
final_from_json = Path(outp)
except Exception:
final_from_json = None
if final_from_json and final_from_json.exists():
source_path = final_from_json
else:
candidates = [p for p in provisional.parent.glob(provisional_stem + '*') if p.exists() and p.is_file()]
non_provisional = [p for p in candidates if p.suffix.lower() not in {'.bin', '.hydrus'}]
pick_from = non_provisional if non_provisional else candidates
if pick_from:
try:
source_path = max(pick_from, key=lambda p: p.stat().st_mtime)
except Exception:
source_path = pick_from[0]
else:
source_path = provisional
candidates = [p for p in provisional.parent.glob(provisional_stem + '*') if p.exists() and p.is_file()]
non_provisional = [p for p in candidates if p.suffix.lower() not in {'.bin', '.hydrus'}]
pick_from = non_provisional if non_provisional else candidates
@@ -1177,16 +1260,6 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
source_path = pick_from[0]
else:
source_path = provisional
candidates = [p for p in provisional.parent.glob(provisional_stem + '*') if p.exists() and p.is_file()]
non_provisional = [p for p in candidates if p.suffix.lower() not in {'.bin', '.hydrus'}]
pick_from = non_provisional if non_provisional else candidates
if pick_from:
try:
source_path = max(pick_from, key=lambda p: p.stat().st_mtime)
except Exception:
source_path = pick_from[0]
else:
source_path = provisional
try:
source_size = source_size or (source_path.stat().st_size if source_path.exists() else None)
except OSError:
@@ -1479,6 +1552,77 @@ def _unique_path(p: Path) -> Path:
return p
def _handle_ytdlp_download(url: str, title: str, config: Dict[str, Any], args: Sequence[str]) -> int:
"""Handle download/streaming of URL using yt-dlp."""
if not url:
log("Error: No URL provided", file=sys.stderr)
return 1
# Check for -storage local
args_list = list(map(str, args))
storage_mode = None
if '-storage' in args_list:
try:
idx = args_list.index('-storage')
if idx + 1 < len(args_list):
storage_mode = args_list[idx + 1].lower()
except ValueError:
pass
force_local = (storage_mode == 'local')
if not force_local:
# Default: Stream to MPV
if _play_in_mpv(url, title, is_stream=True):
from . import pipe
pipe._run(None, [], config)
return 0
else:
# Fallback to browser
try:
import webbrowser
webbrowser.open(url)
debug(f"[get-file] Opened in browser: {title}", file=sys.stderr)
return 0
except Exception:
pass
return 1
# Download mode
try:
import yt_dlp
except ImportError:
log("Error: yt-dlp not installed. Please install it to download.", file=sys.stderr)
return 1
log(f"Downloading {title}...", file=sys.stderr)
# Determine output directory
download_dir = resolve_output_dir(config)
try:
download_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
# Configure yt-dlp
ydl_opts = {
'outtmpl': str(download_dir / '%(title)s.%(ext)s'),
'quiet': False,
'no_warnings': True,
# Use best audio/video
'format': 'best',
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
log(f"Downloaded to: {download_dir}", file=sys.stderr)
return 0
except Exception as e:
log(f"Error downloading: {e}", file=sys.stderr)
return 1
CMDLET = Cmdlet(
name="get-file",
summary="Export files: from Hydrus database OR from AllDebrid magnets via pipe. Auto-detects source and handles accordingly.",