- New TUI using Textual framework with responsive interface - Search, results, download, and queue screens for YouTube video management - Download queue system with sequential background downloads - Status bar with queue count and download progress indicators - Comprehensive test suite (97 tests) with 100% pass rate - Modern Python packaging with pyproject.toml and uv support - Added requirements-tui.txt for TUI-specific dependencies - Updated setup.py and install.sh for TUI integration - Enhanced README.md with TUI usage documentation
259 lines
8.1 KiB
Python
259 lines
8.1 KiB
Python
#!/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 subprocess
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from textual.app import App, ComposeResult
|
|
from textual.widgets import Header, Static
|
|
|
|
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_search_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_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()
|