2025-11-25 20:09:33 -08:00
|
|
|
"""Download request modal screen for initiating new downloads.
|
|
|
|
|
|
|
|
|
|
This modal allows users to specify:
|
|
|
|
|
- URL or search query (paragraph)
|
|
|
|
|
- Tags to apply
|
|
|
|
|
- Source (Hydrus, local, AllDebrid, etc.)
|
|
|
|
|
- Actions (download, screenshot)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from textual.app import ComposeResult
|
|
|
|
|
from textual.screen import ModalScreen
|
|
|
|
|
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
2025-12-29 17:05:03 -08:00
|
|
|
from textual.widgets import (
|
|
|
|
|
Static,
|
|
|
|
|
Button,
|
|
|
|
|
Label,
|
|
|
|
|
Select,
|
|
|
|
|
Checkbox,
|
|
|
|
|
TextArea,
|
|
|
|
|
ProgressBar,
|
|
|
|
|
Tree,
|
|
|
|
|
Input,
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
from textual.binding import Binding
|
|
|
|
|
from textual import work
|
|
|
|
|
import logging
|
|
|
|
|
from typing import Optional, Callable, Any
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import sys
|
|
|
|
|
|
2025-12-11 19:04:02 -08:00
|
|
|
from SYS.logger import log
|
2025-11-25 20:09:33 -08:00
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
# Add parent directory to path for imports
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
# Import cmdlet system to call get-tag
|
2025-11-25 20:09:33 -08:00
|
|
|
try:
|
2025-12-12 21:55:38 -08:00
|
|
|
from cmdlet import get as get_cmdlet
|
2025-11-25 20:09:33 -08:00
|
|
|
except ImportError:
|
|
|
|
|
get_cmdlet = None
|
|
|
|
|
|
|
|
|
|
# Import tag processing helpers
|
|
|
|
|
try:
|
|
|
|
|
from metadata import expand_tag_lists, process_tags_from_string
|
|
|
|
|
except ImportError:
|
|
|
|
|
expand_tag_lists = None
|
|
|
|
|
process_tags_from_string = None
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DownloadModal(ModalScreen):
|
|
|
|
|
"""Modal screen for initiating new download requests."""
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
BINDINGS = [
|
|
|
|
|
Binding("escape", "cancel", "Cancel"),
|
|
|
|
|
Binding("ctrl+enter", "submit", "Submit"),
|
|
|
|
|
]
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
CSS_PATH = "download.tcss"
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
on_submit: Optional[Callable[[dict], None]] = None,
|
|
|
|
|
available_sources: Optional[list] = None,
|
2025-12-29 17:05:03 -08:00
|
|
|
config: Optional[dict] = None,
|
2025-11-25 20:09:33 -08:00
|
|
|
):
|
|
|
|
|
"""Initialize the download modal.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Args:
|
|
|
|
|
on_submit: Callback function that receives download request dict
|
|
|
|
|
available_sources: List of available source names (e.g., ['hydrus', 'local', 'alldebrid'])
|
|
|
|
|
config: Configuration dict with download settings
|
|
|
|
|
"""
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.on_submit = on_submit
|
2025-12-29 17:05:03 -08:00
|
|
|
self.available_sources = available_sources or ["hydrus", "local", "alldebrid"]
|
2025-11-25 20:09:33 -08:00
|
|
|
self.config = config or {}
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# UI Component references
|
|
|
|
|
self.paragraph_textarea: TextArea = None # type: ignore
|
|
|
|
|
self.tags_textarea: TextArea = None # type: ignore
|
|
|
|
|
self.source_select: Select = None # type: ignore
|
|
|
|
|
self.files_select: Select = None # type: ignore
|
|
|
|
|
self.download_checkbox: Checkbox = None # type: ignore
|
|
|
|
|
self.screenshot_checkbox: Checkbox = None # type: ignore
|
|
|
|
|
self.progress_bar: ProgressBar = None # type: ignore
|
|
|
|
|
self.selected_files: set = set() # Track selected files
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Playlist support
|
|
|
|
|
self.playlist_tree: Tree = None # type: ignore
|
|
|
|
|
self.playlist_input: Input = None # type: ignore
|
|
|
|
|
self.playlist_merge_checkbox: Checkbox = None # type: ignore
|
|
|
|
|
self.is_playlist: bool = False # Track if current URL is a playlist
|
|
|
|
|
self.playlist_items: list = [] # Store playlist items
|
|
|
|
|
|
|
|
|
|
def compose(self) -> ComposeResult:
|
|
|
|
|
"""Compose the download request modal."""
|
|
|
|
|
yield Vertical(
|
|
|
|
|
# Title
|
|
|
|
|
Static("📥 New Download Request", id="download_title"),
|
|
|
|
|
# Main layout: Horizontal split into left and right columns
|
|
|
|
|
Horizontal(
|
|
|
|
|
# Left column: URL (top) and Tags (bottom)
|
|
|
|
|
Vertical(
|
|
|
|
|
Container(
|
|
|
|
|
TextArea(
|
|
|
|
|
id="paragraph_textarea",
|
|
|
|
|
language="",
|
|
|
|
|
show_line_numbers=True,
|
|
|
|
|
),
|
|
|
|
|
id="url_container",
|
|
|
|
|
classes="grid_container",
|
|
|
|
|
),
|
|
|
|
|
Container(
|
|
|
|
|
TextArea(
|
|
|
|
|
id="tags_textarea",
|
|
|
|
|
language="",
|
|
|
|
|
show_line_numbers=True,
|
|
|
|
|
),
|
|
|
|
|
id="tags_container",
|
2025-12-29 17:05:03 -08:00
|
|
|
classes="grid_container",
|
2025-11-25 20:09:33 -08:00
|
|
|
),
|
2025-12-29 17:05:03 -08:00
|
|
|
id="left_column",
|
2025-11-25 20:09:33 -08:00
|
|
|
),
|
|
|
|
|
# Right column: Files/Playlist
|
|
|
|
|
Vertical(
|
|
|
|
|
# Formats Select (for single files)
|
|
|
|
|
Container(
|
|
|
|
|
Select(
|
|
|
|
|
id="files_select",
|
|
|
|
|
options=[], # Populated dynamically
|
|
|
|
|
),
|
|
|
|
|
id="files_container",
|
2025-12-29 17:05:03 -08:00
|
|
|
classes="grid_container",
|
2025-11-25 20:09:33 -08:00
|
|
|
),
|
|
|
|
|
# Playlist Tree + Input + Merge (for playlists)
|
|
|
|
|
Container(
|
|
|
|
|
Vertical(
|
|
|
|
|
Tree(
|
|
|
|
|
"Playlist",
|
|
|
|
|
id="playlist_tree",
|
|
|
|
|
),
|
|
|
|
|
Horizontal(
|
|
|
|
|
Input(
|
|
|
|
|
placeholder="Track selection (e.g., 1-3, all, merge, 1 5 8)",
|
|
|
|
|
id="playlist_input",
|
|
|
|
|
),
|
|
|
|
|
Checkbox(
|
|
|
|
|
label="Merge",
|
|
|
|
|
id="playlist_merge_checkbox",
|
|
|
|
|
value=False,
|
|
|
|
|
),
|
2025-12-29 17:05:03 -08:00
|
|
|
id="playlist_input_row",
|
2025-11-25 20:09:33 -08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
id="playlist_container",
|
2025-12-29 17:05:03 -08:00
|
|
|
classes="grid_container",
|
2025-11-25 20:09:33 -08:00
|
|
|
),
|
2025-12-29 17:05:03 -08:00
|
|
|
id="right_column",
|
2025-11-25 20:09:33 -08:00
|
|
|
),
|
2025-12-29 17:05:03 -08:00
|
|
|
id="main_layout",
|
2025-11-25 20:09:33 -08:00
|
|
|
),
|
|
|
|
|
# Footer: All on one row - Checkboxes left, Source middle, Buttons right
|
|
|
|
|
Horizontal(
|
|
|
|
|
# Left: Checkboxes
|
|
|
|
|
Container(
|
|
|
|
|
Checkbox(label="Download", id="download_checkbox"),
|
|
|
|
|
Checkbox(label="Screenshot", id="screenshot_checkbox"),
|
2025-12-29 17:05:03 -08:00
|
|
|
id="checkbox_row",
|
2025-11-25 20:09:33 -08:00
|
|
|
),
|
|
|
|
|
# Middle: Source selector
|
2025-12-29 17:05:03 -08:00
|
|
|
Select(id="source_select", options=self._build_source_options()),
|
2025-11-25 20:09:33 -08:00
|
|
|
# Progress bar (shown during download)
|
|
|
|
|
ProgressBar(id="progress_bar"),
|
|
|
|
|
# Right: Buttons
|
|
|
|
|
Horizontal(
|
|
|
|
|
Button("Cancel", id="cancel_btn", variant="default"),
|
|
|
|
|
Button("Submit", id="submit_btn", variant="primary"),
|
2025-12-29 17:05:03 -08:00
|
|
|
id="button_row",
|
2025-11-25 20:09:33 -08:00
|
|
|
),
|
|
|
|
|
id="footer_layout",
|
2025-12-29 17:05:03 -08:00
|
|
|
classes="modal_footer",
|
2025-11-25 20:09:33 -08:00
|
|
|
),
|
|
|
|
|
id="download_modal",
|
2025-12-29 17:05:03 -08:00
|
|
|
classes="modal_vertical",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _build_source_options(self) -> list[tuple[str, str]]:
|
|
|
|
|
"""Build source select options.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Returns:
|
|
|
|
|
List of (label, value) tuples for Select widget
|
|
|
|
|
"""
|
|
|
|
|
source_icons = {
|
2025-12-29 17:05:03 -08:00
|
|
|
"hydrus": "🗃️ Hydrus",
|
|
|
|
|
"local": "📁 Local",
|
|
|
|
|
"alldebrid": "☁️ AllDebrid",
|
|
|
|
|
"debrid": "☁️ Debrid",
|
|
|
|
|
"soulseek": "🎵 Soulseek",
|
|
|
|
|
"libgen": "📚 LibGen",
|
2025-11-25 20:09:33 -08:00
|
|
|
}
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
options = []
|
|
|
|
|
for source in self.available_sources:
|
|
|
|
|
label = source_icons.get(source.lower(), source)
|
|
|
|
|
options.append((label, source))
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
return options
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def on_mount(self) -> None:
|
|
|
|
|
"""Called when the modal is mounted."""
|
|
|
|
|
# Get references to widgets
|
|
|
|
|
self.paragraph_textarea = self.query_one("#paragraph_textarea", TextArea)
|
|
|
|
|
self.tags_textarea = self.query_one("#tags_textarea", TextArea)
|
|
|
|
|
self.source_select = self.query_one("#source_select", Select)
|
|
|
|
|
self.files_select = self.query_one("#files_select", Select)
|
|
|
|
|
self.download_checkbox = self.query_one("#download_checkbox", Checkbox)
|
|
|
|
|
self.screenshot_checkbox = self.query_one("#screenshot_checkbox", Checkbox)
|
|
|
|
|
self.progress_bar = self.query_one("#progress_bar", ProgressBar)
|
|
|
|
|
self.playlist_tree = self.query_one("#playlist_tree", Tree)
|
|
|
|
|
self.playlist_input = self.query_one("#playlist_input", Input)
|
|
|
|
|
self.playlist_merge_checkbox = self.query_one("#playlist_merge_checkbox", Checkbox)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Set default actions
|
|
|
|
|
self.download_checkbox.value = True
|
|
|
|
|
self.screenshot_checkbox.value = False
|
|
|
|
|
self.playlist_merge_checkbox.value = False
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
# Initialize PDF playlist url (set by _handle_pdf_playlist)
|
|
|
|
|
self.pdf_url = []
|
2025-11-25 20:09:33 -08:00
|
|
|
self.is_pdf_playlist = False
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Hide playlist by default (show format select)
|
|
|
|
|
self._show_format_select()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Focus on tags textarea
|
|
|
|
|
self.tags_textarea.focus()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.debug("Download modal mounted")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def action_submit(self) -> None:
|
|
|
|
|
"""Submit the download request by executing cmdlet pipeline in background."""
|
|
|
|
|
# Validate and get values first (on main thread)
|
|
|
|
|
url = self.paragraph_textarea.text.strip()
|
|
|
|
|
tags_str = self.tags_textarea.text.strip()
|
2025-12-29 17:05:03 -08:00
|
|
|
source = self.source_select.value or "local"
|
2025-11-25 20:09:33 -08:00
|
|
|
download_enabled = self.download_checkbox.value
|
|
|
|
|
merge_enabled = self.playlist_merge_checkbox.value if self.is_playlist else False
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if not url:
|
|
|
|
|
logger.warning("Download request missing URL")
|
2025-12-29 17:05:03 -08:00
|
|
|
self.app.notify("URL is required", title="Missing Input", severity="warning")
|
2025-11-25 20:09:33 -08:00
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Parse tags (one per line)
|
|
|
|
|
tags = []
|
|
|
|
|
if tags_str:
|
2025-12-29 17:05:03 -08:00
|
|
|
tags = [tag.strip() for tag in tags_str.split("\n") if tag.strip()]
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Get playlist selection if this is a playlist
|
|
|
|
|
playlist_selection = ""
|
|
|
|
|
if self.is_playlist and not self.is_pdf_playlist:
|
|
|
|
|
# Regular playlist (non-PDF)
|
|
|
|
|
playlist_selection = self.playlist_input.value.strip()
|
|
|
|
|
if not playlist_selection:
|
|
|
|
|
# No selection provided - default to downloading all tracks
|
|
|
|
|
playlist_selection = f"1-{len(self.playlist_items)}"
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"No selection provided, defaulting to all tracks: {playlist_selection}"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
elif self.is_playlist and self.is_pdf_playlist:
|
|
|
|
|
# PDF playlist - handle selection
|
|
|
|
|
playlist_selection = self.playlist_input.value.strip()
|
|
|
|
|
if not playlist_selection:
|
|
|
|
|
# No selection provided - default to all PDFs
|
|
|
|
|
playlist_selection = f"1-{len(self.playlist_items)}"
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"PDF playlist: no selection provided, defaulting to all PDFs: {playlist_selection}"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
merge_enabled = True # Always merge PDFs if multiple selected
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Launch the background worker with PDF playlist info
|
2025-12-29 17:05:03 -08:00
|
|
|
self._submit_worker(
|
|
|
|
|
url,
|
|
|
|
|
tags,
|
|
|
|
|
source,
|
|
|
|
|
download_enabled,
|
|
|
|
|
playlist_selection,
|
|
|
|
|
merge_enabled,
|
|
|
|
|
is_pdf_playlist=self.is_pdf_playlist,
|
|
|
|
|
pdf_url=self.pdf_url if self.is_pdf_playlist else [],
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
@work(thread=True)
|
2025-12-29 17:05:03 -08:00
|
|
|
def _submit_worker(
|
|
|
|
|
self,
|
|
|
|
|
url: str,
|
|
|
|
|
tags: list,
|
|
|
|
|
source: str,
|
|
|
|
|
download_enabled: bool,
|
|
|
|
|
playlist_selection: str = "",
|
|
|
|
|
merge_enabled: bool = False,
|
|
|
|
|
is_pdf_playlist: bool = False,
|
|
|
|
|
pdf_url: Optional[list] = None,
|
|
|
|
|
) -> None:
|
2025-11-25 20:09:33 -08:00
|
|
|
"""Background worker to execute the cmdlet pipeline.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Args:
|
|
|
|
|
url: URL to download
|
|
|
|
|
tags: List of tags to apply
|
|
|
|
|
source: Source for metadata
|
|
|
|
|
download_enabled: Whether to download the file
|
|
|
|
|
playlist_selection: Playlist track selection (e.g., "1-3", "all", "merge")
|
|
|
|
|
merge_enabled: Whether to merge playlist files after download
|
|
|
|
|
is_pdf_playlist: Whether this is a PDF pseudo-playlist
|
2025-12-11 12:47:30 -08:00
|
|
|
pdf_url: List of PDF url if is_pdf_playlist is True
|
2025-11-25 20:09:33 -08:00
|
|
|
"""
|
2025-12-11 12:47:30 -08:00
|
|
|
if pdf_url is None:
|
|
|
|
|
pdf_url = []
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Initialize worker to None so outer exception handler can check it
|
|
|
|
|
worker = None
|
|
|
|
|
try:
|
|
|
|
|
# Show progress bar on main thread
|
|
|
|
|
self.app.call_from_thread(self._show_progress)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Building cmdlet pipeline: URL={url}, tags={len(tags)}, source={source}, download={download_enabled}, playlist_selection={playlist_selection}"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Create a worker instance using the app's helper method
|
|
|
|
|
worker = None
|
|
|
|
|
try:
|
2025-12-29 17:05:03 -08:00
|
|
|
if hasattr(self.app, "create_worker"):
|
2025-11-25 20:09:33 -08:00
|
|
|
worker = self.app.create_worker(
|
2025-12-29 17:05:03 -08:00
|
|
|
"download",
|
2025-11-25 20:09:33 -08:00
|
|
|
title=f"Download: {url[:50]}",
|
2025-12-29 17:05:03 -08:00
|
|
|
description=f"Tags: {', '.join(tags) if tags else 'None'}",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# Fallback if helper not available
|
|
|
|
|
import uuid
|
2025-12-11 19:04:02 -08:00
|
|
|
from SYS.worker_manager import Worker
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
worker_id = f"dl_{uuid.uuid4().hex[:8]}"
|
2025-12-29 17:05:03 -08:00
|
|
|
worker = Worker(
|
|
|
|
|
worker_id,
|
|
|
|
|
"download",
|
|
|
|
|
f"Download: {url[:50]}",
|
|
|
|
|
f"Tags: {', '.join(tags) if tags else 'None'}",
|
|
|
|
|
None,
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error creating worker: {e}")
|
|
|
|
|
worker = None
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log initial step
|
|
|
|
|
if worker:
|
|
|
|
|
worker.log_step("Download initiated")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Handle PDF playlist specially
|
2025-12-11 12:47:30 -08:00
|
|
|
if is_pdf_playlist and pdf_url:
|
|
|
|
|
logger.info(f"Processing PDF playlist with {len(pdf_url)} PDFs")
|
|
|
|
|
self._handle_pdf_playlist_download(pdf_url, tags, playlist_selection, merge_enabled)
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.call_from_thread(self._hide_progress)
|
|
|
|
|
self.app.call_from_thread(self.dismiss)
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Build the cmdlet pipeline
|
|
|
|
|
# Start with URL as initial object
|
|
|
|
|
result_obj = self._create_url_result(url)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Import cmdlet system
|
|
|
|
|
if not get_cmdlet:
|
2025-12-12 21:55:38 -08:00
|
|
|
logger.error("cmdlet module not available")
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.call_from_thread(
|
2025-12-29 17:05:03 -08:00
|
|
|
self.app.notify, "cmdlet system unavailable", title="Error", severity="error"
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
self.app.call_from_thread(self._hide_progress)
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-27 21:24:27 -08:00
|
|
|
# Stage 1: Download if enabled
|
2025-11-25 20:09:33 -08:00
|
|
|
download_succeeded = False
|
|
|
|
|
download_stderr_text = "" # Store for merge stage
|
|
|
|
|
if download_enabled:
|
2025-12-27 21:24:27 -08:00
|
|
|
download_cmdlet_name = "download-media" if self.is_playlist else "download-file"
|
|
|
|
|
download_cmdlet = get_cmdlet(download_cmdlet_name)
|
2025-11-25 20:09:33 -08:00
|
|
|
if download_cmdlet:
|
2025-12-27 21:24:27 -08:00
|
|
|
logger.info(f"📥 Executing {download_cmdlet_name} stage")
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f"download_cmdlet object: {download_cmdlet}")
|
|
|
|
|
logger.info(f"result_obj: {result_obj}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log step to worker
|
|
|
|
|
if worker:
|
2025-12-27 21:24:27 -08:00
|
|
|
worker.log_step(f"Starting {download_cmdlet_name} stage...")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-27 21:24:27 -08:00
|
|
|
# Build arguments for download-media (yt-dlp) playlists; download-file takes no yt-dlp args.
|
2025-11-25 20:09:33 -08:00
|
|
|
cmdlet_args = []
|
2025-12-27 21:24:27 -08:00
|
|
|
if download_cmdlet_name == "download-media" and self.is_playlist:
|
2025-11-25 20:09:33 -08:00
|
|
|
# Always use yt-dlp's native --playlist-items for playlists
|
|
|
|
|
if playlist_selection:
|
|
|
|
|
# User provided specific selection
|
|
|
|
|
ytdlp_selection = self._convert_selection_to_ytdlp(playlist_selection)
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Playlist with user selection: {playlist_selection} → {ytdlp_selection}"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
|
|
|
|
# No selection provided, download all
|
|
|
|
|
ytdlp_selection = f"1-{len(self.playlist_items)}"
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Playlist mode: downloading all {len(self.playlist_items)} items"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
cmdlet_args = ["--playlist-items", ytdlp_selection]
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f"Built cmdlet_args: {cmdlet_args}")
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"About to call download_cmdlet({result_obj}, {cmdlet_args}, {type(self.config).__name__})"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"📥 Downloading from: {url}\n")
|
|
|
|
|
if cmdlet_args:
|
|
|
|
|
worker.append_stdout(f" Args: {cmdlet_args}\n")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
try:
|
|
|
|
|
# Capture output from the cmdlet using temp files (more reliable than redirect)
|
|
|
|
|
import tempfile
|
|
|
|
|
import subprocess
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Try normal redirect first
|
|
|
|
|
import io
|
|
|
|
|
from contextlib import redirect_stdout, redirect_stderr
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
stdout_buf = io.StringIO()
|
|
|
|
|
stderr_buf = io.StringIO()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Always capture output
|
|
|
|
|
try:
|
|
|
|
|
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
|
|
|
|
logger.info(f"Calling download_cmdlet...")
|
2025-12-29 17:05:03 -08:00
|
|
|
cmd_config = (
|
|
|
|
|
dict(self.config)
|
|
|
|
|
if isinstance(self.config, dict)
|
|
|
|
|
else self.config
|
|
|
|
|
)
|
2025-12-27 21:24:27 -08:00
|
|
|
if isinstance(cmd_config, dict):
|
|
|
|
|
cmd_config["_quiet_background_output"] = True
|
|
|
|
|
returncode = download_cmdlet(result_obj, cmdlet_args, cmd_config)
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f"download_cmdlet returned: {returncode}")
|
|
|
|
|
except Exception as cmdlet_error:
|
|
|
|
|
# If cmdlet throws an exception, log it
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.error(
|
|
|
|
|
f"❌ download-cmdlet exception: {cmdlet_error}", exc_info=True
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
if worker:
|
|
|
|
|
import traceback
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
worker.append_stdout(
|
|
|
|
|
f"❌ download-cmdlet exception: {cmdlet_error}\n{traceback.format_exc()}\n"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
returncode = 1
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
stdout_text = stdout_buf.getvalue()
|
|
|
|
|
stderr_text = stderr_buf.getvalue()
|
|
|
|
|
download_stderr_text = stderr_text # Save for merge stage
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log raw output
|
|
|
|
|
logger.info(f"download-cmdlet returncode: {returncode}")
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"stdout ({len(stdout_text)} chars): {stdout_text[:200] if stdout_text else '(empty)'}"
|
|
|
|
|
)
|
|
|
|
|
logger.info(
|
|
|
|
|
f"stderr ({len(stderr_text)} chars): {stderr_text[:200] if stderr_text else '(empty)'}"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Always append output to worker for debugging
|
|
|
|
|
if worker:
|
|
|
|
|
if stdout_text:
|
2025-12-29 17:05:03 -08:00
|
|
|
worker.append_stdout(
|
|
|
|
|
f"[{download_cmdlet_name} stdout]\n{stdout_text}\n"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
if stderr_text:
|
2025-12-29 17:05:03 -08:00
|
|
|
worker.append_stdout(
|
|
|
|
|
f"[{download_cmdlet_name} stderr]\n{stderr_text}\n"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log the output so it gets captured by WorkerLoggingHandler
|
|
|
|
|
if stdout_text:
|
2025-12-27 21:24:27 -08:00
|
|
|
logger.info(f"[{download_cmdlet_name} output]\n{stdout_text}")
|
2025-11-25 20:09:33 -08:00
|
|
|
if stderr_text:
|
2025-12-27 21:24:27 -08:00
|
|
|
logger.info(f"[{download_cmdlet_name} stderr]\n{stderr_text}")
|
2025-11-25 20:09:33 -08:00
|
|
|
if returncode != 0:
|
2025-12-27 21:24:27 -08:00
|
|
|
download_failed_msg = f"❌ {download_cmdlet_name} stage failed with code {returncode}\nstdout: {stdout_text}\nstderr: {stderr_text}"
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.error(download_failed_msg)
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"\n{download_failed_msg}\n")
|
2025-12-29 17:05:03 -08:00
|
|
|
worker.finish(
|
|
|
|
|
"error", "Download stage failed - see logs above for details"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log to stderr as well so it shows in terminal
|
|
|
|
|
log(f"Return code: {returncode}", file=sys.stderr)
|
|
|
|
|
log(f"stdout:\n{stdout_text}", file=sys.stderr)
|
|
|
|
|
log(f"stderr:\n{stderr_text}", file=sys.stderr)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Extract error reason from stderr/stdout for user notification
|
|
|
|
|
# Try to extract meaningful error from yt-dlp output
|
|
|
|
|
error_reason = "Unknown error"
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Search for yt-dlp error patterns (case-insensitive)
|
|
|
|
|
error_text = (stderr_text + "\n" + stdout_text).lower()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Look for specific error keywords in priority order
|
|
|
|
|
if "http error 403" in error_text or "error 403" in error_text:
|
|
|
|
|
error_reason = "HTTP 403: Access forbidden (YouTube blocked download, may be georestricted or SABR issue)"
|
|
|
|
|
elif "http error 401" in error_text or "error 401" in error_text:
|
2025-12-29 17:05:03 -08:00
|
|
|
error_reason = (
|
|
|
|
|
"HTTP 401: Authentication required (may need login credentials)"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
elif "http error 404" in error_text or "error 404" in error_text:
|
2025-12-29 17:05:03 -08:00
|
|
|
error_reason = (
|
|
|
|
|
"HTTP 404: URL not found (video/content may have been deleted)"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
elif "http error" in error_text:
|
|
|
|
|
# Extract the actual HTTP error code
|
|
|
|
|
import re
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
http_match = re.search(
|
|
|
|
|
r"HTTP Error (\d{3})", stderr_text + stdout_text, re.IGNORECASE
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
if http_match:
|
|
|
|
|
error_reason = f"HTTP Error {http_match.group(1)}: Server returned an error"
|
|
|
|
|
else:
|
|
|
|
|
error_reason = "HTTP error from server"
|
2025-12-29 17:05:03 -08:00
|
|
|
elif (
|
|
|
|
|
"no such file or directory" in error_text
|
|
|
|
|
or "file not found" in error_text
|
|
|
|
|
):
|
|
|
|
|
error_reason = (
|
|
|
|
|
"File not found (yt-dlp may not be installed or not in PATH)"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
elif "unable to download" in error_text:
|
|
|
|
|
error_reason = "Unable to download video (network issue or content unavailable)"
|
2025-12-29 17:05:03 -08:00
|
|
|
elif (
|
|
|
|
|
"connection" in error_text
|
|
|
|
|
or "timeout" in error_text
|
|
|
|
|
or "timed out" in error_text
|
|
|
|
|
):
|
2025-11-25 20:09:33 -08:00
|
|
|
error_reason = "Network connection failed or timed out"
|
|
|
|
|
elif "permission" in error_text or "access denied" in error_text:
|
2025-12-29 17:05:03 -08:00
|
|
|
error_reason = (
|
|
|
|
|
"Permission denied (may need elevated privileges or login)"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
elif "private video" in error_text or "private" in error_text:
|
|
|
|
|
error_reason = "Video is private (not accessible)"
|
|
|
|
|
elif "age restricted" in error_text or "age gate" in error_text:
|
|
|
|
|
error_reason = "Video is age-restricted and requires login"
|
|
|
|
|
elif "region restricted" in error_text or "georestrict" in error_text:
|
2025-12-29 17:05:03 -08:00
|
|
|
error_reason = (
|
|
|
|
|
"Video is region-restricted (not available in your country)"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
elif "member-only" in error_text or "members only" in error_text:
|
|
|
|
|
error_reason = "Video is available to members only"
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# If still unknown, try to extract last line of stderr as it often contains the actual error
|
|
|
|
|
if error_reason == "Unknown error":
|
2025-12-29 17:05:03 -08:00
|
|
|
stderr_lines = [
|
|
|
|
|
line.strip() for line in stderr_text.split("\n") if line.strip()
|
|
|
|
|
]
|
2025-11-25 20:09:33 -08:00
|
|
|
if stderr_lines:
|
|
|
|
|
# Look for error-like lines (usually contain "error", "failed", "ERROR", etc)
|
|
|
|
|
for line in reversed(stderr_lines):
|
2025-12-29 17:05:03 -08:00
|
|
|
if any(
|
|
|
|
|
keyword in line.lower()
|
|
|
|
|
for keyword in [
|
|
|
|
|
"error",
|
|
|
|
|
"failed",
|
|
|
|
|
"exception",
|
|
|
|
|
"traceback",
|
|
|
|
|
"warning",
|
|
|
|
|
]
|
|
|
|
|
):
|
2025-11-25 20:09:33 -08:00
|
|
|
error_reason = line[:150] # Limit to 150 chars
|
|
|
|
|
break
|
|
|
|
|
# If no error keyword found, use the last line
|
|
|
|
|
if error_reason == "Unknown error":
|
|
|
|
|
error_reason = stderr_lines[-1][:150]
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log the extracted error reason for debugging
|
|
|
|
|
logger.error(f"Extracted error reason: {error_reason}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Download failed: {error_reason}",
|
|
|
|
|
title="Download Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
# Finish worker with error status
|
|
|
|
|
try:
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.finish_worker,
|
|
|
|
|
worker_id,
|
|
|
|
|
"error",
|
2025-12-29 17:05:03 -08:00
|
|
|
f"Download failed: {error_reason}",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Also append detailed error info to worker stdout for visibility
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"\n❌ DOWNLOAD FAILED\n")
|
|
|
|
|
worker.append_stdout(f"Reason: {error_reason}\n")
|
|
|
|
|
if stderr_text and stderr_text.strip():
|
|
|
|
|
worker.append_stdout(f"\nFull error output:\n{stderr_text}\n")
|
|
|
|
|
if stdout_text and stdout_text.strip():
|
|
|
|
|
worker.append_stdout(f"\nStandard output:\n{stdout_text}\n")
|
|
|
|
|
# Don't try to tag if download failed
|
|
|
|
|
self.app.call_from_thread(self._hide_progress)
|
|
|
|
|
self.app.call_from_thread(self.dismiss)
|
|
|
|
|
return
|
|
|
|
|
else:
|
|
|
|
|
download_succeeded = True
|
|
|
|
|
# Always log output at INFO level so we can see what happened
|
2025-12-27 21:24:27 -08:00
|
|
|
logger.info(f"{download_cmdlet_name} stage completed successfully")
|
2025-11-25 20:09:33 -08:00
|
|
|
if stdout_text:
|
2025-12-27 21:24:27 -08:00
|
|
|
logger.info(f"{download_cmdlet_name} stdout:\n{stdout_text}")
|
2025-11-25 20:09:33 -08:00
|
|
|
if stderr_text:
|
2025-12-27 21:24:27 -08:00
|
|
|
logger.info(f"{download_cmdlet_name} stderr:\n{stderr_text}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log step to worker
|
|
|
|
|
if worker:
|
2025-12-29 17:05:03 -08:00
|
|
|
worker.log_step(
|
|
|
|
|
f"Download completed: {len(stdout_text.split('Saved to')) - 1} items downloaded"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# For playlists with merge enabled, scan the output directory for ALL downloaded files
|
|
|
|
|
# instead of trying to parse individual "Saved to" lines
|
|
|
|
|
downloaded_files = []
|
|
|
|
|
if self.is_playlist and merge_enabled:
|
|
|
|
|
# Get output directory
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from config import resolve_output_dir
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
output_dir = resolve_output_dir(self.config)
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Merge enabled: scanning {output_dir} for downloaded files"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# First, try to extract filenames from download output
|
|
|
|
|
# Look for patterns like "→ filename.mp3" from yt-dlp output
|
|
|
|
|
extracted_files = []
|
2025-12-29 17:05:03 -08:00
|
|
|
for line in stdout_text.split("\n"):
|
|
|
|
|
if "→" in line:
|
2025-11-25 20:09:33 -08:00
|
|
|
# Extract filename from arrow marker
|
2025-12-29 17:05:03 -08:00
|
|
|
parts = line.split("→")
|
2025-11-25 20:09:33 -08:00
|
|
|
if len(parts) > 1:
|
|
|
|
|
filename = parts[1].strip()
|
|
|
|
|
if filename:
|
|
|
|
|
full_path = output_dir / filename
|
|
|
|
|
if full_path.exists():
|
|
|
|
|
extracted_files.append(str(full_path))
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.debug(
|
|
|
|
|
f"Found downloaded file from output: {filename}"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if extracted_files:
|
|
|
|
|
downloaded_files = extracted_files
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Found {len(downloaded_files)} downloaded files from output markers"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
|
|
|
|
# Fallback: List all recent mp3/m4a files in output directory
|
|
|
|
|
if output_dir.exists():
|
|
|
|
|
import time
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
current_time = time.time()
|
|
|
|
|
recent_files = []
|
2025-12-29 17:05:03 -08:00
|
|
|
for f in (
|
|
|
|
|
list(output_dir.glob("*.mp3"))
|
|
|
|
|
+ list(output_dir.glob("*.m4a"))
|
|
|
|
|
+ list(output_dir.glob("*.mp4"))
|
|
|
|
|
):
|
2025-11-25 20:09:33 -08:00
|
|
|
# Files modified in last 30 minutes (extended window)
|
|
|
|
|
if current_time - f.stat().st_mtime < 1800:
|
|
|
|
|
recent_files.append((f, f.stat().st_mtime))
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Sort by modification time to preserve order
|
|
|
|
|
recent_files.sort(key=lambda x: x[1])
|
|
|
|
|
downloaded_files = [str(f[0]) for f in recent_files]
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Found {len(downloaded_files)} recently modified files in directory (fallback)"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if downloaded_files:
|
|
|
|
|
logger.info(f"Found {len(downloaded_files)} files to merge")
|
|
|
|
|
if downloaded_files:
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Files to merge: {downloaded_files[:3]}... (showing first 3)"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
|
|
|
|
# For non-merge or non-playlist, just look for "Saved to" pattern
|
|
|
|
|
combined_output = stdout_text + "\n" + stderr_text
|
2025-12-29 17:05:03 -08:00
|
|
|
for line in combined_output.split("\n"):
|
|
|
|
|
if "Saved to" in line:
|
2025-11-25 20:09:33 -08:00
|
|
|
# Extract path after "Saved to "
|
2025-12-29 17:05:03 -08:00
|
|
|
saved_idx = line.find("Saved to")
|
2025-11-25 20:09:33 -08:00
|
|
|
if saved_idx != -1:
|
2025-12-29 17:05:03 -08:00
|
|
|
path = line[saved_idx + 8 :].strip()
|
2025-11-25 20:09:33 -08:00
|
|
|
if path:
|
|
|
|
|
downloaded_files.append(path)
|
|
|
|
|
logger.debug(f"Found downloaded file: {path}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# For merge scenarios, DON'T set to first file yet - merge first, then tag
|
|
|
|
|
# For non-merge, set to first file for tagging
|
|
|
|
|
if downloaded_files:
|
|
|
|
|
if not (self.is_playlist and merge_enabled):
|
|
|
|
|
# Non-merge case: set to first file for tagging
|
|
|
|
|
first_file = downloaded_files[0]
|
|
|
|
|
result_obj.target = first_file
|
|
|
|
|
result_obj.path = first_file
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Set result target/path to first file: {first_file}"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
|
|
|
|
# Merge case: save all files, will set to merged file after merge
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Merge enabled - will merge {len(downloaded_files)} files before tagging"
|
|
|
|
|
)
|
|
|
|
|
download_stderr_text = (
|
|
|
|
|
f"DOWNLOADED_FILES:{','.join(downloaded_files)}\n"
|
|
|
|
|
+ download_stderr_text
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-27 21:24:27 -08:00
|
|
|
logger.info(f"{download_cmdlet_name} stage completed successfully")
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
2025-12-27 21:24:27 -08:00
|
|
|
logger.error(f"{download_cmdlet_name} execution error: {e}", exc_info=True)
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Download error: {e}",
|
|
|
|
|
title="Download Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
# Finish worker with error status
|
|
|
|
|
try:
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.finish_worker,
|
|
|
|
|
worker_id,
|
|
|
|
|
"error",
|
2025-12-29 17:05:03 -08:00
|
|
|
f"Download error: {str(e)}",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self.app.call_from_thread(self._hide_progress)
|
|
|
|
|
self.app.call_from_thread(self.dismiss)
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Stage 2: Merge files if enabled and this is a playlist (BEFORE tagging)
|
|
|
|
|
merged_file_path = None
|
|
|
|
|
if merge_enabled and download_succeeded and self.is_playlist:
|
|
|
|
|
merge_cmdlet = get_cmdlet("merge-file")
|
|
|
|
|
if merge_cmdlet:
|
|
|
|
|
from pathlib import Path
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info("Executing merge-file stage")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log step to worker
|
|
|
|
|
if worker:
|
|
|
|
|
worker.log_step("Starting merge-file stage...")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
merge_args = [
|
|
|
|
|
"-delete",
|
|
|
|
|
"-format",
|
|
|
|
|
"mka",
|
|
|
|
|
] # Delete source files, use MKA for speed (stream copy) and chapters
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
try:
|
|
|
|
|
# For merge, we pass a list of result objects
|
|
|
|
|
# The merge-file cmdlet expects objects with 'target' attribute
|
|
|
|
|
files_to_merge = []
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Check if we have the special marker with downloaded files
|
|
|
|
|
if download_stderr_text.startswith("DOWNLOADED_FILES:"):
|
|
|
|
|
# Extract file list from marker
|
2025-12-29 17:05:03 -08:00
|
|
|
files_line = download_stderr_text.split("\n")[0]
|
2025-11-25 20:09:33 -08:00
|
|
|
if files_line.startswith("DOWNLOADED_FILES:"):
|
2025-12-29 17:05:03 -08:00
|
|
|
files_str = files_line[len("DOWNLOADED_FILES:") :]
|
|
|
|
|
file_list = [f.strip() for f in files_str.split(",") if f.strip()]
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f"Found {len(file_list)} downloaded files from marker")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Create result objects with proper attributes
|
|
|
|
|
for filepath in file_list:
|
|
|
|
|
filepath_obj = Path(filepath)
|
2025-12-29 17:05:03 -08:00
|
|
|
file_result = type(
|
|
|
|
|
"FileResult",
|
|
|
|
|
(),
|
|
|
|
|
{
|
|
|
|
|
"target": str(filepath),
|
|
|
|
|
"path": str(filepath),
|
|
|
|
|
"media_kind": "audio",
|
|
|
|
|
"hash": None,
|
|
|
|
|
"url": [],
|
|
|
|
|
"title": filepath_obj.stem,
|
|
|
|
|
},
|
|
|
|
|
)()
|
2025-11-25 20:09:33 -08:00
|
|
|
files_to_merge.append(file_result)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if files_to_merge:
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Merging {len(files_to_merge)} files: {[f.target for f in files_to_merge]}"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Call merge-file with list of results
|
|
|
|
|
import io
|
|
|
|
|
from contextlib import redirect_stdout, redirect_stderr
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
stdout_buf = io.StringIO()
|
|
|
|
|
stderr_buf = io.StringIO()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
|
|
|
|
# Pass the list of file results to merge-file
|
2025-12-29 17:05:03 -08:00
|
|
|
merge_returncode = merge_cmdlet(
|
|
|
|
|
files_to_merge, merge_args, self.config
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
merge_stdout = stdout_buf.getvalue()
|
|
|
|
|
merge_stderr = stderr_buf.getvalue()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log the merge output so it gets captured by WorkerLoggingHandler
|
|
|
|
|
if merge_stdout:
|
|
|
|
|
logger.info(f"[merge-file output]\n{merge_stdout}")
|
|
|
|
|
if merge_stderr:
|
|
|
|
|
logger.info(f"[merge-file stderr]\n{merge_stderr}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if merge_returncode != 0:
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.error(
|
|
|
|
|
f"merge-file stage failed with code {merge_returncode}"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.error(f" stderr: {merge_stderr}")
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Merge failed: {merge_stderr[:100] if merge_stderr else 'unknown error'}",
|
|
|
|
|
title="Merge Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="warning",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
# Don't fail entirely - files were downloaded
|
|
|
|
|
else:
|
|
|
|
|
logger.info("merge-file stage completed successfully")
|
|
|
|
|
if merge_stdout:
|
|
|
|
|
logger.info(f"merge-file stdout: {merge_stdout}")
|
|
|
|
|
if merge_stderr:
|
|
|
|
|
logger.info(f"merge-file stderr: {merge_stderr}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log step to worker
|
|
|
|
|
if worker:
|
|
|
|
|
worker.log_step("Merge completed successfully")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Extract merged file path from stderr
|
|
|
|
|
# The merge-file cmdlet outputs: "[merge-file] Merged N files into: /path/to/merged.mp3"
|
2025-12-29 17:05:03 -08:00
|
|
|
for line in merge_stderr.split("\n"):
|
|
|
|
|
if "Merged" in line and "into:" in line:
|
2025-11-25 20:09:33 -08:00
|
|
|
# Extract path after "into: "
|
2025-12-29 17:05:03 -08:00
|
|
|
into_idx = line.find("into:")
|
2025-11-25 20:09:33 -08:00
|
|
|
if into_idx != -1:
|
2025-12-29 17:05:03 -08:00
|
|
|
merged_file_path = line[into_idx + 5 :].strip()
|
2025-11-25 20:09:33 -08:00
|
|
|
if merged_file_path:
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Detected merged file path: {merged_file_path}"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
break
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# If not found in stderr, try stdout
|
|
|
|
|
if not merged_file_path:
|
2025-12-29 17:05:03 -08:00
|
|
|
for line in merge_stdout.split("\n"):
|
|
|
|
|
if (
|
|
|
|
|
"merged" in line.lower()
|
|
|
|
|
or line.endswith(".mp3")
|
|
|
|
|
or line.endswith(".m4a")
|
|
|
|
|
):
|
2025-11-25 20:09:33 -08:00
|
|
|
merged_file_path = line.strip()
|
2025-12-29 17:05:03 -08:00
|
|
|
if (
|
|
|
|
|
merged_file_path
|
|
|
|
|
and not merged_file_path.startswith("[")
|
|
|
|
|
):
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Detected merged file path: {merged_file_path}"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
break
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# If we found the merged file, update result_obj to point to it
|
|
|
|
|
if merged_file_path:
|
|
|
|
|
result_obj.target = merged_file_path
|
|
|
|
|
result_obj.path = merged_file_path
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Updated result object to point to merged file: {merged_file_path}"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.warning(
|
|
|
|
|
f"No files found to merge. download_stderr_text length: {len(download_stderr_text)}, content preview: {download_stderr_text[:100]}"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"merge-file execution error: {e}", exc_info=True)
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Merge error: {e}",
|
|
|
|
|
title="Merge Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="warning",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
# Don't fail entirely - files were downloaded
|
|
|
|
|
else:
|
|
|
|
|
logger.info("merge-file cmdlet not found")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Stage 3: Add tags (now after merge, if merge happened)
|
|
|
|
|
# If merge succeeded, result_obj now points to merged file
|
|
|
|
|
if tags and (download_succeeded or not download_enabled):
|
2025-12-11 23:21:45 -08:00
|
|
|
add_tags_cmdlet = get_cmdlet("add-tags")
|
2025-11-25 20:09:33 -08:00
|
|
|
if add_tags_cmdlet:
|
2025-12-11 23:21:45 -08:00
|
|
|
logger.info(f"Executing add-tags stage with {len(tags)} tags")
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f" Tags: {tags}")
|
|
|
|
|
logger.info(f" Source: {source}")
|
|
|
|
|
logger.info(f" Result path: {result_obj.path}")
|
|
|
|
|
logger.info(f" Result hash: {result_obj.hash_hex}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log step to worker
|
|
|
|
|
if worker:
|
2025-12-11 23:21:45 -08:00
|
|
|
worker.log_step(f"Starting add-tags stage with {len(tags)} tags...")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-11 23:21:45 -08:00
|
|
|
# Build add-tags arguments. add-tags requires a store; for downloads, default to local sidecar tagging.
|
2025-12-29 17:05:03 -08:00
|
|
|
tag_args = (
|
|
|
|
|
["-store", "local"] + [str(t) for t in tags] + ["--source", str(source)]
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f" Tag args: {tag_args}")
|
2025-12-29 17:05:03 -08:00
|
|
|
logger.info(
|
|
|
|
|
f" Result object attributes: target={getattr(result_obj, 'target', 'MISSING')}, path={getattr(result_obj, 'path', 'MISSING')}, hash_hex={getattr(result_obj, 'hash_hex', 'MISSING')}"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
try:
|
|
|
|
|
# Capture output from the cmdlet
|
|
|
|
|
import io
|
|
|
|
|
from contextlib import redirect_stdout, redirect_stderr
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
stdout_buf = io.StringIO()
|
|
|
|
|
stderr_buf = io.StringIO()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
|
|
|
|
returncode = add_tags_cmdlet(result_obj, tag_args, self.config)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
stdout_text = stdout_buf.getvalue()
|
|
|
|
|
stderr_text = stderr_buf.getvalue()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log the tag output so it gets captured by WorkerLoggingHandler
|
|
|
|
|
if stdout_text:
|
2025-12-11 23:21:45 -08:00
|
|
|
logger.info(f"[add-tags output]\n{stdout_text}")
|
2025-11-25 20:09:33 -08:00
|
|
|
if stderr_text:
|
2025-12-11 23:21:45 -08:00
|
|
|
logger.info(f"[add-tags stderr]\n{stderr_text}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if returncode != 0:
|
2025-12-11 23:21:45 -08:00
|
|
|
logger.error(f"add-tags stage failed with code {returncode}")
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.error(f" stdout: {stdout_text}")
|
|
|
|
|
logger.error(f" stderr: {stderr_text}")
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Failed to add tags: {stderr_text[:100] if stderr_text else stdout_text[:100] if stdout_text else 'unknown error'}",
|
|
|
|
|
title="Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
# Don't dismiss on tag failure - let user retry or cancel, but hide progress
|
|
|
|
|
self.app.call_from_thread(self._hide_progress)
|
|
|
|
|
return
|
|
|
|
|
else:
|
|
|
|
|
if stdout_text:
|
2025-12-11 23:21:45 -08:00
|
|
|
logger.debug(f"add-tags stdout: {stdout_text}")
|
2025-11-25 20:09:33 -08:00
|
|
|
if stderr_text:
|
2025-12-11 23:21:45 -08:00
|
|
|
logger.debug(f"add-tags stderr: {stderr_text}")
|
|
|
|
|
logger.info("add-tags stage completed successfully")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Log step to worker
|
|
|
|
|
if worker:
|
|
|
|
|
worker.log_step(f"Successfully added {len(tags)} tags")
|
|
|
|
|
except Exception as e:
|
2025-12-11 23:21:45 -08:00
|
|
|
logger.error(f"add-tags execution error: {e}", exc_info=True)
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Error adding tags: {e}",
|
|
|
|
|
title="Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
self.app.call_from_thread(self._hide_progress)
|
|
|
|
|
return
|
|
|
|
|
else:
|
2025-12-11 23:21:45 -08:00
|
|
|
logger.error("add-tags cmdlet not found")
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
|
|
|
|
if tags and download_enabled and not download_succeeded:
|
2025-12-11 23:21:45 -08:00
|
|
|
skip_msg = "⚠️ Skipping add-tags stage because download failed"
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(skip_msg)
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"\n{skip_msg}\n")
|
|
|
|
|
worker.finish("error", "Download stage failed - see logs above for details")
|
|
|
|
|
elif tags:
|
|
|
|
|
logger.info("No tags to add (tags list is empty)")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Success notification
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Download request processed: {url}",
|
|
|
|
|
title="Success",
|
|
|
|
|
severity="information",
|
2025-12-29 17:05:03 -08:00
|
|
|
timeout=2,
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Finish worker with success status
|
|
|
|
|
if worker:
|
|
|
|
|
worker.finish("completed", "Download completed successfully")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info("Download request processing complete")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Hide progress and dismiss the modal
|
|
|
|
|
self.app.call_from_thread(self._hide_progress)
|
|
|
|
|
self.app.call_from_thread(self.dismiss)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error in download submit: {e}", exc_info=True)
|
|
|
|
|
# Ensure worker is marked as finished even on exception
|
|
|
|
|
if worker:
|
|
|
|
|
try:
|
|
|
|
|
worker.finish("error", f"Download failed: {str(e)}")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self.app.call_from_thread(self._hide_progress)
|
|
|
|
|
self.app.call_from_thread(
|
2025-12-29 17:05:03 -08:00
|
|
|
self.app.notify, f"Error: {e}", title="Error", severity="error"
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _create_url_result(self, url: str):
|
|
|
|
|
"""Create a result object from a URL for cmdlet processing."""
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
class URLDownloadResult:
|
|
|
|
|
def __init__(self, u):
|
|
|
|
|
self.target = u
|
|
|
|
|
self.url = u
|
|
|
|
|
self.path: str | None = None
|
|
|
|
|
self.hash_hex: str | None = None
|
|
|
|
|
self.media_kind = "url"
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
return URLDownloadResult(url)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def action_cancel(self) -> None:
|
|
|
|
|
"""Cancel the download request."""
|
|
|
|
|
self.dismiss()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def on_key(self, event) -> None:
|
|
|
|
|
"""Handle key presses to implement context-sensitive Ctrl+T."""
|
|
|
|
|
if event.key == "ctrl+t":
|
|
|
|
|
# Check which widget has focus
|
|
|
|
|
focused_widget = self.app.focused
|
|
|
|
|
if focused_widget and focused_widget.id == "paragraph_textarea":
|
|
|
|
|
# URL textarea: scrape fresh metadata, wipe tags and source
|
|
|
|
|
self._action_scrape_url_metadata()
|
|
|
|
|
event.prevent_default()
|
|
|
|
|
elif focused_widget and focused_widget.id == "tags_textarea":
|
|
|
|
|
# Tags textarea: scrape special fields and adjectives
|
|
|
|
|
self._action_scrape_tags()
|
|
|
|
|
event.prevent_default()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _action_scrape_url_metadata(self) -> None:
|
|
|
|
|
"""Scrape metadata from URL(s) in URL textarea - wipes tags and source.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
This is triggered by Ctrl+T when URL textarea is focused.
|
2025-12-11 12:47:30 -08:00
|
|
|
Supports single URL or multiple url (newline/comma-separated).
|
|
|
|
|
For multiple PDF url, creates pseudo-playlist for merge workflow.
|
2025-11-25 20:09:33 -08:00
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
text = self.paragraph_textarea.text.strip()
|
|
|
|
|
if not text:
|
|
|
|
|
logger.warning("No URL to scrape metadata from")
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
# Parse multiple url (newline or comma-separated)
|
|
|
|
|
url = []
|
2025-12-29 17:05:03 -08:00
|
|
|
for line in text.split("\n"):
|
2025-11-25 20:09:33 -08:00
|
|
|
line = line.strip()
|
|
|
|
|
if line:
|
2025-12-11 12:47:30 -08:00
|
|
|
# Handle comma-separated url within a line
|
2025-12-29 17:05:03 -08:00
|
|
|
for url in line.split(","):
|
2025-11-25 20:09:33 -08:00
|
|
|
url = url.strip()
|
|
|
|
|
if url:
|
2025-12-11 12:47:30 -08:00
|
|
|
url.append(url)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
# Check if multiple url provided
|
|
|
|
|
if len(url) > 1:
|
|
|
|
|
logger.info(f"Detected {len(url)} url - checking for PDF pseudo-playlist")
|
|
|
|
|
# Check if all url appear to be PDFs
|
2025-12-29 17:05:03 -08:00
|
|
|
all_pdfs = all(url.endswith(".pdf") or "pdf" in url.lower() for url in url)
|
2025-11-25 20:09:33 -08:00
|
|
|
if all_pdfs:
|
2025-12-11 12:47:30 -08:00
|
|
|
logger.info(f"All url are PDFs - creating pseudo-playlist")
|
|
|
|
|
self._handle_pdf_playlist(url)
|
2025-11-25 20:09:33 -08:00
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Single URL - proceed with normal metadata scraping
|
2025-12-11 12:47:30 -08:00
|
|
|
url = url[0] if url else text.strip()
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f"Scraping fresh metadata from: {url}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Check if tags are already provided in textarea
|
|
|
|
|
existing_tags = self.tags_textarea.text.strip()
|
|
|
|
|
wipe_tags = not existing_tags # Only wipe if no tags exist
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Run in background to prevent UI freezing
|
2025-12-29 17:05:03 -08:00
|
|
|
self._scrape_metadata_worker(
|
|
|
|
|
url, wipe_tags_and_source=wipe_tags, skip_tag_scraping=not wipe_tags
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error in _action_scrape_url_metadata: {e}", exc_info=True)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _action_scrape_tags(self) -> None:
|
|
|
|
|
"""Process tags from tags textarea, expanding tag lists like {philosophy}.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
This is triggered by Ctrl+T when tags textarea is focused.
|
|
|
|
|
Processes tag list references from adjective.json (e.g., {psychology})
|
|
|
|
|
and expands them to the full list of tags.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
current_tags = self.tags_textarea.text.strip()
|
|
|
|
|
if not current_tags:
|
|
|
|
|
logger.warning("No tags to process")
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if not expand_tag_lists or not process_tags_from_string:
|
|
|
|
|
logger.warning("tag_helpers not available")
|
|
|
|
|
self.app.notify(
|
2025-12-29 17:05:03 -08:00
|
|
|
"Tag processing unavailable", title="Error", severity="error", timeout=2
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f"Processing tags: {current_tags[:50]}...")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Parse tags from current text
|
|
|
|
|
tags_set = process_tags_from_string(current_tags, expand_lists=False)
|
|
|
|
|
if not tags_set:
|
|
|
|
|
logger.warning("No tags parsed from text")
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Expand tag list references like {psychology}
|
|
|
|
|
expanded_tags = expand_tag_lists(tags_set)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if len(expanded_tags) > len(tags_set):
|
|
|
|
|
# Tags were expanded
|
|
|
|
|
tags_count_added = len(expanded_tags) - len(tags_set)
|
|
|
|
|
logger.info(f"Expanded tags: added {tags_count_added} new tags")
|
|
|
|
|
self.app.notify(
|
|
|
|
|
f"Expanded: {tags_count_added} new tags added from tag lists",
|
|
|
|
|
title="Tags Expanded",
|
|
|
|
|
severity="information",
|
2025-12-29 17:05:03 -08:00
|
|
|
timeout=2,
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
logger.info("No tag list expansions found")
|
|
|
|
|
self.app.notify(
|
|
|
|
|
"No {list} references found to expand",
|
|
|
|
|
title="Info",
|
|
|
|
|
severity="information",
|
2025-12-29 17:05:03 -08:00
|
|
|
timeout=2,
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Update textarea with expanded tags (one per line)
|
2025-12-29 17:05:03 -08:00
|
|
|
self.tags_textarea.text = "\n".join(sorted(expanded_tags))
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f"Updated tags textarea with {len(expanded_tags)} tags")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error in _action_scrape_tags: {e}", exc_info=True)
|
2025-12-29 17:05:03 -08:00
|
|
|
self.app.notify(f"Error processing tags: {e}", title="Error", severity="error")
|
2025-11-25 20:09:33 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
def _handle_pdf_playlist(self, pdf_url: list) -> None:
|
|
|
|
|
"""Handle multiple PDF url as a pseudo-playlist.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Creates a playlist-like structure with PDF metadata for merge workflow.
|
|
|
|
|
Extracts title from URL or uses default naming.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Args:
|
2025-12-11 12:47:30 -08:00
|
|
|
pdf_url: List of PDF url to process
|
2025-11-25 20:09:33 -08:00
|
|
|
"""
|
|
|
|
|
try:
|
2025-12-11 12:47:30 -08:00
|
|
|
logger.info(f"Creating PDF pseudo-playlist with {len(pdf_url)} items")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
# Create playlist items from PDF url
|
2025-11-25 20:09:33 -08:00
|
|
|
playlist_items = []
|
2025-12-11 12:47:30 -08:00
|
|
|
for idx, url in enumerate(pdf_url, 1):
|
2025-11-25 20:09:33 -08:00
|
|
|
# Extract filename from URL for display
|
|
|
|
|
try:
|
|
|
|
|
# Get filename from URL path
|
|
|
|
|
from urllib.parse import urlparse
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
parsed = urlparse(url)
|
2025-12-29 17:05:03 -08:00
|
|
|
filename = parsed.path.split("/")[-1]
|
|
|
|
|
if not filename or filename.endswith(".pdf"):
|
|
|
|
|
filename = filename or f"pdf_{idx}.pdf"
|
2025-11-25 20:09:33 -08:00
|
|
|
# Remove .pdf extension for display
|
2025-12-29 17:05:03 -08:00
|
|
|
title = filename.replace(".pdf", "").replace("_", " ").replace("-", " ")
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug(f"Could not extract filename: {e}")
|
2025-12-29 17:05:03 -08:00
|
|
|
title = f"PDF {idx}"
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
item = {
|
2025-12-29 17:05:03 -08:00
|
|
|
"id": str(idx - 1), # 0-based index
|
|
|
|
|
"title": title,
|
|
|
|
|
"duration": "", # PDFs don't have duration, leave empty
|
|
|
|
|
"url": url, # Store the URL for later download
|
2025-11-25 20:09:33 -08:00
|
|
|
}
|
|
|
|
|
playlist_items.append(item)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Build minimal metadata structure for UI population
|
|
|
|
|
metadata = {
|
2025-12-29 17:05:03 -08:00
|
|
|
"title": f"{len(pdf_url)} PDF Documents",
|
|
|
|
|
"tags": [],
|
|
|
|
|
"formats": [("pdf", "pdf")], # Default format is PDF
|
|
|
|
|
"playlist_items": playlist_items,
|
|
|
|
|
"is_pdf_playlist": True, # Mark as PDF pseudo-playlist
|
2025-11-25 20:09:33 -08:00
|
|
|
}
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
# Store url for later use during merge
|
|
|
|
|
self.pdf_url = pdf_url
|
2025-11-25 20:09:33 -08:00
|
|
|
self.is_pdf_playlist = True
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Populate the modal with metadata
|
|
|
|
|
logger.info(f"Populating modal with {len(playlist_items)} PDF items")
|
|
|
|
|
self._populate_from_metadata(metadata, wipe_tags_and_source=True)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.notify(
|
2025-12-11 12:47:30 -08:00
|
|
|
f"Loaded {len(pdf_url)} PDFs as playlist",
|
2025-11-25 20:09:33 -08:00
|
|
|
title="PDF Playlist",
|
|
|
|
|
severity="information",
|
2025-12-29 17:05:03 -08:00
|
|
|
timeout=3,
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error handling PDF playlist: {e}", exc_info=True)
|
|
|
|
|
self.app.notify(
|
2025-12-29 17:05:03 -08:00
|
|
|
f"Error loading PDF playlist: {e}", title="Error", severity="error", timeout=3
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
|
2025-12-29 17:05:03 -08:00
|
|
|
def _handle_pdf_playlist_download(
|
|
|
|
|
self, pdf_url: list, tags: list, selection: str, merge_enabled: bool
|
|
|
|
|
) -> None:
|
2025-11-25 20:09:33 -08:00
|
|
|
"""Download and merge PDF playlist.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Args:
|
2025-12-11 12:47:30 -08:00
|
|
|
pdf_url: List of PDF url to download
|
2025-11-25 20:09:33 -08:00
|
|
|
tags: Tags to apply to the merged PDF
|
|
|
|
|
selection: Selection string like "1-3" or "1,3,5"
|
|
|
|
|
merge_enabled: Whether to merge the PDFs
|
|
|
|
|
"""
|
2025-12-16 01:45:01 -08:00
|
|
|
# Check if pypdf is available for merge (needed at function start)
|
2025-11-25 20:09:33 -08:00
|
|
|
try:
|
2025-12-16 01:45:01 -08:00
|
|
|
from pypdf import PdfWriter, PdfReader
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-16 01:45:01 -08:00
|
|
|
HAS_PYPDF = True
|
2025-11-25 20:09:33 -08:00
|
|
|
except ImportError:
|
2025-12-16 01:45:01 -08:00
|
|
|
HAS_PYPDF = False
|
2025-11-25 20:09:33 -08:00
|
|
|
PdfWriter = None
|
|
|
|
|
PdfReader = None
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
try:
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import requests
|
|
|
|
|
from config import resolve_output_dir
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Create temporary list of playlist items for selection parsing
|
|
|
|
|
# We need this because _parse_playlist_selection uses self.playlist_items
|
|
|
|
|
temp_items = []
|
2025-12-11 12:47:30 -08:00
|
|
|
for url in pdf_url:
|
2025-12-29 17:05:03 -08:00
|
|
|
temp_items.append({"title": url})
|
2025-11-25 20:09:33 -08:00
|
|
|
self.playlist_items = temp_items
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Parse selection to get which PDFs to download
|
|
|
|
|
selected_indices = self._parse_playlist_selection(selection)
|
|
|
|
|
if not selected_indices:
|
|
|
|
|
# No valid selection, use all
|
2025-12-11 12:47:30 -08:00
|
|
|
selected_indices = list(range(len(pdf_url)))
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
selected_url = [pdf_url[i] for i in selected_indices]
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
logger.info(f"Downloading {len(selected_url)} selected PDFs for merge")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Download PDFs to temporary directory
|
|
|
|
|
temp_dir = Path.home() / ".downlow_temp_pdfs"
|
|
|
|
|
temp_dir.mkdir(exist_ok=True)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
downloaded_files = []
|
2025-12-11 12:47:30 -08:00
|
|
|
for idx, url in enumerate(selected_url, 1):
|
2025-11-25 20:09:33 -08:00
|
|
|
try:
|
2025-12-11 12:47:30 -08:00
|
|
|
logger.info(f"Downloading PDF {idx}/{len(selected_url)}: {url}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
response = requests.get(url, timeout=30)
|
|
|
|
|
response.raise_for_status()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Generate filename from URL
|
|
|
|
|
from urllib.parse import urlparse
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
parsed = urlparse(url)
|
2025-12-29 17:05:03 -08:00
|
|
|
filename = parsed.path.split("/")[-1]
|
|
|
|
|
if not filename.endswith(".pdf"):
|
|
|
|
|
filename = f"pdf_{idx}.pdf"
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
pdf_path = temp_dir / filename
|
2025-12-29 17:05:03 -08:00
|
|
|
with open(pdf_path, "wb") as f:
|
2025-11-25 20:09:33 -08:00
|
|
|
f.write(response.content)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
downloaded_files.append(pdf_path)
|
|
|
|
|
logger.info(f"Downloaded to: {pdf_path}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to download PDF {idx}: {e}")
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Failed to download PDF {idx}: {e}",
|
|
|
|
|
title="Download Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Merge PDFs if requested
|
|
|
|
|
if merge_enabled and len(downloaded_files) > 1:
|
2025-12-16 01:45:01 -08:00
|
|
|
if not HAS_PYPDF:
|
|
|
|
|
logger.error("pypdf not available for PDF merge")
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
2025-12-16 01:45:01 -08:00
|
|
|
"pypdf required for PDF merge. Install with: pip install pypdf",
|
2025-11-25 20:09:33 -08:00
|
|
|
title="Missing Dependency",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f"Merging {len(downloaded_files)} PDFs")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
try:
|
|
|
|
|
writer = PdfWriter()
|
|
|
|
|
for pdf_file in downloaded_files:
|
|
|
|
|
reader = PdfReader(pdf_file)
|
|
|
|
|
for page in reader.pages:
|
|
|
|
|
writer.add_page(page)
|
|
|
|
|
logger.info(f"Added {len(reader.pages)} pages from {pdf_file.name}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Save merged PDF to output directory
|
|
|
|
|
output_dir = Path(resolve_output_dir(self.config))
|
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
output_path = output_dir / "merged_pdfs.pdf"
|
|
|
|
|
# Make filename unique if it exists
|
|
|
|
|
counter = 1
|
|
|
|
|
while output_path.exists():
|
|
|
|
|
output_path = output_dir / f"merged_pdfs_{counter}.pdf"
|
|
|
|
|
counter += 1
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
with open(output_path, "wb") as f:
|
2025-11-25 20:09:33 -08:00
|
|
|
writer.write(f)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f"Merged PDF saved to: {output_path}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Tag the file if tags provided
|
|
|
|
|
if tags and get_cmdlet:
|
|
|
|
|
tag_cmdlet = get_cmdlet("add-tags")
|
|
|
|
|
if tag_cmdlet:
|
|
|
|
|
logger.info(f"Tagging merged PDF with {len(tags)} tags")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Create a result object for the PDF
|
|
|
|
|
class PDFResult:
|
|
|
|
|
def __init__(self, p):
|
|
|
|
|
self.path = str(p)
|
|
|
|
|
self.target = str(p)
|
|
|
|
|
self.hash_hex = None
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
result_obj = PDFResult(output_path)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
import io
|
|
|
|
|
from contextlib import redirect_stdout, redirect_stderr
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
stdout_buf = io.StringIO()
|
|
|
|
|
stderr_buf = io.StringIO()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-12-11 23:21:45 -08:00
|
|
|
tag_args = ["-store", "local"] + [str(t) for t in tags]
|
2025-11-25 20:09:33 -08:00
|
|
|
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
2025-12-11 23:21:45 -08:00
|
|
|
tag_returncode = tag_cmdlet(result_obj, tag_args, self.config)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if tag_returncode != 0:
|
|
|
|
|
logger.warning(f"Tag stage returned code {tag_returncode}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Successfully merged {len(downloaded_files)} PDFs",
|
|
|
|
|
title="Merge Complete",
|
|
|
|
|
severity="information",
|
2025-12-29 17:05:03 -08:00
|
|
|
timeout=3,
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"PDF merge error: {e}", exc_info=True)
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"PDF merge failed: {e}",
|
|
|
|
|
title="Merge Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
|
|
|
|
# Save individual PDFs to output
|
|
|
|
|
output_dir = Path(resolve_output_dir(self.config))
|
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
for pdf_file in downloaded_files:
|
|
|
|
|
output_path = output_dir / pdf_file.name
|
|
|
|
|
# Make filename unique if it exists
|
|
|
|
|
counter = 1
|
|
|
|
|
base_name = pdf_file.stem
|
|
|
|
|
while output_path.exists():
|
|
|
|
|
output_path = output_dir / f"{base_name}_{counter}.pdf"
|
|
|
|
|
counter += 1
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
import shutil
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
shutil.copy2(pdf_file, output_path)
|
|
|
|
|
logger.info(f"Saved PDF to: {output_path}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Downloaded {len(downloaded_files)} PDFs",
|
|
|
|
|
title="Download Complete",
|
|
|
|
|
severity="information",
|
2025-12-29 17:05:03 -08:00
|
|
|
timeout=3,
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error in PDF playlist download: {e}", exc_info=True)
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Error processing PDF playlist: {e}",
|
|
|
|
|
title="Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@work(thread=True)
|
2025-12-29 17:05:03 -08:00
|
|
|
def _scrape_metadata_worker(
|
|
|
|
|
self, url: str, wipe_tags_and_source: bool = False, skip_tag_scraping: bool = False
|
|
|
|
|
) -> None:
|
2025-11-25 20:09:33 -08:00
|
|
|
"""Background worker to scrape metadata using get-tag cmdlet.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Args:
|
|
|
|
|
url: URL to scrape metadata from
|
|
|
|
|
wipe_tags_and_source: If True, clear tags and source before populating
|
|
|
|
|
skip_tag_scraping: If True, don't scrape tags (only title/formats)
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
logger.info(f"Metadata worker started for: {url}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Call get-tag cmdlet to scrape URL
|
|
|
|
|
if not get_cmdlet:
|
2025-12-12 21:55:38 -08:00
|
|
|
logger.error("cmdlet module not available")
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.call_from_thread(
|
2025-12-29 17:05:03 -08:00
|
|
|
self.app.notify, "cmdlet module not available", title="Error", severity="error"
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Get the get-tag cmdlet
|
|
|
|
|
get_tag_cmdlet = get_cmdlet("get-tag")
|
|
|
|
|
if not get_tag_cmdlet:
|
|
|
|
|
logger.error("get-tag cmdlet not found")
|
|
|
|
|
self.app.call_from_thread(
|
2025-12-29 17:05:03 -08:00
|
|
|
self.app.notify, "get-tag cmdlet not found", title="Error", severity="error"
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Create a simple result object for the cmdlet
|
|
|
|
|
class URLResult:
|
|
|
|
|
def __init__(self, u):
|
|
|
|
|
self.target = u
|
|
|
|
|
self.hash_hex = None
|
|
|
|
|
self.path = None
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
result_obj = URLResult(url)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Call the cmdlet with -scrape flag (unless skipping tag scraping)
|
|
|
|
|
import io
|
|
|
|
|
from contextlib import redirect_stdout, redirect_stderr
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
output_buffer = io.StringIO()
|
|
|
|
|
error_buffer = io.StringIO()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Only scrape if not skipping tag scraping
|
|
|
|
|
args = [] if skip_tag_scraping else ["-scrape", url]
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
with redirect_stdout(output_buffer), redirect_stderr(error_buffer):
|
|
|
|
|
returncode = get_tag_cmdlet(result_obj, args, {})
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if returncode != 0:
|
|
|
|
|
error_msg = error_buffer.getvalue()
|
|
|
|
|
logger.error(f"get-tag cmdlet failed: {error_msg}")
|
|
|
|
|
try:
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Failed to scrape metadata: {error_msg}",
|
|
|
|
|
title="Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug(f"Could not notify user: {e}")
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Parse the JSON output
|
|
|
|
|
output = output_buffer.getvalue().strip()
|
|
|
|
|
if not output:
|
|
|
|
|
logger.warning("get-tag returned no output")
|
|
|
|
|
try:
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
"No metadata returned from get-tag",
|
|
|
|
|
title="Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug(f"Could not notify user: {e}")
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Extract the JSON line (skip debug messages that start with [get-tag])
|
|
|
|
|
json_line = None
|
2025-12-29 17:05:03 -08:00
|
|
|
for line in output.split("\n"):
|
|
|
|
|
if line.strip().startswith("{"):
|
2025-11-25 20:09:33 -08:00
|
|
|
json_line = line.strip()
|
|
|
|
|
break
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if not json_line:
|
|
|
|
|
logger.error(f"No JSON found in get-tag output")
|
|
|
|
|
logger.debug(f"Raw output: {output}")
|
|
|
|
|
try:
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
"No metadata found in response",
|
|
|
|
|
title="Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug(f"Could not notify user: {e}")
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
try:
|
|
|
|
|
metadata_result = json.loads(json_line)
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
logger.error(f"Failed to parse JSON: {e}")
|
|
|
|
|
logger.debug(f"JSON line: {json_line}")
|
|
|
|
|
try:
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Failed to parse metadata: {e}",
|
|
|
|
|
title="Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
except Exception as ne:
|
|
|
|
|
logger.debug(f"Could not notify user: {ne}")
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Build metadata dict in the format expected by _populate_from_metadata
|
|
|
|
|
# If skipping tag scraping, preserve existing tags
|
2025-12-29 17:05:03 -08:00
|
|
|
existing_tags = self.tags_textarea.text.strip().split("\n") if skip_tag_scraping else []
|
2025-11-25 20:09:33 -08:00
|
|
|
existing_tags = [tag.strip() for tag in existing_tags if tag.strip()]
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Extract playlist items if present
|
2025-12-29 17:05:03 -08:00
|
|
|
playlist_items = metadata_result.get("playlist_items", [])
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
metadata = {
|
2025-12-29 17:05:03 -08:00
|
|
|
"title": metadata_result.get("title", "Unknown"),
|
|
|
|
|
"url": url,
|
|
|
|
|
"tags": metadata_result.get("tags", [])
|
|
|
|
|
or existing_tags, # Use existing if new are empty
|
|
|
|
|
"formats": metadata_result.get("formats", []),
|
|
|
|
|
"playlist_items": playlist_items,
|
2025-11-25 20:09:33 -08:00
|
|
|
}
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Retrieved metadata: title={metadata['title']}, tags={len(metadata['tags'])}, formats={len(metadata['formats'])}, playlist_items={len(playlist_items)}"
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
# Update UI on main thread
|
|
|
|
|
self.app.call_from_thread(self._populate_from_metadata, metadata, wipe_tags_and_source)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Metadata worker error: {e}", exc_info=True)
|
|
|
|
|
try:
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Failed to scrape metadata: {e}",
|
|
|
|
|
title="Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
except Exception as ne:
|
|
|
|
|
logger.debug(f"Could not notify user of error: {ne}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _convert_selection_to_ytdlp(self, selection_str: str) -> str:
|
|
|
|
|
"""Convert playlist selection string to yt-dlp --playlist-items format.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Args:
|
|
|
|
|
selection_str: Selection string like "1-3", "all", "merge", "1,3,5-8"
|
|
|
|
|
Can also include multiple keywords separated by spaces
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Returns:
|
|
|
|
|
yt-dlp format string like "1-3,5,8" or "1-10" for all
|
|
|
|
|
"""
|
|
|
|
|
if not selection_str:
|
|
|
|
|
return ""
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
selection_str = selection_str.strip().upper()
|
|
|
|
|
max_idx = len(self.playlist_items)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Handle keywords (all, merge, a, m) - can be space or comma separated
|
|
|
|
|
# "ALL MERGE", "A M", "ALL,MERGE" etc all mean download all items
|
2025-12-29 17:05:03 -08:00
|
|
|
if any(kw in selection_str.replace(",", " ").split() for kw in {"A", "ALL", "M", "MERGE"}):
|
2025-11-25 20:09:33 -08:00
|
|
|
# User said to get all items (merge is same as all in this context)
|
|
|
|
|
return f"1-{max_idx}"
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Parse ranges like "1,3,5-8" and convert to yt-dlp format
|
|
|
|
|
# The selection is already in 1-based format from user, keep it that way
|
|
|
|
|
# yt-dlp expects 1-based indices
|
|
|
|
|
try:
|
|
|
|
|
parts = []
|
2025-12-29 17:05:03 -08:00
|
|
|
for part in selection_str.split(","):
|
2025-11-25 20:09:33 -08:00
|
|
|
part = part.strip()
|
|
|
|
|
if part: # Skip empty parts
|
|
|
|
|
parts.append(part)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
return ",".join(parts)
|
2025-11-25 20:09:33 -08:00
|
|
|
except (ValueError, AttributeError):
|
|
|
|
|
logger.error(f"Failed to convert playlist selection: {selection_str}")
|
|
|
|
|
return ""
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _parse_playlist_selection(self, selection_str: str) -> list:
|
|
|
|
|
"""Parse playlist selection string into list of track indices (0-based).
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Args:
|
|
|
|
|
selection_str: Selection string like "1-3", "all", "merge", "1,3,5-8"
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Returns:
|
|
|
|
|
List of 0-based indices, or empty list if invalid
|
|
|
|
|
"""
|
|
|
|
|
if not selection_str:
|
|
|
|
|
return []
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
selection_str = selection_str.strip().upper()
|
|
|
|
|
max_idx = len(self.playlist_items)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Handle keywords (all, merge, a, m) - can be space or comma separated
|
|
|
|
|
# "ALL MERGE", "A M", "ALL,MERGE" etc all mean download all items
|
2025-12-29 17:05:03 -08:00
|
|
|
if any(kw in selection_str.replace(",", " ").split() for kw in {"A", "ALL", "M", "MERGE"}):
|
2025-11-25 20:09:33 -08:00
|
|
|
# User said to get all items
|
|
|
|
|
return list(range(max_idx))
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Parse ranges like "1,3,5-8"
|
|
|
|
|
indices = set()
|
|
|
|
|
try:
|
2025-12-29 17:05:03 -08:00
|
|
|
for part in selection_str.split(","):
|
2025-11-25 20:09:33 -08:00
|
|
|
part = part.strip()
|
2025-12-29 17:05:03 -08:00
|
|
|
if "-" in part:
|
2025-11-25 20:09:33 -08:00
|
|
|
# Range like "5-8"
|
2025-12-29 17:05:03 -08:00
|
|
|
start_str, end_str = part.split("-", 1)
|
2025-11-25 20:09:33 -08:00
|
|
|
start = int(start_str.strip()) - 1 # Convert to 0-based
|
|
|
|
|
end = int(end_str.strip()) # end is inclusive in user terms
|
|
|
|
|
for i in range(start, end):
|
|
|
|
|
if 0 <= i < max_idx:
|
|
|
|
|
indices.add(i)
|
|
|
|
|
else:
|
|
|
|
|
# Single number
|
|
|
|
|
idx = int(part.strip()) - 1 # Convert to 0-based
|
|
|
|
|
if 0 <= idx < max_idx:
|
|
|
|
|
indices.add(idx)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
return sorted(list(indices))
|
|
|
|
|
except (ValueError, AttributeError):
|
|
|
|
|
logger.error(f"Failed to parse playlist selection: {selection_str}")
|
|
|
|
|
return []
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
def _execute_download_pipeline(
|
|
|
|
|
self, result_obj: Any, tags: list, source: str, download_enabled: bool, worker=None
|
|
|
|
|
) -> None:
|
2025-11-25 20:09:33 -08:00
|
|
|
"""Execute the download pipeline for a single item.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Args:
|
|
|
|
|
result_obj: URL result object
|
|
|
|
|
tags: List of tags to apply
|
|
|
|
|
source: Source for metadata
|
|
|
|
|
download_enabled: Whether to download the file
|
|
|
|
|
worker: Optional Worker instance for logging
|
|
|
|
|
"""
|
|
|
|
|
# Import cmdlet system
|
|
|
|
|
if not get_cmdlet:
|
2025-12-12 21:55:38 -08:00
|
|
|
error_msg = "cmdlet module not available"
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.error(error_msg)
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"❌ ERROR: {error_msg}\n")
|
|
|
|
|
self.app.call_from_thread(
|
2025-12-29 17:05:03 -08:00
|
|
|
self.app.notify, "cmdlet system unavailable", title="Error", severity="error"
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Stage 1: Download data if enabled
|
|
|
|
|
if download_enabled:
|
2025-12-27 21:24:27 -08:00
|
|
|
download_cmdlet_name = "download-file"
|
|
|
|
|
download_cmdlet = get_cmdlet(download_cmdlet_name)
|
2025-11-25 20:09:33 -08:00
|
|
|
if download_cmdlet:
|
2025-12-27 21:24:27 -08:00
|
|
|
stage_msg = f"📥 Executing {download_cmdlet_name} stage"
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(stage_msg)
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"{stage_msg}\n")
|
|
|
|
|
try:
|
|
|
|
|
import io
|
|
|
|
|
from contextlib import redirect_stdout, redirect_stderr
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
stdout_buf = io.StringIO()
|
|
|
|
|
stderr_buf = io.StringIO()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
2025-12-29 17:05:03 -08:00
|
|
|
cmd_config = (
|
|
|
|
|
dict(self.config) if isinstance(self.config, dict) else self.config
|
|
|
|
|
)
|
2025-12-27 21:24:27 -08:00
|
|
|
if isinstance(cmd_config, dict):
|
|
|
|
|
cmd_config["_quiet_background_output"] = True
|
|
|
|
|
returncode = download_cmdlet(result_obj, [], cmd_config)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
stdout_text = stdout_buf.getvalue()
|
|
|
|
|
stderr_text = stderr_buf.getvalue()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if stdout_text:
|
2025-12-27 21:24:27 -08:00
|
|
|
logger.debug(f"{download_cmdlet_name} stdout: {stdout_text}")
|
2025-11-25 20:09:33 -08:00
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(stdout_text)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if stderr_text:
|
2025-12-27 21:24:27 -08:00
|
|
|
logger.debug(f"{download_cmdlet_name} stderr: {stderr_text}")
|
2025-11-25 20:09:33 -08:00
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"⚠️ stderr: {stderr_text}\n")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if returncode != 0:
|
2025-12-27 21:24:27 -08:00
|
|
|
error_msg = f"❌ {download_cmdlet_name} stage failed with code {returncode}\nstderr: {stderr_text}"
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.error(error_msg)
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"{error_msg}\n")
|
|
|
|
|
self.app.call_from_thread(
|
|
|
|
|
self.app.notify,
|
|
|
|
|
f"Download failed: {stderr_text[:100]}",
|
|
|
|
|
title="Download Error",
|
2025-12-29 17:05:03 -08:00
|
|
|
severity="error",
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
else:
|
2025-12-27 21:24:27 -08:00
|
|
|
success_msg = f"{download_cmdlet_name} completed successfully"
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(success_msg)
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"{success_msg}\n")
|
|
|
|
|
except Exception as e:
|
2025-12-27 21:24:27 -08:00
|
|
|
error_msg = f"❌ {download_cmdlet_name} error: {e}"
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.error(error_msg, exc_info=True)
|
|
|
|
|
if worker:
|
2025-12-29 17:05:03 -08:00
|
|
|
worker.append_stdout(
|
|
|
|
|
f"{error_msg}\nTraceback:\n{__import__('traceback').format_exc()}\n"
|
|
|
|
|
)
|
2025-11-25 20:09:33 -08:00
|
|
|
self.app.call_from_thread(
|
2025-12-29 17:05:03 -08:00
|
|
|
self.app.notify, str(e)[:100], title="Download Error", severity="error"
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
|
|
|
|
return
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Stage 2: Tag the file if tags provided
|
|
|
|
|
if tags:
|
|
|
|
|
tag_cmdlet = get_cmdlet("add-tags")
|
2025-12-29 17:05:03 -08:00
|
|
|
if tag_cmdlet and result_obj.get("path"):
|
2025-11-25 20:09:33 -08:00
|
|
|
stage_msg = f"🏷️ Tagging with {len(tags)} tags"
|
|
|
|
|
logger.info(stage_msg)
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"{stage_msg}\n")
|
|
|
|
|
try:
|
|
|
|
|
tag_args = tags
|
|
|
|
|
import io
|
|
|
|
|
from contextlib import redirect_stdout, redirect_stderr
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
stdout_buf = io.StringIO()
|
|
|
|
|
stderr_buf = io.StringIO()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
|
|
|
|
tag_returncode = tag_cmdlet(result_obj, tag_args, {})
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
stdout_text = stdout_buf.getvalue()
|
|
|
|
|
stderr_text = stderr_buf.getvalue()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if stdout_text:
|
|
|
|
|
logger.debug(f"tag stdout: {stdout_text}")
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(stdout_text)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if tag_returncode != 0:
|
|
|
|
|
warning_msg = f"⚠️ Tag stage returned code {tag_returncode}: {stderr_text}"
|
|
|
|
|
logger.warning(warning_msg)
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"{warning_msg}\n")
|
|
|
|
|
else:
|
|
|
|
|
if worker:
|
2025-12-11 12:47:30 -08:00
|
|
|
worker.append_stdout("Tags applied successfully\n")
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
error_msg = f"❌ Tagging error: {e}"
|
|
|
|
|
logger.error(error_msg, exc_info=True)
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"{error_msg}\n")
|
|
|
|
|
else:
|
2025-12-29 17:05:03 -08:00
|
|
|
if not result_obj.get("path"):
|
2025-11-25 20:09:33 -08:00
|
|
|
warning_msg = "⚠️ No file path in result - skipping tagging"
|
|
|
|
|
logger.warning(warning_msg)
|
|
|
|
|
if worker:
|
|
|
|
|
worker.append_stdout(f"{warning_msg}\n")
|
|
|
|
|
else:
|
|
|
|
|
if worker:
|
2025-12-11 12:47:30 -08:00
|
|
|
worker.append_stdout("Download complete (no tags to apply)\n")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _show_format_select(self) -> None:
|
|
|
|
|
"""Show format select (always visible for single files)."""
|
|
|
|
|
try:
|
|
|
|
|
files_container = self.query_one("#files_container", Container)
|
|
|
|
|
playlist_container = self.query_one("#playlist_container", Container)
|
|
|
|
|
# Format select always visible, playlist hidden by default
|
|
|
|
|
files_container.styles.height = "1fr"
|
|
|
|
|
playlist_container.styles.height = "0"
|
|
|
|
|
self.is_playlist = False
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error showing format select: {e}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _show_playlist_controls(self) -> None:
|
|
|
|
|
"""Show playlist tree and input alongside format select (for playlists)."""
|
|
|
|
|
try:
|
|
|
|
|
playlist_container = self.query_one("#playlist_container", Container)
|
|
|
|
|
# Just make playlist visible - format select remains visible above it
|
|
|
|
|
playlist_container.styles.height = "auto"
|
|
|
|
|
self.is_playlist = True
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error showing playlist controls: {e}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _populate_playlist_tree(self, items: list) -> None:
|
|
|
|
|
"""Populate the playlist tree with track items.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Args:
|
|
|
|
|
items: List of track info dicts with 'id', 'title', 'duration', etc.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
self.playlist_tree.clear()
|
|
|
|
|
self.playlist_items = items
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
for idx, item in enumerate(items, 1):
|
2025-12-29 17:05:03 -08:00
|
|
|
title = item.get("title", f"Track {idx}")
|
|
|
|
|
duration = item.get("duration", "")
|
2025-11-25 20:09:33 -08:00
|
|
|
# Format: "1. Song Title (3:45)"
|
|
|
|
|
label = f"{idx}. {title}"
|
|
|
|
|
if duration:
|
|
|
|
|
label += f" ({duration})"
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
self.playlist_tree.root.add_leaf(label)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
logger.info(f"Populated playlist tree with {len(items)} items")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error populating playlist tree: {e}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _populate_from_metadata(self, metadata: dict, wipe_tags_and_source: bool = False) -> None:
|
|
|
|
|
"""Populate modal fields from extracted metadata.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
Args:
|
|
|
|
|
metadata: Dictionary with title, tags, formats
|
|
|
|
|
wipe_tags_and_source: If True, clear tags and source before populating
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Wipe tags and source if requested (fresh scrape from URL)
|
|
|
|
|
if wipe_tags_and_source:
|
|
|
|
|
self.tags_textarea.text = ""
|
|
|
|
|
# Reset source to first available option
|
|
|
|
|
try:
|
|
|
|
|
# Get all options and select the first one
|
|
|
|
|
source_options = self._build_source_options()
|
|
|
|
|
if source_options:
|
|
|
|
|
self.source_select.value = source_options[0][1]
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Could not reset source select: {e}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Populate tags - using extracted tags (one per line format)
|
2025-12-29 17:05:03 -08:00
|
|
|
tags = metadata.get("tags", [])
|
2025-11-25 20:09:33 -08:00
|
|
|
existing_tags = self.tags_textarea.text.strip()
|
2025-12-29 17:05:03 -08:00
|
|
|
title = metadata.get("title", "Unknown")
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Extract meaningful tags:
|
|
|
|
|
# 1. Freeform tags (tag:value)
|
|
|
|
|
# 2. Creator/artist metadata (creator:, artist:, channel:)
|
|
|
|
|
# 3. Other meaningful namespaces (genre:, album:, track:, etc.)
|
|
|
|
|
meaningful_tags = []
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Add title tag first (so user can edit it)
|
2025-12-29 17:05:03 -08:00
|
|
|
if title and title != "Unknown":
|
2025-11-25 20:09:33 -08:00
|
|
|
meaningful_tags.append(f"title:{title}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Namespaces to exclude (metadata-only, not user-facing)
|
|
|
|
|
excluded_namespaces = {
|
2025-12-29 17:05:03 -08:00
|
|
|
"hash", # Hash values (internal)
|
|
|
|
|
"url", # url (internal)
|
|
|
|
|
"relationship", # Internal relationships
|
|
|
|
|
"url", # url (internal)
|
2025-11-25 20:09:33 -08:00
|
|
|
}
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Add all other tags
|
|
|
|
|
for tag in tags:
|
2025-12-29 17:05:03 -08:00
|
|
|
if ":" in tag:
|
|
|
|
|
namespace, value = tag.split(":", 1)
|
2025-11-25 20:09:33 -08:00
|
|
|
# Skip internal/metadata namespaces
|
|
|
|
|
if namespace.lower() not in excluded_namespaces:
|
|
|
|
|
meaningful_tags.append(tag)
|
|
|
|
|
else:
|
|
|
|
|
# Tags without namespace are freeform - always include
|
|
|
|
|
meaningful_tags.append(tag)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Build tags string (one per line)
|
2025-12-29 17:05:03 -08:00
|
|
|
tags_str = "\n".join(meaningful_tags)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if existing_tags:
|
2025-12-29 17:05:03 -08:00
|
|
|
self.tags_textarea.text = existing_tags + "\n" + tags_str
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
|
|
|
|
self.tags_textarea.text = tags_str
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Check if this is a playlist
|
2025-12-29 17:05:03 -08:00
|
|
|
playlist_items = metadata.get("playlist_items", [])
|
|
|
|
|
formats = metadata.get("formats", [])
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Always show format select (single file or default for playlist)
|
|
|
|
|
self._show_format_select()
|
|
|
|
|
if formats:
|
|
|
|
|
# formats may be lists (from JSON) or tuples, convert to tuples
|
|
|
|
|
format_tuples = []
|
|
|
|
|
for fmt in formats:
|
|
|
|
|
if isinstance(fmt, (list, tuple)) and len(fmt) == 2:
|
|
|
|
|
format_tuples.append(tuple(fmt))
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
if format_tuples:
|
|
|
|
|
self.files_select.set_options(format_tuples)
|
|
|
|
|
# Select the first format by default
|
|
|
|
|
self.files_select.value = format_tuples[0][1]
|
|
|
|
|
self.selected_files = {format_tuples[0][0]}
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# If playlist, also show the tree for track selection
|
|
|
|
|
if playlist_items and len(playlist_items) > 0:
|
|
|
|
|
logger.info(f"Detected playlist with {len(playlist_items)} items")
|
|
|
|
|
self._populate_playlist_tree(playlist_items)
|
|
|
|
|
# Show playlist tree alongside format select (height: auto to show)
|
|
|
|
|
playlist_container = self.query_one("#playlist_container", Container)
|
|
|
|
|
playlist_container.styles.height = "auto"
|
|
|
|
|
# SET FLAG SO action_submit() KNOWS THIS IS A PLAYLIST
|
|
|
|
|
self.is_playlist = True
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Populated modal from metadata: {len(meaningful_tags)} tags, {len(playlist_items)} playlist items, {len(formats)} formats"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
# Notify user
|
|
|
|
|
self.app.notify(
|
|
|
|
|
f"Scraped metadata: {title}",
|
|
|
|
|
title="Metadata Loaded",
|
|
|
|
|
severity="information",
|
2025-12-29 17:05:03 -08:00
|
|
|
timeout=3,
|
2025-11-25 20:09:33 -08:00
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error populating metadata: {e}", exc_info=True)
|
2025-12-29 17:05:03 -08:00
|
|
|
self.app.notify(f"Failed to populate metadata: {e}", title="Error", severity="error")
|
|
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def on_select_changed(self, event: Select.Changed) -> None:
|
|
|
|
|
"""Handle Select widget changes (format selection)."""
|
|
|
|
|
if event.select.id == "files_select":
|
|
|
|
|
# Update selected_files to track the chosen format value
|
|
|
|
|
if event.value:
|
|
|
|
|
self.selected_files = {str(event.value)}
|
|
|
|
|
logger.debug(f"Selected format: {event.value}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def on_button_pressed(self, event) -> None:
|
|
|
|
|
"""Handle button clicks."""
|
|
|
|
|
if event.button.id == "submit_btn":
|
|
|
|
|
self.action_submit()
|
|
|
|
|
elif event.button.id == "cancel_btn":
|
|
|
|
|
self.action_cancel()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _show_progress(self) -> None:
|
|
|
|
|
"""Show the progress bar and hide buttons."""
|
|
|
|
|
try:
|
|
|
|
|
# Show progress bar by setting height
|
|
|
|
|
self.progress_bar.styles.height = 1
|
|
|
|
|
self.progress_bar.update(total=100)
|
|
|
|
|
# Hide buttons during download
|
|
|
|
|
button_row = self.query_one("#button_row", Horizontal)
|
|
|
|
|
button_row.display = False
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error showing progress bar: {e}")
|
2025-12-29 17:05:03 -08:00
|
|
|
|
2025-11-25 20:09:33 -08:00
|
|
|
def _hide_progress(self) -> None:
|
|
|
|
|
"""Hide the progress bar and show buttons again."""
|
|
|
|
|
try:
|
|
|
|
|
# Hide progress bar by setting height to 0
|
|
|
|
|
self.progress_bar.styles.height = 0
|
|
|
|
|
# Show buttons again
|
|
|
|
|
button_row = self.query_one("#button_row", Horizontal)
|
|
|
|
|
button_row.display = True
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error hiding progress bar: {e}")
|