#!/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()