291 lines
9.0 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 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_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()