Initial commit: youtube-tui (extracted from youtube-cli)
This commit is contained in:
commit
17f1413d5e
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 jarianc
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
35
README.md
Normal file
35
README.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# YouTube TUI
|
||||||
|
|
||||||
|
Text-based User Interface (TUI) for YouTube CLI
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
youtube-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install textual>=8.0
|
||||||
|
|
||||||
|
# Run the TUI
|
||||||
|
python -m youtube_tui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `__init__.py` - Package initialization
|
||||||
|
- `__main__.py` - Entry point for `python -m youtube_tui`
|
||||||
|
- `app.py` - Main Textual application
|
||||||
|
- `models/` - Data models (Video, etc.)
|
||||||
|
- `services/` - Business logic services
|
||||||
|
- `widgets/` - Textual widgets
|
||||||
|
- `screens/` - TUI screens
|
||||||
5
__init__.py
Normal file
5
__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
YouTube TUI - A Text-based User Interface for browsing and downloading YouTube videos
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
9
__main__.py
Normal file
9
__main__.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Entry point for the YouTube TUI application
|
||||||
|
"""
|
||||||
|
|
||||||
|
from youtube_tui.app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
296
app.py
Normal file
296
app.py
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Main Textual Application class for YouTube TUI
|
||||||
|
Enhanced with command palette, help screen, status bar, and theme support
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Header, Static
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs"
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOG_FILE = LOG_DIR / "app.log"
|
||||||
|
|
||||||
|
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
||||||
|
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
file_handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create console handler
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
handlers=[
|
||||||
|
file_handler,
|
||||||
|
console_handler,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from youtube_tui.models.video import Video
|
||||||
|
from youtube_tui.services.youtube import YouTubeService
|
||||||
|
from youtube_tui.services.queue import DownloadQueue
|
||||||
|
from youtube_tui.services.download_manager import DownloadManager
|
||||||
|
from youtube_tui.widgets.footer import CustomFooter
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeTUI(App):
|
||||||
|
"""Main application class for YouTube TUI"""
|
||||||
|
|
||||||
|
VERSION = "0.1.0"
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
Screen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header {
|
||||||
|
dock: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer {
|
||||||
|
dock: bottom;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("q", "quit", "Quit"),
|
||||||
|
("escape", "cancel", "Cancel"),
|
||||||
|
("ctrl+p", "command_palette", "Command Palette"),
|
||||||
|
("ctrl+h", "show_help", "Help"),
|
||||||
|
("ctrl+t", "toggle_theme", "Toggle Theme"),
|
||||||
|
("ctrl+r", "refresh_screen", "Refresh"),
|
||||||
|
("ctrl+f", "open_search", "Search"),
|
||||||
|
("ctrl+l", "open_queue", "Queue"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
# Set _theme_name directly to avoid property issues during init
|
||||||
|
object.__setattr__(self, "_theme_name", "textual-dark")
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.current_screen: Optional[object] = None
|
||||||
|
self.current_search_term = ""
|
||||||
|
self.search_history: list = []
|
||||||
|
self.load_search_history()
|
||||||
|
self.yt_dlp_version = "unknown"
|
||||||
|
self._check_yt_dlp()
|
||||||
|
self.downloading = False
|
||||||
|
# Initialize queue and download manager
|
||||||
|
self.download_queue: Optional[DownloadQueue] = None
|
||||||
|
self.youtube_service: Optional[YouTubeService] = None
|
||||||
|
self.download_manager: Optional[DownloadManager] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def theme(self) -> str:
|
||||||
|
return getattr(self, "_theme_name", "textual-dark")
|
||||||
|
|
||||||
|
@theme.setter
|
||||||
|
def theme(self, value: str) -> None:
|
||||||
|
self._theme_name = value
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the UI layout with enhanced components"""
|
||||||
|
yield Header()
|
||||||
|
yield Static("YouTube TUI - Browse and download videos", id="main-content")
|
||||||
|
yield CustomFooter(self)
|
||||||
|
|
||||||
|
async def action_quit(self) -> None:
|
||||||
|
"""Quit the application"""
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
"""Handle escape key"""
|
||||||
|
if hasattr(self.screen, "action_cancel"):
|
||||||
|
self.screen.action_cancel()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Called when the app is mounted"""
|
||||||
|
# Initialize download queue and manager
|
||||||
|
self.youtube_service = YouTubeService()
|
||||||
|
self.download_queue = DownloadQueue()
|
||||||
|
self.download_manager = DownloadManager(
|
||||||
|
self.download_queue, self.youtube_service
|
||||||
|
)
|
||||||
|
self.download_manager.start_processing()
|
||||||
|
|
||||||
|
self.push_home_screen()
|
||||||
|
self.current_screen = self.screen
|
||||||
|
|
||||||
|
def on_screen_stack_changed(self) -> None:
|
||||||
|
"""Called when the screen stack changes"""
|
||||||
|
current_screen = self.screen
|
||||||
|
if hasattr(current_screen, "search_term"):
|
||||||
|
self.current_search_term = current_screen.search_term
|
||||||
|
self.current_screen = current_screen
|
||||||
|
|
||||||
|
def push_home_screen(self) -> None:
|
||||||
|
"""Push the home screen"""
|
||||||
|
from youtube_tui.screens.home import HomeScreen
|
||||||
|
|
||||||
|
self.push_screen(HomeScreen())
|
||||||
|
|
||||||
|
def push_search_screen(self) -> None:
|
||||||
|
"""Push the search screen"""
|
||||||
|
from youtube_tui.screens.search import SearchScreen
|
||||||
|
|
||||||
|
self.push_screen(SearchScreen())
|
||||||
|
|
||||||
|
def push_results_screen(self, search_term: str, page: int = 1) -> None:
|
||||||
|
"""Push the results screen"""
|
||||||
|
from youtube_tui.screens.results import ResultsScreen
|
||||||
|
|
||||||
|
self.push_screen(ResultsScreen(search_term, page))
|
||||||
|
|
||||||
|
def push_download_screen(self, video: Video) -> None:
|
||||||
|
"""Push the download screen"""
|
||||||
|
from youtube_tui.screens.download import DownloadScreen
|
||||||
|
|
||||||
|
self.push_screen(DownloadScreen(video))
|
||||||
|
|
||||||
|
def push_category_modal(self) -> str:
|
||||||
|
"""Push the category selection modal and return selected category"""
|
||||||
|
from youtube_tui.screens.modal import CategorySelectionModal
|
||||||
|
|
||||||
|
# ModalScreen doesn't have request_screen in textual
|
||||||
|
self.push_screen(CategorySelectionModal())
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _check_yt_dlp(self) -> None:
|
||||||
|
"""Check yt-dlp installation and version"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["yt-dlp", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
self.yt_dlp_version = result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
self.yt_dlp_version = "not installed"
|
||||||
|
|
||||||
|
def load_search_history(self) -> None:
|
||||||
|
"""Load search history from config file"""
|
||||||
|
config_dir = Path.home() / ".config" / "youtube_cli"
|
||||||
|
history_file = config_dir / "search_history.json"
|
||||||
|
|
||||||
|
if history_file.exists():
|
||||||
|
try:
|
||||||
|
with open(history_file, "r") as f:
|
||||||
|
self.search_history = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
self.search_history = []
|
||||||
|
|
||||||
|
def save_search_history(self) -> None:
|
||||||
|
"""Save search history to config file"""
|
||||||
|
config_dir = Path.home() / ".config" / "youtube_cli"
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
history_file = config_dir / "search_history.json"
|
||||||
|
try:
|
||||||
|
with open(history_file, "w") as f:
|
||||||
|
json.dump(self.search_history, f, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass # Silently fail if we can't save
|
||||||
|
|
||||||
|
def add_to_search_history(self, search_term: str) -> None:
|
||||||
|
"""Add a search term to history"""
|
||||||
|
# Remove duplicates
|
||||||
|
self.search_history = [
|
||||||
|
item
|
||||||
|
for item in self.search_history
|
||||||
|
if item.get("search_term") != search_term
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add new entry
|
||||||
|
self.search_history.insert(
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
"search_term": search_term,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep only last 50 searches
|
||||||
|
self.search_history = self.search_history[:50]
|
||||||
|
|
||||||
|
self.save_search_history()
|
||||||
|
|
||||||
|
def action_command_palette(self) -> None:
|
||||||
|
"""Open the command palette"""
|
||||||
|
from youtube_tui.widgets.command_palette import CommandPalette
|
||||||
|
|
||||||
|
self.push_screen(CommandPalette())
|
||||||
|
|
||||||
|
def action_show_help(self) -> None:
|
||||||
|
"""Show the help screen"""
|
||||||
|
from youtube_tui.screens.help import HelpScreen
|
||||||
|
|
||||||
|
self.push_screen(HelpScreen())
|
||||||
|
|
||||||
|
def action_toggle_theme(self) -> None:
|
||||||
|
"""Toggle between dark and light themes"""
|
||||||
|
# Textual 0.43+ has built-in dark/light theme support
|
||||||
|
# We'll cycle through available themes
|
||||||
|
if self.theme == "css":
|
||||||
|
self.theme = "textual-dark"
|
||||||
|
elif self.theme == "textual-dark":
|
||||||
|
self.theme = "textual-light"
|
||||||
|
else:
|
||||||
|
self.theme = "css"
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def action_refresh_screen(self) -> None:
|
||||||
|
"""Refresh the current screen"""
|
||||||
|
current_screen = self.screen
|
||||||
|
if hasattr(current_screen, "refresh"):
|
||||||
|
current_screen.refresh()
|
||||||
|
|
||||||
|
def action_open_search(self) -> None:
|
||||||
|
"""Open the search screen from anywhere"""
|
||||||
|
from youtube_tui.screens.search import SearchScreen
|
||||||
|
|
||||||
|
self.push_screen(SearchScreen())
|
||||||
|
|
||||||
|
def action_open_queue(self) -> None:
|
||||||
|
"""Open the queue screen from anywhere"""
|
||||||
|
from youtube_tui.screens.queue import QueueScreen
|
||||||
|
|
||||||
|
self.push_screen(QueueScreen())
|
||||||
|
|
||||||
|
def on_search_complete(self, search_term: str) -> None:
|
||||||
|
"""Handle search completion"""
|
||||||
|
self.current_search_term = search_term
|
||||||
|
self.add_to_search_history(search_term)
|
||||||
|
|
||||||
|
def set_downloading(self, downloading: bool) -> None:
|
||||||
|
"""Set downloading state for status bar"""
|
||||||
|
self.downloading = downloading
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point for the TUI application"""
|
||||||
|
app = YouTubeTUI()
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
7
models/__init__.py
Normal file
7
models/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Models package for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from youtube_tui.models.video import Video
|
||||||
|
|
||||||
|
__all__ = ["Video"]
|
||||||
144
models/queue_item.py
Normal file
144
models/queue_item.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Download Queue System for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from youtube_tui.models.video import Video
|
||||||
|
|
||||||
|
|
||||||
|
class QueueStatus(Enum):
|
||||||
|
"""Status of a queue item"""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
DOWNLOADING = "downloading"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class QueueItem:
|
||||||
|
"""Represents an item in the download queue"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
video: Optional[Video],
|
||||||
|
category: Optional[str] = None,
|
||||||
|
network_folder: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self._id = uuid.uuid4() # Unique identifier for tracking
|
||||||
|
self.video = video
|
||||||
|
self.category = category
|
||||||
|
self.network_folder = network_folder
|
||||||
|
self.status = QueueStatus.PENDING
|
||||||
|
self.progress = 0
|
||||||
|
self.started_at: Optional[str] = None
|
||||||
|
self.completed_at: Optional[str] = None
|
||||||
|
self.error_message: Optional[str] = None # Error details when failed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> uuid.UUID:
|
||||||
|
"""Get the unique ID of this queue item"""
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
"""Check if this item is currently active (downloading or pending)"""
|
||||||
|
return self.status in (
|
||||||
|
QueueStatus.PENDING,
|
||||||
|
QueueStatus.DOWNLOADING,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_complete(self) -> bool:
|
||||||
|
"""Check if this item has completed (successfully or not)"""
|
||||||
|
return self.status in (
|
||||||
|
QueueStatus.COMPLETED,
|
||||||
|
QueueStatus.CANCELLED,
|
||||||
|
QueueStatus.FAILED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for JSON serialization"""
|
||||||
|
return {
|
||||||
|
"id": str(self._id),
|
||||||
|
"video": self.video.to_dict() if self.video else None,
|
||||||
|
"category": self.category,
|
||||||
|
"network_folder": self.network_folder,
|
||||||
|
"status": self.status.value,
|
||||||
|
"progress": self.progress,
|
||||||
|
"started_at": self.started_at,
|
||||||
|
"completed_at": self.completed_at,
|
||||||
|
"error_message": self.error_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "QueueItem":
|
||||||
|
"""Create QueueItem instance from dictionary"""
|
||||||
|
video_data = data.get("video")
|
||||||
|
video: Video = (
|
||||||
|
Video.from_dict(video_data)
|
||||||
|
if video_data
|
||||||
|
else Video(
|
||||||
|
video_id="",
|
||||||
|
title="Unknown Video",
|
||||||
|
channel="Unknown Channel",
|
||||||
|
channel_id="",
|
||||||
|
duration="0:00",
|
||||||
|
view_count="0",
|
||||||
|
upload_date="",
|
||||||
|
description="",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
item = cls(
|
||||||
|
video=video,
|
||||||
|
category=data.get("category"),
|
||||||
|
network_folder=data.get("network_folder"),
|
||||||
|
)
|
||||||
|
# Parse UUID from string if present
|
||||||
|
item_id = data.get("id")
|
||||||
|
if item_id:
|
||||||
|
try:
|
||||||
|
item._id = uuid.UUID(item_id)
|
||||||
|
except ValueError:
|
||||||
|
pass # Keep auto-generated UUID if parsing fails
|
||||||
|
item.status = QueueStatus(data.get("status", "pending"))
|
||||||
|
item.progress = data.get("progress", 0)
|
||||||
|
item.started_at = data.get("started_at")
|
||||||
|
item.completed_at = data.get("completed_at")
|
||||||
|
item.error_message = data.get("error_message")
|
||||||
|
return item
|
||||||
|
|
||||||
|
def start_download(self) -> None:
|
||||||
|
"""Mark item as downloading"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
self.status = QueueStatus.DOWNLOADING
|
||||||
|
self.started_at = datetime.now().isoformat()
|
||||||
|
|
||||||
|
def update_progress(self, progress: int) -> None:
|
||||||
|
"""Update download progress"""
|
||||||
|
self.progress = max(0, min(100, progress))
|
||||||
|
|
||||||
|
def complete(self) -> None:
|
||||||
|
"""Mark item as completed"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
self.status = QueueStatus.COMPLETED
|
||||||
|
self.progress = 100
|
||||||
|
self.completed_at = datetime.now().isoformat()
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
"""Mark item as cancelled"""
|
||||||
|
self.status = QueueStatus.CANCELLED
|
||||||
|
self.completed_at = None
|
||||||
|
|
||||||
|
def fail(self, error_message: Optional[str] = None) -> None:
|
||||||
|
"""Mark item as failed with optional error message"""
|
||||||
|
self.status = QueueStatus.FAILED
|
||||||
|
self.completed_at = None
|
||||||
|
self.error_message = error_message
|
||||||
77
models/video.py
Normal file
77
models/video.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Video data model for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Video:
|
||||||
|
"""Represents a YouTube video"""
|
||||||
|
|
||||||
|
video_id: str
|
||||||
|
title: str
|
||||||
|
channel: str
|
||||||
|
channel_id: str
|
||||||
|
duration: str
|
||||||
|
view_count: str
|
||||||
|
upload_date: str
|
||||||
|
description: str
|
||||||
|
thumbnail_url: Optional[str] = None
|
||||||
|
is_short: bool = False
|
||||||
|
url: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Post-initialization to set URL and detect shorts"""
|
||||||
|
if not self.url:
|
||||||
|
self.url = f"https://www.youtube.com/watch?v={self.video_id}"
|
||||||
|
|
||||||
|
if "/shorts/" in self.url or self.duration == "0:00":
|
||||||
|
self.is_short = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_title(self) -> str:
|
||||||
|
"""Get title with short indicator"""
|
||||||
|
if self.is_short:
|
||||||
|
return f"(short) {self.title}"
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_duration(self) -> str:
|
||||||
|
"""Get formatted duration"""
|
||||||
|
if self.is_short:
|
||||||
|
return "Short"
|
||||||
|
return self.duration
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for JSON serialization"""
|
||||||
|
return {
|
||||||
|
"video_id": self.video_id,
|
||||||
|
"title": self.title,
|
||||||
|
"channel": self.channel,
|
||||||
|
"channel_id": self.channel_id,
|
||||||
|
"duration": self.duration,
|
||||||
|
"view_count": self.view_count,
|
||||||
|
"upload_date": self.upload_date,
|
||||||
|
"description": self.description,
|
||||||
|
"thumbnail_url": self.thumbnail_url,
|
||||||
|
"is_short": self.is_short,
|
||||||
|
"url": self.url,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Video":
|
||||||
|
"""Create Video instance from dictionary"""
|
||||||
|
return cls(
|
||||||
|
video_id=data["video_id"],
|
||||||
|
title=data["title"],
|
||||||
|
channel=data["channel"],
|
||||||
|
channel_id=data["channel_id"],
|
||||||
|
duration=data["duration"],
|
||||||
|
view_count=data["view_count"],
|
||||||
|
upload_date=data["upload_date"],
|
||||||
|
description=data.get("description", ""),
|
||||||
|
thumbnail_url=data.get("thumbnail_url"),
|
||||||
|
url=data.get("url", ""),
|
||||||
|
)
|
||||||
59
pyproject.toml
Normal file
59
pyproject.toml
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "youtube-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Text-based User Interface (TUI) for YouTube CLI"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
authors = [
|
||||||
|
{name = "Your Name", email = "your.email@example.com"},
|
||||||
|
]
|
||||||
|
license = {text = "MIT"}
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Environment :: Console",
|
||||||
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Multimedia :: Video",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"textual>=8.0",
|
||||||
|
"yt-dlp",
|
||||||
|
"rich",
|
||||||
|
"requests",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
youtube-tui = "youtube_tui.__main__:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/yourusername/youtube-cli"
|
||||||
|
Issues = "https://github.com/yourusername/youtube-cli/issues"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["youtube_tui", "youtube_tui.screens", "youtube_tui.widgets", "youtube_tui.models", "youtube_tui.services"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
youtube_tui = ["py.typed"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
# Skip whitespace checks in CSS strings (Textual styling)
|
||||||
|
# These are intentional blank lines in CSS
|
||||||
|
lint.ignore = ["W293", "W291", "E402"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
warn_return_any = false
|
||||||
|
warn_unused_ignores = false
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
check_untyped_defs = false
|
||||||
|
follow_imports = "skip"
|
||||||
19
screens/__init__.py
Normal file
19
screens/__init__.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
Screens package for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from youtube_tui.screens.download import DownloadScreen
|
||||||
|
from youtube_tui.screens.help import HelpScreen
|
||||||
|
from youtube_tui.screens.history import SearchHistoryScreen
|
||||||
|
from youtube_tui.screens.modal import CategorySelectionModal
|
||||||
|
from youtube_tui.screens.results import ResultsScreen
|
||||||
|
from youtube_tui.screens.search import SearchScreen
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SearchScreen",
|
||||||
|
"ResultsScreen",
|
||||||
|
"DownloadScreen",
|
||||||
|
"CategorySelectionModal",
|
||||||
|
"HelpScreen",
|
||||||
|
"SearchHistoryScreen",
|
||||||
|
]
|
||||||
204
screens/download.py
Normal file
204
screens/download.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Download Screen for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import (
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
ProgressBar,
|
||||||
|
Static,
|
||||||
|
)
|
||||||
|
|
||||||
|
from youtube_tui.models.video import Video
|
||||||
|
from youtube_tui.services.youtube import YouTubeService
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadScreen(Screen):
|
||||||
|
"""Screen for displaying download progress"""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
DownloadScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#download-container {
|
||||||
|
width: 70%;
|
||||||
|
height: auto;
|
||||||
|
border: double #555555;
|
||||||
|
padding: 2 3;
|
||||||
|
margin: 2 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-title {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
content-align: center middle;
|
||||||
|
background: $surface;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 5;
|
||||||
|
margin: 2 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-message {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
content-align: center middle;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#actions {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
dock: bottom;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
width: 15;
|
||||||
|
margin: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-bar {
|
||||||
|
dock: bottom;
|
||||||
|
height: 1;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("escape", "cancel", "Cancel"),
|
||||||
|
("ctrl+r", "refresh_screen", "Refresh"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, video: Video, category: Optional[str] = None):
|
||||||
|
super().__init__()
|
||||||
|
self.youtube_service = YouTubeService()
|
||||||
|
self.video = video
|
||||||
|
self.category = category
|
||||||
|
self.download_complete = False
|
||||||
|
self.download_error = False
|
||||||
|
self.download_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the download screen"""
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Static(f"[bold]{self.video.display_title}[/bold]", id="video-title"),
|
||||||
|
Static("Preparing download...", id="status-message"),
|
||||||
|
Container(
|
||||||
|
ProgressBar(total=100, id="progress-bar"),
|
||||||
|
id="progress-container",
|
||||||
|
),
|
||||||
|
id="download-container",
|
||||||
|
)
|
||||||
|
yield Static(id="status-bar")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Called when screen is mounted"""
|
||||||
|
self.update_status("[blue]Starting download...[/blue]")
|
||||||
|
# Use asyncio.create_task instead of app.run_background
|
||||||
|
self.download_task = asyncio.create_task(self.start_download())
|
||||||
|
|
||||||
|
def action_refresh_screen(self) -> None:
|
||||||
|
"""Refresh the screen"""
|
||||||
|
# For download screen, refresh just updates the status
|
||||||
|
self.update_status("[blue]Status: Download in progress...[/blue]")
|
||||||
|
|
||||||
|
async def start_download(self) -> None:
|
||||||
|
"""Start the download process"""
|
||||||
|
try:
|
||||||
|
async def progress_callback(percentage: int) -> bool:
|
||||||
|
"""Update progress bar during download"""
|
||||||
|
self.update_progress(percentage)
|
||||||
|
self.update_status(
|
||||||
|
f"[blue]Downloading... {percentage}%[/blue]"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
success = await self.youtube_service.download_video(
|
||||||
|
self.video, self.category, progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.download_complete = True
|
||||||
|
self.update_status("[green]Download completed![/green]")
|
||||||
|
self.update_progress(100)
|
||||||
|
# Wait briefly before returning
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
self.app.pop_screen()
|
||||||
|
else:
|
||||||
|
self.download_error = True
|
||||||
|
self.update_status("[red]Download failed![/red]")
|
||||||
|
# Wait briefly before returning
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Task was cancelled
|
||||||
|
self.download_error = True
|
||||||
|
self.update_status("[yellow]Download cancelled[/yellow]")
|
||||||
|
self.app.pop_screen()
|
||||||
|
except Exception as e:
|
||||||
|
self.download_error = True
|
||||||
|
self.update_status(f"[red]Error: {e}[/red]")
|
||||||
|
# Wait briefly before returning
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def update_progress(self, percentage: int) -> None:
|
||||||
|
"""Update the progress bar"""
|
||||||
|
progress_bar = self.query_one("#progress-bar", ProgressBar)
|
||||||
|
progress_bar.progress = percentage
|
||||||
|
|
||||||
|
def update_status(self, message: str) -> None:
|
||||||
|
"""Update the status message"""
|
||||||
|
status_message = self.query_one("#status-message", Static)
|
||||||
|
status_message.update(message)
|
||||||
|
|
||||||
|
status_bar = self.query_one("#status-bar", Static)
|
||||||
|
status_bar.update(f"[bold white]{message}[/bold white]")
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
"""Cancel the download"""
|
||||||
|
# Check if there's a download manager and queue to cancel
|
||||||
|
if hasattr(self.app, "download_manager") and self.app.download_manager:
|
||||||
|
# Cancel via the download manager
|
||||||
|
self.app.download_manager.cancel_active_download()
|
||||||
|
|
||||||
|
if self.download_task:
|
||||||
|
self.download_task.cancel()
|
||||||
|
self.download_error = True
|
||||||
|
self.update_status("[yellow]Download cancelled[/yellow]")
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def on_unload(self) -> None:
|
||||||
|
"""Called when screen is unloaded"""
|
||||||
|
if self.download_complete:
|
||||||
|
# Show success message briefly before returning
|
||||||
|
self.app.notify(
|
||||||
|
f"Downloaded: {self.video.display_title}",
|
||||||
|
title="Success",
|
||||||
|
severity="information",
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
elif self.download_error:
|
||||||
|
self.app.notify(
|
||||||
|
f"Failed to download: {self.video.display_title}",
|
||||||
|
title="Error",
|
||||||
|
severity="error",
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
183
screens/help.py
Normal file
183
screens/help.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Help Screen for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container, VerticalScroll
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Footer, Header, Static
|
||||||
|
|
||||||
|
|
||||||
|
class HelpScreen(ModalScreen):
|
||||||
|
"""Help screen with keyboard shortcuts documentation"""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
HelpScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-container {
|
||||||
|
width: 80%;
|
||||||
|
height: 80%;
|
||||||
|
border: solid #555555;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-title {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
background: $primary;
|
||||||
|
content-align: center middle;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
width: 100%;
|
||||||
|
height: 2;
|
||||||
|
margin: 1 0;
|
||||||
|
color: $primary;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-row {
|
||||||
|
height: 2;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key {
|
||||||
|
width: 20;
|
||||||
|
color: $accent;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-desc {
|
||||||
|
width: 100%;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-description {
|
||||||
|
width: 100%;
|
||||||
|
height: 6;
|
||||||
|
margin: 1 0;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
#config-info {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1 0;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#close-hint {
|
||||||
|
width: 100%;
|
||||||
|
height: 2;
|
||||||
|
dock: bottom;
|
||||||
|
text-align: center;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("escape", "close_help", "Close"),
|
||||||
|
("q", "close_help", "Close"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the help screen"""
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Static("YouTube TUI Help", id="help-title"),
|
||||||
|
VerticalScroll(
|
||||||
|
Static(
|
||||||
|
"A Text-based User Interface for browsing and downloading YouTube videos",
|
||||||
|
id="app-description",
|
||||||
|
),
|
||||||
|
Static("Global Keyboard Shortcuts", classes="section-title"),
|
||||||
|
Static(
|
||||||
|
"[key]q[/key] [desc]Quit application[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]escape[/key] [desc]Cancel current operation / go back[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]ctrl+p[/key] [desc]Open command palette[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]ctrl+h[/key] [desc]Show this help screen[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]ctrl+t[/key] [desc]Toggle theme (dark/light)[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]ctrl+r[/key] [desc]Refresh current screen[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]ctrl+f[/key] [desc]Open search from any screen[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static("Search Screen Shortcuts", classes="section-title"),
|
||||||
|
Static(
|
||||||
|
"[key]enter[/key] [desc]Perform search[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static("Results Screen Shortcuts", classes="section-title"),
|
||||||
|
Static(
|
||||||
|
"[key]n[/key] [desc]Next page[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]p[/key] [desc]Previous page[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]enter[/key] [desc]Download selected video[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]escape[/key] [desc]Go back to search[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"Category Selection Modal Shortcuts",
|
||||||
|
classes="section-title",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]arrow keys[/key] [desc]Navigate options[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]enter[/key] [desc]Select category[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"[key]escape[/key] [desc]Cancel[/desc]",
|
||||||
|
classes="shortcut-row",
|
||||||
|
),
|
||||||
|
Static("Configuration", classes="section-title"),
|
||||||
|
Static(
|
||||||
|
"Configuration file: [path]~/.config/youtube_cli/config.json[/path]",
|
||||||
|
classes="config-info",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"Archive file: [path]~/.config/youtube_cli/downloaded_videos.json[/path]",
|
||||||
|
classes="config-info",
|
||||||
|
),
|
||||||
|
id="help-content",
|
||||||
|
),
|
||||||
|
Static("Press [key]ESC[/key] or [key]Q[/key] to close", id="close-hint"),
|
||||||
|
id="help-container",
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def action_close_help(self) -> None:
|
||||||
|
"""Close the help screen"""
|
||||||
|
self.app.pop_screen()
|
||||||
186
screens/history.py
Normal file
186
screens/history.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
Search History Screen for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Footer, Header, ListItem, ListView, Static
|
||||||
|
|
||||||
|
|
||||||
|
class SearchHistoryScreen(ModalScreen):
|
||||||
|
"""Screen showing search history"""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
SearchHistoryScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#history-container {
|
||||||
|
width: 70%;
|
||||||
|
height: 70%;
|
||||||
|
border: solid #555555;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#history-title {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
background: $primary;
|
||||||
|
content-align: center middle;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem {
|
||||||
|
height: 3;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem:hover {
|
||||||
|
background: $primary-darken-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem.--highlight {
|
||||||
|
background: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
height: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-time {
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clear-btn {
|
||||||
|
margin: 1 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("escape", "close_history", "Close"),
|
||||||
|
("q", "close_history", "Close"),
|
||||||
|
("d", "delete_selected", "Delete Selected"),
|
||||||
|
("c", "clear_all", "Clear All"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.history_file = (
|
||||||
|
Path.home() / ".config" / "youtube_cli" / "search_history.json"
|
||||||
|
)
|
||||||
|
self.search_history = []
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the history screen"""
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Static("Search History", id="history-title"),
|
||||||
|
ListView(id="history-list"),
|
||||||
|
id="history-container",
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Load history on mount"""
|
||||||
|
self.load_history()
|
||||||
|
|
||||||
|
def load_history(self) -> None:
|
||||||
|
"""Load search history from file"""
|
||||||
|
self.search_history = []
|
||||||
|
|
||||||
|
if self.history_file.exists():
|
||||||
|
try:
|
||||||
|
with open(self.history_file, "r") as f:
|
||||||
|
self.search_history = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
self.search_history = []
|
||||||
|
|
||||||
|
# Reverse to show newest first
|
||||||
|
self.search_history = list(reversed(self.search_history))
|
||||||
|
|
||||||
|
list_view = self.query_one("#history-list", ListView)
|
||||||
|
list_view.clear()
|
||||||
|
|
||||||
|
for item in self.search_history:
|
||||||
|
search_term = item.get("search_term", "")
|
||||||
|
timestamp = item.get("timestamp", "")
|
||||||
|
|
||||||
|
# Format timestamp
|
||||||
|
if timestamp:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
dt = datetime.fromisoformat(timestamp)
|
||||||
|
time_str = dt.strftime("%Y-%m-%d %H:%M")
|
||||||
|
except Exception:
|
||||||
|
time_str = timestamp
|
||||||
|
else:
|
||||||
|
time_str = "Unknown time"
|
||||||
|
|
||||||
|
list_view.append(
|
||||||
|
ListItem(Static(f"[bold]{search_term}[/bold]\n[dim]{time_str}[/dim]"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_delete_selected(self) -> None:
|
||||||
|
"""Delete selected history item"""
|
||||||
|
list_view = self.query_one("#history-list", ListView)
|
||||||
|
if list_view.children:
|
||||||
|
# Get the selected item
|
||||||
|
selected_index = list_view.index
|
||||||
|
if (
|
||||||
|
selected_index is not None
|
||||||
|
and selected_index >= 0
|
||||||
|
and selected_index < len(self.search_history)
|
||||||
|
):
|
||||||
|
# Remove from history
|
||||||
|
del self.search_history[selected_index]
|
||||||
|
self.save_history()
|
||||||
|
self.load_history()
|
||||||
|
|
||||||
|
def action_clear_all(self) -> None:
|
||||||
|
"""Clear all history"""
|
||||||
|
self.search_history = []
|
||||||
|
self.save_history()
|
||||||
|
self.load_history()
|
||||||
|
self.notify("Search history cleared", timeout=2)
|
||||||
|
|
||||||
|
def save_history(self) -> None:
|
||||||
|
"""Save history to file"""
|
||||||
|
try:
|
||||||
|
# Ensure directory exists
|
||||||
|
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(self.history_file, "w") as f:
|
||||||
|
json.dump(list(reversed(self.search_history)), f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
self.notify(f"Error saving history: {e}", severity="error", timeout=3)
|
||||||
|
|
||||||
|
def action_close_history(self) -> None:
|
||||||
|
"""Close the history screen"""
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||||
|
"""Handle item selection"""
|
||||||
|
# Use event.index directly, ignore list_view
|
||||||
|
item_index = event.index
|
||||||
|
|
||||||
|
if 0 <= item_index < len(self.search_history):
|
||||||
|
# Get the search term
|
||||||
|
search_term = self.search_history[item_index].get("search_term", "")
|
||||||
|
|
||||||
|
# Push search screen with the term
|
||||||
|
self.app.push_screen("search")
|
||||||
|
# Note: We'd need to expose the search screen to set the term
|
||||||
|
# For now, just notify
|
||||||
|
self.notify(f"Selected: {search_term}", timeout=2)
|
||||||
192
screens/home.py
Normal file
192
screens/home.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Home Screen for YouTube TUI
|
||||||
|
Main dashboard with quick actions
|
||||||
|
"""
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Static,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeScreen(Screen):
|
||||||
|
"""Main dashboard screen with quick actions"""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
HomeScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#welcome-container {
|
||||||
|
width: 60%;
|
||||||
|
height: auto;
|
||||||
|
border: double #555555;
|
||||||
|
padding: 2 3;
|
||||||
|
margin: 2 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#welcome-title {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
content-align: center middle;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#welcome-info {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 2;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#quick-actions {
|
||||||
|
width: 100%;
|
||||||
|
layout: grid;
|
||||||
|
grid-gutter: 1;
|
||||||
|
grid-columns: 2;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-info {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 2;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-bar {
|
||||||
|
dock: bottom;
|
||||||
|
height: 1;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("s", "open_search", "Search"),
|
||||||
|
("q", "open_queue", "Queue"),
|
||||||
|
("h", "open_history", "History"),
|
||||||
|
("ctrl+h", "show_help", "Help"),
|
||||||
|
("q", "quit", "Quit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.download_queue = None
|
||||||
|
self.download_manager = None
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the home screen"""
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Static(
|
||||||
|
"[bold cyan]YouTube CLI[/bold cyan] - Browse and download videos",
|
||||||
|
id="welcome-title",
|
||||||
|
),
|
||||||
|
Static(
|
||||||
|
"Use keyboard shortcuts or buttons to navigate",
|
||||||
|
id="welcome-info",
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
Button("Search Videos", id="search-btn"),
|
||||||
|
Button("Download Queue", id="queue-btn"),
|
||||||
|
Button("Search History", id="history-btn"),
|
||||||
|
Button("Help", id="help-btn"),
|
||||||
|
id="quick-actions",
|
||||||
|
),
|
||||||
|
Static(id="status-info"),
|
||||||
|
id="welcome-container",
|
||||||
|
)
|
||||||
|
yield Static(id="status-bar")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Called when screen is mounted"""
|
||||||
|
if hasattr(self.app, "download_queue"):
|
||||||
|
self.download_queue = self.app.download_queue
|
||||||
|
if hasattr(self.app, "download_manager"):
|
||||||
|
self.download_manager = self.app.download_manager
|
||||||
|
|
||||||
|
self.update_queue_status()
|
||||||
|
self.update_status("[green]Welcome to YouTube TUI - Press 's' to search[/green]")
|
||||||
|
|
||||||
|
def update_queue_status(self) -> None:
|
||||||
|
"""Update queue status info"""
|
||||||
|
if self.download_manager:
|
||||||
|
status = self.download_manager.get_queue_status()
|
||||||
|
status_text = (
|
||||||
|
f"Queue: {status['pending_count']} pending, "
|
||||||
|
f"{status['downloading_count']} downloading, "
|
||||||
|
f"{status['total_count']} total"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status_text = "Queue: No active downloads"
|
||||||
|
|
||||||
|
status_info = self.query_one("#status-info", Static)
|
||||||
|
status_info.update(f"[dim]{status_text}[/dim]")
|
||||||
|
|
||||||
|
def update_status(self, message: str) -> None:
|
||||||
|
"""Update the status bar message"""
|
||||||
|
status_bar = self.query_one("#status-bar", Static)
|
||||||
|
status_bar.update(f"[bold white]{message}[/bold white]")
|
||||||
|
|
||||||
|
def action_open_search(self) -> None:
|
||||||
|
"""Open search screen"""
|
||||||
|
if hasattr(self.app, "push_search_screen"):
|
||||||
|
self.app.push_search_screen()
|
||||||
|
else:
|
||||||
|
from youtube_tui.screens.search import SearchScreen
|
||||||
|
|
||||||
|
self.app.push_screen(SearchScreen())
|
||||||
|
|
||||||
|
def action_open_queue(self) -> None:
|
||||||
|
"""Open queue screen"""
|
||||||
|
if hasattr(self.app, "action_open_queue"):
|
||||||
|
self.app.action_open_queue()
|
||||||
|
else:
|
||||||
|
from youtube_tui.screens.queue import QueueScreen
|
||||||
|
|
||||||
|
self.app.push_screen(QueueScreen())
|
||||||
|
|
||||||
|
def action_open_history(self) -> None:
|
||||||
|
"""Open search history"""
|
||||||
|
from youtube_tui.screens.history import SearchHistoryScreen
|
||||||
|
|
||||||
|
self.app.push_screen(SearchHistoryScreen())
|
||||||
|
|
||||||
|
def action_show_help(self) -> None:
|
||||||
|
"""Show help screen"""
|
||||||
|
from youtube_tui.screens.help import HelpScreen
|
||||||
|
|
||||||
|
self.app.push_screen(HelpScreen())
|
||||||
|
|
||||||
|
def action_quit(self) -> None:
|
||||||
|
"""Quit the application"""
|
||||||
|
self.app.exit()
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
"""Handle escape key"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle button presses"""
|
||||||
|
if event.button.id == "search-btn":
|
||||||
|
self.action_open_search()
|
||||||
|
elif event.button.id == "queue-btn":
|
||||||
|
self.action_open_queue()
|
||||||
|
elif event.button.id == "history-btn":
|
||||||
|
self.action_open_history()
|
||||||
|
elif event.button.id == "help-btn":
|
||||||
|
self.action_show_help()
|
||||||
253
screens/modal.py
Normal file
253
screens/modal.py
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Category Selection Modal for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container, Vertical
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Input,
|
||||||
|
ListItem,
|
||||||
|
ListView,
|
||||||
|
Static,
|
||||||
|
)
|
||||||
|
|
||||||
|
from youtube_tui.services.youtube import YouTubeService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CategorySelectionModal(ModalScreen):
|
||||||
|
"""Modal for selecting a download category"""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
CategorySelectionModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-container {
|
||||||
|
width: 60%;
|
||||||
|
height: auto;
|
||||||
|
border: solid #555555;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-title {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
background: $primary;
|
||||||
|
content-align: center middle;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
#categories-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 20;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#custom-input {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-actions {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
dock: bottom;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
width: 15;
|
||||||
|
margin: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem {
|
||||||
|
height: 3;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem:hover {
|
||||||
|
background: $primary-darken-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem.--highlight {
|
||||||
|
background: $primary;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("escape", "close_modal", "Cancel"),
|
||||||
|
("enter", "select_category", "Select"),
|
||||||
|
("up", "cursor_up", "Cursor Up"),
|
||||||
|
("down", "cursor_down", "Cursor Down"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.youtube_service = YouTubeService()
|
||||||
|
self.selected_category: Optional[str] = None
|
||||||
|
self.selected_index = 0
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the modal"""
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Static("Select Download Category", id="modal-title"),
|
||||||
|
Vertical(
|
||||||
|
Static("Available Categories:", id="categories-label"),
|
||||||
|
ListView(id="categories-list"),
|
||||||
|
Static("Or type custom folder name:", id="custom-label"),
|
||||||
|
Input(placeholder="Enter custom folder name...", id="custom-input"),
|
||||||
|
id="categories-container",
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
Button("Select", id="select-btn"),
|
||||||
|
Button("Cancel", id="cancel-btn"),
|
||||||
|
id="modal-actions",
|
||||||
|
),
|
||||||
|
id="modal-container",
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Called when modal is mounted"""
|
||||||
|
self.load_categories()
|
||||||
|
self.update_status("Use arrow keys to select, Enter to confirm")
|
||||||
|
|
||||||
|
def load_categories(self) -> None:
|
||||||
|
"""Load available categories into the list"""
|
||||||
|
list_view = self.query_one("#categories-list", ListView)
|
||||||
|
list_view.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Note: This is called from on_mount which is sync
|
||||||
|
# In a real async context, this should be awaited
|
||||||
|
categories: list = self.youtube_service.cli.get_categories(
|
||||||
|
self.youtube_service.cli.config
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"load_categories: categories={categories}")
|
||||||
|
|
||||||
|
for category in categories:
|
||||||
|
# Extract folder name for display
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
folder_name = Path(category).name if Path(category).name else "Root"
|
||||||
|
category_id = Path(category).name.replace(" ", "-").replace("/", "-")
|
||||||
|
logger.debug(
|
||||||
|
f"load_categories: category={category}, folder_name={folder_name}, category_id={category_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a custom widget for the item
|
||||||
|
item = ListItem(
|
||||||
|
Static(f" {folder_name}"), id=f"category-{category_id}"
|
||||||
|
)
|
||||||
|
list_view.append(item)
|
||||||
|
|
||||||
|
# Highlight first item
|
||||||
|
if list_view.children:
|
||||||
|
list_view.children[0].add_class("--highlight")
|
||||||
|
self.selected_index = 0
|
||||||
|
logger.debug(
|
||||||
|
f"load_categories: first item highlighted, selected_index={self.selected_index}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
list_view.append(
|
||||||
|
ListItem(Static(f"[red]Error loading categories: {e}[/red]"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_status(self, message: str) -> None:
|
||||||
|
"""Update the modal status"""
|
||||||
|
# We could add a status line if needed
|
||||||
|
pass
|
||||||
|
|
||||||
|
def action_select_category(self) -> None:
|
||||||
|
"""Select the current category"""
|
||||||
|
list_view = self.query_one("#categories-list", ListView)
|
||||||
|
|
||||||
|
# Debug logging
|
||||||
|
logger.debug(
|
||||||
|
f"action_select_category: selected_index={self.selected_index}, children_count={len(list_view.children)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if list_view.children and 0 <= self.selected_index < len(list_view.children):
|
||||||
|
# Get the selected item
|
||||||
|
item = list_view.children[self.selected_index]
|
||||||
|
category_id = item.id
|
||||||
|
logger.debug(f"action_select_category: item.id={category_id}")
|
||||||
|
if category_id and category_id.startswith("category-"):
|
||||||
|
self.selected_category = category_id.replace("category-", "")
|
||||||
|
logger.debug(
|
||||||
|
f"action_select_category: selected_category={self.selected_category}"
|
||||||
|
)
|
||||||
|
self.dismiss(self.selected_category)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check custom input
|
||||||
|
custom_input = self.query_one("#custom-input", Input)
|
||||||
|
custom_name = custom_input.value.strip()
|
||||||
|
if custom_name:
|
||||||
|
self.selected_category = custom_name
|
||||||
|
self.dismiss(self.selected_category)
|
||||||
|
return
|
||||||
|
|
||||||
|
# No valid selection
|
||||||
|
self.update_status("[red]Please select a category or enter a custom name[/red]")
|
||||||
|
|
||||||
|
def action_cursor_up(self) -> None:
|
||||||
|
"""Move cursor up"""
|
||||||
|
list_view = self.query_one("#categories-list", ListView)
|
||||||
|
if list_view.children:
|
||||||
|
# Remove highlight from current item
|
||||||
|
if 0 <= self.selected_index < len(list_view.children):
|
||||||
|
list_view.children[self.selected_index].remove_class("--highlight")
|
||||||
|
|
||||||
|
# Move up
|
||||||
|
self.selected_index = max(0, self.selected_index - 1)
|
||||||
|
|
||||||
|
# Highlight new item
|
||||||
|
list_view.children[self.selected_index].add_class("--highlight")
|
||||||
|
|
||||||
|
def action_cursor_down(self) -> None:
|
||||||
|
"""Move cursor down"""
|
||||||
|
list_view = self.query_one("#categories-list", ListView)
|
||||||
|
if list_view.children:
|
||||||
|
# Remove highlight from current item
|
||||||
|
if 0 <= self.selected_index < len(list_view.children):
|
||||||
|
list_view.children[self.selected_index].remove_class("--highlight")
|
||||||
|
|
||||||
|
# Move down
|
||||||
|
self.selected_index = min(
|
||||||
|
len(list_view.children) - 1, self.selected_index + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Highlight new item
|
||||||
|
list_view.children[self.selected_index].add_class("--highlight")
|
||||||
|
|
||||||
|
def action_close_modal(self) -> None:
|
||||||
|
"""Close modal without selecting"""
|
||||||
|
self.selected_category = None
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle button presses"""
|
||||||
|
if event.button.id == "select-btn":
|
||||||
|
self.action_select_category()
|
||||||
|
elif event.button.id == "cancel-btn":
|
||||||
|
self.action_close_modal()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
"""Handle enter key in custom input"""
|
||||||
|
self.action_select_category()
|
||||||
433
screens/queue.py
Normal file
433
screens/queue.py
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Queue Screen for YouTube TUI
|
||||||
|
Displays and manages the download queue
|
||||||
|
"""
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Static,
|
||||||
|
)
|
||||||
|
|
||||||
|
from youtube_tui.models.queue_item import QueueStatus
|
||||||
|
|
||||||
|
|
||||||
|
class QueueScreen(Screen):
|
||||||
|
"""Screen for displaying and managing the download queue"""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
QueueScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#queue-container {
|
||||||
|
width: 95%;
|
||||||
|
height: 70%;
|
||||||
|
border: solid #555555;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#queue-title {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
background: $surface;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stats-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
background: $surface;
|
||||||
|
content-align: center middle;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-bar {
|
||||||
|
dock: bottom;
|
||||||
|
height: 1;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
width: 15;
|
||||||
|
margin: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable .datatable-row-highlight {
|
||||||
|
background: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable .datatable-header {
|
||||||
|
background: $primary-darken-2;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("q", "go_back", "Back"),
|
||||||
|
("escape", "go_back", "Back"),
|
||||||
|
("ctrl+r", "refresh_screen", "Refresh"),
|
||||||
|
("d", "download_selected", "Download Now"),
|
||||||
|
("r", "remove_selected", "Remove"),
|
||||||
|
("y", "retry_selected", "Retry"),
|
||||||
|
("c", "clear_completed", "Clear Completed"),
|
||||||
|
("f", "clear_failed", "Clear Failed"),
|
||||||
|
("ctrl+f", "search_from_anywhere", "Search"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.youtube_service = None
|
||||||
|
self.download_queue = None
|
||||||
|
self.download_manager = None
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the queue screen"""
|
||||||
|
yield Header()
|
||||||
|
yield Static("Download Queue", id="queue-title")
|
||||||
|
yield Container(
|
||||||
|
Static(id="stats-container"),
|
||||||
|
DataTable(id="queue-table", show_cursor=False),
|
||||||
|
id="queue-container",
|
||||||
|
)
|
||||||
|
yield Container(
|
||||||
|
Button("← Back", id="back-btn"),
|
||||||
|
Button("Refresh", id="refresh-btn"),
|
||||||
|
Button("Remove", id="remove-btn"),
|
||||||
|
Button("Retry", id="retry-btn"),
|
||||||
|
Button("Clear Done", id="clear-done-btn"),
|
||||||
|
Button("Clear Failed", id="clear-failed-btn"),
|
||||||
|
id="queue-controls",
|
||||||
|
)
|
||||||
|
yield Static(id="status-bar")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Called when screen is mounted"""
|
||||||
|
# Get references from app
|
||||||
|
if hasattr(self.app, "youtube_service"):
|
||||||
|
self.youtube_service = self.app.youtube_service
|
||||||
|
if hasattr(self.app, "download_queue"):
|
||||||
|
self.download_queue = self.app.download_queue
|
||||||
|
if hasattr(self.app, "download_manager"):
|
||||||
|
self.download_manager = self.app.download_manager
|
||||||
|
|
||||||
|
self.update_table()
|
||||||
|
self.update_stats()
|
||||||
|
self.update_status("[green]Queue loaded[/green]")
|
||||||
|
|
||||||
|
# Start auto-refresh if downloads active
|
||||||
|
if self.download_manager and self.download_manager.is_processing():
|
||||||
|
self.set_interval(2, self._auto_refresh)
|
||||||
|
|
||||||
|
def _auto_refresh(self) -> None:
|
||||||
|
"""Auto-refresh queue table every 2 seconds"""
|
||||||
|
self.update_table()
|
||||||
|
self.update_stats()
|
||||||
|
|
||||||
|
# Stop auto-refresh when no downloads active
|
||||||
|
if self.download_manager:
|
||||||
|
status = self.download_manager.get_queue_status()
|
||||||
|
if not status.get("has_active_download") and not status.get("pending_count"):
|
||||||
|
self.clear_interval(self._auto_refresh)
|
||||||
|
|
||||||
|
def action_refresh_screen(self) -> None:
|
||||||
|
"""Refresh the screen"""
|
||||||
|
self.update_table()
|
||||||
|
self.update_stats()
|
||||||
|
self.update_status("[blue]Refreshed queue[/blue]")
|
||||||
|
|
||||||
|
def action_search_from_anywhere(self) -> None:
|
||||||
|
"""Open search from anywhere"""
|
||||||
|
if hasattr(self.app, "action_open_search"):
|
||||||
|
self.app.action_open_search()
|
||||||
|
else:
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def update_table(self) -> None:
|
||||||
|
"""Update the DataTable with queue items"""
|
||||||
|
table = self.query_one("#queue-table", DataTable)
|
||||||
|
|
||||||
|
# Get queue items
|
||||||
|
if self.download_queue:
|
||||||
|
items = self.download_queue.get_queue()
|
||||||
|
|
||||||
|
# Clear and rebuild columns
|
||||||
|
table.clear()
|
||||||
|
|
||||||
|
# Set up columns
|
||||||
|
table.add_columns("Status", "Title", "Category", "Progress")
|
||||||
|
table.add_columns("Started", "Completed")
|
||||||
|
table.add_columns("Actions")
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
# Get status text with color
|
||||||
|
status = item.status.value
|
||||||
|
status_color = {
|
||||||
|
QueueStatus.PENDING: "yellow",
|
||||||
|
QueueStatus.DOWNLOADING: "blue",
|
||||||
|
QueueStatus.COMPLETED: "green",
|
||||||
|
QueueStatus.CANCELLED: "yellow",
|
||||||
|
QueueStatus.FAILED: "red",
|
||||||
|
}.get(item.status, "white")
|
||||||
|
|
||||||
|
# Truncate long titles
|
||||||
|
title = item.video.display_title if item.video else "Unknown"
|
||||||
|
if len(title) > 40:
|
||||||
|
title = title[:37] + "..."
|
||||||
|
|
||||||
|
# Get category
|
||||||
|
category = item.category or "Default"
|
||||||
|
|
||||||
|
# Format progress
|
||||||
|
progress = f"{item.progress}%"
|
||||||
|
if item.status == QueueStatus.DOWNLOADING:
|
||||||
|
progress = f"[blue]{progress}[/blue]"
|
||||||
|
|
||||||
|
# Format timestamps
|
||||||
|
started_at = item.started_at or "-"
|
||||||
|
completed_at = item.completed_at or "-"
|
||||||
|
|
||||||
|
row_key = item.video.video_id if item.video else ""
|
||||||
|
|
||||||
|
# Determine actions for this row
|
||||||
|
actions = ""
|
||||||
|
if item.status == QueueStatus.FAILED:
|
||||||
|
actions = "[yellow]Retry[/yellow]"
|
||||||
|
|
||||||
|
# Use add_row with check for duplicate
|
||||||
|
try:
|
||||||
|
table.add_row(
|
||||||
|
f"[{status_color}]{status}[/{status_color}]",
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
progress,
|
||||||
|
started_at,
|
||||||
|
completed_at,
|
||||||
|
actions,
|
||||||
|
key=row_key,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Row already exists, skip it
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Focus the table
|
||||||
|
table.focus()
|
||||||
|
|
||||||
|
def update_stats(self) -> None:
|
||||||
|
"""Update the queue statistics"""
|
||||||
|
if self.download_queue:
|
||||||
|
stats = self.download_queue.get_stats()
|
||||||
|
stats_text = (
|
||||||
|
f"Total: {stats['total']} | "
|
||||||
|
f"Pending: {stats['pending']} | "
|
||||||
|
f"Downloading: {stats['downloading']} | "
|
||||||
|
f"Completed: {stats['completed']} | "
|
||||||
|
f"Cancelled: {stats['cancelled']} | "
|
||||||
|
f"Failed: {stats['failed']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
stats_container = self.query_one("#stats-container", Static)
|
||||||
|
stats_container.update(f"[bold]{stats_text}[/bold]")
|
||||||
|
|
||||||
|
def update_status(self, message: str) -> None:
|
||||||
|
"""Update the status bar message"""
|
||||||
|
status_bar = self.query_one("#status-bar", Static)
|
||||||
|
status_bar.update(f"[bold white]{message}[/bold white]")
|
||||||
|
|
||||||
|
def action_download_selected(self) -> None:
|
||||||
|
"""Download selected item immediately"""
|
||||||
|
table = self.query_one("#queue-table", DataTable)
|
||||||
|
selected_row = table.cursor_row
|
||||||
|
|
||||||
|
if selected_row < 0:
|
||||||
|
self.update_status("[yellow]Select an item to download[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
items = self.download_queue.get_queue() if self.download_queue else []
|
||||||
|
if selected_row >= len(items):
|
||||||
|
self.update_status("[yellow]Invalid selection[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
item = items[selected_row]
|
||||||
|
|
||||||
|
if item.status == QueueStatus.PENDING:
|
||||||
|
# Start the download manager if not running
|
||||||
|
if self.download_manager and not self.download_manager.is_processing():
|
||||||
|
self.download_manager.start()
|
||||||
|
|
||||||
|
# Force immediate download by moving item to top
|
||||||
|
# In a real implementation, we'd have a priority queue
|
||||||
|
self.update_status(
|
||||||
|
f"[blue]Downloading: {item.video.display_title if item.video else 'Unknown'}[/blue]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.update_status("[yellow]Only pending items can be downloaded[/yellow]")
|
||||||
|
|
||||||
|
def action_retry_selected(self) -> None:
|
||||||
|
"""Retry selected failed item"""
|
||||||
|
table = self.query_one("#queue-table", DataTable)
|
||||||
|
selected_row = table.cursor_row
|
||||||
|
|
||||||
|
if selected_row < 0:
|
||||||
|
self.update_status("[yellow]Select an item to retry[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
items = self.download_queue.get_queue() if self.download_queue else []
|
||||||
|
if selected_row >= len(items):
|
||||||
|
return
|
||||||
|
|
||||||
|
item = items[selected_row]
|
||||||
|
|
||||||
|
# Only allow retrying failed items
|
||||||
|
if item.status != QueueStatus.FAILED:
|
||||||
|
self.update_status("[yellow]Only failed items can be retried[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
if item.video:
|
||||||
|
# Reset the item status to PENDING
|
||||||
|
item.status = QueueStatus.PENDING
|
||||||
|
item.started_at = None
|
||||||
|
item.completed_at = None
|
||||||
|
item.progress = 0
|
||||||
|
|
||||||
|
# Re-add to download queue
|
||||||
|
if self.download_queue.add_video(item.video, item.category):
|
||||||
|
self.update_status(
|
||||||
|
f"[green]Retrying: {item.video.display_title}[/green]"
|
||||||
|
)
|
||||||
|
self.app.notify(
|
||||||
|
f"Retrying: {item.video.display_title}",
|
||||||
|
title="Queue",
|
||||||
|
severity="information",
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.update_status("[red]Failed to retry item[/red]")
|
||||||
|
else:
|
||||||
|
self.update_status("[red]Invalid item for retry[/red]")
|
||||||
|
|
||||||
|
self.update_table()
|
||||||
|
self.update_stats()
|
||||||
|
|
||||||
|
def action_remove_selected(self) -> None:
|
||||||
|
"""Remove selected item from queue"""
|
||||||
|
table = self.query_one("#queue-table", DataTable)
|
||||||
|
selected_row = table.cursor_row
|
||||||
|
|
||||||
|
if selected_row < 0:
|
||||||
|
self.update_status("[yellow]Select an item to remove[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
items = self.download_queue.get_queue() if self.download_queue else []
|
||||||
|
if selected_row >= len(items):
|
||||||
|
return
|
||||||
|
|
||||||
|
item = items[selected_row]
|
||||||
|
|
||||||
|
if item.video:
|
||||||
|
if self.download_queue.remove_video(item.video.video_id):
|
||||||
|
self.update_status(
|
||||||
|
f"[green]Removed: {item.video.display_title}[/green]"
|
||||||
|
)
|
||||||
|
self.app.notify(
|
||||||
|
f"Removed: {item.video.display_title}",
|
||||||
|
title="Queue",
|
||||||
|
severity="information",
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.update_status("[red]Failed to remove item[/red]")
|
||||||
|
|
||||||
|
self.update_table()
|
||||||
|
self.update_stats()
|
||||||
|
|
||||||
|
def action_clear_completed(self) -> None:
|
||||||
|
"""Clear completed and cancelled items from queue"""
|
||||||
|
if self.download_queue:
|
||||||
|
removed = self.download_queue.clear_completed()
|
||||||
|
if removed > 0:
|
||||||
|
self.update_status(
|
||||||
|
f"[green]Cleared {removed} completed/cancelled items[/green]"
|
||||||
|
)
|
||||||
|
self.app.notify(
|
||||||
|
f"Cleared {removed} items",
|
||||||
|
title="Queue",
|
||||||
|
severity="information",
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.update_status(
|
||||||
|
"[yellow]No completed/cancelled items to clear[/yellow]"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.update_table()
|
||||||
|
self.update_stats()
|
||||||
|
|
||||||
|
def action_clear_failed(self) -> None:
|
||||||
|
"""Clear failed items from queue"""
|
||||||
|
if self.download_queue:
|
||||||
|
removed = self.download_queue.clear_failed()
|
||||||
|
if removed > 0:
|
||||||
|
self.update_status(f"[green]Cleared {removed} failed items[/green]")
|
||||||
|
self.app.notify(
|
||||||
|
f"Cleared {removed} items",
|
||||||
|
title="Queue",
|
||||||
|
severity="information",
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.update_status("[yellow]No failed items to clear[/yellow]")
|
||||||
|
|
||||||
|
self.update_table()
|
||||||
|
self.update_stats()
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
"""Go back to results screen"""
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def action_quit(self) -> None:
|
||||||
|
"""Quit to search screen"""
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle button presses"""
|
||||||
|
if event.button.id == "back-btn":
|
||||||
|
self.action_go_back()
|
||||||
|
elif event.button.id == "refresh-btn":
|
||||||
|
self.action_refresh_screen()
|
||||||
|
elif event.button.id == "remove-btn":
|
||||||
|
self.action_remove_selected()
|
||||||
|
elif event.button.id == "retry-btn":
|
||||||
|
self.action_retry_selected()
|
||||||
|
elif event.button.id == "clear-done-btn":
|
||||||
|
self.action_clear_completed()
|
||||||
|
elif event.button.id == "clear-failed-btn":
|
||||||
|
self.action_clear_failed()
|
||||||
|
|
||||||
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||||
|
"""Handle row selection"""
|
||||||
|
# Get the queue item that was selected
|
||||||
|
row_key = event.row_key
|
||||||
|
row_index = int(row_key.value) - 1 if row_key else -1 # type: ignore[arg-type]
|
||||||
|
|
||||||
|
if self.download_queue:
|
||||||
|
items = self.download_queue.get_queue()
|
||||||
|
if 0 <= row_index < len(items):
|
||||||
|
item = items[row_index]
|
||||||
|
if item.video:
|
||||||
|
self.update_status(f"Selected: {item.video.display_title}")
|
||||||
364
screens/results.py
Normal file
364
screens/results.py
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Results Screen for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Static,
|
||||||
|
)
|
||||||
|
|
||||||
|
from youtube_tui.models.video import Video
|
||||||
|
from youtube_tui.screens.modal import CategorySelectionModal
|
||||||
|
from youtube_tui.services.youtube import YouTubeService
|
||||||
|
|
||||||
|
|
||||||
|
class ResultsScreen(Screen):
|
||||||
|
"""Screen for displaying search results"""
|
||||||
|
|
||||||
|
ALLOW_SELECT = True
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
ResultsScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results-container {
|
||||||
|
width: 95%;
|
||||||
|
height: 70%;
|
||||||
|
border: solid #555555;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results-title {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
background: $surface;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pagination-controls {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
dock: bottom;
|
||||||
|
background: $surface;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-bar {
|
||||||
|
dock: bottom;
|
||||||
|
height: 1;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
width: 15;
|
||||||
|
margin: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable .datatable-row-highlight {
|
||||||
|
background: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable .datatable-header {
|
||||||
|
background: $primary-darken-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table {
|
||||||
|
background: $surface;
|
||||||
|
border: round #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table .datatable-row:hover {
|
||||||
|
background: $primary-lighten-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table .datatable-row-selected {
|
||||||
|
background: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table .datatable-row-active {
|
||||||
|
background: $primary-darken-2;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("n", "next_page", "Next Page"),
|
||||||
|
("p", "previous_page", "Previous Page"),
|
||||||
|
("q", "go_back", "Back"),
|
||||||
|
("enter", "download", "Download"),
|
||||||
|
("escape", "go_back", "Back"),
|
||||||
|
("ctrl+r", "refresh_screen", "Refresh"),
|
||||||
|
("ctrl+f", "search_from_anywhere", "Search"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, search_term: str, page: int = 1):
|
||||||
|
super().__init__()
|
||||||
|
self.youtube_service = YouTubeService()
|
||||||
|
self.search_term = search_term
|
||||||
|
self.page = page
|
||||||
|
self.videos: List[Video] = []
|
||||||
|
self.total_pages: int = 1
|
||||||
|
self.max_per_page: int = 15
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the results screen"""
|
||||||
|
yield Header()
|
||||||
|
yield Static(
|
||||||
|
f"Results for: [bold cyan]{self.search_term}[/bold cyan] (Page {self.page})",
|
||||||
|
id="results-title",
|
||||||
|
)
|
||||||
|
table = DataTable(
|
||||||
|
id="results-table",
|
||||||
|
show_cursor=True,
|
||||||
|
cursor_type="row",
|
||||||
|
show_row_labels=False,
|
||||||
|
classes="results-table",
|
||||||
|
)
|
||||||
|
yield Container(
|
||||||
|
table,
|
||||||
|
id="results-container",
|
||||||
|
)
|
||||||
|
yield Container(
|
||||||
|
Button("← Prev", id="prev-btn"),
|
||||||
|
Static(id="page-indicator"),
|
||||||
|
Button("Next →", id="next-btn"),
|
||||||
|
id="pagination-controls",
|
||||||
|
)
|
||||||
|
yield Static(id="status-bar")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Called when screen is mounted"""
|
||||||
|
self.query_one("#results-table", DataTable).focus()
|
||||||
|
# Use asyncio.create_task to run the async load_results method
|
||||||
|
# since on_mount is synchronous but we need to fetch data asynchronously
|
||||||
|
self.load_task = asyncio.create_task(self.load_results())
|
||||||
|
self.update_status("[blue]Loading search results...[/blue]")
|
||||||
|
|
||||||
|
def action_refresh_screen(self) -> None:
|
||||||
|
"""Refresh the screen"""
|
||||||
|
# Cancel any existing load task and start a new one
|
||||||
|
if hasattr(self, "load_task") and self.load_task:
|
||||||
|
self.load_task.cancel()
|
||||||
|
self.load_task = asyncio.create_task(self.load_results())
|
||||||
|
self.update_status("[blue]Refreshing results...[/blue]")
|
||||||
|
|
||||||
|
def action_search_from_anywhere(self) -> None:
|
||||||
|
"""Open search from anywhere"""
|
||||||
|
# Use the app's action_open_search if available, otherwise go back
|
||||||
|
if hasattr(self.app, "action_open_search"):
|
||||||
|
self.app.action_open_search()
|
||||||
|
else:
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
async def load_results(self) -> None:
|
||||||
|
"""Load search results from YouTube"""
|
||||||
|
# Clear stale results immediately to prevent mixing with new search
|
||||||
|
self.videos = []
|
||||||
|
self.total_pages = 1
|
||||||
|
self.update_table()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.videos = await self.youtube_service.search_videos(
|
||||||
|
self.search_term, page=self.page, per_page=self.max_per_page
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate total pages (simplified - yt-dlp returns 15 per page)
|
||||||
|
if len(self.videos) == self.max_per_page:
|
||||||
|
self.total_pages = self.page + 1 # There might be more pages
|
||||||
|
else:
|
||||||
|
self.total_pages = self.page
|
||||||
|
|
||||||
|
# Update the table
|
||||||
|
self.update_table()
|
||||||
|
|
||||||
|
# Update pagination controls
|
||||||
|
self.update_pagination()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.update_status(f"[red]Error loading results: {e}[/red]")
|
||||||
|
self.videos = []
|
||||||
|
|
||||||
|
def update_table(self) -> None:
|
||||||
|
"""Update the DataTable with videos"""
|
||||||
|
table = self.query_one("#results-table", DataTable)
|
||||||
|
|
||||||
|
# Clear existing data
|
||||||
|
table.clear(columns=True)
|
||||||
|
|
||||||
|
# Set up columns
|
||||||
|
table.add_columns("#", "Title", "Author", "Duration", "Type")
|
||||||
|
|
||||||
|
# Add rows
|
||||||
|
for i, video in enumerate(self.videos, 1):
|
||||||
|
# Determine video type
|
||||||
|
if video.is_short:
|
||||||
|
video_type = "Short"
|
||||||
|
elif "/playlist" in video.url:
|
||||||
|
video_type = "Playlist"
|
||||||
|
else:
|
||||||
|
video_type = "Video"
|
||||||
|
|
||||||
|
# Truncate long titles
|
||||||
|
title = video.display_title
|
||||||
|
if len(title) > 50:
|
||||||
|
title = title[:47] + "..."
|
||||||
|
|
||||||
|
author = video.channel
|
||||||
|
if len(author) > 20:
|
||||||
|
author = author[:17] + "..."
|
||||||
|
|
||||||
|
table.add_row(
|
||||||
|
str(i),
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
video.display_duration,
|
||||||
|
video_type,
|
||||||
|
key=video.video_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Focus the table
|
||||||
|
table.focus()
|
||||||
|
|
||||||
|
def update_pagination(self) -> None:
|
||||||
|
"""Update pagination controls"""
|
||||||
|
page_indicator = self.query_one("#page-indicator", Static)
|
||||||
|
page_indicator.update(f"Page {self.page} of {self.total_pages}")
|
||||||
|
|
||||||
|
prev_btn = self.query_one("#prev-btn", Button)
|
||||||
|
next_btn = self.query_one("#next-btn", Button)
|
||||||
|
|
||||||
|
# Disable previous button on first page
|
||||||
|
prev_btn.disabled = self.page <= 1
|
||||||
|
|
||||||
|
# Disable next button if we're on the last known page and have fewer results
|
||||||
|
if len(self.videos) < self.max_per_page:
|
||||||
|
next_btn.disabled = True
|
||||||
|
else:
|
||||||
|
next_btn.disabled = False
|
||||||
|
|
||||||
|
def update_status(self, message: str) -> None:
|
||||||
|
"""Update the status bar message"""
|
||||||
|
status_bar = self.query_one("#status-bar", Static)
|
||||||
|
status_bar.update(f"[bold white]{message}[/bold white]")
|
||||||
|
|
||||||
|
def action_add_to_queue(self) -> None:
|
||||||
|
"""Add selected video to queue"""
|
||||||
|
table = self.query_one("#results-table", DataTable)
|
||||||
|
selected_row = table.cursor_row
|
||||||
|
|
||||||
|
if selected_row < 0 or selected_row >= len(self.videos):
|
||||||
|
self.update_status("[yellow]Select a video to add to queue[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
video = self.videos[selected_row]
|
||||||
|
|
||||||
|
# Show modal for category selection
|
||||||
|
self.app.push_screen(
|
||||||
|
CategorySelectionModal(),
|
||||||
|
lambda category: self._add_to_queue_with_category(video, category),
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_download(self) -> None:
|
||||||
|
"""Download selected video - add to queue"""
|
||||||
|
self.action_add_to_queue()
|
||||||
|
|
||||||
|
def action_next_page(self) -> None:
|
||||||
|
"""Go to next page"""
|
||||||
|
if self.page < self.total_pages or len(self.videos) >= self.max_per_page:
|
||||||
|
self.page += 1
|
||||||
|
# Cancel any existing load task and start a new one
|
||||||
|
if hasattr(self, "load_task") and self.load_task:
|
||||||
|
self.load_task.cancel()
|
||||||
|
self.load_task = asyncio.create_task(self.load_results())
|
||||||
|
self.update_status(f"[blue]Loading page {self.page}...[/blue]")
|
||||||
|
|
||||||
|
def action_previous_page(self) -> None:
|
||||||
|
"""Go to previous page"""
|
||||||
|
if self.page > 1:
|
||||||
|
self.page -= 1
|
||||||
|
# Cancel any existing load task and start a new one
|
||||||
|
if hasattr(self, "load_task") and self.load_task:
|
||||||
|
self.load_task.cancel()
|
||||||
|
self.load_task = asyncio.create_task(self.load_results())
|
||||||
|
self.update_status(f"[blue]Loading page {self.page}...[/blue]")
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
"""Go back to search screen"""
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def action_quit(self) -> None:
|
||||||
|
"""Quit to search screen"""
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle button presses"""
|
||||||
|
if event.button.id == "prev-btn":
|
||||||
|
self.action_previous_page()
|
||||||
|
elif event.button.id == "next-btn":
|
||||||
|
self.action_next_page()
|
||||||
|
|
||||||
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||||
|
"""Handle row selection (Enter key) - add to queue"""
|
||||||
|
self._add_selected_video_to_queue()
|
||||||
|
|
||||||
|
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
|
||||||
|
"""Handle cell click - add to queue"""
|
||||||
|
self._add_selected_video_to_queue()
|
||||||
|
|
||||||
|
def _add_to_queue_with_category(self, video: Video, category: str | None) -> None:
|
||||||
|
"""Add video to queue with selected category (callback from modal)"""
|
||||||
|
if category is None:
|
||||||
|
self.update_status("[yellow]Category selection cancelled[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(self.app, "download_queue") and self.app.download_queue:
|
||||||
|
self.app.download_queue.add_video(video, category=category)
|
||||||
|
self.update_status(
|
||||||
|
f"[green]Added to queue: {video.display_title}[/green]"
|
||||||
|
)
|
||||||
|
self.app.notify(
|
||||||
|
f"Added to queue: {video.display_title}",
|
||||||
|
title="Queue",
|
||||||
|
severity="information",
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.update_status("[yellow]Queue not available[/yellow]")
|
||||||
|
except Exception as e:
|
||||||
|
self.update_status(f"[red]Error adding to queue: {e}[/red]")
|
||||||
|
|
||||||
|
def _add_selected_video_to_queue(self) -> None:
|
||||||
|
"""Helper method to add selected video to queue"""
|
||||||
|
table = self.query_one("#results-table", DataTable)
|
||||||
|
row_index = table.cursor_row
|
||||||
|
|
||||||
|
if row_index < 0 or row_index >= len(self.videos):
|
||||||
|
return
|
||||||
|
|
||||||
|
video = self.videos[row_index]
|
||||||
|
|
||||||
|
# Show modal for category selection
|
||||||
|
self.app.push_screen(
|
||||||
|
CategorySelectionModal(),
|
||||||
|
lambda category: self._add_to_queue_with_category(video, category),
|
||||||
|
)
|
||||||
156
screens/search.py
Normal file
156
screens/search.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Search Screen for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Input,
|
||||||
|
Static,
|
||||||
|
)
|
||||||
|
|
||||||
|
from youtube_tui.services.youtube import YouTubeService
|
||||||
|
|
||||||
|
|
||||||
|
class SearchScreen(Screen):
|
||||||
|
"""Screen for searching YouTube videos"""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
SearchScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-container {
|
||||||
|
width: 80%;
|
||||||
|
height: auto;
|
||||||
|
border: double #555555;
|
||||||
|
padding: 1 2;
|
||||||
|
margin: 2 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#buttons {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
dock: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
width: 20;
|
||||||
|
margin: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-bar {
|
||||||
|
dock: bottom;
|
||||||
|
height: 1;
|
||||||
|
background: #333333;
|
||||||
|
color: #aaaaaa;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-indicator {
|
||||||
|
height: 3;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("escape", "go_back", "Back"),
|
||||||
|
("enter", "search", "Search"),
|
||||||
|
("ctrl+f", "search_from_anywhere", "Search"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.youtube_service = YouTubeService()
|
||||||
|
self.search_term = ""
|
||||||
|
self.is_searching = False
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the search screen"""
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Static("YouTube Search", id="search-title", classes="title"),
|
||||||
|
Static("Enter a search term to find YouTube videos", id="search-hint"),
|
||||||
|
Input(placeholder="Search for videos...", id="search-input"),
|
||||||
|
Button("Search", id="search-button"),
|
||||||
|
Button("Cancel", id="cancel-button"),
|
||||||
|
id="search-container",
|
||||||
|
)
|
||||||
|
yield Static(id="status-bar")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Called when screen is mounted"""
|
||||||
|
self.query_one(Input).focus()
|
||||||
|
self.update_status(
|
||||||
|
"Ready to search - Press Enter to search, Ctrl+F to search from anywhere"
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_search_from_anywhere(self) -> None:
|
||||||
|
"""Open search from anywhere"""
|
||||||
|
# This is a fallback for the key binding
|
||||||
|
self.action_search()
|
||||||
|
|
||||||
|
def update_status(self, message: str) -> None:
|
||||||
|
"""Update the status bar message"""
|
||||||
|
status_bar = self.query_one("#status-bar", Static)
|
||||||
|
status_bar.update(f"[bold white]{message}[/bold white]")
|
||||||
|
|
||||||
|
def action_search(self) -> None:
|
||||||
|
"""Perform the search operation"""
|
||||||
|
input_widget = self.query_one(Input)
|
||||||
|
search_term = input_widget.value.strip()
|
||||||
|
|
||||||
|
if not search_term:
|
||||||
|
self.update_status("[red]Please enter a search term[/red]")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.search_term = search_term
|
||||||
|
self.update_status(f"[blue]Searching for: {search_term}[/blue]")
|
||||||
|
|
||||||
|
# Trigger search - get the app and push results screen
|
||||||
|
app = self.app
|
||||||
|
if hasattr(app, "push_results_screen"):
|
||||||
|
app.push_results_screen(search_term)
|
||||||
|
else:
|
||||||
|
# Fallback if app methods aren't available
|
||||||
|
from youtube_tui.screens.results import ResultsScreen
|
||||||
|
|
||||||
|
self.app.push_screen(ResultsScreen(search_term))
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
"""Handle cancel action"""
|
||||||
|
self.action_go_back()
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
"""Go back to previous screen"""
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def action_quit(self) -> None:
|
||||||
|
"""Quit the application"""
|
||||||
|
# Only quit if we're at the root level
|
||||||
|
if len(self.app.screen_stack) <= 2: # Header + Footer + Screen
|
||||||
|
self.app.exit()
|
||||||
|
else:
|
||||||
|
self.action_go_back()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle button presses"""
|
||||||
|
if event.button.id == "search-button":
|
||||||
|
self.action_search()
|
||||||
|
elif event.button.id == "cancel-button":
|
||||||
|
self.action_quit()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
"""Handle enter key in search input"""
|
||||||
|
self.action_search()
|
||||||
7
services/__init__.py
Normal file
7
services/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Services package for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from youtube_tui.services.youtube import YouTubeService
|
||||||
|
|
||||||
|
__all__ = ["YouTubeService"]
|
||||||
220
services/download_manager.py
Normal file
220
services/download_manager.py
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Download Manager for YouTube TUI
|
||||||
|
Handles background downloads sequentially
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from youtube_tui.models.queue_item import QueueItem, QueueStatus
|
||||||
|
from youtube_tui.models.video import Video
|
||||||
|
from youtube_tui.services.queue import DownloadQueue
|
||||||
|
from youtube_tui.services.youtube import YouTubeService
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs"
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOG_FILE = LOG_DIR / "app.log"
|
||||||
|
|
||||||
|
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
||||||
|
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
file_handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create console handler
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
handlers=[
|
||||||
|
file_handler,
|
||||||
|
console_handler,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadManager:
|
||||||
|
"""Manages background downloads from the queue"""
|
||||||
|
|
||||||
|
def __init__(self, queue: DownloadQueue, youtube_service: YouTubeService):
|
||||||
|
self._queue: DownloadQueue = queue
|
||||||
|
self._active_task: Optional[asyncio.Task] = None
|
||||||
|
self._current_item: Optional[QueueItem] = None
|
||||||
|
self._is_running = False
|
||||||
|
self._cancel_requested = False
|
||||||
|
self._youtube_service = youtube_service
|
||||||
|
|
||||||
|
def add_to_queue(
|
||||||
|
self,
|
||||||
|
video: Video,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
network_folder: Optional[str] = None,
|
||||||
|
) -> QueueItem:
|
||||||
|
"""Add a video to the download queue"""
|
||||||
|
return self._queue.add_video(video, category, network_folder)
|
||||||
|
|
||||||
|
def remove_from_queue(self, item_id: str) -> bool:
|
||||||
|
"""Remove an item from the queue by UUID string"""
|
||||||
|
return self._queue.remove_item(item_id)
|
||||||
|
|
||||||
|
def cancel_active_download(self) -> None:
|
||||||
|
"""Cancel the currently active download"""
|
||||||
|
self._cancel_requested = True
|
||||||
|
if self._active_task:
|
||||||
|
self._active_task.cancel()
|
||||||
|
|
||||||
|
def get_queue_status(self) -> dict:
|
||||||
|
"""Get queue status information"""
|
||||||
|
stats = self._queue.get_stats()
|
||||||
|
# Use _current_item to determine if there's an active download
|
||||||
|
has_active_download = self._current_item is not None
|
||||||
|
return {
|
||||||
|
"pending_count": stats["pending"],
|
||||||
|
"downloading_count": 1 if has_active_download else stats["downloading"],
|
||||||
|
"total_count": stats["total"],
|
||||||
|
"has_active_download": has_active_download,
|
||||||
|
"active_item": self._current_item.to_dict() if self._current_item else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_active_item(self) -> Optional[QueueItem]:
|
||||||
|
"""Get the currently downloading item"""
|
||||||
|
return self._current_item
|
||||||
|
|
||||||
|
def start_processing(self) -> None:
|
||||||
|
"""Start the background download processing task"""
|
||||||
|
if not self._is_running:
|
||||||
|
self._is_running = True
|
||||||
|
self._active_task = asyncio.create_task(self._process_queue())
|
||||||
|
|
||||||
|
def stop_processing(self) -> None:
|
||||||
|
"""Stop the background download processing task"""
|
||||||
|
self._is_running = False
|
||||||
|
if self._active_task:
|
||||||
|
self._active_task.cancel()
|
||||||
|
|
||||||
|
async def _process_queue(self) -> None:
|
||||||
|
"""Process the download queue sequentially"""
|
||||||
|
# Loop is available via asyncio.run() in main context
|
||||||
|
|
||||||
|
while self._is_running:
|
||||||
|
try:
|
||||||
|
# Check if we have a pending item
|
||||||
|
item = self._queue.get_next_pending()
|
||||||
|
if item is None:
|
||||||
|
await asyncio.sleep(1) # Wait for new items
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Mark item as current
|
||||||
|
self._current_item = item
|
||||||
|
self._cancel_requested = False
|
||||||
|
|
||||||
|
# Start downloading
|
||||||
|
await self._download_item(item)
|
||||||
|
|
||||||
|
# Clear current item after completion
|
||||||
|
self._current_item = None
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Task was cancelled
|
||||||
|
logger.warning("Download manager cancelled")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error in download manager: {e}")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
async def _download_item(self, item: QueueItem) -> None:
|
||||||
|
"""Download a single queue item"""
|
||||||
|
# Update status to downloading
|
||||||
|
item.start_download()
|
||||||
|
if item.video:
|
||||||
|
self._queue.update_item_status(str(item.id), QueueStatus.DOWNLOADING)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine if it's a playlist or video
|
||||||
|
is_playlist = item.video and (
|
||||||
|
"/playlist" in item.video.url.lower()
|
||||||
|
or "list=" in item.video.url.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Download with progress callback
|
||||||
|
async def progress_callback(percentage: int) -> bool:
|
||||||
|
"""Progress callback that checks for cancellation"""
|
||||||
|
# Update progress
|
||||||
|
if item.video:
|
||||||
|
self._queue.update_progress(str(item.id), percentage)
|
||||||
|
item.update_progress(percentage)
|
||||||
|
|
||||||
|
# Check for cancellation
|
||||||
|
if self._cancel_requested:
|
||||||
|
raise asyncio.CancelledError("Download cancelled by user")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
if is_playlist and item.video:
|
||||||
|
success = await self._youtube_service.download_playlist(
|
||||||
|
item.video,
|
||||||
|
category=item.category,
|
||||||
|
network_folder=item.network_folder,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
|
elif item.video:
|
||||||
|
success = await self._youtube_service.download_video(
|
||||||
|
item.video,
|
||||||
|
category=item.category,
|
||||||
|
network_folder=item.network_folder,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No video to download
|
||||||
|
if item.video is None:
|
||||||
|
item.fail(error_message="No video data available")
|
||||||
|
success = False
|
||||||
|
|
||||||
|
# Check final status
|
||||||
|
if self._cancel_requested:
|
||||||
|
# Download was cancelled
|
||||||
|
if item.video:
|
||||||
|
self._queue.update_item_status(str(item.id), QueueStatus.CANCELLED)
|
||||||
|
item.cancel()
|
||||||
|
elif success:
|
||||||
|
# Download succeeded
|
||||||
|
if item.video:
|
||||||
|
self._queue.update_item_status(str(item.id), QueueStatus.COMPLETED)
|
||||||
|
item.complete()
|
||||||
|
else:
|
||||||
|
# Download failed
|
||||||
|
if item.video:
|
||||||
|
self._queue.update_item_status(str(item.id), QueueStatus.FAILED)
|
||||||
|
item.fail(error_message="Download failed")
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Task was cancelled
|
||||||
|
if item.video:
|
||||||
|
self._queue.update_item_status(str(item.id), QueueStatus.CANCELLED)
|
||||||
|
item.cancel()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Download error: {e}")
|
||||||
|
if item.video:
|
||||||
|
self._queue.update_item_status(str(item.id), QueueStatus.FAILED)
|
||||||
|
item.fail(error_message=str(e))
|
||||||
|
|
||||||
|
def is_processing(self) -> bool:
|
||||||
|
"""Check if download manager is processing queue"""
|
||||||
|
return self._is_running
|
||||||
268
services/queue.py
Normal file
268
services/queue.py
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Download Queue Service for YouTube TUI
|
||||||
|
Manages the queue of videos to download
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from youtube_tui.models.queue_item import QueueItem, QueueStatus
|
||||||
|
from youtube_tui.models.video import Video
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs"
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOG_FILE = LOG_DIR / "app.log"
|
||||||
|
|
||||||
|
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
||||||
|
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
file_handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create console handler
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
handlers=[
|
||||||
|
file_handler,
|
||||||
|
console_handler,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadQueue:
|
||||||
|
"""Manages the download queue"""
|
||||||
|
|
||||||
|
_archive_file = Path.home() / ".config" / "youtube_cli" / "download_queue.json"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._queue: List[QueueItem] = []
|
||||||
|
self._load_queue()
|
||||||
|
|
||||||
|
def _load_queue(self) -> None:
|
||||||
|
"""Load queue from archive file"""
|
||||||
|
try:
|
||||||
|
if self._archive_file.exists():
|
||||||
|
with open(self._archive_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self._queue = [QueueItem.from_dict(item) for item in data]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error loading queue: {e}")
|
||||||
|
self._queue = []
|
||||||
|
|
||||||
|
def _save_queue(self) -> None:
|
||||||
|
"""Save queue to archive file"""
|
||||||
|
try:
|
||||||
|
self._archive_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self._archive_file, "w") as f:
|
||||||
|
data = [item.to_dict() for item in self._queue]
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error saving queue: {e}")
|
||||||
|
|
||||||
|
def add_video(
|
||||||
|
self,
|
||||||
|
video: Video,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
network_folder: Optional[str] = None,
|
||||||
|
) -> QueueItem:
|
||||||
|
"""Add a video to the queue"""
|
||||||
|
item = QueueItem(video=video, category=category, network_folder=network_folder)
|
||||||
|
self._queue.append(item)
|
||||||
|
self._save_queue()
|
||||||
|
return item
|
||||||
|
|
||||||
|
def remove_item(self, item_id: str) -> bool:
|
||||||
|
"""Remove a queue item by its UUID string"""
|
||||||
|
try:
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
item_uuid = uuid.UUID(item_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
for i, item in enumerate(self._queue):
|
||||||
|
if item.id == item_uuid:
|
||||||
|
del self._queue[i]
|
||||||
|
self._save_queue()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_next_pending(self) -> Optional[QueueItem]:
|
||||||
|
"""Get the next pending video to download"""
|
||||||
|
for item in self._queue:
|
||||||
|
if item.status == QueueStatus.PENDING:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_item_status(self, item_id: str, status: QueueStatus) -> None:
|
||||||
|
"""Update the status of a queue item by UUID string"""
|
||||||
|
try:
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
item_uuid = uuid.UUID(item_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in self._queue:
|
||||||
|
if item.id == item_uuid:
|
||||||
|
item.status = status
|
||||||
|
self._save_queue()
|
||||||
|
return
|
||||||
|
|
||||||
|
def update_progress(self, item_id: str, percentage: int) -> None:
|
||||||
|
"""Update the progress of a queue item by UUID string"""
|
||||||
|
try:
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
item_uuid = uuid.UUID(item_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in self._queue:
|
||||||
|
if item.id == item_uuid:
|
||||||
|
item.update_progress(percentage)
|
||||||
|
self._save_queue()
|
||||||
|
return
|
||||||
|
|
||||||
|
def get_all_items(self) -> List[QueueItem]:
|
||||||
|
"""Get all queue items"""
|
||||||
|
return self._queue.copy()
|
||||||
|
|
||||||
|
def get_active_count(self) -> int:
|
||||||
|
"""Get count of active items (pending + downloading)"""
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for item in self._queue
|
||||||
|
if item.status in (QueueStatus.PENDING, QueueStatus.DOWNLOADING)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_pending_count(self) -> int:
|
||||||
|
"""Get count of pending items"""
|
||||||
|
return sum(1 for item in self._queue if item.status == QueueStatus.PENDING)
|
||||||
|
|
||||||
|
def cancel_item(self, item_id: str) -> None:
|
||||||
|
"""Cancel a queue item by UUID string"""
|
||||||
|
try:
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
item_uuid = uuid.UUID(item_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in self._queue:
|
||||||
|
if item.id == item_uuid:
|
||||||
|
item.cancel()
|
||||||
|
self._save_queue()
|
||||||
|
return
|
||||||
|
|
||||||
|
def clear_completed(self) -> int:
|
||||||
|
"""Remove completed and cancelled items from queue"""
|
||||||
|
initial_count = len(self._queue)
|
||||||
|
self._queue = [
|
||||||
|
item
|
||||||
|
for item in self._queue
|
||||||
|
if item.status not in (QueueStatus.COMPLETED, QueueStatus.CANCELLED)
|
||||||
|
]
|
||||||
|
removed = initial_count - len(self._queue)
|
||||||
|
if removed > 0:
|
||||||
|
self._save_queue()
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def clear_failed(self) -> int:
|
||||||
|
"""Remove failed items from queue"""
|
||||||
|
initial_count = len(self._queue)
|
||||||
|
self._queue = [
|
||||||
|
item for item in self._queue if item.status != QueueStatus.FAILED
|
||||||
|
]
|
||||||
|
removed = initial_count - len(self._queue)
|
||||||
|
if removed > 0:
|
||||||
|
self._save_queue()
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def get_downloading_item(self) -> Optional[QueueItem]:
|
||||||
|
"""Get the currently downloading item"""
|
||||||
|
for item in self._queue:
|
||||||
|
if item.status == QueueStatus.DOWNLOADING:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
def remove_video(self, video_id: str) -> bool:
|
||||||
|
"""Remove a video from the queue by video ID (alias for remove_by_video_id)"""
|
||||||
|
return self.remove_by_video_id(video_id)
|
||||||
|
|
||||||
|
def update_status(
|
||||||
|
self, video_id: str, status: QueueStatus, progress: Optional[int] = None
|
||||||
|
) -> None:
|
||||||
|
"""Update the status of a video in the queue by video ID"""
|
||||||
|
for item in self._queue:
|
||||||
|
if item.video and item.video.video_id == video_id:
|
||||||
|
item.status = status
|
||||||
|
if progress is not None:
|
||||||
|
item.update_progress(progress)
|
||||||
|
self._save_queue()
|
||||||
|
return
|
||||||
|
|
||||||
|
def cancel_video(self, video_id: str) -> bool:
|
||||||
|
"""Cancel a video in the queue by video ID"""
|
||||||
|
for item in self._queue:
|
||||||
|
if item.video and item.video.video_id == video_id:
|
||||||
|
item.cancel()
|
||||||
|
self._save_queue()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_by_video_id(self, video_id: str) -> bool:
|
||||||
|
"""Remove a video from the queue by video ID"""
|
||||||
|
for i, item in enumerate(self._queue):
|
||||||
|
if item.video and item.video.video_id == video_id:
|
||||||
|
del self._queue[i]
|
||||||
|
self._save_queue()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_queue(self) -> List[QueueItem]:
|
||||||
|
"""Get all queue items (alias for get_all_items)"""
|
||||||
|
return self.get_all_items()
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
"""Get queue statistics"""
|
||||||
|
total = len(self._queue)
|
||||||
|
pending = sum(1 for item in self._queue if item.status == QueueStatus.PENDING)
|
||||||
|
downloading = sum(
|
||||||
|
1 for item in self._queue if item.status == QueueStatus.DOWNLOADING
|
||||||
|
)
|
||||||
|
completed = sum(
|
||||||
|
1 for item in self._queue if item.status == QueueStatus.COMPLETED
|
||||||
|
)
|
||||||
|
cancelled = sum(
|
||||||
|
1 for item in self._queue if item.status == QueueStatus.CANCELLED
|
||||||
|
)
|
||||||
|
failed = sum(1 for item in self._queue if item.status == QueueStatus.FAILED)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"pending": pending,
|
||||||
|
"downloading": downloading,
|
||||||
|
"completed": completed,
|
||||||
|
"cancelled": cancelled,
|
||||||
|
"failed": failed,
|
||||||
|
}
|
||||||
344
services/youtube.py
Normal file
344
services/youtube.py
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
YouTube service wrapper around YouTubeCLI - Async implementation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from youtube_cli.main import YouTubeCLI
|
||||||
|
from youtube_tui.models.video import Video
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs"
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOG_FILE = LOG_DIR / "app.log"
|
||||||
|
|
||||||
|
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
||||||
|
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
file_handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create console handler
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
handlers=[
|
||||||
|
file_handler,
|
||||||
|
console_handler,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeServiceError(Exception):
|
||||||
|
"""Base exception for YouTubeService errors"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SearchError(YouTubeServiceError):
|
||||||
|
"""Exception raised during search operations"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadError(YouTubeServiceError):
|
||||||
|
"""Exception raised during download operations"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveError(YouTubeServiceError):
|
||||||
|
"""Exception raised during archive operations"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeService:
|
||||||
|
"""Service class that wraps YouTubeCLI for TUI integration with async support"""
|
||||||
|
|
||||||
|
def __init__(self, config_path: Optional[str] = None):
|
||||||
|
"""Initialize the YouTube service"""
|
||||||
|
self.cli = YouTubeCLI(config_path=config_path)
|
||||||
|
self.console = Console()
|
||||||
|
|
||||||
|
async def search_videos(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 15,
|
||||||
|
) -> List[Video]:
|
||||||
|
"""
|
||||||
|
Search for videos on YouTube (async)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
page: Page number (1-indexed)
|
||||||
|
per_page: Number of videos per page (ignored, hardcoded to 15 in CLI)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Video objects
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SearchError: If search fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Use asyncio.to_thread to run blocking subprocess calls
|
||||||
|
def _search() -> List[Video]:
|
||||||
|
try:
|
||||||
|
# Call the search_videos method with return_results=True
|
||||||
|
results = self.cli.search_videos(
|
||||||
|
query, self.cli.config, page, return_results=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Convert results to Video objects
|
||||||
|
return [self._create_video_from_result(r) for r in results]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error searching videos: {e}")
|
||||||
|
raise SearchError(f"Failed to search videos: {e}") from e
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_search)
|
||||||
|
|
||||||
|
async def download_video(
|
||||||
|
self,
|
||||||
|
video: Video,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
network_folder: Optional[str] = None,
|
||||||
|
progress_callback=None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Download a video (async)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video: Video object to download
|
||||||
|
category: Category folder for download location
|
||||||
|
network_folder: Optional network share folder
|
||||||
|
progress_callback: Optional callback to report progress (percentage: int)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if download succeeded, False otherwise
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DownloadError: If download fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _download() -> bool:
|
||||||
|
try:
|
||||||
|
success = self.cli.download_video(
|
||||||
|
video.url,
|
||||||
|
self.cli.config,
|
||||||
|
category=category,
|
||||||
|
network_folder=network_folder,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
|
return success is not False # download_video returns None on error
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading video: {e}")
|
||||||
|
raise DownloadError(f"Failed to download video: {e}") from e
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_download)
|
||||||
|
|
||||||
|
async def download_playlist(
|
||||||
|
self,
|
||||||
|
video: Video,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
network_folder: Optional[str] = None,
|
||||||
|
progress_callback=None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Download a playlist (async)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video: Video object containing playlist URL
|
||||||
|
category: Category folder for download location
|
||||||
|
network_folder: Optional network share folder
|
||||||
|
progress_callback: Optional callback to report progress (percentage: int)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if download succeeded, False otherwise
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DownloadError: If download fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _download_playlist() -> bool:
|
||||||
|
try:
|
||||||
|
success = self.cli.download_playlist(
|
||||||
|
video.url,
|
||||||
|
self.cli.config,
|
||||||
|
category=category,
|
||||||
|
network_folder=network_folder,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
|
return success is not False # download_playlist returns None on error
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading playlist: {e}")
|
||||||
|
raise DownloadError(f"Failed to download playlist: {e}") from e
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_download_playlist)
|
||||||
|
|
||||||
|
async def get_categories(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get available download categories (async)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of category names
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _get_categories() -> List[str]:
|
||||||
|
return self.cli.get_categories(self.cli.config)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_get_categories)
|
||||||
|
|
||||||
|
async def is_video_downloaded(self, video_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a video has already been downloaded (async)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_id: YouTube video ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if video is in archive, False otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _check_archive() -> bool:
|
||||||
|
return self.cli.is_video_downloaded(video_id)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_check_archive)
|
||||||
|
|
||||||
|
async def add_to_archive(self, video: Video) -> None:
|
||||||
|
"""
|
||||||
|
Add a video to the archive (async)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video: Video object to add
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ArchiveError: If archive operation fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _add_to_archive() -> None:
|
||||||
|
try:
|
||||||
|
self.cli.add_to_archive(
|
||||||
|
{
|
||||||
|
"url": video.url,
|
||||||
|
"id": video.video_id,
|
||||||
|
"title": video.title,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding to archive: {e}")
|
||||||
|
raise ArchiveError(f"Failed to add video to archive: {e}") from e
|
||||||
|
|
||||||
|
await asyncio.to_thread(_add_to_archive)
|
||||||
|
|
||||||
|
async def get_archive(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load the entire archive (async)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Archive dictionary containing all downloaded videos
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _load_archive() -> Dict[str, Any]:
|
||||||
|
return self.cli.load_archive()
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_load_archive)
|
||||||
|
|
||||||
|
async def get_downloaded_video_ids(self) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Get set of all downloaded video IDs (async)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of video IDs that have been downloaded
|
||||||
|
"""
|
||||||
|
archive = await self.get_archive()
|
||||||
|
return set(archive.keys())
|
||||||
|
|
||||||
|
async def remove_from_archive(self, video_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Remove a video from the archive (async)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_id: YouTube video ID to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if video was removed, False if not found
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _remove_from_archive() -> bool:
|
||||||
|
try:
|
||||||
|
archive = self.cli.load_archive()
|
||||||
|
if video_id in archive:
|
||||||
|
del archive[video_id]
|
||||||
|
self.cli.save_archive(archive)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_remove_from_archive)
|
||||||
|
|
||||||
|
def _create_video_from_result(self, result: Dict[str, Any]) -> Video:
|
||||||
|
"""
|
||||||
|
Create a Video object from yt-dlp result
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result: yt-dlp search result dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Video object
|
||||||
|
"""
|
||||||
|
duration = result.get("length", "0:00")
|
||||||
|
if duration:
|
||||||
|
duration = str(duration)
|
||||||
|
else:
|
||||||
|
duration = "0:00"
|
||||||
|
|
||||||
|
# Extract channel from author
|
||||||
|
channel = result.get("author", result.get("channel", "Unknown"))
|
||||||
|
|
||||||
|
return Video(
|
||||||
|
video_id=result.get("id", ""),
|
||||||
|
title=result.get("title", "Unknown"),
|
||||||
|
channel=channel,
|
||||||
|
channel_id=result.get("channel_id", ""),
|
||||||
|
duration=duration,
|
||||||
|
view_count=str(result.get("view_count", "0")),
|
||||||
|
upload_date=result.get("upload_date", ""),
|
||||||
|
description=result.get("description", ""),
|
||||||
|
thumbnail_url=result.get("thumbnail"),
|
||||||
|
url=result.get("url", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_duration(self, seconds: int) -> str:
|
||||||
|
"""
|
||||||
|
Format duration in seconds to MM:SS or HH:MM:SS format
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seconds: Duration in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted duration string
|
||||||
|
"""
|
||||||
|
return self.cli.format_duration(seconds)
|
||||||
152
test_tui.py
Normal file
152
test_tui.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from youtube_tui.app import YouTubeTUI
|
||||||
|
|
||||||
|
|
||||||
|
def test_imports():
|
||||||
|
"""Test that all imports work correctly"""
|
||||||
|
try:
|
||||||
|
print("✓ App imported successfully")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Import failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_creation():
|
||||||
|
"""Test that the app can be created"""
|
||||||
|
try:
|
||||||
|
app = YouTubeTUI()
|
||||||
|
print("✓ YouTubeTUI created successfully")
|
||||||
|
print(f" - App version: {app.VERSION}")
|
||||||
|
print(f" - yt-dlp version: {app.yt_dlp_version}")
|
||||||
|
print(f" - Search history loaded: {len(app.search_history)} items")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ App creation failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_model():
|
||||||
|
"""Test the Video model"""
|
||||||
|
try:
|
||||||
|
from youtube_tui.models.video import Video
|
||||||
|
|
||||||
|
video = Video(
|
||||||
|
video_id="dQw4w9WgXcQ",
|
||||||
|
title="Test Video",
|
||||||
|
channel="Test Channel",
|
||||||
|
channel_id="UC123",
|
||||||
|
duration="3:45",
|
||||||
|
view_count="1000000",
|
||||||
|
upload_date="20230101",
|
||||||
|
description="Test description",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("✓ Video model created successfully")
|
||||||
|
print(f" - Video ID: {video.video_id}")
|
||||||
|
print(f" - Title: {video.title}")
|
||||||
|
print(f" - Display title: {video.display_title}")
|
||||||
|
print(f" - Duration: {video.duration}")
|
||||||
|
print(f" - URL: {video.url}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Video model test failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_history():
|
||||||
|
"""Test search history functionality"""
|
||||||
|
try:
|
||||||
|
app = YouTubeTUI()
|
||||||
|
|
||||||
|
# Test adding to history
|
||||||
|
app.add_to_search_history("test search 1")
|
||||||
|
app.add_to_search_history("test search 2")
|
||||||
|
|
||||||
|
print("✓ Search history operations successful")
|
||||||
|
print(f" - History items: {len(app.search_history)}")
|
||||||
|
|
||||||
|
# Test that duplicates are removed
|
||||||
|
app.add_to_search_history("test search 1")
|
||||||
|
print(f" - After duplicate: {len(app.search_history)} items")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Search history test failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_from_dict():
|
||||||
|
"""Test Video model from_dict method"""
|
||||||
|
try:
|
||||||
|
from youtube_tui.models.video import Video
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"video_id": "abc123",
|
||||||
|
"title": "Test Video",
|
||||||
|
"channel": "Test Channel",
|
||||||
|
"channel_id": "UC123",
|
||||||
|
"duration": "5:30",
|
||||||
|
"view_count": "500000",
|
||||||
|
"upload_date": "20230101",
|
||||||
|
"description": "Test description",
|
||||||
|
}
|
||||||
|
|
||||||
|
video = Video.from_dict(data)
|
||||||
|
|
||||||
|
print("✓ Video.from_dict() works correctly")
|
||||||
|
print(f" - Video ID: {video.video_id}")
|
||||||
|
print(f" - Title: {video.title}")
|
||||||
|
|
||||||
|
# Test to_dict
|
||||||
|
video_dict = video.to_dict()
|
||||||
|
print(f" - to_dict() keys: {list(video_dict.keys())}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Video from_dict test failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("=" * 60)
|
||||||
|
print("YouTube TUI Test Suite")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
("Imports", test_imports),
|
||||||
|
("App Creation", test_app_creation),
|
||||||
|
("Video Model", test_video_model),
|
||||||
|
("Search History", test_search_history),
|
||||||
|
("Video from Dict", test_video_from_dict),
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for name, test_func in tests:
|
||||||
|
print(f"\nTesting: {name}")
|
||||||
|
print("-" * 40)
|
||||||
|
result = test_func()
|
||||||
|
results.append((name, result))
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Test Results Summary")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
passed = sum(1 for _, r in results if r)
|
||||||
|
total = len(results)
|
||||||
|
|
||||||
|
for name, result in results:
|
||||||
|
status = "✓ PASS" if result else "✗ FAIL"
|
||||||
|
print(f"{status}: {name}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"Total: {passed}/{total} tests passed")
|
||||||
|
print("=" * 60)
|
||||||
11
widgets/__init__.py
Normal file
11
widgets/__init__.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
Widgets package for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from youtube_tui.widgets.command_palette import CommandPalette
|
||||||
|
from youtube_tui.widgets.status_bar import StatusBar
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"StatusBar",
|
||||||
|
"CommandPalette",
|
||||||
|
]
|
||||||
199
widgets/command_palette.py
Normal file
199
widgets/command_palette.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
Command Palette Widget for YouTube TUI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Footer, Header, Input, ListView, ListItem, Static
|
||||||
|
|
||||||
|
|
||||||
|
class CommandPalette(ModalScreen):
|
||||||
|
"""Command palette for quick access to actions"""
|
||||||
|
|
||||||
|
DEFAULT_COMMANDS = [
|
||||||
|
("Search", "Search for videos"),
|
||||||
|
("Download", "Quick download mode"),
|
||||||
|
("History", "Show search history"),
|
||||||
|
("Settings", "Open settings"),
|
||||||
|
("Help", "Show help screen"),
|
||||||
|
("Quit", "Quit application"),
|
||||||
|
]
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
CommandPalette {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#palette-container {
|
||||||
|
width: 60%;
|
||||||
|
height: auto;
|
||||||
|
border: solid #555555;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#palette-title {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
background: $primary;
|
||||||
|
content-align: center middle;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
#palette-input {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#palette-list {
|
||||||
|
width: 100%;
|
||||||
|
height: 20;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#palette-actions {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
dock: bottom;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem {
|
||||||
|
height: 3;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem:hover {
|
||||||
|
background: $primary-darken-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem.--highlight {
|
||||||
|
background: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-description {
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("escape", "close_palette", "Close"),
|
||||||
|
("up", "cursor_up", "Cursor Up"),
|
||||||
|
("down", "cursor_down", "Cursor Down"),
|
||||||
|
("enter", "select_command", "Select"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.selected_index = 0
|
||||||
|
self.commands = self.DEFAULT_COMMANDS.copy()
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Compose the command palette"""
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Static("Command Palette", id="palette-title"),
|
||||||
|
Input(placeholder="Type to filter commands...", id="palette-input"),
|
||||||
|
ListView(id="palette-list-view"),
|
||||||
|
id="palette-container",
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Called when palette is mounted"""
|
||||||
|
self.update_list()
|
||||||
|
self.query_one(Input).focus()
|
||||||
|
|
||||||
|
def update_list(self) -> None:
|
||||||
|
"""Update the command list"""
|
||||||
|
input_widget = self.query_one("#palette-input", Input)
|
||||||
|
filter_text = input_widget.value.lower()
|
||||||
|
|
||||||
|
# Filter commands
|
||||||
|
if filter_text:
|
||||||
|
self.commands = [
|
||||||
|
(cmd, desc)
|
||||||
|
for cmd, desc in self.DEFAULT_COMMANDS
|
||||||
|
if filter_text in cmd.lower() or filter_text in desc.lower()
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
self.commands = self.DEFAULT_COMMANDS.copy()
|
||||||
|
|
||||||
|
# Update the list view
|
||||||
|
list_view = self.query_one("#palette-list-view", ListView)
|
||||||
|
list_view.clear()
|
||||||
|
|
||||||
|
for i, (command, description) in enumerate(self.commands):
|
||||||
|
item = ListItem(
|
||||||
|
Static(f"[bold]{command}[/bold]\n{description}"),
|
||||||
|
)
|
||||||
|
list_view.append(item)
|
||||||
|
|
||||||
|
# Update selected index if needed
|
||||||
|
if self.selected_index >= len(self.commands):
|
||||||
|
self.selected_index = max(0, len(self.commands) - 1)
|
||||||
|
|
||||||
|
# Highlight selected item
|
||||||
|
self.highlight_selected()
|
||||||
|
|
||||||
|
def highlight_selected(self) -> None:
|
||||||
|
"""Highlight the selected item"""
|
||||||
|
list_view = self.query_one("#palette-list-view", ListView)
|
||||||
|
|
||||||
|
for i, item in enumerate(list_view.children):
|
||||||
|
if i == self.selected_index:
|
||||||
|
item.add_class("--highlight")
|
||||||
|
else:
|
||||||
|
item.remove_class("--highlight")
|
||||||
|
|
||||||
|
def action_cursor_up(self) -> None:
|
||||||
|
"""Move selection up"""
|
||||||
|
if self.commands:
|
||||||
|
self.selected_index = max(0, self.selected_index - 1)
|
||||||
|
self.highlight_selected()
|
||||||
|
|
||||||
|
def action_cursor_down(self) -> None:
|
||||||
|
"""Move selection down"""
|
||||||
|
if self.commands:
|
||||||
|
self.selected_index = min(len(self.commands) - 1, self.selected_index + 1)
|
||||||
|
self.highlight_selected()
|
||||||
|
|
||||||
|
def action_select_command(self) -> None:
|
||||||
|
"""Execute the selected command"""
|
||||||
|
if not self.commands:
|
||||||
|
return
|
||||||
|
|
||||||
|
command = self.commands[self.selected_index][0]
|
||||||
|
self.execute_command(command)
|
||||||
|
|
||||||
|
def execute_command(self, command: str) -> None:
|
||||||
|
"""Execute a command"""
|
||||||
|
if command == "Search":
|
||||||
|
self.app.push_screen("search")
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif command == "Download":
|
||||||
|
# Go to search for quick download
|
||||||
|
self.app.push_screen("search")
|
||||||
|
self.app.pop_screen()
|
||||||
|
elif command == "History":
|
||||||
|
self.app.push_screen("history")
|
||||||
|
elif command == "Settings":
|
||||||
|
self.app.push_screen("settings")
|
||||||
|
elif command == "Help":
|
||||||
|
self.app.push_screen("help")
|
||||||
|
elif command == "Quit":
|
||||||
|
self.app.exit()
|
||||||
|
|
||||||
|
def action_close_palette(self) -> None:
|
||||||
|
"""Close the palette"""
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
|
"""Handle input changes"""
|
||||||
|
self.update_list()
|
||||||
|
|
||||||
|
def on_data_table_row_selected(self, event) -> None:
|
||||||
|
"""Handle row selection"""
|
||||||
|
self.action_select_command()
|
||||||
106
widgets/footer.py
Normal file
106
widgets/footer.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Custom Footer Widget for YouTube TUI
|
||||||
|
Includes status bar with queue information
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from textual.widgets import Footer
|
||||||
|
from textual.app import App
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFooter(Footer):
|
||||||
|
"""Custom footer widget with status bar that includes queue info"""
|
||||||
|
|
||||||
|
def __init__(self, app: App, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._app = app
|
||||||
|
self.current_screen = "Search"
|
||||||
|
self.status_message = "Ready"
|
||||||
|
self.downloading = False
|
||||||
|
self.queue_pending = 0
|
||||||
|
self.download_progress = 0
|
||||||
|
self.yt_dlp_version = "unknown"
|
||||||
|
self.update_version()
|
||||||
|
|
||||||
|
def update_version(self) -> None:
|
||||||
|
"""Update yt-dlp version"""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["yt-dlp", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
self.yt_dlp_version = result.stdout.strip()
|
||||||
|
else:
|
||||||
|
self.yt_dlp_version = "not installed"
|
||||||
|
except Exception:
|
||||||
|
self.yt_dlp_version = "unknown"
|
||||||
|
|
||||||
|
def set_screen(self, screen_name: str) -> None:
|
||||||
|
"""Set the current screen name"""
|
||||||
|
self.current_screen = screen_name
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def set_downloading(self, downloading: bool) -> None:
|
||||||
|
"""Set downloading state"""
|
||||||
|
self.downloading = downloading
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def set_status(self, message: str) -> None:
|
||||||
|
"""Set status message"""
|
||||||
|
self.status_message = message
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def set_queue_pending(self, count: int) -> None:
|
||||||
|
"""Set the number of pending queue items"""
|
||||||
|
self.queue_pending = count
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def set_download_progress(self, progress: int) -> None:
|
||||||
|
"""Set the current download progress percentage"""
|
||||||
|
self.download_progress = max(0, min(100, progress))
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def update_time(self) -> None:
|
||||||
|
"""Update the time display"""
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
"""Render the footer content with queue status"""
|
||||||
|
# Get current time
|
||||||
|
current_time = datetime.now().strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
# Get theme info
|
||||||
|
theme_name = getattr(self._app, "theme", "css")
|
||||||
|
|
||||||
|
# Build status string
|
||||||
|
status_parts = [
|
||||||
|
f"[bold]{self.current_screen}[/bold]",
|
||||||
|
f"v{getattr(self._app, 'VERSION', '0.1.0')}",
|
||||||
|
f"yt-dlp {self.yt_dlp_version}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add queue status if available
|
||||||
|
if self.queue_pending > 0:
|
||||||
|
status_parts.append(f"[cyan]Queue: {self.queue_pending} pending[/cyan]")
|
||||||
|
|
||||||
|
# Add download progress if available
|
||||||
|
if self.downloading and self.download_progress > 0:
|
||||||
|
status_parts.append(
|
||||||
|
f"| [yellow]Downloading: {self.download_progress}%[/yellow]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add status message
|
||||||
|
status_parts.append(f"[bold]{self.status_message}[/bold]")
|
||||||
|
|
||||||
|
# Add footer elements
|
||||||
|
status_parts.append(f"[dim]{current_time}[/dim]")
|
||||||
|
status_parts.append(f"[dim]{theme_name} theme[/dim]")
|
||||||
|
|
||||||
|
return " ".join(status_parts)
|
||||||
119
widgets/status_bar.py
Normal file
119
widgets/status_bar.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
Status Bar Widget for YouTube TUI
|
||||||
|
Simple status bar widget (custom footer handles queue status)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from textual.app import App
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
|
||||||
|
class StatusBar(Static):
|
||||||
|
"""Simple status bar widget for YouTube TUI"""
|
||||||
|
|
||||||
|
def __init__(self, app: App, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Store app as _app since app is a read-only property in Static
|
||||||
|
self._app = app
|
||||||
|
self.current_screen = "Search"
|
||||||
|
self.status_message = "Ready"
|
||||||
|
self.downloading = False
|
||||||
|
self.queue_pending = 0
|
||||||
|
self.download_progress = 0
|
||||||
|
self.yt_dlp_version = "unknown"
|
||||||
|
self.update_version()
|
||||||
|
# Note: set_interval requires active app context, so we skip it in tests
|
||||||
|
# The timer functionality is tested separately if needed
|
||||||
|
try:
|
||||||
|
self.set_interval(1, self.update_time)
|
||||||
|
except Exception:
|
||||||
|
# Timer not available in test context, skip
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_version(self) -> None:
|
||||||
|
"""Update yt-dlp version"""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["yt-dlp", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
self.yt_dlp_version = result.stdout.strip()
|
||||||
|
else:
|
||||||
|
self.yt_dlp_version = "not installed"
|
||||||
|
except Exception:
|
||||||
|
self.yt_dlp_version = "unknown"
|
||||||
|
|
||||||
|
def set_screen(self, screen_name: str) -> None:
|
||||||
|
"""Set the current screen name"""
|
||||||
|
self.current_screen = screen_name
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def set_status(self, message: str) -> None:
|
||||||
|
"""Set status message"""
|
||||||
|
self.status_message = message
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def set_downloading(self, downloading: bool) -> None:
|
||||||
|
"""Set downloading state"""
|
||||||
|
self.downloading = downloading
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def set_queue_pending(self, count: int) -> None:
|
||||||
|
"""Set the number of pending queue items"""
|
||||||
|
self.queue_pending = count
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def set_download_progress(self, progress: int) -> None:
|
||||||
|
"""Set the current download progress percentage"""
|
||||||
|
self.download_progress = max(0, min(100, progress))
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def update_time(self) -> None:
|
||||||
|
"""Update the time display"""
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
"""Render the status bar content"""
|
||||||
|
# Get current time
|
||||||
|
current_time = datetime.now().strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
# Get theme info
|
||||||
|
theme_name = getattr(self._app, "theme", "css")
|
||||||
|
|
||||||
|
# Build status string
|
||||||
|
status_parts = [
|
||||||
|
f"[bold]{self.current_screen}[/bold]",
|
||||||
|
f"v{getattr(self._app, 'VERSION', '0.1.0')}",
|
||||||
|
f"yt-dlp {self.yt_dlp_version}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add download indicator if downloading
|
||||||
|
if self.downloading:
|
||||||
|
status_parts.append("[bold green]↓[/bold green]")
|
||||||
|
|
||||||
|
# Add queue status if there are pending items
|
||||||
|
if self.queue_pending > 0:
|
||||||
|
status_parts.append(
|
||||||
|
f"[bold cyan]Queue: {self.queue_pending} pending[/bold cyan]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add download progress if downloading
|
||||||
|
if self.downloading and self.download_progress > 0:
|
||||||
|
status_parts.append(
|
||||||
|
f"[bold yellow]Downloading: {self.download_progress}%[/bold yellow]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add status message
|
||||||
|
status_parts.append(f"[bold]{self.status_message}[/bold]")
|
||||||
|
|
||||||
|
# Add footer elements
|
||||||
|
status_parts.append(f"[dim]{current_time}[/dim]")
|
||||||
|
status_parts.append(f"[dim]{theme_name} theme[/dim]")
|
||||||
|
|
||||||
|
return " ".join(status_parts)
|
||||||
Loading…
x
Reference in New Issue
Block a user