hh
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,7 +31,7 @@ test_*
|
|||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
*.manifest
|
*.manifest
|
||||||
*.spec
|
*.spec
|
||||||
|
cookies.txt
|
||||||
# Installer logs
|
# Installer logs
|
||||||
pip-log.txt
|
pip-log.txt
|
||||||
pip-delete-this-directory.txt
|
pip-delete-this-directory.txt
|
||||||
|
|||||||
8
CLI.py
8
CLI.py
@@ -600,6 +600,10 @@ def _create_cmdlet_cli():
|
|||||||
# Load config
|
# Load config
|
||||||
config = _load_cli_config()
|
config = _load_cli_config()
|
||||||
|
|
||||||
|
# Initialize cookies check for yt-dlp
|
||||||
|
from hydrus_health_check import initialize_cookies_check
|
||||||
|
initialize_cookies_check()
|
||||||
|
|
||||||
# Initialize debug logging if enabled
|
# Initialize debug logging if enabled
|
||||||
if config:
|
if config:
|
||||||
from helper.logger import set_debug
|
from helper.logger import set_debug
|
||||||
@@ -691,11 +695,13 @@ def _create_cmdlet_cli():
|
|||||||
check_mpv_availability,
|
check_mpv_availability,
|
||||||
initialize_matrix_health_check,
|
initialize_matrix_health_check,
|
||||||
initialize_hydrus_health_check,
|
initialize_hydrus_health_check,
|
||||||
initialize_local_library_scan
|
initialize_local_library_scan,
|
||||||
|
initialize_cookies_check
|
||||||
)
|
)
|
||||||
check_mpv_availability()
|
check_mpv_availability()
|
||||||
initialize_hydrus_health_check(config)
|
initialize_hydrus_health_check(config)
|
||||||
initialize_matrix_health_check(config)
|
initialize_matrix_health_check(config)
|
||||||
|
initialize_cookies_check()
|
||||||
initialize_local_library_scan(config)
|
initialize_local_library_scan(config)
|
||||||
|
|
||||||
# --- Startup File Counts ---
|
# --- Startup File Counts ---
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from helper.logger import log, debug
|
from helper.logger import log, debug
|
||||||
from helper.download import download_media, probe_url
|
from helper.download import download_media, probe_url, is_url_supported_by_ytdlp
|
||||||
from helper.utils import sha256_file
|
from helper.utils import sha256_file
|
||||||
from models import DownloadOptions
|
from models import DownloadOptions
|
||||||
|
|
||||||
@@ -710,6 +710,97 @@ def _parse_time_range(clip_spec: str) -> Optional[Tuple[int, int]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_section_ranges(section_spec: str) -> Optional[List[Tuple[int, int]]]:
|
||||||
|
"""Parse section ranges from comma-separated time ranges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
section_spec: Section ranges like "1:30-1:35,0:05-0:15" or "90-95,5-15"
|
||||||
|
May include quotes from CLI which will be stripped
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (start_seconds, end_seconds) tuples or None if invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Strip quotes if present (from CLI parsing)
|
||||||
|
section_spec = section_spec.strip('"\'')
|
||||||
|
|
||||||
|
if not section_spec or ',' not in section_spec and '-' not in section_spec:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ranges = []
|
||||||
|
# Handle both comma-separated ranges and single range
|
||||||
|
if ',' in section_spec:
|
||||||
|
section_parts = section_spec.split(',')
|
||||||
|
else:
|
||||||
|
section_parts = [section_spec]
|
||||||
|
|
||||||
|
for part in section_parts:
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse each range using the same logic as _parse_time_range
|
||||||
|
# Handle format like "1:30-1:35" or "90-95"
|
||||||
|
if '-' not in part:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Split carefully to handle cases like "1:30-1:35"
|
||||||
|
# We need to find the dash that separates start and end
|
||||||
|
# Look for pattern: something-something where first something may have colons
|
||||||
|
dash_pos = -1
|
||||||
|
colon_count = 0
|
||||||
|
for i, char in enumerate(part):
|
||||||
|
if char == ':':
|
||||||
|
colon_count += 1
|
||||||
|
elif char == '-':
|
||||||
|
# If we've seen a colon and this is a dash, check if it's the separator
|
||||||
|
# Could be "1:30-1:35" or just "90-95"
|
||||||
|
# The separator dash should come after the first number/time
|
||||||
|
if i > 0 and i < len(part) - 1:
|
||||||
|
dash_pos = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if dash_pos == -1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
start_str = part[:dash_pos]
|
||||||
|
end_str = part[dash_pos+1:]
|
||||||
|
|
||||||
|
# Parse start time
|
||||||
|
if ':' in start_str:
|
||||||
|
start_parts = start_str.split(':')
|
||||||
|
if len(start_parts) == 2:
|
||||||
|
start_sec = int(start_parts[0]) * 60 + int(start_parts[1])
|
||||||
|
elif len(start_parts) == 3:
|
||||||
|
start_sec = int(start_parts[0]) * 3600 + int(start_parts[1]) * 60 + int(start_parts[2])
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
start_sec = int(start_str)
|
||||||
|
|
||||||
|
# Parse end time
|
||||||
|
if ':' in end_str:
|
||||||
|
end_parts = end_str.split(':')
|
||||||
|
if len(end_parts) == 2:
|
||||||
|
end_sec = int(end_parts[0]) * 60 + int(end_parts[1])
|
||||||
|
elif len(end_parts) == 3:
|
||||||
|
end_sec = int(end_parts[0]) * 3600 + int(end_parts[1]) * 60 + int(end_parts[2])
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
end_sec = int(end_str)
|
||||||
|
|
||||||
|
if start_sec >= end_sec:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ranges.append((start_sec, end_sec))
|
||||||
|
|
||||||
|
return ranges if ranges else None
|
||||||
|
|
||||||
|
except (ValueError, AttributeError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
MEDIA_EXTENSIONS = {'.mp3', '.m4a', '.mp4', '.mkv', '.webm', '.flac', '.wav', '.aac'}
|
MEDIA_EXTENSIONS = {'.mp3', '.m4a', '.mp4', '.mkv', '.webm', '.flac', '.wav', '.aac'}
|
||||||
|
|
||||||
|
|
||||||
@@ -1024,6 +1115,23 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
|
|||||||
log(f"Invalid clip format: {clip_spec}", file=sys.stderr)
|
log(f"Invalid clip format: {clip_spec}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Section download (yt-dlp only)
|
||||||
|
section_spec = parsed.get("section")
|
||||||
|
section_ranges = None
|
||||||
|
if section_spec:
|
||||||
|
# Parse section spec like "1:30-1:35,0:05-0:15" into list of (start, end) tuples
|
||||||
|
section_ranges = _parse_section_ranges(section_spec)
|
||||||
|
if section_ranges:
|
||||||
|
debug(f"Section ranges: {section_spec} ({len(section_ranges)} sections)")
|
||||||
|
# When downloading sections, auto-select best format if not specified
|
||||||
|
# Since we're only getting portions, quality matters less than completeness
|
||||||
|
if not format_selector:
|
||||||
|
format_selector = "bestvideo+bestaudio/best"
|
||||||
|
debug(f"Auto-selecting format for sections: {format_selector}")
|
||||||
|
else:
|
||||||
|
log(f"Invalid section format: {section_spec}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
cookies_path = parsed.get("cookies")
|
cookies_path = parsed.get("cookies")
|
||||||
storage_location = parsed.get("storage")
|
storage_location = parsed.get("storage")
|
||||||
|
|
||||||
@@ -2361,7 +2469,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
|
|||||||
if (not current_format_selector and not list_formats_mode and
|
if (not current_format_selector and not list_formats_mode and
|
||||||
isinstance(url, str) and url.startswith(('http://', 'https://'))):
|
isinstance(url, str) and url.startswith(('http://', 'https://'))):
|
||||||
# Check if this is a yt-dlp supported URL (YouTube, Vimeo, etc.)
|
# Check if this is a yt-dlp supported URL (YouTube, Vimeo, etc.)
|
||||||
from helper.download import is_url_supported_by_ytdlp, list_formats
|
from helper.download import list_formats
|
||||||
from result_table import ResultTable
|
from result_table import ResultTable
|
||||||
|
|
||||||
if is_url_supported_by_ytdlp(url):
|
if is_url_supported_by_ytdlp(url):
|
||||||
@@ -2562,13 +2670,35 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
|
|||||||
# Detect YouTube URLs and set no_playlist to download only the single video
|
# Detect YouTube URLs and set no_playlist to download only the single video
|
||||||
is_youtube_url = isinstance(url, str) and ('youtube.com' in url or 'youtu.be' in url)
|
is_youtube_url = isinstance(url, str) and ('youtube.com' in url or 'youtu.be' in url)
|
||||||
|
|
||||||
|
# Determine clip_sections to pass to yt-dlp
|
||||||
|
# Sections take precedence over clip if both are specified
|
||||||
|
# Sections are for yt-dlp download-sections (merge multiple clips at source)
|
||||||
|
# Clip is for post-download extraction
|
||||||
|
clip_sections_str = None
|
||||||
|
if section_ranges:
|
||||||
|
# Check if this is a yt-dlp URL
|
||||||
|
if is_url_supported_by_ytdlp(url):
|
||||||
|
# Convert section ranges to yt-dlp format: "start1-end1,start2-end2"
|
||||||
|
# Use * prefix to indicate download_sections (yt-dlp convention in some contexts)
|
||||||
|
# But here we just pass the string and let helper/download.py parse it
|
||||||
|
clip_sections_str = ",".join(f"{start}-{end}" for start, end in section_ranges)
|
||||||
|
debug(f"Using yt-dlp sections: {clip_sections_str}")
|
||||||
|
else:
|
||||||
|
log(f"Warning: -section only works with yt-dlp supported URLs. Use -clip for {url}", file=sys.stderr)
|
||||||
|
elif clip_range:
|
||||||
|
# For -clip, we use the same field but it's handled differently in helper/download.py
|
||||||
|
# Wait, helper/download.py treats clip_sections as download_sections for yt-dlp
|
||||||
|
# So -clip should also work as download_sections if it's a yt-dlp URL?
|
||||||
|
# Currently -clip is just one range.
|
||||||
|
clip_sections_str = f"{clip_range[0]}-{clip_range[1]}"
|
||||||
|
|
||||||
download_opts = DownloadOptions(
|
download_opts = DownloadOptions(
|
||||||
url=url,
|
url=url,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
output_dir=final_output_dir,
|
output_dir=final_output_dir,
|
||||||
cookies_path=final_cookies_path,
|
cookies_path=final_cookies_path,
|
||||||
ytdl_format=current_format_selector, # Use per-URL format override if available
|
ytdl_format=current_format_selector, # Use per-URL format override if available
|
||||||
clip_sections=f"{clip_range[0]}-{clip_range[1]}" if clip_range else None,
|
clip_sections=clip_sections_str,
|
||||||
playlist_items=playlist_items,
|
playlist_items=playlist_items,
|
||||||
no_playlist=is_youtube_url, # For YouTube, ignore playlist URLs and download single video
|
no_playlist=is_youtube_url, # For YouTube, ignore playlist URLs and download single video
|
||||||
)
|
)
|
||||||
@@ -2584,8 +2714,36 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
|
|||||||
file_path = result_data.path
|
file_path = result_data.path
|
||||||
|
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
|
# Check if we have multiple section files to emit
|
||||||
|
if result_data.paths:
|
||||||
|
# Section download - emit each section file separately for merge-file
|
||||||
|
debug(f"📋 Section download: emitting {len(result_data.paths)} file(s) to merge-file")
|
||||||
|
for section_file in result_data.paths:
|
||||||
|
if section_file.exists():
|
||||||
|
file_hash = _compute_file_hash(section_file)
|
||||||
|
tags = result_data.tags if result_data.tags else []
|
||||||
|
|
||||||
|
pipe_obj = create_pipe_object_result(
|
||||||
|
source='download',
|
||||||
|
identifier=section_file.stem,
|
||||||
|
file_path=str(section_file),
|
||||||
|
cmdlet_name='download-data',
|
||||||
|
title=section_file.name,
|
||||||
|
file_hash=file_hash,
|
||||||
|
is_temp=False,
|
||||||
|
extra={
|
||||||
|
'url': url,
|
||||||
|
'tags': tags,
|
||||||
|
'audio_mode': audio_mode,
|
||||||
|
'format': format_selector,
|
||||||
|
'from_sections': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
downloaded_files.append(section_file)
|
||||||
|
pipeline_context.emit(pipe_obj)
|
||||||
# Check if this was a playlist download (is_actual_playlist tracks if we have a multi-item playlist)
|
# Check if this was a playlist download (is_actual_playlist tracks if we have a multi-item playlist)
|
||||||
if is_actual_playlist:
|
elif is_actual_playlist:
|
||||||
if not selected_playlist_entries:
|
if not selected_playlist_entries:
|
||||||
debug("⚠ Playlist metadata unavailable; cannot emit selected items for this stage.")
|
debug("⚠ Playlist metadata unavailable; cannot emit selected items for this stage.")
|
||||||
exit_code = 1
|
exit_code = 1
|
||||||
@@ -2788,6 +2946,11 @@ CMDLET = Cmdlet(
|
|||||||
type="string",
|
type="string",
|
||||||
description="Extract time range: MM:SS-MM:SS (e.g., 34:03-35:08) or seconds"
|
description="Extract time range: MM:SS-MM:SS (e.g., 34:03-35:08) or seconds"
|
||||||
),
|
),
|
||||||
|
CmdletArg(
|
||||||
|
name="section",
|
||||||
|
type="string",
|
||||||
|
description="Download sections (yt-dlp only): TIME_RANGE[,TIME_RANGE...] (e.g., '1:30-1:35,0:05-0:15')"
|
||||||
|
),
|
||||||
CmdletArg(
|
CmdletArg(
|
||||||
name="cookies",
|
name="cookies",
|
||||||
type="string",
|
type="string",
|
||||||
@@ -2841,6 +3004,12 @@ CMDLET = Cmdlet(
|
|||||||
" Format: MM:SS-MM:SS (e.g., 34:03-35:08)",
|
" Format: MM:SS-MM:SS (e.g., 34:03-35:08)",
|
||||||
" Also accepts: 2043-2108 (seconds)",
|
" Also accepts: 2043-2108 (seconds)",
|
||||||
"",
|
"",
|
||||||
|
"SECTION DOWNLOAD (yt-dlp only):",
|
||||||
|
" -section RANGES Download specific time sections and merge them",
|
||||||
|
" Format: HH:MM:SS-HH:MM:SS[,HH:MM:SS-HH:MM:SS...]",
|
||||||
|
" Example: -section '1:30-1:35,0:05-0:15'",
|
||||||
|
" Each section is downloaded separately then merged in order",
|
||||||
|
"",
|
||||||
"PLAYLIST MODE:",
|
"PLAYLIST MODE:",
|
||||||
" Automatically detects playlists",
|
" Automatically detects playlists",
|
||||||
" Shows numbered list of tracks",
|
" Shows numbered list of tracks",
|
||||||
@@ -2866,6 +3035,9 @@ CMDLET = Cmdlet(
|
|||||||
" # Extract specific clip from video",
|
" # Extract specific clip from video",
|
||||||
" download-data https://vimeo.com/123456 -clip 1:30-2:45 -format best",
|
" download-data https://vimeo.com/123456 -clip 1:30-2:45 -format best",
|
||||||
"",
|
"",
|
||||||
|
" # Download multiple sections and merge them",
|
||||||
|
" download-data https://youtube.com/watch?v=xyz -section '1:30-1:35,0:05-0:15' | merge-file | add-file -storage local",
|
||||||
|
"",
|
||||||
" # Download specific tracks from playlist",
|
" # Download specific tracks from playlist",
|
||||||
" download-data https://youtube.com/playlist?list=xyz -item '1,3,5-8'",
|
" download-data https://youtube.com/playlist?list=xyz -item '1,3,5-8'",
|
||||||
"",
|
"",
|
||||||
|
|||||||
180
cmdlets/pipe.py
180
cmdlets/pipe.py
@@ -10,9 +10,11 @@ from helper.logger import log, debug
|
|||||||
from result_table import ResultTable
|
from result_table import ResultTable
|
||||||
from helper.mpv_ipc import get_ipc_pipe_path, MPVIPCClient
|
from helper.mpv_ipc import get_ipc_pipe_path, MPVIPCClient
|
||||||
import pipeline as ctx
|
import pipeline as ctx
|
||||||
|
from helper.download import is_url_supported_by_ytdlp
|
||||||
|
|
||||||
from helper.local_library import LocalLibrarySearchOptimizer
|
from helper.local_library import LocalLibrarySearchOptimizer
|
||||||
from config import get_local_storage_path
|
from config import get_local_storage_path
|
||||||
|
from hydrus_health_check import get_cookies_file_path
|
||||||
|
|
||||||
def _send_ipc_command(command: Dict[str, Any], silent: bool = False) -> Optional[Any]:
|
def _send_ipc_command(command: Dict[str, Any], silent: bool = False) -> Optional[Any]:
|
||||||
"""Send a command to the MPV IPC pipe and return the response."""
|
"""Send a command to the MPV IPC pipe and return the response."""
|
||||||
@@ -70,6 +72,62 @@ def _extract_title_from_item(item: Dict[str, Any]) -> str:
|
|||||||
|
|
||||||
return title or filename or "Unknown"
|
return title or filename or "Unknown"
|
||||||
|
|
||||||
|
def _ensure_ytdl_cookies() -> None:
|
||||||
|
"""Ensure yt-dlp options are set correctly for this session."""
|
||||||
|
from pathlib import Path
|
||||||
|
cookies_path = get_cookies_file_path()
|
||||||
|
if cookies_path:
|
||||||
|
# Check if file exists and has content (use forward slashes for path checking)
|
||||||
|
check_path = cookies_path.replace('\\', '/')
|
||||||
|
file_obj = Path(cookies_path)
|
||||||
|
if file_obj.exists():
|
||||||
|
file_size = file_obj.stat().st_size
|
||||||
|
debug(f"Cookies file verified: {check_path} ({file_size} bytes)")
|
||||||
|
else:
|
||||||
|
debug(f"WARNING: Cookies file does not exist: {check_path}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
debug("No cookies file configured")
|
||||||
|
|
||||||
|
def _monitor_mpv_logs(duration: float = 3.0) -> None:
|
||||||
|
"""Monitor MPV logs for a short duration to capture errors."""
|
||||||
|
try:
|
||||||
|
client = MPVIPCClient()
|
||||||
|
if not client.connect():
|
||||||
|
debug("Failed to connect to MPV for log monitoring", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Request log messages
|
||||||
|
client.send_command({"command": ["request_log_messages", "warn"]})
|
||||||
|
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
while time.time() - start_time < duration:
|
||||||
|
# We need to read raw lines from the socket
|
||||||
|
if client.is_windows:
|
||||||
|
try:
|
||||||
|
line = client.sock.readline()
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
if msg.get("event") == "log-message":
|
||||||
|
text = msg.get("text", "").strip()
|
||||||
|
prefix = msg.get("prefix", "")
|
||||||
|
level = msg.get("level", "")
|
||||||
|
if "ytdl" in prefix or level == "error":
|
||||||
|
debug(f"[MPV {prefix}] {text}", file=sys.stderr)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Unix socket handling (simplified)
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _queue_items(items: List[Any], clear_first: bool = False) -> bool:
|
def _queue_items(items: List[Any], clear_first: bool = False) -> bool:
|
||||||
"""Queue items to MPV, starting it if necessary.
|
"""Queue items to MPV, starting it if necessary.
|
||||||
|
|
||||||
@@ -80,6 +138,9 @@ def _queue_items(items: List[Any], clear_first: bool = False) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
True if MPV was started, False if items were queued via IPC.
|
True if MPV was started, False if items were queued via IPC.
|
||||||
"""
|
"""
|
||||||
|
# Just verify cookies are configured, don't try to set via IPC
|
||||||
|
_ensure_ytdl_cookies()
|
||||||
|
|
||||||
for i, item in enumerate(items):
|
for i, item in enumerate(items):
|
||||||
# Extract URL/Path
|
# Extract URL/Path
|
||||||
target = None
|
target = None
|
||||||
@@ -95,11 +156,14 @@ def _queue_items(items: List[Any], clear_first: bool = False) -> bool:
|
|||||||
target = item
|
target = item
|
||||||
|
|
||||||
if target:
|
if target:
|
||||||
# Add to MPV playlist
|
# Check if it's a yt-dlp supported URL
|
||||||
# We use loadfile with append flag (or replace if clear_first is set)
|
is_ytdlp = False
|
||||||
|
if target.startswith("http") and is_url_supported_by_ytdlp(target):
|
||||||
|
is_ytdlp = True
|
||||||
|
|
||||||
# Use memory:// M3U hack to pass title to MPV
|
# Use memory:// M3U hack to pass title to MPV
|
||||||
if title:
|
# Skip for yt-dlp URLs to ensure proper handling
|
||||||
|
if title and not is_ytdlp:
|
||||||
# Sanitize title for M3U (remove newlines)
|
# Sanitize title for M3U (remove newlines)
|
||||||
safe_title = title.replace('\n', ' ').replace('\r', '')
|
safe_title = title.replace('\n', ' ').replace('\r', '')
|
||||||
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}"
|
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}"
|
||||||
@@ -164,9 +228,25 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
mpv_started = False
|
mpv_started = False
|
||||||
if url_arg:
|
if url_arg:
|
||||||
mpv_started = _queue_items([url_arg])
|
mpv_started = _queue_items([url_arg])
|
||||||
# If we just queued a URL, we probably want to list the playlist to show it was added
|
# Auto-play the URL when it's queued via .pipe "url" (without explicit flags)
|
||||||
# unless other flags are present
|
# unless other flags are present
|
||||||
if not (clear_mode or play_mode or pause_mode or save_mode or load_mode):
|
if not (clear_mode or play_mode or pause_mode or save_mode or load_mode):
|
||||||
|
if mpv_started:
|
||||||
|
# MPV was just started, wait a moment for it to be ready, then play first item
|
||||||
|
import time
|
||||||
|
time.sleep(0.5)
|
||||||
|
index_arg = "1" # 1-based index for first item
|
||||||
|
play_mode = True
|
||||||
|
else:
|
||||||
|
# MPV was already running, get playlist and play the newly added item
|
||||||
|
playlist = _get_playlist(silent=True)
|
||||||
|
if playlist and len(playlist) > 0:
|
||||||
|
# Auto-play the last item in the playlist (the one we just added)
|
||||||
|
# Use 1-based indexing
|
||||||
|
index_arg = str(len(playlist))
|
||||||
|
play_mode = True
|
||||||
|
else:
|
||||||
|
# Fallback: just list the playlist if we can't determine index
|
||||||
list_mode = True
|
list_mode = True
|
||||||
|
|
||||||
# Handle Save Playlist
|
# Handle Save Playlist
|
||||||
@@ -290,8 +370,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
print(table)
|
print(table)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Handle Play/Pause commands
|
# Handle Play/Pause commands (but skip if we have index_arg to play a specific item)
|
||||||
if play_mode:
|
if play_mode and index_arg is None:
|
||||||
cmd = {"command": ["set_property", "pause", False], "request_id": 103}
|
cmd = {"command": ["set_property", "pause", False], "request_id": 103}
|
||||||
resp = _send_ipc_command(cmd)
|
resp = _send_ipc_command(cmd)
|
||||||
if resp and resp.get("error") == "success":
|
if resp and resp.get("error") == "success":
|
||||||
@@ -345,10 +425,16 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
|
|
||||||
if items is None:
|
if items is None:
|
||||||
if mpv_started:
|
if mpv_started:
|
||||||
# MPV was just started, so we can't list items yet.
|
# MPV was just started, retry getting playlist after a brief delay
|
||||||
# But we know it's running (or trying to start), so don't start another instance.
|
import time
|
||||||
return 0
|
time.sleep(0.3)
|
||||||
|
items = _get_playlist(silent=True)
|
||||||
|
|
||||||
|
if items is None:
|
||||||
|
# Still can't connect, but MPV is starting
|
||||||
|
debug("MPV is starting up...")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
debug("MPV is not running. Starting new instance...")
|
debug("MPV is not running. Starting new instance...")
|
||||||
_start_mpv([])
|
_start_mpv([])
|
||||||
return 0
|
return 0
|
||||||
@@ -393,11 +479,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
_send_ipc_command(unpause_cmd)
|
_send_ipc_command(unpause_cmd)
|
||||||
|
|
||||||
debug(f"Playing: {title}")
|
debug(f"Playing: {title}")
|
||||||
|
|
||||||
|
# Monitor logs briefly for errors (e.g. ytdl failures)
|
||||||
|
_monitor_mpv_logs(3.0)
|
||||||
return 0
|
return 0
|
||||||
else:
|
else:
|
||||||
debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}")
|
debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
debug(f"Invalid index: {index_arg}")
|
debug(f"Invalid index: {index_arg}")
|
||||||
return 1
|
return 1
|
||||||
@@ -443,44 +531,70 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
|
|
||||||
def _start_mpv(items: List[Any]) -> None:
|
def _start_mpv(items: List[Any]) -> None:
|
||||||
"""Start MPV with a list of items."""
|
"""Start MPV with a list of items."""
|
||||||
|
import subprocess
|
||||||
|
import time as _time_module
|
||||||
|
|
||||||
|
# Kill any existing MPV processes to ensure clean start
|
||||||
|
try:
|
||||||
|
subprocess.run(['taskkill', '/IM', 'mpv.exe', '/F'],
|
||||||
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL, timeout=2)
|
||||||
|
_time_module.sleep(0.5) # Wait for process to die
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
ipc_pipe = get_ipc_pipe_path()
|
ipc_pipe = get_ipc_pipe_path()
|
||||||
|
|
||||||
|
# Start MPV in idle mode with IPC server
|
||||||
cmd = ['mpv', f'--input-ipc-server={ipc_pipe}', '--idle', '--force-window']
|
cmd = ['mpv', f'--input-ipc-server={ipc_pipe}', '--idle', '--force-window']
|
||||||
cmd.append('--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]')
|
cmd.append('--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]')
|
||||||
|
|
||||||
# Add items
|
# Use cookies.txt if available, otherwise fallback to browser cookies
|
||||||
for item in items:
|
cookies_path = get_cookies_file_path()
|
||||||
target = None
|
if cookies_path:
|
||||||
title = None
|
# yt-dlp on Windows needs forward slashes OR properly escaped backslashes
|
||||||
|
# Using forward slashes is more reliable across systems
|
||||||
if isinstance(item, dict):
|
cookies_path_normalized = cookies_path.replace('\\', '/')
|
||||||
target = item.get("target") or item.get("url") or item.get("path") or item.get("filename")
|
debug(f"Starting MPV with cookies file: {cookies_path_normalized}")
|
||||||
title = item.get("title") or item.get("name")
|
# yt-dlp expects the cookies option with file path
|
||||||
elif hasattr(item, "target"):
|
cmd.append(f'--ytdl-raw-options=cookies={cookies_path_normalized}')
|
||||||
target = item.target
|
|
||||||
title = getattr(item, "title", None)
|
|
||||||
elif isinstance(item, str):
|
|
||||||
target = item
|
|
||||||
|
|
||||||
if target:
|
|
||||||
if title:
|
|
||||||
# Use memory:// M3U hack to pass title
|
|
||||||
safe_title = title.replace('\n', ' ').replace('\r', '')
|
|
||||||
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}"
|
|
||||||
cmd.append(f"memory://{m3u_content}")
|
|
||||||
else:
|
else:
|
||||||
cmd.append(target)
|
# Use cookies from browser (Chrome) to handle age-restricted content
|
||||||
|
debug("Starting MPV with browser cookies: chrome")
|
||||||
|
cmd.append('--ytdl-raw-options=cookies-from-browser=chrome')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if platform.system() == 'Windows':
|
if platform.system() == 'Windows':
|
||||||
kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS
|
kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS
|
||||||
|
|
||||||
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs)
|
# Log the complete MPV command being executed
|
||||||
debug(f"Started MPV with {len(items)} items")
|
debug(f"DEBUG: Full MPV command: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs)
|
||||||
|
debug(f"Started MPV process")
|
||||||
|
|
||||||
|
# Wait for IPC pipe to be ready
|
||||||
|
import time
|
||||||
|
max_retries = 20
|
||||||
|
for i in range(max_retries):
|
||||||
|
time.sleep(0.2)
|
||||||
|
client = MPVIPCClient(socket_path=ipc_pipe)
|
||||||
|
if client.connect():
|
||||||
|
client.disconnect()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
debug("Timed out waiting for MPV IPC connection", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Queue items via IPC
|
||||||
|
if items:
|
||||||
|
_queue_items(items)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
debug(f"Error starting MPV: {e}", file=sys.stderr)
|
debug(f"Error starting MPV: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
CMDLET = Cmdlet(
|
CMDLET = Cmdlet(
|
||||||
name=".pipe",
|
name=".pipe",
|
||||||
aliases=["pipe", "playlist", "queue", "ls-pipe"],
|
aliases=["pipe", "playlist", "queue", "ls-pipe"],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Lean, focused downloader without event infrastructure overhead.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re # noqa: F401
|
import re # noqa: F401
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -22,6 +23,7 @@ from helper.logger import log, debug
|
|||||||
from .utils import ensure_directory, sha256_file
|
from .utils import ensure_directory, sha256_file
|
||||||
from .http_client import HTTPClient
|
from .http_client import HTTPClient
|
||||||
from models import DownloadError, DownloadOptions, DownloadMediaResult, DebugLogger, ProgressBar
|
from models import DownloadError, DownloadOptions, DownloadMediaResult, DebugLogger, ProgressBar
|
||||||
|
from hydrus_health_check import get_cookies_file_path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yt_dlp # type: ignore
|
import yt_dlp # type: ignore
|
||||||
@@ -153,10 +155,79 @@ def list_formats(url: str, no_playlist: bool = False, playlist_items: Optional[s
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"✗ Error fetching formats: {e}", file=sys.stderr)
|
log(f"✗ Error fetching formats: {e}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _download_with_sections_via_cli(url: str, ytdl_options: Dict[str, Any], sections: List[str]) -> None:
|
||||||
|
"""Download each section separately so merge-file can combine them.
|
||||||
|
|
||||||
|
yt-dlp with multiple --download-sections args merges them into one file.
|
||||||
|
We need separate files for merge-file, so download each section individually.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sections_list = ytdl_options.get("download_sections", [])
|
||||||
|
if not sections_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Download each section separately with unique output template
|
||||||
|
for section_idx, section in enumerate(sections_list, 1):
|
||||||
|
# Build unique output template for this section
|
||||||
|
# e.g., "title.section_1_of_3.ext" for the first section
|
||||||
|
base_outtmpl = ytdl_options.get("outtmpl", "%(title)s.%(ext)s")
|
||||||
|
|
||||||
|
# Insert section number before extension
|
||||||
|
# e.g., "/path/title.hash.webm" → "/path/title.hash.section_1_of_3.webm"
|
||||||
|
if base_outtmpl.endswith(".%(ext)s"):
|
||||||
|
section_outtmpl = base_outtmpl.replace(".%(ext)s", f".section_{section_idx}_of_{len(sections_list)}.%(ext)s")
|
||||||
|
else:
|
||||||
|
section_outtmpl = base_outtmpl + f".section_{section_idx}_of_{len(sections_list)}"
|
||||||
|
|
||||||
|
# Build yt-dlp command for this section
|
||||||
|
cmd = ["yt-dlp"]
|
||||||
|
|
||||||
|
# Add format
|
||||||
|
if ytdl_options.get("format"):
|
||||||
|
cmd.extend(["-f", ytdl_options["format"]])
|
||||||
|
|
||||||
|
# Add ONLY this section (not all sections)
|
||||||
|
cmd.extend(["--download-sections", section])
|
||||||
|
|
||||||
|
# Add force-keyframes-at-cuts if specified
|
||||||
|
if ytdl_options.get("force_keyframes_at_cuts"):
|
||||||
|
cmd.append("--force-keyframes-at-cuts")
|
||||||
|
|
||||||
|
# Add output template for this section
|
||||||
|
cmd.extend(["-o", section_outtmpl])
|
||||||
|
|
||||||
|
# Add cookies file if present
|
||||||
|
if ytdl_options.get("cookiefile"):
|
||||||
|
# Convert backslashes to forward slashes for better compatibility
|
||||||
|
cookies_path = ytdl_options["cookiefile"].replace("\\", "/")
|
||||||
|
cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
|
# Add no-playlist if specified
|
||||||
|
if ytdl_options.get("noplaylist"):
|
||||||
|
cmd.append("--no-playlist")
|
||||||
|
|
||||||
|
# Add the URL
|
||||||
|
cmd.append(url)
|
||||||
|
|
||||||
|
debug(f"Running yt-dlp for section {section_idx}/{len(sections_list)}: {section}")
|
||||||
|
|
||||||
|
# Run the subprocess
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=False, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise DownloadError(f"yt-dlp subprocess failed for section {section_idx} with code {result.returncode}")
|
||||||
|
except Exception as exc:
|
||||||
|
raise DownloadError(f"yt-dlp subprocess error for section {section_idx}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]:
|
def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]:
|
||||||
"""Build yt-dlp download options."""
|
"""Build yt-dlp download options."""
|
||||||
ensure_directory(opts.output_dir)
|
ensure_directory(opts.output_dir)
|
||||||
|
|
||||||
|
# Build output template
|
||||||
|
# When downloading sections, each section will have .section_N_of_M added by _download_with_sections_via_cli
|
||||||
outtmpl = str((opts.output_dir / "%(title)s.%(ext)s").resolve())
|
outtmpl = str((opts.output_dir / "%(title)s.%(ext)s").resolve())
|
||||||
|
|
||||||
base_options: Dict[str, Any] = {
|
base_options: Dict[str, Any] = {
|
||||||
@@ -174,6 +245,14 @@ def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]:
|
|||||||
|
|
||||||
if opts.cookies_path and opts.cookies_path.is_file():
|
if opts.cookies_path and opts.cookies_path.is_file():
|
||||||
base_options["cookiefile"] = str(opts.cookies_path)
|
base_options["cookiefile"] = str(opts.cookies_path)
|
||||||
|
else:
|
||||||
|
# Check global cookies file
|
||||||
|
global_cookies = get_cookies_file_path()
|
||||||
|
if global_cookies:
|
||||||
|
base_options["cookiefile"] = global_cookies
|
||||||
|
else:
|
||||||
|
# Fallback to browser cookies
|
||||||
|
base_options["cookiesfrombrowser"] = ("chrome",)
|
||||||
|
|
||||||
# Add no-playlist option if specified (for single video from playlist URLs)
|
# Add no-playlist option if specified (for single video from playlist URLs)
|
||||||
if opts.no_playlist:
|
if opts.no_playlist:
|
||||||
@@ -189,9 +268,36 @@ def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]:
|
|||||||
"res:4320", "res:2880", "res:2160", "res:1440", "res:1080", "res:720", "res"
|
"res:4320", "res:2880", "res:2160", "res:1440", "res:1080", "res:720", "res"
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add clip sections if provided
|
# Add clip sections if provided (yt-dlp will download only these sections)
|
||||||
if opts.clip_sections:
|
if opts.clip_sections:
|
||||||
base_options["download_sections"] = opts.clip_sections
|
# Parse section ranges like "48-65,120-152,196-205" (seconds)
|
||||||
|
# and convert to yt-dlp format: "*HH:MM:SS-HH:MM:SS,*HH:MM:SS-HH:MM:SS"
|
||||||
|
sections = []
|
||||||
|
for section_range in opts.clip_sections.split(','):
|
||||||
|
try:
|
||||||
|
start_str, end_str = section_range.strip().split('-')
|
||||||
|
start_sec = float(start_str)
|
||||||
|
end_sec = float(end_str)
|
||||||
|
|
||||||
|
# Convert seconds to HH:MM:SS format
|
||||||
|
def sec_to_hhmmss(seconds):
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
minutes = int((seconds % 3600) // 60)
|
||||||
|
secs = int(seconds % 60)
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||||
|
|
||||||
|
start_time = sec_to_hhmmss(start_sec)
|
||||||
|
end_time = sec_to_hhmmss(end_sec)
|
||||||
|
sections.append(f"*{start_time}-{end_time}")
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if sections:
|
||||||
|
# Pass each section as a separate element in the list (yt-dlp expects multiple --download-sections args)
|
||||||
|
base_options["download_sections"] = sections
|
||||||
|
debug(f"Download sections configured: {', '.join(sections)}")
|
||||||
|
# Force keyframes at cuts for accurate section boundaries
|
||||||
|
base_options["force_keyframes_at_cuts"] = True
|
||||||
|
|
||||||
# Add playlist items selection if provided
|
# Add playlist items selection if provided
|
||||||
if opts.playlist_items:
|
if opts.playlist_items:
|
||||||
@@ -547,9 +653,13 @@ def probe_url(url: str, no_playlist: bool = False) -> Optional[Dict[str, Any]]:
|
|||||||
"skip_download": True, # Don't actually download
|
"skip_download": True, # Don't actually download
|
||||||
"extract_flat": "in_playlist", # Get playlist with metadata for each entry
|
"extract_flat": "in_playlist", # Get playlist with metadata for each entry
|
||||||
"noprogress": True, # No progress bars
|
"noprogress": True, # No progress bars
|
||||||
"quiet": True,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add cookies if available
|
||||||
|
global_cookies = get_cookies_file_path()
|
||||||
|
if global_cookies:
|
||||||
|
ydl_opts["cookiefile"] = global_cookies
|
||||||
|
|
||||||
# Add no_playlist option if specified
|
# Add no_playlist option if specified
|
||||||
if no_playlist:
|
if no_playlist:
|
||||||
ydl_opts["noplaylist"] = True
|
ydl_opts["noplaylist"] = True
|
||||||
@@ -635,6 +745,16 @@ def download_media(
|
|||||||
|
|
||||||
assert yt_dlp is not None
|
assert yt_dlp is not None
|
||||||
try:
|
try:
|
||||||
|
# Debug: show what options we're using
|
||||||
|
if ytdl_options.get("download_sections"):
|
||||||
|
debug(f"[yt-dlp] download_sections: {ytdl_options['download_sections']}")
|
||||||
|
debug(f"[yt-dlp] force_keyframes_at_cuts: {ytdl_options.get('force_keyframes_at_cuts', False)}")
|
||||||
|
|
||||||
|
# Use subprocess when download_sections are present (Python API doesn't support them properly)
|
||||||
|
if ytdl_options.get("download_sections"):
|
||||||
|
_download_with_sections_via_cli(opts.url, ytdl_options, ytdl_options.get("download_sections", []))
|
||||||
|
info = None
|
||||||
|
else:
|
||||||
with yt_dlp.YoutubeDL(ytdl_options) as ydl: # type: ignore[arg-type]
|
with yt_dlp.YoutubeDL(ytdl_options) as ydl: # type: ignore[arg-type]
|
||||||
info = ydl.extract_info(opts.url, download=True)
|
info = ydl.extract_info(opts.url, download=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -650,6 +770,75 @@ def download_media(
|
|||||||
)
|
)
|
||||||
raise DownloadError("yt-dlp download failed") from exc
|
raise DownloadError("yt-dlp download failed") from exc
|
||||||
|
|
||||||
|
# If we used subprocess, we need to find the file manually
|
||||||
|
if info is None:
|
||||||
|
# Find files created/modified during this download (after we started)
|
||||||
|
# Look for files matching the expected output template pattern
|
||||||
|
try:
|
||||||
|
import glob
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Get the expected filename pattern from outtmpl
|
||||||
|
# For sections: "C:\path\title.section_1_of_3.ext", "C:\path\title.section_2_of_3.ext", etc.
|
||||||
|
# For non-sections: "C:\path\title.ext"
|
||||||
|
|
||||||
|
# Wait a moment to ensure files are fully written
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# List all files in output_dir, sorted by modification time
|
||||||
|
files = sorted(opts.output_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
if not files:
|
||||||
|
raise FileNotFoundError(f"No files found in {opts.output_dir}")
|
||||||
|
|
||||||
|
# If we downloaded sections, look for files with .section_N_of_M pattern
|
||||||
|
if opts.clip_sections:
|
||||||
|
# Pattern: "title.section_1_of_3.ext", "title.section_2_of_3.ext", etc.
|
||||||
|
section_pattern = re.compile(r'\.section_(\d+)_of_(\d+)\.')
|
||||||
|
matching_files = [f for f in files if section_pattern.search(f.name)]
|
||||||
|
|
||||||
|
if matching_files:
|
||||||
|
# Sort by section number to ensure correct order
|
||||||
|
def extract_section_num(path: Path) -> int:
|
||||||
|
match = section_pattern.search(path.name)
|
||||||
|
return int(match.group(1)) if match else 999
|
||||||
|
|
||||||
|
matching_files.sort(key=extract_section_num)
|
||||||
|
media_path = matching_files[0] # First section
|
||||||
|
media_paths = matching_files # All sections
|
||||||
|
debug(f"✓ Downloaded {len(media_paths)} section file(s)")
|
||||||
|
else:
|
||||||
|
# Fallback to most recent file if pattern not found
|
||||||
|
media_path = files[0]
|
||||||
|
media_paths = None
|
||||||
|
else:
|
||||||
|
# No sections, just take the most recent file
|
||||||
|
media_path = files[0]
|
||||||
|
media_paths = None
|
||||||
|
|
||||||
|
debug(f"✓ Downloaded: {media_path.name}")
|
||||||
|
if debug_logger is not None:
|
||||||
|
debug_logger.write_record("ytdlp-file-found", {"path": str(media_path)})
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"Error finding downloaded file: {exc}", file=sys.stderr)
|
||||||
|
if debug_logger is not None:
|
||||||
|
debug_logger.write_record(
|
||||||
|
"exception",
|
||||||
|
{"phase": "find-file", "error": str(exc)},
|
||||||
|
)
|
||||||
|
raise DownloadError(str(exc)) from exc
|
||||||
|
|
||||||
|
# Create result with minimal data extracted from filename
|
||||||
|
file_hash = sha256_file(media_path)
|
||||||
|
return DownloadMediaResult(
|
||||||
|
path=media_path,
|
||||||
|
info={"id": media_path.stem, "title": media_path.stem, "ext": media_path.suffix.lstrip(".")},
|
||||||
|
tags=[],
|
||||||
|
source_url=opts.url,
|
||||||
|
hash_value=file_hash,
|
||||||
|
paths=media_paths, # Include all section files if present
|
||||||
|
)
|
||||||
|
|
||||||
if not isinstance(info, dict):
|
if not isinstance(info, dict):
|
||||||
log(f"Unexpected yt-dlp response: {type(info)}", file=sys.stderr)
|
log(f"Unexpected yt-dlp response: {type(info)}", file=sys.stderr)
|
||||||
raise DownloadError("Unexpected yt-dlp response type")
|
raise DownloadError("Unexpected yt-dlp response type")
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ class MPVIPCClient:
|
|||||||
|
|
||||||
payload = json.dumps(request) + "\n"
|
payload = json.dumps(request) + "\n"
|
||||||
|
|
||||||
|
# Debug: log the command being sent
|
||||||
|
from helper.logger import debug as _debug
|
||||||
|
_debug(f"[IPC] Sending: {payload.strip()}")
|
||||||
|
|
||||||
# Send command
|
# Send command
|
||||||
if self.is_windows:
|
if self.is_windows:
|
||||||
self.sock.write(payload.encode('utf-8'))
|
self.sock.write(payload.encode('utf-8'))
|
||||||
@@ -160,6 +164,10 @@ class MPVIPCClient:
|
|||||||
if not line: continue
|
if not line: continue
|
||||||
resp = json.loads(line)
|
resp = json.loads(line)
|
||||||
|
|
||||||
|
# Debug: log responses
|
||||||
|
from helper.logger import debug as _debug
|
||||||
|
_debug(f"[IPC] Received: {line}")
|
||||||
|
|
||||||
# Check if this is the response to our request
|
# Check if this is the response to our request
|
||||||
if resp.get("request_id") == request.get("request_id"):
|
if resp.get("request_id") == request.get("request_id"):
|
||||||
return resp
|
return resp
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ _MATRIX_AVAILABLE: Optional[bool] = None
|
|||||||
_MATRIX_UNAVAILABLE_REASON: Optional[str] = None
|
_MATRIX_UNAVAILABLE_REASON: Optional[str] = None
|
||||||
_MATRIX_CHECK_COMPLETE = False
|
_MATRIX_CHECK_COMPLETE = False
|
||||||
|
|
||||||
|
# Global state for Cookies availability
|
||||||
|
_COOKIES_FILE_PATH: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def check_hydrus_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
def check_hydrus_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
||||||
"""Check if Hydrus API is available by pinging it.
|
"""Check if Hydrus API is available by pinging it.
|
||||||
@@ -575,3 +578,24 @@ def initialize_local_library_scan(config: Dict[str, Any]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Startup] Failed to scan local library: {e}", exc_info=True)
|
logger.error(f"[Startup] Failed to scan local library: {e}", exc_info=True)
|
||||||
debug(f"⚠️ Local Library: ERROR - Scan failed: {e}", file=sys.stderr)
|
debug(f"⚠️ Local Library: ERROR - Scan failed: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_cookies_check() -> None:
|
||||||
|
"""Check for cookies.txt in the application root directory."""
|
||||||
|
global _COOKIES_FILE_PATH
|
||||||
|
|
||||||
|
# Assume CLI.py is in the root
|
||||||
|
root_dir = Path(__file__).parent
|
||||||
|
cookies_path = root_dir / "cookies.txt"
|
||||||
|
|
||||||
|
if cookies_path.exists():
|
||||||
|
_COOKIES_FILE_PATH = str(cookies_path)
|
||||||
|
debug(f"✅ Cookies: ENABLED - Found cookies.txt", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
_COOKIES_FILE_PATH = None
|
||||||
|
# debug("ℹ️ Cookies: Using browser cookies (fallback)", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cookies_file_path() -> Optional[str]:
|
||||||
|
"""Get the path to the cookies.txt file if it exists."""
|
||||||
|
return _COOKIES_FILE_PATH
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ class DownloadMediaResult:
|
|||||||
tags: List[str]
|
tags: List[str]
|
||||||
source_url: Optional[str]
|
source_url: Optional[str]
|
||||||
hash_value: Optional[str] = None
|
hash_value: Optional[str] = None
|
||||||
|
paths: Optional[List[Path]] = None # For multiple files (e.g., section downloads)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user